From d951cca257227d6882747fee753e7788ea800e6f Mon Sep 17 00:00:00 2001 From: larswijn <51133685+larswijn@users.noreply.github.com> Date: Mon, 1 Jul 2019 23:06:25 +0200 Subject: Completely re-submit file to fix the write history of the file --- bot/cogs/utils.py | 90 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 46 insertions(+), 44 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 65c729414..68a24a446 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -38,56 +38,58 @@ class Utils: else: return await ctx.invoke(self.bot.get_command("help"), "pep") - # Newer PEPs are written in RST instead of txt - if pep_number > 542: - pep_url = f"{self.base_github_pep_url}{pep_number:04}.rst" - else: - pep_url = f"{self.base_github_pep_url}{pep_number:04}.txt" - - # Attempt to fetch the PEP - log.trace(f"Requesting PEP {pep_number} with {pep_url}") - response = await self.bot.http_session.get(pep_url) - - if response.status == 200: - log.trace("PEP found") - - pep_content = await response.text() - - # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 - pep_header = HeaderParser().parse(StringIO(pep_content)) - - # Assemble the embed - pep_embed = Embed( - title=f"**PEP {pep_number} - {pep_header['Title']}**", - description=f"[Link]({self.base_pep_url}{pep_number:04})", - ) - - pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") - - # Add the interesting information - if "Status" in pep_header: - pep_embed.add_field(name="Status", value=pep_header["Status"]) - if "Python-Version" in pep_header: - pep_embed.add_field(name="Python-Version", value=pep_header["Python-Version"]) - if "Created" in pep_header: - pep_embed.add_field(name="Created", value=pep_header["Created"]) - if "Type" in pep_header: - pep_embed.add_field(name="Type", value=pep_header["Type"]) + possible_extensions = ['.rst', '.txt'] + found_pep = False + for extension in possible_extensions: + # Attempt to fetch the PEP + pep_url = f"{self.base_github_pep_url}{pep_number:04}{extension}" + log.trace(f"Requesting PEP {pep_number} with {pep_url}") + response = await self.bot.http_session.get(pep_url) + + if response.status == 200: + log.trace("PEP found") + found_pep = True + + pep_content = await response.text() + + # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 + pep_header = HeaderParser().parse(StringIO(pep_content)) + + # Assemble the embed + pep_embed = Embed( + title=f"**PEP {pep_number} - {pep_header['Title']}**", + description=f"[Link]({self.base_pep_url}{pep_number:04})", + ) - elif response.status == 404: + pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") + + # Add the interesting information + if "Status" in pep_header: + pep_embed.add_field(name="Status", value=pep_header["Status"]) + if "Python-Version" in pep_header: + pep_embed.add_field(name="Python-Version", value=pep_header["Python-Version"]) + if "Created" in pep_header: + pep_embed.add_field(name="Created", value=pep_header["Created"]) + if "Type" in pep_header: + pep_embed.add_field(name="Type", value=pep_header["Type"]) + + elif response.status != 404: + # any response except 200 and 404 is expected + found_pep = True # actually not, but it's easier to display this way + log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " + f"{response.status}.\n{response.text}") + + error_message = "Unexpected HTTP error during PEP search. Please let us know." + pep_embed = Embed(title="Unexpected error", description=error_message) + pep_embed.colour = Colour.red() + break + + if not found_pep: log.trace("PEP was not found") not_found = f"PEP {pep_number} does not exist." pep_embed = Embed(title="PEP not found", description=not_found) pep_embed.colour = Colour.red() - else: - log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " - f"{response.status}.\n{response.text}") - - error_message = "Unexpected HTTP error during PEP search. Please let us know." - pep_embed = Embed(title="Unexpected error", description=error_message) - pep_embed.colour = Colour.red() - await ctx.message.channel.send(embed=pep_embed) @command() -- cgit v1.2.3 From 3e798b2ee0c63907583cc07163ef63afb26c102b Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sun, 29 Sep 2019 14:24:34 +0200 Subject: Add humanized delta and content to confirmation message --- 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 6e91d2c06..695cd36ae 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -170,9 +170,12 @@ class Reminders(Scheduler, Cog): } ) + now = datetime.utcnow() + # Confirm to the user that it worked. await self._send_confirmation( - ctx, on_success="Your reminder has been created successfully!" + ctx, + on_success=f"Your reminder `{content}` will arrive in {humanize_delta(relativedelta(expiration, now))}!" ) loop = asyncio.get_event_loop() -- cgit v1.2.3 From aecda4ced7150d3c246220d7aee54635fe6d0082 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 30 Sep 2019 16:35:31 +0200 Subject: Add jump url --- bot/cogs/reminders.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 695cd36ae..4a470a640 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -100,9 +100,11 @@ class Reminders(Scheduler, Cog): embed.set_author( icon_url=Icons.remind_blurple, name="It has arrived!" - ) - - embed.description = f"Here's your reminder: `{reminder['content']}`" + + if "jump_url" in reminder: # keep backward compatibility + embed.description = f"Here's your reminder: `{reminder['content']}`. Jump back when you created the reminder : {jump_url}" + else: + embed.description = f"Here's your reminder: `{reminder['content']}`" if late: embed.colour = Colour.red() @@ -165,6 +167,7 @@ class Reminders(Scheduler, Cog): json={ 'author': ctx.author.id, 'channel_id': ctx.message.channel.id, + 'jump_url': ctx.message.jump_url, 'content': content, 'expiration': expiration.isoformat() } -- cgit v1.2.3 From 387bb50518e3766f543e23f8efe6821fb0527dd7 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 30 Sep 2019 18:55:53 +0200 Subject: Fix linting error Fix some liting error Correct error Fix linting (maybe) --- bot/cogs/reminders.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 4a470a640..27954d10e 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -99,10 +99,11 @@ class Reminders(Scheduler, Cog): embed.colour = Colour.blurple() embed.set_author( icon_url=Icons.remind_blurple, - name="It has arrived!" - - if "jump_url" in reminder: # keep backward compatibility - embed.description = f"Here's your reminder: `{reminder['content']}`. Jump back when you created the reminder : {jump_url}" + name="It has arrived!") + + if "jump_url" in reminder: # keep backward compatibility + embed.description = (f"Here's your reminder: `{reminder['content']}`." + f"Jump back when you created the reminder : {reminder['jump_url']}") else: embed.description = f"Here's your reminder: `{reminder['content']}`" -- cgit v1.2.3 From 0159a601af54845f154fbd739ae0f135120b4b2e Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 5 Oct 2019 17:01:32 +1000 Subject: Create the !mention command --- bot/cogs/utils.py | 37 +++++++++++++++++++++++++++++++++---- bot/constants.py | 7 +++++++ config-default.yml | 4 ++++ tests/cogs/test_information.py | 7 +++---- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index b6cecdc7c..b1c289807 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,15 +1,16 @@ import logging import re import unicodedata +from asyncio import TimeoutError, sleep from email.parser import HeaderParser from io import StringIO from typing import Tuple -from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Context, command +from discord import Colour, Embed, Message +from discord.ext.commands import Bot, Cog, Context, RoleConverter, command -from bot.constants import Channels, STAFF_ROLES -from bot.decorators import in_channel +from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES +from bot.decorators import in_channel, with_role log = logging.getLogger(__name__) @@ -128,6 +129,34 @@ class Utils(Cog): await ctx.send(embed=embed) + @command() + @with_role(*MODERATION_ROLES) + async def mention(self, ctx: Context, *, role: RoleConverter) -> None: + """Set a role to be mentionable for a limited time.""" + if role.mentionable: + await ctx.send(f'{role} (ID: {role.id}) is already mentionable!') + return + + await role.edit(mentionable=True, reason=f'!mention done by {ctx.author} ' + f'(ID: {ctx.author.id})') + + await ctx.send(f'{role} (ID: {role.id}) has been made mentionable. ' + f'I will reset it in {Mention.message_timeout} seconds,' + f' or when you send a message mentioning this role.') + + def check(m: Message) -> bool: + return role in m.role_mentions + + try: + await self.bot.wait_for('message', check=check, timeout=Mention.message_timeout) + await sleep(Mention.reset_delay) + except TimeoutError: + pass + + await role.edit(mentionable=False) + await ctx.send(f'{ctx.author.mention}, ' + f'I have reset {role} (ID: {role.id}) to be unmentionable.') + def setup(bot: Bot) -> None: """Utils cog load.""" diff --git a/bot/constants.py b/bot/constants.py index 1deeaa3b8..f4f45eb2c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -475,6 +475,13 @@ class Free(metaclass=YAMLGetter): cooldown_per: float +class Mention(metaclass=YAMLGetter): + section = 'mention' + + message_timeout: int + reset_delay: int + + class RedirectOutput(metaclass=YAMLGetter): section = 'redirect_output' diff --git a/config-default.yml b/config-default.yml index 38b26f64f..827ae4619 100644 --- a/config-default.yml +++ b/config-default.yml @@ -347,6 +347,10 @@ free: cooldown_rate: 1 cooldown_per: 60.0 +mention: + message_timeout: 300 + reset_delay: 5 + redirect_output: delete_invocation: true delete_delay: 15 diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py index 184bd2595..986e73a65 100644 --- a/tests/cogs/test_information.py +++ b/tests/cogs/test_information.py @@ -9,7 +9,6 @@ from discord import ( CategoryChannel, Colour, Permissions, - Role, TextChannel, VoiceChannel, ) @@ -69,7 +68,7 @@ def test_roles_info_command(cog, ctx): def test_role_info_command(cog, ctx): - dummy_role = MagicMock(spec=Role) + dummy_role = MagicMock() dummy_role.name = "Dummy" dummy_role.colour = Colour.blurple() dummy_role.id = 112233445566778899 @@ -77,7 +76,7 @@ def test_role_info_command(cog, ctx): dummy_role.permissions = Permissions(0) dummy_role.members = [ctx.author] - admin_role = MagicMock(spec=Role) + admin_role = MagicMock() admin_role.name = "Admin" admin_role.colour = Colour.red() admin_role.id = 998877665544332211 @@ -90,7 +89,7 @@ def test_role_info_command(cog, ctx): cog.role_info.can_run = AsyncMock() cog.role_info.can_run.return_value = True - coroutine = cog.role_info.callback(cog, ctx, dummy_role, admin_role) + coroutine = cog.role_info.callback(cog, ctx, [dummy_role, admin_role]) assert asyncio.run(coroutine) is None -- cgit v1.2.3 From ade137df048846a9f376282ce70b12422bade378 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 5 Oct 2019 17:17:31 +1000 Subject: revert back `tests.cogs.test_information` --- tests/cogs/test_information.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py index 986e73a65..3f365c901 100644 --- a/tests/cogs/test_information.py +++ b/tests/cogs/test_information.py @@ -9,6 +9,7 @@ from discord import ( CategoryChannel, Colour, Permissions, + Role, TextChannel, VoiceChannel, ) @@ -47,7 +48,7 @@ def ctx(moderator_role, simple_ctx): def test_roles_info_command(cog, ctx): - everyone_role = MagicMock() + everyone_role = MagicMock(spec=Role) everyone_role.name = '@everyone' # should be excluded in the output ctx.author.roles.append(everyone_role) ctx.guild.roles = ctx.author.roles @@ -76,7 +77,7 @@ def test_role_info_command(cog, ctx): dummy_role.permissions = Permissions(0) dummy_role.members = [ctx.author] - admin_role = MagicMock() + admin_role = MagicMock(spec=Role) admin_role.name = "Admin" admin_role.colour = Colour.red() admin_role.id = 998877665544332211 @@ -89,7 +90,7 @@ def test_role_info_command(cog, ctx): cog.role_info.can_run = AsyncMock() cog.role_info.can_run.return_value = True - coroutine = cog.role_info.callback(cog, ctx, [dummy_role, admin_role]) + coroutine = cog.role_info.callback(cog, ctx, dummy_role, admin_role) assert asyncio.run(coroutine) is None -- cgit v1.2.3 From f1522adf11d204c9aaf372f13407541d4f5a0e44 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 5 Oct 2019 17:20:19 +1000 Subject: revert back `tests.cogs.test_information`. I got them in the wrong order... --- tests/cogs/test_information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py index 3f365c901..184bd2595 100644 --- a/tests/cogs/test_information.py +++ b/tests/cogs/test_information.py @@ -48,7 +48,7 @@ def ctx(moderator_role, simple_ctx): def test_roles_info_command(cog, ctx): - everyone_role = MagicMock(spec=Role) + everyone_role = MagicMock() everyone_role.name = '@everyone' # should be excluded in the output ctx.author.roles.append(everyone_role) ctx.guild.roles = ctx.author.roles @@ -69,7 +69,7 @@ def test_roles_info_command(cog, ctx): def test_role_info_command(cog, ctx): - dummy_role = MagicMock() + dummy_role = MagicMock(spec=Role) dummy_role.name = "Dummy" dummy_role.colour = Colour.blurple() dummy_role.id = 112233445566778899 -- cgit v1.2.3 From 5eda4431411a85dbab3be44bd527c3bb0badee7c Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 5 Oct 2019 20:33:07 +1000 Subject: add requested changes for review --- bot/cogs/utils.py | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index b1c289807..c38d2709a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -2,12 +2,13 @@ import logging import re import unicodedata from asyncio import TimeoutError, sleep +from contextlib import suppress from email.parser import HeaderParser from io import StringIO from typing import Tuple -from discord import Colour, Embed, Message -from discord.ext.commands import Bot, Cog, Context, RoleConverter, command +from discord import Colour, Embed, Message, Role +from discord.ext.commands import Bot, Cog, Context, command from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES from bot.decorators import in_channel, with_role @@ -129,33 +130,45 @@ class Utils(Cog): await ctx.send(embed=embed) + @staticmethod + def readable_time(seconds: int) -> str: + minutes, seconds = divmod(seconds, 60) + + if minutes: + fmt = '{m}min {s}sec' + else: + fmt = '{s}sec' + + return fmt.format(m=minutes, s=seconds) + @command() @with_role(*MODERATION_ROLES) - async def mention(self, ctx: Context, *, role: RoleConverter) -> None: + async def mention(self, ctx: Context, *, role: Role) -> None: """Set a role to be mentionable for a limited time.""" if role.mentionable: - await ctx.send(f'{role} (ID: {role.id}) is already mentionable!') + await ctx.send(f"{role} is already mentionable!") return - await role.edit(mentionable=True, reason=f'!mention done by {ctx.author} ' - f'(ID: {ctx.author.id})') + await role.edit(reason=f"Role unlocked by {ctx.author}", mentionable=True) - await ctx.send(f'{role} (ID: {role.id}) has been made mentionable. ' - f'I will reset it in {Mention.message_timeout} seconds,' - f' or when you send a message mentioning this role.') + await ctx.send( + f"{role} has been made mentionable. I will reset it in " + f"{self.readable_time(Mention.message_timeout)} seconds, or when a staff member mentions this role." + ) def check(m: Message) -> bool: + if not any(m.id in MODERATION_ROLES for m in m.author.roles): + await ctx.send(f"{ctx.author.mention}, {m.author} has mentioned the role you set to mentionable.") + return False + return role in m.role_mentions - try: - await self.bot.wait_for('message', check=check, timeout=Mention.message_timeout) + with suppress(TimeoutError): + await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout) await sleep(Mention.reset_delay) - except TimeoutError: - pass await role.edit(mentionable=False) - await ctx.send(f'{ctx.author.mention}, ' - f'I have reset {role} (ID: {role.id}) to be unmentionable.') + await ctx.send(f"{ctx.author.mention}, I have reset {role} to be unmentionable.") def setup(bot: Bot) -> None: -- cgit v1.2.3 From 08ef78fa6f5a98da29abdb4f97a9f5513e09fb7e Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 5 Oct 2019 20:48:10 +1000 Subject: properly send message with `asyncio.run` --- bot/cogs/utils.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index c38d2709a..fd30f4321 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,7 +1,7 @@ import logging import re import unicodedata -from asyncio import TimeoutError, sleep +from asyncio import TimeoutError, sleep, run from contextlib import suppress from email.parser import HeaderParser from io import StringIO @@ -157,8 +157,12 @@ class Utils(Cog): ) def check(m: Message) -> bool: - if not any(m.id in MODERATION_ROLES for m in m.author.roles): - await ctx.send(f"{ctx.author.mention}, {m.author} has mentioned the role you set to mentionable.") + """Checks that the message contains the role mention and the user is a staff member.""" + if not any(m.id in MODERATION_ROLES for m in m.author.roles) and role in m.role_mentions: + run(ctx.send( + f"{ctx.author.mention}, {m.author} has mentioned the role you set to mentionable." + ) + ) return False return role in m.role_mentions -- cgit v1.2.3 From dffe89248c42839e690dcfe4db115b9ef12fdb39 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 5 Oct 2019 20:49:42 +1000 Subject: fix linter --- bot/cogs/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index fd30f4321..32f7ee208 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,7 +1,7 @@ import logging import re import unicodedata -from asyncio import TimeoutError, sleep, run +from asyncio import TimeoutError, run, sleep from contextlib import suppress from email.parser import HeaderParser from io import StringIO @@ -132,6 +132,7 @@ class Utils(Cog): @staticmethod def readable_time(seconds: int) -> str: + """Returns a number of seconds into a human-readable minutes/seconds combination.""" minutes, seconds = divmod(seconds, 60) if minutes: -- cgit v1.2.3 From d79e89e573a426c48d6c254add707f3f819327e8 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sun, 6 Oct 2019 16:51:21 +1100 Subject: Update !mention - Lock the role if the message wait has timed out - Sleep, lock role and send notification if mention by staff member found. - Lock role and send notification if mention by non-staff member found. --- bot/cogs/utils.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 32f7ee208..117bff373 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,8 +1,7 @@ import logging import re import unicodedata -from asyncio import TimeoutError, run, sleep -from contextlib import suppress +from asyncio import TimeoutError, sleep from email.parser import HeaderParser from io import StringIO from typing import Tuple @@ -158,22 +157,30 @@ class Utils(Cog): ) def check(m: Message) -> bool: - """Checks that the message contains the role mention and the user is a staff member.""" - if not any(m.id in MODERATION_ROLES for m in m.author.roles) and role in m.role_mentions: - run(ctx.send( - f"{ctx.author.mention}, {m.author} has mentioned the role you set to mentionable." - ) - ) - return False - + """Checks that the message contains the role mention.""" return role in m.role_mentions - with suppress(TimeoutError): - await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout) + try: + msg = await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout) + except TimeoutError: + await role.edit(mentionable=False, reason=f"Automatic role lock - timeout.") + await ctx.send(f"{ctx.author.mention}, you took too long. I have reset {role} to be unmentionable.") + return + + if any(r.id in MODERATION_ROLES for r in msg.author.roles): await sleep(Mention.reset_delay) + await role.edit(mentionable=False, reason=f"Automatic role lock by {msg.author}") + await ctx.send( + f"{ctx.author.mention}, I have reset {role} to be unmentionable as " + f"{msg.author if msg.author != ctx.author else 'you'} sent a message mentioning it." + ) + return - await role.edit(mentionable=False) - await ctx.send(f"{ctx.author.mention}, I have reset {role} to be unmentionable.") + await role.edit(mentionable=False, reason=f"Automatic role lock - unauthorised use by {msg.author}") + await ctx.send( + f"{ctx.author.mention}, I have reset {role} to be unmentionable " + f"as I detected unauthorised use by {msg.author} (ID: {msg.author.id})." + ) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 72734cb0cb3ac7292009d62f5c9fd0f367aa139a Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sun, 6 Oct 2019 13:03:22 +0200 Subject: Add line break between the reminder and the jump url --- 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 27954d10e..288035b56 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -102,7 +102,7 @@ class Reminders(Scheduler, Cog): name="It has arrived!") if "jump_url" in reminder: # keep backward compatibility - embed.description = (f"Here's your reminder: `{reminder['content']}`." + embed.description = (f"Here's your reminder: `{reminder['content']}`.\n" f"Jump back when you created the reminder : {reminder['jump_url']}") else: embed.description = f"Here's your reminder: `{reminder['content']}`" -- cgit v1.2.3 From 859ca0be8acf2a1fa229ca2972fc4989abb32584 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 7 Oct 2019 12:11:32 +0200 Subject: Fix my broken English Co-Authored-By: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- 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 288035b56..1b38d0bb2 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -103,7 +103,7 @@ class Reminders(Scheduler, Cog): if "jump_url" in reminder: # keep backward compatibility embed.description = (f"Here's your reminder: `{reminder['content']}`.\n" - f"Jump back when you created the reminder : {reminder['jump_url']}") + f"Jump back to when you created the reminder : {reminder['jump_url']}") else: embed.description = f"Here's your reminder: `{reminder['content']}`" -- cgit v1.2.3 From 5a8feefd6fe28b9f0030e6c221c982905848a74a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 7 Oct 2019 12:17:55 +0200 Subject: Turn the jump URL into a clickable link --- 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 1b38d0bb2..16c431448 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -103,7 +103,7 @@ class Reminders(Scheduler, Cog): if "jump_url" in reminder: # keep backward compatibility embed.description = (f"Here's your reminder: `{reminder['content']}`.\n" - f"Jump back to when you created the reminder : {reminder['jump_url']}") + f"[Jump back to when you created the reminder]({reminder['jump_url']})") else: embed.description = f"Here's your reminder: `{reminder['content']}`" -- cgit v1.2.3 From 716cb544c95fe9ed98981fd18245cedd625daa8c Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 7 Oct 2019 12:39:30 +0200 Subject: Delete “confirmation echo” MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 16c431448..d06dbcc7f 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -179,7 +179,7 @@ class Reminders(Scheduler, Cog): # Confirm to the user that it worked. await self._send_confirmation( ctx, - on_success=f"Your reminder `{content}` will arrive in {humanize_delta(relativedelta(expiration, now))}!" + on_success=f"Your reminder will arrive in {humanize_delta(relativedelta(expiration, now))}!" ) loop = asyncio.get_event_loop() -- cgit v1.2.3 From 1b6f3d23d4c0b1f6dfe1354a3a210e589f7b4956 Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Mon, 7 Oct 2019 18:40:02 +0200 Subject: Make sure that poor code does not contains token Added a new function `is_token_in_message` in `token_remover`. This function returns a `bool` and if the code contains a token then the embed message about the poorly formatted code is not displayed. --- bot/cogs/bot.py | 3 ++- bot/cogs/token_remover.py | 32 +++++++++++++++++++------------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 7583b2f2d..e8ac0a234 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -7,6 +7,7 @@ from typing import Optional, Tuple from discord import Embed, Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog, Context, command, group +from bot.cogs.token_remover import TokenRemover from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs from bot.decorators import with_role from bot.utils.messages import wait_for_deletion @@ -237,7 +238,7 @@ class Bot(Cog): and len(msg.content.splitlines()) > 3 ) - if parse_codeblock: + if parse_codeblock and not TokenRemover.is_token_in_message: # if there is no token in the code on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 if not on_cooldown or DEBUG_MODE: try: diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 7dd0afbbd..8f356cf19 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -52,19 +52,8 @@ class TokenRemover(Cog): See: https://discordapp.com/developers/docs/reference#snowflakes """ - if msg.author.bot: - return - - maybe_match = TOKEN_RE.search(msg.content) - if maybe_match is None: - return - - try: - user_id, creation_timestamp, hmac = maybe_match.group(0).split('.') - except ValueError: - return - - if self.is_valid_user_id(user_id) and self.is_valid_timestamp(creation_timestamp): + if self.is_token_in_message(msg): + user_id, creation_timestamp, hmac = TOKEN_RE.search(msg.content).group(0).split('.') self.mod_log.ignore(Event.message_delete, msg.id) await msg.delete() await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) @@ -86,6 +75,23 @@ class TokenRemover(Cog): channel_id=Channels.mod_alerts, ) + def is_token_in_message(self, msg: Message) -> bool: + """Check if `msg` contains a seemly valid token.""" + if msg.author.bot: + return False + + maybe_match = TOKEN_RE.search(msg.content) + if maybe_match is None: + return False + + try: + user_id, creation_timestamp, hmac = maybe_match.group(0).split('.') + except ValueError: + return False + + if self.is_valid_user_id(user_id) and self.is_valid_timestamp(creation_timestamp): + return True + @staticmethod def is_valid_user_id(b64_content: str) -> bool: """ -- cgit v1.2.3 From 2899bac85c3c0529b354a762ba27a587a520d7cd Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Mon, 7 Oct 2019 18:51:18 +0200 Subject: minor fix --- bot/cogs/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index e8ac0a234..729550c1a 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -238,7 +238,7 @@ class Bot(Cog): and len(msg.content.splitlines()) > 3 ) - if parse_codeblock and not TokenRemover.is_token_in_message: # if there is no token in the code + if parse_codeblock and not TokenRemover.is_token_in_message(msg): # if there is no token in the code on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 if not on_cooldown or DEBUG_MODE: try: -- cgit v1.2.3 From b94e8487c22d7c25ab09bb3d44c44d62e5a2b613 Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Mon, 7 Oct 2019 21:33:43 +0200 Subject: Another fix After a new bunch of test I found bugs, and this fix resolves them --- bot/cogs/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 729550c1a..b8de29f2a 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -238,7 +238,7 @@ class Bot(Cog): and len(msg.content.splitlines()) > 3 ) - if parse_codeblock and not TokenRemover.is_token_in_message(msg): # if there is no token in the code + if parse_codeblock and not TokenRemover.is_token_in_message(TokenRemover, msg): # if there is no token in the code on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 if not on_cooldown or DEBUG_MODE: try: -- cgit v1.2.3 From 25d4c05b2656ce8d9454269c77d42e18fb1ba785 Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Mon, 7 Oct 2019 21:42:49 +0200 Subject: fix linting error fix linting error --- bot/cogs/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index b8de29f2a..eab253681 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -238,7 +238,7 @@ class Bot(Cog): and len(msg.content.splitlines()) > 3 ) - if parse_codeblock and not TokenRemover.is_token_in_message(TokenRemover, msg): # if there is no token in the code + if parse_codeblock and not TokenRemover.is_token_in_message(TokenRemover, msg): # no token in the msg on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 if not on_cooldown or DEBUG_MODE: try: -- cgit v1.2.3 From 9462f70505ed27a352c11556c685ccc5e1c386c0 Mon Sep 17 00:00:00 2001 From: Ava Date: Tue, 8 Oct 2019 20:20:50 +0300 Subject: Add raw command Closes #334 --- bot/cogs/information.py | 86 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 1afb37103..19f3bf7e6 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -1,13 +1,19 @@ import colorsys import logging +import pprint import textwrap import typing +from collections import Mapping +from typing import Any, Optional -from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils -from discord.ext.commands import Bot, Cog, Context, command +import discord +from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel +from discord import Role, utils +from discord.ext import commands +from discord.ext.commands import Bot, Cog, Context, command, group from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES -from bot.decorators import InChannelCheckFailure, with_role +from bot.decorators import InChannelCheckFailure, with_role, in_channel from bot.utils.checks import with_role_check from bot.utils.time import time_since @@ -229,6 +235,80 @@ class Information(Cog): await ctx.send(embed=embed) + def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None): + # sorting is technically superfluous but nice if you want to look for a specific field + fields = sorted(mapping.items(), key=lambda item: item[0]) + + if field_width is None: + field_width = len(max(mapping.keys(), key=len)) + + out = '' + + for key, val in fields: + if isinstance(val, dict): + # if we have dicts inside dicts we want to apply the same treatment to the inner dictionaries + inner_width = int(field_width * 1.6) + val = '\n' + self.format_fields(val, field_width=inner_width) + + elif isinstance(val, str): + # split up text since it might be long + text = textwrap.fill(val, width=100, replace_whitespace=False) + + # indent it, I guess you could do this with `wrap` and `join` but this is nicer + val = textwrap.indent(text, ' ' * (field_width + len(': '))) + + # the first line is already indented so we `str.lstrip` it + val = val.lstrip() + + if key == 'color': + # makes the base 10 representation of a hex number readable to humans + val = hex(val) + + out += '{0:>{width}}: {1}\n'.format(key, val, width=field_width) + + # remove trailing whitespace + return out.rstrip() + + @group(invoke_without_command=True) + @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) + async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False): + """Shows information about the raw API response.""" + + # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling + # doing this extra request is also much easier than trying to convert everything back into a dictionary again + raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) + + paginator = commands.Paginator() + + def add_content(title, content): + paginator.add_line(f'== {title} ==\n') + # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution. + # we hope it's not close to 2000 + paginator.add_line(content.replace('```', '`` `')) + paginator.close_page() + + if message.content: + add_content('Raw message', message.content) + + transformer = pprint.pformat if json else self.format_fields + for field_name in 'embeds attachments'.split(): + data = raw_data[field_name] + + if not data: + continue + + total = len(data) + for current, item in enumerate(data, start=1): + title = f'Raw {field_name} ({current}/{total})' + add_content(title, transformer(item)) + + for page in paginator.pages: + await ctx.send(page) + + @raw.command() + async def json(self, ctx: Context, message: discord.Message): + await ctx.invoke(self.raw, message=message, json=True) + def setup(bot: Bot) -> None: """Information cog load.""" -- cgit v1.2.3 From 0c31a417e215d6eff4066202cd357896f7f95892 Mon Sep 17 00:00:00 2001 From: Ava Date: Tue, 8 Oct 2019 20:22:52 +0300 Subject: Fix wrong import --- bot/cogs/information.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 19f3bf7e6..bffb12751 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -3,8 +3,7 @@ import logging import pprint import textwrap import typing -from collections import Mapping -from typing import Any, Optional +from typing import Any, Optional, Mapping import discord from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel -- cgit v1.2.3 From be529fc2bbfa84addb464190b65aed194fd043f1 Mon Sep 17 00:00:00 2001 From: Ava Date: Tue, 8 Oct 2019 20:48:38 +0300 Subject: Fix linting errors --- bot/cogs/information.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index bffb12751..b3525c6f7 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -3,7 +3,7 @@ import logging import pprint import textwrap import typing -from typing import Any, Optional, Mapping +from typing import Any, Mapping, Optional import discord from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel @@ -12,7 +12,7 @@ from discord.ext import commands from discord.ext.commands import Bot, Cog, Context, command, group from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES -from bot.decorators import InChannelCheckFailure, with_role, in_channel +from bot.decorators import InChannelCheckFailure, in_channel, with_role from bot.utils.checks import with_role_check from bot.utils.time import time_since @@ -234,7 +234,8 @@ class Information(Cog): await ctx.send(embed=embed) - def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None): + def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: + """Format a mapping to be readable to a human.""" # sorting is technically superfluous but nice if you want to look for a specific field fields = sorted(mapping.items(), key=lambda item: item[0]) @@ -270,16 +271,15 @@ class Information(Cog): @group(invoke_without_command=True) @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) - async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False): + async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False) -> None: """Shows information about the raw API response.""" - # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling # doing this extra request is also much easier than trying to convert everything back into a dictionary again raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) paginator = commands.Paginator() - def add_content(title, content): + def add_content(title: str, content: str) -> None: paginator.add_line(f'== {title} ==\n') # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution. # we hope it's not close to 2000 @@ -305,7 +305,8 @@ class Information(Cog): await ctx.send(page) @raw.command() - async def json(self, ctx: Context, message: discord.Message): + async def json(self, ctx: Context, message: discord.Message) -> None: + """Shows information about the raw API response in a copy-pasteable Python format.""" await ctx.invoke(self.raw, message=message, json=True) -- cgit v1.2.3 From 2cb7ee12805957c7d655679ff54a14f16e059a80 Mon Sep 17 00:00:00 2001 From: Jens Date: Wed, 9 Oct 2019 23:43:16 +0200 Subject: Add Reddit OAuth tasks and refactor code --- bot/cogs/reddit.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++++----- bot/constants.py | 2 ++ config-default.yml | 2 ++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 6880aab85..bf4403ce4 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -2,10 +2,12 @@ import asyncio import logging import random import textwrap +from aiohttp import BasicAuth from datetime import datetime, timedelta from typing import List from discord import Colour, Embed, Message, TextChannel +from discord.ext import tasks from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES @@ -19,8 +21,13 @@ log = logging.getLogger(__name__) class Reddit(Cog): """Track subreddit posts and show detailed statistics about them.""" - HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"} + # Change your client's User-Agent string to something unique and descriptive, + # including the target platform, a unique application identifier, a version string, + # and your username as contact information, in the following format: + # :: (by /u/) + USER_AGENT = "docker:Discord Bot of https://pythondiscord.com/:v?.?.? (by /u/PythonDiscord)" URL = "https://www.reddit.com" + OAUTH_URL = "https://oauth.reddit.com" MAX_FETCH_RETRIES = 3 def __init__(self, bot: Bot): @@ -34,6 +41,66 @@ class Reddit(Cog): self.new_posts_task = None self.top_weekly_posts_task = None + self.refresh_access_token.start() + + @tasks.loop(hours=0.99) # access tokens are valid for one hour + async def refresh_access_token(self) -> None: + """Refresh the access token""" + headers = {"Authorization": self.client_auth} + data = { + "grant_type": "refresh_token", + "refresh_token": self.refresh_token + } + + response = await self.bot.http_session.post( + url = f"{self.URL}/api/v1/access_token", + headers=headers, + data=data, + ) + + content = await response.json() + self.access_token = content["access_token"] + self.headers = { + "Authorization": "bearer " + self.access_token, + "User-Agent": self.USER_AGENT + } + + @refresh_access_token.before_loop + async def get_tokens(self) -> None: + """Get Reddit access and refresh tokens""" + await self.bot.wait_until_ready() + + headers = {"User-Agent": self.USER_AGENT} + data = { + "grant_type": "client_credentials", + "duration": "permanent" + } + + if RedditConfig.client_id and RedditConfig.secret: + self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) + + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/access_token", + headers=headers, + auth=self.client_auth, + data=data + ) + + content = await response.json() + self.access_token = content["access_token"] + self.refresh_token = content["refresh_token"] + self.headers = { + "Authorization": "bearer " + self.access_token, + "User-Agent": self.USER_AGENT + } + else: + self.client_auth = None + self.access_token = None + self.refresh_token = None + self.headers = None + + log.error("Unable to find client credentials.") + async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: """A helper method to fetch a certain amount of Reddit posts at a given route.""" # Reddit's JSON responses only provide 25 posts at most. @@ -43,11 +110,11 @@ class Reddit(Cog): if params is None: params = {} - url = f"{self.URL}/{route}.json" + url = f"{self.OAUTH_URL}/{route}" for _ in range(self.MAX_FETCH_RETRIES): response = await self.bot.http_session.get( url=url, - headers=self.HEADERS, + headers=self.headers, params=params ) if response.status == 200 and response.content_type == 'application/json': @@ -55,7 +122,7 @@ class Reddit(Cog): content = await response.json() posts = content["data"]["children"] return posts[:amount] - + await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") @@ -127,8 +194,8 @@ class Reddit(Cog): for subreddit in RedditConfig.subreddits: # Make a HEAD request to the subreddit head_response = await self.bot.http_session.head( - url=f"{self.URL}/{subreddit}/new.rss", - headers=self.HEADERS + url=f"{self.OAUTH_URL}/{subreddit}/new.rss", + headers=self.headers ) content_length = head_response.headers["content-length"] diff --git a/bot/constants.py b/bot/constants.py index 1deeaa3b8..f84889e10 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -440,6 +440,8 @@ class Reddit(metaclass=YAMLGetter): request_delay: int subreddits: list + client_id: str + secret: str class Wolfram(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 0dac9bf9f..c43ea4f8f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -326,6 +326,8 @@ reddit: request_delay: 60 subreddits: - 'r/Python' + client_id: !ENV "REDDIT_CLIENT_ID" + secret: !ENV "REDDIT_SECRET" wolfram: -- cgit v1.2.3 From 2a1b017d5586e7646d6922c4795f8c98d20c722b Mon Sep 17 00:00:00 2001 From: kosayoda Date: Fri, 11 Oct 2019 21:17:35 +0800 Subject: Add check to !otn add to prevent too similar names. --- bot/cogs/off_topic_names.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 16717d523..362aa1b6d 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -96,14 +96,36 @@ class OffTopicNames(Cog): @otname_group.command(name='add', aliases=('a',)) @with_role(*MODERATION_ROLES) async def add_command(self, ctx: Context, *names: OffTopicName) -> None: - """Adds a new off-topic name to the rotation.""" + """ + Adds a new off-topic name to the rotation. + + The name is not added if it is too similar to an existing name. + """ # Chain multiple words to a single one name = "-".join(names) + existing_names = await self.bot.api_client.get('bot/off-topic-channel-names') + close_match = difflib.get_close_matches(name, existing_names, n=1, cutoff=0.8) + + if close_match: + match = close_match[0] + log.info( + f"{ctx.author.name}#{ctx.author.discriminator}" + f" tried to add channel name '{name}' but it was too similar to '{match}'" + ) + await ctx.send( + f":x: The channel name `{name}` is too similar to `{match}`, and thus was not added. " + f"Use `!otn forceadd` to override this check." + ) + else: + await self._add_name(ctx, name) + + async def _add_name(self, ctx: Context, name: str) -> None: + """Adds an off-topic channel name to the site storage.""" await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name}) log.info( f"{ctx.author.name}#{ctx.author.discriminator}" - f" added the off-topic channel name '{name}" + f" added the off-topic channel name '{name}'" ) await ctx.send(f":ok_hand: Added `{name}` to the names list.") -- cgit v1.2.3 From fedf5bbdb326ba193a6bc1ab679049c22d21f4b4 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Fri, 11 Oct 2019 21:18:38 +0800 Subject: Add !otn forceadd command. --- bot/cogs/off_topic_names.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 362aa1b6d..8cde22240 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -120,6 +120,14 @@ class OffTopicNames(Cog): else: await self._add_name(ctx, name) + @otname_group.command(name='forceadd', aliases=('fa',)) + @with_role(*MODERATION_ROLES) + async def force_add_command(self, ctx: Context, *names: OffTopicName) -> None: + """Forcefully adds a new off-topic name to the rotation.""" + # Chain multiple words to a single one + name = "-".join(names) + await self._add_name(ctx, name) + async def _add_name(self, ctx: Context, name: str) -> None: """Adds an off-topic channel name to the site storage.""" await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name}) -- cgit v1.2.3 From c4213744c18be23e3e4484f126ae0b2d0eba4437 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 2 Oct 2019 16:59:03 +0200 Subject: Migrate pytest to unittest After a discussion in the core developers channel, we have decided to migrate from `pytest` to `unittest` as the testing framework. This commit sets up the repository to use `unittest` and migrates the first couple of tests files to the new framework. What I have done to migrate to `unitest`: - Removed all `pytest` test files, since they are incompatible. - Removed `pytest`-related dependencies from the Pipfile. - Added `coverage.py` to the Pipfile dev-packages and relocked. - Added convenience scripts to Pipfile for running the test suite. - Adjust to `azure-pipelines.yml` to use `coverage.py` and `unittest`. - Migrated four test files from `pytest` to `unittest` format. In addition, I've added five helper Mock subclasses in `helpers.py` and created a `TestCase` subclass in `base.py` to add an assertion that asserts that no log records were logged within the context of the context manager. Obviously, these new utility functions and classes are fully tested in their respective `test_` files. Finally, I've started with an introductory guide for writing tests for our bot in `README.md`. --- .coveragerc | 5 + Pipfile | 5 +- Pipfile.lock | 116 ++++--------- azure-pipelines.yml | 5 +- tests/README.md | 200 ++++++++++++++++++++++ tests/__init__.py | 5 + tests/base.py | 70 ++++++++ tests/bot/__init__.py | 0 tests/bot/cogs/__init__.py | 0 tests/bot/cogs/test_information.py | 164 ++++++++++++++++++ tests/bot/patches/__init__.py | 0 tests/bot/resources/__init__.py | 0 tests/bot/rules/__init__.py | 0 tests/bot/test_api.py | 134 +++++++++++++++ tests/bot/test_converters.py | 273 +++++++++++++++++++++++++++++ tests/bot/utils/__init__.py | 0 tests/bot/utils/test_checks.py | 43 +++++ tests/cogs/__init__.py | 0 tests/cogs/sync/__init__.py | 0 tests/cogs/sync/test_roles.py | 103 ----------- tests/cogs/sync/test_users.py | 69 -------- tests/cogs/test_antispam.py | 30 ---- tests/cogs/test_information.py | 211 ----------------------- tests/cogs/test_security.py | 54 ------ tests/cogs/test_token_remover.py | 133 --------------- tests/conftest.py | 32 ---- tests/helpers.py | 247 +++++++++++++++++++++++++-- tests/rules/__init__.py | 0 tests/rules/test_attachments.py | 52 ------ tests/test_api.py | 106 ------------ tests/test_base.py | 61 +++++++ tests/test_constants.py | 23 --- tests/test_converters.py | 264 ----------------------------- tests/test_helpers.py | 339 +++++++++++++++++++++++++++++++++++++ tests/test_pagination.py | 29 ---- tests/test_resources.py | 13 -- tests/utils/__init__.py | 0 tests/utils/test_checks.py | 66 -------- 38 files changed, 1567 insertions(+), 1285 deletions(-) create mode 100644 .coveragerc create mode 100644 tests/README.md create mode 100644 tests/base.py create mode 100644 tests/bot/__init__.py create mode 100644 tests/bot/cogs/__init__.py create mode 100644 tests/bot/cogs/test_information.py create mode 100644 tests/bot/patches/__init__.py create mode 100644 tests/bot/resources/__init__.py create mode 100644 tests/bot/rules/__init__.py create mode 100644 tests/bot/test_api.py create mode 100644 tests/bot/test_converters.py create mode 100644 tests/bot/utils/__init__.py create mode 100644 tests/bot/utils/test_checks.py delete mode 100644 tests/cogs/__init__.py delete mode 100644 tests/cogs/sync/__init__.py delete mode 100644 tests/cogs/sync/test_roles.py delete mode 100644 tests/cogs/sync/test_users.py delete mode 100644 tests/cogs/test_antispam.py delete mode 100644 tests/cogs/test_information.py delete mode 100644 tests/cogs/test_security.py delete mode 100644 tests/cogs/test_token_remover.py delete mode 100644 tests/conftest.py delete mode 100644 tests/rules/__init__.py delete mode 100644 tests/rules/test_attachments.py delete mode 100644 tests/test_api.py create mode 100644 tests/test_base.py delete mode 100644 tests/test_constants.py delete mode 100644 tests/test_converters.py create mode 100644 tests/test_helpers.py delete mode 100644 tests/test_pagination.py delete mode 100644 tests/test_resources.py delete mode 100644 tests/utils/__init__.py delete mode 100644 tests/utils/test_checks.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..d572bd705 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[run] +branch = true +source = + bot + tests diff --git a/Pipfile b/Pipfile index 82847b23f..0c73e4ca2 100644 --- a/Pipfile +++ b/Pipfile @@ -21,6 +21,7 @@ more_itertools = "~=7.2" urllib3 = ">=1.24.2,<1.25" [dev-packages] +coverage = "~=4.5" flake8 = "~=3.7" flake8-annotations = "~=1.1" flake8-bugbear = "~=19.8" @@ -32,8 +33,6 @@ flake8-todo = "~=0.7" pre-commit = "~=1.18" safety = "~=1.8" dodgy = "~=0.1" -pytest = "~=5.1" -pytest-cov = "~=2.7" [requires] python_version = "3.7" @@ -44,3 +43,5 @@ lint = "python -m flake8" precommit = "pre-commit install" build = "docker build -t pythondiscord/bot:latest -f Dockerfile ." push = "docker push pythondiscord/bot:latest" +test = "coverage run -m unittest" +report = "coverage report" diff --git a/Pipfile.lock b/Pipfile.lock index 4e6b4eaf8..366d1e525 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c2537cc3c5b0886d0b38f9b48f4f4b93e1e74d925454aa71a2189bddedadde42" + "sha256": "f5f32a03b561f1805f52447ca4e6582dd459581c5d581638925b7fabb09869f8" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:29f27a8092169924c9eefb0c5e428d216706618dc9caa75ddb7759638e16cf26", - "sha256:4f77ba9b6e7bc27fc88c49638bc3657ae5d4a2539e17fa0c2b25b370547b1b50" + "sha256:1dcec3e3e3309e277511dc0d7d157676d0165c174a6a745673fc9cf0510db8f0", + "sha256:dd5a23ca26a4872ee73bd107e4c545bace572cdec2a574aeb61f4062c7774b2a" ], "index": "pypi", - "version": "==6.1.2" + "version": "==6.1.3" }, "aiodns": { "hashes": [ @@ -83,10 +83,10 @@ }, "attrs": { "hashes": [ - "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", - "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" + "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2", + "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396" ], - "version": "==19.1.0" + "version": "==19.2.0" }, "babel": { "hashes": [ @@ -97,11 +97,11 @@ }, "beautifulsoup4": { "hashes": [ - "sha256:05668158c7b85b791c5abde53e50265e16f98ad601c402ba44d70f96c4159612", - "sha256:25288c9e176f354bf277c0a10aa96c782a6a18a17122dba2e8cec4a97e03343b", - "sha256:f040590be10520f2ea4c2ae8c3dae441c7cfff5308ec9d58a0ec0c1b8f81d469" + "sha256:5279c36b4b2ec2cb4298d723791467e3000e5384a43ea0cdf5d45207c7e97169", + "sha256:6135db2ba678168c07950f9a16c4031822c6f4aec75a65e0a97bc5ca09789931", + "sha256:dcdef580e18a76d54002088602eba453eec38ebbcafafeaabd8cab12b6155d57" ], - "version": "==4.8.0" + "version": "==4.8.1" }, "certifi": { "hashes": [ @@ -150,13 +150,6 @@ ], "version": "==3.0.4" }, - "colorama": { - "hashes": [ - "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", - "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" - ], - "version": "==0.4.1" - }, "deepdiff": { "hashes": [ "sha256:1123762580af0904621136d117c8397392a244d3ff0fa0a50de57a7939582476", @@ -204,10 +197,10 @@ }, "jinja2": { "hashes": [ - "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", - "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" + "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", + "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de" ], - "version": "==2.10.1" + "version": "==2.10.3" }, "jsonpickle": { "hashes": [ @@ -407,10 +400,10 @@ }, "pytz": { "hashes": [ - "sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32", - "sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7" + "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", + "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" ], - "version": "==2019.2" + "version": "==2019.3" }, "pyyaml": { "hashes": [ @@ -448,9 +441,10 @@ }, "snowballstemmer": { "hashes": [ - "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e" + "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", + "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" ], - "version": "==1.9.1" + "version": "==2.0.0" }, "soupsieve": { "hashes": [ @@ -568,19 +562,12 @@ ], "version": "==1.3.0" }, - "atomicwrites": { - "hashes": [ - "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", - "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" - ], - "version": "==1.3.0" - }, "attrs": { "hashes": [ - "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", - "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" + "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2", + "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396" ], - "version": "==19.1.0" + "version": "==19.2.0" }, "certifi": { "hashes": [ @@ -610,13 +597,6 @@ ], "version": "==7.0" }, - "colorama": { - "hashes": [ - "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", - "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" - ], - "version": "==0.4.1" - }, "coverage": { "hashes": [ "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", @@ -652,6 +632,7 @@ "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" ], + "index": "pypi", "version": "==4.5.4" }, "dodgy": { @@ -701,11 +682,11 @@ }, "flake8-docstrings": { "hashes": [ - "sha256:1666dd069c9c457ee57e80af3c1a6b37b00cc1801c6fde88e455131bb2e186cd", - "sha256:9c0db5a79a1affd70fdf53b8765c8a26bf968e59e0252d7f2fc546b41c0cda06" + "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717", + "sha256:a256ba91bc52307bef1de59e2a009c3cf61c3d0952dbe035d6ff7208940c2edc" ], "index": "pypi", - "version": "==1.4.0" + "version": "==1.5.0" }, "flake8-import-order": { "hashes": [ @@ -757,7 +738,6 @@ "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" ], - "markers": "python_version < '3.8'", "version": "==0.23" }, "mccabe": { @@ -788,13 +768,6 @@ ], "version": "==19.2" }, - "pluggy": { - "hashes": [ - "sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", - "sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34" - ], - "version": "==0.13.0" - }, "pre-commit": { "hashes": [ "sha256:1d3c0587bda7c4e537a46c27f2c84aa006acc18facf9970bf947df596ce91f3f", @@ -803,13 +776,6 @@ "index": "pypi", "version": "==1.18.3" }, - "py": { - "hashes": [ - "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", - "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" - ], - "version": "==1.8.0" - }, "pycodestyle": { "hashes": [ "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", @@ -838,22 +804,6 @@ ], "version": "==2.4.2" }, - "pytest": { - "hashes": [ - "sha256:813b99704b22c7d377bbd756ebe56c35252bb710937b46f207100e843440b3c2", - "sha256:cc6620b96bc667a0c8d4fa592a8c9c94178a1bd6cc799dbb057dfd9286d31a31" - ], - "index": "pypi", - "version": "==5.1.3" - }, - "pytest-cov": { - "hashes": [ - "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", - "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a" - ], - "index": "pypi", - "version": "==2.7.1" - }, "pyyaml": { "hashes": [ "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", @@ -898,9 +848,10 @@ }, "snowballstemmer": { "hashes": [ - "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e" + "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", + "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" ], - "version": "==1.9.1" + "version": "==2.0.0" }, "toml": { "hashes": [ @@ -944,13 +895,6 @@ ], "version": "==16.7.5" }, - "wcwidth": { - "hashes": [ - "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", - "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" - ], - "version": "==0.1.7" - }, "zipp": { "hashes": [ "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c22bac089..3d0932398 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -30,9 +30,12 @@ jobs: - script: python -m flake8 displayName: 'Run linter' - - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz python -m pytest --junitxml=junit.xml --cov=bot --cov-branch --cov-report=term --cov-report=xml tests + - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz coverage run -m unittest displayName: Run tests + - script: coverage xml -o coverage.xml + displayName: Create test coverage report + - task: PublishCodeCoverageResults@1 displayName: 'Publish Coverage Results' condition: succeededOrFailed() diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..085ea39e0 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,200 @@ +# Testing our Bot + +Our bot is one of the most important tools we have to help us run our community. To make sure that tool doesn't break, we decided to start testing it using unit tests. It is our goal to provide it with 100% test coverage in the future. This guide will help you get started with writing the tests needed to achieve that. + +_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY). + +## Tools + +We are using the following modules and packages for our unit tests: + +- [unittest](https://docs.python.org/3/library/unittest.html) (standard library) +- [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) (standard library) +- [coverage.py](https://coverage.readthedocs.io/en/stable/) + +To ensure the results you obtain on your personal machine are comparable to those in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts: + +1. `pipenv run test` will run `unittest` with `coverage.py` +2. `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. + +**Note:** If you want a coverage report, make sure to run the tests with `pipenv run test` *first*. + +## Writing tests + +Since our bot is a collaborative, community project, it's important to take a couple of things into when writing tests. This ensures that our test suite is consistent and that everyone understands the tests someone else has written. + +### File and directory structure + +To organize our test suite, we have chosen to mirror the directory structure of [`bot`](/bot/) in the [`tests`](/tests/) subdirectory. This makes it easy to find the relevant tests by providing a natural grouping of files. More general files, such as [`helpers.py`](/tests/helpers.py) are located directly in the `tests` subdirectory. + +All files containing tests should have a filename starting with `test_` to make sure `unittest` will discover them. This prefix is typically followed by the name of the file the tests are written for. If needed, a test file can contain multiple test classes, both to provide structure and to be able to provide different fixtures/set-up methods for different groups of tests. + +### Writing individual and independent tests + +When writing individual test methods, it is important to make sure that each test tests its own *independent* unit. In general, this means that you don't write one large test method that tests every possible branch/part/condition relevant to your function, but rather write separate test methods for each one. The reason is that this will make sure that if one test fails, the rest of the tests will still run and we feedback on exactly which parts are and which are not working correctly. (However, the [DRY-principle](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) also applies to tests, see the `Using self.subTest for independent subtests` section.) + +#### Method names and docstrings + +It's very important that the output of failing tests is easy to understand. That's why it is important to give your test methods a descriptive name that tells us what the failing test was testing. In addition, since **single-line** docstrings will be printed in the output along with the method name, it's also important to add a good single-line docstring that summarizes the purpose of the test to your test method. + +#### Using self.subTest for independent subtests + +Since it's important to make sure all of our tests are independent from each other, you may be tempted to copy-paste your test methods to test a function against a number of different input and output parameters. Luckily, `unittest` provides a better a better of doing that: [`subtests`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests). + +By using the `subTest` context manager, we can perform multiple independent subtests within one test method (e.g., by having a loop for the various inputs we want to test). Using this context manager ensures the test will be run independently and that, if one of them fails, the rest of the subtests are still executed. (Normally, a test function stops once the first exception, including `AssertionError`, is raised.) + +An example (taken from [`test_converters.py`])(/tests/bot/test_converters.py): + +```py + def test_tag_content_converter_for_valid(self): + """TagContentConverter should return correct values for valid input.""" + test_values = ( + ('hello', 'hellpo'), + (' h ello ', 'h ello'), + ) + + for content, expected_conversion in test_values: + with self.subTest(content=content, expected_conversion=expected_conversion): + conversion = asyncio.run(TagContentConverter.convert(self.context, content)) + self.assertEqual(conversion, expected_conversion) +``` + +It's important to note the keyword arguments we provide to the `self.subTest` context manager: These keyword arguments and their values will printed in the output when one of the subtests fail, making sure we know *which* subTest failed: + +``` +.................................................................... +====================================================================== +FAIL: test_tag_content_converter_for_valid (tests.bot.test_converters.ConverterTests) (content='hello', expected_conversion='hellpo') +TagContentConverter should return correct values for valid input. +---------------------------------------------------------------------- + +# Snipped to save vertical space +``` + +## Mocking + +Since we are testing independent "units" of code, we sometimes need to provide "fake" versions of objects generated by code external to the unit we are trying to test to make sure we're truly testing independently from other units of code. We call these "fake objects" mocks. We mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module to create these mock objects, but we also have a couple special mock types defined in [`helpers.py`](/tests/helpers.py). + +An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object and then assert if the `send` method of the mocked version was called with the right message content by the command function: + +```py +import asyncio +import unittest + +from bot.cogs import bot +from tests.helpers import MockBot, MockContext + + +class BotCogTests(unittest.TestCase): + def test_echo_command_correctly_echoes_arguments(self): + """Test if the `!echo ` command correctly echoes the content.""" + mocked_bot = MockBot() + bot_cog = bot.Bot(mocked_bot) + + mocked_context = MockContext() + + text = "Hello! This should be echoed!" + + asyncio.run(bot_cog.echo_command.callback(bot_cog, mocked_context, text=text)) + + mocked_context.send.assert_called_with(text) +``` + +### Mocking coroutines + +By default, `unittest.mock.Mock` and `unittest.mock.MagicMock` can't mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. + +### Special mocks for some `discord.py` types + +To quote Ned Batchelder, Mock objects are "automatic chameleons". This means that they will happily allow the access to any attribute or method and provide a mocked value in return. One downside to this is that if the code you are testing gets the name of the attribute wrong, your mock object will not complain and the test may still pass. + +In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist by default on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. + +These special mocks are added when they are needed, so if you think it would be sensible to add another one, feel free to propose one in your PR. + +**Note:** These mock types only "know" the attributes that are set by default when these `discord.py` types are first initialized. If you need to work with dynamically set attributes that are added after initialization, you can still explicitly mock them: + +```py +import unittest.mock +from tests.helpers import MockGuild + +guild = MockGuild() +guild.some_attribute = unittest.mock.MagicMock() +``` + +The attribute `some_attribute` will now be accessible as a `MagicMock` on the mocked object. + +--- + +## Some considerations + +Finally, there are some considerations to make when writing tests, both for writing tests in general and for writing tests for our bot in particular. + +### Test coverage is a starting point + +Having test coverage is a good starting point for unit testing: If a part of your code was not covered by a test, we know that we have not tested it properly. The reverse is unfortunately not true: Even if the code we are testing has 100% branch coverage, it does not mean it's fully tested or guaranteed to work. + +One problem is that 100% branch coverage may be misleading if we haven't tested our code against all the realistic input it may get in production. For instance, take a look at the following `member_information` function and the test we've written for it: + +```py +import datetime +import unittest +import unittest.mock + + +def member_information(member): + joined = member.joined.stfptime("%d-%m-%Y") if member.joined else "unknown" + return f"{member.name} (joined: {joined})" + + +class FunctionsTests(unittest.TestCase): + def test_member_information(self): + member = unittest.mock.Mock() + member.name = "lemon" + member.joined = None + self.assertEqual(member_information(member), "lemon (joined: unknown)") +``` + +If you were to run this test, not only would the function pass the test, `coverage.py` will also tell us that the test provides 100% branch coverage for the function. Can you spot the bug the test suite did not catch? + +The problem here is that we have only tested our function with a member object that had `None` for the `member.joined` attribute. This means that `member.joined.stfptime("%d-%m-%Y")` was never executed during our test, leading to us missing the spelling mistake in `stfptime` (it should be `strftime`). + +Adding another test would not increase the test coverage we have, but it does ensure that we'll notice that this function can fail with realistic data: + +```py +# (...) +class FunctionsTests(unittest.TestCase): + # (...) + def test_member_information_with_join_datetime(self): + member = unittest.mock.Mock() + member.name = "lemon" + member.joined = datetime.datetime(year=2019, month=10, day=10) + self.assertEqual(member_information(member), "lemon (joined: 10-10-2019)") +``` + +Output: +``` +.E +====================================================================== +ERROR: test_member_information_with_join_datetime (tests.test_functions.FunctionsTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/home/pydis/playground/tests/test_functions.py", line 23, in test_member_information_with_join_datetime + self.assertEqual(member_information(member), "lemon (joined: 10-10-2019)") + File "/home/pydis/playground/tests/test_functions.py", line 8, in member_information + joined = member.joined.stfptime("%d-%m-%Y") if member.joined else "unknown" +AttributeError: 'datetime.datetime' object has no attribute 'stfptime' + +---------------------------------------------------------------------- +Ran 2 tests in 0.003s + +FAILED (errors=1) +``` + +What's more, even if the spelling mistake would not have been there, the first test did not test if the `member_information` function formatted the `member.join` according to the output we actually want to see. + +### Unit Testing vs Integration Testing + +Another restriction of unit testing is that it tests in, well, units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we work under the implicit assumption of understanding and utilizing those external objects correctly. So, in addition to testing the parts separately, we also need to test if the parts integrate correctly into a single application. + +We currently have no automated integration tests or functional tests, so **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written. diff --git a/tests/__init__.py b/tests/__init__.py index e69de29bb..2228110ad 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,5 @@ +import logging + + +log = logging.getLogger() +log.setLevel(logging.CRITICAL) diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 000000000..625dcc0a8 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,70 @@ +import logging +import unittest +from contextlib import contextmanager + + +class _CaptureLogHandler(logging.Handler): + """ + A logging handler capturing all (raw and formatted) logging output. + """ + + def __init__(self): + super().__init__() + self.records = [] + + def flush(self): + pass + + def emit(self, record): + self.records.append(record) + + +class LoggingTestCase(unittest.TestCase): + """TestCase subclass that adds more logging assertion tools.""" + + @contextmanager + def assertNotLogs(self, logger=None, level=None, msg=None): + """ + Asserts that no logs of `level` and higher were emitted by `logger`. + + You can specify a specific `logger`, the minimum `logging` level we want to watch and a + custom `msg` to be added to the `AssertionError` if thrown. If the assertion fails, the + recorded log records will be outputted with the `AssertionError` message. The context + manager does not yield a live `look` into the logging records, since we use this context + manager when we're testing under the assumption that no log records will be emitted. + """ + if not isinstance(logger, logging.Logger): + logger = logging.getLogger(logger) + + if level: + level = logging._nameToLevel.get(level, level) + else: + level = logging.INFO + + handler = _CaptureLogHandler() + old_handlers = logger.handlers[:] + old_level = logger.level + old_propagate = logger.propagate + + logger.handlers = [handler] + logger.setLevel(level) + logger.propagate = False + + try: + yield + except Exception as exc: + raise exc + finally: + logger.handlers = old_handlers + logger.propagate = old_propagate + logger.setLevel(old_level) + + if handler.records: + level_name = logging.getLevelName(level) + n_logs = len(handler.records) + base_message = f"{n_logs} logs of {level_name} or higher were triggered on {logger.name}:\n" + records = [str(record) for record in handler.records] + record_message = "\n".join(records) + standard_message = self._truncateMessage(base_message, record_message) + msg = self._formatMessage(msg, standard_message) + self.fail(msg) diff --git a/tests/bot/__init__.py b/tests/bot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/__init__.py b/tests/bot/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py new file mode 100644 index 000000000..9bbd35a91 --- /dev/null +++ b/tests/bot/cogs/test_information.py @@ -0,0 +1,164 @@ +import asyncio +import textwrap +import unittest +import unittest.mock + +import discord + +from bot import constants +from bot.cogs import information +from tests.helpers import AsyncMock, MockBot, MockContext, MockGuild, MockMember, MockRole + + +class InformationCogTests(unittest.TestCase): + """Tests the Information cog.""" + + @classmethod + def setUpClass(cls): + cls.moderator_role = MockRole(name="Moderator", role_id=constants.Roles.moderator) + + def setUp(self): + """Sets up fresh objects for each test.""" + self.bot = MockBot() + + self.cog = information.Information(self.bot) + + self.ctx = MockContext() + self.ctx.author.roles.append(self.moderator_role) + + def test_roles_command_command(self): + """Test if the `role_info` command correctly returns the `moderator_role`.""" + self.ctx.guild.roles.append(self.moderator_role) + + self.cog.roles_info.can_run = AsyncMock() + self.cog.roles_info.can_run.return_value = True + + coroutine = self.cog.roles_info.callback(self.cog, self.ctx) + + self.assertIsNone(asyncio.run(coroutine)) + self.ctx.send.assert_called_once() + + _, kwargs = self.ctx.send.call_args + embed = kwargs.pop('embed') + + self.assertEqual(embed.title, "Role information") + self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual(embed.description, f"`{self.moderator_role.id}` - {self.moderator_role.mention}\n") + self.assertEqual(embed.footer.text, "Total roles: 1") + + def test_role_info_command(self): + """Tests the `role info` command.""" + dummy_role = MockRole( + name="Dummy", + role_id=112233445566778899, + colour=discord.Colour.blurple(), + position=10, + members=[self.ctx.author], + permissions=discord.Permissions(0) + ) + + admin_role = MockRole( + name="Admins", + role_id=998877665544332211, + colour=discord.Colour.red(), + position=3, + members=[self.ctx.author], + permissions=discord.Permissions(0), + ) + + self.ctx.guild.roles.append([dummy_role, admin_role]) + + self.cog.role_info.can_run = AsyncMock() + self.cog.role_info.can_run.return_value = True + + coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) + + self.assertIsNone(asyncio.run(coroutine)) + + self.assertEqual(self.ctx.send.call_count, 2) + + (_, dummy_kwargs), (_, admin_kwargs) = self.ctx.send.call_args_list + + dummy_embed = dummy_kwargs["embed"] + admin_embed = admin_kwargs["embed"] + + self.assertEqual(dummy_embed.title, "Dummy info") + self.assertEqual(dummy_embed.colour, discord.Colour.blurple()) + + self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id)) + self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") + self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218") + self.assertEqual(dummy_embed.fields[3].value, "1") + self.assertEqual(dummy_embed.fields[4].value, "10") + self.assertEqual(dummy_embed.fields[5].value, "0") + + self.assertEqual(admin_embed.title, "Admins info") + self.assertEqual(admin_embed.colour, discord.Colour.red()) + + @unittest.mock.patch('bot.cogs.information.time_since') + def test_server_info_command(self, time_since_patch): + time_since_patch.return_value = '2 days ago' + + self.ctx.guild = MockGuild( + features=('lemons', 'apples'), + region="The Moon", + roles=[self.moderator_role], + channels=[ + discord.TextChannel( + state={}, + guild=self.ctx.guild, + data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} + ), + discord.CategoryChannel( + state={}, + guild=self.ctx.guild, + data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} + ), + discord.VoiceChannel( + state={}, + guild=self.ctx.guild, + data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} + ) + ], + members=[ + *(MockMember(status='online') for _ in range(2)), + *(MockMember(status='idle') for _ in range(1)), + *(MockMember(status='dnd') for _ in range(4)), + *(MockMember(status='offline') for _ in range(3)), + ], + member_count=1_234, + icon_url='a-lemon.jpg', + ) + + coroutine = self.cog.server_info.callback(self.cog, self.ctx) + self.assertIsNone(asyncio.run(coroutine)) + + time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') + _, kwargs = self.ctx.send.call_args + embed = kwargs.pop('embed') + self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual( + embed.description, + textwrap.dedent( + f""" + **Server information** + Created: {time_since_patch.return_value} + Voice region: {self.ctx.guild.region} + Features: {', '.join(self.ctx.guild.features)} + + **Counts** + Members: {self.ctx.guild.member_count:,} + Roles: {len(self.ctx.guild.roles)} + Text: 1 + Voice: 1 + Channel categories: 1 + + **Members** + {constants.Emojis.status_online} 2 + {constants.Emojis.status_idle} 1 + {constants.Emojis.status_dnd} 4 + {constants.Emojis.status_offline} 3 + """ + ) + ) + self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') diff --git a/tests/bot/patches/__init__.py b/tests/bot/patches/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/resources/__init__.py b/tests/bot/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/rules/__init__.py b/tests/bot/rules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py new file mode 100644 index 000000000..e0ede0eb1 --- /dev/null +++ b/tests/bot/test_api.py @@ -0,0 +1,134 @@ +import logging +import unittest +from unittest.mock import MagicMock, patch + +from bot import api +from tests.base import LoggingTestCase +from tests.helpers import async_test + + +class APIClientTests(unittest.TestCase): + """Tests for the bot's API client.""" + + @classmethod + def setUpClass(cls): + """Sets up the shared fixtures for the tests.""" + cls.error_api_response = MagicMock() + cls.error_api_response.status = 999 + + def test_loop_is_not_running_by_default(self): + """The event loop should not be running by default.""" + self.assertFalse(api.loop_is_running()) + + @async_test + async def test_loop_is_running_in_async_context(self): + """The event loop should be running in an async context.""" + self.assertTrue(api.loop_is_running()) + + def test_response_code_error_default_initialization(self): + """Test the default initialization of `ResponseCodeError` without `text` or `json`""" + error = api.ResponseCodeError(response=self.error_api_response) + + self.assertIs(error.status, self.error_api_response.status) + self.assertEqual(error.response_json, {}) + self.assertEqual(error.response_text, "") + self.assertIs(error.response, self.error_api_response) + + def test_responde_code_error_string_representation_default_initialization(self): + """Test the string representation of `ResponseCodeError` initialized without text or json.""" + error = api.ResponseCodeError(response=self.error_api_response) + self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: ") + + def test_response_code_error_initialization_with_json(self): + """Test the initialization of `ResponseCodeError` with json.""" + json_data = {'hello': 'world'} + error = api.ResponseCodeError( + response=self.error_api_response, + response_json=json_data, + ) + self.assertEqual(error.response_json, json_data) + self.assertEqual(error.response_text, "") + + def test_response_code_error_string_representation_with_nonempty_response_json(self): + """Test the string representation of `ResponseCodeError` initialized with json.""" + json_data = {'hello': 'world'} + error = api.ResponseCodeError( + response=self.error_api_response, + response_json=json_data + ) + self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {json_data}") + + def test_response_code_error_initialization_with_text(self): + """Test the initialization of `ResponseCodeError` with text.""" + text_data = 'Lemon will eat your soul' + error = api.ResponseCodeError( + response=self.error_api_response, + response_text=text_data, + ) + self.assertEqual(error.response_text, text_data) + self.assertEqual(error.response_json, {}) + + def test_response_code_error_string_representation_with_nonempty_response_text(self): + """Test the string representation of `ResponseCodeError` initialized with text.""" + text_data = 'Lemon will eat your soul' + error = api.ResponseCodeError( + response=self.error_api_response, + response_text=text_data + ) + self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {text_data}") + + +class LoggingHandlerTests(LoggingTestCase): + """Tests the bot's API Log Handler.""" + + @classmethod + def setUpClass(cls): + cls.debug_log_record = logging.LogRecord( + name='my.logger', level=logging.DEBUG, + pathname='my/logger.py', lineno=666, + msg="Lemon wins", args=(), + exc_info=None + ) + + cls.trace_log_record = logging.LogRecord( + name='my.logger', level=logging.TRACE, + pathname='my/logger.py', lineno=666, + msg="This will not be logged", args=(), + exc_info=None + ) + + def setUp(self): + self.log_handler = api.APILoggingHandler(None) + + def test_emit_appends_to_queue_with_stopped_event_loop(self): + """Test if `APILoggingHandler.emit` appends to queue when the event loop is not running.""" + with patch("bot.api.APILoggingHandler.ship_off") as ship_off: + # Patch `ship_off` to ease testing against the return value of this coroutine. + ship_off.return_value = 42 + self.log_handler.emit(self.debug_log_record) + + self.assertListEqual(self.log_handler.queue, [42]) + + def test_emit_ignores_less_than_debug(self): + """`APILoggingHandler.emit` should not queue logs with a log level lower than DEBUG.""" + self.log_handler.emit(self.trace_log_record) + self.assertListEqual(self.log_handler.queue, []) + + def test_schedule_queued_tasks_for_empty_queue(self): + """`APILoggingHandler` should not schedule anything when the queue is empty.""" + with self.assertNotLogs(level=logging.DEBUG): + self.log_handler.schedule_queued_tasks() + + def test_schedule_queued_tasks_for_nonempty_queue(self): + """`APILoggingHandler` should schedule logs when the queue is not empty.""" + with self.assertLogs(level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task: + self.log_handler.queue = [555] + self.log_handler.schedule_queued_tasks() + self.assertListEqual(self.log_handler.queue, []) + create_task.assert_called_once_with(555) + + [record] = logs.records + self.assertEqual(record.message, "Scheduled 1 pending logging tasks.") + self.assertEqual(record.levelno, logging.DEBUG) + self.assertEqual(record.name, 'bot.api') + self.assertIn('via_handler', record.__dict__) diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py new file mode 100644 index 000000000..b2b78d9dd --- /dev/null +++ b/tests/bot/test_converters.py @@ -0,0 +1,273 @@ +import asyncio +import datetime +import unittest +from unittest.mock import MagicMock, patch + +from dateutil.relativedelta import relativedelta +from discord.ext.commands import BadArgument + +from bot.converters import ( + Duration, + ISODateTime, + TagContentConverter, + TagNameConverter, + ValidPythonIdentifier, +) + + +class ConverterTests(unittest.TestCase): + """Tests our custom argument converters.""" + + @classmethod + def setUpClass(cls): + cls.context = MagicMock + cls.context.author = 'bob' + + cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') + + def test_tag_content_converter_for_valid(self): + """TagContentConverter should return correct values for valid input.""" + test_values = ( + ('hello', 'hello'), + (' h ello ', 'h ello'), + ) + + for content, expected_conversion in test_values: + with self.subTest(content=content, expected_conversion=expected_conversion): + conversion = asyncio.run(TagContentConverter.convert(self.context, content)) + self.assertEqual(conversion, expected_conversion) + + def test_tag_content_converter_for_invalid(self): + """TagContentConverter should raise the proper exception for invalid input.""" + test_values = ( + ('', "Tag contents should not be empty, or filled with whitespace."), + (' ', "Tag contents should not be empty, or filled with whitespace."), + ) + + for value, exception_message in test_values: + with self.subTest(tag_content=value, exception_message=exception_message): + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(TagContentConverter.convert(self.context, value)) + + def test_tag_name_converter_for_valid(self): + """TagNameConverter should return the correct values for valid tag names.""" + test_values = ( + ('tracebacks', 'tracebacks'), + ('Tracebacks', 'tracebacks'), + (' Tracebacks ', 'tracebacks'), + ) + + for name, expected_conversion in test_values: + with self.subTest(name=name, expected_conversion=expected_conversion): + conversion = asyncio.run(TagNameConverter.convert(self.context, name)) + self.assertEqual(conversion, expected_conversion) + + def test_tag_name_converter_for_invalid(self): + """TagNameConverter should raise the correct exception for invalid tag names.""" + test_values = ( + ('👋', "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."), + ('x' * 128, "Are you insane? That's way too long!"), + ) + + for invalid_name, exception_message in test_values: + with self.subTest(invalid_name=invalid_name, exception_message=exception_message): + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(TagNameConverter.convert(self.context, invalid_name)) + + def test_valid_python_identifier_for_valid(self): + """ValidPythonIdentifier returns valid identifiers unchanged.""" + test_values = ('foo', 'lemon') + + for name in test_values: + with self.subTest(identifier=name): + conversion = asyncio.run(ValidPythonIdentifier.convert(self.context, name)) + self.assertEqual(name, conversion) + + def test_valid_python_identifier_for_invalid(self): + """ValidPythonIdentifier raises the proper exception for invalid identifiers.""" + test_values = ('nested.stuff', '#####') + + for name in test_values: + with self.subTest(identifier=name): + exception_message = f'`{name}` is not a valid Python identifier' + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(ValidPythonIdentifier.convert(self.context, name)) + + def test_duration_converter_for_valid(self): + """Duration returns the correct `datetime` for valid duration strings.""" + test_values = ( + # Simple duration strings + ('1Y', {"years": 1}), + ('1y', {"years": 1}), + ('1year', {"years": 1}), + ('1years', {"years": 1}), + ('1m', {"months": 1}), + ('1month', {"months": 1}), + ('1months', {"months": 1}), + ('1w', {"weeks": 1}), + ('1W', {"weeks": 1}), + ('1week', {"weeks": 1}), + ('1weeks', {"weeks": 1}), + ('1d', {"days": 1}), + ('1D', {"days": 1}), + ('1day', {"days": 1}), + ('1days', {"days": 1}), + ('1h', {"hours": 1}), + ('1H', {"hours": 1}), + ('1hour', {"hours": 1}), + ('1hours', {"hours": 1}), + ('1M', {"minutes": 1}), + ('1minute', {"minutes": 1}), + ('1minutes', {"minutes": 1}), + ('1s', {"seconds": 1}), + ('1S', {"seconds": 1}), + ('1second', {"seconds": 1}), + ('1seconds', {"seconds": 1}), + + # Complex duration strings + ( + '1y1m1w1d1H1M1S', + { + "years": 1, + "months": 1, + "weeks": 1, + "days": 1, + "hours": 1, + "minutes": 1, + "seconds": 1 + } + ), + ('5y100S', {"years": 5, "seconds": 100}), + ('2w28H', {"weeks": 2, "hours": 28}), + + # Duration strings with spaces + ('1 year 2 months', {"years": 1, "months": 2}), + ('1d 2H', {"days": 1, "hours": 2}), + ('1 week2 days', {"weeks": 1, "days": 2}), + ) + + converter = Duration() + + for duration, duration_dict in test_values: + expected_datetime = self.fixed_utc_now + relativedelta(**duration_dict) + + with patch('bot.converters.datetime') as mock_datetime: + mock_datetime.utcnow.return_value = self.fixed_utc_now + + with self.subTest(duration=duration, duration_dict=duration_dict): + converted_datetime = asyncio.run(converter.convert(self.context, duration)) + self.assertEqual(converted_datetime, expected_datetime) + + def test_duration_converter_for_invalid(self): + """Duration raises the right exception for invalid duration strings.""" + test_values = ( + # Units in wrong order + ('1d1w'), + ('1s1y'), + + # Duplicated units + ('1 year 2 years'), + ('1 M 10 minutes'), + + # Unknown substrings + ('1MVes'), + ('1y3breads'), + + # Missing amount + ('ym'), + + # Incorrect whitespace + (" 1y"), + ("1S "), + ("1y 1m"), + + # Garbage + ('Guido van Rossum'), + ('lemon lemon lemon lemon lemon lemon lemon'), + ) + + converter = Duration() + + for invalid_duration in test_values: + with self.subTest(invalid_duration=invalid_duration): + exception_message = f'`{invalid_duration}` is not a valid duration string.' + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(converter.convert(self.context, invalid_duration)) + + def test_isodatetime_converter_for_valid(self): + """ISODateTime converter returns correct datetime for valid datetime string.""" + test_values = ( + # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` + ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` + ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` + ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` + ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` + ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` + ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), + ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), + + # `YYYY-mm-dd` + ('2019-04-01', datetime.datetime(2019, 4, 1)), + + # `YYYY-mm` + ('2019-02-01', datetime.datetime(2019, 2, 1)), + + # `YYYY` + ('2025', datetime.datetime(2025, 1, 1)), + ) + + converter = ISODateTime() + + for datetime_string, expected_dt in test_values: + with self.subTest(datetime_string=datetime_string, expected_dt=expected_dt): + converted_dt = asyncio.run(converter.convert(self.context, datetime_string)) + self.assertIsNone(converted_dt.tzinfo) + self.assertEqual(converted_dt, expected_dt) + + def test_isodatetime_converter_for_invalid(self): + """ISODateTime converter raises the correct exception for invalid datetime strings.""" + test_values = ( + # Make sure it doesn't interfere with the Duration converter + ('1Y'), + ('1d'), + ('1H'), + + # Check if it fails when only providing the optional time part + ('10:10:10'), + ('10:00'), + + # Invalid date format + ('19-01-01'), + + # Other non-valid strings + ('fisk the tag master'), + ) + + converter = ISODateTime() + for datetime_string in test_values: + with self.subTest(datetime_string=datetime_string): + exception_message = f"`{datetime_string}` is not a valid ISO-8601 datetime string" + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(converter.convert(self.context, datetime_string)) diff --git a/tests/bot/utils/__init__.py b/tests/bot/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py new file mode 100644 index 000000000..22dc93073 --- /dev/null +++ b/tests/bot/utils/test_checks.py @@ -0,0 +1,43 @@ +import unittest + +from bot.utils import checks +from tests.helpers import MockContext, MockRole + + +class ChecksTests(unittest.TestCase): + """Tests the check functions defined in `bot.checks`.""" + + def setUp(self): + self.ctx = MockContext() + + def test_with_role_check_without_guild(self): + """`with_role_check` returns `False` if `Context.guild` is None.""" + self.ctx.guild = None + self.assertFalse(checks.with_role_check(self.ctx)) + + def test_with_role_check_without_required_roles(self): + """`with_role_check` returns `False` if `Context.author` lacks the required role.""" + self.ctx.author.roles = [] + self.assertFalse(checks.with_role_check(self.ctx)) + + def test_with_role_check_with_guild_and_required_role(self): + """`with_role_check` returns `True` if `Context.author` has the required role.""" + self.ctx.author.roles.append(MockRole(role_id=10)) + self.assertTrue(checks.with_role_check(self.ctx, 10)) + + def test_without_role_check_without_guild(self): + """`without_role_check` should return `False` when `Context.guild` is None.""" + self.ctx.guild = None + self.assertFalse(checks.without_role_check(self.ctx)) + + def test_without_role_check_returns_false_with_unwanted_role(self): + """`without_role_check` returns `False` if `Context.author` has unwanted role.""" + role_id = 42 + self.ctx.author.roles.append(MockRole(role_id=role_id)) + self.assertFalse(checks.without_role_check(self.ctx, role_id)) + + def test_without_role_check_returns_true_without_unwanted_role(self): + """`without_role_check` returns `True` if `Context.author` does not have unwanted role.""" + role_id = 42 + self.ctx.author.roles.append(MockRole(role_id=role_id)) + self.assertTrue(checks.without_role_check(self.ctx, role_id + 10)) diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py deleted file mode 100644 index c561ba447..000000000 --- a/tests/cogs/sync/test_roles.py +++ /dev/null @@ -1,103 +0,0 @@ -from bot.cogs.sync.syncers import Role, get_roles_for_sync - - -def test_get_roles_for_sync_empty_return_for_equal_roles(): - 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)} - - assert get_roles_for_sync(guild_roles, api_roles) == (set(), set(), set()) - - -def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(): - 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)} - - assert get_roles_for_sync(guild_roles, api_roles) == ( - set(), - guild_roles, - set(), - ) - - -def test_get_roles_only_returns_roles_that_require_update(): - 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) - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - set(), - {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, - set(), - ) - - -def test_get_roles_returns_new_roles_in_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) - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, - set(), - set(), - ) - - -def test_get_roles_returns_roles_to_update_and_new_roles(): - 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) - } - - assert 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_get_roles_returns_roles_to_delete(): - 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), - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - set(), - set(), - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) - - -def test_get_roles_returns_roles_to_delete_update_and_new_roles(): - 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), - } - - assert 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)}, - ) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py deleted file mode 100644 index a863ae35b..000000000 --- a/tests/cogs/sync/test_users.py +++ /dev/null @@ -1,69 +0,0 @@ -from bot.cogs.sync.syncers import User, get_users_for_sync - - -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) - - -def test_get_users_for_sync_returns_nothing_for_empty_params(): - assert get_users_for_sync({}, {}) == (set(), set()) - - -def test_get_users_for_sync_returns_nothing_for_equal_users(): - api_users = {43: fake_user()} - guild_users = {43: fake_user()} - - assert get_users_for_sync(guild_users, api_users) == (set(), set()) - - -def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(): - api_users = {43: fake_user()} - guild_users = {43: fake_user(name='new fancy name')} - - assert get_users_for_sync(guild_users, api_users) == ( - set(), - {fake_user(name='new fancy name')} - ) - - -def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(): - api_users = {43: fake_user()} - guild_users = {43: fake_user(), 63: fake_user(id=63)} - - assert get_users_for_sync(guild_users, api_users) == ( - {fake_user(id=63)}, - set() - ) - - -def test_get_users_for_sync_updates_in_guild_field_on_user_leave(): - api_users = {43: fake_user(), 63: fake_user(id=63)} - guild_users = {43: fake_user()} - - assert get_users_for_sync(guild_users, api_users) == ( - set(), - {fake_user(id=63, in_guild=False)} - ) - - -def test_get_users_for_sync_updates_and_creates_users_as_needed(): - api_users = {43: fake_user()} - guild_users = {63: fake_user(id=63)} - - assert get_users_for_sync(guild_users, api_users) == ( - {fake_user(id=63)}, - {fake_user(in_guild=False)} - ) - - -def test_get_users_for_sync_does_not_duplicate_update_users(): - api_users = {43: fake_user(in_guild=False)} - guild_users = {} - - assert get_users_for_sync(guild_users, api_users) == (set(), set()) diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py deleted file mode 100644 index 67900b275..000000000 --- a/tests/cogs/test_antispam.py +++ /dev/null @@ -1,30 +0,0 @@ -import pytest - -from bot.cogs import antispam - - -def test_default_antispam_config_is_valid(): - validation_errors = antispam.validate_config() - assert not validation_errors - - -@pytest.mark.parametrize( - ('config', 'expected'), - ( - ( - {'invalid-rule': {}}, - {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} - ), - ( - {'burst': {'interval': 10}}, - {'burst': "Key `max` is required but not set for rule `burst`"} - ), - ( - {'burst': {'max': 10}}, - {'burst': "Key `interval` is required but not set for rule `burst`"} - ) - ) -) -def test_invalid_antispam_config_returns_validation_errors(config, expected): - validation_errors = antispam.validate_config(config) - assert validation_errors == expected diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py deleted file mode 100644 index 184bd2595..000000000 --- a/tests/cogs/test_information.py +++ /dev/null @@ -1,211 +0,0 @@ -import asyncio -import logging -import textwrap -from datetime import datetime -from unittest.mock import MagicMock, patch - -import pytest -from discord import ( - CategoryChannel, - Colour, - Permissions, - Role, - TextChannel, - VoiceChannel, -) - -from bot.cogs import information -from bot.constants import Emojis -from bot.decorators import InChannelCheckFailure -from tests.helpers import AsyncMock - - -@pytest.fixture() -def cog(simple_bot): - return information.Information(simple_bot) - - -def role(name: str, id_: int): - r = MagicMock() - r.name = name - r.id = id_ - r.mention = f'&{name}' - return r - - -def member(status: str): - m = MagicMock() - m.status = status - return m - - -@pytest.fixture() -def ctx(moderator_role, simple_ctx): - simple_ctx.author.roles = [moderator_role] - simple_ctx.guild.created_at = datetime(2001, 1, 1) - simple_ctx.send = AsyncMock() - return simple_ctx - - -def test_roles_info_command(cog, ctx): - everyone_role = MagicMock() - everyone_role.name = '@everyone' # should be excluded in the output - ctx.author.roles.append(everyone_role) - ctx.guild.roles = ctx.author.roles - - cog.roles_info.can_run = AsyncMock() - cog.roles_info.can_run.return_value = True - - coroutine = cog.roles_info.callback(cog, ctx) - - assert asyncio.run(coroutine) is None # no rval - ctx.send.assert_called_once() - _, kwargs = ctx.send.call_args - embed = kwargs.pop('embed') - assert embed.title == "Role information" - assert embed.colour == Colour.blurple() - assert embed.description == f"`{ctx.guild.roles[0].id}` - {ctx.guild.roles[0].mention}\n" - assert embed.footer.text == "Total roles: 1" - - -def test_role_info_command(cog, ctx): - dummy_role = MagicMock(spec=Role) - dummy_role.name = "Dummy" - dummy_role.colour = Colour.blurple() - dummy_role.id = 112233445566778899 - dummy_role.position = 10 - dummy_role.permissions = Permissions(0) - dummy_role.members = [ctx.author] - - admin_role = MagicMock(spec=Role) - admin_role.name = "Admin" - admin_role.colour = Colour.red() - admin_role.id = 998877665544332211 - admin_role.position = 3 - admin_role.permissions = Permissions(0) - admin_role.members = [ctx.author] - - ctx.guild.roles = [dummy_role, admin_role] - - cog.role_info.can_run = AsyncMock() - cog.role_info.can_run.return_value = True - - coroutine = cog.role_info.callback(cog, ctx, dummy_role, admin_role) - - assert asyncio.run(coroutine) is None - - assert ctx.send.call_count == 2 - - (_, dummy_kwargs), (_, admin_kwargs) = ctx.send.call_args_list - - dummy_embed = dummy_kwargs["embed"] - admin_embed = admin_kwargs["embed"] - - assert dummy_embed.title == "Dummy info" - assert dummy_embed.colour == Colour.blurple() - - assert dummy_embed.fields[0].value == str(dummy_role.id) - assert dummy_embed.fields[1].value == f"#{dummy_role.colour.value:0>6x}" - assert dummy_embed.fields[2].value == "0.63 0.48 218" - assert dummy_embed.fields[3].value == "1" - assert dummy_embed.fields[4].value == "10" - assert dummy_embed.fields[5].value == "0" - - assert admin_embed.title == "Admin info" - assert admin_embed.colour == Colour.red() - -# There is no argument passed in here that we can use to test, -# so the return value would change constantly. -@patch('bot.cogs.information.time_since') -def test_server_info_command(time_since_patch, cog, ctx, moderator_role): - time_since_patch.return_value = '2 days ago' - - ctx.guild.created_at = datetime(2001, 1, 1) - ctx.guild.features = ('lemons', 'apples') - ctx.guild.region = 'The Moon' - ctx.guild.roles = [moderator_role] - ctx.guild.channels = [ - TextChannel( - state={}, - guild=ctx.guild, - data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} - ), - CategoryChannel( - state={}, - guild=ctx.guild, - data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} - ), - VoiceChannel( - state={}, - guild=ctx.guild, - data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} - ) - ] - ctx.guild.members = [ - member('online'), member('online'), - member('idle'), - member('dnd'), member('dnd'), member('dnd'), member('dnd'), - member('offline'), member('offline'), member('offline') - ] - ctx.guild.member_count = 1_234 - ctx.guild.icon_url = 'a-lemon.png' - - coroutine = cog.server_info.callback(cog, ctx) - assert asyncio.run(coroutine) is None # no rval - - time_since_patch.assert_called_once_with(ctx.guild.created_at, precision='days') - _, kwargs = ctx.send.call_args - embed = kwargs.pop('embed') - assert embed.colour == Colour.blurple() - assert embed.description == textwrap.dedent(f""" - **Server information** - Created: {time_since_patch.return_value} - Voice region: {ctx.guild.region} - Features: {', '.join(ctx.guild.features)} - - **Counts** - Members: {ctx.guild.member_count:,} - Roles: {len(ctx.guild.roles)} - Text: 1 - Voice: 1 - Channel categories: 1 - - **Members** - {Emojis.status_online} 2 - {Emojis.status_idle} 1 - {Emojis.status_dnd} 4 - {Emojis.status_offline} 3 - """) - assert embed.thumbnail.url == 'a-lemon.png' - - -def test_user_info_on_other_users_from_non_moderator(ctx, cog): - ctx.author = MagicMock() - ctx.author.__eq__.return_value = False - ctx.author.roles = [] - coroutine = cog.user_info.callback(cog, ctx, user='scragly') # skip checks, pass args - - assert asyncio.run(coroutine) is None # no rval - ctx.send.assert_called_once_with( - "You may not use this command on users other than yourself." - ) - - -def test_user_info_in_wrong_channel_from_non_moderator(ctx, cog): - ctx.author = MagicMock() - ctx.author.__eq__.return_value = False - ctx.author.roles = [] - - coroutine = cog.user_info.callback(cog, ctx) - message = 'Sorry, but you may only use this command within <#267659945086812160>.' - with pytest.raises(InChannelCheckFailure, match=message): - assert asyncio.run(coroutine) is None # no rval - - -def test_setup(simple_bot, caplog): - information.setup(simple_bot) - simple_bot.add_cog.assert_called_once() - [record] = caplog.records - - assert record.message == "Cog loaded: Information" - assert record.levelno == logging.INFO diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py deleted file mode 100644 index 1efb460fe..000000000 --- a/tests/cogs/test_security.py +++ /dev/null @@ -1,54 +0,0 @@ -import logging -from unittest.mock import MagicMock - -import pytest -from discord.ext.commands import NoPrivateMessage - -from bot.cogs import security - - -@pytest.fixture() -def cog(): - bot = MagicMock() - return security.Security(bot) - - -@pytest.fixture() -def context(): - return MagicMock() - - -def test_check_additions(cog): - cog.bot.check.assert_any_call(cog.check_on_guild) - cog.bot.check.assert_any_call(cog.check_not_bot) - - -def test_check_not_bot_for_humans(cog, context): - context.author.bot = False - assert cog.check_not_bot(context) - - -def test_check_not_bot_for_robots(cog, context): - context.author.bot = True - assert not cog.check_not_bot(context) - - -def test_check_on_guild_outside_of_guild(cog, context): - context.guild = None - - with pytest.raises(NoPrivateMessage, match="This command cannot be used in private messages."): - cog.check_on_guild(context) - - -def test_check_on_guild_on_guild(cog, context): - context.guild = "lemon's lemonade stand" - assert cog.check_on_guild(context) - - -def test_security_cog_load(caplog): - bot = MagicMock() - security.setup(bot) - bot.add_cog.assert_called_once() - [record] = caplog.records - assert record.message == "Cog loaded: Security" - assert record.levelno == logging.INFO diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py deleted file mode 100644 index 9d46b3a05..000000000 --- a/tests/cogs/test_token_remover.py +++ /dev/null @@ -1,133 +0,0 @@ -import asyncio -from unittest.mock import MagicMock - -import pytest -from discord import Colour - -from bot.cogs.token_remover import ( - DELETION_MESSAGE_TEMPLATE, - TokenRemover, - setup as setup_cog, -) -from bot.constants import Channels, Colours, Event, Icons -from tests.helpers import AsyncMock - - -@pytest.fixture() -def token_remover(): - bot = MagicMock() - bot.get_cog.return_value = MagicMock() - bot.get_cog.return_value.send_log_message = AsyncMock() - return TokenRemover(bot=bot) - - -@pytest.fixture() -def message(): - message = MagicMock() - message.author.__str__.return_value = 'lemon' - message.author.bot = False - message.author.avatar_url_as.return_value = 'picture-lemon.png' - message.author.id = 42 - message.author.mention = '@lemon' - message.channel.send = AsyncMock() - message.channel.mention = '#lemonade-stand' - message.content = '' - message.delete = AsyncMock() - message.id = 555 - return message - - -@pytest.mark.parametrize( - ('content', 'expected'), - ( - ('MTIz', True), # 123 - ('YWJj', False), # abc - ) -) -def test_is_valid_user_id(content: str, expected: bool): - assert TokenRemover.is_valid_user_id(content) is expected - - -@pytest.mark.parametrize( - ('content', 'expected'), - ( - ('DN9r_A', True), # stolen from dapi, thanks to the author of the 'token' tag! - ('MTIz', False), # 123 - ) -) -def test_is_valid_timestamp(content: str, expected: bool): - assert TokenRemover.is_valid_timestamp(content) is expected - - -def test_mod_log_property(token_remover): - token_remover.bot.get_cog.return_value = 'lemon' - assert token_remover.mod_log == 'lemon' - token_remover.bot.get_cog.assert_called_once_with('ModLog') - - -def test_ignores_bot_messages(token_remover, message): - message.author.bot = True - coroutine = token_remover.on_message(message) - assert asyncio.run(coroutine) is None - - -@pytest.mark.parametrize('content', ('', 'lemon wins')) -def test_ignores_messages_without_tokens(token_remover, message, content): - message.content = content - coroutine = token_remover.on_message(message) - assert asyncio.run(coroutine) is None - - -@pytest.mark.parametrize('content', ('foo.bar.baz', 'x.y.')) -def test_ignores_invalid_tokens(token_remover, message, content): - message.content = content - coroutine = token_remover.on_message(message) - assert asyncio.run(coroutine) is None - - -@pytest.mark.parametrize( - 'content, censored_token', - ( - ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), - ) -) -def test_censors_valid_tokens( - token_remover, message, content, censored_token, caplog -): - message.content = content - coroutine = token_remover.on_message(message) - assert asyncio.run(coroutine) is None # still no rval - - # asyncio logs some stuff about its reactor, discard it - [_, record] = caplog.records - assert record.message == ( - "Censored a seemingly valid token sent by lemon (`42`) in #lemonade-stand, " - f"token was `{censored_token}`" - ) - - message.delete.assert_called_once_with() - message.channel.send.assert_called_once_with( - DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') - ) - token_remover.bot.get_cog.assert_called_with('ModLog') - message.author.avatar_url_as.assert_called_once_with(static_format='png') - - mod_log = token_remover.bot.get_cog.return_value - mod_log.ignore.assert_called_once_with(Event.message_delete, message.id) - mod_log.send_log_message.assert_called_once_with( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=record.message, - thumbnail='picture-lemon.png', - channel_id=Channels.mod_alerts - ) - - -def test_setup(caplog): - bot = MagicMock() - setup_cog(bot) - [record] = caplog.records - - bot.add_cog.assert_called_once() - assert record.message == "Cog loaded: TokenRemover" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index d3de4484d..000000000 --- a/tests/conftest.py +++ /dev/null @@ -1,32 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from bot.constants import Roles -from tests.helpers import AsyncMock - - -@pytest.fixture() -def moderator_role(): - mock = MagicMock() - mock.id = Roles.moderator - mock.name = 'Moderator' - mock.mention = f'&{mock.name}' - return mock - - -@pytest.fixture() -def simple_bot(): - mock = MagicMock() - mock._before_invoke = AsyncMock() - mock._after_invoke = AsyncMock() - mock.can_run = AsyncMock() - mock.can_run.return_value = True - return mock - - -@pytest.fixture() -def simple_ctx(simple_bot): - mock = MagicMock() - mock.bot = simple_bot - return mock diff --git a/tests/helpers.py b/tests/helpers.py index 2908294f7..64fc04afe 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,23 +1,18 @@ +from __future__ import annotations + import asyncio import functools -from unittest.mock import MagicMock - - -__all__ = ('AsyncMock', 'async_test') +import unittest.mock +from typing import Iterable, Optional - -# TODO: Remove me on 3.8 -class AsyncMock(MagicMock): - async def __call__(self, *args, **kwargs): - return super(AsyncMock, self).__call__(*args, **kwargs) +import discord +from discord.ext.commands import Bot, Context def async_test(wrapped): """ Run a test case via asyncio. - Example: - >>> @async_test ... async def lemon_wins(): ... assert True @@ -27,3 +22,233 @@ def async_test(wrapped): def wrapper(*args, **kwargs): return asyncio.run(wrapped(*args, **kwargs)) return wrapper + + +# TODO: Remove me in Python 3.8 +class AsyncMock(unittest.mock.MagicMock): + """ + A MagicMock subclass to mock async callables. + + Python 3.8 will introduce an AsyncMock class in the standard library that will have some more + features; this stand-in only overwrites the `__call__` method to an async version. + """ + + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +class HashableMixin(discord.mixins.EqualityComparable): + """ + Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. + + Note: discord.py`s `Hashable` mixin bit-shifts `self.id` (`>> 22`); to prevent hash-collisions + for the relative small `id` integers we generally use in tests, this bit-shift is omitted. + """ + + def __hash__(self): + return self.id + + +class ColourMixin: + """A mixin of Mocks that provides the aliasing of color->colour like discord.py does.""" + + @property + def color(self) -> discord.Colour: + return self.colour + + @color.setter + def color(self, color: discord.Colour) -> None: + self.colour = color + + +class AttributeMock: + """Ensures attributes of our mock types will be instantiated with the correct mock type.""" + + def __new__(cls, *args, **kwargs): + """Stops the regular parent class from propagating to newly mocked attributes.""" + if 'parent' in kwargs: + return cls.attribute_mocktype(*args, **kwargs) + + return super().__new__(cls) + + +# Create a guild instance to get a realistic Mock of `discord.Guild` +guild_data = { + 'id': 1, + 'name': 'guild', + 'region': 'Europe', + 'verification_level': 2, + 'default_notications': 1, + 'afk_timeout': 100, + 'icon': "icon.png", + 'banner': 'banner.png', + 'mfa_level': 1, + 'splash': 'splash.png', + 'system_channel_id': 464033278631084042, + 'description': 'mocking is fun', + 'max_presences': 10_000, + 'max_members': 100_000, + 'preferred_locale': 'UTC', + 'owner_id': 1, + 'afk_channel_id': 464033278631084042, +} +guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) + + +class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): + """ + A `Mock` subclass to mock `discord.Guild` objects. + + A MockGuild instance will follow the specifications of a `discord.Guild` instance. This means + that if the code you're testing tries to access an attribute or method that normally does not + exist for a `discord.Guild` object this will raise an `AttributeError`. This is to make sure our + tests fail if the code we're testing uses a `discord.Guild` object in the wrong way. + + One restriction of that is that if the code tries to access an attribute that normally does not + exist for `discord.Guild` instance but was added dynamically, this will raise an exception with + the mocked object. To get around that, you can set the non-standard attribute explicitly for the + instance of `MockGuild`: + + >>> guild = MockGuild() + >>> guild.attribute_that_normally_does_not_exist = unittest.mock.MagicMock() + + In addition to attribute simulation, mocked guild object will pass an `isinstance` check against + `discord.Guild`: + + >>> guild = MockGuild() + >>> isinstance(guild, discord.Guild) + True + + For more info, see the `Mocking` section in `tests/README.md`. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__( + self, + guild_id: int = 1, + roles: Optional[Iterable[MockRole]] = None, + members: Optional[Iterable[MockMember]] = None, + **kwargs, + ) -> None: + super().__init__(spec=guild_instance, **kwargs) + + self.id = guild_id + + self.roles = [MockRole("@everyone", 1)] + if roles: + self.roles.extend(roles) + + self.members = [] + if members: + self.members.extend(members) + + +# Create a Role instance to get a realistic Mock of `discord.Role` +role_data = {'name': 'role', 'id': 1} +role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) + + +class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock `discord.Role` objects. + + Instances of this class will follow the specifications of `discord.Role` instances. For more + information, see the `MockGuild` docstring. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__( + self, + name: str = "role", + role_id: int = 1, + position: int = 1, + **kwargs, + ) -> None: + super().__init__(spec=role_instance, **kwargs) + self.name = name + self.id = role_id + self.position = position + self.mention = f'&{self.name}' + + def __lt__(self, other): + """Simplified position-based comparisons similar to those of `discord.Role`.""" + return self.position < other.position + + +# Create a Member instance to get a realistic Mock of `discord.Member` +member_data = {'user': 'lemon', 'roles': [1]} +state_mock = unittest.mock.MagicMock() +member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) + + +class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock Member objects. + + Instances of this class will follow the specifications of `discord.Member` instances. For more + information, see the `MockGuild` docstring. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__( + self, + name: str = "member", + user_id: int = 1, + roles: Optional[Iterable[MockRole]] = None, + **kwargs, + ) -> None: + super().__init__(spec=member_instance, **kwargs) + self.name = name + self.id = user_id + self.roles = [MockRole("@everyone", 1)] + if roles: + self.roles.extend(roles) + self.mention = f"@{self.name}" + self.send = AsyncMock() + + +# Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` +bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) + + +class MockBot(AttributeMock, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Bot objects. + + Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. + For more information, see the `MockGuild` docstring. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, **kwargs) -> None: + super().__init__(spec=bot_instance, **kwargs) + self._before_invoke = AsyncMock() + self._after_invoke = AsyncMock() + self.user = MockMember(name="Python", user_id=123456789) + + +# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` +context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) + + +class MockContext(AttributeMock, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Context objects. + + Instances of this class will follow the specifications of `discord.ext.commands.Context` + instances. For more information, see the `MockGuild` docstring. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, **kwargs) -> None: + super().__init__(spec=context_instance, **kwargs) + self.bot = MockBot() + self.send = AsyncMock() + self.guild = MockGuild() + self.author = MockMember() + self.command = unittest.mock.MagicMock() diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py deleted file mode 100644 index 6f025b3cb..000000000 --- a/tests/rules/test_attachments.py +++ /dev/null @@ -1,52 +0,0 @@ -import asyncio -from dataclasses import dataclass -from typing import Any, List - -import pytest - -from bot.rules import attachments - - -# Using `MagicMock` sadly doesn't work for this usecase -# since it's __eq__ compares the MagicMock's ID. We just -# want to compare the actual attributes we set. -@dataclass -class FakeMessage: - author: str - attachments: List[Any] - - -def msg(total_attachments: int): - return FakeMessage(author='lemon', attachments=list(range(total_attachments))) - - -@pytest.mark.parametrize( - 'messages', - ( - (msg(0), msg(0), msg(0)), - (msg(2), msg(2)), - (msg(0),), - ) -) -def test_allows_messages_without_too_many_attachments(messages): - last_message, *recent_messages = messages - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - assert asyncio.run(coro) is None - - -@pytest.mark.parametrize( - ('messages', 'relevant_messages', 'total'), - ( - ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), - ((msg(6),), [msg(6)], 6), - ((msg(1),) * 6, [msg(1)] * 6, 6), - ) -) -def test_disallows_messages_with_too_many_attachments(messages, relevant_messages, total): - last_message, *recent_messages = messages - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - assert asyncio.run(coro) == ( - f"sent {total} attachments in 5s", - ('lemon',), - relevant_messages - ) diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index ce69ef187..000000000 --- a/tests/test_api.py +++ /dev/null @@ -1,106 +0,0 @@ -import logging -from unittest.mock import MagicMock, patch - -import pytest - -from bot import api -from tests.helpers import async_test - - -def test_loop_is_not_running_by_default(): - assert not api.loop_is_running() - - -@async_test -async def test_loop_is_running_in_async_test(): - assert api.loop_is_running() - - -@pytest.fixture() -def error_api_response(): - response = MagicMock() - response.status = 999 - return response - - -@pytest.fixture() -def api_log_handler(): - return api.APILoggingHandler(None) - - -@pytest.fixture() -def debug_log_record(): - return logging.LogRecord( - name='my.logger', level=logging.DEBUG, - pathname='my/logger.py', lineno=666, - msg="Lemon wins", args=(), - exc_info=None - ) - - -def test_response_code_error_default_initialization(error_api_response): - error = api.ResponseCodeError(response=error_api_response) - assert error.status is error_api_response.status - assert not error.response_json - assert not error.response_text - assert error.response is error_api_response - - -def test_response_code_error_default_representation(error_api_response): - error = api.ResponseCodeError(response=error_api_response) - assert str(error) == f"Status: {error_api_response.status} Response: " - - -def test_response_code_error_representation_with_nonempty_response_json(error_api_response): - error = api.ResponseCodeError( - response=error_api_response, - response_json={'hello': 'world'} - ) - assert str(error) == f"Status: {error_api_response.status} Response: {{'hello': 'world'}}" - - -def test_response_code_error_representation_with_nonempty_response_text(error_api_response): - error = api.ResponseCodeError( - response=error_api_response, - response_text='Lemon will eat your soul' - ) - assert str(error) == f"Status: {error_api_response.status} Response: Lemon will eat your soul" - - -@patch('bot.api.APILoggingHandler.ship_off') -def test_emit_appends_to_queue_with_stopped_event_loop( - ship_off_patch, api_log_handler, debug_log_record -): - # This is a coroutine so returns something we should await, - # but asyncio complains about that. To ease testing, we patch - # `ship_off` to just return a regular value instead. - ship_off_patch.return_value = 42 - api_log_handler.emit(debug_log_record) - - assert api_log_handler.queue == [42] - - -def test_emit_ignores_less_than_debug(debug_log_record, api_log_handler): - debug_log_record.levelno = logging.DEBUG - 5 - api_log_handler.emit(debug_log_record) - assert not api_log_handler.queue - - -def test_schedule_queued_tasks_for_empty_queue(api_log_handler, caplog): - api_log_handler.schedule_queued_tasks() - # Logs when tasks are scheduled - assert not caplog.records - - -@patch('asyncio.create_task') -def test_schedule_queued_tasks_for_nonempty_queue(create_task_patch, api_log_handler, caplog): - api_log_handler.queue = [555] - api_log_handler.schedule_queued_tasks() - assert not api_log_handler.queue - create_task_patch.assert_called_once_with(555) - - [record] = caplog.records - assert record.message == "Scheduled 1 pending logging tasks." - assert record.levelno == logging.DEBUG - assert record.name == 'bot.api' - assert record.__dict__['via_handler'] diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 000000000..b7c1e0037 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,61 @@ +import logging +import unittest +import unittest.mock + + +from tests.base import LoggingTestCase + + +class LoggingTestCaseTests(unittest.TestCase): + """Tests for the LoggingTestCase.""" + + @classmethod + def setUpClass(cls): + cls.log = logging.getLogger(__name__) + + def test_assert_not_logs_does_not_raise_with_no_logs(self): + """Test if LoggingTestCase.assertNotLogs does not raise when no logs were emitted.""" + try: + with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): + pass + except AssertionError: + self.fail("`self.assertNotLogs` raised an AssertionError when it should not!") + + @unittest.mock.patch("tests.base.LoggingTestCase.assertNotLogs") + def test_the_test_function_assert_not_logs_does_not_raise_with_no_logs(self, assertNotLogs): + """Test if test_assert_not_logs_does_not_raise_with_no_logs captures exception correctly.""" + assertNotLogs.return_value = iter([None]) + assertNotLogs.side_effect = AssertionError + + message = "`self.assertNotLogs` raised an AssertionError when it should not!" + with self.assertRaises(AssertionError, msg=message): + self.test_assert_not_logs_does_not_raise_with_no_logs() + + def test_assert_not_logs_raises_correct_assertion_error_when_logs_are_emitted(self): + """Test if LoggingTestCase.assertNotLogs raises AssertionError when logs were emitted.""" + msg_regex = ( + r"1 logs of DEBUG or higher were triggered on root:\n" + r'' + ) + with self.assertRaisesRegex(AssertionError, msg_regex): + with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): + self.log.debug("Log!") + + def test_assert_not_logs_reraises_unexpected_exception_in_managed_context(self): + """Test if LoggingTestCase.assertNotLogs reraises an unexpected exception.""" + with self.assertRaises(ValueError, msg="test exception"): + with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): + raise ValueError("test exception") + + def test_assert_not_logs_restores_old_logging_settings(self): + """Test if LoggingTestCase.assertNotLogs reraises an unexpected exception.""" + old_handlers = self.log.handlers[:] + old_level = self.log.level + old_propagate = self.log.propagate + + with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): + pass + + self.assertEqual(self.log.handlers, old_handlers) + self.assertEqual(self.log.level, old_level) + self.assertEqual(self.log.propagate, old_propagate) diff --git a/tests/test_constants.py b/tests/test_constants.py deleted file mode 100644 index e4a29d994..000000000 --- a/tests/test_constants.py +++ /dev/null @@ -1,23 +0,0 @@ -import inspect - -import pytest - -from bot import constants - - -@pytest.mark.parametrize( - 'section', - ( - cls - for (name, cls) in inspect.getmembers(constants) - if hasattr(cls, 'section') and isinstance(cls, type) - ) -) -def test_section_configuration_matches_typespec(section): - for (name, annotation) in section.__annotations__.items(): - value = getattr(section, name) - - if getattr(annotation, '_name', None) in ('Dict', 'List'): - pytest.skip("Cannot validate containers yet") - - assert isinstance(value, annotation) diff --git a/tests/test_converters.py b/tests/test_converters.py deleted file mode 100644 index f69995ec6..000000000 --- a/tests/test_converters.py +++ /dev/null @@ -1,264 +0,0 @@ -import asyncio -import datetime -from unittest.mock import MagicMock, patch - -import pytest -from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument - -from bot.converters import ( - Duration, - ISODateTime, - TagContentConverter, - TagNameConverter, - ValidPythonIdentifier, -) - - -@pytest.mark.parametrize( - ('value', 'expected'), - ( - ('hello', 'hello'), - (' h ello ', 'h ello') - ) -) -def test_tag_content_converter_for_valid(value: str, expected: str): - assert asyncio.run(TagContentConverter.convert(None, value)) == expected - - -@pytest.mark.parametrize( - ('value', 'expected'), - ( - ('', "Tag contents should not be empty, or filled with whitespace."), - (' ', "Tag contents should not be empty, or filled with whitespace.") - ) -) -def test_tag_content_converter_for_invalid(value: str, expected: str): - context = MagicMock() - context.author = 'bob' - - with pytest.raises(BadArgument, match=expected): - asyncio.run(TagContentConverter.convert(context, value)) - - -@pytest.mark.parametrize( - ('value', 'expected'), - ( - ('tracebacks', 'tracebacks'), - ('Tracebacks', 'tracebacks'), - (' Tracebacks ', 'tracebacks'), - ) -) -def test_tag_name_converter_for_valid(value: str, expected: str): - assert asyncio.run(TagNameConverter.convert(None, value)) == expected - - -@pytest.mark.parametrize( - ('value', 'expected'), - ( - ('👋', "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."), - # Escape question mark as this is evaluated as regular expression. - ('x' * 128, r"Are you insane\? That's way too long!"), - ) -) -def test_tag_name_converter_for_invalid(value: str, expected: str): - context = MagicMock() - context.author = 'bob' - - with pytest.raises(BadArgument, match=expected): - asyncio.run(TagNameConverter.convert(context, value)) - - -@pytest.mark.parametrize('value', ('foo', 'lemon')) -def test_valid_python_identifier_for_valid(value: str): - assert asyncio.run(ValidPythonIdentifier.convert(None, value)) == value - - -@pytest.mark.parametrize('value', ('nested.stuff', '#####')) -def test_valid_python_identifier_for_invalid(value: str): - with pytest.raises(BadArgument, match=f'`{value}` is not a valid Python identifier'): - asyncio.run(ValidPythonIdentifier.convert(None, value)) - - -FIXED_UTC_NOW = datetime.datetime.fromisoformat('2019-01-01T00:00:00') - - -@pytest.fixture( - params=( - # Simple duration strings - ('1Y', {"years": 1}), - ('1y', {"years": 1}), - ('1year', {"years": 1}), - ('1years', {"years": 1}), - ('1m', {"months": 1}), - ('1month', {"months": 1}), - ('1months', {"months": 1}), - ('1w', {"weeks": 1}), - ('1W', {"weeks": 1}), - ('1week', {"weeks": 1}), - ('1weeks', {"weeks": 1}), - ('1d', {"days": 1}), - ('1D', {"days": 1}), - ('1day', {"days": 1}), - ('1days', {"days": 1}), - ('1h', {"hours": 1}), - ('1H', {"hours": 1}), - ('1hour', {"hours": 1}), - ('1hours', {"hours": 1}), - ('1M', {"minutes": 1}), - ('1minute', {"minutes": 1}), - ('1minutes', {"minutes": 1}), - ('1s', {"seconds": 1}), - ('1S', {"seconds": 1}), - ('1second', {"seconds": 1}), - ('1seconds', {"seconds": 1}), - - # Complex duration strings - ( - '1y1m1w1d1H1M1S', - { - "years": 1, - "months": 1, - "weeks": 1, - "days": 1, - "hours": 1, - "minutes": 1, - "seconds": 1 - } - ), - ('5y100S', {"years": 5, "seconds": 100}), - ('2w28H', {"weeks": 2, "hours": 28}), - - # Duration strings with spaces - ('1 year 2 months', {"years": 1, "months": 2}), - ('1d 2H', {"days": 1, "hours": 2}), - ('1 week2 days', {"weeks": 1, "days": 2}), - ) -) -def create_future_datetime(request): - """Yields duration string and target datetime.datetime object.""" - duration, duration_dict = request.param - future_datetime = FIXED_UTC_NOW + relativedelta(**duration_dict) - yield duration, future_datetime - - -def test_duration_converter_for_valid(create_future_datetime: tuple): - converter = Duration() - duration, expected = create_future_datetime - with patch('bot.converters.datetime') as mock_datetime: - mock_datetime.utcnow.return_value = FIXED_UTC_NOW - assert asyncio.run(converter.convert(None, duration)) == expected - - -@pytest.mark.parametrize( - ('duration'), - ( - # Units in wrong order - ('1d1w'), - ('1s1y'), - - # Duplicated units - ('1 year 2 years'), - ('1 M 10 minutes'), - - # Unknown substrings - ('1MVes'), - ('1y3breads'), - - # Missing amount - ('ym'), - - # Incorrect whitespace - (" 1y"), - ("1S "), - ("1y 1m"), - - # Garbage - ('Guido van Rossum'), - ('lemon lemon lemon lemon lemon lemon lemon'), - ) -) -def test_duration_converter_for_invalid(duration: str): - converter = Duration() - with pytest.raises(BadArgument, match=f'`{duration}` is not a valid duration string.'): - asyncio.run(converter.convert(None, duration)) - - -@pytest.mark.parametrize( - ("datetime_string", "expected_dt"), - ( - - # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` - ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` - ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` - ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` - ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` - ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` - ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), - ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), - - # `YYYY-mm-dd` - ('2019-04-01', datetime.datetime(2019, 4, 1)), - - # `YYYY-mm` - ('2019-02-01', datetime.datetime(2019, 2, 1)), - - # `YYYY` - ('2025', datetime.datetime(2025, 1, 1)), - ), -) -def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: datetime.datetime): - converter = ISODateTime() - converted_dt = asyncio.run(converter.convert(None, datetime_string)) - assert converted_dt.tzinfo is None - assert converted_dt == expected_dt - - -@pytest.mark.parametrize( - ("datetime_string"), - ( - # Make sure it doesn't interfere with the Duration converter - ('1Y'), - ('1d'), - ('1H'), - - # Check if it fails when only providing the optional time part - ('10:10:10'), - ('10:00'), - - # Invalid date format - ('19-01-01'), - - # Other non-valid strings - ('fisk the tag master'), - ), -) -def test_isodatetime_converter_for_invalid(datetime_string: str): - converter = ISODateTime() - with pytest.raises( - BadArgument, - match=f"`{datetime_string}` is not a valid ISO-8601 datetime string", - ): - asyncio.run(converter.convert(None, datetime_string)) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 000000000..766fe17b8 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,339 @@ +import asyncio +import inspect +import unittest +import unittest.mock + +import discord + +from tests import helpers + + +class MockObjectTests(unittest.TestCase): + """Tests the mock objects and mixins we've defined.""" + @classmethod + def setUpClass(cls): + cls.hashable_mocks = (helpers.MockRole, helpers.MockMember, helpers.MockGuild) + + def test_colour_mixin(self): + """Test if the ColourMixin adds aliasing of color -> colour for child classes.""" + class MockHemlock(unittest.mock.MagicMock, helpers.ColourMixin): + pass + + hemlock = MockHemlock() + hemlock.color = 1 + self.assertEqual(hemlock.colour, 1) + self.assertEqual(hemlock.colour, hemlock.color) + + def test_hashable_mixin_hash_returns_id(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly() + scragly.id = 10 + self.assertEqual(hash(scragly), scragly.id) + + def test_hashable_mixin_uses_id_for_equality_comparison(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly(spec=object) + scragly.id = 10 + eevee = MockScragly(spec=object) + eevee.id = 10 + python = MockScragly(spec=object) + python.id = 20 + + self.assertTrue(scragly == eevee) + self.assertFalse(scragly == python) + + def test_hashable_mixin_uses_id_for_nonequality_comparison(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly(spec=object) + scragly.id = 10 + eevee = MockScragly(spec=object) + eevee.id = 10 + python = MockScragly(spec=object) + python.id = 20 + + self.assertTrue(scragly != python) + self.assertFalse(scragly != eevee) + + def test_mock_class_with_hashable_mixin_uses_id_for_hashing(self): + """Test if the MagicMock subclasses that implement the HashableMixin use id for hash.""" + for mock in self.hashable_mocks: + with self.subTest(mock_class=mock): + instance = helpers.MockRole(role_id=100) + self.assertEqual(hash(instance), instance.id) + + def test_mock_class_with_hashable_mixin_uses_id_for_equality(self): + """Test if MagicMocks that implement the HashableMixin use id for equality comparisons.""" + for mock_class in self.hashable_mocks: + with self.subTest(mock_class=mock_class): + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() + + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 + + self.assertTrue(instance_one == instance_two) + self.assertFalse(instance_one == instance_three) + + def test_mock_class_with_hashable_mixin_uses_id_for_nonequality(self): + """Test if MagicMocks that implement HashableMixin use id for nonequality comparisons.""" + for mock_class in self.hashable_mocks: + with self.subTest(mock_class=mock_class): + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() + + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 + + self.assertFalse(instance_one != instance_two) + self.assertTrue(instance_one != instance_three) + + def test_spec_propagation_of_mock_subclasses(self): + """Test if the `spec` does not propagate to attributes of the mock object.""" + test_values = ( + (helpers.MockGuild, "region"), + (helpers.MockRole, "mentionable"), + (helpers.MockMember, "display_name"), + (helpers.MockBot, "owner_id"), + (helpers.MockContext, "command_failed"), + ) + + for mock_type, valid_attribute in test_values: + with self.subTest(mock_type=mock_type, attribute=valid_attribute): + mock = mock_type() + self.assertTrue(isinstance(mock, mock_type)) + attribute = getattr(mock, valid_attribute) + self.assertTrue(isinstance(attribute, mock_type.attribute_mocktype)) + + def test_mock_role_default_initialization(self): + """Test if the default initialization of MockRole results in the correct object.""" + role = helpers.MockRole() + + # The `spec` argument makes sure `isistance` checks with `discord.Role` pass + self.assertIsInstance(role, discord.Role) + + self.assertEqual(role.name, "role") + self.assertEqual(role.id, 1) + self.assertEqual(role.position, 1) + self.assertEqual(role.mention, "&role") + + def test_mock_role_alternative_arguments(self): + """Test if MockRole initializes with the arguments provided.""" + role = helpers.MockRole( + name="Admins", + role_id=90210, + position=10, + ) + + self.assertEqual(role.name, "Admins") + self.assertEqual(role.id, 90210) + self.assertEqual(role.position, 10) + self.assertEqual(role.mention, "&Admins") + + def test_mock_role_accepts_dynamic_arguments(self): + """Test if MockRole accepts and sets abitrary keyword arguments.""" + role = helpers.MockRole( + guild="Dino Man", + hoist=True, + ) + + self.assertEqual(role.guild, "Dino Man") + self.assertTrue(role.hoist) + + def test_mock_role_rejects_accessing_attributes_not_following_spec(self): + """Test if MockRole throws AttributeError for attribute not existing in discord.Role.""" + with self.assertRaises(AttributeError): + role = helpers.MockRole() + role.joseph + + def test_mock_role_rejects_accessing_methods_not_following_spec(self): + """Test if MockRole throws AttributeError for method not existing in discord.Role.""" + with self.assertRaises(AttributeError): + role = helpers.MockRole() + role.lemon() + + def test_mock_role_accepts_accessing_attributes_following_spec(self): + """Test if MockRole accepts attribute access for valid attributes of discord.Role.""" + role = helpers.MockRole() + role.hoist + + def test_mock_role_accepts_accessing_methods_following_spec(self): + """Test if MockRole accepts method calls for valid methods of discord.Role.""" + role = helpers.MockRole() + role.edit() + + def test_mock_role_uses_position_for_less_than_greater_than(self): + """Test if `<` and `>` comparisons for MockRole are based on its position attribute.""" + role_one = helpers.MockRole(position=1) + role_two = helpers.MockRole(position=2) + role_three = helpers.MockRole(position=3) + + self.assertLess(role_one, role_two) + self.assertLess(role_one, role_three) + self.assertLess(role_two, role_three) + self.assertGreater(role_three, role_two) + self.assertGreater(role_three, role_one) + self.assertGreater(role_two, role_one) + + def test_mock_member_default_initialization(self): + """Test if the default initialization of Mockmember results in the correct object.""" + member = helpers.MockMember() + + # The `spec` argument makes sure `isistance` checks with `discord.Member` pass + self.assertIsInstance(member, discord.Member) + + self.assertEqual(member.name, "member") + self.assertEqual(member.id, 1) + self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1)]) + self.assertEqual(member.mention, "@member") + + def test_mock_member_alternative_arguments(self): + """Test if MockMember initializes with the arguments provided.""" + core_developer = helpers.MockRole("Core Developer", 2) + member = helpers.MockMember( + name="Mark", + user_id=12345, + roles=[core_developer] + ) + + self.assertEqual(member.name, "Mark") + self.assertEqual(member.id, 12345) + self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1), core_developer]) + self.assertEqual(member.mention, "@Mark") + + def test_mock_member_accepts_dynamic_arguments(self): + """Test if MockMember accepts and sets abitrary keyword arguments.""" + member = helpers.MockMember( + nick="Dino Man", + colour=discord.Colour.default(), + ) + + self.assertEqual(member.nick, "Dino Man") + self.assertEqual(member.colour, discord.Colour.default()) + + def test_mock_member_rejects_accessing_attributes_not_following_spec(self): + """Test if MockMember throws AttributeError for attribute not existing in spec discord.Member.""" + with self.assertRaises(AttributeError): + member = helpers.MockMember() + member.joseph + + def test_mock_member_rejects_accessing_methods_not_following_spec(self): + """Test if MockMember throws AttributeError for method not existing in spec discord.Member.""" + with self.assertRaises(AttributeError): + member = helpers.MockMember() + member.lemon() + + def test_mock_member_accepts_accessing_attributes_following_spec(self): + """Test if MockMember accepts attribute access for valid attributes of discord.Member.""" + member = helpers.MockMember() + member.display_name + + def test_mock_member_accepts_accessing_methods_following_spec(self): + """Test if MockMember accepts method calls for valid methods of discord.Member.""" + member = helpers.MockMember() + member.mentioned_in() + + def test_mock_guild_default_initialization(self): + """Test if the default initialization of Mockguild results in the correct object.""" + guild = helpers.MockGuild() + + # The `spec` argument makes sure `isistance` checks with `discord.Guild` pass + self.assertIsInstance(guild, discord.Guild) + + self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1)]) + self.assertListEqual(guild.members, []) + + def test_mock_guild_alternative_arguments(self): + """Test if MockGuild initializes with the arguments provided.""" + core_developer = helpers.MockRole("Core Developer", 2) + guild = helpers.MockGuild( + roles=[core_developer], + members=[helpers.MockMember(user_id=54321)], + ) + + self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1), core_developer]) + self.assertListEqual(guild.members, [helpers.MockMember(user_id=54321)]) + + def test_mock_guild_accepts_dynamic_arguments(self): + """Test if MockGuild accepts and sets abitrary keyword arguments.""" + guild = helpers.MockGuild( + emojis=(":hyperjoseph:", ":pensive_ela:"), + premium_subscription_count=15, + ) + + self.assertTupleEqual(guild.emojis, (":hyperjoseph:", ":pensive_ela:")) + self.assertEqual(guild.premium_subscription_count, 15) + + def test_mock_guild_rejects_accessing_attributes_not_following_spec(self): + """Test if MockGuild throws AttributeError for attribute not existing in spec discord.Guild.""" + with self.assertRaises(AttributeError): + guild = helpers.MockGuild() + guild.aperture + + def test_mock_guild_rejects_accessing_methods_not_following_spec(self): + """Test if MockGuild throws AttributeError for method not existing in spec discord.Guild.""" + with self.assertRaises(AttributeError): + guild = helpers.MockGuild() + guild.volcyyy() + + def test_mock_guild_accepts_accessing_attributes_following_spec(self): + """Test if MockGuild accepts attribute access for valid attributes of discord.Guild.""" + guild = helpers.MockGuild() + guild.name + + def test_mock_guild_accepts_accessing_methods_following_spec(self): + """Test if MockGuild accepts method calls for valid methods of discord.Guild.""" + guild = helpers.MockGuild() + guild.by_category() + + def test_mock_bot_default_initialization(self): + """Tests if MockBot initializes with the correct values.""" + bot = helpers.MockBot() + + # The `spec` argument makes sure `isistance` checks with `discord.ext.commands.Bot` pass + self.assertIsInstance(bot, discord.ext.commands.Bot) + + self.assertIsInstance(bot._before_invoke, helpers.AsyncMock) + self.assertIsInstance(bot._after_invoke, helpers.AsyncMock) + self.assertEqual(bot.user, helpers.MockMember(name="Python", user_id=123456789)) + + def test_mock_context_default_initialization(self): + """Tests if MockContext initializes with the correct values.""" + context = helpers.MockContext() + + # The `spec` argument makes sure `isistance` checks with `discord.ext.commands.Context` pass + self.assertIsInstance(context, discord.ext.commands.Context) + + self.assertIsInstance(context.bot, helpers.MockBot) + self.assertIsInstance(context.send, helpers.AsyncMock) + self.assertIsInstance(context.guild, helpers.MockGuild) + self.assertIsInstance(context.author, helpers.MockMember) + + def test_async_mock_provides_coroutine_for_dunder_call(self): + """Test if AsyncMock objects have a coroutine for their __call__ method.""" + async_mock = helpers.AsyncMock() + self.assertTrue(inspect.iscoroutinefunction(async_mock.__call__)) + + coroutine = async_mock() + self.assertTrue(inspect.iscoroutine(coroutine)) + self.assertIsNotNone(asyncio.run(coroutine)) + + def test_async_test_decorator_allows_synchronous_call_to_async_def(self): + """Test if the `async_test` decorator allows an `async def` to be called synchronously.""" + @helpers.async_test + async def kosayoda(): + return "return value" + + self.assertEqual(kosayoda(), "return value") diff --git a/tests/test_pagination.py b/tests/test_pagination.py deleted file mode 100644 index 11d6541ae..000000000 --- a/tests/test_pagination.py +++ /dev/null @@ -1,29 +0,0 @@ -from unittest import TestCase - -import pytest - -from bot import pagination - - -class LinePaginatorTests(TestCase): - def setUp(self): - self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) - - def test_add_line_raises_on_too_long_lines(self): - message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" - with pytest.raises(RuntimeError, match=message): - self.paginator.add_line('x' * self.paginator.max_size) - - def test_add_line_works_on_small_lines(self): - self.paginator.add_line('x' * (self.paginator.max_size - 3)) - - -class ImagePaginatorTests(TestCase): - def setUp(self): - self.paginator = pagination.ImagePaginator() - - def test_add_image_appends_image(self): - image = 'lemon' - self.paginator.add_image(image) - - assert self.paginator.images == [image] diff --git a/tests/test_resources.py b/tests/test_resources.py deleted file mode 100644 index bcf124f05..000000000 --- a/tests/test_resources.py +++ /dev/null @@ -1,13 +0,0 @@ -import json -from pathlib import Path - - -def test_stars_valid(): - """Validates that `bot/resources/stars.json` contains a list of strings.""" - - path = Path('bot', 'resources', 'stars.json') - content = path.read_text() - data = json.loads(content) - - for name in data: - assert type(name) is str diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py deleted file mode 100644 index 7121acebd..000000000 --- a/tests/utils/test_checks.py +++ /dev/null @@ -1,66 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from bot.utils import checks - - -@pytest.fixture() -def context(): - return MagicMock() - - -def test_with_role_check_without_guild(context): - context.guild = None - - assert not checks.with_role_check(context) - - -def test_with_role_check_with_guild_without_required_role(context): - context.guild = True - context.author.roles = [] - - assert not checks.with_role_check(context) - - -def test_with_role_check_with_guild_with_required_role(context): - context.guild = True - role = MagicMock() - role.id = 42 - context.author.roles = (role,) - - assert checks.with_role_check(context, role.id) - - -def test_without_role_check_without_guild(context): - context.guild = None - - assert not checks.without_role_check(context) - - -def test_without_role_check_with_unwanted_role(context): - context.guild = True - role = MagicMock() - role.id = 42 - context.author.roles = (role,) - - assert not checks.without_role_check(context, role.id) - - -def test_without_role_check_without_unwanted_role(context): - context.guild = True - role = MagicMock() - role.id = 42 - context.author.roles = (role,) - - assert checks.without_role_check(context, role.id + 10) - - -def test_in_channel_check_for_correct_channel(context): - context.channel.id = 42 - assert checks.in_channel_check(context, context.channel.id) - - -def test_in_channel_check_for_incorrect_channel(context): - context.channel.id = 42 - assert not checks.in_channel_check(context, context.channel.id + 10) -- cgit v1.2.3 From 70fb1315199d83f53d24b0772c940e66422d4cd4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Oct 2019 18:11:43 +0200 Subject: Add tests for tests.base I forgot to test some aspects of the `tests.base` module, including some branches of the `self.assertNotLogs` method. I've corrected that by including a couple of tests. I also removed the test result publishing from the Azure pipeline, since I've not configured an XML test runner yet. The coverage report is still published, of course and test output will be available in standard out, so information is readily available. --- azure-pipelines.yml | 7 ------- tests/base.py | 3 --- tests/test_base.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 3d0932398..15470f9be 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -43,13 +43,6 @@ jobs: codeCoverageTool: Cobertura summaryFileLocation: coverage.xml - - task: PublishTestResults@2 - displayName: 'Publish Test Results' - condition: succeededOrFailed() - inputs: - testResultsFiles: junit.xml - testRunTitle: 'Bot Test results' - - job: build displayName: 'Build & Push Container' dependsOn: 'test' diff --git a/tests/base.py b/tests/base.py index 625dcc0a8..029a249ed 100644 --- a/tests/base.py +++ b/tests/base.py @@ -12,9 +12,6 @@ class _CaptureLogHandler(logging.Handler): super().__init__() self.records = [] - def flush(self): - pass - def emit(self, record): self.records.append(record) diff --git a/tests/test_base.py b/tests/test_base.py index b7c1e0037..a16e2af8f 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -3,7 +3,7 @@ import unittest import unittest.mock -from tests.base import LoggingTestCase +from tests.base import LoggingTestCase, _CaptureLogHandler class LoggingTestCaseTests(unittest.TestCase): @@ -59,3 +59,33 @@ class LoggingTestCaseTests(unittest.TestCase): self.assertEqual(self.log.handlers, old_handlers) self.assertEqual(self.log.level, old_level) self.assertEqual(self.log.propagate, old_propagate) + + def test_logging_test_case_works_with_logger_instance(self): + """Test if the LoggingTestCase captures logging for provided logger.""" + log = logging.getLogger("new_logger") + with self.assertRaises(AssertionError): + with LoggingTestCase.assertNotLogs(self, logger=log): + log.info("Hello, this should raise an AssertionError") + + def test_logging_test_case_respects_alternative_logger(self): + """Test if LoggingTestCase only checks the provided logger.""" + log_one = logging.getLogger("log one") + log_two = logging.getLogger("log two") + with LoggingTestCase.assertNotLogs(self, logger=log_one): + log_two.info("Hello, this should not raise an AssertionError") + + def test_logging_test_case_respects_logging_level(self): + """Test if LoggingTestCase does not raise for a logging level lower than provided.""" + with LoggingTestCase.assertNotLogs(self, level=logging.CRITICAL): + self.log.info("Hello, this should raise an AssertionError") + + def test_capture_log_handler_default_initialization(self): + """Test if the _CaptureLogHandler is initialized properly.""" + handler = _CaptureLogHandler() + self.assertFalse(handler.records) + + def test_capture_log_handler_saves_record_on_emit(self): + """Test if the _CaptureLogHandler saves the log record when it's emitted.""" + handler = _CaptureLogHandler() + handler.emit("Log message") + self.assertIn("Log message", handler.records) -- cgit v1.2.3 From 6d9cb1ad99d064d8810feb553c6b0463c74c92d4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Oct 2019 19:54:47 +0200 Subject: Change pipeline testrunner to xmlrunner I have change the testrunner from `unittest` to `xmlrunner` in the Azure pipeline to be able to publish our test results on Azure. This is the same runner as `site` uses to generate XML reports. In addition, I've cleaned up some small mistakes in docstrings and `README.md`. --- .gitignore | 4 ++-- Pipfile | 1 + Pipfile.lock | 10 +++++++++- azure-pipelines.yml | 13 ++++++++++--- tests/README.md | 6 +++--- tests/helpers.py | 14 ++++++-------- 6 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 261fa179f..210847759 100644 --- a/.gitignore +++ b/.gitignore @@ -114,5 +114,5 @@ log.* # Custom user configuration config.yml -# JUnit XML reports from pytest -junit.xml +# xmlrunner unittest XML reports +TEST-**.xml diff --git a/Pipfile b/Pipfile index 0c73e4ca2..48d839fc3 100644 --- a/Pipfile +++ b/Pipfile @@ -32,6 +32,7 @@ flake8-tidy-imports = "~=2.0" flake8-todo = "~=0.7" pre-commit = "~=1.18" safety = "~=1.8" +unittest-xml-reporting = "~=2.5" dodgy = "~=0.1" [requires] diff --git a/Pipfile.lock b/Pipfile.lock index 366d1e525..95955ff89 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f5f32a03b561f1805f52447ca4e6582dd459581c5d581638925b7fabb09869f8" + "sha256": "c27d699b4aeeed204dee41f924f682ae2a670add8549a8826e58776594370582" }, "pipfile-spec": 6, "requires": { @@ -880,6 +880,14 @@ ], "version": "==1.4.0" }, + "unittest-xml-reporting": { + "hashes": [ + "sha256:140982e4b58e4052d9ecb775525b246a96bfc1fc26097806e05ea06e9166dd6c", + "sha256:d1fbc7a1b6c6680ccfe75b5e9701e5431c646970de049e687b4bb35ba4325d72" + ], + "index": "pypi", + "version": "==2.5.1" + }, "urllib3": { "hashes": [ "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 15470f9be..da3b06201 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -30,11 +30,11 @@ jobs: - script: python -m flake8 displayName: 'Run linter' - - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz coverage run -m unittest + - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz coverage run -m xmlrunner displayName: Run tests - - script: coverage xml -o coverage.xml - displayName: Create test coverage report + - script: coverage report -m && coverage xml -o coverage.xml + displayName: Generate test coverage report - task: PublishCodeCoverageResults@1 displayName: 'Publish Coverage Results' @@ -43,6 +43,13 @@ jobs: codeCoverageTool: Cobertura summaryFileLocation: coverage.xml + - task: PublishTestResults@2 + condition: succeededOrFailed() + displayName: 'Publish Test Results' + inputs: + testResultsFiles: '**/TEST-*.xml' + testRunTitle: 'Bot Test Results' + - job: build displayName: 'Build & Push Container' dependsOn: 'test' diff --git a/tests/README.md b/tests/README.md index 085ea39e0..471a00923 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,8 +1,8 @@ # Testing our Bot -Our bot is one of the most important tools we have to help us run our community. To make sure that tool doesn't break, we decided to start testing it using unit tests. It is our goal to provide it with 100% test coverage in the future. This guide will help you get started with writing the tests needed to achieve that. +Our bot is one of the most important tools we have to help us run our community. To make sure that tool doesn't break, we've decided to start writing unit tests for it. It is our goal to provide it with 100% test coverage in the future. This guide will help you get started with writing the tests needed to achieve that. -_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY). +_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY)._ ## Tools @@ -43,7 +43,7 @@ Since it's important to make sure all of our tests are independent from each oth By using the `subTest` context manager, we can perform multiple independent subtests within one test method (e.g., by having a loop for the various inputs we want to test). Using this context manager ensures the test will be run independently and that, if one of them fails, the rest of the subtests are still executed. (Normally, a test function stops once the first exception, including `AssertionError`, is raised.) -An example (taken from [`test_converters.py`])(/tests/bot/test_converters.py): +An example (taken from [`test_converters.py`](/tests/bot/test_converters.py)): ```py def test_tag_content_converter_for_valid(self): diff --git a/tests/helpers.py b/tests/helpers.py index 64fc04afe..18c9866bf 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -50,7 +50,7 @@ class HashableMixin(discord.mixins.EqualityComparable): class ColourMixin: - """A mixin of Mocks that provides the aliasing of color->colour like discord.py does.""" + """A mixin for Mocks that provides the aliasing of color->colour like discord.py does.""" @property def color(self) -> discord.Colour: @@ -159,14 +159,9 @@ class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): attribute_mocktype = unittest.mock.MagicMock - def __init__( - self, - name: str = "role", - role_id: int = 1, - position: int = 1, - **kwargs, - ) -> None: + def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) + self.name = name self.id = role_id self.position = position @@ -201,11 +196,14 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): **kwargs, ) -> None: super().__init__(spec=member_instance, **kwargs) + self.name = name self.id = user_id + self.roles = [MockRole("@everyone", 1)] if roles: self.roles.extend(roles) + self.mention = f"@{self.name}" self.send = AsyncMock() -- cgit v1.2.3 From 837e72920f3ac2daeeaf8710b21b42ac0120394f Mon Sep 17 00:00:00 2001 From: Ava Date: Fri, 11 Oct 2019 22:06:52 +0300 Subject: Small code review fixes --- bot/cogs/information.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index b3525c6f7..b6a3c4a40 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -6,8 +6,7 @@ import typing from typing import Any, Mapping, Optional import discord -from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel -from discord import Role, utils +from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils from discord.ext import commands from discord.ext.commands import Bot, Cog, Context, command, group @@ -290,7 +289,7 @@ class Information(Cog): add_content('Raw message', message.content) transformer = pprint.pformat if json else self.format_fields - for field_name in 'embeds attachments'.split(): + for field_name in ('embeds', 'attachments'): data = raw_data[field_name] if not data: -- cgit v1.2.3 From d6bedd16eb8fc81b2f0a17992ba000ed27fd7d72 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Oct 2019 23:05:01 +0200 Subject: Make textual changes to testing guide I've made some textual changes to the testing guidelines defined in README.md. --- tests/README.md | 54 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/tests/README.md b/tests/README.md index 471a00923..4ed32c29b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # Testing our Bot -Our bot is one of the most important tools we have to help us run our community. To make sure that tool doesn't break, we've decided to start writing unit tests for it. It is our goal to provide it with 100% test coverage in the future. This guide will help you get started with writing the tests needed to achieve that. +Our bot is one of the most important tools we have for running our community. As we don't want that tool tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. This guide will help you get started with writing the tests needed to achieve that. _**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY)._ @@ -12,38 +12,45 @@ We are using the following modules and packages for our unit tests: - [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) (standard library) - [coverage.py](https://coverage.readthedocs.io/en/stable/) -To ensure the results you obtain on your personal machine are comparable to those in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts: +To ensure the results you obtain on your personal machine are comparable to those generated in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts: -1. `pipenv run test` will run `unittest` with `coverage.py` -2. `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. +- `pipenv run test` will run `unittest` with `coverage.py` +- `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. -**Note:** If you want a coverage report, make sure to run the tests with `pipenv run test` *first*. +If you want a coverage report, make sure to run the tests with `pipenv run test` *first*. ## Writing tests -Since our bot is a collaborative, community project, it's important to take a couple of things into when writing tests. This ensures that our test suite is consistent and that everyone understands the tests someone else has written. +Since consistency is an important consideration for collaborative projects, we have written some guidelines on writing tests for the bot. In addition to these guidelines, it's a good idea to look at the existing code base for examples (e.g., [`test_converters.py`](/tests/bot/test_converters.py)). ### File and directory structure -To organize our test suite, we have chosen to mirror the directory structure of [`bot`](/bot/) in the [`tests`](/tests/) subdirectory. This makes it easy to find the relevant tests by providing a natural grouping of files. More general files, such as [`helpers.py`](/tests/helpers.py) are located directly in the `tests` subdirectory. +To organize our test suite, we have chosen to mirror the directory structure of [`bot`](/bot/) in the [`tests`](/tests/) subdirectory. This makes it easy to find the relevant tests by providing a natural grouping of files. More general testing files, such as [`helpers.py`](/tests/helpers.py) are located directly in the `tests` subdirectory. All files containing tests should have a filename starting with `test_` to make sure `unittest` will discover them. This prefix is typically followed by the name of the file the tests are written for. If needed, a test file can contain multiple test classes, both to provide structure and to be able to provide different fixtures/set-up methods for different groups of tests. -### Writing individual and independent tests +### Writing independent tests -When writing individual test methods, it is important to make sure that each test tests its own *independent* unit. In general, this means that you don't write one large test method that tests every possible branch/part/condition relevant to your function, but rather write separate test methods for each one. The reason is that this will make sure that if one test fails, the rest of the tests will still run and we feedback on exactly which parts are and which are not working correctly. (However, the [DRY-principle](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) also applies to tests, see the `Using self.subTest for independent subtests` section.) +When writing unit tests, it's really important to make sure each test that you write runs independently from all of the other tests. This both means that the code you write for one test shouldn't influence the result of another test and that if one tests fails, the other tests should still run. + +The basis for this is that when you write a test method, it should really only test a single aspect of the thing you're testing. This often means that you do not write one large test that tests "everything" that can be tested for a function, but rather that you write multiple smaller tests that each test a specific branch/path/condition of the function under scrutiny. + +To make sure you're not repeating the same set-up steps in all these smaller tests, `unittest` provides fixtures that are executed before and after each test is run. In addition to test fixtures, it also provides special set-up and clean-up methods that are run before the first test in a test class or after the last test of that class has been run. For more information, see the documentation for [`unittest.TestCase`](https://docs.python.org/3/library/unittest.html#unittest.TestCase). #### Method names and docstrings -It's very important that the output of failing tests is easy to understand. That's why it is important to give your test methods a descriptive name that tells us what the failing test was testing. In addition, since **single-line** docstrings will be printed in the output along with the method name, it's also important to add a good single-line docstring that summarizes the purpose of the test to your test method. +As you can probably imagine, writing smaller, independent tests also means that the number of tests will be large. This means that it is incredibly import to use good method names to identify what each test is doing. A general guideline is that the name should capture the goal of your test: What is this test method trying to assert? + +In addition to good method names, it's also really important to write a good *single-line* docstring. The `unittest` module will print such a single-line docstring along with the method name in the output it gives when a test fails. This means that a good docstring that really captures the purpose of the test makes it much easier to quickly make sense of output. #### Using self.subTest for independent subtests -Since it's important to make sure all of our tests are independent from each other, you may be tempted to copy-paste your test methods to test a function against a number of different input and output parameters. Luckily, `unittest` provides a better a better of doing that: [`subtests`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests). +Another thing that you will probably encounter is that you want to test a function against a list of input and output values. Given the section on writing independent tests, you may now be tempted to copy-paste the same test method over and over again, once for each unique value that you want to test. However, that would result in a lot of duplicate code that is hard to maintain. + -By using the `subTest` context manager, we can perform multiple independent subtests within one test method (e.g., by having a loop for the various inputs we want to test). Using this context manager ensures the test will be run independently and that, if one of them fails, the rest of the subtests are still executed. (Normally, a test function stops once the first exception, including `AssertionError`, is raised.) +Luckily, `unittest` provides a good alternative to that: the [`subTest`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests) context manager. It is often used in conjunction with a `for`-loop iterating of a collection of values you want to test your function against and it provides two important features. First, it will make sure that if an assertion statements fails on of the iterations, the other iterations are still run. The other important feature it provides is that it will distinguish your iterations from each other in the output. -An example (taken from [`test_converters.py`](/tests/bot/test_converters.py)): +This is an example of `TestCase.subTest` in action (taken from [`test_converters.py`](/tests/bot/test_converters.py)): ```py def test_tag_content_converter_for_valid(self): @@ -68,14 +75,19 @@ FAIL: test_tag_content_converter_for_valid (tests.bot.test_converters.ConverterT TagContentConverter should return correct values for valid input. ---------------------------------------------------------------------- -# Snipped to save vertical space +# ... ``` ## Mocking -Since we are testing independent "units" of code, we sometimes need to provide "fake" versions of objects generated by code external to the unit we are trying to test to make sure we're truly testing independently from other units of code. We call these "fake objects" mocks. We mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module to create these mock objects, but we also have a couple special mock types defined in [`helpers.py`](/tests/helpers.py). +As we are trying to test our "units" of code independently, we want to make sure that we do not rely objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or an external piece of code. -An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object and then assert if the `send` method of the mocked version was called with the right message content by the command function: + +However, the features that we are trying to test often depend on those objects generated by other pieces of code. It would be difficult to test a bot command, without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". + +To create these mock object, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section on the below.). + +An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object. This makes sure we can then check (_assert_) if the `send` method of the mocked Context object was called with the correct message content (without having to send a real message to the Discord API!): ```py import asyncio @@ -102,13 +114,13 @@ class BotCogTests(unittest.TestCase): ### Mocking coroutines -By default, `unittest.mock.Mock` and `unittest.mock.MagicMock` can't mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. +By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. ### Special mocks for some `discord.py` types To quote Ned Batchelder, Mock objects are "automatic chameleons". This means that they will happily allow the access to any attribute or method and provide a mocked value in return. One downside to this is that if the code you are testing gets the name of the attribute wrong, your mock object will not complain and the test may still pass. -In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist by default on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. +In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. These special mocks are added when they are needed, so if you think it would be sensible to add another one, feel free to propose one in your PR. @@ -193,8 +205,10 @@ FAILED (errors=1) What's more, even if the spelling mistake would not have been there, the first test did not test if the `member_information` function formatted the `member.join` according to the output we actually want to see. +All in all, it's not only important to consider if all statements or branches were touched at least once with a test, but also if they are extensively tested in all situations that may happen in production. + ### Unit Testing vs Integration Testing -Another restriction of unit testing is that it tests in, well, units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we work under the implicit assumption of understanding and utilizing those external objects correctly. So, in addition to testing the parts separately, we also need to test if the parts integrate correctly into a single application. +Another restriction of unit testing is that it tests, well, in units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we are work under the implicit assumption that we fully understand those external parts and utilize it correctly. What if our mocked `Context` object works with a `send` method, but `discord.py` has changed it to a `send_message` method in a recent update? It could mean our tests are passing, but the code it's testing still doesn't work in production. -We currently have no automated integration tests or functional tests, so **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written. +The answer to this is that we also need to make sure that the individual parts come together into a working application. In addition, we will also need to make sure that the application communicates correctly with external applications. Since we currently have no automated integration tests or functional tests, that means **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written. -- cgit v1.2.3 From 2d938c610f42b62de78a26a186e6ffb5ff6e624a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Oct 2019 23:16:28 +0200 Subject: Update README.md --- tests/README.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/README.md b/tests/README.md index 4ed32c29b..6ab9bc93e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # Testing our Bot -Our bot is one of the most important tools we have for running our community. As we don't want that tool tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. This guide will help you get started with writing the tests needed to achieve that. +Our bot is one of the most important tools we have for running our community. As we don't want that tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. This guide will help you get started with writing the tests needed to achieve that. _**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY)._ @@ -31,7 +31,7 @@ All files containing tests should have a filename starting with `test_` to make ### Writing independent tests -When writing unit tests, it's really important to make sure each test that you write runs independently from all of the other tests. This both means that the code you write for one test shouldn't influence the result of another test and that if one tests fails, the other tests should still run. +When writing unit tests, it's really important to make sure that each test that you write runs independently from all of the other tests. This both means that the code you write for one test shouldn't influence the result of another test and that if one tests fails, the other tests should still run. The basis for this is that when you write a test method, it should really only test a single aspect of the thing you're testing. This often means that you do not write one large test that tests "everything" that can be tested for a function, but rather that you write multiple smaller tests that each test a specific branch/path/condition of the function under scrutiny. @@ -39,7 +39,7 @@ To make sure you're not repeating the same set-up steps in all these smaller tes #### Method names and docstrings -As you can probably imagine, writing smaller, independent tests also means that the number of tests will be large. This means that it is incredibly import to use good method names to identify what each test is doing. A general guideline is that the name should capture the goal of your test: What is this test method trying to assert? +As you can probably imagine, writing smaller, independent tests also results in a large number of tests. To make sure that it's easy to see which test does what, it is incredibly important to use good method names to identify what each test is doing. A general guideline is that the name should capture the goal of your test: What is this test method trying to assert? In addition to good method names, it's also really important to write a good *single-line* docstring. The `unittest` module will print such a single-line docstring along with the method name in the output it gives when a test fails. This means that a good docstring that really captures the purpose of the test makes it much easier to quickly make sense of output. @@ -47,8 +47,7 @@ In addition to good method names, it's also really important to write a good *si Another thing that you will probably encounter is that you want to test a function against a list of input and output values. Given the section on writing independent tests, you may now be tempted to copy-paste the same test method over and over again, once for each unique value that you want to test. However, that would result in a lot of duplicate code that is hard to maintain. - -Luckily, `unittest` provides a good alternative to that: the [`subTest`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests) context manager. It is often used in conjunction with a `for`-loop iterating of a collection of values you want to test your function against and it provides two important features. First, it will make sure that if an assertion statements fails on of the iterations, the other iterations are still run. The other important feature it provides is that it will distinguish your iterations from each other in the output. +Luckily, `unittest` provides a good alternative to that: the [`subTest`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests) context manager. This method is often used in conjunction with a `for`-loop iterating of a collection of values that we want to test a function against and it provides two important features. First, it will make sure that if an assertion statements fails on one of the iterations, the other iterations are still run. The other important feature it provides is that it will distinguish the iterations from each other in the output. This is an example of `TestCase.subTest` in action (taken from [`test_converters.py`](/tests/bot/test_converters.py)): @@ -80,12 +79,12 @@ TagContentConverter should return correct values for valid input. ## Mocking -As we are trying to test our "units" of code independently, we want to make sure that we do not rely objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or an external piece of code. +As we are trying to test our "units" of code independently, we want to make sure that we do not rely objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or something external to it. -However, the features that we are trying to test often depend on those objects generated by other pieces of code. It would be difficult to test a bot command, without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". +However, the features that we are trying to test often depend on those objects generated by external pieces of code. It would be difficult to test a bot command without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". -To create these mock object, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section on the below.). +To create these mock object, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we have also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section on the below.). An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object. This makes sure we can then check (_assert_) if the `send` method of the mocked Context object was called with the correct message content (without having to send a real message to the Discord API!): -- cgit v1.2.3 From d3edb192d286d420a31d8d6a137c58dae97b3e91 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 12 Oct 2019 12:59:36 +1100 Subject: Use `bot.utils.humanize_delta`, tidy bot response, remove stray f from f-string --- bot/cogs/utils.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 117bff373..9306c8986 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -6,11 +6,13 @@ from email.parser import HeaderParser from io import StringIO from typing import Tuple +from dateutil import relativedelta from discord import Colour, Embed, Message, Role from discord.ext.commands import Bot, Cog, Context, command from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES from bot.decorators import in_channel, with_role +from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -129,18 +131,6 @@ class Utils(Cog): await ctx.send(embed=embed) - @staticmethod - def readable_time(seconds: int) -> str: - """Returns a number of seconds into a human-readable minutes/seconds combination.""" - minutes, seconds = divmod(seconds, 60) - - if minutes: - fmt = '{m}min {s}sec' - else: - fmt = '{s}sec' - - return fmt.format(m=minutes, s=seconds) - @command() @with_role(*MODERATION_ROLES) async def mention(self, ctx: Context, *, role: Role) -> None: @@ -151,9 +141,9 @@ class Utils(Cog): await role.edit(reason=f"Role unlocked by {ctx.author}", mentionable=True) + human_time = humanize_delta(relativedelta.relativedelta(seconds=Mention.message_timeout)) await ctx.send( - f"{role} has been made mentionable. I will reset it in " - f"{self.readable_time(Mention.message_timeout)} seconds, or when a staff member mentions this role." + f"{role} has been made mentionable. I will reset it in {human_time}, or when someone mentions this role." ) def check(m: Message) -> bool: @@ -163,7 +153,7 @@ class Utils(Cog): try: msg = await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout) except TimeoutError: - await role.edit(mentionable=False, reason=f"Automatic role lock - timeout.") + await role.edit(mentionable=False, reason="Automatic role lock - timeout.") await ctx.send(f"{ctx.author.mention}, you took too long. I have reset {role} to be unmentionable.") return -- cgit v1.2.3 From d05bf261fd5ed1c8051e904d807a25641742fe78 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sat, 12 Oct 2019 10:31:47 +0800 Subject: Utilize __str__ of discord.Member in logging and output --- bot/cogs/antispam.py | 2 +- bot/cogs/defcon.py | 5 ++--- bot/cogs/doc.py | 3 +-- bot/cogs/filtering.py | 2 +- bot/cogs/modlog.py | 30 +++++++++++++++--------------- bot/cogs/off_topic_names.py | 22 ++++++++-------------- bot/cogs/snekbox.py | 12 +++--------- bot/cogs/superstarify/__init__.py | 4 ++-- 8 files changed, 33 insertions(+), 47 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 8dfa0ad05..dac00c1a3 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -59,7 +59,7 @@ class DeletionContext: async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: """Method that takes care of uploading the queue and posting modlog alert.""" - triggered_by_users = ", ".join(f"{m.display_name}#{m.discriminator} (`{m.id}`)" for m in self.members.values()) + triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) mod_alert_message = ( f"**Triggered by:** {triggered_by_users}\n" diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 048d8a683..f3740b276 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -88,8 +88,7 @@ class Defcon(Cog): await member.kick(reason="DEFCON active, user is too new") message = ( - f"{member.name}#{member.discriminator} (`{member.id}`) " - f"was denied entry because their account is too new." + f"{member} (`{member.id}`) was denied entry because their account is too new." ) if not message_sent: @@ -252,7 +251,7 @@ class Defcon(Cog): `change` string may be one of the following: ('enabled', 'disabled', 'updated') """ - log_msg = f"**Staffer:** {actor.name}#{actor.discriminator} (`{actor.id}`)\n" + log_msg = f"**Staffer:** {actor} (`{actor.id}`)\n" if change.lower() == "enabled": icon = Icons.defcon_enabled diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index c9e6b3b91..d5a14dbc8 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -331,8 +331,7 @@ class Doc(commands.Cog): await self.bot.api_client.post('bot/documentation-links', json=body) log.info( - f"User @{ctx.author.name}#{ctx.author.discriminator} ({ctx.author.id}) " - "added a new documentation package:\n" + f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n" f"Package name: {package_name}\n" f"Base url: {base_url}\n" f"Inventory URL: {inventory_url}" diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index bd8c6ed67..095ddc185 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -186,7 +186,7 @@ class Filtering(Cog): message = ( f"The {filter_name} {_filter['type']} was triggered " - f"by **{msg.author.name}#{msg.author.discriminator}** " + f"by **{msg.author}** " f"(`{msg.author.id}`) {channel_str} with [the " f"following message]({msg.jump_url}):\n\n" f"{msg.content}" diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 68424d268..79787dc95 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -367,7 +367,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.user_ban, Colour(Colours.soft_red), - "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)", + "User banned", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.modlog ) @@ -378,7 +378,7 @@ class ModLog(Cog, name="ModLog"): if member.guild.id != GuildConstant.id: return - message = f"{member.name}#{member.discriminator} (`{member.id}`)" + message = f"{member} (`{member.id}`)" now = datetime.utcnow() difference = abs(relativedelta(now, member.created_at)) @@ -406,7 +406,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.sign_out, Colour(Colours.soft_red), - "User left", f"{member.name}#{member.discriminator} (`{member.id}`)", + "User left", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.userlog ) @@ -423,7 +423,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.user_unban, Colour.blurple(), - "User unbanned", f"{member.name}#{member.discriminator} (`{member.id}`)", + "User unbanned", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.modlog ) @@ -510,7 +510,7 @@ class ModLog(Cog, name="ModLog"): for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" - message = f"**{after.name}#{after.discriminator}** (`{after.id}`)\n{message}" + message = f"**{after}** (`{after.id}`)\n{message}" await self.send_log_message( Icons.user_update, Colour.blurple(), @@ -539,14 +539,14 @@ class ModLog(Cog, name="ModLog"): if channel.category: response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" ) else: response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -637,7 +637,7 @@ class ModLog(Cog, name="ModLog"): if channel.category: before_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{before.id}`\n" "\n" @@ -645,7 +645,7 @@ class ModLog(Cog, name="ModLog"): ) after_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{before.id}`\n" "\n" @@ -653,7 +653,7 @@ class ModLog(Cog, name="ModLog"): ) else: before_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{before.id}`\n" "\n" @@ -661,7 +661,7 @@ class ModLog(Cog, name="ModLog"): ) after_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{before.id}`\n" "\n" @@ -720,7 +720,7 @@ class ModLog(Cog, name="ModLog"): if channel.category: before_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -728,7 +728,7 @@ class ModLog(Cog, name="ModLog"): ) after_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -736,7 +736,7 @@ class ModLog(Cog, name="ModLog"): ) else: before_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -744,7 +744,7 @@ class ModLog(Cog, name="ModLog"): ) after_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 8cde22240..bbd592529 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -110,12 +110,11 @@ class OffTopicNames(Cog): if close_match: match = close_match[0] log.info( - f"{ctx.author.name}#{ctx.author.discriminator}" - f" tried to add channel name '{name}' but it was too similar to '{match}'" + f"{ctx.author} tried to add channel name '{name}' but it was too similar to '{match}'" ) await ctx.send( f":x: The channel name `{name}` is too similar to `{match}`, and thus was not added. " - f"Use `!otn forceadd` to override this check." + "Use `!otn forceadd` to override this check." ) else: await self._add_name(ctx, name) @@ -130,11 +129,9 @@ class OffTopicNames(Cog): async def _add_name(self, ctx: Context, name: str) -> None: """Adds an off-topic channel name to the site storage.""" - await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name}) - log.info( - f"{ctx.author.name}#{ctx.author.discriminator}" - f" added the off-topic channel name '{name}'" - ) + await self.bot.api_client.post('bot/off-topic-channel-names', params={'name': name}) + + log.info(f"{ctx.author} added the off-topic channel name '{name}'") await ctx.send(f":ok_hand: Added `{name}` to the names list.") @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) @@ -143,12 +140,9 @@ class OffTopicNames(Cog): """Removes a off-topic name from the rotation.""" # Chain multiple words to a single one name = "-".join(names) - await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') - log.info( - f"{ctx.author.name}#{ctx.author.discriminator}" - f" deleted the off-topic channel name '{name}" - ) + + log.info(f"{ctx.author} deleted the off-topic channel name '{name}'") await ctx.send(f":ok_hand: Removed `{name}` from the names list.") @otname_group.command(name='list', aliases=('l',)) @@ -180,7 +174,7 @@ class OffTopicNames(Cog): close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70) lines = sorted(f"• {name}" for name in in_matches.union(close_matches)) embed = Embed( - title=f"Query results", + title="Query results", colour=Colour.blue() ) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 81185cf3e..c0390cb1e 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -178,7 +178,7 @@ class Snekbox(Cog): if ctx.author.id in self.jobs: await ctx.send( f"{ctx.author.mention} You've already got a job running - " - f"please wait for it to finish!" + "please wait for it to finish!" ) return @@ -186,10 +186,7 @@ class Snekbox(Cog): await ctx.invoke(self.bot.get_command("help"), "eval") return - log.info( - f"Received code from {ctx.author.name}#{ctx.author.discriminator} " - f"for evaluation:\n{code}" - ) + 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) @@ -213,10 +210,7 @@ class Snekbox(Cog): wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) ) - log.info( - f"{ctx.author.name}#{ctx.author.discriminator}'s job had a return code of " - f"{results['returncode']}" - ) + log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") finally: del self.jobs[ctx.author.id] diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index 87021eded..b36f70404 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -132,7 +132,7 @@ class Superstarify(Cog): # Log to the mod_log channel log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") mod_log_message = ( - f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" + f"**{member}** (`{member.id}`)\n\n" f"Superstarified member potentially tried to escape the prison.\n" f"Restored enforced nickname: `{forced_nick}`\n" f"Superstardom ends: **{end_timestamp_human}**" @@ -192,7 +192,7 @@ class Superstarify(Cog): # Log to the mod_log channel log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") mod_log_message = ( - f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" + f"**{member}** (`{member.id}`)\n\n" f"Superstarified by **{ctx.author.name}**\n" f"Old nickname: `{member.display_name}`\n" f"New nickname: `{forced_nick}`\n" -- cgit v1.2.3 From 8a83d68e370d479072846e669be7b73c242e1d96 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 12:39:56 +0200 Subject: Move the `sync` cog tests to `unittest`. --- tests/cogs/__init__.py | 0 tests/cogs/sync/__init__.py | 0 tests/cogs/sync/test_roles.py | 126 ++++++++++++++++++++++++++++++++++++++++++ tests/cogs/sync/test_users.py | 84 ++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/sync/__init__.py create mode 100644 tests/cogs/sync/test_roles.py create mode 100644 tests/cogs/sync/test_users.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py new file mode 100644 index 000000000..27ae27639 --- /dev/null +++ b/tests/cogs/sync/test_roles.py @@ -0,0 +1,126 @@ +import unittest + +from bot.cogs.sync.syncers import Role, get_roles_for_sync + + +class GetRolesForSyncTests(unittest.TestCase): + """Tests constructing the roles to synchronize with the site.""" + + 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)} + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + (set(), set(), set()) + ) + + 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)} + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + (set(), guild_roles, set()) + ) + + 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) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + set(), + {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, + set(), + ) + ) + + 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) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, + set(), + set(), + ) + ) + + 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_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), + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + set(), + set(), + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + ) + + 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), + } + + 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)}, + ) + ) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py new file mode 100644 index 000000000..ccaf67490 --- /dev/null +++ b/tests/cogs/sync/test_users.py @@ -0,0 +1,84 @@ +import unittest + +from bot.cogs.sync.syncers import User, get_users_for_sync + + +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) + + +class GetUsersForSyncTests(unittest.TestCase): + """Tests constructing the users to synchronize with the site.""" + + 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_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()} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), set()) + ) + + 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')} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), {fake_user(name='new fancy name')}) + ) + + 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)} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + ({fake_user(id=63)}, set()) + ) + + 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`.""" + api_users = {43: fake_user(), 63: fake_user(id=63)} + guild_users = {43: fake_user()} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), {fake_user(id=63, in_guild=False)}) + ) + + 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)} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + ({fake_user(id=63)}, {fake_user(in_guild=False)}) + ) + + 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()) + ) -- cgit v1.2.3 From 42abcc5b3c3cc2846a7be8b0e2f5549d820e196e Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 12:44:16 +0200 Subject: Move `tests.test_resources` to `unittest`. --- tests/test_resources.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/test_resources.py diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 000000000..2fc36c697 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,16 @@ +import json +import unittest +from pathlib import Path + + +class ResourceValidationTests(unittest.TestCase): + """Validates resources used by the bot.""" + def test_stars_valid(self): + """The resource `bot/resources/stars.json` should contain a list of strings.""" + path = Path('bot', 'resources', 'stars.json') + content = path.read_text() + data = json.loads(content) + + self.assertIsInstance(data, list) + for name in data: + self.assertIsInstance(name, str) -- cgit v1.2.3 From e1c4f0819ba94e88baea4f0de4ff7edb9b9cf2ca Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 12:48:18 +0200 Subject: Move `tests.test_pagination` to `unittest`. --- tests/test_pagination.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_pagination.py diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 000000000..0a734b505 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,36 @@ +from unittest import TestCase + +from bot import pagination + + +class LinePaginatorTests(TestCase): + """Tests functionality of the `LinePaginator`.""" + + def setUp(self): + """Create a paginator for the test method.""" + self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) + + def test_add_line_raises_on_too_long_lines(self): + """`add_line` should raise a `RuntimeError` for too long lines.""" + message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" + with self.assertRaises(RuntimeError, msg=message): + self.paginator.add_line('x' * self.paginator.max_size) + + def test_add_line_works_on_small_lines(self): + """`add_line` should allow small lines to be added.""" + self.paginator.add_line('x' * (self.paginator.max_size - 3)) + + +class ImagePaginatorTests(TestCase): + """Tests functionality of the `ImagePaginator`.""" + + def setUp(self): + """Create a paginator for the test method.""" + self.paginator = pagination.ImagePaginator() + + def test_add_image_appends_image(self): + """`add_image` appends the image to the image list.""" + image = 'lemon' + self.paginator.add_image(image) + + assert self.paginator.images == [image] -- cgit v1.2.3 From 4f225508b7c1c0c5ea02f9788c9495e7edf4414c Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 12:54:32 +0200 Subject: Move the `rules.attachments` module tests to `unittest`. --- tests/rules/__init__.py | 0 tests/rules/test_attachments.py | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/rules/__init__.py create mode 100644 tests/rules/test_attachments.py diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py new file mode 100644 index 000000000..4bb0acf7c --- /dev/null +++ b/tests/rules/test_attachments.py @@ -0,0 +1,52 @@ +import asyncio +import unittest +from dataclasses import dataclass +from typing import Any, List + +from bot.rules import attachments + + +# Using `MagicMock` sadly doesn't work for this usecase +# since it's __eq__ compares the MagicMock's ID. We just +# want to compare the actual attributes we set. +@dataclass +class FakeMessage: + author: str + attachments: List[Any] + + +def msg(total_attachments: int) -> FakeMessage: + return FakeMessage(author='lemon', attachments=list(range(total_attachments))) + + +class AttachmentRuleTests(unittest.TestCase): + """Tests applying the `attachment` antispam rule.""" + + def test_allows_messages_without_too_many_attachments(self): + """Messages without too many attachments are allowed as-is.""" + cases = ( + (msg(0), msg(0), msg(0)), + (msg(2), msg(2)), + (msg(0),), + ) + + for last_message, *recent_messages in cases: + with self.subTest(last_message=last_message, recent_messages=recent_messages): + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + self.assertIsNone(asyncio.run(coro)) + + def test_disallows_messages_with_too_many_attachments(self): + """Messages with too many attachments trigger the rule.""" + cases = ( + ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), + ((msg(6),), [msg(6)], 6), + ((msg(1),) * 6, [msg(1)] * 6, 6), + ) + for messages, relevant_messages, total in cases: + with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): + last_message, *recent_messages = messages + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + self.assertEqual( + asyncio.run(coro), + (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) + ) -- cgit v1.2.3 From 6d3af7ccbc8a0ff0d9657c562707281a3a7c2ee2 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 14:16:20 +0200 Subject: Move the `antispam` cog tests to `unittest`. --- tests/cogs/__init__.py | 0 tests/cogs/test_antispam.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/test_antispam.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py new file mode 100644 index 000000000..ce5472c71 --- /dev/null +++ b/tests/cogs/test_antispam.py @@ -0,0 +1,35 @@ +import unittest + +from bot.cogs import antispam + + +class AntispamConfigurationValidationTests(unittest.TestCase): + """Tests validation of the antispam cog configuration.""" + + def test_default_antispam_config_is_valid(self): + """The default antispam configuration is valid.""" + validation_errors = antispam.validate_config() + self.assertEqual(validation_errors, {}) + + def test_unknown_rule_returns_error(self): + """Configuring an unknown rule returns an error.""" + self.assertEqual( + antispam.validate_config({'invalid-rule': {}}), + {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} + ) + + def test_missing_keys_returns_error(self): + """Not configuring required keys returns an error.""" + keys = (('interval', 'max'), ('max', 'interval')) + for configured_key, unconfigured_key in keys: + with self.subTest( + configured_key=configured_key, + unconfigured_key=unconfigured_key + ): + config = {'burst': {configured_key: 10}} + error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" + + self.assertEqual( + antispam.validate_config(config), + {'burst': error} + ) -- cgit v1.2.3 From 63fad8b9a23d83539d8d17fc711883215f932db5 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 14:26:41 +0200 Subject: Move the `security` cog tests to `unittest`. --- tests/cogs/__init__.py | 0 tests/cogs/test_security.py | 59 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/test_security.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py new file mode 100644 index 000000000..6c646ae70 --- /dev/null +++ b/tests/cogs/test_security.py @@ -0,0 +1,59 @@ +import logging +import unittest +from unittest.mock import MagicMock + +from discord.ext.commands import NoPrivateMessage + +from bot.cogs import security + + +class SecurityCogTests(unittest.TestCase): + """Tests the `Security` cog.""" + + def setUp(self): + """Attach an instance of the cog to the class for tests.""" + self.bot = MagicMock() + self.cog = security.Security(self.bot) + self.ctx = MagicMock() + self.ctx.author = MagicMock() + + def test_check_additions(self): + """The cog should add its checks after initialization.""" + self.bot.check.assert_any_call(self.cog.check_on_guild) + self.bot.check.assert_any_call(self.cog.check_not_bot) + + def test_check_not_bot_returns_false_for_humans(self): + """The bot check should return `True` when invoked with human authors.""" + self.ctx.author.bot = False + self.assertTrue(self.cog.check_not_bot(self.ctx)) + + def test_check_not_bot_returns_true_for_robots(self): + """The bot check should return `False` when invoked with robotic authors.""" + self.ctx.author.bot = True + self.assertFalse(self.cog.check_not_bot(self.ctx)) + + def test_check_on_guild_raises_when_outside_of_guild(self): + """When invoked outside of a guild, `check_on_guild` should cause an error.""" + self.ctx.guild = None + + with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): + self.cog.check_on_guild(self.ctx) + + def test_check_on_guild_returns_true_inside_of_guild(self): + """When invoked inside of a guild, `check_on_guild` should return `True`.""" + self.ctx.guild = "lemon's lemonade stand" + self.assertTrue(self.cog.check_on_guild(self.ctx)) + + +class SecurityCogLoadTests(unittest.TestCase): + """Tests loading the `Security` cog.""" + + def test_security_cog_load(self): + """Cog loading logs a message at `INFO` level.""" + bot = MagicMock() + with self.assertLogs(logger='bot.cogs.security', level=logging.INFO) as cm: + security.setup(bot) + bot.add_cog.assert_called_once() + + [line] = cm.output + self.assertIn("Cog loaded: Security", line) -- cgit v1.2.3 From aa0096469e12546b0eadbb4b214cd3cae3a3a80d Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Sat, 12 Oct 2019 14:54:58 +0200 Subject: Use a `classmethod` --- bot/cogs/bot.py | 2 +- bot/cogs/token_remover.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index eab253681..53221cd8b 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -238,7 +238,7 @@ class Bot(Cog): and len(msg.content.splitlines()) > 3 ) - if parse_codeblock and not TokenRemover.is_token_in_message(TokenRemover, msg): # no token in the msg + if parse_codeblock and not TokenRemover.is_token_in_message(msg): # no token in the msg on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 if not on_cooldown or DEBUG_MODE: try: diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 8f356cf19..5e83a777e 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -75,6 +75,7 @@ class TokenRemover(Cog): channel_id=Channels.mod_alerts, ) + @classmethod def is_token_in_message(self, msg: Message) -> bool: """Check if `msg` contains a seemly valid token.""" if msg.author.bot: -- cgit v1.2.3 -- cgit v1.2.3 From 4f406f335d87d05c2f2287af35556ce16584991b Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Sat, 12 Oct 2019 22:25:01 +0200 Subject: Compact `!free` output --- bot/cogs/free.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/bot/cogs/free.py b/bot/cogs/free.py index 269c5c1b9..afd54671a 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -72,30 +72,32 @@ class Free(Cog): # Display all potentially inactive channels # in descending order of inactivity if free_channels: - embed.description += "**The following channel{0} look{1} free:**\n\n**".format( - 's' if len(free_channels) > 1 else '', - '' if len(free_channels) > 1 else 's' - ) - # Sort channels in descending order by seconds # Get position in list, inactivity, and channel object # For each channel, add to embed.description sorted_channels = sorted(free_channels, key=itemgetter(0), reverse=True) + if len(sorted_channels) > 3: # display 3 channels max + sorted_channels = sorted_channels[:3] + for i, (inactive, channel) in enumerate(sorted_channels, 1): minutes, seconds = divmod(inactive, 60) if minutes > 59: hours, minutes = divmod(minutes, 60) - embed.description += f"{i}. {channel.mention} inactive for {hours}h{minutes}m{seconds}s\n\n" + embed.description += f"{i}. {channel.mention} **{hours}h {minutes}m {seconds}s** inactive\n" else: - embed.description += f"{i}. {channel.mention} inactive for {minutes}m{seconds}s\n\n" + embed.description += f"{i}. {channel.mention} **{minutes}m {seconds}s** inactive\n" - embed.description += ("**\nThese channels aren't guaranteed to be free, " - "so use your best judgement and check for yourself.") + embed.description += ( + "Please confirm these channels " + "are free before posting" + ) else: - embed.description = ("**Doesn't look like any channels are available right now. " - "You're welcome to check for yourself to be sure. " - "If all channels are truly busy, please be patient " - "as one will likely be available soon.**") + embed.description = ( + "**Doesn't look like any channels are available right now. " + "You're welcome to check for yourself to be sure. " + "If all channels are truly busy, please be patient " + "as one will likely be available soon.**" + ) await ctx.send(embed=embed) -- cgit v1.2.3 From 0fc5a123a50a5433a60fb30c000acf146fc07650 Mon Sep 17 00:00:00 2001 From: bendiller Date: Sat, 12 Oct 2019 15:13:55 -0600 Subject: Create barebones Antimalware cog and config - detects bad file extensions --- bot/cogs/antimalware.py | 43 +++++++++++++++++++++++++++++++++++++++++++ bot/constants.py | 6 ++++++ config-default.yml | 6 ++++++ 3 files changed, 55 insertions(+) create mode 100644 bot/cogs/antimalware.py diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py new file mode 100644 index 000000000..e4688295e --- /dev/null +++ b/bot/cogs/antimalware.py @@ -0,0 +1,43 @@ +import logging + +from discord import Message, utils +from discord.ext.commands import Bot, Cog + +from bot.constants import AntiMalware as AntiMalwareConfig, Channels + +log = logging.getLogger(__name__) + + +class AntiMalware(Cog): + """Cog providing anti-malware behavior.""" + def __init__(self, bot: Bot): + self.bot = bot + self.whitelist = tuple(AntiMalwareConfig.whitelist) + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Identify messages with prohibited attachments.""" + log.trace("Entered AntiMalware.on_message()") + rejected_attachments = [a for a in message.attachments if + not a.filename.lower().endswith(self.whitelist)] + detected_pyfile = len([a for a in message.attachments if a.filename.lower().endswith('.py')]) > 0 + + if len(rejected_attachments) > 0: + log.trace("Identified rejected attachment(s)") + # Send a message indicating the problem to the user (with special treatment for .py) + author = message.author + if detected_pyfile: + msg = f"{author.mention}, it looks like you tried to attach a Python file - please " \ + f"use a code-pasting service such as https://paste.pythondiscord.com/ instead." + else: + meta_channel = utils.get(message.guild.channels, id=Channels.meta) + msg = f"{author.mention}, it looks like you tried to attach a file type we don't " \ + f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake." + + await message.channel.send(msg) + + +def setup(bot: Bot) -> None: + """AntiMalware cog load.""" + bot.add_cog(AntiMalware(bot)) + log.info("Cog loaded: AntiMalware") diff --git a/bot/constants.py b/bot/constants.py index 1deeaa3b8..81f316d57 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -460,6 +460,12 @@ class AntiSpam(metaclass=YAMLGetter): rules: Dict[str, Dict[str, int]] +class AntiMalware(metaclass=YAMLGetter): + section = "anti_malware" + + whitelist: tuple + + class BigBrother(metaclass=YAMLGetter): section = 'big_brother' diff --git a/config-default.yml b/config-default.yml index 0dac9bf9f..30d505d6d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -107,6 +107,7 @@ guild: help_7: 587375768556797982 helpers: 385474242440986624 message_log: &MESSAGE_LOG 467752170159079424 + meta: 429409067623251969 mod_alerts: 473092532147060736 modlog: &MODLOG 282638479504965634 off_topic_0: 291284109232308226 @@ -322,6 +323,11 @@ anti_spam: max: 3 +anti_malware: + whitelist: ['.bmp', '.gif', '.jpg', '.jpeg', '.png', '.tiff', # Images + '.3gp', '.3g2', '.avi', '.h264', '.m4v', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.wmv' ] # Videos + + reddit: request_delay: 60 subreddits: -- cgit v1.2.3 From b7afb7979cb2a63869ef5cfa03b325a0be269ddb Mon Sep 17 00:00:00 2001 From: bendiller Date: Sat, 12 Oct 2019 17:01:53 -0600 Subject: Implement message deletion --- bot/__main__.py | 1 + bot/cogs/antimalware.py | 17 +++++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index 19a7e5ec6..f352cd60e 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -39,6 +39,7 @@ bot.load_extension("bot.cogs.logging") bot.load_extension("bot.cogs.security") # Commands, etc +bot.load_extension("bot.cogs.antimalware") bot.load_extension("bot.cogs.antispam") bot.load_extension("bot.cogs.bot") bot.load_extension("bot.cogs.clean") diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index e4688295e..94566c156 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -1,6 +1,6 @@ import logging -from discord import Message, utils +from discord import Message, NotFound from discord.ext.commands import Bot, Cog from bot.constants import AntiMalware as AntiMalwareConfig, Channels @@ -10,6 +10,7 @@ log = logging.getLogger(__name__) class AntiMalware(Cog): """Cog providing anti-malware behavior.""" + def __init__(self, bot: Bot): self.bot = bot self.whitelist = tuple(AntiMalwareConfig.whitelist) @@ -17,27 +18,31 @@ class AntiMalware(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Identify messages with prohibited attachments.""" - log.trace("Entered AntiMalware.on_message()") rejected_attachments = [a for a in message.attachments if not a.filename.lower().endswith(self.whitelist)] detected_pyfile = len([a for a in message.attachments if a.filename.lower().endswith('.py')]) > 0 if len(rejected_attachments) > 0: - log.trace("Identified rejected attachment(s)") - # Send a message indicating the problem to the user (with special treatment for .py) + # Send a message to the user indicating the problem (with special treatment for .py) author = message.author if detected_pyfile: msg = f"{author.mention}, it looks like you tried to attach a Python file - please " \ f"use a code-pasting service such as https://paste.pythondiscord.com/ instead." else: - meta_channel = utils.get(message.guild.channels, id=Channels.meta) + meta_channel = self.bot.get_channel(Channels.meta) msg = f"{author.mention}, it looks like you tried to attach a file type we don't " \ f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake." await message.channel.send(msg) + # Delete the offending message: + try: + await message.delete() + except NotFound: + log.info(f"Tried to delete message `{message.id}`, but message could not be found.") + def setup(bot: Bot) -> None: - """AntiMalware cog load.""" + """Antimalware cog load.""" bot.add_cog(AntiMalware(bot)) log.info("Cog loaded: AntiMalware") -- cgit v1.2.3 From feb08b23a4992dab67161b2dd956bb92ef1f04ea Mon Sep 17 00:00:00 2001 From: bendiller Date: Sat, 12 Oct 2019 17:34:29 -0600 Subject: Fix Constants.AntiMalware.whitelist type --- bot/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 81f316d57..aecd6be59 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -463,7 +463,7 @@ class AntiSpam(metaclass=YAMLGetter): class AntiMalware(metaclass=YAMLGetter): section = "anti_malware" - whitelist: tuple + whitelist: list class BigBrother(metaclass=YAMLGetter): -- cgit v1.2.3 From 776861636dbd180b8ad0bcc9540d935afaf2b873 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 13 Oct 2019 11:36:06 +0200 Subject: Add subTest + move test_resource to resources subdir I've added a `self.subTest` to the `name` loop so we still test and get output for all names in the list if one of them fails the test. In addition, I've moved it to the `tests/bot/resources` subdirectory. --- tests/bot/resources/test_resources.py | 17 +++++++++++++++++ tests/test_resources.py | 16 ---------------- 2 files changed, 17 insertions(+), 16 deletions(-) create mode 100644 tests/bot/resources/test_resources.py delete mode 100644 tests/test_resources.py diff --git a/tests/bot/resources/test_resources.py b/tests/bot/resources/test_resources.py new file mode 100644 index 000000000..73937cfa6 --- /dev/null +++ b/tests/bot/resources/test_resources.py @@ -0,0 +1,17 @@ +import json +import unittest +from pathlib import Path + + +class ResourceValidationTests(unittest.TestCase): + """Validates resources used by the bot.""" + def test_stars_valid(self): + """The resource `bot/resources/stars.json` should contain a list of strings.""" + path = Path('bot', 'resources', 'stars.json') + content = path.read_text() + data = json.loads(content) + + self.assertIsInstance(data, list) + for name in data: + with self.subTest(name=name): + self.assertIsInstance(name, str) diff --git a/tests/test_resources.py b/tests/test_resources.py deleted file mode 100644 index 2fc36c697..000000000 --- a/tests/test_resources.py +++ /dev/null @@ -1,16 +0,0 @@ -import json -import unittest -from pathlib import Path - - -class ResourceValidationTests(unittest.TestCase): - """Validates resources used by the bot.""" - def test_stars_valid(self): - """The resource `bot/resources/stars.json` should contain a list of strings.""" - path = Path('bot', 'resources', 'stars.json') - content = path.read_text() - data = json.loads(content) - - self.assertIsInstance(data, list) - for name in data: - self.assertIsInstance(name, str) -- cgit v1.2.3 From 562ede819308929900e3e0c6a41ae61ca32abab6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 13 Oct 2019 11:56:36 +0200 Subject: Move test_attachments.py to tests/bot/rules dir --- tests/bot/rules/test_attachments.py | 52 +++++++++++++++++++++++++++++++++++++ tests/rules/__init__.py | 0 tests/rules/test_attachments.py | 52 ------------------------------------- 3 files changed, 52 insertions(+), 52 deletions(-) create mode 100644 tests/bot/rules/test_attachments.py delete mode 100644 tests/rules/__init__.py delete mode 100644 tests/rules/test_attachments.py diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py new file mode 100644 index 000000000..4bb0acf7c --- /dev/null +++ b/tests/bot/rules/test_attachments.py @@ -0,0 +1,52 @@ +import asyncio +import unittest +from dataclasses import dataclass +from typing import Any, List + +from bot.rules import attachments + + +# Using `MagicMock` sadly doesn't work for this usecase +# since it's __eq__ compares the MagicMock's ID. We just +# want to compare the actual attributes we set. +@dataclass +class FakeMessage: + author: str + attachments: List[Any] + + +def msg(total_attachments: int) -> FakeMessage: + return FakeMessage(author='lemon', attachments=list(range(total_attachments))) + + +class AttachmentRuleTests(unittest.TestCase): + """Tests applying the `attachment` antispam rule.""" + + def test_allows_messages_without_too_many_attachments(self): + """Messages without too many attachments are allowed as-is.""" + cases = ( + (msg(0), msg(0), msg(0)), + (msg(2), msg(2)), + (msg(0),), + ) + + for last_message, *recent_messages in cases: + with self.subTest(last_message=last_message, recent_messages=recent_messages): + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + self.assertIsNone(asyncio.run(coro)) + + def test_disallows_messages_with_too_many_attachments(self): + """Messages with too many attachments trigger the rule.""" + cases = ( + ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), + ((msg(6),), [msg(6)], 6), + ((msg(1),) * 6, [msg(1)] * 6, 6), + ) + for messages, relevant_messages, total in cases: + with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): + last_message, *recent_messages = messages + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + self.assertEqual( + asyncio.run(coro), + (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) + ) diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py deleted file mode 100644 index 4bb0acf7c..000000000 --- a/tests/rules/test_attachments.py +++ /dev/null @@ -1,52 +0,0 @@ -import asyncio -import unittest -from dataclasses import dataclass -from typing import Any, List - -from bot.rules import attachments - - -# Using `MagicMock` sadly doesn't work for this usecase -# since it's __eq__ compares the MagicMock's ID. We just -# want to compare the actual attributes we set. -@dataclass -class FakeMessage: - author: str - attachments: List[Any] - - -def msg(total_attachments: int) -> FakeMessage: - return FakeMessage(author='lemon', attachments=list(range(total_attachments))) - - -class AttachmentRuleTests(unittest.TestCase): - """Tests applying the `attachment` antispam rule.""" - - def test_allows_messages_without_too_many_attachments(self): - """Messages without too many attachments are allowed as-is.""" - cases = ( - (msg(0), msg(0), msg(0)), - (msg(2), msg(2)), - (msg(0),), - ) - - for last_message, *recent_messages in cases: - with self.subTest(last_message=last_message, recent_messages=recent_messages): - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - self.assertIsNone(asyncio.run(coro)) - - def test_disallows_messages_with_too_many_attachments(self): - """Messages with too many attachments trigger the rule.""" - cases = ( - ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), - ((msg(6),), [msg(6)], 6), - ((msg(1),) * 6, [msg(1)] * 6, 6), - ) - for messages, relevant_messages, total in cases: - with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): - last_message, *recent_messages = messages - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - self.assertEqual( - asyncio.run(coro), - (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) - ) -- cgit v1.2.3 From 7bd0be0c7dfd0d75ccaa639ccc124fffd9ef785a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 13 Oct 2019 12:11:13 +0200 Subject: Move test_antispam.py to tests.bot.cogs --- tests/bot/cogs/test_antispam.py | 35 +++++++++++++++++++++++++++++++++++ tests/cogs/__init__.py | 0 tests/cogs/test_antispam.py | 35 ----------------------------------- 3 files changed, 35 insertions(+), 35 deletions(-) create mode 100644 tests/bot/cogs/test_antispam.py delete mode 100644 tests/cogs/__init__.py delete mode 100644 tests/cogs/test_antispam.py diff --git a/tests/bot/cogs/test_antispam.py b/tests/bot/cogs/test_antispam.py new file mode 100644 index 000000000..ce5472c71 --- /dev/null +++ b/tests/bot/cogs/test_antispam.py @@ -0,0 +1,35 @@ +import unittest + +from bot.cogs import antispam + + +class AntispamConfigurationValidationTests(unittest.TestCase): + """Tests validation of the antispam cog configuration.""" + + def test_default_antispam_config_is_valid(self): + """The default antispam configuration is valid.""" + validation_errors = antispam.validate_config() + self.assertEqual(validation_errors, {}) + + def test_unknown_rule_returns_error(self): + """Configuring an unknown rule returns an error.""" + self.assertEqual( + antispam.validate_config({'invalid-rule': {}}), + {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} + ) + + def test_missing_keys_returns_error(self): + """Not configuring required keys returns an error.""" + keys = (('interval', 'max'), ('max', 'interval')) + for configured_key, unconfigured_key in keys: + with self.subTest( + configured_key=configured_key, + unconfigured_key=unconfigured_key + ): + config = {'burst': {configured_key: 10}} + error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" + + self.assertEqual( + antispam.validate_config(config), + {'burst': error} + ) diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py deleted file mode 100644 index ce5472c71..000000000 --- a/tests/cogs/test_antispam.py +++ /dev/null @@ -1,35 +0,0 @@ -import unittest - -from bot.cogs import antispam - - -class AntispamConfigurationValidationTests(unittest.TestCase): - """Tests validation of the antispam cog configuration.""" - - def test_default_antispam_config_is_valid(self): - """The default antispam configuration is valid.""" - validation_errors = antispam.validate_config() - self.assertEqual(validation_errors, {}) - - def test_unknown_rule_returns_error(self): - """Configuring an unknown rule returns an error.""" - self.assertEqual( - antispam.validate_config({'invalid-rule': {}}), - {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} - ) - - def test_missing_keys_returns_error(self): - """Not configuring required keys returns an error.""" - keys = (('interval', 'max'), ('max', 'interval')) - for configured_key, unconfigured_key in keys: - with self.subTest( - configured_key=configured_key, - unconfigured_key=unconfigured_key - ): - config = {'burst': {configured_key: 10}} - error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" - - self.assertEqual( - antispam.validate_config(config), - {'burst': error} - ) -- cgit v1.2.3 From 5032521fa8a4be9738c52b16e6bd82322cdee337 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 13 Oct 2019 12:56:35 +0200 Subject: Move sync tests to tests.bot.cogs.sync --- tests/bot/cogs/sync/__init__.py | 0 tests/bot/cogs/sync/test_roles.py | 126 ++++++++++++++++++++++++++++++++++++++ tests/bot/cogs/sync/test_users.py | 84 +++++++++++++++++++++++++ tests/cogs/sync/__init__.py | 0 tests/cogs/sync/test_roles.py | 126 -------------------------------------- tests/cogs/sync/test_users.py | 84 ------------------------- 6 files changed, 210 insertions(+), 210 deletions(-) create mode 100644 tests/bot/cogs/sync/__init__.py create mode 100644 tests/bot/cogs/sync/test_roles.py create mode 100644 tests/bot/cogs/sync/test_users.py delete mode 100644 tests/cogs/sync/__init__.py delete mode 100644 tests/cogs/sync/test_roles.py delete mode 100644 tests/cogs/sync/test_users.py diff --git a/tests/bot/cogs/sync/__init__.py b/tests/bot/cogs/sync/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py new file mode 100644 index 000000000..27ae27639 --- /dev/null +++ b/tests/bot/cogs/sync/test_roles.py @@ -0,0 +1,126 @@ +import unittest + +from bot.cogs.sync.syncers import Role, get_roles_for_sync + + +class GetRolesForSyncTests(unittest.TestCase): + """Tests constructing the roles to synchronize with the site.""" + + 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)} + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + (set(), set(), set()) + ) + + 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)} + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + (set(), guild_roles, set()) + ) + + 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) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + set(), + {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, + set(), + ) + ) + + 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) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, + set(), + set(), + ) + ) + + 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_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), + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + set(), + set(), + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + ) + + 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), + } + + 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)}, + ) + ) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py new file mode 100644 index 000000000..ccaf67490 --- /dev/null +++ b/tests/bot/cogs/sync/test_users.py @@ -0,0 +1,84 @@ +import unittest + +from bot.cogs.sync.syncers import User, get_users_for_sync + + +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) + + +class GetUsersForSyncTests(unittest.TestCase): + """Tests constructing the users to synchronize with the site.""" + + 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_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()} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), set()) + ) + + 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')} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), {fake_user(name='new fancy name')}) + ) + + 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)} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + ({fake_user(id=63)}, set()) + ) + + 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`.""" + api_users = {43: fake_user(), 63: fake_user(id=63)} + guild_users = {43: fake_user()} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), {fake_user(id=63, in_guild=False)}) + ) + + 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)} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + ({fake_user(id=63)}, {fake_user(in_guild=False)}) + ) + + 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()) + ) diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py deleted file mode 100644 index 27ae27639..000000000 --- a/tests/cogs/sync/test_roles.py +++ /dev/null @@ -1,126 +0,0 @@ -import unittest - -from bot.cogs.sync.syncers import Role, get_roles_for_sync - - -class GetRolesForSyncTests(unittest.TestCase): - """Tests constructing the roles to synchronize with the site.""" - - 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)} - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - (set(), set(), set()) - ) - - 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)} - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - (set(), guild_roles, set()) - ) - - 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) - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - set(), - {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, - set(), - ) - ) - - 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) - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, - set(), - set(), - ) - ) - - 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_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), - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - set(), - set(), - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) - ) - - 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), - } - - 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)}, - ) - ) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py deleted file mode 100644 index ccaf67490..000000000 --- a/tests/cogs/sync/test_users.py +++ /dev/null @@ -1,84 +0,0 @@ -import unittest - -from bot.cogs.sync.syncers import User, get_users_for_sync - - -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) - - -class GetUsersForSyncTests(unittest.TestCase): - """Tests constructing the users to synchronize with the site.""" - - 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_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()} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), set()) - ) - - 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')} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), {fake_user(name='new fancy name')}) - ) - - 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)} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - ({fake_user(id=63)}, set()) - ) - - 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`.""" - api_users = {43: fake_user(), 63: fake_user(id=63)} - guild_users = {43: fake_user()} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), {fake_user(id=63, in_guild=False)}) - ) - - 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)} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - ({fake_user(id=63)}, {fake_user(in_guild=False)}) - ) - - 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()) - ) -- cgit v1.2.3 From d26eba13ebee06013bacf07e35e8e11c2de250c1 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 13 Oct 2019 21:19:28 +0800 Subject: Show matched word and location in watchlist embed --- bot/cogs/filtering.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 265ae5160..18c2550a2 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -184,12 +184,25 @@ class Filtering(Cog): else: channel_str = f"in {msg.channel.mention}" + # Word and match stats for watch_words and watch_tokens + if filter_name in ("watch_words", "watch_tokens"): + m = triggered + match = m[0] + surroundings = m.string[max(m.start() - 10, 0): m.end() + 10] + message_content = ( + f"**Match:** '{match}'\n" + f"**Location:** '...{surroundings}...'\n" + f"\n**Original Message:**\n{msg.content}" + ) + else: # Use content of discord Message + message_content = msg.content + message = ( f"The {filter_name} {_filter['type']} was triggered " f"by **{msg.author.name}#{msg.author.discriminator}** " f"(`{msg.author.id}`) {channel_str} with [the " f"following message]({msg.jump_url}):\n\n" - f"{msg.content}" + f"{message_content}" ) log.debug(message) @@ -237,8 +250,9 @@ class Filtering(Cog): Only matches words with boundaries before and after the expression. """ for regex_pattern in WORD_WATCHLIST_PATTERNS: - if regex_pattern.search(text): - return True + match = regex_pattern.search(text) + if match: + return match # match objects always have a boolean value of True return False @@ -250,11 +264,12 @@ class Filtering(Cog): This will match the expression even if it does not have boundaries before and after. """ for regex_pattern in TOKEN_WATCHLIST_PATTERNS: - if regex_pattern.search(text): + match = regex_pattern.search(text) + if match: # Make sure it's not a URL if not URL_RE.search(text): - return True + return match # match objects always have a boolean value of True return False -- cgit v1.2.3 From 612994ae248e614a9f1712337e0eb7942e0c5f32 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 13 Oct 2019 17:15:32 +0200 Subject: Use `MockBot` and `MockContext`. --- tests/cogs/test_security.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py index 6c646ae70..efa7a50b1 100644 --- a/tests/cogs/test_security.py +++ b/tests/cogs/test_security.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock from discord.ext.commands import NoPrivateMessage from bot.cogs import security +from tests.helpers import MockBot, MockContext class SecurityCogTests(unittest.TestCase): @@ -12,10 +13,9 @@ class SecurityCogTests(unittest.TestCase): def setUp(self): """Attach an instance of the cog to the class for tests.""" - self.bot = MagicMock() + self.bot = MockBot() self.cog = security.Security(self.bot) - self.ctx = MagicMock() - self.ctx.author = MagicMock() + self.ctx = MockContext() def test_check_additions(self): """The cog should add its checks after initialization.""" -- cgit v1.2.3 From e049f758da923fcb050ce32d1bf0657b220f87d2 Mon Sep 17 00:00:00 2001 From: larswijn <51133685+larswijn@users.noreply.github.com> Date: Sun, 13 Oct 2019 18:31:17 +0200 Subject: Update utils.py Switch around trying order (txt first, then rst) --- bot/cogs/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 68a24a446..62d46d5a1 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -38,7 +38,7 @@ class Utils: else: return await ctx.invoke(self.bot.get_command("help"), "pep") - possible_extensions = ['.rst', '.txt'] + possible_extensions = ['.txt', '.rst'] found_pep = False for extension in possible_extensions: # Attempt to fetch the PEP -- cgit v1.2.3 From 9b4b98eb6f37060771c61979458737629b3c5db7 Mon Sep 17 00:00:00 2001 From: Jens Date: Wed, 9 Oct 2019 23:43:16 +0200 Subject: Add Reddit OAuth tasks and refactor code --- bot/cogs/reddit.py | 78 +++++++++++++++++++++++++++++++++++++++++++++++++----- bot/constants.py | 2 ++ config-default.yml | 2 ++ 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 0f575cece..25df014f8 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -2,10 +2,12 @@ import asyncio import logging import random import textwrap +from aiohttp import BasicAuth from datetime import datetime, timedelta from typing import List from discord import Colour, Embed, Message, TextChannel +from discord.ext import tasks from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES @@ -19,8 +21,13 @@ log = logging.getLogger(__name__) class Reddit(Cog): """Track subreddit posts and show detailed statistics about them.""" - HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"} + # Change your client's User-Agent string to something unique and descriptive, + # including the target platform, a unique application identifier, a version string, + # and your username as contact information, in the following format: + # :: (by /u/) + USER_AGENT = "docker:Discord Bot of https://pythondiscord.com/:v?.?.? (by /u/PythonDiscord)" URL = "https://www.reddit.com" + OAUTH_URL = "https://oauth.reddit.com" MAX_FETCH_RETRIES = 3 def __init__(self, bot: Bot): @@ -35,6 +42,65 @@ class Reddit(Cog): self.top_weekly_posts_task = None self.bot.loop.create_task(self.init_reddit_polling()) + self.refresh_access_token.start() + + @tasks.loop(hours=0.99) # access tokens are valid for one hour + async def refresh_access_token(self) -> None: + """Refresh the access token""" + headers = {"Authorization": self.client_auth} + data = { + "grant_type": "refresh_token", + "refresh_token": self.refresh_token + } + + response = await self.bot.http_session.post( + url = f"{self.URL}/api/v1/access_token", + headers=headers, + data=data, + ) + + content = await response.json() + self.access_token = content["access_token"] + self.headers = { + "Authorization": "bearer " + self.access_token, + "User-Agent": self.USER_AGENT + } + + @refresh_access_token.before_loop + async def get_tokens(self) -> None: + """Get Reddit access and refresh tokens""" + await self.bot.wait_until_ready() + + headers = {"User-Agent": self.USER_AGENT} + data = { + "grant_type": "client_credentials", + "duration": "permanent" + } + + if RedditConfig.client_id and RedditConfig.secret: + self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) + + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/access_token", + headers=headers, + auth=self.client_auth, + data=data + ) + + content = await response.json() + self.access_token = content["access_token"] + self.refresh_token = content["refresh_token"] + self.headers = { + "Authorization": "bearer " + self.access_token, + "User-Agent": self.USER_AGENT + } + else: + self.client_auth = None + self.access_token = None + self.refresh_token = None + self.headers = None + + log.error("Unable to find client credentials.") async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: """A helper method to fetch a certain amount of Reddit posts at a given route.""" @@ -45,11 +111,11 @@ class Reddit(Cog): if params is None: params = {} - url = f"{self.URL}/{route}.json" + url = f"{self.OAUTH_URL}/{route}" for _ in range(self.MAX_FETCH_RETRIES): response = await self.bot.http_session.get( url=url, - headers=self.HEADERS, + headers=self.headers, params=params ) if response.status == 200 and response.content_type == 'application/json': @@ -57,7 +123,7 @@ class Reddit(Cog): content = await response.json() posts = content["data"]["children"] return posts[:amount] - + await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") @@ -129,8 +195,8 @@ class Reddit(Cog): for subreddit in RedditConfig.subreddits: # Make a HEAD request to the subreddit head_response = await self.bot.http_session.head( - url=f"{self.URL}/{subreddit}/new.rss", - headers=self.HEADERS + url=f"{self.OAUTH_URL}/{subreddit}/new.rss", + headers=self.headers ) content_length = head_response.headers["content-length"] diff --git a/bot/constants.py b/bot/constants.py index 1deeaa3b8..f84889e10 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -440,6 +440,8 @@ class Reddit(metaclass=YAMLGetter): request_delay: int subreddits: list + client_id: str + secret: str class Wolfram(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 0dac9bf9f..c43ea4f8f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -326,6 +326,8 @@ reddit: request_delay: 60 subreddits: - 'r/Python' + client_id: !ENV "REDDIT_CLIENT_ID" + secret: !ENV "REDDIT_SECRET" wolfram: -- cgit v1.2.3 From 2e18b4164c70e8f96750667bfd8e0e14f4a65cff Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 13:34:47 +0200 Subject: Move test_pagination to tests.bot subdir --- tests/bot/test_pagination.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_pagination.py | 36 ------------------------------------ 2 files changed, 36 insertions(+), 36 deletions(-) create mode 100644 tests/bot/test_pagination.py delete mode 100644 tests/test_pagination.py diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py new file mode 100644 index 000000000..0a734b505 --- /dev/null +++ b/tests/bot/test_pagination.py @@ -0,0 +1,36 @@ +from unittest import TestCase + +from bot import pagination + + +class LinePaginatorTests(TestCase): + """Tests functionality of the `LinePaginator`.""" + + def setUp(self): + """Create a paginator for the test method.""" + self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) + + def test_add_line_raises_on_too_long_lines(self): + """`add_line` should raise a `RuntimeError` for too long lines.""" + message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" + with self.assertRaises(RuntimeError, msg=message): + self.paginator.add_line('x' * self.paginator.max_size) + + def test_add_line_works_on_small_lines(self): + """`add_line` should allow small lines to be added.""" + self.paginator.add_line('x' * (self.paginator.max_size - 3)) + + +class ImagePaginatorTests(TestCase): + """Tests functionality of the `ImagePaginator`.""" + + def setUp(self): + """Create a paginator for the test method.""" + self.paginator = pagination.ImagePaginator() + + def test_add_image_appends_image(self): + """`add_image` appends the image to the image list.""" + image = 'lemon' + self.paginator.add_image(image) + + assert self.paginator.images == [image] diff --git a/tests/test_pagination.py b/tests/test_pagination.py deleted file mode 100644 index 0a734b505..000000000 --- a/tests/test_pagination.py +++ /dev/null @@ -1,36 +0,0 @@ -from unittest import TestCase - -from bot import pagination - - -class LinePaginatorTests(TestCase): - """Tests functionality of the `LinePaginator`.""" - - def setUp(self): - """Create a paginator for the test method.""" - self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) - - def test_add_line_raises_on_too_long_lines(self): - """`add_line` should raise a `RuntimeError` for too long lines.""" - message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" - with self.assertRaises(RuntimeError, msg=message): - self.paginator.add_line('x' * self.paginator.max_size) - - def test_add_line_works_on_small_lines(self): - """`add_line` should allow small lines to be added.""" - self.paginator.add_line('x' * (self.paginator.max_size - 3)) - - -class ImagePaginatorTests(TestCase): - """Tests functionality of the `ImagePaginator`.""" - - def setUp(self): - """Create a paginator for the test method.""" - self.paginator = pagination.ImagePaginator() - - def test_add_image_appends_image(self): - """`add_image` appends the image to the image list.""" - image = 'lemon' - self.paginator.add_image(image) - - assert self.paginator.images == [image] -- cgit v1.2.3 From 0dabafc3fba58c5ffc74207d32b6654f9b219379 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 13:45:43 +0200 Subject: Move test_security to tests.bot.cogs --- tests/bot/cogs/test_security.py | 59 +++++++++++++++++++++++++++++++++++++++++ tests/cogs/__init__.py | 0 tests/cogs/test_security.py | 59 ----------------------------------------- 3 files changed, 59 insertions(+), 59 deletions(-) create mode 100644 tests/bot/cogs/test_security.py delete mode 100644 tests/cogs/__init__.py delete mode 100644 tests/cogs/test_security.py diff --git a/tests/bot/cogs/test_security.py b/tests/bot/cogs/test_security.py new file mode 100644 index 000000000..efa7a50b1 --- /dev/null +++ b/tests/bot/cogs/test_security.py @@ -0,0 +1,59 @@ +import logging +import unittest +from unittest.mock import MagicMock + +from discord.ext.commands import NoPrivateMessage + +from bot.cogs import security +from tests.helpers import MockBot, MockContext + + +class SecurityCogTests(unittest.TestCase): + """Tests the `Security` cog.""" + + def setUp(self): + """Attach an instance of the cog to the class for tests.""" + self.bot = MockBot() + self.cog = security.Security(self.bot) + self.ctx = MockContext() + + def test_check_additions(self): + """The cog should add its checks after initialization.""" + self.bot.check.assert_any_call(self.cog.check_on_guild) + self.bot.check.assert_any_call(self.cog.check_not_bot) + + def test_check_not_bot_returns_false_for_humans(self): + """The bot check should return `True` when invoked with human authors.""" + self.ctx.author.bot = False + self.assertTrue(self.cog.check_not_bot(self.ctx)) + + def test_check_not_bot_returns_true_for_robots(self): + """The bot check should return `False` when invoked with robotic authors.""" + self.ctx.author.bot = True + self.assertFalse(self.cog.check_not_bot(self.ctx)) + + def test_check_on_guild_raises_when_outside_of_guild(self): + """When invoked outside of a guild, `check_on_guild` should cause an error.""" + self.ctx.guild = None + + with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): + self.cog.check_on_guild(self.ctx) + + def test_check_on_guild_returns_true_inside_of_guild(self): + """When invoked inside of a guild, `check_on_guild` should return `True`.""" + self.ctx.guild = "lemon's lemonade stand" + self.assertTrue(self.cog.check_on_guild(self.ctx)) + + +class SecurityCogLoadTests(unittest.TestCase): + """Tests loading the `Security` cog.""" + + def test_security_cog_load(self): + """Cog loading logs a message at `INFO` level.""" + bot = MagicMock() + with self.assertLogs(logger='bot.cogs.security', level=logging.INFO) as cm: + security.setup(bot) + bot.add_cog.assert_called_once() + + [line] = cm.output + self.assertIn("Cog loaded: Security", line) diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py deleted file mode 100644 index efa7a50b1..000000000 --- a/tests/cogs/test_security.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging -import unittest -from unittest.mock import MagicMock - -from discord.ext.commands import NoPrivateMessage - -from bot.cogs import security -from tests.helpers import MockBot, MockContext - - -class SecurityCogTests(unittest.TestCase): - """Tests the `Security` cog.""" - - def setUp(self): - """Attach an instance of the cog to the class for tests.""" - self.bot = MockBot() - self.cog = security.Security(self.bot) - self.ctx = MockContext() - - def test_check_additions(self): - """The cog should add its checks after initialization.""" - self.bot.check.assert_any_call(self.cog.check_on_guild) - self.bot.check.assert_any_call(self.cog.check_not_bot) - - def test_check_not_bot_returns_false_for_humans(self): - """The bot check should return `True` when invoked with human authors.""" - self.ctx.author.bot = False - self.assertTrue(self.cog.check_not_bot(self.ctx)) - - def test_check_not_bot_returns_true_for_robots(self): - """The bot check should return `False` when invoked with robotic authors.""" - self.ctx.author.bot = True - self.assertFalse(self.cog.check_not_bot(self.ctx)) - - def test_check_on_guild_raises_when_outside_of_guild(self): - """When invoked outside of a guild, `check_on_guild` should cause an error.""" - self.ctx.guild = None - - with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): - self.cog.check_on_guild(self.ctx) - - def test_check_on_guild_returns_true_inside_of_guild(self): - """When invoked inside of a guild, `check_on_guild` should return `True`.""" - self.ctx.guild = "lemon's lemonade stand" - self.assertTrue(self.cog.check_on_guild(self.ctx)) - - -class SecurityCogLoadTests(unittest.TestCase): - """Tests loading the `Security` cog.""" - - def test_security_cog_load(self): - """Cog loading logs a message at `INFO` level.""" - bot = MagicMock() - with self.assertLogs(logger='bot.cogs.security', level=logging.INFO) as cm: - security.setup(bot) - bot.add_cog.assert_called_once() - - [line] = cm.output - self.assertIn("Cog loaded: Security", line) -- cgit v1.2.3 From ffe5dba72d428f73a5874e19bf4fcff52fb4fb6e Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 14:16:10 +0200 Subject: Remove empty tests.cogs folder --- tests/cogs/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/cogs/__init__.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 -- cgit v1.2.3 From 9de24c0417be4277354af4322dd174a38e8d1785 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 14:48:18 +0200 Subject: Migrate test_constants to unittest Migrates the `test_constants.py` file to unittest. As with the pytest version, there is not yet support to test container types. --- tests/bot/test_constants.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/bot/test_constants.py diff --git a/tests/bot/test_constants.py b/tests/bot/test_constants.py new file mode 100644 index 000000000..dae7c066c --- /dev/null +++ b/tests/bot/test_constants.py @@ -0,0 +1,26 @@ +import inspect +import unittest + +from bot import constants + + +class ConstantsTests(unittest.TestCase): + """Tests for our constants.""" + + def test_section_configuration_matches_type_specification(self): + """The section annotations should match the actual types of the sections.""" + + sections = ( + cls + for (name, cls) in inspect.getmembers(constants) + if hasattr(cls, 'section') and isinstance(cls, type) + ) + for section in sections: + for name, annotation in section.__annotations__.items(): + with self.subTest(section=section, name=name, annotation=annotation): + value = getattr(section, name) + + if getattr(annotation, '_name', None) in ('Dict', 'List'): + self.skipTest("Cannot validate containers yet.") + + self.assertIsInstance(value, annotation) -- cgit v1.2.3 From e12965d3604e7086d7fd2a37ac7caa68a39687a1 Mon Sep 17 00:00:00 2001 From: Ava Date: Mon, 14 Oct 2019 18:02:33 +0300 Subject: Implement a bypassable cooldown decorator --- bot/cogs/information.py | 5 +++-- bot/utils/checks.py | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index b6a3c4a40..3a7ba0444 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -8,11 +8,11 @@ from typing import Any, Mapping, Optional import discord from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils from discord.ext import commands -from discord.ext.commands import Bot, Cog, Context, command, group +from discord.ext.commands import Bot, BucketType, Cog, Context, command, group from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES from bot.decorators import InChannelCheckFailure, in_channel, with_role -from bot.utils.checks import with_role_check +from bot.utils.checks import cooldown_with_role_bypass, with_role_check from bot.utils.time import time_since log = logging.getLogger(__name__) @@ -268,6 +268,7 @@ class Information(Cog): # remove trailing whitespace return out.rstrip() + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=STAFF_ROLES) @group(invoke_without_command=True) @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False) -> None: diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 19f64ff9f..ad892e512 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -1,6 +1,8 @@ +import datetime import logging +from typing import Callable, Iterable -from discord.ext.commands import Context +from discord.ext.commands import BucketType, Cog, Command, CommandOnCooldown, Context, Cooldown, CooldownMapping log = logging.getLogger(__name__) @@ -42,3 +44,47 @@ def in_channel_check(ctx: Context, channel_id: int) -> bool: log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " f"The result of the in_channel check was {check}.") return check + + +def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketType.default, *, + bypass_roles: Iterable[int]) -> Callable: + """ + Applies a cooldown to a command, but allows members with certain roles to be ignored. + + NOTE: this replaces the `Command.before_invoke` callback, which *might* introduce problems in the future. + """ + # make it a set so lookup is hash based + bypass = set(bypass_roles) + + # this handles the actual cooldown logic + buckets = CooldownMapping(Cooldown(rate, per, type)) + + # will be called after the command has been parse but before it has been invoked, ensures that + # the cooldown won't be updated if the user screws up their input to the command + async def predicate(cog: Cog, ctx: Context) -> None: + nonlocal bypass, buckets + + if any(role.id in bypass for role in ctx.author.roles): + return + + # cooldown logic, taken from discord.py internals + current = ctx.message.created_at.replace(tzinfo=datetime.timezone.utc).timestamp() + bucket = buckets.get_bucket(ctx.message) + retry_after = bucket.update_rate_limit(current) + if retry_after: + raise CommandOnCooldown(bucket, retry_after) + + def wrapper(command: Command) -> Command: + # NOTE: this could be changed if a subclass of Command were to be used. I didn't see the need for it + # so I just made it raise an error when the decorator is applied before the actual command object exists. + # + # if the `before_invoke` detail is ever a problem then I can quickly just swap over. + if not isinstance(command, Command): + raise TypeError('Decorator `cooldown_with_role_bypass` must be applied after the command decorator. ' + 'This means it has to be above the command decorator in the code.') + + command._before_invoke = predicate + + return command + + return wrapper -- cgit v1.2.3 From e4e01cd5388da19435637353e711c2feab5a0e59 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 22:13:22 +0200 Subject: Add more specialized Mocks to tests.helpers This commit introduces some new Mock-types to the already existing Mock-types for discord.py objects. The total list is now: - MockGuild - MockRole - MockMember - MockBot - MockContext - MockTextChannel - MockMessage In addition, I've added all coroutines in the documentation for these discord.py objects as `AsyncMock` attributes to ease testing. Tests ensure that the attributes set for the Mocks exist for the actual discord.py objects as well. --- tests/helpers.py | 179 ++++++++++++++++++++++- tests/test_helpers.py | 385 +++++++++++++++++++++++++++----------------------- 2 files changed, 383 insertions(+), 181 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 18c9866bf..f8e8357f1 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -143,6 +143,32 @@ class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): if members: self.members.extend(members) + # `discord.Guild` coroutines + self.create_category_channel = AsyncMock() + self.ban = AsyncMock() + self.bans = AsyncMock() + self.create_category = AsyncMock() + self.create_custom_emoji = AsyncMock() + self.create_role = AsyncMock() + self.create_text_channel = AsyncMock() + self.create_voice_channel = AsyncMock() + self.delete = AsyncMock() + self.edit = AsyncMock() + self.estimate_pruned_members = AsyncMock() + self.fetch_ban = AsyncMock() + self.fetch_channels = AsyncMock() + self.fetch_emoji = AsyncMock() + self.fetch_emojis = AsyncMock() + self.fetch_member = AsyncMock() + self.invites = AsyncMock() + self.kick = AsyncMock() + self.leave = AsyncMock() + self.prune_members = AsyncMock() + self.unban = AsyncMock() + self.vanity_invite = AsyncMock() + self.webhooks = AsyncMock() + self.widget = AsyncMock() + # Create a Role instance to get a realistic Mock of `discord.Role` role_data = {'name': 'role', 'id': 1} @@ -167,6 +193,10 @@ class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): self.position = position self.mention = f'&{self.name}' + # 'discord.Role' coroutines + self.delete = AsyncMock() + self.edit = AsyncMock() + def __lt__(self, other): """Simplified position-based comparisons similar to those of `discord.Role`.""" return self.position < other.position @@ -205,7 +235,19 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): self.roles.extend(roles) self.mention = f"@{self.name}" + + # `discord.Member` coroutines + self.add_roles = AsyncMock() + self.ban = AsyncMock() + self.edit = AsyncMock() + self.fetch_message = AsyncMock() + self.kick = AsyncMock() + self.move_to = AsyncMock() + self.pins = AsyncMock() + self.remove_roles = AsyncMock() self.send = AsyncMock() + self.trigger_typing = AsyncMock() + self.unban = AsyncMock() # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` @@ -224,9 +266,37 @@ class MockBot(AttributeMock, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) + + # `discord.ext.commands.Bot` coroutines self._before_invoke = AsyncMock() self._after_invoke = AsyncMock() - self.user = MockMember(name="Python", user_id=123456789) + self.application_info = AsyncMock() + self.change_presence = AsyncMock() + self.connect = AsyncMock() + self.close = AsyncMock() + self.create_guild = AsyncMock() + self.delete_invite = AsyncMock() + self.fetch_channel = AsyncMock() + self.fetch_guild = AsyncMock() + self.fetch_guilds = AsyncMock() + self.fetch_invite = AsyncMock() + self.fetch_user = AsyncMock() + self.fetch_user_profile = AsyncMock() + self.fetch_webhook = AsyncMock() + self.fetch_widget = AsyncMock() + self.get_context = AsyncMock() + self.get_prefix = AsyncMock() + self.invoke = AsyncMock() + self.is_owner = AsyncMock() + self.login = AsyncMock() + self.logout = AsyncMock() + self.on_command_error = AsyncMock() + self.on_error = AsyncMock() + self.process_commands = AsyncMock() + self.request_offline_members = AsyncMock() + self.start = AsyncMock() + self.wait_until_ready = AsyncMock() + self.wait_for = AsyncMock() # Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` @@ -246,7 +316,112 @@ class MockContext(AttributeMock, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(spec=context_instance, **kwargs) self.bot = MockBot() - self.send = AsyncMock() self.guild = MockGuild() self.author = MockMember() self.command = unittest.mock.MagicMock() + + # `discord.ext.commands.Context` coroutines + self.fetch_message = AsyncMock() + self.invoke = AsyncMock() + self.pins = AsyncMock() + self.reinvoke = AsyncMock() + self.send = AsyncMock() + self.send_help = AsyncMock() + self.trigger_typing = AsyncMock() + + +# Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` +channel_data = { + 'id': 1, + 'type': 'TextChannel', + 'name': 'channel', + 'parent_id': 1234567890, + 'topic': 'topic', + 'position': 1, + 'nsfw': False, + 'last_message_id': 1, +} +state = unittest.mock.MagicMock() +guild = unittest.mock.MagicMock() +channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) + + +class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin): + """ + A MagicMock subclass to mock TextChannel objects. + + Instances of this class will follow the specifications of `discord.TextChannel` instances. For + more information, see the `MockGuild` docstring. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: + super().__init__(spec=channel_instance, **kwargs) + self.id = channel_id + self.name = name + self.guild = MockGuild() + self.mention = f"#{self.name}" + + # `discord.TextChannel` coroutines + self.clone = AsyncMock() + self.create_invite = AsyncMock() + self.create_webhook = AsyncMock() + self.delete = AsyncMock() + self.delete_messages = AsyncMock() + self.edit = AsyncMock() + self.fetch_message = AsyncMock() + self.invites = AsyncMock() + self.pins = AsyncMock() + self.purge = AsyncMock() + self.send = AsyncMock() + self.set_permissions = AsyncMock() + self.trigger_typing = AsyncMock() + self.webhooks = AsyncMock() + + +# Create a Message instance to get a realistic MagicMock of `discord.Message` +message_data = { + 'id': 1, + 'webhook_id': 431341013479718912, + 'attachments': [], + 'embeds': [], + 'application': 'Python Discord', + 'activity': 'mocking', + 'channel': unittest.mock.MagicMock(), + 'edited_timestamp': '2019-10-14T15:33:48+00:00', + 'type': 'message', + 'pinned': False, + 'mention_everyone': False, + 'tts': None, + 'content': 'content', + 'nonce': None, +} +state = unittest.mock.MagicMock() +channel = unittest.mock.MagicMock() +message_instance = discord.Message(state=state, channel=channel, data=message_data) + + +class MockMessage(AttributeMock, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Message objects. + + Instances of this class will follow the specifications of `discord.Message` instances. For more + information, see the `MockGuild` docstring. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, **kwargs) -> None: + super().__init__(spec=message_instance, **kwargs) + self.author = MockMember() + + # `discord.Message` coroutines + self.ack = AsyncMock() + self.add_reaction = AsyncMock() + self.clear_reactions = AsyncMock() + self.delete = AsyncMock() + self.edit = AsyncMock() + self.pin = AsyncMock() + self.remove_reaction = AsyncMock() + self.unpin = AsyncMock() diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 766fe17b8..f08239981 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -8,114 +8,8 @@ import discord from tests import helpers -class MockObjectTests(unittest.TestCase): - """Tests the mock objects and mixins we've defined.""" - @classmethod - def setUpClass(cls): - cls.hashable_mocks = (helpers.MockRole, helpers.MockMember, helpers.MockGuild) - - def test_colour_mixin(self): - """Test if the ColourMixin adds aliasing of color -> colour for child classes.""" - class MockHemlock(unittest.mock.MagicMock, helpers.ColourMixin): - pass - - hemlock = MockHemlock() - hemlock.color = 1 - self.assertEqual(hemlock.colour, 1) - self.assertEqual(hemlock.colour, hemlock.color) - - def test_hashable_mixin_hash_returns_id(self): - """Test if the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): - pass - - scragly = MockScragly() - scragly.id = 10 - self.assertEqual(hash(scragly), scragly.id) - - def test_hashable_mixin_uses_id_for_equality_comparison(self): - """Test if the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): - pass - - scragly = MockScragly(spec=object) - scragly.id = 10 - eevee = MockScragly(spec=object) - eevee.id = 10 - python = MockScragly(spec=object) - python.id = 20 - - self.assertTrue(scragly == eevee) - self.assertFalse(scragly == python) - - def test_hashable_mixin_uses_id_for_nonequality_comparison(self): - """Test if the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): - pass - - scragly = MockScragly(spec=object) - scragly.id = 10 - eevee = MockScragly(spec=object) - eevee.id = 10 - python = MockScragly(spec=object) - python.id = 20 - - self.assertTrue(scragly != python) - self.assertFalse(scragly != eevee) - - def test_mock_class_with_hashable_mixin_uses_id_for_hashing(self): - """Test if the MagicMock subclasses that implement the HashableMixin use id for hash.""" - for mock in self.hashable_mocks: - with self.subTest(mock_class=mock): - instance = helpers.MockRole(role_id=100) - self.assertEqual(hash(instance), instance.id) - - def test_mock_class_with_hashable_mixin_uses_id_for_equality(self): - """Test if MagicMocks that implement the HashableMixin use id for equality comparisons.""" - for mock_class in self.hashable_mocks: - with self.subTest(mock_class=mock_class): - instance_one = mock_class() - instance_two = mock_class() - instance_three = mock_class() - - instance_one.id = 10 - instance_two.id = 10 - instance_three.id = 20 - - self.assertTrue(instance_one == instance_two) - self.assertFalse(instance_one == instance_three) - - def test_mock_class_with_hashable_mixin_uses_id_for_nonequality(self): - """Test if MagicMocks that implement HashableMixin use id for nonequality comparisons.""" - for mock_class in self.hashable_mocks: - with self.subTest(mock_class=mock_class): - instance_one = mock_class() - instance_two = mock_class() - instance_three = mock_class() - - instance_one.id = 10 - instance_two.id = 10 - instance_three.id = 20 - - self.assertFalse(instance_one != instance_two) - self.assertTrue(instance_one != instance_three) - - def test_spec_propagation_of_mock_subclasses(self): - """Test if the `spec` does not propagate to attributes of the mock object.""" - test_values = ( - (helpers.MockGuild, "region"), - (helpers.MockRole, "mentionable"), - (helpers.MockMember, "display_name"), - (helpers.MockBot, "owner_id"), - (helpers.MockContext, "command_failed"), - ) - - for mock_type, valid_attribute in test_values: - with self.subTest(mock_type=mock_type, attribute=valid_attribute): - mock = mock_type() - self.assertTrue(isinstance(mock, mock_type)) - attribute = getattr(mock, valid_attribute) - self.assertTrue(isinstance(attribute, mock_type.attribute_mocktype)) +class DiscordMocksTests(unittest.TestCase): + """Tests for our specialized discord.py mocks.""" def test_mock_role_default_initialization(self): """Test if the default initialization of MockRole results in the correct object.""" @@ -152,28 +46,6 @@ class MockObjectTests(unittest.TestCase): self.assertEqual(role.guild, "Dino Man") self.assertTrue(role.hoist) - def test_mock_role_rejects_accessing_attributes_not_following_spec(self): - """Test if MockRole throws AttributeError for attribute not existing in discord.Role.""" - with self.assertRaises(AttributeError): - role = helpers.MockRole() - role.joseph - - def test_mock_role_rejects_accessing_methods_not_following_spec(self): - """Test if MockRole throws AttributeError for method not existing in discord.Role.""" - with self.assertRaises(AttributeError): - role = helpers.MockRole() - role.lemon() - - def test_mock_role_accepts_accessing_attributes_following_spec(self): - """Test if MockRole accepts attribute access for valid attributes of discord.Role.""" - role = helpers.MockRole() - role.hoist - - def test_mock_role_accepts_accessing_methods_following_spec(self): - """Test if MockRole accepts method calls for valid methods of discord.Role.""" - role = helpers.MockRole() - role.edit() - def test_mock_role_uses_position_for_less_than_greater_than(self): """Test if `<` and `>` comparisons for MockRole are based on its position attribute.""" role_one = helpers.MockRole(position=1) @@ -223,28 +95,6 @@ class MockObjectTests(unittest.TestCase): self.assertEqual(member.nick, "Dino Man") self.assertEqual(member.colour, discord.Colour.default()) - def test_mock_member_rejects_accessing_attributes_not_following_spec(self): - """Test if MockMember throws AttributeError for attribute not existing in spec discord.Member.""" - with self.assertRaises(AttributeError): - member = helpers.MockMember() - member.joseph - - def test_mock_member_rejects_accessing_methods_not_following_spec(self): - """Test if MockMember throws AttributeError for method not existing in spec discord.Member.""" - with self.assertRaises(AttributeError): - member = helpers.MockMember() - member.lemon() - - def test_mock_member_accepts_accessing_attributes_following_spec(self): - """Test if MockMember accepts attribute access for valid attributes of discord.Member.""" - member = helpers.MockMember() - member.display_name - - def test_mock_member_accepts_accessing_methods_following_spec(self): - """Test if MockMember accepts method calls for valid methods of discord.Member.""" - member = helpers.MockMember() - member.mentioned_in() - def test_mock_guild_default_initialization(self): """Test if the default initialization of Mockguild results in the correct object.""" guild = helpers.MockGuild() @@ -276,28 +126,6 @@ class MockObjectTests(unittest.TestCase): self.assertTupleEqual(guild.emojis, (":hyperjoseph:", ":pensive_ela:")) self.assertEqual(guild.premium_subscription_count, 15) - def test_mock_guild_rejects_accessing_attributes_not_following_spec(self): - """Test if MockGuild throws AttributeError for attribute not existing in spec discord.Guild.""" - with self.assertRaises(AttributeError): - guild = helpers.MockGuild() - guild.aperture - - def test_mock_guild_rejects_accessing_methods_not_following_spec(self): - """Test if MockGuild throws AttributeError for method not existing in spec discord.Guild.""" - with self.assertRaises(AttributeError): - guild = helpers.MockGuild() - guild.volcyyy() - - def test_mock_guild_accepts_accessing_attributes_following_spec(self): - """Test if MockGuild accepts attribute access for valid attributes of discord.Guild.""" - guild = helpers.MockGuild() - guild.name - - def test_mock_guild_accepts_accessing_methods_following_spec(self): - """Test if MockGuild accepts method calls for valid methods of discord.Guild.""" - guild = helpers.MockGuild() - guild.by_category() - def test_mock_bot_default_initialization(self): """Tests if MockBot initializes with the correct values.""" bot = helpers.MockBot() @@ -305,10 +133,6 @@ class MockObjectTests(unittest.TestCase): # The `spec` argument makes sure `isistance` checks with `discord.ext.commands.Bot` pass self.assertIsInstance(bot, discord.ext.commands.Bot) - self.assertIsInstance(bot._before_invoke, helpers.AsyncMock) - self.assertIsInstance(bot._after_invoke, helpers.AsyncMock) - self.assertEqual(bot.user, helpers.MockMember(name="Python", user_id=123456789)) - def test_mock_context_default_initialization(self): """Tests if MockContext initializes with the correct values.""" context = helpers.MockContext() @@ -317,10 +141,213 @@ class MockObjectTests(unittest.TestCase): self.assertIsInstance(context, discord.ext.commands.Context) self.assertIsInstance(context.bot, helpers.MockBot) - self.assertIsInstance(context.send, helpers.AsyncMock) self.assertIsInstance(context.guild, helpers.MockGuild) self.assertIsInstance(context.author, helpers.MockMember) + def test_mocks_allows_access_to_attributes_part_of_spec(self): + """Accessing attributes that are valid for the objects they mock should succeed.""" + mocks = ( + (helpers.MockGuild(), 'name'), + (helpers.MockRole(), 'hoist'), + (helpers.MockMember(), 'display_name'), + (helpers.MockBot(), 'user'), + (helpers.MockContext(), 'invoked_with'), + (helpers.MockTextChannel(), 'last_message'), + (helpers.MockMessage(), 'mention_everyone'), + ) + + for mock, valid_attribute in mocks: + with self.subTest(mock=mock): + try: + getattr(mock, valid_attribute) + except AttributeError: + msg = f"accessing valid attribute `{valid_attribute}` raised an AttributeError" + self.fail(msg) + + @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') + @unittest.mock.patch(f'{__name__}.getattr') + def test_mock_allows_access_to_attributes_test(self, mock_getattr, mock_subtest): + """The valid attribute test should raise an AssertionError after an AttributeError.""" + mock_getattr.side_effect = AttributeError + + msg = "accessing valid attribute `name` raised an AttributeError" + with self.assertRaises(AssertionError, msg=msg): + self.test_mocks_allows_access_to_attributes_part_of_spec() + + def test_mocks_rejects_access_to_attributes_not_part_of_spec(self): + """Accessing attributes that are invalid for the objects they mock should fail.""" + mocks = ( + helpers.MockGuild(), + helpers.MockRole(), + helpers.MockMember(), + helpers.MockBot(), + helpers.MockContext(), + helpers.MockTextChannel(), + helpers.MockMessage(), + ) + + for mock in mocks: + with self.subTest(mock=mock): + with self.assertRaises(AttributeError): + mock.the_cake_is_a_lie + + def test_custom_mock_methods_are_valid_discord_object_methods(self): + """The `AsyncMock` attributes of the mocks should be valid for the class they're mocking.""" + mocks = ( + (helpers.MockGuild, helpers.guild_instance), + (helpers.MockRole, helpers.role_instance), + (helpers.MockMember, helpers.member_instance), + (helpers.MockBot, helpers.bot_instance), + (helpers.MockContext, helpers.context_instance), + (helpers.MockTextChannel, helpers.channel_instance), + (helpers.MockMessage, helpers.message_instance), + ) + + for mock_class, instance in mocks: + mock = mock_class() + async_methods = ( + attr for attr in dir(mock) if isinstance(getattr(mock, attr), helpers.AsyncMock) + ) + + # spec_mock = unittest.mock.MagicMock(spec=instance) + for method in async_methods: + with self.subTest(mock_class=mock_class, method=method): + try: + getattr(instance, method) + except AttributeError: + msg = f"method {method} is not a method attribute of {instance.__class__}" + self.fail(msg) + + @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') + def test_the_custom_mock_methods_test(self, subtest_mock): + """The custom method test should raise AssertionError for invalid methods.""" + class FakeMockBot(helpers.AttributeMock, unittest.mock.MagicMock): + """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, **kwargs): + super().__init__(spec=helpers.bot_instance, **kwargs) + + # Fake attribute + self.release_the_walrus = helpers.AsyncMock() + + with unittest.mock.patch("tests.helpers.MockBot", new=FakeMockBot): + msg = "method release_the_walrus is not a valid method of " + with self.assertRaises(AssertionError, msg=msg): + self.test_custom_mock_methods_are_valid_discord_object_methods() + + +class MockObjectTests(unittest.TestCase): + """Tests the mock objects and mixins we've defined.""" + + @classmethod + def setUpClass(cls): + cls.hashable_mocks = (helpers.MockRole, helpers.MockMember, helpers.MockGuild) + + def test_colour_mixin(self): + """Test if the ColourMixin adds aliasing of color -> colour for child classes.""" + class MockHemlock(unittest.mock.MagicMock, helpers.ColourMixin): + pass + + hemlock = MockHemlock() + hemlock.color = 1 + self.assertEqual(hemlock.colour, 1) + self.assertEqual(hemlock.colour, hemlock.color) + + def test_hashable_mixin_hash_returns_id(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly() + scragly.id = 10 + self.assertEqual(hash(scragly), scragly.id) + + def test_hashable_mixin_uses_id_for_equality_comparison(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly(spec=object) + scragly.id = 10 + eevee = MockScragly(spec=object) + eevee.id = 10 + python = MockScragly(spec=object) + python.id = 20 + + self.assertTrue(scragly == eevee) + self.assertFalse(scragly == python) + + def test_hashable_mixin_uses_id_for_nonequality_comparison(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly(spec=object) + scragly.id = 10 + eevee = MockScragly(spec=object) + eevee.id = 10 + python = MockScragly(spec=object) + python.id = 20 + + self.assertTrue(scragly != python) + self.assertFalse(scragly != eevee) + + def test_mock_class_with_hashable_mixin_uses_id_for_hashing(self): + """Test if the MagicMock subclasses that implement the HashableMixin use id for hash.""" + for mock in self.hashable_mocks: + with self.subTest(mock_class=mock): + instance = helpers.MockRole(role_id=100) + self.assertEqual(hash(instance), instance.id) + + def test_mock_class_with_hashable_mixin_uses_id_for_equality(self): + """Test if MagicMocks that implement the HashableMixin use id for equality comparisons.""" + for mock_class in self.hashable_mocks: + with self.subTest(mock_class=mock_class): + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() + + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 + + self.assertTrue(instance_one == instance_two) + self.assertFalse(instance_one == instance_three) + + def test_mock_class_with_hashable_mixin_uses_id_for_nonequality(self): + """Test if MagicMocks that implement HashableMixin use id for nonequality comparisons.""" + for mock_class in self.hashable_mocks: + with self.subTest(mock_class=mock_class): + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() + + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 + + self.assertFalse(instance_one != instance_two) + self.assertTrue(instance_one != instance_three) + + def test_spec_propagation_of_mock_subclasses(self): + """Test if the `spec` does not propagate to attributes of the mock object.""" + test_values = ( + (helpers.MockGuild, "region"), + (helpers.MockRole, "mentionable"), + (helpers.MockMember, "display_name"), + (helpers.MockBot, "owner_id"), + (helpers.MockContext, "command_failed"), + ) + + for mock_type, valid_attribute in test_values: + with self.subTest(mock_type=mock_type, attribute=valid_attribute): + mock = mock_type() + self.assertTrue(isinstance(mock, mock_type)) + attribute = getattr(mock, valid_attribute) + self.assertTrue(isinstance(attribute, mock_type.attribute_mocktype)) + def test_async_mock_provides_coroutine_for_dunder_call(self): """Test if AsyncMock objects have a coroutine for their __call__ method.""" async_mock = helpers.AsyncMock() -- cgit v1.2.3 From 0ccb798f03ecb92b73111ffc05ee0f446034142b Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 14:43:10 +0200 Subject: Move the `token_remover` cog tests to `unittest`. --- tests/cogs/__init__.py | 0 tests/cogs/test_token_remover.py | 139 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/test_token_remover.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py new file mode 100644 index 000000000..e5d3648de --- /dev/null +++ b/tests/cogs/test_token_remover.py @@ -0,0 +1,139 @@ +import asyncio +import logging +import unittest +from unittest.mock import MagicMock + +from discord import Colour + +from bot.cogs.token_remover import ( + DELETION_MESSAGE_TEMPLATE, + TokenRemover, + setup as setup_cog, +) +from bot.constants import Channels, Colours, Event, Icons +from tests.helpers import AsyncMock + + +class TokenRemoverTests(unittest.TestCase): + """Tests the `TokenRemover` cog.""" + + def setUp(self): + """Adds the cog, a bot, and a message to the instance for usage in tests.""" + self.bot = MagicMock() + self.bot.get_cog.return_value = MagicMock() + self.bot.get_cog.return_value.send_log_message = AsyncMock() + self.cog = TokenRemover(bot=self.bot) + + self.msg = MagicMock() + self.msg.author = MagicMock() + self.msg.author.__str__.return_value = 'lemon' + self.msg.author.bot = False + self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' + self.msg.author.id = 42 + self.msg.author.mention = '@lemon' + self.msg.channel.send = AsyncMock() + self.msg.channel.mention = '#lemonade-stand' + self.msg.content = '' + self.msg.delete = AsyncMock() + self.msg.id = 555 + + def test_is_valid_user_id_is_true_for_numeric_content(self): + """A string decoding to numeric characters is a valid user ID.""" + # MTIz = base64(123) + self.assertTrue(TokenRemover.is_valid_user_id('MTIz')) + + def test_is_valid_user_id_is_false_for_alphabetic_content(self): + """A string decoding to alphabetic characters is not a valid user ID.""" + # YWJj = base64(abc) + self.assertFalse(TokenRemover.is_valid_user_id('YWJj')) + + def test_is_valid_timestamp_is_true_for_valid_timestamps(self): + """A string decoding to a valid timestamp should be recognized as such.""" + self.assertTrue(TokenRemover.is_valid_timestamp('DN9r_A')) + + def test_is_valid_timestamp_is_false_for_invalid_values(self): + """A string not decoding to a valid timestamp should not be recognized as such.""" + # MTIz = base64(123) + self.assertFalse(TokenRemover.is_valid_timestamp('MTIz')) + + def test_mod_log_property(self): + """The `mod_log` property should ask the bot to return the `ModLog` cog.""" + self.bot.get_cog.return_value = 'lemon' + self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) + self.bot.get_cog.assert_called_once_with('ModLog') + + def test_ignores_bot_messages(self): + """When the message event handler is called with a bot message, nothing is done.""" + self.msg.author.bot = True + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_ignores_messages_without_tokens(self): + """Messages without anything looking like a token are ignored.""" + for content in ('', 'lemon wins'): + with self.subTest(content=content): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_ignores_messages_with_invalid_tokens(self): + """Messages with values that are invalid tokens are ignored.""" + for content in ('foo.bar.baz', 'x.y.'): + with self.subTest(content=content): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_censors_valid_tokens(self): + """Valid tokens are censored.""" + cases = ( + # (content, censored_token) + ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), + ) + + for content, censored_token in cases: + with self.subTest(content=content, censored_token=censored_token): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + with self.assertLogs(logger='bot.cogs.token_remover', level=logging.DEBUG) as cm: + self.assertIsNone(asyncio.run(coroutine)) # no return value + + [line] = cm.output + log_message = ( + "Censored a seemingly valid token sent by " + "lemon (`42`) in #lemonade-stand, " + f"token was `{censored_token}`" + ) + self.assertIn(log_message, line) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_called_once_with( + DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') + ) + self.bot.get_cog.assert_called_with('ModLog') + self.msg.author.avatar_url_as.assert_called_once_with(static_format='png') + + mod_log = self.bot.get_cog.return_value + mod_log.ignore.assert_called_once_with(Event.message_delete, self.msg.id) + mod_log.send_log_message.assert_called_once_with( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=log_message, + thumbnail='picture-lemon.png', + channel_id=Channels.mod_alerts + ) + + +class TokenRemoverSetupTests(unittest.TestCase): + """Tests setup of the `TokenRemover` cog.""" + + def test_setup(self): + """Setup of the cog should log a message at `INFO` level.""" + bot = MagicMock() + with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: + setup_cog(bot) + + [line] = cm.output + bot.add_cog.assert_called_once() + self.assertIn("Cog loaded: TokenRemover", line) -- cgit v1.2.3 From ae0177432e26c5bde66db46cdeb7850a7dddeca0 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 14 Oct 2019 19:15:07 +0200 Subject: Use `MockBot`. --- tests/cogs/test_token_remover.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py index e5d3648de..3738b6d1b 100644 --- a/tests/cogs/test_token_remover.py +++ b/tests/cogs/test_token_remover.py @@ -11,7 +11,7 @@ from bot.cogs.token_remover import ( setup as setup_cog, ) from bot.constants import Channels, Colours, Event, Icons -from tests.helpers import AsyncMock +from tests.helpers import AsyncMock, MockBot class TokenRemoverTests(unittest.TestCase): @@ -19,7 +19,7 @@ class TokenRemoverTests(unittest.TestCase): def setUp(self): """Adds the cog, a bot, and a message to the instance for usage in tests.""" - self.bot = MagicMock() + self.bot = MockBot() self.bot.get_cog.return_value = MagicMock() self.bot.get_cog.return_value.send_log_message = AsyncMock() self.cog = TokenRemover(bot=self.bot) @@ -130,7 +130,7 @@ class TokenRemoverSetupTests(unittest.TestCase): def test_setup(self): """Setup of the cog should log a message at `INFO` level.""" - bot = MagicMock() + bot = MockBot() with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: setup_cog(bot) -- cgit v1.2.3 From e66237395ab1470002f5dd61de9eeb19ca4600eb Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 22:28:51 +0200 Subject: Make test_token_remover use our discord Mocks This commit replaces the standard MagicMocks by our specialized mocks for discord.py objects. It also adds the missing `channel` attribute to the `tests.helpers.MockMessage` mock and moves the file to the correct folder. --- tests/bot/cogs/test_token_remover.py | 135 ++++++++++++++++++++++++++++++++++ tests/cogs/test_token_remover.py | 139 ----------------------------------- tests/helpers.py | 1 + 3 files changed, 136 insertions(+), 139 deletions(-) create mode 100644 tests/bot/cogs/test_token_remover.py delete mode 100644 tests/cogs/test_token_remover.py diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py new file mode 100644 index 000000000..dfb1bafc9 --- /dev/null +++ b/tests/bot/cogs/test_token_remover.py @@ -0,0 +1,135 @@ +import asyncio +import logging +import unittest +from unittest.mock import MagicMock + +from discord import Colour + +from bot.cogs.token_remover import ( + DELETION_MESSAGE_TEMPLATE, + TokenRemover, + setup as setup_cog, +) +from bot.constants import Channels, Colours, Event, Icons +from tests.helpers import AsyncMock, MockBot, MockMessage + + +class TokenRemoverTests(unittest.TestCase): + """Tests the `TokenRemover` cog.""" + + def setUp(self): + """Adds the cog, a bot, and a message to the instance for usage in tests.""" + self.bot = MockBot() + self.bot.get_cog.return_value = MagicMock() + self.bot.get_cog.return_value.send_log_message = AsyncMock() + self.cog = TokenRemover(bot=self.bot) + + self.msg = MockMessage(message_id=555, content='') + self.msg.author.__str__ = MagicMock() + self.msg.author.__str__.return_value = 'lemon' + self.msg.author.bot = False + self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' + self.msg.author.id = 42 + self.msg.author.mention = '@lemon' + self.msg.channel.mention = "#lemonade-stand" + + def test_is_valid_user_id_is_true_for_numeric_content(self): + """A string decoding to numeric characters is a valid user ID.""" + # MTIz = base64(123) + self.assertTrue(TokenRemover.is_valid_user_id('MTIz')) + + def test_is_valid_user_id_is_false_for_alphabetic_content(self): + """A string decoding to alphabetic characters is not a valid user ID.""" + # YWJj = base64(abc) + self.assertFalse(TokenRemover.is_valid_user_id('YWJj')) + + def test_is_valid_timestamp_is_true_for_valid_timestamps(self): + """A string decoding to a valid timestamp should be recognized as such.""" + self.assertTrue(TokenRemover.is_valid_timestamp('DN9r_A')) + + def test_is_valid_timestamp_is_false_for_invalid_values(self): + """A string not decoding to a valid timestamp should not be recognized as such.""" + # MTIz = base64(123) + self.assertFalse(TokenRemover.is_valid_timestamp('MTIz')) + + def test_mod_log_property(self): + """The `mod_log` property should ask the bot to return the `ModLog` cog.""" + self.bot.get_cog.return_value = 'lemon' + self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) + self.bot.get_cog.assert_called_once_with('ModLog') + + def test_ignores_bot_messages(self): + """When the message event handler is called with a bot message, nothing is done.""" + self.msg.author.bot = True + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_ignores_messages_without_tokens(self): + """Messages without anything looking like a token are ignored.""" + for content in ('', 'lemon wins'): + with self.subTest(content=content): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_ignores_messages_with_invalid_tokens(self): + """Messages with values that are invalid tokens are ignored.""" + for content in ('foo.bar.baz', 'x.y.'): + with self.subTest(content=content): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_censors_valid_tokens(self): + """Valid tokens are censored.""" + cases = ( + # (content, censored_token) + ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), + ) + + for content, censored_token in cases: + with self.subTest(content=content, censored_token=censored_token): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + with self.assertLogs(logger='bot.cogs.token_remover', level=logging.DEBUG) as cm: + self.assertIsNone(asyncio.run(coroutine)) # no return value + + [line] = cm.output + log_message = ( + "Censored a seemingly valid token sent by " + "lemon (`42`) in #lemonade-stand, " + f"token was `{censored_token}`" + ) + self.assertIn(log_message, line) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_called_once_with( + DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') + ) + self.bot.get_cog.assert_called_with('ModLog') + self.msg.author.avatar_url_as.assert_called_once_with(static_format='png') + + mod_log = self.bot.get_cog.return_value + mod_log.ignore.assert_called_once_with(Event.message_delete, self.msg.id) + mod_log.send_log_message.assert_called_once_with( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=log_message, + thumbnail='picture-lemon.png', + channel_id=Channels.mod_alerts + ) + + +class TokenRemoverSetupTests(unittest.TestCase): + """Tests setup of the `TokenRemover` cog.""" + + def test_setup(self): + """Setup of the cog should log a message at `INFO` level.""" + bot = MockBot() + with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: + setup_cog(bot) + + [line] = cm.output + bot.add_cog.assert_called_once() + self.assertIn("Cog loaded: TokenRemover", line) diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py deleted file mode 100644 index 3738b6d1b..000000000 --- a/tests/cogs/test_token_remover.py +++ /dev/null @@ -1,139 +0,0 @@ -import asyncio -import logging -import unittest -from unittest.mock import MagicMock - -from discord import Colour - -from bot.cogs.token_remover import ( - DELETION_MESSAGE_TEMPLATE, - TokenRemover, - setup as setup_cog, -) -from bot.constants import Channels, Colours, Event, Icons -from tests.helpers import AsyncMock, MockBot - - -class TokenRemoverTests(unittest.TestCase): - """Tests the `TokenRemover` cog.""" - - def setUp(self): - """Adds the cog, a bot, and a message to the instance for usage in tests.""" - self.bot = MockBot() - self.bot.get_cog.return_value = MagicMock() - self.bot.get_cog.return_value.send_log_message = AsyncMock() - self.cog = TokenRemover(bot=self.bot) - - self.msg = MagicMock() - self.msg.author = MagicMock() - self.msg.author.__str__.return_value = 'lemon' - self.msg.author.bot = False - self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' - self.msg.author.id = 42 - self.msg.author.mention = '@lemon' - self.msg.channel.send = AsyncMock() - self.msg.channel.mention = '#lemonade-stand' - self.msg.content = '' - self.msg.delete = AsyncMock() - self.msg.id = 555 - - def test_is_valid_user_id_is_true_for_numeric_content(self): - """A string decoding to numeric characters is a valid user ID.""" - # MTIz = base64(123) - self.assertTrue(TokenRemover.is_valid_user_id('MTIz')) - - def test_is_valid_user_id_is_false_for_alphabetic_content(self): - """A string decoding to alphabetic characters is not a valid user ID.""" - # YWJj = base64(abc) - self.assertFalse(TokenRemover.is_valid_user_id('YWJj')) - - def test_is_valid_timestamp_is_true_for_valid_timestamps(self): - """A string decoding to a valid timestamp should be recognized as such.""" - self.assertTrue(TokenRemover.is_valid_timestamp('DN9r_A')) - - def test_is_valid_timestamp_is_false_for_invalid_values(self): - """A string not decoding to a valid timestamp should not be recognized as such.""" - # MTIz = base64(123) - self.assertFalse(TokenRemover.is_valid_timestamp('MTIz')) - - def test_mod_log_property(self): - """The `mod_log` property should ask the bot to return the `ModLog` cog.""" - self.bot.get_cog.return_value = 'lemon' - self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) - self.bot.get_cog.assert_called_once_with('ModLog') - - def test_ignores_bot_messages(self): - """When the message event handler is called with a bot message, nothing is done.""" - self.msg.author.bot = True - coroutine = self.cog.on_message(self.msg) - self.assertIsNone(asyncio.run(coroutine)) - - def test_ignores_messages_without_tokens(self): - """Messages without anything looking like a token are ignored.""" - for content in ('', 'lemon wins'): - with self.subTest(content=content): - self.msg.content = content - coroutine = self.cog.on_message(self.msg) - self.assertIsNone(asyncio.run(coroutine)) - - def test_ignores_messages_with_invalid_tokens(self): - """Messages with values that are invalid tokens are ignored.""" - for content in ('foo.bar.baz', 'x.y.'): - with self.subTest(content=content): - self.msg.content = content - coroutine = self.cog.on_message(self.msg) - self.assertIsNone(asyncio.run(coroutine)) - - def test_censors_valid_tokens(self): - """Valid tokens are censored.""" - cases = ( - # (content, censored_token) - ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), - ) - - for content, censored_token in cases: - with self.subTest(content=content, censored_token=censored_token): - self.msg.content = content - coroutine = self.cog.on_message(self.msg) - with self.assertLogs(logger='bot.cogs.token_remover', level=logging.DEBUG) as cm: - self.assertIsNone(asyncio.run(coroutine)) # no return value - - [line] = cm.output - log_message = ( - "Censored a seemingly valid token sent by " - "lemon (`42`) in #lemonade-stand, " - f"token was `{censored_token}`" - ) - self.assertIn(log_message, line) - - self.msg.delete.assert_called_once_with() - self.msg.channel.send.assert_called_once_with( - DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') - ) - self.bot.get_cog.assert_called_with('ModLog') - self.msg.author.avatar_url_as.assert_called_once_with(static_format='png') - - mod_log = self.bot.get_cog.return_value - mod_log.ignore.assert_called_once_with(Event.message_delete, self.msg.id) - mod_log.send_log_message.assert_called_once_with( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=log_message, - thumbnail='picture-lemon.png', - channel_id=Channels.mod_alerts - ) - - -class TokenRemoverSetupTests(unittest.TestCase): - """Tests setup of the `TokenRemover` cog.""" - - def test_setup(self): - """Setup of the cog should log a message at `INFO` level.""" - bot = MockBot() - with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: - setup_cog(bot) - - [line] = cm.output - bot.add_cog.assert_called_once() - self.assertIn("Cog loaded: TokenRemover", line) diff --git a/tests/helpers.py b/tests/helpers.py index f8e8357f1..892d42e6c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -415,6 +415,7 @@ class MockMessage(AttributeMock, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(spec=message_instance, **kwargs) self.author = MockMember() + self.channel = MockTextChannel() # `discord.Message` coroutines self.ack = AsyncMock() -- cgit v1.2.3 From 20f1cedef806d5bec84533e4ae99f45469c20132 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 22:42:56 +0200 Subject: Remove empty tests.cogs folder --- tests/cogs/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/cogs/__init__.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 -- cgit v1.2.3 From 003f8fe85cbb90d51225580bc35b91ebf21fd0a3 Mon Sep 17 00:00:00 2001 From: bendiller Date: Mon, 14 Oct 2019 17:59:37 -0600 Subject: Improve code readability and docstring --- bot/cogs/antimalware.py | 25 ++++++++++++++----------- bot/constants.py | 1 + config-default.yml | 20 ++++++++++++++++++-- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 94566c156..156239a63 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -9,29 +9,32 @@ log = logging.getLogger(__name__) class AntiMalware(Cog): - """Cog providing anti-malware behavior.""" + """Delete messages which contain attachments with non-whitelisted file extensions.""" def __init__(self, bot: Bot): self.bot = bot - self.whitelist = tuple(AntiMalwareConfig.whitelist) @Cog.listener() async def on_message(self, message: Message) -> None: """Identify messages with prohibited attachments.""" - rejected_attachments = [a for a in message.attachments if - not a.filename.lower().endswith(self.whitelist)] - detected_pyfile = len([a for a in message.attachments if a.filename.lower().endswith('.py')]) > 0 - - if len(rejected_attachments) > 0: + rejected_attachments = list() + detected_pyfile = list() + for attachment in message.attachments: + if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)): + rejected_attachments.append(attachment) + if attachment.filename.lower().endswith('.py'): + detected_pyfile.append(attachment) + + if rejected_attachments: # Send a message to the user indicating the problem (with special treatment for .py) author = message.author if detected_pyfile: - msg = f"{author.mention}, it looks like you tried to attach a Python file - please " \ - f"use a code-pasting service such as https://paste.pythondiscord.com/ instead." + msg = (f"{author.mention}, it looks like you tried to attach a Python file - please " + f"use a code-pasting service such as https://paste.pythondiscord.com/ instead.") else: meta_channel = self.bot.get_channel(Channels.meta) - msg = f"{author.mention}, it looks like you tried to attach a file type we don't " \ - f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake." + msg = (f"{author.mention}, it looks like you tried to attach a file type we don't " + f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake.") await message.channel.send(msg) diff --git a/bot/constants.py b/bot/constants.py index aecd6be59..13f25e4f8 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -345,6 +345,7 @@ class Channels(metaclass=YAMLGetter): help_7: int helpers: int message_log: int + meta: int mod_alerts: int modlog: int off_topic_0: int diff --git a/config-default.yml b/config-default.yml index 30d505d6d..071478206 100644 --- a/config-default.yml +++ b/config-default.yml @@ -324,8 +324,24 @@ anti_spam: anti_malware: - whitelist: ['.bmp', '.gif', '.jpg', '.jpeg', '.png', '.tiff', # Images - '.3gp', '.3g2', '.avi', '.h264', '.m4v', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.wmv' ] # Videos + whitelist: + - '.3gp' + - '.3g2' + - '.avi' + - '.bmp' + - '.gif' + - '.h264' + - '.jpg' + - '.jpeg' + - '.m4v' + - '.mkv' + - '.mov' + - '.mp4' + - '.mpeg' + - '.mpg' + - '.png' + - '.tiff' + - '.wmv' reddit: -- cgit v1.2.3 From 2b25644e5e7f0abaa332750ed9666d845534fc35 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Tue, 15 Oct 2019 11:50:56 +1100 Subject: Utilise `tasks.loop` and a webhook for reddit postings --- bot/cogs/reddit.py | 223 +++++++++++++++++++---------------------------------- bot/constants.py | 2 +- config-default.yml | 2 +- 3 files changed, 80 insertions(+), 147 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 0f575cece..58a6d3c7e 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -2,13 +2,14 @@ import asyncio import logging import random import textwrap -from datetime import datetime, timedelta +from datetime import datetime from typing import List -from discord import Colour, Embed, Message, TextChannel +from discord import Colour, Embed, TextChannel from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.tasks import loop -from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES +from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES, Webhooks from bot.converters import Subreddit from bot.decorators import with_role from bot.pagination import LinePaginator @@ -26,15 +27,25 @@ class Reddit(Cog): def __init__(self, bot: Bot): self.bot = bot - self.reddit_channel = None + self.webhook = None # set in on_ready + bot.loop.create_task(self.init_reddit_ready()) - self.prev_lengths = {} - self.last_ids = {} + self.auto_poster_loop.start() - self.new_posts_task = None - self.top_weekly_posts_task = None + def cog_unload(self) -> None: + """Stops the loops when the cog is unloaded.""" + self.auto_poster_loop.cancel() - self.bot.loop.create_task(self.init_reddit_polling()) + async def init_reddit_ready(self) -> None: + """Sets the reddit webhook when the cog is loaded.""" + await self.bot.wait_until_ready() + if not self.webhook: + self.webhook = await self.bot.fetch_webhook(Webhooks.reddit) + + @property + def channel(self) -> TextChannel: + """Returns the #reddit channel object from the bot's cache.""" + return self.bot.get_channel(Channels.reddit) async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: """A helper method to fetch a certain amount of Reddit posts at a given route.""" @@ -63,23 +74,18 @@ class Reddit(Cog): log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") return list() # Failed to get appropriate response within allowed number of retries. - async def send_top_posts( - self, channel: TextChannel, subreddit: Subreddit, content: str = None, time: str = "all" - ) -> Message: - """Create an embed for the top posts, then send it in a given TextChannel.""" + async def get_top_posts(self, subreddit: Subreddit, time: str = "all") -> Embed: + """Returns an embed for the top posts of the given subreddit.""" # Create the new spicy embed. - embed = Embed() - embed.description = "" - - # Get the posts - async with channel.typing(): - posts = await self.fetch_posts( - route=f"{subreddit}/top", - amount=5, - params={ - "t": time - } - ) + embed = Embed(description="") + + posts = await self.fetch_posts( + route=f"{subreddit}/top", + amount=5, + params={ + "t": time + } + ) if not posts: embed.title = random.choice(ERROR_REPLIES) @@ -89,9 +95,7 @@ class Reddit(Cog): "If this problem persists, please let us know." ) - return await channel.send( - embed=embed - ) + return embed for post in posts: data = post["data"] @@ -115,103 +119,51 @@ class Reddit(Cog): ) embed.colour = Colour.blurple() + return embed - return await channel.send( - content=content, - embed=embed - ) - - async def poll_new_posts(self) -> None: - """Periodically search for new subreddit posts.""" - while True: - await asyncio.sleep(RedditConfig.request_delay) - - for subreddit in RedditConfig.subreddits: - # Make a HEAD request to the subreddit - head_response = await self.bot.http_session.head( - url=f"{self.URL}/{subreddit}/new.rss", - headers=self.HEADERS - ) - - content_length = head_response.headers["content-length"] - - # If the content is the same size as before, assume there's no new posts. - if content_length == self.prev_lengths.get(subreddit, None): - continue - - self.prev_lengths[subreddit] = content_length - - # Now we can actually fetch the new data - posts = await self.fetch_posts(f"{subreddit}/new") - new_posts = [] - - # Only show new posts if we've checked before. - if subreddit in self.last_ids: - for post in posts: - data = post["data"] - - # Convert the ID to an integer for easy comparison. - int_id = int(data["id"], 36) + @loop() + async def auto_poster_loop(self) -> None: + """Post the top 5 posts daily, and the top 5 posts weekly.""" + # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter + now = datetime.utcnow() + midnight_tomorrow = now.replace(day=now.day + 1, hour=0, minute=0, second=0) + seconds_until = (midnight_tomorrow - now).total_seconds() - # If we've already seen this post, finish checking - if int_id <= self.last_ids[subreddit]: - break + await asyncio.sleep(seconds_until) - embed_data = { - "title": textwrap.shorten(data["title"], width=64, placeholder="..."), - "text": textwrap.shorten(data["selftext"], width=128, placeholder="..."), - "url": self.URL + data["permalink"], - "author": data["author"] - } - - new_posts.append(embed_data) - - self.last_ids[subreddit] = int(posts[0]["data"]["id"], 36) - - # Send all of the new posts as spicy embeds - for data in new_posts: - embed = Embed() - - embed.title = data["title"] - embed.url = data["url"] - embed.description = data["text"] - embed.set_footer(text=f"Posted by u/{data['author']} in {subreddit}") - embed.colour = Colour.blurple() - - await self.reddit_channel.send(embed=embed) + await self.bot.wait_until_ready() + if not self.webhook: + await self.bot.fetch_webhook(Webhooks.reddit) - log.trace(f"Sent {len(new_posts)} new {subreddit} posts to channel {self.reddit_channel.id}.") + if datetime.utcnow().weekday() == 0: + await self.top_weekly_posts() + # if it's a monday send the top weekly posts - async def poll_top_weekly_posts(self) -> None: - """Post a summary of the top posts every week.""" - while True: - now = datetime.utcnow() + for subreddit in RedditConfig.subreddits: + top_posts = await self.get_top_posts(subreddit=subreddit, time="day") + await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts) - # Calculate the amount of seconds until midnight next monday. - monday = now + timedelta(days=7 - now.weekday()) - monday = monday.replace(hour=0, minute=0, second=0) - until_monday = (monday - now).total_seconds() + async def top_weekly_posts(self) -> None: + """Post a summary of the top posts.""" + for subreddit in RedditConfig.subreddits: + # Send and pin the new weekly posts. + top_posts = await self.get_top_posts(subreddit=subreddit, time="week") - await asyncio.sleep(until_monday) + message = await self.webhook.send(wait=True, username=f"{subreddit} Top Weekly Posts", embed=top_posts) - for subreddit in RedditConfig.subreddits: - # Send and pin the new weekly posts. - message = await self.send_top_posts( - channel=self.reddit_channel, - subreddit=subreddit, - content=f"This week's top {subreddit} posts have arrived!", - time="week" - ) + if subreddit.lower() == "r/python": + if not self.channel: + log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") + return - if subreddit.lower() == "r/python": - # Remove the oldest pins so that only 5 remain at most. - pins = await self.reddit_channel.pins() + # Remove the oldest pins so that only 5 remain at most. + pins = await self.channel.pins() - while len(pins) >= 5: - await pins[-1].unpin() - del pins[-1] + while len(pins) >= 5: + await pins[-1].unpin() + del pins[-1] - await message.pin() + await message.pin() @group(name="reddit", invoke_without_command=True) async def reddit_group(self, ctx: Context) -> None: @@ -221,32 +173,26 @@ class Reddit(Cog): @reddit_group.command(name="top") async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of all time from a given subreddit.""" - await self.send_top_posts( - channel=ctx.channel, - subreddit=subreddit, - content=f"Here are the top {subreddit} posts of all time!", - time="all" - ) + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="all") + + await ctx.send(content=f"Here are the top {subreddit} posts of all time!", embed=embed) @reddit_group.command(name="daily") async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of today from a given subreddit.""" - await self.send_top_posts( - channel=ctx.channel, - subreddit=subreddit, - content=f"Here are today's top {subreddit} posts!", - time="day" - ) + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="day") + + await ctx.send(content=f"Here are today's top {subreddit} posts!", embed=embed) @reddit_group.command(name="weekly") async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of this week from a given subreddit.""" - await self.send_top_posts( - channel=ctx.channel, - subreddit=subreddit, - content=f"Here are this week's top {subreddit} posts!", - time="week" - ) + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="week") + + await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed) @with_role(*STAFF_ROLES) @reddit_group.command(name="subreddits", aliases=("subs",)) @@ -264,19 +210,6 @@ class Reddit(Cog): max_lines=15 ) - async def init_reddit_polling(self) -> None: - """Initiate reddit post event loop.""" - await self.bot.wait_until_ready() - self.reddit_channel = await self.bot.fetch_channel(Channels.reddit) - - if self.reddit_channel is not None: - if self.new_posts_task is None: - self.new_posts_task = self.bot.loop.create_task(self.poll_new_posts()) - if self.top_weekly_posts_task is None: - self.top_weekly_posts_task = self.bot.loop.create_task(self.poll_top_weekly_posts()) - else: - log.warning("Couldn't locate a channel for subreddit relaying.") - def setup(bot: Bot) -> None: """Reddit cog load.""" diff --git a/bot/constants.py b/bot/constants.py index f4f45eb2c..ffe32e1ea 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -364,6 +364,7 @@ class Webhooks(metaclass=YAMLGetter): talent_pool: int big_brother: int + reddit: int class Roles(metaclass=YAMLGetter): @@ -438,7 +439,6 @@ class URLs(metaclass=YAMLGetter): class Reddit(metaclass=YAMLGetter): section = "reddit" - request_delay: int subreddits: list diff --git a/config-default.yml b/config-default.yml index ca405337e..52e0b880c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -141,6 +141,7 @@ guild: webhooks: talent_pool: 569145364800602132 big_brother: 569133704568373283 + reddit: 123456789 filter: @@ -323,7 +324,6 @@ anti_spam: reddit: - request_delay: 60 subreddits: - 'r/Python' -- cgit v1.2.3 From 7120ae014ebcef984c619e859cdad5cac9073a45 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 15 Oct 2019 19:39:43 +0800 Subject: Rename `triggered` to `match` --- bot/cogs/filtering.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 18c2550a2..13b8b9678 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -154,11 +154,11 @@ class Filtering(Cog): # Does the filter only need the message content or the full message? if _filter["content_only"]: - triggered = await _filter["function"](msg.content) + match = await _filter["function"](msg.content) else: - triggered = await _filter["function"](msg) + match = await _filter["function"](msg) - if triggered: + if match: # If this is a filter (not a watchlist), we should delete the message. if _filter["type"] == "filter": try: @@ -186,11 +186,9 @@ class Filtering(Cog): # Word and match stats for watch_words and watch_tokens if filter_name in ("watch_words", "watch_tokens"): - m = triggered - match = m[0] - surroundings = m.string[max(m.start() - 10, 0): m.end() + 10] + surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] message_content = ( - f"**Match:** '{match}'\n" + f"**Match:** '{match[0]}'\n" f"**Location:** '...{surroundings}...'\n" f"\n**Original Message:**\n{msg.content}" ) @@ -212,7 +210,7 @@ class Filtering(Cog): if filter_name == "filter_invites": additional_embeds = [] - for invite, data in triggered.items(): + for invite, data in match.items(): embed = discord.Embed(description=( f"**Members:**\n{data['members']}\n" f"**Active:**\n{data['active']}" -- cgit v1.2.3 From c209cc6544aaa8c6b6635ced1c000d50d1bee890 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 15 Oct 2019 22:25:52 +0800 Subject: Fix rule alias. Allow rule alias to take rule numbers, passes them to the `site rules` command. Rules are now 1-indexed to conform with the representation on the website. --- bot/cogs/alias.py | 6 +++--- bot/cogs/site.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 6648805e9..5190c559b 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -79,10 +79,10 @@ class Alias (Cog): """Alias for invoking site faq.""" await self.invoke(ctx, "site faq") - @command(name="rules", hidden=True) - async def site_rules_alias(self, ctx: Context) -> None: + @command(name="rules", aliases=("rule",), hidden=True) + async def site_rules_alias(self, ctx: Context, *rules: int) -> None: """Alias for invoking site rules.""" - await self.invoke(ctx, "site rules") + await self.invoke(ctx, "site rules", *rules) @command(name="reload", hidden=True) async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None: diff --git a/bot/cogs/site.py b/bot/cogs/site.py index c3bdf85e4..d95359159 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -126,15 +126,15 @@ class Site(Cog): invalid_indices = tuple( pick for pick in rules - if pick < 0 or pick >= len(full_rules) + if pick < 1 or pick > len(full_rules) ) if invalid_indices: indices = ', '.join(map(str, invalid_indices)) - await ctx.send(f":x: Invalid rule indices {indices}") + await ctx.send(f":x: Invalid rule indices: {indices}") return - final_rules = tuple(f"**{pick}.** {full_rules[pick]}" for pick in rules) + final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From 79ed098809ce3cfaa0fa75608f6f6a85af2a90dd Mon Sep 17 00:00:00 2001 From: Jens Date: Tue, 15 Oct 2019 23:36:36 +0200 Subject: Unload cog on auth error and fix linting warnings --- bot/cogs/reddit.py | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 25df014f8..451d2bf4c 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -2,10 +2,10 @@ import asyncio import logging import random import textwrap -from aiohttp import BasicAuth from datetime import datetime, timedelta from typing import List +from aiohttp import BasicAuth from discord import Colour, Embed, Message, TextChannel from discord.ext import tasks from discord.ext.commands import Bot, Cog, Context, group @@ -46,7 +46,7 @@ class Reddit(Cog): @tasks.loop(hours=0.99) # access tokens are valid for one hour async def refresh_access_token(self) -> None: - """Refresh the access token""" + """Refresh Reddits access token.""" headers = {"Authorization": self.client_auth} data = { "grant_type": "refresh_token", @@ -54,7 +54,7 @@ class Reddit(Cog): } response = await self.bot.http_session.post( - url = f"{self.URL}/api/v1/access_token", + url=f"{self.URL}/api/v1/access_token", headers=headers, data=data, ) @@ -68,7 +68,7 @@ class Reddit(Cog): @refresh_access_token.before_loop async def get_tokens(self) -> None: - """Get Reddit access and refresh tokens""" + """Get Reddit access and refresh tokens.""" await self.bot.wait_until_ready() headers = {"User-Agent": self.USER_AGENT} @@ -77,16 +77,16 @@ class Reddit(Cog): "duration": "permanent" } - if RedditConfig.client_id and RedditConfig.secret: - self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) + self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) - response = await self.bot.http_session.post( - url=f"{self.URL}/api/v1/access_token", - headers=headers, - auth=self.client_auth, - data=data - ) + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/access_token", + headers=headers, + auth=self.client_auth, + data=data + ) + if response.status == 200 and response.content_type == "application/json": content = await response.json() self.access_token = content["access_token"] self.refresh_token = content["refresh_token"] @@ -95,12 +95,9 @@ class Reddit(Cog): "User-Agent": self.USER_AGENT } else: - self.client_auth = None - self.access_token = None - self.refresh_token = None - self.headers = None - - log.error("Unable to find client credentials.") + log.error("Authentication with Reddit API failed. Unloading extension.") + self.bot.remove_cog(self.__class__.__name__) + return async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: """A helper method to fetch a certain amount of Reddit posts at a given route.""" @@ -123,7 +120,7 @@ class Reddit(Cog): content = await response.json() posts = content["data"]["children"] return posts[:amount] - + await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") -- cgit v1.2.3 From d9b4f391f914b680bdf4d5b93882abe11b8dc9d4 Mon Sep 17 00:00:00 2001 From: bendiller Date: Wed, 16 Oct 2019 17:42:48 -0600 Subject: Improve code readability and provide early exit from loop --- bot/cogs/antimalware.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 156239a63..2ef61e8ad 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -17,24 +17,29 @@ class AntiMalware(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Identify messages with prohibited attachments.""" - rejected_attachments = list() - detected_pyfile = list() + rejected_attachments = False + detected_pyfile = False for attachment in message.attachments: if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)): - rejected_attachments.append(attachment) + rejected_attachments = True if attachment.filename.lower().endswith('.py'): - detected_pyfile.append(attachment) + detected_pyfile = True + break # Other detections irrelevant because we prioritize the .py message. if rejected_attachments: # Send a message to the user indicating the problem (with special treatment for .py) author = message.author if detected_pyfile: - msg = (f"{author.mention}, it looks like you tried to attach a Python file - please " - f"use a code-pasting service such as https://paste.pythondiscord.com/ instead.") + msg = ( + f"{author.mention}, it looks like you tried to attach a Python file - please " + f"use a code-pasting service such as https://paste.pythondiscord.com/ instead." + ) else: meta_channel = self.bot.get_channel(Channels.meta) - msg = (f"{author.mention}, it looks like you tried to attach a file type we don't " - f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake.") + msg = ( + f"{author.mention}, it looks like you tried to attach a file type we don't " + f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake." + ) await message.channel.send(msg) -- cgit v1.2.3 From 23d4acfef58465f08b4f62d45bf22cdad976f3f3 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 17 Oct 2019 13:17:55 +0700 Subject: Fix defcon having wrong text when disabling. #### Closes #539 This pull request fixes the issue for wrong status in the embed, as well as unifying certains if else to prevent similar future errors. Most notable code is here: ```python log_msg = f"**Staffer:** {actor} (`{actor.id}`)\n" if change.lower() == "enabled": icon = Icons.defcon_enabled color = Colours.soft_green status_msg = "DEFCON enabled" log_msg += f"**Days:** {self.days.days}\n\n" elif change.lower() == "disabled": icon = Icons.defcon_disabled color = Colours.soft_red status_msg = "DEFCON enabled" elif change.lower() == "updated": icon = Icons.defcon_updated color = Colour.blurple() status_msg = "DEFCON updated" log_msg += f"**Days:** {self.days.days}\n\n" ``` To remedy this issue, the class will now have a private class variable ```py _defcon_log_info: Dict[str, Tuple] = { 'enabled': (Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n"), 'disabled': (Icons.defcon_disabled, Colours.soft_red, ""), 'updated': (Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n"), } ``` Another big change is the introduction of `_defcon_action` to unify the process all defcon actions has to go through: - Try to do the action. - Log error if there is any. - Send log message. --- bot/cogs/defcon.py | 96 ++++++++++++++++++++++++++---------------------------- 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 38a0915e5..e41605109 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -1,5 +1,6 @@ import logging from datetime import datetime, timedelta +from typing import Awaitable, Dict, Tuple from discord import Colour, Embed, Member from discord.ext.commands import Bot, Cog, Context, group @@ -30,6 +31,12 @@ class Defcon(Cog): days = None # type: timedelta enabled = False # type: bool + _defcon_log_info: Dict[str, Tuple] = { + 'enabled': (Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n"), + 'disabled': (Icons.defcon_disabled, Colours.soft_red, ""), + 'updated': (Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n"), + } + def __init__(self, bot: Bot): self.bot = bot self.channel = None @@ -46,6 +53,7 @@ class Defcon(Cog): """On cog load, try to synchronize DEFCON settings to the API.""" await self.bot.wait_until_ready() self.channel = await self.bot.fetch_channel(Channels.defcon) + try: response = await self.bot.api_client.get('bot/bot-settings/defcon') data = response['data'] @@ -107,6 +115,18 @@ class Defcon(Cog): """Check the DEFCON status or run a subcommand.""" await ctx.invoke(self.bot.get_command("help"), "defcon") + async def _defcon_action(self, ctx: Context, action: Awaitable[None], action_type: str) -> None: + """Providing a structured way to do an defcon actions - enabled / disabled / updated.""" + error = None + try: + await action + except Exception as err: + log.exception("Unable to update DEFCON settings.") + error = err + finally: + await ctx.send(self.build_defcon_msg(action_type, error)) + await self.send_defcon_log(action_type, ctx.author, error) + @defcon_group.command(name='enable', aliases=('on', 'e')) @with_role(Roles.admin, Roles.owner) async def enable_command(self, ctx: Context) -> None: @@ -118,8 +138,9 @@ class Defcon(Cog): """ self.enabled = True - try: - await self.bot.api_client.put( + await self._defcon_action( + ctx, + action=self.bot.api_client.put( 'bot/bot-settings/defcon', json={ 'name': 'defcon', @@ -129,16 +150,9 @@ class Defcon(Cog): 'days': 0 } } - ) - - except Exception as e: - log.exception("Unable to update DEFCON settings.") - await ctx.send(self.build_defcon_msg("enabled", e)) - await self.send_defcon_log("enabled", ctx.author, e) - - else: - await ctx.send(self.build_defcon_msg("enabled")) - await self.send_defcon_log("enabled", ctx.author) + ), + action_type='enabled' + ) await self.update_channel_topic() @@ -148,8 +162,9 @@ class Defcon(Cog): """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False - try: - await self.bot.api_client.put( + await self._defcon_action( + ctx, + action=self.bot.api_client.put( 'bot/bot-settings/defcon', json={ 'data': { @@ -158,14 +173,8 @@ class Defcon(Cog): }, 'name': 'defcon' } - ) - except Exception as e: - log.exception("Unable to update DEFCON settings.") - await ctx.send(self.build_defcon_msg("disabled", e)) - await self.send_defcon_log("disabled", ctx.author, e) - else: - await ctx.send(self.build_defcon_msg("disabled")) - await self.send_defcon_log("disabled", ctx.author) + ), + action_type='disabled') await self.update_channel_topic() @@ -187,8 +196,9 @@ class Defcon(Cog): """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) - try: - await self.bot.api_client.put( + await self._defcon_action( + ctx, + action=self.bot.api_client.put( 'bot/bot-settings/defcon', json={ 'data': { @@ -197,18 +207,14 @@ class Defcon(Cog): }, 'name': 'defcon' } - ) - except Exception as e: - log.exception("Unable to update DEFCON settings.") - await ctx.send(self.build_defcon_msg("updated", e)) - await self.send_defcon_log("updated", ctx.author, e) - else: - await ctx.send(self.build_defcon_msg("updated")) - await self.send_defcon_log("updated", ctx.author) + ), + action_type="updated" + ) # Enable DEFCON if it's not already - if not self.enabled: - self.enabled = True + # seems redundant to check if False, can directly set to True + # if not self.enabled: + self.enabled = True await self.update_channel_topic() @@ -253,22 +259,12 @@ class Defcon(Cog): `change` string may be one of the following: ('enabled', 'disabled', 'updated') """ - log_msg = f"**Staffer:** {actor} (`{actor.id}`)\n" - - if change.lower() == "enabled": - icon = Icons.defcon_enabled - color = Colours.soft_green - status_msg = "DEFCON enabled" - log_msg += f"**Days:** {self.days.days}\n\n" - elif change.lower() == "disabled": - icon = Icons.defcon_disabled - color = Colours.soft_red - status_msg = "DEFCON enabled" - elif change.lower() == "updated": - icon = Icons.defcon_updated - color = Colour.blurple() - status_msg = "DEFCON updated" - log_msg += f"**Days:** {self.days.days}\n\n" + icon, color, info_template = self._defcon_log_info[change.lower()] + log_msg: str = ( + f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" + f"{info_template.format(days=self.days.days)}" + ) + status_msg = f"DEFCON {change.lower()}" if e: log_msg += ( -- cgit v1.2.3 From 0d164adf6ac13611f1fc66825998d20d26f240ac Mon Sep 17 00:00:00 2001 From: bendiller Date: Thu, 17 Oct 2019 14:08:13 -0600 Subject: Address reviewer request --- bot/cogs/antimalware.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 2ef61e8ad..b8c12accb 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -20,13 +20,14 @@ class AntiMalware(Cog): rejected_attachments = False detected_pyfile = False for attachment in message.attachments: - if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)): - rejected_attachments = True if attachment.filename.lower().endswith('.py'): detected_pyfile = True break # Other detections irrelevant because we prioritize the .py message. + if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)): + rejected_attachments = True + break - if rejected_attachments: + if detected_pyfile or rejected_attachments: # Send a message to the user indicating the problem (with special treatment for .py) author = message.author if detected_pyfile: -- cgit v1.2.3 From 8722e954af926e7ec6b480df9edde1249c8ab795 Mon Sep 17 00:00:00 2001 From: bendiller Date: Thu, 17 Oct 2019 14:23:23 -0600 Subject: Bugfix - ensure .py attachment is prioritized over other non-whitelisted --- bot/cogs/antimalware.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index b8c12accb..ababd6f18 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -25,7 +25,6 @@ class AntiMalware(Cog): break # Other detections irrelevant because we prioritize the .py message. if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)): rejected_attachments = True - break if detected_pyfile or rejected_attachments: # Send a message to the user indicating the problem (with special treatment for .py) -- cgit v1.2.3 From 8c2871f89846f2c34f52061323dc7503855266ea Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Fri, 18 Oct 2019 10:39:27 +0700 Subject: Make it easier for user to search for tags #### Closes #231 Applying the algorithm for `Needles and Haystack` to find and match tag in tags, for example: ![Example](https://cdn.discordapp.com/attachments/634243438459486219/634592981915140107/unknown.png) This only applies to searching tag_name with more than 3 in length, and at least 80% of its letters are found, from left to right. There are 3 levels of checking, stop at first found: - Check if exact name ( case insensitive ) O(1) getting from a dictionary Dict[str, Tag] - Check for all tags that has 100% matching via algorithm - Check for all tags that has >= 80% matching If there are more than one hit, it will be shown as suggestions: ![Suggestions](https://cdn.discordapp.com/attachments/634243438459486219/634595369531211778/unknown.png) In order to avoid api being called multiple times, I've implemented a cache to only refresh itself when the is a gap of more than 5 minutes from the last api call to get all tags. Editing / Adding / Deleting tags will also modify the cache directly. ##### What about other solution like fuzzywuzzy? fuzzywuzzy was considered for using, but from testing, it was giving much lower scores than expected: Code used to test: ```py from fuzzywuzzy import fuzz def _fuzzy_search(search: str, target: str) -> bool: found = 0 index = 0 _search = search.lower().replace(' ', '') _target = target.lower().replace(' ', '') for letter in _search: index = _target.find(letter, index) if index == -1: break found += index > 0 # return found / len(_search) * 100 return ( found / len(_search) * 100, fuzz.ratio(search, target), fuzz.partial_ratio(search, target) ) tests = ( 'this-is-gonna-be-fun', 'this-too-will-be-fun' ) for test in tests: print(test, '->', _fuzzy_search('this too fun', test)) ``` Result from test: ```py this-is-gonna-be-fun -> (30.0, 50, 50) this-too-will-be-fun -> (90.0, 62, 58) ``` --- bot/cogs/tags.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index cd70e783a..1aea97b37 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -9,7 +9,6 @@ from bot.converters import TagContentConverter, TagNameConverter from bot.decorators import with_role from bot.pagination import LinePaginator - log = logging.getLogger(__name__) TEST_CHANNELS = ( @@ -26,6 +25,44 @@ class Tags(Cog): self.bot = bot self.tag_cooldowns = {} + self._cache = {} + self._last_fetch = None + + async def _get_tags(self, is_forced: bool = False) -> None: + """Getting all tags.""" + # Refresh only when there's a more than 5m gap from last call. + if is_forced or not self._last_fetch or time.time() - self._last_fetch > 5 * 60: + tags = await self.bot.api_client.get('bot/tags') + self._cache = {tag['title'].lower(): tag for tag in tags} + + @staticmethod + def _fuzzy_search(search: str, target: str) -> bool: + found = 0 + index = 0 + _search = search.lower().replace(' ', '') + _target = target.lower().replace(' ', '') + for letter in _search: + index = _target.find(letter, index) + if index == -1: + break + found += index > 0 + return found / len(_search) * 100 + + def _get_suggestions(self, tag_name: str, score: int) -> list: + return sorted( + (tag for tag in self._cache.values() if Tags._fuzzy_search(tag_name, tag['title']) >= score), + key=lambda tag: Tags._fuzzy_search(tag_name, tag['title']), + reverse=True + ) + + async def _get_tag(self, tag_name: str) -> list: + """Get a specific tag.""" + await self._get_tags() + found = [self._cache.get(tag_name.lower(), None)] + if not found[0]: + return self._get_suggestions(tag_name, 100) or self._get_suggestions(tag_name, 80) + return found + @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: """Show all known tags, a single tag, or run a subcommand.""" @@ -59,17 +96,29 @@ class Tags(Cog): f"Cooldown ends in {time_left:.1f} seconds.") return + await self._get_tags() + if tag_name is not None: - tag = await self.bot.api_client.get(f'bot/tags/{tag_name}') - if ctx.channel.id not in TEST_CHANNELS: - self.tag_cooldowns[tag_name] = { - "time": time.time(), - "channel": ctx.channel.id - } - await ctx.send(embed=Embed.from_dict(tag['embed'])) + # tag = await self.bot.api_client.get(f'bot/tags/{tag_name}') + founds = await self._get_tag(tag_name) + + if len(founds) == 1: + tag = founds[0] + if ctx.channel.id not in TEST_CHANNELS: + self.tag_cooldowns[tag_name] = { + "time": time.time(), + "channel": ctx.channel.id + } + await ctx.send(embed=Embed.from_dict(tag['embed'])) + elif founds and len(tag_name) >= 3: + await ctx.send(embed=Embed( + title='Did you mean ...', + description='\n'.join(tag['title'] for tag in founds[:10]) + )) else: - tags = await self.bot.api_client.get('bot/tags') + # tags = await self.bot.api_client.get('bot/tags') + tags = self._cache.values() if not tags: await ctx.send(embed=Embed( description="**There are no tags in the database!**", @@ -105,6 +154,7 @@ class Tags(Cog): } await self.bot.api_client.post('bot/tags', json=body) + self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}') log.debug(f"{ctx.author} successfully added the following tag to our database: \n" f"tag_name: {tag_name}\n" @@ -134,6 +184,7 @@ class Tags(Cog): } await self.bot.api_client.patch(f'bot/tags/{tag_name}', json=body) + self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}') log.debug(f"{ctx.author} successfully edited the following tag in our database: \n" f"tag_name: {tag_name}\n" @@ -150,6 +201,7 @@ class Tags(Cog): async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None: """Remove a tag from the database.""" await self.bot.api_client.delete(f'bot/tags/{tag_name}') + self._cache.pop(tag_name.lower(), None) log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'") await ctx.send(embed=Embed( -- cgit v1.2.3 From 49e626743cc3f1b51a0035c5fe676002b22a68bd Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Sat, 19 Oct 2019 18:01:38 +0700 Subject: Update defcon.py - Further unify defcon actions in `_defcon_action` - Thanks to Mark's suggestions. - Changed from a Dict to an Enum for defcon log info. --- bot/cogs/defcon.py | 100 ++++++++++++++++++++--------------------------------- 1 file changed, 37 insertions(+), 63 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index e41605109..ee6a19a67 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -1,6 +1,7 @@ import logging from datetime import datetime, timedelta -from typing import Awaitable, Dict, Tuple +from enum import Enum +from typing import Optional from discord import Colour, Embed, Member from discord.ext.commands import Bot, Cog, Context, group @@ -25,18 +26,25 @@ will be resolved soon. In the meantime, please feel free to peruse the resources BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" +class DefconLogInfo(Enum): + """Defcon Logging Information.""" + + ENABLED = (Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") + DISABLED = (Icons.defcon_disabled, Colours.soft_red, "") + UPDATED = (Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") + + @staticmethod + def from_action(action: str) -> Optional[tuple]: + """Getting value from an action.""" + return DefconLogInfo[action.upper()].value + + class Defcon(Cog): """Time-sensitive server defense mechanisms.""" days = None # type: timedelta enabled = False # type: bool - _defcon_log_info: Dict[str, Tuple] = { - 'enabled': (Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n"), - 'disabled': (Icons.defcon_disabled, Colours.soft_red, ""), - 'updated': (Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n"), - } - def __init__(self, bot: Bot): self.bot = bot self.channel = None @@ -115,11 +123,26 @@ class Defcon(Cog): """Check the DEFCON status or run a subcommand.""" await ctx.invoke(self.bot.get_command("help"), "defcon") - async def _defcon_action(self, ctx: Context, action: Awaitable[None], action_type: str) -> None: - """Providing a structured way to do an defcon actions - enabled / disabled / updated.""" + async def _defcon_action(self, ctx: Context, days: int, enabled: bool) -> None: + """Providing a structured way to do an defcon action.""" error = None + if days == 0: + action_type = 'enabled' if enabled else 'disabled' + else: + action_type = 'updated' + try: - await action + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'name': 'defcon', + 'data': { + # TODO: retrieve old days count + 'days': days, + 'enabled': enabled, + } + } + ) except Exception as err: log.exception("Unable to update DEFCON settings.") error = err @@ -137,23 +160,7 @@ class Defcon(Cog): in days. """ self.enabled = True - - await self._defcon_action( - ctx, - action=self.bot.api_client.put( - 'bot/bot-settings/defcon', - json={ - 'name': 'defcon', - 'data': { - 'enabled': True, - # TODO: retrieve old days count - 'days': 0 - } - } - ), - action_type='enabled' - ) - + await self._defcon_action(ctx, days=0, enabled=True) await self.update_channel_topic() @defcon_group.command(name='disable', aliases=('off', 'd')) @@ -161,21 +168,7 @@ class Defcon(Cog): async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False - - await self._defcon_action( - ctx, - action=self.bot.api_client.put( - 'bot/bot-settings/defcon', - json={ - 'data': { - 'days': 0, - 'enabled': False - }, - 'name': 'defcon' - } - ), - action_type='disabled') - + await self._defcon_action(ctx, days=0, enabled=False) await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) @@ -195,27 +188,8 @@ class Defcon(Cog): 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) - - await self._defcon_action( - ctx, - action=self.bot.api_client.put( - 'bot/bot-settings/defcon', - json={ - 'data': { - 'days': days, - 'enabled': True - }, - 'name': 'defcon' - } - ), - action_type="updated" - ) - - # Enable DEFCON if it's not already - # seems redundant to check if False, can directly set to True - # if not self.enabled: self.enabled = True - + await self._defcon_action(ctx, days=days, enabled=True) await self.update_channel_topic() async def update_channel_topic(self) -> None: @@ -259,7 +233,7 @@ class Defcon(Cog): `change` string may be one of the following: ('enabled', 'disabled', 'updated') """ - icon, color, info_template = self._defcon_log_info[change.lower()] + icon, color, info_template = DefconLogInfo.from_action(change) log_msg: str = ( f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" f"{info_template.format(days=self.days.days)}" -- cgit v1.2.3 From a7e1d5542d3bbd5d1df4aa179a838ff5430fcaf7 Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Sat, 19 Oct 2019 17:09:39 +0530 Subject: Add moderation channels check to cog_check --- bot/cogs/moderation/management.py | 8 ++++++-- bot/constants.py | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 491f6d400..b7f5db2f5 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -11,7 +11,7 @@ from bot import constants from bot.converters import InfractionSearchQuery from bot.pagination import LinePaginator from bot.utils import time -from bot.utils.checks import with_role_check +from bot.utils.checks import in_channel_check, with_role_check from . import utils from .infractions import Infractions from .modlog import ModLog @@ -257,7 +257,11 @@ class ModManagement(commands.Cog): # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) + checks = [ + with_role_check(ctx, *constants.MODERATION_ROLES), + in_channel_check(ctx, *constants.MODERATION_CHANNELS) + ] + return all(checks) # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: diff --git a/bot/constants.py b/bot/constants.py index f4f45eb2c..f22668a2b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -347,6 +347,8 @@ class Channels(metaclass=YAMLGetter): message_log: int mod_alerts: int modlog: int + mods: int + mod_spam: int off_topic_0: int off_topic_1: int off_topic_2: int @@ -500,6 +502,9 @@ PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner +# Default Channel combinations +MODERATION_CHANNELS = Channels.admins, Channels.mod_alerts, Channels.mods, Channels.mod_spam + # Bot replies NEGATIVE_REPLIES = [ -- cgit v1.2.3 From fe514ab501df2513ab2878bf565b39bb9a742750 Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Sat, 19 Oct 2019 17:23:35 +0530 Subject: Add missing channels to the config --- config-default.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config-default.yml b/config-default.yml index ca405337e..4b8185aaa 100644 --- a/config-default.yml +++ b/config-default.yml @@ -109,6 +109,8 @@ guild: message_log: &MESSAGE_LOG 467752170159079424 mod_alerts: 473092532147060736 modlog: &MODLOG 282638479504965634 + mods: 305126844661760000 + mod_spam: 620607373828030464 off_topic_0: 291284109232308226 off_topic_1: 463035241142026251 off_topic_2: 463035268514185226 -- cgit v1.2.3 From 99386234ac3aa72d5f81f6104f5f5945bdd6c1e5 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 19 Oct 2019 17:24:58 +0200 Subject: Pluralize "infractions" as necessary. --- bot/cogs/moderation/infractions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 592ead60f..e17a9844c 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -2,6 +2,7 @@ import logging import textwrap import typing as t from datetime import datetime +from gettext import ngettext import dateutil.parser import discord @@ -463,7 +464,8 @@ class Infractions(Scheduler, commands.Cog): "bot/infractions", params={"user__id": str(user.id)} ) - end_msg = f" ({len(infractions)} infractions total)" + total = len(infractions) + end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" # Execute the necessary actions to apply the infraction on Discord. if action_coro: -- cgit v1.2.3 From 48a78deb42d5a96066d96f19e87e7b8586cd62bd Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 19 Oct 2019 17:31:34 +0200 Subject: Do not display an expiry for notes or warnings. --- bot/cogs/moderation/infractions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index e17a9844c..f2ae7b95d 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -437,7 +437,13 @@ class Infractions(Scheduler, commands.Cog): # Default values for the confirmation message and mod log. confirm_msg = f":ok_hand: applied" - expiry_msg = f" until {expiry}" if expiry else " permanently" + + # Specifying an expiry for a note or warning makes no sense. + if infr_type in ("note", "warning"): + expiry_msg = "" + else: + expiry_msg = f" until {expiry}" if expiry else " permanently" + dm_result = "" dm_log_text = "" expiry_log_text = f"Expires: {expiry}" if expiry else "" -- cgit v1.2.3 From e84e760af56e9149d7c8f19279ef17a933de8f19 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sun, 20 Oct 2019 21:15:06 +1100 Subject: Apply suggestions from review, and add correct webhook ID. - More informative docstrings for `Reddit.channel` and `get_top_posts` - Add the `amount` parameter, defaulting to 5. - Pin a max of 12 weeks worth of top posts. --- bot/cogs/reddit.py | 24 ++++++++++++++---------- config-default.yml | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 58a6d3c7e..7749d237f 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -44,7 +44,7 @@ class Reddit(Cog): @property def channel(self) -> TextChannel: - """Returns the #reddit channel object from the bot's cache.""" + """Get the #reddit channel object from the bot's cache.""" return self.bot.get_channel(Channels.reddit) async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: @@ -74,17 +74,21 @@ class Reddit(Cog): log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") return list() # Failed to get appropriate response within allowed number of retries. - async def get_top_posts(self, subreddit: Subreddit, time: str = "all") -> Embed: - """Returns an embed for the top posts of the given subreddit.""" - # Create the new spicy embed. + async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: + """ + Get the top amount of posts for a given subreddit within a specified timeframe. + + A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top + weekly posts. + + The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. + """ embed = Embed(description="") posts = await self.fetch_posts( route=f"{subreddit}/top", - amount=5, - params={ - "t": time - } + amount=amount, + params={"t": time} ) if not posts: @@ -156,10 +160,10 @@ class Reddit(Cog): log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") return - # Remove the oldest pins so that only 5 remain at most. + # Remove the oldest pins so that only 12 remain at most. pins = await self.channel.pins() - while len(pins) >= 5: + while len(pins) >= 12: await pins[-1].unpin() del pins[-1] diff --git a/config-default.yml b/config-default.yml index 52e0b880c..c97072d57 100644 --- a/config-default.yml +++ b/config-default.yml @@ -141,7 +141,7 @@ guild: webhooks: talent_pool: 569145364800602132 big_brother: 569133704568373283 - reddit: 123456789 + reddit: 635408384794951680 filter: -- cgit v1.2.3 From 4efb97c5020f591d8cdd1e214e06df294e72d8f1 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 20 Oct 2019 18:32:25 +0200 Subject: add handling for duplicate symbols in docs inventories --- bot/cogs/doc.py | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index a13464bff..43315f477 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -23,7 +23,17 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) logging.getLogger('urllib3').setLevel(logging.WARNING) - +NO_OVERRIDE_GROUPS = ( + "2to3fixer", + "token", + "label", + "pdbcommand", + "term", + "function" +) +NO_OVERRIDE_PACKAGES = ( + "Python", +) UNWANTED_SIGNATURE_SYMBOLS = ('[source]', '¶') WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") @@ -125,6 +135,7 @@ class Doc(commands.Cog): self.base_urls = {} self.bot = bot self.inventories = {} + self.renamed_symbols = set() self.bot.loop.create_task(self.init_refresh_inventory()) @@ -151,12 +162,32 @@ class Doc(commands.Cog): self.base_urls[package_name] = base_url fetch_func = functools.partial(intersphinx.fetch_inventory, config, '', inventory_url) - for _, value in (await self.bot.loop.run_in_executor(None, fetch_func)).items(): + for group, value in (await self.bot.loop.run_in_executor(None, fetch_func)).items(): # Each value has a bunch of information in the form # `(package_name, version, relative_url, ???)`, and we only - # need the relative documentation URL. - for symbol, (_, _, relative_doc_url, _) in value.items(): + # need the package_name and the relative documentation URL. + for symbol, (package_name, _, relative_doc_url, _) in value.items(): absolute_doc_url = base_url + relative_doc_url + + if symbol in self.inventories: + # get `group_name` from _:group_name + group_name = group.split(":")[1] + if (group_name in NO_OVERRIDE_GROUPS + # check if any package from `NO_OVERRIDE_PACKAGES` + # is in base URL of the symbol that would be overridden + or any(package in self.inventories[symbol].split("/", 3)[2] + for package in NO_OVERRIDE_PACKAGES)): + + symbol = f"{group_name}.{symbol}" + # if renamed `symbol` was already exists, add library name in front + if symbol in self.renamed_symbols: + # split `package_name` because of packages like Pillow that have spaces in them + symbol = f"{package_name.split()[0]}.{symbol}" + + self.inventories[symbol] = absolute_doc_url + self.renamed_symbols.add(symbol) + continue + self.inventories[symbol] = absolute_doc_url log.trace(f"Fetched inventory for {package_name}.") -- cgit v1.2.3 From f1dbb63e6c4a7ed38f8bed994c109e638498d546 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 20 Oct 2019 18:39:08 +0200 Subject: show renamed duplicates in embed footer --- bot/cogs/doc.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 43315f477..ecff43864 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -281,18 +281,23 @@ class Doc(commands.Cog): if not signature: # It's some "meta-page", for example: # https://docs.djangoproject.com/en/dev/ref/views/#module-django.views - return discord.Embed( + embed = discord.Embed( title=f'`{symbol}`', url=permalink, description="This appears to be a generic page not tied to a specific symbol." ) - - signature = textwrap.shorten(signature, 500) - return discord.Embed( - title=f'`{symbol}`', - url=permalink, - description=f"```py\n{signature}```{description}" - ) + else: + signature = textwrap.shorten(signature, 500) + embed = discord.Embed( + title=f'`{symbol}`', + url=permalink, + description=f"```py\n{signature}```{description}" + ) + # show all symbols with the same name that were renamed in the footer + embed.set_footer(text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} + if renamed.endswith(f".{symbol}")) + ) + return embed @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: -- cgit v1.2.3 From a05f28c97d0f2ea9d3dafcdbd24444c59905af84 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 20 Oct 2019 18:42:59 +0200 Subject: Auto delete messages when docs are not found --- bot/cogs/doc.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index ecff43864..9bb21cce3 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -4,17 +4,19 @@ import logging import re import textwrap from collections import OrderedDict +from contextlib import suppress from typing import Any, Callable, Optional, Tuple import discord from bs4 import BeautifulSoup from bs4.element import PageElement +from discord.errors import NotFound from discord.ext import commands from markdownify import MarkdownConverter from requests import ConnectionError from sphinx.ext import intersphinx -from bot.constants import MODERATION_ROLES +from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator @@ -23,6 +25,7 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) logging.getLogger('urllib3').setLevel(logging.WARNING) +NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay NO_OVERRIDE_GROUPS = ( "2to3fixer", "token", @@ -343,7 +346,10 @@ class Doc(commands.Cog): description=f"Sorry, I could not find any documentation for `{symbol}`.", colour=discord.Colour.red() ) - await ctx.send(embed=error_embed) + error_message = await ctx.send(embed=error_embed) + with suppress(NotFound): + await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) + await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) else: await ctx.send(embed=doc_embed) -- cgit v1.2.3 From eda6cd7ff818454ad7bf448040a87ff0077025bc Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 20 Oct 2019 21:15:12 +0200 Subject: remove "function" from NO_OVERRIDE_GROUPS --- bot/cogs/doc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 9bb21cce3..f1213d170 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -32,7 +32,6 @@ NO_OVERRIDE_GROUPS = ( "label", "pdbcommand", "term", - "function" ) NO_OVERRIDE_PACKAGES = ( "Python", -- cgit v1.2.3 From bc907daa428d755d7f2cb0a6b945a179d523b31d Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Sun, 20 Oct 2019 21:35:38 +0200 Subject: Add check when a message is edited --- bot/cogs/token_remover.py | 60 +++++++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 5e83a777e..e5b0e5b45 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -53,30 +53,44 @@ class TokenRemover(Cog): See: https://discordapp.com/developers/docs/reference#snowflakes """ if self.is_token_in_message(msg): - user_id, creation_timestamp, hmac = TOKEN_RE.search(msg.content).group(0).split('.') - self.mod_log.ignore(Event.message_delete, msg.id) - await msg.delete() - await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - - message = ( - "Censored a seemingly valid token sent by " - f"{msg.author} (`{msg.author.id}`) in {msg.channel.mention}, token was " - f"`{user_id}.{creation_timestamp}.{'x' * len(hmac)}`" - ) - log.debug(message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ) + await self.take_action(msg) + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """ + Check each edit for a string that matches Discord's token pattern. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ + if self.is_token_in_message(after): + await self.take_action(after) + + async def take_action(self, msg: Message) -> None: + """Remove the `msg` containing a token an send a mod_log message.""" + user_id, creation_timestamp, hmac = TOKEN_RE.search(msg.content).group(0).split('.') + self.mod_log.ignore(Event.message_delete, msg.id) + await msg.delete() + await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) + + message = ( + "Censored a seemingly valid token sent by " + f"{msg.author} (`{msg.author.id}`) in {msg.channel.mention}, token was " + f"`{user_id}.{creation_timestamp}.{'x' * len(hmac)}`" + ) + log.debug(message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ) @classmethod - def is_token_in_message(self, msg: Message) -> bool: + def is_token_in_message(cls, msg: Message) -> bool: """Check if `msg` contains a seemly valid token.""" if msg.author.bot: return False @@ -90,7 +104,7 @@ class TokenRemover(Cog): except ValueError: return False - if self.is_valid_user_id(user_id) and self.is_valid_timestamp(creation_timestamp): + if cls.is_valid_user_id(user_id) and cls.is_valid_timestamp(creation_timestamp): return True @staticmethod -- cgit v1.2.3 From 4b22d551dfe0b91a90e2bf12892c6d8326f9698b Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Mon, 21 Oct 2019 12:38:35 +0700 Subject: Update defcon.py - Renamed `DefconInfoLog` to `Action` - Added `ActionInfo` namedtuple. - Use `Action` consistently for `_defcon_action()` and `send_defcon_log()` --- bot/cogs/defcon.py | 43 +++++++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index ee6a19a67..419d42da3 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import logging +from collections import namedtuple from datetime import datetime, timedelta from enum import Enum -from typing import Optional from discord import Colour, Embed, Member from discord.ext.commands import Bot, Cog, Context, group @@ -26,17 +28,19 @@ will be resolved soon. In the meantime, please feel free to peruse the resources BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" -class DefconLogInfo(Enum): +class Action(Enum): """Defcon Logging Information.""" - ENABLED = (Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") - DISABLED = (Icons.defcon_disabled, Colours.soft_red, "") - UPDATED = (Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") + ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template']) + + ENABLED = ActionInfo(Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") + DISABLED = ActionInfo(Icons.defcon_disabled, Colours.soft_red, "") + UPDATED = ActionInfo(Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") @staticmethod - def from_action(action: str) -> Optional[tuple]: + def get_info(action: str) -> Action.ActionInfo: """Getting value from an action.""" - return DefconLogInfo[action.upper()].value + return Action[action.upper()].value class Defcon(Cog): @@ -123,14 +127,9 @@ class Defcon(Cog): """Check the DEFCON status or run a subcommand.""" await ctx.invoke(self.bot.get_command("help"), "defcon") - async def _defcon_action(self, ctx: Context, days: int, enabled: bool) -> None: + async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: """Providing a structured way to do an defcon action.""" error = None - if days == 0: - action_type = 'enabled' if enabled else 'disabled' - else: - action_type = 'updated' - try: await self.bot.api_client.put( 'bot/bot-settings/defcon', @@ -139,7 +138,7 @@ class Defcon(Cog): 'data': { # TODO: retrieve old days count 'days': days, - 'enabled': enabled, + 'enabled': action is not Action.DISABLED, } } ) @@ -147,8 +146,8 @@ class Defcon(Cog): log.exception("Unable to update DEFCON settings.") error = err finally: - await ctx.send(self.build_defcon_msg(action_type, error)) - await self.send_defcon_log(action_type, ctx.author, error) + await ctx.send(self.build_defcon_msg(action.name.lower(), error)) + await self.send_defcon_log(action.name.lower(), ctx.author, error) @defcon_group.command(name='enable', aliases=('on', 'e')) @with_role(Roles.admin, Roles.owner) @@ -160,7 +159,7 @@ class Defcon(Cog): in days. """ self.enabled = True - await self._defcon_action(ctx, days=0, enabled=True) + await self._defcon_action(ctx, days=0, action=Action.ENABLED) await self.update_channel_topic() @defcon_group.command(name='disable', aliases=('off', 'd')) @@ -168,7 +167,7 @@ class Defcon(Cog): async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False - await self._defcon_action(ctx, days=0, enabled=False) + await self._defcon_action(ctx, days=0, action=Action.DISABLED) await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) @@ -189,7 +188,7 @@ class Defcon(Cog): """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) self.enabled = True - await self._defcon_action(ctx, days=days, enabled=True) + await self._defcon_action(ctx, days=days, action=Action.UPDATED) await self.update_channel_topic() async def update_channel_topic(self) -> None: @@ -233,10 +232,10 @@ class Defcon(Cog): `change` string may be one of the following: ('enabled', 'disabled', 'updated') """ - icon, color, info_template = DefconLogInfo.from_action(change) + info = Action.get_info(change) log_msg: str = ( f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" - f"{info_template.format(days=self.days.days)}" + f"{info.template.format(days=self.days.days)}" ) status_msg = f"DEFCON {change.lower()}" @@ -246,7 +245,7 @@ class Defcon(Cog): f"```py\n{e}\n```" ) - await self.mod_log.send_log_message(icon, color, status_msg, log_msg) + await self.mod_log.send_log_message(info.icon, info.color, status_msg, log_msg) def setup(bot: Bot) -> None: -- cgit v1.2.3 From a7e90e2392822bb21bfd07d1a725ebb0587927b9 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Mon, 21 Oct 2019 13:03:32 +0700 Subject: Update defcon.py - Remove the unneccessary `get_info()` method of Enum `Action`. - Pass Enum `Action` directly to `build_defcon_msg()` and `send_defcon_log()` - Right now, only `_defcon_action()` is using them. --- bot/cogs/defcon.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 419d42da3..7d067d66b 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -37,11 +37,6 @@ class Action(Enum): DISABLED = ActionInfo(Icons.defcon_disabled, Colours.soft_red, "") UPDATED = ActionInfo(Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") - @staticmethod - def get_info(action: str) -> Action.ActionInfo: - """Getting value from an action.""" - return Action[action.upper()].value - class Defcon(Cog): """Time-sensitive server defense mechanisms.""" @@ -146,8 +141,8 @@ class Defcon(Cog): log.exception("Unable to update DEFCON settings.") error = err finally: - await ctx.send(self.build_defcon_msg(action.name.lower(), error)) - await self.send_defcon_log(action.name.lower(), ctx.author, error) + await ctx.send(self.build_defcon_msg(action, error)) + await self.send_defcon_log(action, ctx.author, error) @defcon_group.command(name='enable', aliases=('on', 'e')) @with_role(Roles.admin, Roles.owner) @@ -202,17 +197,17 @@ class Defcon(Cog): self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) await self.channel.edit(topic=new_topic) - def build_defcon_msg(self, change: str, e: Exception = None) -> str: + def build_defcon_msg(self, action: Action, e: Exception = None) -> str: """ Build in-channel response string for DEFCON action. `change` string may be one of the following: ('enabled', 'disabled', 'updated') """ - if change.lower() == "enabled": + if action is Action.ENABLED: msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" - elif change.lower() == "disabled": + elif action is Action.DISABLED: msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" - elif change.lower() == "updated": + elif action is Action.UPDATED: msg = ( f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days} " "days old to join the server.\n\n" @@ -226,18 +221,18 @@ class Defcon(Cog): return msg - async def send_defcon_log(self, change: str, actor: Member, e: Exception = None) -> None: + async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None: """ Send log message for DEFCON action. `change` string may be one of the following: ('enabled', 'disabled', 'updated') """ - info = Action.get_info(change) + info = action.value log_msg: str = ( f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" f"{info.template.format(days=self.days.days)}" ) - status_msg = f"DEFCON {change.lower()}" + status_msg = f"DEFCON {action.name.lower()}" if e: log_msg += ( -- cgit v1.2.3 From f576a524bcef34fdeb7bb999b7b61c53f0440b43 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Mon, 21 Oct 2019 13:13:42 +0700 Subject: Update defcon.py - Update docstrings for `build_defcon_msg()` and `send_defcon_log()` - It is now taking in an `Action` directly instead of a string. --- bot/cogs/defcon.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 7d067d66b..ec93d422d 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -29,7 +29,7 @@ BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" class Action(Enum): - """Defcon Logging Information.""" + """Defcon Action.""" ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template']) @@ -198,11 +198,7 @@ class Defcon(Cog): await self.channel.edit(topic=new_topic) def build_defcon_msg(self, action: Action, e: Exception = None) -> str: - """ - Build in-channel response string for DEFCON action. - - `change` string may be one of the following: ('enabled', 'disabled', 'updated') - """ + """Build in-channel response string for DEFCON action.""" if action is Action.ENABLED: msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" elif action is Action.DISABLED: @@ -222,11 +218,7 @@ class Defcon(Cog): return msg async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None: - """ - Send log message for DEFCON action. - - `change` string may be one of the following: ('enabled', 'disabled', 'updated') - """ + """Send log message for DEFCON action.""" info = action.value log_msg: str = ( f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" -- cgit v1.2.3 From 3828a6dd335fe3d68277b30e89c21cc1a1051755 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Mon, 21 Oct 2019 13:46:48 +0700 Subject: Fixing formatting for timedelta. - Now it will only show the amount of days. - Quality of Life: Also show `day` instead of `days` when it's just 1 day. --- bot/cogs/defcon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index ec93d422d..bedd70c86 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -205,8 +205,8 @@ class Defcon(Cog): msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" elif action is Action.UPDATED: msg = ( - f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days} " - "days old to join the server.\n\n" + f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days.days} " + f"day{'s' if self.days.days > 1 else ''} old to join the server.\n\n" ) if e: -- cgit v1.2.3 From 3e6e7899927f97be7c3722aad7fe6539e0357e04 Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Mon, 21 Oct 2019 14:42:05 +0200 Subject: Add a footer and one-line a string --- bot/cogs/free.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/bot/cogs/free.py b/bot/cogs/free.py index afd54671a..f8031a32a 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -79,18 +79,15 @@ class Free(Cog): if len(sorted_channels) > 3: # display 3 channels max sorted_channels = sorted_channels[:3] - for i, (inactive, channel) in enumerate(sorted_channels, 1): + for (inactive, channel) in sorted_channels: minutes, seconds = divmod(inactive, 60) if minutes > 59: hours, minutes = divmod(minutes, 60) - embed.description += f"{i}. {channel.mention} **{hours}h {minutes}m {seconds}s** inactive\n" + embed.description += f"{channel.mention} **{hours}h {minutes}m {seconds}s** inactive\n" else: - embed.description += f"{i}. {channel.mention} **{minutes}m {seconds}s** inactive\n" + embed.description += f"{channel.mention} **{minutes}m {seconds}s** inactive\n" - embed.description += ( - "Please confirm these channels " - "are free before posting" - ) + embed.set_footer(text="Please confirm these channels are free before posting") else: embed.description = ( "**Doesn't look like any channels are available right now. " -- cgit v1.2.3 From d16fd998fb46702d01d409e68c4132fb07722249 Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Mon, 21 Oct 2019 19:34:03 +0530 Subject: Add #admin-spam channel to moderation channels list - Change doc string for cog_check method --- bot/cogs/moderation/management.py | 2 +- bot/constants.py | 3 ++- config-default.yml | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index b7f5db2f5..44a508436 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -256,7 +256,7 @@ class ModManagement(commands.Cog): # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" + """Only allow moderators from moderator channels to invoke the commands in this cog.""" checks = [ with_role_check(ctx, *constants.MODERATION_ROLES), in_channel_check(ctx, *constants.MODERATION_CHANNELS) diff --git a/bot/constants.py b/bot/constants.py index f22668a2b..e4086d8e6 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -328,6 +328,7 @@ class Channels(metaclass=YAMLGetter): subsection = "channels" admins: int + admin_spam: int announcements: int big_brother_logs: int bot: int @@ -503,7 +504,7 @@ MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner # Default Channel combinations -MODERATION_CHANNELS = Channels.admins, Channels.mod_alerts, Channels.mods, Channels.mod_spam +MODERATION_CHANNELS = Channels.admins, Channels.admin_spam, Channels.mod_alerts, Channels.mods, Channels.mod_spam # Bot replies diff --git a/config-default.yml b/config-default.yml index 4b8185aaa..cd8f5600e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -90,6 +90,7 @@ guild: channels: admins: &ADMINS 365960823622991872 + admin-spam: 563594791770914816 announcements: 354619224620138496 big_brother_logs: &BBLOGS 468507907357409333 bot: 267659945086812160 -- cgit v1.2.3 From b17b6e3ab8f867b55c7040c5f24c708a8caa0494 Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Mon, 21 Oct 2019 19:39:28 +0530 Subject: Fix typo in channel name --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index cd8f5600e..31e88c839 100644 --- a/config-default.yml +++ b/config-default.yml @@ -90,7 +90,7 @@ guild: channels: admins: &ADMINS 365960823622991872 - admin-spam: 563594791770914816 + admin_spam: 563594791770914816 announcements: 354619224620138496 big_brother_logs: &BBLOGS 468507907357409333 bot: 267659945086812160 -- cgit v1.2.3 From 0b020642c8df1133d87ee650741026e33f7570f4 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Mon, 21 Oct 2019 17:07:39 +0200 Subject: Update send_reminder message creation It is now truly backward compatible and use a more DRY method --- bot/cogs/reminders.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index b8dafcd05..8adebb04b 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -101,13 +101,13 @@ class Reminders(Scheduler, Cog): embed.colour = Colour.blurple() embed.set_author( icon_url=Icons.remind_blurple, - name="It has arrived!") + name="It has arrived!" + ) + + embed.description = f"Here's your reminder: `{reminder['content']}`." - if "jump_url" in reminder: # keep backward compatibility - embed.description = (f"Here's your reminder: `{reminder['content']}`.\n" - f"[Jump back to when you created the reminder]({reminder['jump_url']})") - else: - embed.description = f"Here's your reminder: `{reminder['content']}`" + if reminder.get("jump_url"): # keep backward compatibility + embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" if late: embed.colour = Colour.red() -- cgit v1.2.3 From 39e52f573bdcf8fb538fff5175f241c55bd9e0b3 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Mon, 21 Oct 2019 18:44:17 +0200 Subject: Create STAFF_CHANNELS constant --- bot/constants.py | 8 ++++++++ config-default.yml | 9 +++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 4beae84e9..f341fb499 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -328,6 +328,7 @@ class Channels(metaclass=YAMLGetter): subsection = "channels" admins: int + admin_spam: int announcements: int big_brother_logs: int bot: int @@ -346,11 +347,14 @@ class Channels(metaclass=YAMLGetter): helpers: int message_log: int meta: int + mod_spam: int + mods: int mod_alerts: int modlog: int off_topic_0: int off_topic_1: int off_topic_2: int + organisation: int python: int reddit: int talent_pool: int @@ -392,6 +396,7 @@ class Guild(metaclass=YAMLGetter): id: int ignored: List[int] + staff_channels: List[int] class Keys(metaclass=YAMLGetter): @@ -507,6 +512,9 @@ PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner +# Roles combinations +STAFF_CHANNELS = Guild.staff_channels + # Bot replies NEGATIVE_REPLIES = [ diff --git a/config-default.yml b/config-default.yml index 197743296..23dcbd44c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -90,11 +90,12 @@ guild: channels: admins: &ADMINS 365960823622991872 + admin_spam: &ADMIN_SPAM 563594791770914816 announcements: 354619224620138496 big_brother_logs: &BBLOGS 468507907357409333 bot: 267659945086812160 checkpoint_test: 422077681434099723 - defcon: 464469101889454091 + defcon: &DEFCON 464469101889454091 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 help_0: 303906576991780866 @@ -105,14 +106,17 @@ guild: help_5: 454941769734422538 help_6: 587375753306570782 help_7: 587375768556797982 - helpers: 385474242440986624 + 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 + organisation: &ORGANISATION 551789653284356126 python: 267624335836053506 reddit: 458224812528238616 staff_lounge: &STAFF_LOUNGE 464905259261755392 @@ -121,6 +125,7 @@ guild: user_event_a: &USER_EVENT_A 592000283102674944 verification: 352442727016693763 + staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] roles: -- cgit v1.2.3 From 2bff275f39347c0c1fb06be38b3119fe7f23e273 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Mon, 21 Oct 2019 18:45:25 +0200 Subject: Show total infraction count only in staff channels --- bot/cogs/moderation/infractions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index f2ae7b95d..997ffe524 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -12,7 +12,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.api import ResponseCodeError -from bot.constants import Colours, Event +from bot.constants import Colours, Event, STAFF_CHANNELS from bot.decorators import respect_role_hierarchy from bot.utils import time from bot.utils.checks import with_role_check @@ -465,6 +465,8 @@ class Infractions(Scheduler, commands.Cog): if infraction["actor"] == self.bot.user.id: end_msg = f" (reason: {infraction['reason']})" + elif ctx.channel.id not in STAFF_CHANNELS: + end_msg = '' else: infractions = await self.bot.api_client.get( "bot/infractions", -- cgit v1.2.3 From d5dea25fef79e16d726f1f0ce8d2bb25291d6c49 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 21 Oct 2019 22:09:46 +0200 Subject: Don't include a signature and only get first paragraphs when scraping when symbol is a module --- bot/cogs/doc.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index f1213d170..a13552ac0 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -222,12 +222,11 @@ class Doc(commands.Cog): """ Given a Python symbol, return its signature and description. - Returns a tuple in the form (str, str), or `None`. - The first tuple element is the signature of the given symbol as a markup-free string, and the second tuple element is the description of the given symbol with HTML markup included. - If the given symbol could not be found, returns `None`. + If the given symbol is a module, returns a tuple `(None, str)` + else if the symbol could not be found, returns `None`. """ url = self.inventories.get(symbol) if url is None: @@ -245,14 +244,23 @@ class Doc(commands.Cog): if symbol_heading is None: return None - # Traverse the tags of the signature header and ignore any - # unwanted symbols from it. Add all of it to a temporary buffer. - for tag in symbol_heading.strings: - if tag not in UNWANTED_SIGNATURE_SYMBOLS: - signature_buffer.append(tag.replace('\\', '')) + if symbol_id == f"module-{symbol}": + # Get all paragraphs until the first div after the section div + # if searched symbol is a module. + trailing_div = symbol_heading.findNext("div") + info_paragraphs = trailing_div.find_previous_siblings("p")[::-1] + signature = None + description = ''.join(str(paragraph) for paragraph in info_paragraphs).replace('¶', '') - signature = ''.join(signature_buffer) - description = str(symbol_heading.next_sibling.next_sibling).replace('¶', '') + else: + # Traverse the tags of the signature header and ignore any + # unwanted symbols from it. Add all of it to a temporary buffer. + + for tag in symbol_heading.strings: + if tag not in UNWANTED_SIGNATURE_SYMBOLS: + signature_buffer.append(tag.replace('\\', '')) + signature = ''.join(signature_buffer) + description = str(symbol_heading.next_sibling.next_sibling).replace('¶', '') return signature, description -- cgit v1.2.3 From 55b276a1f7e56a950e215bd8289b7f946b2f180e Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 21 Oct 2019 22:10:45 +0200 Subject: Allow embeds to not include signatures in case the symbol is a module --- bot/cogs/doc.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index a13552ac0..0c370f665 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -288,21 +288,24 @@ class Doc(commands.Cog): description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description) - if not signature: + if signature is None: + # If symbol is a module, don't show signature. + embed_description = description + + elif not signature: # It's some "meta-page", for example: # https://docs.djangoproject.com/en/dev/ref/views/#module-django.views - embed = discord.Embed( - title=f'`{symbol}`', - url=permalink, - description="This appears to be a generic page not tied to a specific symbol." - ) + embed_description = "This appears to be a generic page not tied to a specific symbol." + else: signature = textwrap.shorten(signature, 500) - embed = discord.Embed( - title=f'`{symbol}`', - url=permalink, - description=f"```py\n{signature}```{description}" - ) + embed_description = f"```py\n{signature}```{description}" + + embed = discord.Embed( + title=f'`{symbol}`', + url=permalink, + description=embed_description + ) # show all symbols with the same name that were renamed in the footer embed.set_footer(text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} if renamed.endswith(f".{symbol}")) -- cgit v1.2.3 From 09f5cd78142201ff0133a25ee1ea6cff1c739e1f Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 21 Oct 2019 22:11:20 +0200 Subject: Grammar check comment --- bot/cogs/doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 0c370f665..8b81b3053 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -306,7 +306,7 @@ class Doc(commands.Cog): url=permalink, description=embed_description ) - # show all symbols with the same name that were renamed in the footer + # Show all symbols with the same name that were renamed in the footer. embed.set_footer(text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} if renamed.endswith(f".{symbol}")) ) -- cgit v1.2.3 From 1dc08b8622b4d99bc7c480da5f91f774fdfd0787 Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Tue, 22 Oct 2019 21:53:39 +0530 Subject: Modify in_channel_check to accept list of channels - Update test cases for in_channel_check --- bot/utils/checks.py | 6 +++--- tests/utils/test_checks.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/utils/checks.py b/bot/utils/checks.py index ad892e512..db56c347c 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -38,9 +38,9 @@ def without_role_check(ctx: Context, *role_ids: int) -> bool: return check -def in_channel_check(ctx: Context, channel_id: int) -> bool: - """Checks if the command was executed inside of the specified channel.""" - check = ctx.channel.id == channel_id +def in_channel_check(ctx: Context, *channel_ids: int) -> bool: + """Checks if the command was executed inside the list of specified channels.""" + check = ctx.channel.id in channel_ids log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " f"The result of the in_channel check was {check}.") return check diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py index 7121acebd..ef1144ac9 100644 --- a/tests/utils/test_checks.py +++ b/tests/utils/test_checks.py @@ -57,10 +57,10 @@ def test_without_role_check_without_unwanted_role(context): def test_in_channel_check_for_correct_channel(context): - context.channel.id = 42 - assert checks.in_channel_check(context, context.channel.id) + context.channel.id = [42] + assert checks.in_channel_check(context, *context.channel.id) def test_in_channel_check_for_incorrect_channel(context): - context.channel.id = 42 - assert not checks.in_channel_check(context, context.channel.id + 10) + context.channel.id = [42 + 10] + assert not checks.in_channel_check(context, *context.channel.id) -- cgit v1.2.3 From a11596de969a53853151ad8a5ca2d6564227e0ab Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Tue, 22 Oct 2019 22:37:22 +0530 Subject: Add test cases for in_channel_check --- tests/bot/utils/test_checks.py | 8 ++++++++ tests/utils/test_checks.py | 0 2 files changed, 8 insertions(+) delete mode 100644 tests/utils/test_checks.py diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py index 22dc93073..19b758336 100644 --- a/tests/bot/utils/test_checks.py +++ b/tests/bot/utils/test_checks.py @@ -41,3 +41,11 @@ class ChecksTests(unittest.TestCase): role_id = 42 self.ctx.author.roles.append(MockRole(role_id=role_id)) self.assertTrue(checks.without_role_check(self.ctx, role_id + 10)) + + def test_in_channel_check_for_correct_channel(self): + self.ctx.channel.id = 42 + self.assertTrue(checks.in_channel_check(self.ctx, *[42])) + + def test_in_channel_check_for_incorrect_channel(self): + self.ctx.channel.id = 42 + 10 + self.assertFalse(checks.in_channel_check(self.ctx, *[42])) diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py deleted file mode 100644 index e69de29bb..000000000 -- cgit v1.2.3 From 5ec4db0cba484f8adfc25b642a4f24f362a5b53c Mon Sep 17 00:00:00 2001 From: Jens Date: Tue, 22 Oct 2019 22:05:50 +0200 Subject: Add reddit environment variable, change User-Agent and fix lint problem --- bot/cogs/reddit.py | 4 ++-- docker-compose.yml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 7b183221c..76da0f09f 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -25,7 +25,7 @@ class Reddit(Cog): # including the target platform, a unique application identifier, a version string, # and your username as contact information, in the following format: # :: (by /u/) - USER_AGENT = "docker:Discord Bot of https://pythondiscord.com/:v?.?.? (by /u/PythonDiscord)" + USER_AGENT = "docker-python3:Discord Bot of PythonDiscord (https://pythondiscord.com/):v?.?.? (by /u/PythonDiscord)" URL = "https://www.reddit.com" OAUTH_URL = "https://oauth.reddit.com" MAX_FETCH_RETRIES = 3 @@ -117,7 +117,7 @@ class Reddit(Cog): content = await response.json() posts = content["data"]["children"] return posts[:amount] - + await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") diff --git a/docker-compose.yml b/docker-compose.yml index f79fdba58..7281c7953 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,3 +42,5 @@ services: environment: BOT_TOKEN: ${BOT_TOKEN} BOT_API_KEY: badbot13m0n8f570f942013fc818f234916ca531 + REDDIT_CLIENT_ID: ${REDDIT_CLIENT_ID} + REDDIT_SECRET: ${REDDIT_SECRET} -- cgit v1.2.3 From 27cf5788d7f960e6bfb68891874cdfdaa5584ba1 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Wed, 23 Oct 2019 10:15:47 +0200 Subject: Substract one to now calculation Avoid aving `Your reminder will arrive in 59 minutes and 59 seconds!` instead of `Your reminder will arrive in 1 hour!` --- bot/cogs/reminders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 8adebb04b..81990704b 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -2,7 +2,7 @@ import asyncio import logging import random import textwrap -from datetime import datetime +from datetime import datetime, timedelta from operator import itemgetter from typing import Optional @@ -176,7 +176,7 @@ class Reminders(Scheduler, Cog): } ) - now = datetime.utcnow() + now = datetime.utcnow() - timedelta(seconds=1) # Confirm to the user that it worked. await self._send_confirmation( -- cgit v1.2.3 From fae86078206eb5726c606636aaa6a0ba315f5b73 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Wed, 23 Oct 2019 11:04:26 +0200 Subject: Use standart filter conditions even if DEBUG_MODE is on. The old method filtered only in #dev-test but this channel no longer exists --- bot/cogs/filtering.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 1d1d74e74..fda58b83a 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -9,7 +9,7 @@ from discord.ext.commands import Bot, Cog from bot.cogs.moderation import ModLog from bot.constants import ( - Channels, Colours, DEBUG_MODE, + Channels, Colours, Filter, Icons, URLs ) @@ -136,10 +136,6 @@ class Filtering(Cog): and not msg.author.bot # Author not a bot ) - # If we're running the bot locally, ignore role whitelist and only listen to #dev-test - if DEBUG_MODE: - filter_message = not msg.author.bot and msg.channel.id == Channels.devtest - # If none of the above, we can start filtering. if filter_message: for filter_name, _filter in self.filters.items(): -- cgit v1.2.3 From 28985df9fd98ae2d22b9606f80f63a96040424cf Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 24 Oct 2019 10:45:14 +0800 Subject: Prepend emoji indicative of success of !eval --- bot/cogs/snekbox.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 81185cf3e..7316f9583 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -115,6 +115,16 @@ class Snekbox(Cog): return msg, error + @staticmethod + def get_status_emoji(results: dict) -> str: + """Return an emoji corresponding to the status code or lack of output in result.""" + if not results["stdout"].strip(): # No output + return ":warning:" + elif results["returncode"] == 0: # No error + return ":white_check_mark:" + else: # Exception + return ":x:" + async def format_output(self, output: str) -> Tuple[str, Optional[str]]: """ Format the output and return a tuple of the formatted output and a URL to the full output. @@ -204,7 +214,8 @@ class Snekbox(Cog): else: output, paste_link = await self.format_output(results["stdout"]) - msg = f"{ctx.author.mention} {msg}.\n\n```py\n{output}\n```" + 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}" -- cgit v1.2.3 From 7ad9b1b96cba59818943ada569ddbba1d092069b Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 24 Oct 2019 15:22:40 +0800 Subject: Fix incorrect type hint for return value --- bot/cogs/filtering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 13b8b9678..ec387ac40 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -241,7 +241,7 @@ class Filtering(Cog): break # We don't want multiple filters to trigger @staticmethod - async def _has_watchlist_words(text: str) -> bool: + async def _has_watchlist_words(text: str) -> Union[bool, re.Match]: """ Returns True if the text contains one of the regular expressions from the word_watchlist in our filter config. @@ -255,7 +255,7 @@ class Filtering(Cog): return False @staticmethod - async def _has_watchlist_tokens(text: str) -> bool: + async def _has_watchlist_tokens(text: str) -> Union[bool, re.Match]: """ Returns True if the text contains one of the regular expressions from the token_watchlist in our filter config. -- cgit v1.2.3 From 8ba85a74b77822ff89e1d18cd3f98a68439f0bbf Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Thu, 24 Oct 2019 12:39:41 +0200 Subject: Requested changes --- bot/cogs/free.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/free.py b/bot/cogs/free.py index f8031a32a..e1554a4b4 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -76,10 +76,8 @@ class Free(Cog): # Get position in list, inactivity, and channel object # For each channel, add to embed.description sorted_channels = sorted(free_channels, key=itemgetter(0), reverse=True) - if len(sorted_channels) > 3: # display 3 channels max - sorted_channels = sorted_channels[:3] - for (inactive, channel) in sorted_channels: + for (inactive, channel) in sorted_channels[:3]: minutes, seconds = divmod(inactive, 60) if minutes > 59: hours, minutes = divmod(minutes, 60) -- cgit v1.2.3 From efdd666f0972c4e5468aee3477b5150d176f6d25 Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Fri, 25 Oct 2019 09:06:12 +0200 Subject: Remove bold tag when no channel is available --- bot/cogs/free.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/free.py b/bot/cogs/free.py index e1554a4b4..82285656b 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -88,10 +88,10 @@ class Free(Cog): embed.set_footer(text="Please confirm these channels are free before posting") else: embed.description = ( - "**Doesn't look like any channels are available right now. " + "Doesn't look like any channels are available right now. " "You're welcome to check for yourself to be sure. " "If all channels are truly busy, please be patient " - "as one will likely be available soon.**" + "as one will likely be available soon." ) await ctx.send(embed=embed) -- cgit v1.2.3 From 1c0310c398664bc7565232494f5547b35d05c648 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 21 Oct 2019 13:16:53 -0700 Subject: Moderation: create a class to handle scheduling of infractions The class handles application, pardoning, and expiration of infractions. This will allow other cogs, such as superstarify, to later subclass it to receive the same functionality that the infractions cog had. --- bot/cogs/moderation/infractions.py | 357 +---------------------------------- bot/cogs/moderation/scheduler.py | 368 +++++++++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+), 353 deletions(-) create mode 100644 bot/cogs/moderation/scheduler.py diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 997ffe524..9d65dfa8a 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -1,8 +1,6 @@ import logging -import textwrap import typing as t from datetime import datetime -from gettext import ngettext import dateutil.parser import discord @@ -11,14 +9,11 @@ from discord.ext import commands from discord.ext.commands import Context, command from bot import constants -from bot.api import ResponseCodeError -from bot.constants import Colours, Event, STAFF_CHANNELS +from bot.constants import Event from bot.decorators import respect_role_hierarchy -from bot.utils import time from bot.utils.checks import with_role_check -from bot.utils.scheduling import Scheduler from . import utils -from .modlog import ModLog +from .scheduler import InfractionScheduler from .utils import MemberObject log = logging.getLogger(__name__) @@ -26,38 +21,18 @@ log = logging.getLogger(__name__) MemberConverter = t.Union[utils.UserTypes, utils.proxy_user] -class Infractions(Scheduler, commands.Cog): +class Infractions(InfractionScheduler, commands.Cog): """Apply and pardon infractions on users for moderation purposes.""" category = "Moderation" category_description = "Server moderation tools." def __init__(self, bot: commands.Bot): - super().__init__() + super().__init__(bot) - self.bot = bot self.category = "Moderation" self._muted_role = discord.Object(constants.Roles.muted) - self.bot.loop.create_task(self.reschedule_infractions()) - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def reschedule_infractions(self) -> None: - """Schedule expiration for previous infractions.""" - await self.bot.wait_until_ready() - - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={'active': 'true'} - ) - for infraction in infractions: - if infraction["expires_at"] is not None: - self.schedule_task(self.bot.loop, infraction["id"], infraction) - @commands.Cog.listener() async def on_member_join(self, member: Member) -> None: """Reapply active mute infractions for returning members.""" @@ -277,330 +252,6 @@ class Infractions(Scheduler, commands.Cog): action = ctx.guild.ban(user, reason=reason, delete_message_days=0) await self.apply_infraction(ctx, infraction, user, action) - # endregion - # region: Utility functions - - async def _scheduled_task(self, infraction: utils.Infraction) -> None: - """ - Marks an infraction expired after the delay from time of scheduling to time of expiration. - - At the time of expiration, the infraction is marked as inactive on the website and the - expiration task is cancelled. - """ - _id = infraction["id"] - - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - await time.wait_until(expiry) - - log.debug(f"Marking infraction {_id} as inactive (expired).") - await self.deactivate_infraction(infraction) - - async def deactivate_infraction( - self, - infraction: utils.Infraction, - send_log: bool = True - ) -> t.Dict[str, str]: - """ - Deactivate an active infraction and return a dictionary of lines to send in a mod log. - - The infraction is removed from Discord, marked as inactive in the database, and has its - expiration task cancelled. If `send_log` is True, a mod log is sent for the - deactivation of the infraction. - - Supported infraction types are mute and ban. Other types will raise a ValueError. - """ - guild = self.bot.get_guild(constants.Guild.id) - mod_role = guild.get_role(constants.Roles.moderator) - user_id = infraction["user"] - _type = infraction["type"] - _id = infraction["id"] - reason = f"Infraction #{_id} expired or was pardoned." - - log.debug(f"Marking infraction #{_id} as inactive (expired).") - - log_content = None - log_text = { - "Member": str(user_id), - "Actor": str(self.bot.user), - "Reason": infraction["reason"] - } - - try: - if _type == "mute": - user = guild.get_member(user_id) - if user: - # Remove the muted role. - self.mod_log.ignore(Event.member_update, user.id) - await user.remove_roles(self._muted_role, reason=reason) - - # DM the user about the expiration. - notified = await utils.notify_pardon( - user=user, - title="You have been unmuted.", - content="You may now send messages in the server.", - icon_url=utils.INFRACTION_ICONS["mute"][1] - ) - - log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["DM"] = "Sent" if notified else "**Failed**" - else: - log.info(f"Failed to unmute user {user_id}: user not found") - log_text["Failure"] = "User was not found in the guild." - elif _type == "ban": - user = discord.Object(user_id) - self.mod_log.ignore(Event.member_unban, user_id) - try: - await guild.unban(user, reason=reason) - except discord.NotFound: - log.info(f"Failed to unban user {user_id}: no active ban found on Discord") - log_text["Note"] = "No active ban found on Discord." - else: - raise ValueError( - f"Attempted to deactivate an unsupported infraction #{_id} ({_type})!" - ) - except discord.Forbidden: - log.warning(f"Failed to deactivate infraction #{_id} ({_type}): bot lacks permissions") - log_text["Failure"] = f"The bot lacks permissions to do this (role hierarchy?)" - log_content = mod_role.mention - except discord.HTTPException as e: - log.exception(f"Failed to deactivate infraction #{_id} ({_type})") - log_text["Failure"] = f"HTTPException with code {e.code}." - log_content = mod_role.mention - - # Check if the user is currently being watched by Big Brother. - try: - active_watch = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "watch", - "user__id": user_id - } - ) - - log_text["Watching"] = "Yes" if active_watch else "No" - except ResponseCodeError: - log.exception(f"Failed to fetch watch status for user {user_id}") - log_text["Watching"] = "Unknown - failed to fetch watch status." - - try: - # Mark infraction as inactive in the database. - await self.bot.api_client.patch( - f"bot/infractions/{_id}", - json={"active": False} - ) - except ResponseCodeError as e: - log.exception(f"Failed to deactivate infraction #{_id} ({_type})") - log_line = f"API request failed with code {e.status}." - log_content = mod_role.mention - - # Append to an existing failure message if possible - if "Failure" in log_text: - log_text["Failure"] += f" {log_line}" - else: - log_text["Failure"] = log_line - - # Cancel the expiration task. - if infraction["expires_at"] is not None: - self.cancel_task(infraction["id"]) - - # Send a log message to the mod log. - if send_log: - log_title = f"expiration failed" if "Failure" in log_text else "expired" - - await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS[_type][1], - colour=Colours.soft_green, - title=f"Infraction {log_title}: {_type}", - text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=f"ID: {_id}", - content=log_content, - ) - - return log_text - - async def apply_infraction( - self, - ctx: Context, - infraction: utils.Infraction, - user: MemberObject, - action_coro: t.Optional[t.Awaitable] = None - ) -> None: - """Apply an infraction to the user, log the infraction, and optionally notify the user.""" - infr_type = infraction["type"] - icon = utils.INFRACTION_ICONS[infr_type][0] - reason = infraction["reason"] - expiry = infraction["expires_at"] - - if expiry: - expiry = time.format_infraction(expiry) - - # Default values for the confirmation message and mod log. - confirm_msg = f":ok_hand: applied" - - # Specifying an expiry for a note or warning makes no sense. - if infr_type in ("note", "warning"): - expiry_msg = "" - else: - expiry_msg = f" until {expiry}" if expiry else " permanently" - - dm_result = "" - dm_log_text = "" - expiry_log_text = f"Expires: {expiry}" if expiry else "" - log_title = "applied" - log_content = None - - # DM the user about the infraction if it's not a shadow/hidden infraction. - if not infraction["hidden"]: - # Sometimes user is a discord.Object; make it a proper user. - await self.bot.fetch_user(user.id) - - # Accordingly display whether the user was successfully notified via DM. - if await utils.notify_infraction(user, infr_type, expiry, reason, icon): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" - else: - dm_log_text = "\nDM: **Failed**" - log_content = ctx.author.mention - - if infraction["actor"] == self.bot.user.id: - end_msg = f" (reason: {infraction['reason']})" - elif ctx.channel.id not in STAFF_CHANNELS: - end_msg = '' - else: - infractions = await self.bot.api_client.get( - "bot/infractions", - params={"user__id": str(user.id)} - ) - total = len(infractions) - end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" - - # Execute the necessary actions to apply the infraction on Discord. - if action_coro: - try: - await action_coro - if expiry: - # Schedule the expiration of the infraction. - self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - except discord.Forbidden: - # Accordingly display that applying the infraction failed. - confirm_msg = f":x: failed to apply" - expiry_msg = "" - log_content = ctx.author.mention - log_title = "failed to apply" - - # Send a confirmation message to the invoking context. - await ctx.send( - f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{end_msg}." - ) - - # Send a log message to the mod log. - await self.mod_log.send_log_message( - icon_url=icon, - colour=Colours.soft_red, - title=f"Infraction {log_title}: {infr_type}", - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author}{dm_log_text} - Reason: {reason} - {expiry_log_text} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) - - async def pardon_infraction(self, ctx: Context, infr_type: str, user: MemberObject) -> None: - """Prematurely end an infraction for a user and log the action in the mod log.""" - # Check the current active infraction - response = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': infr_type, - 'user__id': user.id - } - ) - - if not response: - await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.") - return - - # Deactivate the infraction and cancel its scheduled expiration task. - log_text = await self.deactivate_infraction(response[0], send_log=False) - - log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["Actor"] = str(ctx.message.author) - log_content = None - footer = f"ID: {response[0]['id']}" - - # If multiple active infractions were found, mark them as inactive in the database - # and cancel their expiration tasks. - if len(response) > 1: - log.warning(f"Found more than one active {infr_type} infraction for user {user.id}") - - footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - - log_note = f"Found multiple **active** {infr_type} infractions in the database." - if "Note" in log_text: - log_text["Note"] = f" {log_note}" - else: - log_text["Note"] = log_note - - # deactivate_infraction() is not called again because: - # 1. Discord cannot store multiple active bans or assign multiples of the same role - # 2. It would send a pardon DM for each active infraction, which is redundant - for infraction in response[1:]: - _id = infraction['id'] - try: - # Mark infraction as inactive in the database. - await self.bot.api_client.patch( - f"bot/infractions/{_id}", - json={"active": False} - ) - except ResponseCodeError: - log.exception(f"Failed to deactivate infraction #{_id} ({infr_type})") - # This is simpler and cleaner than trying to concatenate all the errors. - log_text["Failure"] = "See bot's logs for details." - - # Cancel pending expiration task. - if infraction["expires_at"] is not None: - self.cancel_task(infraction["id"]) - - # Accordingly display whether the user was successfully notified via DM. - dm_emoji = "" - if log_text.get("DM") == "Sent": - dm_emoji = ":incoming_envelope: " - elif "DM" in log_text: - # Mention the actor because the DM failed to send. - log_content = ctx.author.mention - - # Accordingly display whether the pardon failed. - if "Failure" in log_text: - confirm_msg = ":x: failed to pardon" - log_title = "pardon failed" - log_content = ctx.author.mention - else: - confirm_msg = f":ok_hand: pardoned" - log_title = "pardoned" - - # Send a confirmation message to the invoking context. - await ctx.send( - f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " - f"{log_text.get('Failure', '')}" - ) - - # Send a log message to the mod log. - await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS[infr_type][1], - colour=Colours.soft_green, - title=f"Infraction {log_title}: {infr_type}", - thumbnail=user.avatar_url_as(static_format="png"), - text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=footer, - content=log_content, - ) - # endregion # This cannot be static (must have a __func__ attribute). diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py new file mode 100644 index 000000000..408e9943e --- /dev/null +++ b/bot/cogs/moderation/scheduler.py @@ -0,0 +1,368 @@ +import logging +import textwrap +import typing as t +from gettext import ngettext + +import dateutil.parser +import discord +from discord.ext import commands +from discord.ext.commands import Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.constants import Colours, Event, STAFF_CHANNELS +from bot.utils import time +from bot.utils.scheduling import Scheduler +from . import utils +from .modlog import ModLog +from .utils import MemberObject + +log = logging.getLogger(__name__) + + +class InfractionScheduler(Scheduler): + """Handles the application, pardoning, and expiration of infractions.""" + + def __init__(self, bot: commands.Bot): + super().__init__() + + self.bot = bot + self.bot.loop.create_task(self.reschedule_infractions()) + + @property + def mod_log(self) -> ModLog: + """Get the currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + async def reschedule_infractions(self) -> None: + """Schedule expiration for previous infractions.""" + await self.bot.wait_until_ready() + + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={'active': 'true'} + ) + for infraction in infractions: + if infraction["expires_at"] is not None: + self.schedule_task(self.bot.loop, infraction["id"], infraction) + + async def apply_infraction( + self, + ctx: Context, + infraction: utils.Infraction, + user: MemberObject, + action_coro: t.Optional[t.Awaitable] = None + ) -> None: + """Apply an infraction to the user, log the infraction, and optionally notify the user.""" + infr_type = infraction["type"] + icon = utils.INFRACTION_ICONS[infr_type][0] + reason = infraction["reason"] + expiry = infraction["expires_at"] + + if expiry: + expiry = time.format_infraction(expiry) + + # Default values for the confirmation message and mod log. + confirm_msg = f":ok_hand: applied" + + # Specifying an expiry for a note or warning makes no sense. + if infr_type in ("note", "warning"): + expiry_msg = "" + else: + expiry_msg = f" until {expiry}" if expiry else " permanently" + + dm_result = "" + dm_log_text = "" + expiry_log_text = f"Expires: {expiry}" if expiry else "" + log_title = "applied" + log_content = None + + # DM the user about the infraction if it's not a shadow/hidden infraction. + if not infraction["hidden"]: + # Sometimes user is a discord.Object; make it a proper user. + await self.bot.fetch_user(user.id) + + # Accordingly display whether the user was successfully notified via DM. + if await utils.notify_infraction(user, infr_type, expiry, reason, icon): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" + else: + dm_log_text = "\nDM: **Failed**" + log_content = ctx.author.mention + + if infraction["actor"] == self.bot.user.id: + end_msg = f" (reason: {infraction['reason']})" + elif ctx.channel.id not in STAFF_CHANNELS: + end_msg = "" + else: + infractions = await self.bot.api_client.get( + "bot/infractions", + params={"user__id": str(user.id)} + ) + total = len(infractions) + end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" + + # Execute the necessary actions to apply the infraction on Discord. + if action_coro: + try: + await action_coro + if expiry: + # Schedule the expiration of the infraction. + self.schedule_task(ctx.bot.loop, infraction["id"], infraction) + except discord.Forbidden: + # Accordingly display that applying the infraction failed. + confirm_msg = f":x: failed to apply" + expiry_msg = "" + log_content = ctx.author.mention + log_title = "failed to apply" + + # Send a confirmation message to the invoking context. + await ctx.send( + f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{end_msg}." + ) + + # Send a log message to the mod log. + await self.mod_log.send_log_message( + icon_url=icon, + colour=Colours.soft_red, + title=f"Infraction {log_title}: {infr_type}", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author}{dm_log_text} + Reason: {reason} + {expiry_log_text} + """), + content=log_content, + footer=f"ID {infraction['id']}" + ) + + async def pardon_infraction(self, ctx: Context, infr_type: str, user: MemberObject) -> None: + """Prematurely end an infraction for a user and log the action in the mod log.""" + # Check the current active infraction + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': infr_type, + 'user__id': user.id + } + ) + + if not response: + await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.") + return + + # Deactivate the infraction and cancel its scheduled expiration task. + log_text = await self.deactivate_infraction(response[0], send_log=False) + + log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["Actor"] = str(ctx.message.author) + log_content = None + footer = f"ID: {response[0]['id']}" + + # If multiple active infractions were found, mark them as inactive in the database + # and cancel their expiration tasks. + if len(response) > 1: + log.warning(f"Found more than one active {infr_type} infraction for user {user.id}") + + footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" + + log_note = f"Found multiple **active** {infr_type} infractions in the database." + if "Note" in log_text: + log_text["Note"] = f" {log_note}" + else: + log_text["Note"] = log_note + + # deactivate_infraction() is not called again because: + # 1. Discord cannot store multiple active bans or assign multiples of the same role + # 2. It would send a pardon DM for each active infraction, which is redundant + for infraction in response[1:]: + _id = infraction['id'] + try: + # Mark infraction as inactive in the database. + await self.bot.api_client.patch( + f"bot/infractions/{_id}", + json={"active": False} + ) + except ResponseCodeError: + log.exception(f"Failed to deactivate infraction #{_id} ({infr_type})") + # This is simpler and cleaner than trying to concatenate all the errors. + log_text["Failure"] = "See bot's logs for details." + + # Cancel pending expiration task. + if infraction["expires_at"] is not None: + self.cancel_task(infraction["id"]) + + # Accordingly display whether the user was successfully notified via DM. + dm_emoji = "" + if log_text.get("DM") == "Sent": + dm_emoji = ":incoming_envelope: " + elif "DM" in log_text: + # Mention the actor because the DM failed to send. + log_content = ctx.author.mention + + # Accordingly display whether the pardon failed. + if "Failure" in log_text: + confirm_msg = ":x: failed to pardon" + log_title = "pardon failed" + log_content = ctx.author.mention + else: + confirm_msg = f":ok_hand: pardoned" + log_title = "pardoned" + + # Send a confirmation message to the invoking context. + await ctx.send( + f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " + f"{log_text.get('Failure', '')}" + ) + + # Send a log message to the mod log. + await self.mod_log.send_log_message( + icon_url=utils.INFRACTION_ICONS[infr_type][1], + colour=Colours.soft_green, + title=f"Infraction {log_title}: {infr_type}", + thumbnail=user.avatar_url_as(static_format="png"), + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), + footer=footer, + content=log_content, + ) + + async def deactivate_infraction( + self, + infraction: utils.Infraction, + send_log: bool = True + ) -> t.Dict[str, str]: + """ + Deactivate an active infraction and return a dictionary of lines to send in a mod log. + + The infraction is removed from Discord, marked as inactive in the database, and has its + expiration task cancelled. If `send_log` is True, a mod log is sent for the + deactivation of the infraction. + + Supported infraction types are mute and ban. Other types will raise a ValueError. + """ + guild = self.bot.get_guild(constants.Guild.id) + mod_role = guild.get_role(constants.Roles.moderator) + user_id = infraction["user"] + _type = infraction["type"] + _id = infraction["id"] + reason = f"Infraction #{_id} expired or was pardoned." + + log.debug(f"Marking infraction #{_id} as inactive (expired).") + + log_content = None + log_text = { + "Member": str(user_id), + "Actor": str(self.bot.user), + "Reason": infraction["reason"] + } + + try: + if _type == "mute": + user = guild.get_member(user_id) + if user: + # Remove the muted role. + self.mod_log.ignore(Event.member_update, user.id) + await user.remove_roles(self._muted_role, reason=reason) + + # DM the user about the expiration. + notified = await utils.notify_pardon( + user=user, + title="You have been unmuted.", + content="You may now send messages in the server.", + icon_url=utils.INFRACTION_ICONS["mute"][1] + ) + + log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["DM"] = "Sent" if notified else "**Failed**" + else: + log.info(f"Failed to unmute user {user_id}: user not found") + log_text["Failure"] = "User was not found in the guild." + elif _type == "ban": + user = discord.Object(user_id) + self.mod_log.ignore(Event.member_unban, user_id) + try: + await guild.unban(user, reason=reason) + except discord.NotFound: + log.info(f"Failed to unban user {user_id}: no active ban found on Discord") + log_text["Note"] = "No active ban found on Discord." + else: + raise ValueError( + f"Attempted to deactivate an unsupported infraction #{_id} ({_type})!" + ) + except discord.Forbidden: + log.warning(f"Failed to deactivate infraction #{_id} ({_type}): bot lacks permissions") + log_text["Failure"] = f"The bot lacks permissions to do this (role hierarchy?)" + log_content = mod_role.mention + except discord.HTTPException as e: + log.exception(f"Failed to deactivate infraction #{_id} ({_type})") + log_text["Failure"] = f"HTTPException with code {e.code}." + log_content = mod_role.mention + + # Check if the user is currently being watched by Big Brother. + try: + active_watch = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "watch", + "user__id": user_id + } + ) + + log_text["Watching"] = "Yes" if active_watch else "No" + except ResponseCodeError: + log.exception(f"Failed to fetch watch status for user {user_id}") + log_text["Watching"] = "Unknown - failed to fetch watch status." + + try: + # Mark infraction as inactive in the database. + await self.bot.api_client.patch( + f"bot/infractions/{_id}", + json={"active": False} + ) + except ResponseCodeError as e: + log.exception(f"Failed to deactivate infraction #{_id} ({_type})") + log_line = f"API request failed with code {e.status}." + log_content = mod_role.mention + + # Append to an existing failure message if possible + if "Failure" in log_text: + log_text["Failure"] += f" {log_line}" + else: + log_text["Failure"] = log_line + + # Cancel the expiration task. + if infraction["expires_at"] is not None: + self.cancel_task(infraction["id"]) + + # Send a log message to the mod log. + if send_log: + log_title = f"expiration failed" if "Failure" in log_text else "expired" + + await self.mod_log.send_log_message( + icon_url=utils.INFRACTION_ICONS[_type][1], + colour=Colours.soft_green, + title=f"Infraction {log_title}: {_type}", + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), + footer=f"ID: {_id}", + content=log_content, + ) + + return log_text + + async def _scheduled_task(self, infraction: utils.Infraction) -> None: + """ + Marks an infraction expired after the delay from time of scheduling to time of expiration. + + At the time of expiration, the infraction is marked as inactive on the website and the + expiration task is cancelled. + """ + _id = infraction["id"] + + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + await time.wait_until(expiry) + + log.debug(f"Marking infraction {_id} as inactive (expired).") + await self.deactivate_infraction(infraction) -- cgit v1.2.3 From 832c7129d57a919f15919e883607845df6b6dacc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 21 Oct 2019 17:33:27 -0700 Subject: InfractionScheduler: delegate type-specific pardon code to subclasses * Create an abstract method for performing type-specific infraction deactivation * Move infraction-specific pardon code to separate functions --- bot/cogs/moderation/infractions.py | 61 +++++++++++++++++++++++++++++++++++++- bot/cogs/moderation/scheduler.py | 51 +++++++++++-------------------- 2 files changed, 77 insertions(+), 35 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 9d65dfa8a..fbfcb8ae3 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -209,7 +209,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.pardon_infraction(ctx, "ban", user) # endregion - # region: Base infraction functions + # region: Base apply functions async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: """Apply a mute infraction with kwargs passed to `post_infraction`.""" @@ -252,6 +252,65 @@ class Infractions(InfractionScheduler, commands.Cog): action = ctx.guild.ban(user, reason=reason, delete_message_days=0) await self.apply_infraction(ctx, infraction, user, action) + # endregion + # region: Base pardon functions + + async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: str) -> t.Dict[str, str]: + """Remove a user's muted role, DM them a notification, and return a log dict.""" + user = guild.get_member(user_id) + log_text = {} + + if user: + # Remove the muted role. + self.mod_log.ignore(Event.member_update, user.id) + await user.remove_roles(self._muted_role, reason=reason) + + # DM the user about the expiration. + notified = await utils.notify_pardon( + user=user, + title="You have been unmuted.", + content="You may now send messages in the server.", + icon_url=utils.INFRACTION_ICONS["mute"][1] + ) + + log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["DM"] = "Sent" if notified else "**Failed**" + else: + log.info(f"Failed to unmute user {user_id}: user not found") + log_text["Failure"] = "User was not found in the guild." + + return log_text + + async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: str) -> t.Dict[str, str]: + """Remove a user's ban on the Discord guild and return a log dict.""" + user = discord.Object(user_id) + log_text = {} + + self.mod_log.ignore(Event.member_unban, user_id) + + try: + await guild.unban(user, reason=reason) + except discord.NotFound: + log.info(f"Failed to unban user {user_id}: no active ban found on Discord") + log_text["Note"] = "No active ban found on Discord." + + return log_text + + async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """ + Execute deactivation steps specific to the infraction's type and return a log dict. + + If an infraction type is unsupported, return None instead. + """ + guild = self.bot.get_guild(constants.Guild.id) + user_id = infraction["user"] + reason = f"Infraction #{infraction['id']} expired or was pardoned." + + if infraction["type"] == "mute": + return await self.pardon_mute(user_id, guild, reason) + elif infraction["type"] == "ban": + return await self.pardon_ban(user_id, guild, reason) + # endregion # This cannot be static (must have a __func__ attribute). diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 408e9943e..045b0bd13 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -1,16 +1,16 @@ import logging import textwrap import typing as t +from abc import abstractmethod from gettext import ngettext import dateutil.parser import discord -from discord.ext import commands -from discord.ext.commands import Context +from discord.ext.commands import Bot, Context from bot import constants from bot.api import ResponseCodeError -from bot.constants import Colours, Event, STAFF_CHANNELS +from bot.constants import Colours, STAFF_CHANNELS from bot.utils import time from bot.utils.scheduling import Scheduler from . import utils @@ -23,7 +23,7 @@ log = logging.getLogger(__name__) class InfractionScheduler(Scheduler): """Handles the application, pardoning, and expiration of infractions.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): super().__init__() self.bot = bot @@ -240,14 +240,13 @@ class InfractionScheduler(Scheduler): expiration task cancelled. If `send_log` is True, a mod log is sent for the deactivation of the infraction. - Supported infraction types are mute and ban. Other types will raise a ValueError. + Infractions of unsupported types will raise a ValueError. """ guild = self.bot.get_guild(constants.Guild.id) mod_role = guild.get_role(constants.Roles.moderator) user_id = infraction["user"] _type = infraction["type"] _id = infraction["id"] - reason = f"Infraction #{_id} expired or was pardoned." log.debug(f"Marking infraction #{_id} as inactive (expired).") @@ -259,34 +258,9 @@ class InfractionScheduler(Scheduler): } try: - if _type == "mute": - user = guild.get_member(user_id) - if user: - # Remove the muted role. - self.mod_log.ignore(Event.member_update, user.id) - await user.remove_roles(self._muted_role, reason=reason) - - # DM the user about the expiration. - notified = await utils.notify_pardon( - user=user, - title="You have been unmuted.", - content="You may now send messages in the server.", - icon_url=utils.INFRACTION_ICONS["mute"][1] - ) - - log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["DM"] = "Sent" if notified else "**Failed**" - else: - log.info(f"Failed to unmute user {user_id}: user not found") - log_text["Failure"] = "User was not found in the guild." - elif _type == "ban": - user = discord.Object(user_id) - self.mod_log.ignore(Event.member_unban, user_id) - try: - await guild.unban(user, reason=reason) - except discord.NotFound: - log.info(f"Failed to unban user {user_id}: no active ban found on Discord") - log_text["Note"] = "No active ban found on Discord." + returned_log = await self._pardon_action(infraction) + if returned_log is not None: + log_text = {**log_text, **returned_log} # Merge the logs together else: raise ValueError( f"Attempted to deactivate an unsupported infraction #{_id} ({_type})!" @@ -352,6 +326,15 @@ class InfractionScheduler(Scheduler): return log_text + @abstractmethod + async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """ + Execute deactivation steps specific to the infraction's type and return a log dict. + + If an infraction type is unsupported, return None instead. + """ + raise NotImplementedError + async def _scheduled_task(self, infraction: utils.Infraction) -> None: """ Marks an infraction expired after the delay from time of scheduling to time of expiration. -- cgit v1.2.3 From c1ab8ecfb6992f175351b7c921fabada9ce520d2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 23 Oct 2019 14:57:59 -0700 Subject: Superstarify: add icons to constants and config --- bot/cogs/moderation/utils.py | 7 ++++--- bot/constants.py | 3 +++ config-default.yml | 3 +++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 788a40d40..c05cb69a1 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -15,11 +15,12 @@ log = logging.getLogger(__name__) # apply icon, pardon icon INFRACTION_ICONS = { - "mute": (Icons.user_mute, Icons.user_unmute), - "kick": (Icons.sign_out, None), "ban": (Icons.user_ban, Icons.user_unban), - "warning": (Icons.user_warn, None), + "kick": (Icons.sign_out, None), + "mute": (Icons.user_mute, Icons.user_unmute), "note": (Icons.user_warn, None), + "superstar": (Icons.superstarify, Icons.unsuperstarify), + "warning": (Icons.user_warn, None), } RULES_URL = "https://pythondiscord.com/pages/rules" APPEALABLE_INFRACTIONS = ("ban", "mute") diff --git a/bot/constants.py b/bot/constants.py index 838fe7a79..ad68919ea 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -308,6 +308,9 @@ class Icons(metaclass=YAMLGetter): questionmark: str + superstarify: str + unsuperstarify: str + class CleanMessages(metaclass=YAMLGetter): section = "bot" diff --git a/config-default.yml b/config-default.yml index 4638a89ee..8d7481833 100644 --- a/config-default.yml +++ b/config-default.yml @@ -82,6 +82,9 @@ style: questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png" + superstarify: "https://cdn.discordapp.com/emojis/636288153044516874.png" + unsuperstarify: "https://cdn.discordapp.com/emojis/636288201258172446.png" + guild: id: 267624335836053506 -- cgit v1.2.3 From 6ba259ab5a39cf4257084f5458b93ab02fbfa8fa Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 23 Oct 2019 17:43:08 -0700 Subject: InfractionScheduler: use fetched user for notify_infraction --- bot/cogs/moderation/scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 045b0bd13..cef96de99 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -80,7 +80,7 @@ class InfractionScheduler(Scheduler): # DM the user about the infraction if it's not a shadow/hidden infraction. if not infraction["hidden"]: # Sometimes user is a discord.Object; make it a proper user. - await self.bot.fetch_user(user.id) + user = await self.bot.fetch_user(user.id) # Accordingly display whether the user was successfully notified via DM. if await utils.notify_infraction(user, infr_type, expiry, reason, icon): -- cgit v1.2.3 From 3cf0dbf59cfb58fe010fd07b0d5a7e4acedcca0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 24 Oct 2019 22:27:58 -0700 Subject: Superstarify: schedule infractions by subclassing InfractionScheduler --- bot/cogs/moderation/superstarify.py | 67 ++++++++++++++----------------------- 1 file changed, 25 insertions(+), 42 deletions(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 82f8621fc..ed2412c35 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -1,17 +1,18 @@ import json import logging import random +import typing as t from pathlib import Path from discord import Colour, Embed, Member from discord.errors import Forbidden -from discord.ext.commands import Bot, Cog, Context, command +from discord.ext.commands import Cog, Context, command from bot import constants from bot.utils.checks import with_role_check from bot.utils.time import format_infraction from . import utils -from .modlog import ModLog +from .scheduler import InfractionScheduler log = logging.getLogger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" @@ -20,17 +21,9 @@ with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file: STAR_NAMES = json.load(stars_file) -class Superstarify(Cog): +class Superstarify(InfractionScheduler, Cog): """A set of commands to moderate terrible nicknames.""" - def __init__(self, bot: Bot): - self.bot = bot - - @property - def modlog(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - @Cog.listener() async def on_member_update(self, before: Member, after: Member) -> None: """ @@ -189,7 +182,7 @@ class Superstarify(Cog): f"New nickname: `{forced_nick}`\n" f"Superstardom ends: **{expiry_str}**" ) - await self.modlog.send_log_message( + await self.mod_log.send_log_message( icon_url=constants.Icons.user_update, colour=Colour.gold(), title="Member Achieved Superstardom", @@ -207,45 +200,35 @@ class Superstarify(Cog): # Change the nick and return the embed log.trace("Changing the users nickname and sending the embed.") await member.edit(nick=forced_nick) + self.schedule_task(ctx.bot.loop, infraction["id"], infraction) await ctx.send(embed=embed) @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) async def unsuperstarify(self, ctx: Context, member: Member) -> None: - """Remove the superstarify entry from our database, allowing the user to change their nickname.""" - log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") - - embed = Embed() - embed.colour = Colour.blurple() - - active_superstarifies = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'superstar', - 'user__id': str(member.id) - } - ) - if not active_superstarifies: - await ctx.send(":x: There is no active superstarify infraction for this user.") - return + """Remove the superstarify infraction and allow the user to change their nickname.""" + await self.pardon_infraction(ctx, "superstar", member) - [infraction] = active_superstarifies - await self.bot.api_client.patch( - 'bot/infractions/' + str(infraction['id']), - json={'active': False} - ) + async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """Pardon a superstar infraction and return a log dict.""" + guild = self.bot.get_guild(constants.Guild.id) + user = guild.get_member(infraction["user"]) - embed = Embed() - embed.description = "User has been released from superstar-prison." - embed.title = random.choice(constants.POSITIVE_REPLIES) + # Don't bother sending a notification if the user left the guild. + if infraction["type"] != "mute" or not user: + return {} - await utils.notify_pardon( - user=member, + # DM the user about the expiration. + notified = await utils.notify_pardon( + user=user, title="You are no longer superstarified.", - content="You may now change your nickname on the server." + content="You may now change your nickname on the server.", + icon_url=utils.INFRACTION_ICONS["superstar"][1] ) - log.trace(f"{member.display_name} was successfully released from superstar-prison.") - await ctx.send(embed=embed) + + return { + "Member": f"{user.mention}(`{user.id}`)", + "DM": "Sent" if notified else "**Failed**" + } @staticmethod def get_nick(infraction_id: int, member_id: int) -> str: -- cgit v1.2.3 From d922892204f20fe4936684880cb744c0dcfdfa8f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 24 Oct 2019 22:30:39 -0700 Subject: Superstarify: refactor superstarify command * Edit the nickname and schedule the infraction before sending messages * Reformat mod log description to be consistent with other infractions * Use INFRACTION_ICONS constant --- bot/cogs/moderation/superstarify.py | 64 ++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index ed2412c35..051eaf6b5 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -1,6 +1,7 @@ import json import logging import random +import textwrap import typing as t from pathlib import Path @@ -158,51 +159,56 @@ class Superstarify(InfractionScheduler, Cog): if await utils.has_active_infraction(ctx, member, "superstar"): return - reason = reason or ('old nick: ' + member.display_name) - infraction = await utils.post_infraction(ctx, member, 'superstar', reason, expires_at=duration) - forced_nick = self.get_nick(infraction['id'], member.id) + # Post the infraction to the API + reason = reason or f"old nick: {member.display_name}" + infraction = await utils.post_infraction(ctx, member, "superstar", reason, expires_at=duration) + + forced_nick = self.get_nick(infraction["id"], member.id) expiry_str = format_infraction(infraction["expires_at"]) + # Apply the infraction and schedule the expiration task. + await member.edit(nick=forced_nick, reason=reason) + self.schedule_task(ctx.bot.loop, infraction["id"], infraction) + + # Send a DM to the user to notify them of their new infraction. + await utils.notify_infraction( + user=member, + infr_type="Superstarify", + expires_at=expiry_str, + reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL}).", + icon_url=utils.INFRACTION_ICONS["superstar"][0] + ) + + # Send an embed with the infraction information to the invoking context. embed = Embed() embed.title = "Congratulations!" embed.description = ( - f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. " + f"Your previous nickname, **{member.display_name}**, " + f"was so bad that we have decided to change it. " f"Your new nickname will be **{forced_nick}**.\n\n" f"You will be unable to change your nickname until \n**{expiry_str}**.\n\n" "If you're confused by this, please read our " f"[official nickname policy]({NICKNAME_POLICY_URL})." ) + await ctx.send(embed=embed) - # Log to the mod_log channel - log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") - mod_log_message = ( - f"**{member}** (`{member.id}`)\n\n" - f"Superstarified by **{ctx.author.name}**\n" - f"Old nickname: `{member.display_name}`\n" - f"New nickname: `{forced_nick}`\n" - f"Superstardom ends: **{expiry_str}**" - ) + # Log to the mod log channel. await self.mod_log.send_log_message( - icon_url=constants.Icons.user_update, + icon_url=utils.INFRACTION_ICONS["superstar"][0], colour=Colour.gold(), title="Member Achieved Superstardom", - text=mod_log_message, - thumbnail=member.avatar_url_as(static_format="png") - ) - - await utils.notify_infraction( - user=member, - infr_type="Superstarify", - expires_at=expiry_str, - reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." + thumbnail=member.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {member.mentiom} (`{member.id}`) + Actor: {ctx.message.author} + Reason: {reason} + Expires: {expiry_str} + Old nickname: `{member.display_name}` + New nickname: `{forced_nick}` + """), + footer=f"ID {infraction['id']}" ) - # Change the nick and return the embed - log.trace("Changing the users nickname and sending the embed.") - await member.edit(nick=forced_nick) - self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - await ctx.send(embed=embed) - @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) async def unsuperstarify(self, ctx: Context, member: Member) -> None: """Remove the superstarify infraction and allow the user to change their nickname.""" -- cgit v1.2.3 From 91e03a8ae03e90a50afb7cfee3cfb2a963365b30 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 24 Oct 2019 23:30:35 -0700 Subject: InfractionScheduler: create generic function for reapplying infractions reapply_infraction() is a generic function which reapplies a given infraction if it is still active or deactivates it if less than 60 seconds remain. Most of the code from Infractions.on_member_joined now lives in this new function. This simplifies the "fancier" DM and mod log that were previously sent for superstar in on_member_join. --- bot/cogs/moderation/infractions.py | 30 ++++++------------- bot/cogs/moderation/scheduler.py | 20 +++++++++++++ bot/cogs/moderation/superstarify.py | 57 +++++++------------------------------ 3 files changed, 39 insertions(+), 68 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index fbfcb8ae3..7e8d58144 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -1,8 +1,6 @@ import logging import typing as t -from datetime import datetime -import dateutil.parser import discord from discord import Member from discord.ext import commands @@ -37,31 +35,19 @@ class Infractions(InfractionScheduler, commands.Cog): async def on_member_join(self, member: Member) -> None: """Reapply active mute infractions for returning members.""" active_mutes = await self.bot.api_client.get( - 'bot/infractions', + "bot/infractions", params={ - 'user__id': str(member.id), - 'type': 'mute', - 'active': 'true' + "active": "true", + "type": "mute", + "user__id": member.id } ) - if not active_mutes: - return - - # Assume a single mute because of restrictions elsewhere. - mute = active_mutes[0] - # Calculate the time remaining, in seconds, for the mute. - expiry = dateutil.parser.isoparse(mute["expires_at"]).replace(tzinfo=None) - delta = (expiry - datetime.utcnow()).total_seconds() - - # Mark as inactive if less than a minute remains. - if delta < 60: - await self.deactivate_infraction(mute) - return + if active_mutes: + reason = f"Re-applying active mute: {active_mutes[0]['id']}" + action = member.add_roles(self._muted_role, reason=reason) - # Allowing mod log since this is a passive action that should be logged. - await member.add_roles(self._muted_role, reason=f"Re-applying active mute: {mute['id']}") - log.debug(f"User {member.id} has been re-muted on rejoin.") + await self.reapply_infraction(active_mutes[0], action) # region: Permanent infractions diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index cef96de99..fe8e43fbe 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -2,6 +2,7 @@ import logging import textwrap import typing as t from abc import abstractmethod +from datetime import datetime from gettext import ngettext import dateutil.parser @@ -46,6 +47,25 @@ class InfractionScheduler(Scheduler): if infraction["expires_at"] is not None: self.schedule_task(self.bot.loop, infraction["id"], infraction) + async def reapply_infraction( + self, + infraction: utils.Infraction, + apply_coro: t.Optional[t.Awaitable] + ) -> None: + """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" + # Calculate the time remaining, in seconds, for the mute. + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + delta = (expiry - datetime.utcnow()).total_seconds() + + # Mark as inactive if less than a minute remains. + if delta < 60: + await self.deactivate_infraction(infraction) + return + + # Allowing mod log since this is a passive action that should be logged. + await apply_coro + log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") + async def apply_infraction( self, ctx: Context, diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 051eaf6b5..3200087ae 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -82,60 +82,25 @@ class Superstarify(InfractionScheduler, Cog): @Cog.listener() async def on_member_join(self, member: Member) -> None: - """ - This event will trigger when someone (re)joins the server. - - At this point we will look up the user in our database and check whether they are in - superstar-prison. If so, we will change their name back to the forced nickname. - """ + """Reapply active superstar infractions for returning members.""" active_superstarifies = await self.bot.api_client.get( - 'bot/infractions', + "bot/infractions", params={ - 'active': 'true', - 'type': 'superstar', - 'user__id': member.id + "active": "true", + "type": "superstar", + "user__id": member.id } ) if active_superstarifies: - [infraction] = active_superstarifies - forced_nick = self.get_nick(infraction['id'], member.id) - await member.edit(nick=forced_nick) - end_timestamp_human = format_infraction(infraction['expires_at']) - - try: - await member.send( - "You have left and rejoined the **Python Discord** server, effectively resetting " - f"your nickname from **{forced_nick}** to **{member.name}**, " - "but as you are currently in superstar-prison, you do not have permission to do so. " - "Therefore your nickname was automatically changed back. You will be allowed to " - "change your nickname again at the following time:\n\n" - f"**{end_timestamp_human}**." - ) - except Forbidden: - log.warning( - "The user left and rejoined the server while in superstar-prison. " - "This led to the bot trying to DM the user to let them know their name was restored, " - "but the user had either blocked the bot or disabled DMs, so it was not possible " - "to DM them, and a discord.errors.Forbidden error was incurred." - ) - - # Log to the mod_log channel - log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") - mod_log_message = ( - f"**{member}** (`{member.id}`)\n\n" - f"Superstarified member potentially tried to escape the prison.\n" - f"Restored enforced nickname: `{forced_nick}`\n" - f"Superstardom ends: **{end_timestamp_human}**" - ) - await self.modlog.send_log_message( - icon_url=constants.Icons.user_update, - colour=Colour.gold(), - title="Superstar member rejoined server", - text=mod_log_message, - thumbnail=member.avatar_url_as(static_format="png") + infraction = active_superstarifies[0] + action = member.edit( + nick=self.get_nick(infraction["id"], member.id), + reason=f"Superstarified member tried to escape the prison: {infraction['id']}" ) + await self.reapply_infraction(infraction, action) + @command(name='superstarify', aliases=('force_nick', 'star')) async def superstarify(self, ctx: Context, member: Member, duration: utils.Expiry, reason: str = None) -> None: """ -- cgit v1.2.3 From df48de7f6330e43782a9af5ff2e4ebee5c7ed9b1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 24 Oct 2019 23:51:33 -0700 Subject: InfractionScheduler: prevent duplicate rescheduling of infractions --- bot/cogs/moderation/infractions.py | 2 +- bot/cogs/moderation/scheduler.py | 8 ++++---- bot/cogs/moderation/superstarify.py | 5 ++++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 7e8d58144..0e29b02f7 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -26,7 +26,7 @@ class Infractions(InfractionScheduler, commands.Cog): category_description = "Server moderation tools." def __init__(self, bot: commands.Bot): - super().__init__(bot) + super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"}) self.category = "Moderation" self._muted_role = discord.Object(constants.Roles.muted) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index fe8e43fbe..74ff62981 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -24,18 +24,18 @@ log = logging.getLogger(__name__) class InfractionScheduler(Scheduler): """Handles the application, pardoning, and expiration of infractions.""" - def __init__(self, bot: Bot): + def __init__(self, bot: Bot, supported_infractions: t.Container[str]): super().__init__() self.bot = bot - self.bot.loop.create_task(self.reschedule_infractions()) + self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) @property def mod_log(self) -> ModLog: """Get the currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def reschedule_infractions(self) -> None: + async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None: """Schedule expiration for previous infractions.""" await self.bot.wait_until_ready() @@ -44,7 +44,7 @@ class InfractionScheduler(Scheduler): params={'active': 'true'} ) for infraction in infractions: - if infraction["expires_at"] is not None: + if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: self.schedule_task(self.bot.loop, infraction["id"], infraction) async def reapply_infraction( diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 3200087ae..a534ec762 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -7,7 +7,7 @@ from pathlib import Path from discord import Colour, Embed, Member from discord.errors import Forbidden -from discord.ext.commands import Cog, Context, command +from discord.ext.commands import Bot, Cog, Context, command from bot import constants from bot.utils.checks import with_role_check @@ -25,6 +25,9 @@ with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file: class Superstarify(InfractionScheduler, Cog): """A set of commands to moderate terrible nicknames.""" + def __init__(self, bot: Bot): + super().__init__(bot, supported_infractions={"superstar"}) + @Cog.listener() async def on_member_update(self, before: Member, after: Member) -> None: """ -- cgit v1.2.3 From a8e85349f22435c5bdb12287f6f632f95ee9389b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 00:00:10 -0700 Subject: Superstarify: ignore member update event when applying --- bot/cogs/moderation/superstarify.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index a534ec762..06a68d89e 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -135,6 +135,7 @@ class Superstarify(InfractionScheduler, Cog): expiry_str = format_infraction(infraction["expires_at"]) # Apply the infraction and schedule the expiration task. + self.mod_log.ignore(constants.Event.member_update, member.id) await member.edit(nick=forced_nick, reason=reason) self.schedule_task(ctx.bot.loop, infraction["id"], infraction) -- cgit v1.2.3 From 58de283e68253ce75c33d25433d2c5825e2d732b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 14:58:48 -0700 Subject: Superstarify: refactor on_member_update event * Use notify_infraction to be more consistent * Add an audit log reason to the member edit * Simplify docstring * Simplify log message --- bot/cogs/moderation/superstarify.py | 74 ++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 39 deletions(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 06a68d89e..45db6676a 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -6,7 +6,6 @@ import typing as t from pathlib import Path from discord import Colour, Embed, Member -from discord.errors import Forbidden from discord.ext.commands import Bot, Cog, Context, command from bot import constants @@ -30,13 +29,7 @@ class Superstarify(InfractionScheduler, Cog): @Cog.listener() async def on_member_update(self, before: Member, after: Member) -> None: - """ - This event will trigger when someone changes their name. - - At this point we will look up the user in our database and check whether they are allowed to - change their names, or if they are in superstar-prison. If they are not allowed, we will - change it back. - """ + """Revert nickname edits if the user has an active superstarify infraction.""" if before.display_name == after.display_name: return # User didn't change their nickname. Abort! @@ -46,42 +39,45 @@ class Superstarify(InfractionScheduler, Cog): ) active_superstarifies = await self.bot.api_client.get( - 'bot/infractions', + "bot/infractions", params={ - 'active': 'true', - 'type': 'superstar', - 'user__id': str(before.id) + "active": "true", + "type": "superstar", + "user__id": str(before.id) } ) if active_superstarifies: - [infraction] = active_superstarifies - forced_nick = self.get_nick(infraction['id'], before.id) - if after.display_name == forced_nick: - return # Nick change was triggered by this event. Ignore. - - log.info( - f"{after.display_name} is currently in superstar-prison. " - f"Changing the nick back to {before.display_name}." - ) - await after.edit(nick=forced_nick) - end_timestamp_human = format_infraction(infraction['expires_at']) - - try: - await after.send( - "You have tried to change your nickname on the **Python Discord** server " - f"from **{before.display_name}** to **{after.display_name}**, but as you " - "are currently in superstar-prison, you do not have permission to do so. " - "You will be allowed to change your nickname again at the following time:\n\n" - f"**{end_timestamp_human}**." - ) - except Forbidden: - log.warning( - "The user tried to change their nickname while in superstar-prison. " - "This led to the bot trying to DM the user to let them know they cannot do that, " - "but the user had either blocked the bot or disabled DMs, so it was not possible " - "to DM them, and a discord.errors.Forbidden error was incurred." - ) + return + + infraction = active_superstarifies[0] + forced_nick = self.get_nick(infraction["id"], before.id) + if after.display_name == forced_nick: + return # Nick change was triggered by this event. Ignore. + + log.info( + f"{after.display_name} is currently in superstar-prison. " + f"Changing the nick back to {before.display_name}." + ) + await after.edit( + nick=forced_nick, + reason=f"Superstarified member tried to escape the prison: {infraction['id']}" + ) + + notified = await utils.notify_infraction( + user=after, + infr_type="Superstarify", + expires_at=format_infraction(infraction["expires_at"]), + reason=( + "You have tried to change your nickname on the **Python Discord** server " + f"from **{before.display_name}** to **{after.display_name}**, but as you " + "are currently in superstar-prison, you do not have permission to do so." + ), + icon_url=utils.INFRACTION_ICONS["superstar"][0] + ) + + if not notified: + log.warning("Failed to DM user about why they cannot change their nickname.") @Cog.listener() async def on_member_join(self, member: Member) -> None: -- cgit v1.2.3 From ad64553291b1962f8a5f7cf2d5fdd22f6c6a24f5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 15:06:22 -0700 Subject: Superstarify: fit within 100 columns & use double quotes --- bot/cogs/moderation/superstarify.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 45db6676a..fb89a4982 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -100,10 +100,16 @@ class Superstarify(InfractionScheduler, Cog): await self.reapply_infraction(infraction, action) - @command(name='superstarify', aliases=('force_nick', 'star')) - async def superstarify(self, ctx: Context, member: Member, duration: utils.Expiry, reason: str = None) -> None: + @command(name="superstarify", aliases=("force_nick", "star")) + async def superstarify( + self, + ctx: Context, + member: Member, + duration: utils.Expiry, + reason: str = None + ) -> None: """ - Force a random superstar name (like Taylor Swift) to be the user's nickname for a specified duration. + Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname. A unit of time should be appended to the duration. Units (∗case-sensitive): @@ -125,7 +131,7 @@ class Superstarify(InfractionScheduler, Cog): # Post the infraction to the API reason = reason or f"old nick: {member.display_name}" - infraction = await utils.post_infraction(ctx, member, "superstar", reason, expires_at=duration) + infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration) forced_nick = self.get_nick(infraction["id"], member.id) expiry_str = format_infraction(infraction["expires_at"]) @@ -140,8 +146,8 @@ class Superstarify(InfractionScheduler, Cog): user=member, infr_type="Superstarify", expires_at=expiry_str, - reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL}).", - icon_url=utils.INFRACTION_ICONS["superstar"][0] + icon_url=utils.INFRACTION_ICONS["superstar"][0], + reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." ) # Send an embed with the infraction information to the invoking context. @@ -174,7 +180,7 @@ class Superstarify(InfractionScheduler, Cog): footer=f"ID {infraction['id']}" ) - @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) + @command(name="unsuperstarify", aliases=("release_nick", "unstar")) async def unsuperstarify(self, ctx: Context, member: Member) -> None: """Remove the superstarify infraction and allow the user to change their nickname.""" await self.pardon_infraction(ctx, "superstar", member) -- cgit v1.2.3 From 8ec8446c5e6d62758ac17cd40c8fa32ff50c92db Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 15:16:55 -0700 Subject: Superstarify: fix mod log mention --- bot/cogs/moderation/superstarify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index fb89a4982..55e1fe04d 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -170,7 +170,7 @@ class Superstarify(InfractionScheduler, Cog): title="Member Achieved Superstardom", thumbnail=member.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" - Member: {member.mentiom} (`{member.id}`) + Member: {member.mention} (`{member.id}`) Actor: {ctx.message.author} Reason: {reason} Expires: {expiry_str} -- cgit v1.2.3 From 4f2a50a15c9dac107f0bd2c8be6d81e9a80df2f0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 15:17:32 -0700 Subject: Superstarify: negate active infractions check in on_member_update --- bot/cogs/moderation/superstarify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 55e1fe04d..95962b750 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -47,7 +47,7 @@ class Superstarify(InfractionScheduler, Cog): } ) - if active_superstarifies: + if not active_superstarifies: return infraction = active_superstarifies[0] -- cgit v1.2.3 From 1acdf4c40e14bc15a842d84385bafd312088a03d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 15:20:02 -0700 Subject: Superstarify: save the old nickname before editing it --- bot/cogs/moderation/superstarify.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 95962b750..803ecee1c 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -133,6 +133,7 @@ class Superstarify(InfractionScheduler, Cog): reason = reason or f"old nick: {member.display_name}" infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration) + old_nick = member.display_name forced_nick = self.get_nick(infraction["id"], member.id) expiry_str = format_infraction(infraction["expires_at"]) @@ -154,7 +155,7 @@ class Superstarify(InfractionScheduler, Cog): embed = Embed() embed.title = "Congratulations!" embed.description = ( - f"Your previous nickname, **{member.display_name}**, " + f"Your previous nickname, **{old_nick}**, " f"was so bad that we have decided to change it. " f"Your new nickname will be **{forced_nick}**.\n\n" f"You will be unable to change your nickname until \n**{expiry_str}**.\n\n" @@ -174,7 +175,7 @@ class Superstarify(InfractionScheduler, Cog): Actor: {ctx.message.author} Reason: {reason} Expires: {expiry_str} - Old nickname: `{member.display_name}` + Old nickname: `{old_nick}` New nickname: `{forced_nick}` """), footer=f"ID {infraction['id']}" -- cgit v1.2.3 From 757adde72470b1e54e96990894563ee3e52f3538 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 15:22:36 -0700 Subject: InfractionScheduler: remove redundant logging --- bot/cogs/moderation/scheduler.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 74ff62981..7990df226 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -362,10 +362,7 @@ class InfractionScheduler(Scheduler): At the time of expiration, the infraction is marked as inactive on the website and the expiration task is cancelled. """ - _id = infraction["id"] - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) await time.wait_until(expiry) - log.debug(f"Marking infraction {_id} as inactive (expired).") await self.deactivate_infraction(infraction) -- cgit v1.2.3 From e17fc1e7583d70545c067410cb7431afb8956af4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 15:48:25 -0700 Subject: Superstarify: fix incorrect infraction type check in _pardon_action --- bot/cogs/moderation/superstarify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 803ecee1c..598b5a40e 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -192,7 +192,7 @@ class Superstarify(InfractionScheduler, Cog): user = guild.get_member(infraction["user"]) # Don't bother sending a notification if the user left the guild. - if infraction["type"] != "mute" or not user: + if infraction["type"] != "superstar" or not user: return {} # DM the user about the expiration. -- cgit v1.2.3 From 0621715392103e395c1f6c9c8c2231148f7b1b29 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 15:49:25 -0700 Subject: Superstarify: make the apply embed colour gold --- bot/cogs/moderation/superstarify.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 598b5a40e..352fccb10 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -152,15 +152,17 @@ class Superstarify(InfractionScheduler, Cog): ) # Send an embed with the infraction information to the invoking context. - embed = Embed() - embed.title = "Congratulations!" - embed.description = ( - f"Your previous nickname, **{old_nick}**, " - f"was so bad that we have decided to change it. " - f"Your new nickname will be **{forced_nick}**.\n\n" - f"You will be unable to change your nickname until \n**{expiry_str}**.\n\n" - "If you're confused by this, please read our " - f"[official nickname policy]({NICKNAME_POLICY_URL})." + embed = Embed( + title="Congratulations!", + colour=constants.Colours.soft_orange, + description=( + f"Your previous nickname, **{old_nick}**, " + f"was so bad that we have decided to change it. " + f"Your new nickname will be **{forced_nick}**.\n\n" + f"You will be unable to change your nickname until **{expiry_str}**.\n\n" + "If you're confused by this, please read our " + f"[official nickname policy]({NICKNAME_POLICY_URL})." + ) ) await ctx.send(embed=embed) -- cgit v1.2.3 From 4ceb7354523836b455670bacbf6c52488e21964b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 15:58:41 -0700 Subject: Superstarify: make mod log title lowercase for consistency --- bot/cogs/moderation/superstarify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 352fccb10..518bb3bae 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -170,7 +170,7 @@ class Superstarify(InfractionScheduler, Cog): await self.mod_log.send_log_message( icon_url=utils.INFRACTION_ICONS["superstar"][0], colour=Colour.gold(), - title="Member Achieved Superstardom", + title="Member achieved superstardom", thumbnail=member.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {member.mention} (`{member.id}`) -- cgit v1.2.3 From 6077e8a18fb5598d5dfc979e0bae1fe199c1945f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 16:01:12 -0700 Subject: Moderation: remove full stops from pardon embed titles --- bot/cogs/moderation/infractions.py | 2 +- bot/cogs/moderation/superstarify.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 0e29b02f7..2713a1b68 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -254,7 +254,7 @@ class Infractions(InfractionScheduler, commands.Cog): # DM the user about the expiration. notified = await utils.notify_pardon( user=user, - title="You have been unmuted.", + title="You have been unmuted", content="You may now send messages in the server.", icon_url=utils.INFRACTION_ICONS["mute"][1] ) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 518bb3bae..f43f3d027 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -200,7 +200,7 @@ class Superstarify(InfractionScheduler, Cog): # DM the user about the expiration. notified = await utils.notify_pardon( user=user, - title="You are no longer superstarified.", + title="You are no longer superstarified", content="You may now change your nickname on the server.", icon_url=utils.INFRACTION_ICONS["superstar"][1] ) -- cgit v1.2.3 From dd537c9802e8f39849592a99cb33af3cd764a9a2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 16:03:17 -0700 Subject: Moderation: make infraction notification embed title lowercase --- bot/cogs/moderation/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index c05cb69a1..9179c0afb 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -127,7 +127,7 @@ async def notify_infraction( colour=Colours.soft_red ) - embed.set_author(name="Infraction Information", icon_url=icon_url, url=RULES_URL) + embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL) embed.title = f"Please review our rules over at {RULES_URL}" embed.url = RULES_URL -- cgit v1.2.3 From 47f07ff3a7be12096df4a2f1463c7bf2357761e9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 17:02:04 -0700 Subject: Superstarify: return None in _pardon_action for supported types --- bot/cogs/moderation/superstarify.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index f43f3d027..c66222e5a 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -190,11 +190,14 @@ class Superstarify(InfractionScheduler, Cog): async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: """Pardon a superstar infraction and return a log dict.""" + if infraction["type"] != "superstar": + return + guild = self.bot.get_guild(constants.Guild.id) user = guild.get_member(infraction["user"]) # Don't bother sending a notification if the user left the guild. - if infraction["type"] != "superstar" or not user: + if not user: return {} # DM the user about the expiration. -- cgit v1.2.3 From 2c0fffbc697db5f33e759f733c310f9f0b754d11 Mon Sep 17 00:00:00 2001 From: Jens Date: Sat, 26 Oct 2019 17:24:09 +0200 Subject: Fix linting error --- bot/cogs/reddit.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 7e2ba40d5..64a940af1 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -20,6 +20,7 @@ log = logging.getLogger(__name__) class Reddit(Cog): """Track subreddit posts and show detailed statistics about them.""" + # Change your client's User-Agent string to something unique and descriptive, # including the target platform, a unique application identifier, a version string, # and your username as contact information, in the following format: -- cgit v1.2.3 From 7f1d319a11de5e443307517ff1fd55fe87a69bb3 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 27 Oct 2019 02:50:20 +0200 Subject: Add duck-pond constants. This adds the emojis, the channel, and the configuration needed for the duck-pond feature. This is added both to config-default.yml, and to the constants.py file. --- bot/constants.py | 57 +++++++++++++++++++++++++++++++----------------------- config-default.yml | 15 +++++++++----- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 838fe7a79..ed1e65cca 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -236,6 +236,14 @@ class Colours(metaclass=YAMLGetter): soft_orange: int +class DuckPond(metaclass=YAMLGetter): + section = "duck_pond" + + ducks_required: int + duck_custom_emojis: List[int] + duck_pond_channel: int + + class Emojis(metaclass=YAMLGetter): section = "style" subsection = "emojis" @@ -370,6 +378,7 @@ class Webhooks(metaclass=YAMLGetter): talent_pool: int big_brother: int reddit: int + duck_pond: int class Roles(metaclass=YAMLGetter): @@ -501,6 +510,30 @@ class RedirectOutput(metaclass=YAMLGetter): delete_delay: int +class Event(Enum): + """ + Event names. This does not include every event (for example, raw + events aren't here), but only events used in ModLog for now. + """ + + guild_channel_create = "guild_channel_create" + guild_channel_delete = "guild_channel_delete" + guild_channel_update = "guild_channel_update" + guild_role_create = "guild_role_create" + guild_role_delete = "guild_role_delete" + guild_role_update = "guild_role_update" + guild_update = "guild_update" + + member_join = "member_join" + member_remove = "member_remove" + member_ban = "member_ban" + member_unban = "member_unban" + member_update = "member_update" + + message_delete = "message_delete" + message_edit = "message_edit" + + # Debug mode DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False @@ -572,27 +605,3 @@ ERROR_REPLIES = [ "Noooooo!!", "I can't believe you've done this", ] - - -class Event(Enum): - """ - Event names. This does not include every event (for example, raw - events aren't here), but only events used in ModLog for now. - """ - - guild_channel_create = "guild_channel_create" - guild_channel_delete = "guild_channel_delete" - guild_channel_update = "guild_channel_update" - guild_role_create = "guild_role_create" - guild_role_delete = "guild_role_delete" - guild_role_update = "guild_role_update" - guild_update = "guild_update" - - member_join = "member_join" - member_remove = "member_remove" - member_ban = "member_ban" - member_unban = "member_unban" - member_update = "member_update" - - message_delete = "message_delete" - message_edit = "message_edit" diff --git a/config-default.yml b/config-default.yml index 4638a89ee..bad9c72db 100644 --- a/config-default.yml +++ b/config-default.yml @@ -22,11 +22,6 @@ style: defcon_enabled: "<:defconenabled:470326274213150730>" defcon_updated: "<:defconsettingsupdated:470326274082996224>" - green_chevron: "<:greenchevron:418104310329769993>" - red_chevron: "<:redchevron:418112778184818698>" - white_chevron: "<:whitechevron:418110396973711363>" - bb_message: "<:bbmessage:476273120999636992>" - status_online: "<:status_online:470326272351010816>" status_idle: "<:status_idle:470326266625785866>" status_dnd: "<:status_dnd:470326272082313216>" @@ -37,6 +32,9 @@ style: new: "\U0001F195" cross_mark: "\u274C" + ducky: &DUCKY_EMOJI 574951975574175744 + ducky_blurple: &DUCKY_BLURPLE_EMOJI 574951975310065675 + icons: crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png" @@ -98,6 +96,7 @@ guild: defcon: &DEFCON 464469101889454091 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 + duck_pond: &DUCK_POND 000000000000000000 help_0: 303906576991780866 help_1: 303906556754395136 help_2: 303906514266226689 @@ -148,6 +147,7 @@ guild: talent_pool: 569145364800602132 big_brother: 569133704568373283 reddit: 635408384794951680 + duck_pond: 637779355304722435 filter: @@ -382,5 +382,10 @@ redirect_output: delete_invocation: true delete_delay: 15 +duck_pond: + ducks_required: 5 + duck_custom_emojis: [*DUCKY_EMOJI, *DUCKY_BLURPLE_EMOJI] + duck_pond_channel: *DUCK_POND + config: required_keys: ['bot.token'] -- cgit v1.2.3 From 957f46226a6c9cbc9e86bab8a4365665d479885f Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 27 Oct 2019 02:56:20 +0200 Subject: Add duck_pond cog. This cog will listen for duck reactions on any message, and then: - If the reaction was added by a staff member - and the reaction was a duck - and the message has not already been added to the #duck-pond It will add the message to the #duck-pond and then add a green checkbox to the original message to indicate that the message has been ponded. Messages are added to the #duck-pond via webhook, so that they can retain the appearance of having their original authors. Once this checkmark has been added, the message will not be processed in the future. If the checkmark is removed and there are more than ducks_required ducks on the message, the bot will automatically add the checkmark back. However, if all reactions are removed, the bot does not have a countermeasure for this. In order to implement a countermeasure, it would be necessary to involve the API and the database. --- bot/__main__.py | 1 + bot/cogs/duck_pond.py | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 bot/cogs/duck_pond.py diff --git a/bot/__main__.py b/bot/__main__.py index f352cd60e..ea7c43a12 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -55,6 +55,7 @@ if not DEBUG_MODE: bot.load_extension("bot.cogs.alias") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.eval") +bot.load_extension("bot.cogs.duck_pond") bot.load_extension("bot.cogs.free") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py new file mode 100644 index 000000000..d5d528458 --- /dev/null +++ b/bot/cogs/duck_pond.py @@ -0,0 +1,206 @@ +import logging +from typing import List, Optional, Union + +import discord +from discord import Color, Embed, Member, Message, PartialEmoji, RawReactionActionEvent, Reaction, User, errors +from discord.ext.commands import Bot, Cog + +import bot.constants as constants +from bot.utils.messages import send_attachments + +log = logging.getLogger(__name__) + + +class DuckPond(Cog): + """Relays messages to #duck-pond whenever a certain number of duck reactions have been achieved.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.log = log + self.webhook_id = constants.Webhooks.duck_pond + self.bot.loop.create_task(self.fetch_webhook()) + + async def fetch_webhook(self): + """Fetches the webhook object, so we can post to it.""" + await self.bot.wait_until_ready() + + try: + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: + self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + @staticmethod + def is_staff(member: Union[User, Member]) -> bool: + """Check if a specific member or user is staff""" + if hasattr(member, "roles"): + for role in member.roles: + if role.id in constants.STAFF_ROLES: + return True + return False + + @staticmethod + def has_green_checkmark(message: Optional[Message] = None, reaction_list: Optional[List[Reaction]] = None) -> bool: + """Check if the message has a green checkmark reaction.""" + assert message or reaction_list, "You can either pass message or reactions, but not both, or neither." + + if message: + reactions = message.reactions + else: + reactions = reaction_list + + for reaction in reactions: + if isinstance(reaction.emoji, str): + if reaction.emoji == "✅": + return True + elif isinstance(reaction.emoji, PartialEmoji): + if reaction.emoji.name == "✅": + return True + return False + + async def send_webhook( + self, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embed: Optional[Embed] = None, + ) -> None: + try: + await self.webhook.send( + content=content, + username=username, + avatar_url=avatar_url, + embed=embed + ) + except discord.HTTPException as exc: + self.log.exception( + f"Failed to send a message to the Duck Pool webhook", + exc_info=exc + ) + + async def count_ducks(self, message: Optional[Message] = None, reaction_list: Optional[List[Reaction]] = None) -> int: + """Count the number of ducks in the reactions of a specific message. + + Only counts ducks added by staff members. + """ + assert message or reaction_list, "You can either pass message or reactions, but not both, or neither." + + duck_count = 0 + duck_reactors = [] + + if message: + reactions = message.reactions + else: + reactions = reaction_list + + for reaction in reactions: + async for user in reaction.users(): + + # Is the user or member a staff member? + if self.is_staff(user) and user.id not in duck_reactors: + + # Is the emoji a duck? + if hasattr(reaction.emoji, "id"): + if reaction.emoji.id in constants.DuckPond.duck_custom_emojis: + duck_count += 1 + duck_reactors.append(user.id) + else: + if isinstance(reaction.emoji, str): + if reaction.emoji == "🦆": + duck_count += 1 + duck_reactors.append(user.id) + elif isinstance(reaction.emoji, PartialEmoji): + if reaction.emoji.name == "🦆": + duck_count += 1 + duck_reactors.append(user.id) + return duck_count + + @Cog.listener() + async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: + """Determine if a message should be sent to the duck pond. + + This will count the number of duck reactions on the message, and if this amount meets the + amount of ducks specified in the config under duck_pond/ducks_required, it will + send the message off to the duck pond. + """ + channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) + message = await channel.fetch_message(payload.message_id) + member = discord.utils.get(message.guild.members, id=payload.user_id) + + # Is the member a staff member? + if not self.is_staff(member): + return + + # Bot reactions don't count. + if member.bot: + return + + # Is the emoji in the reaction a duck? + if payload.emoji.is_custom_emoji(): + if payload.emoji.id not in constants.DuckPond.duck_custom_emojis: + return + else: + if payload.emoji.name != "🦆": + return + + # Does the message already have a green checkmark? + if self.has_green_checkmark(message): + return + + # Time to count our ducks! + duck_count = await self.count_ducks(message) + + # If we've got more than the required amount of ducks, send the message to the duck_pond. + if duck_count >= constants.DuckPond.ducks_required: + clean_content = message.clean_content + + if clean_content: + await self.send_webhook( + content=message.clean_content, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + + if message.attachments: + try: + await send_attachments(message, self.webhook) + except (errors.Forbidden, errors.NotFound): + e = Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await self.send_webhook( + embed=e, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + except discord.HTTPException as exc: + self.log.exception( + f"Failed to send an attachment to the webhook", + exc_info=exc + ) + await message.add_reaction("✅") + + @Cog.listener() + async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: + """Ensure that people don't remove the green checkmark from duck ponded messages.""" + channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) + message = await channel.fetch_message(payload.message_id) + + # Prevent the green checkmark from being removed + if isinstance(payload.emoji, str): + if payload.emoji == "✅": + duck_count = await self.count_ducks(message) + if duck_count >= constants.DuckPond.ducks_required: + await message.add_reaction("✅") + + elif isinstance(payload.emoji, PartialEmoji): + if payload.emoji.name == "✅": + duck_count = await self.count_ducks(message) + if duck_count >= constants.DuckPond.ducks_required: + await message.add_reaction("✅") + + +def setup(bot: Bot) -> None: + """Token Remover cog load.""" + bot.add_cog(DuckPond(bot)) + log.info("Cog loaded: DuckPond") -- cgit v1.2.3 From 38579ade38a7390e9aca428410a9703dd7ba9fac Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 27 Oct 2019 02:07:55 +0100 Subject: Appease the linter --- bot/cogs/duck_pond.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index d5d528458..b2b786a3f 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -5,7 +5,7 @@ import discord from discord import Color, Embed, Member, Message, PartialEmoji, RawReactionActionEvent, Reaction, User, errors from discord.ext.commands import Bot, Cog -import bot.constants as constants +from bot import constants from bot.utils.messages import send_attachments log = logging.getLogger(__name__) @@ -20,7 +20,7 @@ class DuckPond(Cog): self.webhook_id = constants.Webhooks.duck_pond self.bot.loop.create_task(self.fetch_webhook()) - async def fetch_webhook(self): + async def fetch_webhook(self) -> None: """Fetches the webhook object, so we can post to it.""" await self.bot.wait_until_ready() @@ -31,7 +31,7 @@ class DuckPond(Cog): @staticmethod def is_staff(member: Union[User, Member]) -> bool: - """Check if a specific member or user is staff""" + """Check if a specific member or user is staff.""" if hasattr(member, "roles"): for role in member.roles: if role.id in constants.STAFF_ROLES: @@ -64,6 +64,7 @@ class DuckPond(Cog): avatar_url: Optional[str] = None, embed: Optional[Embed] = None, ) -> None: + """Send a webhook to the duck_pond channel.""" try: await self.webhook.send( content=content, @@ -77,8 +78,13 @@ class DuckPond(Cog): exc_info=exc ) - async def count_ducks(self, message: Optional[Message] = None, reaction_list: Optional[List[Reaction]] = None) -> int: - """Count the number of ducks in the reactions of a specific message. + async def count_ducks( + self, + message: Optional[Message] = None, + reaction_list: Optional[List[Reaction]] = None + ) -> int: + """ + Count the number of ducks in the reactions of a specific message. Only counts ducks added by staff members. """ @@ -116,7 +122,8 @@ class DuckPond(Cog): @Cog.listener() async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: - """Determine if a message should be sent to the duck pond. + """ + Determine if a message should be sent to the duck pond. This will count the number of duck reactions on the message, and if this amount meets the amount of ducks specified in the config under duck_pond/ducks_required, it will -- cgit v1.2.3 From 0957bee71fafc575c99ac107f941e9bfe6a72397 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 27 Oct 2019 02:16:41 +0100 Subject: Add correct values for constants from production server. --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index bad9c72db..074143f92 100644 --- a/config-default.yml +++ b/config-default.yml @@ -96,7 +96,7 @@ guild: defcon: &DEFCON 464469101889454091 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 - duck_pond: &DUCK_POND 000000000000000000 + duck_pond: &DUCK_POND 637820308341915648 help_0: 303906576991780866 help_1: 303906556754395136 help_2: 303906514266226689 @@ -147,7 +147,7 @@ guild: talent_pool: 569145364800602132 big_brother: 569133704568373283 reddit: 635408384794951680 - duck_pond: 637779355304722435 + duck_pond: 637821475327311927 filter: -- cgit v1.2.3 From c66cd6f236c9f8c68a39caafdbbba1f5724947a5 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 27 Oct 2019 02:26:52 +0100 Subject: Fix broken constant tests --- bot/constants.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index ed1e65cca..d626fd4ba 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -252,11 +252,6 @@ class Emojis(metaclass=YAMLGetter): defcon_enabled: str # noqa: E704 defcon_updated: str # noqa: E704 - green_chevron: str - red_chevron: str - white_chevron: str - bb_message: str - status_online: str status_offline: str status_idle: str @@ -267,6 +262,9 @@ class Emojis(metaclass=YAMLGetter): pencil: str cross_mark: str + ducky: int + ducky_blurple: int + class Icons(metaclass=YAMLGetter): section = "style" @@ -344,6 +342,7 @@ class Channels(metaclass=YAMLGetter): defcon: int devlog: int devtest: int + duck_pond: int help_0: int help_1: int help_2: int -- cgit v1.2.3 From dac975e8bf238b545b60f10cd1891a68f31dc1ef Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 27 Oct 2019 02:28:03 +0100 Subject: Improve the setup() docstring Co-Authored-By: Mark --- bot/cogs/duck_pond.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index b2b786a3f..6244bdf5a 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -208,6 +208,6 @@ class DuckPond(Cog): def setup(bot: Bot) -> None: - """Token Remover cog load.""" + """Load the duck pond cog.""" bot.add_cog(DuckPond(bot)) log.info("Cog loaded: DuckPond") -- cgit v1.2.3 From 4e87b4e9c3eee762c061b8d1c17d57f172e4dd43 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 27 Oct 2019 10:56:11 +0800 Subject: Use trashcan emoji for message deletion --- bot/constants.py | 2 ++ bot/utils/messages.py | 6 +++++- config-default.yml | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index f341fb499..4737ce6a3 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -254,6 +254,8 @@ class Emojis(metaclass=YAMLGetter): status_idle: str status_dnd: str + trashcan: str + bullet: str new: str pencil: str diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 549b33ca6..fe0b6b29f 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -15,7 +15,7 @@ MAX_SIZE = 1024 * 1024 * 8 # 8 Mebibytes async def wait_for_deletion( message: Message, user_ids: Sequence[Snowflake], - deletion_emojis: Sequence[str] = (Emojis.cross_mark,), + deletion_emojis: Sequence[str] = None, timeout: float = 60 * 5, attach_emojis: bool = True, client: Optional[Client] = None @@ -34,6 +34,10 @@ async def wait_for_deletion( bot = client or message.guild.me + if deletion_emojis is None: + default_emoji = bot.get_emoji(int(Emojis.trashcan)) or Emojis.cross_mark + deletion_emojis = (default_emoji,) + if attach_emojis: for emoji in deletion_emojis: await message.add_reaction(emoji) diff --git a/config-default.yml b/config-default.yml index 23dcbd44c..16842534d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -32,6 +32,8 @@ style: status_dnd: "<:status_dnd:470326272082313216>" status_offline: "<:status_offline:470326266537705472>" + trashcan: "637136429717389331" + bullet: "\u2022" pencil: "\u270F" new: "\U0001F195" -- cgit v1.2.3 From 30ba84eecd7fc10c96a739dc53d3454de5d8fd6a Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 27 Oct 2019 17:24:21 +0800 Subject: Differentiate clear and delete emoji in help cog --- bot/cogs/help.py | 13 ++++++++++--- bot/utils/messages.py | 8 ++------ config-default.yml | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 9607dbd8d..63b4e89b8 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -10,20 +10,22 @@ from discord.ext.commands import Bot, CheckFailure, Cog as DiscordCog, Command, from fuzzywuzzy import fuzz, process from bot import constants -from bot.constants import Channels, STAFF_ROLES +from bot.constants import Channels, Emojis, STAFF_ROLES from bot.decorators import redirect_output from bot.pagination import ( - DELETE_EMOJI, FIRST_EMOJI, LAST_EMOJI, + DELETE_EMOJI as CLEAR_EMOJI, FIRST_EMOJI, LAST_EMOJI, LEFT_EMOJI, LinePaginator, RIGHT_EMOJI, ) +DELETE_EMOJI = Emojis.trashcan REACTIONS = { FIRST_EMOJI: 'first', LEFT_EMOJI: 'back', RIGHT_EMOJI: 'next', LAST_EMOJI: 'end', - DELETE_EMOJI: 'stop' + CLEAR_EMOJI: 'clear', + DELETE_EMOJI: 'stop', } Cog = namedtuple('Cog', ['name', 'description', 'commands']) @@ -496,6 +498,11 @@ class HelpSession: if not self.is_last_page: await self.update_page(len(self._pages)-1) + async def do_clear(self) -> None: + """Event that is called when the user clears the emojis from the pagination.""" + await self.message.clear_reactions() + await self.message.add_reaction(DELETE_EMOJI) + async def do_stop(self) -> None: """Event that is called when the user requests to stop the help session.""" await self.message.delete() diff --git a/bot/utils/messages.py b/bot/utils/messages.py index fe0b6b29f..654d71797 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -15,7 +15,7 @@ MAX_SIZE = 1024 * 1024 * 8 # 8 Mebibytes async def wait_for_deletion( message: Message, user_ids: Sequence[Snowflake], - deletion_emojis: Sequence[str] = None, + deletion_emojis: Sequence[str] = (Emojis.trashcan,), timeout: float = 60 * 5, attach_emojis: bool = True, client: Optional[Client] = None @@ -34,10 +34,6 @@ async def wait_for_deletion( bot = client or message.guild.me - if deletion_emojis is None: - default_emoji = bot.get_emoji(int(Emojis.trashcan)) or Emojis.cross_mark - deletion_emojis = (default_emoji,) - if attach_emojis: for emoji in deletion_emojis: await message.add_reaction(emoji) @@ -46,7 +42,7 @@ async def wait_for_deletion( """Check that the deletion emoji is reacted by the approprite user.""" return ( reaction.message.id == message.id - and reaction.emoji in deletion_emojis + and str(reaction.emoji) in deletion_emojis and user.id in user_ids ) diff --git a/config-default.yml b/config-default.yml index 16842534d..696ef8a7e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -32,7 +32,7 @@ style: status_dnd: "<:status_dnd:470326272082313216>" status_offline: "<:status_offline:470326266537705472>" - trashcan: "637136429717389331" + trashcan: "<:trashcan:637136429717389331>" bullet: "\u2022" pencil: "\u270F" -- cgit v1.2.3 From 56696b3b1858ad27dc7f3dce2898c7a6eb151f43 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 27 Oct 2019 17:26:27 +0800 Subject: Remove dev-test limit for filtering debugging --- bot/cogs/filtering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 1d1d74e74..11c5d7223 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -136,9 +136,9 @@ class Filtering(Cog): and not msg.author.bot # Author not a bot ) - # If we're running the bot locally, ignore role whitelist and only listen to #dev-test + # If we're running the bot locally, ignore role whitelist if DEBUG_MODE: - filter_message = not msg.author.bot and msg.channel.id == Channels.devtest + filter_message = not msg.author.bot # If none of the above, we can start filtering. if filter_message: -- cgit v1.2.3 From 51622223f4173e35a90d2a306a61e020fc0b422b Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 27 Oct 2019 15:16:35 +0100 Subject: Addressing review by Mark. This refactors the duck pond cog to have fewer redundancies, removes some unused features (like supporting reaction_list in the count_duck and has_green_checkbox helpers), and makes other various minor (mostly cosmetic) improvements. --- bot/cogs/duck_pond.py | 120 ++++++++++++++++---------------------------------- bot/constants.py | 5 +-- config-default.yml | 6 +-- 3 files changed, 42 insertions(+), 89 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index b2b786a3f..70cf0d2b0 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -1,8 +1,8 @@ import logging -from typing import List, Optional, Union +from typing import Optional, Union import discord -from discord import Color, Embed, Member, Message, PartialEmoji, RawReactionActionEvent, Reaction, User, errors +from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors from discord.ext.commands import Bot, Cog from bot import constants @@ -16,7 +16,6 @@ class DuckPond(Cog): def __init__(self, bot: Bot): self.bot = bot - self.log = log self.webhook_id = constants.Webhooks.duck_pond self.bot.loop.create_task(self.fetch_webhook()) @@ -27,7 +26,7 @@ class DuckPond(Cog): try: self.webhook = await self.bot.fetch_webhook(self.webhook_id) except discord.HTTPException: - self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") @staticmethod def is_staff(member: Union[User, Member]) -> bool: @@ -39,22 +38,11 @@ class DuckPond(Cog): return False @staticmethod - def has_green_checkmark(message: Optional[Message] = None, reaction_list: Optional[List[Reaction]] = None) -> bool: + def has_green_checkmark(message: Message) -> bool: """Check if the message has a green checkmark reaction.""" - assert message or reaction_list, "You can either pass message or reactions, but not both, or neither." - - if message: - reactions = message.reactions - else: - reactions = reaction_list - - for reaction in reactions: - if isinstance(reaction.emoji, str): - if reaction.emoji == "✅": - return True - elif isinstance(reaction.emoji, PartialEmoji): - if reaction.emoji.name == "✅": - return True + for reaction in message.reactions: + if reaction.emoji == "✅": + return True return False async def send_webhook( @@ -72,52 +60,34 @@ class DuckPond(Cog): avatar_url=avatar_url, embed=embed ) - except discord.HTTPException as exc: - self.log.exception( - f"Failed to send a message to the Duck Pool webhook", - exc_info=exc - ) + except discord.HTTPException: + log.exception(f"Failed to send a message to the Duck Pool webhook") - async def count_ducks( - self, - message: Optional[Message] = None, - reaction_list: Optional[List[Reaction]] = None - ) -> int: + async def count_ducks(self, message: Message) -> int: """ Count the number of ducks in the reactions of a specific message. Only counts ducks added by staff members. """ - assert message or reaction_list, "You can either pass message or reactions, but not both, or neither." - duck_count = 0 duck_reactors = [] - if message: - reactions = message.reactions - else: - reactions = reaction_list - - for reaction in reactions: + for reaction in message.reactions: async for user in reaction.users(): # Is the user or member a staff member? - if self.is_staff(user) and user.id not in duck_reactors: - - # Is the emoji a duck? - if hasattr(reaction.emoji, "id"): - if reaction.emoji.id in constants.DuckPond.duck_custom_emojis: - duck_count += 1 - duck_reactors.append(user.id) - else: - if isinstance(reaction.emoji, str): - if reaction.emoji == "🦆": - duck_count += 1 - duck_reactors.append(user.id) - elif isinstance(reaction.emoji, PartialEmoji): - if reaction.emoji.name == "🦆": - duck_count += 1 - duck_reactors.append(user.id) + if not self.is_staff(user) or not user.id not in duck_reactors: + continue + + # Is the emoji a duck? + if hasattr(reaction.emoji, "id"): + if reaction.emoji.id in constants.DuckPond.custom_emojis: + duck_count += 1 + duck_reactors.append(user.id) + elif isinstance(reaction.emoji, str): + if reaction.emoji == "🦆": + duck_count += 1 + duck_reactors.append(user.id) return duck_count @Cog.listener() @@ -126,28 +96,23 @@ class DuckPond(Cog): Determine if a message should be sent to the duck pond. This will count the number of duck reactions on the message, and if this amount meets the - amount of ducks specified in the config under duck_pond/ducks_required, it will + amount of ducks specified in the config under duck_pond/threshold, it will send the message off to the duck pond. """ channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) message = await channel.fetch_message(payload.message_id) member = discord.utils.get(message.guild.members, id=payload.user_id) - # Is the member a staff member? - if not self.is_staff(member): - return - - # Bot reactions don't count. - if member.bot: + # Is the member a human and a staff member? + if not self.is_staff(member) or member.bot: return # Is the emoji in the reaction a duck? if payload.emoji.is_custom_emoji(): - if payload.emoji.id not in constants.DuckPond.duck_custom_emojis: - return - else: - if payload.emoji.name != "🦆": + if payload.emoji.id not in constants.DuckPond.custom_emojis: return + elif payload.emoji.name != "🦆": + return # Does the message already have a green checkmark? if self.has_green_checkmark(message): @@ -157,7 +122,7 @@ class DuckPond(Cog): duck_count = await self.count_ducks(message) # If we've got more than the required amount of ducks, send the message to the duck_pond. - if duck_count >= constants.DuckPond.ducks_required: + if duck_count >= constants.DuckPond.threshold: clean_content = message.clean_content if clean_content: @@ -180,31 +145,22 @@ class DuckPond(Cog): username=message.author.display_name, avatar_url=message.author.avatar_url ) - except discord.HTTPException as exc: - self.log.exception( - f"Failed to send an attachment to the webhook", - exc_info=exc - ) + except discord.HTTPException: + log.exception(f"Failed to send an attachment to the webhook") + await message.add_reaction("✅") @Cog.listener() async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: """Ensure that people don't remove the green checkmark from duck ponded messages.""" channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) - message = await channel.fetch_message(payload.message_id) # Prevent the green checkmark from being removed - if isinstance(payload.emoji, str): - if payload.emoji == "✅": - duck_count = await self.count_ducks(message) - if duck_count >= constants.DuckPond.ducks_required: - await message.add_reaction("✅") - - elif isinstance(payload.emoji, PartialEmoji): - if payload.emoji.name == "✅": - duck_count = await self.count_ducks(message) - if duck_count >= constants.DuckPond.ducks_required: - await message.add_reaction("✅") + if payload.emoji.name == "✅": + message = await channel.fetch_message(payload.message_id) + duck_count = await self.count_ducks(message) + if duck_count >= constants.DuckPond.threshold: + await message.add_reaction("✅") def setup(bot: Bot) -> None: diff --git a/bot/constants.py b/bot/constants.py index d626fd4ba..79845711d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -239,9 +239,8 @@ class Colours(metaclass=YAMLGetter): class DuckPond(metaclass=YAMLGetter): section = "duck_pond" - ducks_required: int - duck_custom_emojis: List[int] - duck_pond_channel: int + threshold: int + custom_emojis: List[int] class Emojis(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 074143f92..59087f51f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -96,7 +96,6 @@ guild: defcon: &DEFCON 464469101889454091 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 - duck_pond: &DUCK_POND 637820308341915648 help_0: 303906576991780866 help_1: 303906556754395136 help_2: 303906514266226689 @@ -383,9 +382,8 @@ redirect_output: delete_delay: 15 duck_pond: - ducks_required: 5 - duck_custom_emojis: [*DUCKY_EMOJI, *DUCKY_BLURPLE_EMOJI] - duck_pond_channel: *DUCK_POND + threshold: 5 + custom_emojis: [*DUCKY_EMOJI, *DUCKY_BLURPLE_EMOJI] config: required_keys: ['bot.token'] -- cgit v1.2.3 From 08d0c46c4aca4f181e60ef4cf6aa9a450c0db200 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 27 Oct 2019 15:38:09 +0100 Subject: Adding kosas additional ducks to default-config --- config-default.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/config-default.yml b/config-default.yml index 59087f51f..76892677e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -32,8 +32,13 @@ style: new: "\U0001F195" cross_mark: "\u274C" - ducky: &DUCKY_EMOJI 574951975574175744 - ducky_blurple: &DUCKY_BLURPLE_EMOJI 574951975310065675 + ducky_yellow: &DUCKY_YELLOW 574951975574175744 + ducky_blurple: &DUCKY_BLURPLE 574951975310065675 + ducky_regal: &DUCKY_REGAL 637883439185395712 + ducky_camo: &DUCKY_CAMO 637914731566596096 + ducky_ninja: &DUCKY_NINJA 637923502535606293 + ducky_devil: &DUCKY_DEVIL 637925314982576139 + ducky_tube: &DUCKY_TUBE 637881368008851456 icons: crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" @@ -383,7 +388,7 @@ redirect_output: duck_pond: threshold: 5 - custom_emojis: [*DUCKY_EMOJI, *DUCKY_BLURPLE_EMOJI] + custom_emojis: [*DUCKY_YELLOW, *DUCKY_BLURPLE, *DUCKY_CAMO, *DUCKY_DEVIL, *DUCKY_NINJA, *DUCKY_REGAL, *DUCKY_TUBE] config: required_keys: ['bot.token'] -- cgit v1.2.3 From af0e5329a5202c52cd94f86078c4a03119f7e324 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 28 Oct 2019 02:55:43 +0800 Subject: Revert "Remove dev-test limit for filtering debugging" This reverts commit 56696b3b1858ad27dc7f3dce2898c7a6eb151f43. --- bot/cogs/filtering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 11c5d7223..1d1d74e74 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -136,9 +136,9 @@ class Filtering(Cog): and not msg.author.bot # Author not a bot ) - # If we're running the bot locally, ignore role whitelist + # If we're running the bot locally, ignore role whitelist and only listen to #dev-test if DEBUG_MODE: - filter_message = not msg.author.bot + filter_message = not msg.author.bot and msg.channel.id == Channels.devtest # If none of the above, we can start filtering. if filter_message: -- cgit v1.2.3 From a2210247a4ba9a2903f9e21bb637bd12603b486e Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 28 Oct 2019 10:47:10 +0800 Subject: Add delete emoji to pagination --- bot/cogs/help.py | 3 +-- bot/pagination.py | 28 ++++++++++++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 63b4e89b8..f5538ce5e 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -13,7 +13,7 @@ from bot import constants from bot.constants import Channels, Emojis, STAFF_ROLES from bot.decorators import redirect_output from bot.pagination import ( - DELETE_EMOJI as CLEAR_EMOJI, FIRST_EMOJI, LAST_EMOJI, + CLEAR_EMOJI, FIRST_EMOJI, LAST_EMOJI, LEFT_EMOJI, LinePaginator, RIGHT_EMOJI, ) @@ -501,7 +501,6 @@ class HelpSession: async def do_clear(self) -> None: """Event that is called when the user clears the emojis from the pagination.""" await self.message.clear_reactions() - await self.message.add_reaction(DELETE_EMOJI) async def do_stop(self) -> None: """Event that is called when the user requests to stop the help session.""" diff --git a/bot/pagination.py b/bot/pagination.py index 76082f459..11f7c77fe 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -6,13 +6,16 @@ from discord import Embed, Member, Message, Reaction from discord.abc import User from discord.ext.commands import Context, Paginator +from bot import constants + FIRST_EMOJI = "\u23EE" # [:track_previous:] LEFT_EMOJI = "\u2B05" # [:arrow_left:] RIGHT_EMOJI = "\u27A1" # [:arrow_right:] LAST_EMOJI = "\u23ED" # [:track_next:] -DELETE_EMOJI = "\u274c" # [:x:] +CLEAR_EMOJI = "\u274c" # [:x:] +DELETE_EMOJI = constants.Emojis.trashcan # [:trashcan:] -PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI] +PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, CLEAR_EMOJI, DELETE_EMOJI] log = logging.getLogger(__name__) @@ -131,7 +134,7 @@ class LinePaginator(Paginator): # Reaction is on this message reaction_.message.id == message.id, # Reaction is one of the pagination emotes - reaction_.emoji in PAGINATION_EMOJI, + str(reaction_.emoji) in PAGINATION_EMOJI, # Reaction was not made by the Bot user_.id != ctx.bot.user.id, # There were no restrictions @@ -203,10 +206,14 @@ class LinePaginator(Paginator): log.debug("Timed out waiting for a reaction") break # We're done, no reactions for the last 5 minutes - if reaction.emoji == DELETE_EMOJI: - log.debug("Got delete reaction") + if reaction.emoji == CLEAR_EMOJI: + log.debug("Got clear reaction") break + if str(reaction.emoji) == DELETE_EMOJI: + log.debug("Got delete reaction") + return await message.delete() + if reaction.emoji == FIRST_EMOJI: await message.remove_reaction(reaction.emoji, user) current_page = 0 @@ -342,7 +349,7 @@ class ImagePaginator(Paginator): # Reaction is on the same message sent reaction_.message.id == message.id, # The reaction is part of the navigation menu - reaction_.emoji in PAGINATION_EMOJI, + str(reaction_.emoji) in PAGINATION_EMOJI, # The reactor is not a bot not member.bot )) @@ -388,11 +395,16 @@ class ImagePaginator(Paginator): # Deletes the users reaction await message.remove_reaction(reaction.emoji, user) - # Delete reaction press - [:x:] - if reaction.emoji == DELETE_EMOJI: + # Clear reaction press - [:x:] + if reaction.emoji == CLEAR_EMOJI: log.debug("Got delete reaction") break + # Delete reaction press - [:trashcan:] + if str(reaction.emoji) == DELETE_EMOJI: + log.debug("Got delete reaction") + return await message.delete() + # First reaction press - [:track_previous:] if reaction.emoji == FIRST_EMOJI: if current_page == 0: -- cgit v1.2.3 From bab7fc2e4600bd89a62ad07dd8177de0ff943343 Mon Sep 17 00:00:00 2001 From: Kieran Siek Date: Mon, 28 Oct 2019 12:37:24 +0800 Subject: Apply suggestions from code review Fix incorrect docstring and comment Co-Authored-By: Mark --- bot/pagination.py | 2 +- bot/utils/messages.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index 11f7c77fe..8e3329c4b 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -397,7 +397,7 @@ class ImagePaginator(Paginator): # Clear reaction press - [:x:] if reaction.emoji == CLEAR_EMOJI: - log.debug("Got delete reaction") + log.debug("Got clear reaction") break # Delete reaction press - [:trashcan:] diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 654d71797..7ab35257c 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -39,7 +39,7 @@ async def wait_for_deletion( await message.add_reaction(emoji) def check(reaction: Reaction, user: Member) -> bool: - """Check that the deletion emoji is reacted by the approprite user.""" + """Check that the deletion emoji is reacted by the appropriate user.""" return ( reaction.message.id == message.id and str(reaction.emoji) in deletion_emojis -- cgit v1.2.3 From d4ff83e6d2ffdbde4b934c37b24f61a8dc6c4ebb Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 28 Oct 2019 13:01:47 +0800 Subject: Fix linting error --- bot/pagination.py | 2 +- bot/utils/messages.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index 8e3329c4b..a7938fe85 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -397,7 +397,7 @@ class ImagePaginator(Paginator): # Clear reaction press - [:x:] if reaction.emoji == CLEAR_EMOJI: - log.debug("Got clear reaction") + log.debug("Got clear reaction") break # Delete reaction press - [:trashcan:] diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 7ab35257c..022f79599 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -39,7 +39,7 @@ async def wait_for_deletion( await message.add_reaction(emoji) def check(reaction: Reaction, user: Member) -> bool: - """Check that the deletion emoji is reacted by the appropriate user.""" + """Check that the deletion emoji is reacted by the appropriate user.""" return ( reaction.message.id == message.id and str(reaction.emoji) in deletion_emojis -- cgit v1.2.3 From 538048189c8babfa72c70563a81a4b725fcb90e4 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 28 Oct 2019 13:44:46 +0800 Subject: Chain words before length check --- bot/cogs/off_topic_names.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 1f9fb0b4f..78792240f 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -24,6 +24,9 @@ class OffTopicName(Converter): """Attempt to replace any invalid characters with their approximate Unicode equivalent.""" allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" + # Chain multiple words to a single one + argument = "-".join(argument.split()) + if not (2 <= len(argument) <= 96): raise BadArgument("Channel name must be between 2 and 96 chars long") @@ -97,15 +100,12 @@ class OffTopicNames(Cog): @otname_group.command(name='add', aliases=('a',)) @with_role(*MODERATION_ROLES) - async def add_command(self, ctx: Context, *names: OffTopicName) -> None: + async def add_command(self, ctx: Context, *, name: OffTopicName) -> None: """ Adds a new off-topic name to the rotation. The name is not added if it is too similar to an existing name. """ - # Chain multiple words to a single one - name = "-".join(names) - existing_names = await self.bot.api_client.get('bot/off-topic-channel-names') close_match = difflib.get_close_matches(name, existing_names, n=1, cutoff=0.8) @@ -123,10 +123,8 @@ class OffTopicNames(Cog): @otname_group.command(name='forceadd', aliases=('fa',)) @with_role(*MODERATION_ROLES) - async def force_add_command(self, ctx: Context, *names: OffTopicName) -> None: + async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None: """Forcefully adds a new off-topic name to the rotation.""" - # Chain multiple words to a single one - name = "-".join(names) await self._add_name(ctx, name) async def _add_name(self, ctx: Context, name: str) -> None: @@ -138,10 +136,8 @@ class OffTopicNames(Cog): @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) @with_role(*MODERATION_ROLES) - async def delete_command(self, ctx: Context, *names: OffTopicName) -> None: + async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None: """Removes a off-topic name from the rotation.""" - # Chain multiple words to a single one - name = "-".join(names) await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') log.info(f"{ctx.author} deleted the off-topic channel name '{name}'") -- cgit v1.2.3 From c5d0eb473a9a1dc486dd2dd60603463435e49da4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 28 Oct 2019 15:56:34 +0100 Subject: Change generation of child mocks - https://docs.python.org/3/library/unittest.mock.html We previously used an override of the `__new__` method to prevent our custom mock types from instantiating their children with their own type instead of a general mock type like `MagicMock` or `Mock`. As it turns out, the Python documentation suggests another method of doing this that does not involve overriding `__new__`. This commit implements this new method to make sure we're using the idiomatic way of handling this. The suggested method is overriding the `_get_child_mock` method in the subclass. To make our code DRY, I've created a mixin that should come BEFORE the mock type we're subclassing in the MRO. --- In addition, I have also added this new mixin to our `AsyncMock` class to make sure that its `__call__` method returns a proper mock object after it has been awaited. This makes sure that subsequent attribute access on the returned object is mocked as expected. --- tests/helpers.py | 85 +++++++++++++++++++++++++++++++-------------------- tests/test_helpers.py | 18 +++++++++-- 2 files changed, 67 insertions(+), 36 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 892d42e6c..9375d0986 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -24,19 +24,6 @@ def async_test(wrapped): return wrapper -# TODO: Remove me in Python 3.8 -class AsyncMock(unittest.mock.MagicMock): - """ - A MagicMock subclass to mock async callables. - - Python 3.8 will introduce an AsyncMock class in the standard library that will have some more - features; this stand-in only overwrites the `__call__` method to an async version. - """ - - async def __call__(self, *args, **kwargs): - return super(AsyncMock, self).__call__(*args, **kwargs) - - class HashableMixin(discord.mixins.EqualityComparable): """ Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. @@ -61,15 +48,43 @@ class ColourMixin: self.colour = color -class AttributeMock: +class GetChildMockMixin: """Ensures attributes of our mock types will be instantiated with the correct mock type.""" - def __new__(cls, *args, **kwargs): - """Stops the regular parent class from propagating to newly mocked attributes.""" - if 'parent' in kwargs: - return cls.attribute_mocktype(*args, **kwargs) + def _get_child_mock(self, **kw): + """ + Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes. + + Mock objects automatically create children when you access an attribute or call a method on them. By default, + the class of these children is the type of the parent itself. However, this would mean that the children created + for our custom mock types would also be instances of that custom mock type. This is not desirable, as attributes + of, e.g., a `Bot` object are not `Bot` objects themselves. The Python docs for `unittest.mock` hint that + overwriting this method is the best way to deal with that. + + This override will look for an attribute called `child_mock_type` and use that as the type of the child mock. + """ + klass = self.child_mock_type - return super().__new__(cls) + if self._mock_sealed: + attribute = "." + kw["name"] if "name" in kw else "()" + mock_name = self._extract_mock_name() + attribute + raise AttributeError(mock_name) + + return klass(**kw) + + +# TODO: Remove me in Python 3.8 +class AsyncMock(GetChildMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock async callables. + + Python 3.8 will introduce an AsyncMock class in the standard library that will have some more + features; this stand-in only overwrites the `__call__` method to an async version. + """ + child_mock_type = unittest.mock.MagicMock + + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) # Create a guild instance to get a realistic Mock of `discord.Guild` @@ -95,7 +110,7 @@ guild_data = { guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) -class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): +class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): """ A `Mock` subclass to mock `discord.Guild` objects. @@ -122,7 +137,7 @@ class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__( self, @@ -175,7 +190,7 @@ role_data = {'name': 'role', 'id': 1} role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) -class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock `discord.Role` objects. @@ -183,7 +198,7 @@ class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) @@ -208,7 +223,7 @@ state_mock = unittest.mock.MagicMock() member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) -class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock Member objects. @@ -216,7 +231,7 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__( self, @@ -254,7 +269,7 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) -class MockBot(AttributeMock, unittest.mock.MagicMock): +class MockBot(GetChildMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Bot objects. @@ -262,11 +277,15 @@ class MockBot(AttributeMock, unittest.mock.MagicMock): For more information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) + # Our custom attributes and methods + self.http_session = unittest.mock.MagicMock() + self.api_client = unittest.mock.MagicMock() + # `discord.ext.commands.Bot` coroutines self._before_invoke = AsyncMock() self._after_invoke = AsyncMock() @@ -303,7 +322,7 @@ class MockBot(AttributeMock, unittest.mock.MagicMock): context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) -class MockContext(AttributeMock, unittest.mock.MagicMock): +class MockContext(GetChildMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Context objects. @@ -311,7 +330,7 @@ class MockContext(AttributeMock, unittest.mock.MagicMock): instances. For more information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs) -> None: super().__init__(spec=context_instance, **kwargs) @@ -346,7 +365,7 @@ guild = unittest.mock.MagicMock() channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) -class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin): +class MockTextChannel(GetChildMockMixin, unittest.mock.Mock, HashableMixin): """ A MagicMock subclass to mock TextChannel objects. @@ -354,7 +373,7 @@ class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin): more information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: super().__init__(spec=channel_instance, **kwargs) @@ -402,7 +421,7 @@ channel = unittest.mock.MagicMock() message_instance = discord.Message(state=state, channel=channel, data=message_data) -class MockMessage(AttributeMock, unittest.mock.MagicMock): +class MockMessage(GetChildMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Message objects. @@ -410,7 +429,7 @@ class MockMessage(AttributeMock, unittest.mock.MagicMock): information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs) -> None: super().__init__(spec=message_instance, **kwargs) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f08239981..62007ff4e 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -221,10 +221,10 @@ class DiscordMocksTests(unittest.TestCase): @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') def test_the_custom_mock_methods_test(self, subtest_mock): """The custom method test should raise AssertionError for invalid methods.""" - class FakeMockBot(helpers.AttributeMock, unittest.mock.MagicMock): + class FakeMockBot(helpers.GetChildMockMixin, unittest.mock.MagicMock): """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs): super().__init__(spec=helpers.bot_instance, **kwargs) @@ -331,6 +331,18 @@ class MockObjectTests(unittest.TestCase): self.assertFalse(instance_one != instance_two) self.assertTrue(instance_one != instance_three) + def test_get_child_mock_mixin_accepts_mock_seal(self): + """The `GetChildMockMixin` should support `unittest.mock.seal`.""" + class MyMock(helpers.GetChildMockMixin, unittest.mock.MagicMock): + + child_mock_type = unittest.mock.MagicMock + pass + + mock = MyMock() + unittest.mock.seal(mock) + with self.assertRaises(AttributeError, msg="MyMock.shirayuki"): + mock.shirayuki = "hello!" + def test_spec_propagation_of_mock_subclasses(self): """Test if the `spec` does not propagate to attributes of the mock object.""" test_values = ( @@ -346,7 +358,7 @@ class MockObjectTests(unittest.TestCase): mock = mock_type() self.assertTrue(isinstance(mock, mock_type)) attribute = getattr(mock, valid_attribute) - self.assertTrue(isinstance(attribute, mock_type.attribute_mocktype)) + self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) def test_async_mock_provides_coroutine_for_dunder_call(self): """Test if AsyncMock objects have a coroutine for their __call__ method.""" -- cgit v1.2.3 From 4a5165a9f46046a9e9db6562fb421e36749fd32a Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Mon, 28 Oct 2019 19:40:14 -0300 Subject: Remove redirection on rules command Previously restricted for the staff. This change was suggested due its possible usefulness for regular users. --- bot/cogs/site.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index d95359159..683613788 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -3,8 +3,7 @@ import logging from discord import Colour, Embed from discord.ext.commands import Bot, Cog, Context, group -from bot.constants import Channels, STAFF_ROLES, URLs -from bot.decorators import redirect_output +from bot.constants import URLs from bot.pagination import LinePaginator log = logging.getLogger(__name__) @@ -105,7 +104,6 @@ class Site(Cog): await ctx.send(embed=embed) @site_group.command(aliases=['r', 'rule'], name='rules') - @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) async def site_rules(self, ctx: Context, *rules: int) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title='Rules', color=Colour.blurple()) -- cgit v1.2.3 From 1a1ef440aa8c1c63e9ed38742641d18da68c30c3 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 29 Oct 2019 13:44:13 +0100 Subject: Enhance Reddit webhook embeds https://github.com/python-discord/bot/issues/634 I have changes the appearance of the embed generated for the reddit webhook. The changes: - Bold markdown around the links to prevent it breaking on android. - Stylized the meta-data line with newly created emoji-based icons. - Removed redundant mentions of the subreddit from the embed itself. The emojis were uploaded to the `Emojis II` guild and the IDs have been added to the constants files. In addition, I've set the reddit logo as the default avatar for the webhook in the guild. This commit closes #634 --- bot/cogs/reddit.py | 6 +++--- bot/constants.py | 4 ++++ config-default.yml | 4 ++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 7749d237f..f947a7d78 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -9,7 +9,7 @@ from discord import Colour, Embed, TextChannel from discord.ext.commands import Bot, Cog, Context, group from discord.ext.tasks import loop -from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES, Webhooks +from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks from bot.converters import Subreddit from bot.decorators import with_role from bot.pagination import LinePaginator @@ -117,9 +117,9 @@ class Reddit(Cog): link = self.URL + data["permalink"] embed.description += ( - f"[**{title}**]({link})\n" + f"**[{title}]({link})**\n" f"{text}" - f"| {ups} upvotes | {comments} comments | u/{author} | {subreddit} |\n\n" + f"{Emojis.upvotes} {ups} {Emojis.comments} {comments} {Emojis.user} {author}\n\n" ) embed.colour = Colour.blurple() diff --git a/bot/constants.py b/bot/constants.py index 838fe7a79..d3e79b4c2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -259,6 +259,10 @@ class Emojis(metaclass=YAMLGetter): pencil: str cross_mark: str + upvotes: str + comments: str + user: str + class Icons(metaclass=YAMLGetter): section = "style" diff --git a/config-default.yml b/config-default.yml index 4638a89ee..9c7830077 100644 --- a/config-default.yml +++ b/config-default.yml @@ -37,6 +37,10 @@ style: new: "\U0001F195" cross_mark: "\u274C" + upvotes: "<:upvotes:638706000714792962>" + comments: "<:comments:638706001159258132>" + user: "<:user:638706001217978368>" + icons: crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png" -- cgit v1.2.3 From 55b83342f389aeacdbd5d2558dea4981c90aa2c4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 29 Oct 2019 14:27:56 +0100 Subject: Use 'local' emojis for reddit webhook embed Apparently, webhooks cannot use external emojis. For now, I have added the emojis to the guild directly and updated the IDs in this commit. I don't really like having "utility" emojis on the main guild, but the alternative would be to not use webhooks, which is a change we will need to discuss before we apply it. (I don't see anything against it now we just send a daily digest, although, in principle, this is precisely what a webhook is meant for.) --- config-default.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config-default.yml b/config-default.yml index 9c7830077..bce6ea266 100644 --- a/config-default.yml +++ b/config-default.yml @@ -37,9 +37,9 @@ style: new: "\U0001F195" cross_mark: "\u274C" - upvotes: "<:upvotes:638706000714792962>" - comments: "<:comments:638706001159258132>" - user: "<:user:638706001217978368>" + upvotes: "<:upvotes:638729835245731840>" + comments: "<:comments:638729835073765387>" + user: "<:user:638729835442602003>" icons: crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" -- cgit v1.2.3 From 618ba6a523dababde230382e1965ecc89f23aaf5 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 29 Oct 2019 10:04:37 +0100 Subject: Enhance custom mock helpers I have enhanced the custom mocks defined in `tests/helpers.py` in a couple of important ways. 1. Automatically create AsyncMock attributes using `inspect` Our previous approach, hard-coding AsynckMock attributes for all the coroutine function methods defined for the class we are trying to mock is prone to human error and not resilient against changes introduced in updates of the library we are using. Instead, I have now created a helper method in our `CustomMockMixin` (formerly `GetChildMockMixin`) that automatically inspects the spec instance we've passed for `coroutine functions` using the `inspect` module. It then sets the according attributes with instances of the AsyncMock class. There is one caveat: `discord.py` very rarely defines regular methods that return a coroutine object. Since the returned coroutine should still be awaited, these regular methods should also be mocked with an AsyncMock. However, since they are regular methods, `inspect` does not detect them and they have to be added manually. (The only case of this I've found so far is `Client.wait_for`.) 2. Properly set special attributes using `kwargs.get` As we want attributes that point to other discord.py objects to use our custom mocks (.e.g, `Message.author` should use `MockMember`), the `__init__` method of our custom mocks make sure to correctly instantiate these attributes. However, the way we previously did that means we can't instantiate the custom mock with a mock instance we provide, since this special instantiation would overwrite the custom object we'd passed. I've solved this by using `kwargs.get`, with a new mock as the default value. This makes sure we only create a new mock if we didn't pass a custom one: ```py class MockMesseage: def __init__(self, **kwargs): self.author = kwargs.get('author', MockMember()) ``` As you can see, we will only create a new MockMember if we did not pass an `author` argument. 3. Factoring out duplicate lines Since our `CustomMockMixin` is a parent to all of our custom mock types, it makes sense to use it to factor out common code of all of our custom mocks. I've made the following changes: - Set a default child mock type in the mixin. - Create an `__init__` that takes care of the `inspect` of point 1 This means we won't have to repeat this in all of the child classes. 4. Three new Mock types: Emoji, PartialEmoji, and Reaction I have added three more custom mocks: - MockEmoji - MockPartialEmoji - MockReaction --- tests/helpers.py | 253 +++++++++++++++++++------------------------------- tests/test_helpers.py | 58 +++++++++++- 2 files changed, 150 insertions(+), 161 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 9375d0986..673beae3f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio import functools +import inspect import unittest.mock -from typing import Iterable, Optional +from typing import Any, Iterable, Optional import discord from discord.ext.commands import Bot, Context @@ -48,9 +49,16 @@ class ColourMixin: self.colour = color -class GetChildMockMixin: +class CustomMockMixin: """Ensures attributes of our mock types will be instantiated with the correct mock type.""" + child_mock_type = unittest.mock.MagicMock + + def __init__(self, spec: Any = None, **kwargs): + super().__init__(spec=spec, **kwargs) + if spec: + self._extract_coroutine_methods_from_spec_instance(spec) + def _get_child_mock(self, **kw): """ Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes. @@ -72,17 +80,20 @@ class GetChildMockMixin: return klass(**kw) + def _extract_coroutine_methods_from_spec_instance(self, source: Any) -> None: + """Automatically detect coroutine functions in `source` and set them as AsyncMock attributes.""" + for name, _method in inspect.getmembers(source, inspect.iscoroutinefunction): + setattr(self, name, AsyncMock()) + # TODO: Remove me in Python 3.8 -class AsyncMock(GetChildMockMixin, unittest.mock.MagicMock): +class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock async callables. Python 3.8 will introduce an AsyncMock class in the standard library that will have some more features; this stand-in only overwrites the `__call__` method to an async version. """ - child_mock_type = unittest.mock.MagicMock - async def __call__(self, *args, **kwargs): return super(AsyncMock, self).__call__(*args, **kwargs) @@ -110,7 +121,7 @@ guild_data = { guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) -class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): +class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): """ A `Mock` subclass to mock `discord.Guild` objects. @@ -136,9 +147,6 @@ class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ - - child_mock_type = unittest.mock.MagicMock - def __init__( self, guild_id: int = 1, @@ -158,39 +166,13 @@ class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): if members: self.members.extend(members) - # `discord.Guild` coroutines - self.create_category_channel = AsyncMock() - self.ban = AsyncMock() - self.bans = AsyncMock() - self.create_category = AsyncMock() - self.create_custom_emoji = AsyncMock() - self.create_role = AsyncMock() - self.create_text_channel = AsyncMock() - self.create_voice_channel = AsyncMock() - self.delete = AsyncMock() - self.edit = AsyncMock() - self.estimate_pruned_members = AsyncMock() - self.fetch_ban = AsyncMock() - self.fetch_channels = AsyncMock() - self.fetch_emoji = AsyncMock() - self.fetch_emojis = AsyncMock() - self.fetch_member = AsyncMock() - self.invites = AsyncMock() - self.kick = AsyncMock() - self.leave = AsyncMock() - self.prune_members = AsyncMock() - self.unban = AsyncMock() - self.vanity_invite = AsyncMock() - self.webhooks = AsyncMock() - self.widget = AsyncMock() - # Create a Role instance to get a realistic Mock of `discord.Role` role_data = {'name': 'role', 'id': 1} role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) -class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock `discord.Role` objects. @@ -208,10 +190,6 @@ class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin self.position = position self.mention = f'&{self.name}' - # 'discord.Role' coroutines - self.delete = AsyncMock() - self.edit = AsyncMock() - def __lt__(self, other): """Simplified position-based comparisons similar to those of `discord.Role`.""" return self.position < other.position @@ -223,16 +201,13 @@ state_mock = unittest.mock.MagicMock() member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) -class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock Member objects. Instances of this class will follow the specifications of `discord.Member` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__( self, name: str = "member", @@ -251,34 +226,18 @@ class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMix self.mention = f"@{self.name}" - # `discord.Member` coroutines - self.add_roles = AsyncMock() - self.ban = AsyncMock() - self.edit = AsyncMock() - self.fetch_message = AsyncMock() - self.kick = AsyncMock() - self.move_to = AsyncMock() - self.pins = AsyncMock() - self.remove_roles = AsyncMock() - self.send = AsyncMock() - self.trigger_typing = AsyncMock() - self.unban = AsyncMock() - # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) -class MockBot(GetChildMockMixin, unittest.mock.MagicMock): +class MockBot(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Bot objects. Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) @@ -286,69 +245,12 @@ class MockBot(GetChildMockMixin, unittest.mock.MagicMock): self.http_session = unittest.mock.MagicMock() self.api_client = unittest.mock.MagicMock() - # `discord.ext.commands.Bot` coroutines - self._before_invoke = AsyncMock() - self._after_invoke = AsyncMock() - self.application_info = AsyncMock() - self.change_presence = AsyncMock() - self.connect = AsyncMock() - self.close = AsyncMock() - self.create_guild = AsyncMock() - self.delete_invite = AsyncMock() - self.fetch_channel = AsyncMock() - self.fetch_guild = AsyncMock() - self.fetch_guilds = AsyncMock() - self.fetch_invite = AsyncMock() - self.fetch_user = AsyncMock() - self.fetch_user_profile = AsyncMock() - self.fetch_webhook = AsyncMock() - self.fetch_widget = AsyncMock() - self.get_context = AsyncMock() - self.get_prefix = AsyncMock() - self.invoke = AsyncMock() - self.is_owner = AsyncMock() - self.login = AsyncMock() - self.logout = AsyncMock() - self.on_command_error = AsyncMock() - self.on_error = AsyncMock() - self.process_commands = AsyncMock() - self.request_offline_members = AsyncMock() - self.start = AsyncMock() - self.wait_until_ready = AsyncMock() + # 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 + # is technically incorrect, since it's a regular def.) self.wait_for = AsyncMock() -# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` -context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) - - -class MockContext(GetChildMockMixin, unittest.mock.MagicMock): - """ - A MagicMock subclass to mock Context objects. - - Instances of this class will follow the specifications of `discord.ext.commands.Context` - instances. For more information, see the `MockGuild` docstring. - """ - - child_mock_type = unittest.mock.MagicMock - - def __init__(self, **kwargs) -> None: - super().__init__(spec=context_instance, **kwargs) - self.bot = MockBot() - self.guild = MockGuild() - self.author = MockMember() - self.command = unittest.mock.MagicMock() - - # `discord.ext.commands.Context` coroutines - self.fetch_message = AsyncMock() - self.invoke = AsyncMock() - self.pins = AsyncMock() - self.reinvoke = AsyncMock() - self.send = AsyncMock() - self.send_help = AsyncMock() - self.trigger_typing = AsyncMock() - - # Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` channel_data = { 'id': 1, @@ -365,39 +267,20 @@ guild = unittest.mock.MagicMock() channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) -class MockTextChannel(GetChildMockMixin, unittest.mock.Mock, HashableMixin): +class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): """ A MagicMock subclass to mock TextChannel objects. Instances of this class will follow the specifications of `discord.TextChannel` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: super().__init__(spec=channel_instance, **kwargs) self.id = channel_id self.name = name - self.guild = MockGuild() + self.guild = kwargs.get('guild', MockGuild()) self.mention = f"#{self.name}" - # `discord.TextChannel` coroutines - self.clone = AsyncMock() - self.create_invite = AsyncMock() - self.create_webhook = AsyncMock() - self.delete = AsyncMock() - self.delete_messages = AsyncMock() - self.edit = AsyncMock() - self.fetch_message = AsyncMock() - self.invites = AsyncMock() - self.pins = AsyncMock() - self.purge = AsyncMock() - self.send = AsyncMock() - self.set_permissions = AsyncMock() - self.trigger_typing = AsyncMock() - self.webhooks = AsyncMock() - # Create a Message instance to get a realistic MagicMock of `discord.Message` message_data = { @@ -421,27 +304,83 @@ channel = unittest.mock.MagicMock() message_instance = discord.Message(state=state, channel=channel, data=message_data) -class MockMessage(GetChildMockMixin, unittest.mock.MagicMock): +# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` +context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) + + +class MockContext(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Context objects. + + Instances of this class will follow the specifications of `discord.ext.commands.Context` + instances. For more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=context_instance, **kwargs) + self.bot = kwargs.get('bot', MockBot()) + self.guild = kwargs.get('guild', MockGuild()) + self.author = kwargs.get('author', MockMember()) + self.channel = kwargs.get('channel', MockTextChannel()) + self.command = kwargs.get('command', unittest.mock.MagicMock()) + + +class MockMessage(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Message objects. Instances of this class will follow the specifications of `discord.Message` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=message_instance, **kwargs) + self.author = kwargs.get('author', MockMember()) + self.channel = kwargs.get('channel', MockTextChannel()) - child_mock_type = unittest.mock.MagicMock +emoji_data = {'require_colons': True, 'managed': True, 'id': 1, 'name': 'hyperlemon'} +emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data) + + +class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Emoji objects. + + Instances of this class will follow the specifications of `discord.Emoji` instances. For more + information, see the `MockGuild` docstring. + """ def __init__(self, **kwargs) -> None: - super().__init__(spec=message_instance, **kwargs) - self.author = MockMember() - self.channel = MockTextChannel() - - # `discord.Message` coroutines - self.ack = AsyncMock() - self.add_reaction = AsyncMock() - self.clear_reactions = AsyncMock() - self.delete = AsyncMock() - self.edit = AsyncMock() - self.pin = AsyncMock() - self.remove_reaction = AsyncMock() - self.unpin = AsyncMock() + super().__init__(spec=emoji_instance, **kwargs) + self.guild = kwargs.get('guild', MockGuild()) + + # Get all coroutine functions and set them as AsyncMock attributes + self._extract_coroutine_methods_from_spec_instance(emoji_instance) + + +partial_emoji_instance = discord.PartialEmoji(animated=False, name='guido') + + +class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock PartialEmoji objects. + + Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For + more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=partial_emoji_instance, **kwargs) + + +reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji()) + + +class MockReaction(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Reaction objects. + + Instances of this class will follow the specifications of `discord.Reaction` instances. For + more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=reaction_instance, **kwargs) + self.emoji = kwargs.get('emoji', MockEmoji()) + self.message = kwargs.get('message', MockMessage()) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 62007ff4e..2b58634dd 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -221,7 +221,7 @@ class DiscordMocksTests(unittest.TestCase): @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') def test_the_custom_mock_methods_test(self, subtest_mock): """The custom method test should raise AssertionError for invalid methods.""" - class FakeMockBot(helpers.GetChildMockMixin, unittest.mock.MagicMock): + class FakeMockBot(helpers.CustomMockMixin, unittest.mock.MagicMock): """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" child_mock_type = unittest.mock.MagicMock @@ -331,9 +331,9 @@ class MockObjectTests(unittest.TestCase): self.assertFalse(instance_one != instance_two) self.assertTrue(instance_one != instance_three) - def test_get_child_mock_mixin_accepts_mock_seal(self): - """The `GetChildMockMixin` should support `unittest.mock.seal`.""" - class MyMock(helpers.GetChildMockMixin, unittest.mock.MagicMock): + def test_custom_mock_mixin_accepts_mock_seal(self): + """The `CustomMockMixin` should support `unittest.mock.seal`.""" + class MyMock(helpers.CustomMockMixin, unittest.mock.MagicMock): child_mock_type = unittest.mock.MagicMock pass @@ -351,6 +351,10 @@ class MockObjectTests(unittest.TestCase): (helpers.MockMember, "display_name"), (helpers.MockBot, "owner_id"), (helpers.MockContext, "command_failed"), + (helpers.MockMessage, "mention_everyone"), + (helpers.MockEmoji, 'managed'), + (helpers.MockPartialEmoji, 'url'), + (helpers.MockReaction, 'me'), ) for mock_type, valid_attribute in test_values: @@ -360,6 +364,52 @@ class MockObjectTests(unittest.TestCase): attribute = getattr(mock, valid_attribute) self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) + def test_extract_coroutine_methods_from_spec_instance_should_extract_all_and_only_coroutines(self): + """Test if all coroutine functions are extracted, but not regular methods or attributes.""" + class CoroutineDonor: + def __init__(self): + self.some_attribute = 'alpha' + + async def first_coroutine(): + """This coroutine function should be extracted.""" + + async def second_coroutine(): + """This coroutine function should be extracted.""" + + def regular_method(): + """This regular function should not be extracted.""" + + class Receiver: + pass + + donor = CoroutineDonor() + receiver = Receiver() + + helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance(receiver, donor) + + self.assertIsInstance(receiver.first_coroutine, helpers.AsyncMock) + self.assertIsInstance(receiver.second_coroutine, helpers.AsyncMock) + self.assertFalse(hasattr(receiver, 'regular_method')) + self.assertFalse(hasattr(receiver, 'some_attribute')) + + @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) + @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") + def test_custom_mock_mixin_init_with_spec(self, extract_method_mock): + """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" + spec = "pydis" + + helpers.CustomMockMixin(spec=spec) + + extract_method_mock.assert_called_once_with(spec) + + @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) + @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") + def test_custom_mock_mixin_init_without_spec(self, extract_method_mock): + """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" + helpers.CustomMockMixin() + + extract_method_mock.assert_not_called() + def test_async_mock_provides_coroutine_for_dunder_call(self): """Test if AsyncMock objects have a coroutine for their __call__ method.""" async_mock = helpers.AsyncMock() -- cgit v1.2.3 From 586ae18842de0dd92e93945c63ed5e6cd158c1f7 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 30 Oct 2019 22:54:27 +0100 Subject: Update docstring and remove redundant attribute I accidentally forgot to update the docstring of `CustomMockMixin`, which changed quite dramatically in scope with the last commit. This commit remedies that. In addition, I inadvertently forgot to remove the `child_mock_type` class attribute from `MockRole`. Since it uses the default value, it is no longer necessary to specify it in the child class as well. --- tests/helpers.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 673beae3f..8496ba031 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -50,7 +50,15 @@ class ColourMixin: class CustomMockMixin: - """Ensures attributes of our mock types will be instantiated with the correct mock type.""" + """ + Provides common functionality for our custom Mock types. + + The cooperative `__init__` automatically creates `AsyncMock` attributes for every coroutine + function `inspect` detects in the `spec` instance we provide. In addition, this mixin takes care + of making sure child mocks are instantiated with the correct class. By default, the mock of the + children will be `unittest.mock.MagicMock`, but this can be overwritten by setting the attribute + `child_mock_type` on the custom mock inheriting from this mixin. + """ child_mock_type = unittest.mock.MagicMock @@ -179,9 +187,6 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): Instances of this class will follow the specifications of `discord.Role` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) -- cgit v1.2.3 From a98485e173208cc2272f5c6355ddaf5858050403 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Thu, 31 Oct 2019 07:39:03 +0100 Subject: Figure out which tests we need. This adds empty tests for all the tests I'd like to add to this pull request. It also adds a few more duckies to the emoji constant list, and adds a single line of clarification to the testing readme. --- bot/constants.py | 8 +++- tests/README.md | 1 + tests/bot/cogs/test_duck_pond.py | 80 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/bot/cogs/test_duck_pond.py diff --git a/bot/constants.py b/bot/constants.py index 79845711d..dbbf32063 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -261,8 +261,13 @@ class Emojis(metaclass=YAMLGetter): pencil: str cross_mark: str - ducky: int + ducky_yellow: int ducky_blurple: int + ducky_regal: int + ducky_camo: int + ducky_ninja: int + ducky_devil: int + ducky_tube: int class Icons(metaclass=YAMLGetter): @@ -341,7 +346,6 @@ class Channels(metaclass=YAMLGetter): defcon: int devlog: int devtest: int - duck_pond: int help_0: int help_1: int help_2: int diff --git a/tests/README.md b/tests/README.md index 6ab9bc93e..d052de2f6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -15,6 +15,7 @@ We are using the following modules and packages for our unit tests: To ensure the results you obtain on your personal machine are comparable to those generated in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts: - `pipenv run test` will run `unittest` with `coverage.py` +- `pipenv run test path/to/test.py` will run a specific test. - `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. If you want a coverage report, make sure to run the tests with `pipenv run test` *first*. diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py new file mode 100644 index 000000000..79f11843b --- /dev/null +++ b/tests/bot/cogs/test_duck_pond.py @@ -0,0 +1,80 @@ +import logging +import unittest +from unittest.mock import MagicMock + +from bot.cogs import duck_pond +from tests.helpers import MockBot, MockMessage + + +class DuckPondTest(unittest.TestCase): + """Tests the `DuckPond` cog.""" + + def setUp(self): + """Adds the cog, a bot, and a message to the instance for usage in tests.""" + self.bot = MockBot() + self.cog = duck_pond.DuckPond(bot=self.bot) + + self.msg = MockMessage(message_id=555, content='') + self.msg.author.__str__ = MagicMock() + self.msg.author.__str__.return_value = 'lemon' + self.msg.author.bot = False + self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' + self.msg.author.id = 42 + self.msg.author.mention = '@lemon' + self.msg.channel.mention = "#lemonade-stand" + + def test_is_staff_correctly_identifies_staff(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_has_green_checkmark(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_count_custom_duck_emojis(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_count_unicode_duck_emojis(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_count_mixed_duck_emojis(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_raw_reaction_add_rejects_bot(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_raw_reaction_add_rejects_non_staff(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_raw_reaction_add_sends_message_on_valid_input(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_raw_reaction_remove_rejects_non_checkmarks(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_raw_reaction_remove_prevents_checkmark_removal(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + +class DuckPondSetupTests(unittest.TestCase): + """Tests setup of the `DuckPond` cog.""" + + def test_setup(self): + """Setup of the cog should log a message at `INFO` level.""" + bot = MockBot() + log = logging.getLogger('bot.cogs.duck_pond') + + with self.assertLogs(logger=log, level=logging.INFO) as log_watcher: + duck_pond.setup(bot) + line = log_watcher.output[0] + + bot.add_cog.assert_called_once() + self.assertIn("Cog loaded: DuckPond", line) -- cgit v1.2.3 From bed913eb358f3effa592cab507f329ce8f50171a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 31 Oct 2019 13:07:55 +0100 Subject: Re-post attachments Before sending the attachments to API for logging, we now re-post them in the channel that have the id stored in the constant Guild.attachment_repost (it needs to be configured). These new links will never expires. --- bot/cogs/antispam.py | 4 ---- bot/cogs/moderation/modlog.py | 19 ++++++++++++++++++- bot/constants.py | 1 + config-default.yml | 1 + 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 1340eb608..a450c18ce 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -1,4 +1,3 @@ -import asyncio import logging from collections.abc import Mapping from dataclasses import dataclass, field @@ -243,9 +242,6 @@ class AntiSpam(Cog): async def _process_deletion_context(self, context_id: int) -> None: """Processes the Deletion Context queue.""" - log.trace("Sleeping before processing message deletion queue.") - await asyncio.sleep(10) - if context_id not in self.message_deletion_queue: log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!") return diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 88f2b6c67..6d4b66644 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -2,6 +2,7 @@ import asyncio import logging import typing as t from datetime import datetime +from io import BytesIO import discord from dateutil.relativedelta import relativedelta @@ -53,7 +54,8 @@ class ModLog(Cog, name="ModLog"): 'author': message.author.id, 'channel_id': message.channel.id, 'content': message.content, - 'embeds': [embed.to_dict() for embed in message.embeds] + 'embeds': [embed.to_dict() for embed in message.embeds], + 'attachments': await self.reupload_attachments(message) if message.attachments else [], } for message in messages ] @@ -116,6 +118,21 @@ class ModLog(Cog, name="ModLog"): return await self.bot.get_context(log_message) # Optionally return for use with antispam + async def reupload_attachments( + self, + message: discord.Message, + channel_id: int = GuildConstant.attachment_repost + ) -> t.List[str]: + """Re-upload message's attachments to the the channel_id and return the list of re-posted attachments URLs.""" + channel = self.bot.get_channel(channel_id) + out = [] + for attachment in message.attachments: + buffer = BytesIO() + await attachment.save(buffer, use_cached=True) + reupload = await channel.send(file=discord.File(buffer, filename=attachment.filename)) + out.append(reupload.attachments[0].url) + return out + @Cog.listener() async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None: """Log channel create event to mod log.""" diff --git a/bot/constants.py b/bot/constants.py index 838fe7a79..9582fea96 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -398,6 +398,7 @@ class Guild(metaclass=YAMLGetter): id: int ignored: List[int] staff_channels: List[int] + attachment_repost: int class Keys(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 4638a89ee..9d2ee7941 100644 --- a/config-default.yml +++ b/config-default.yml @@ -127,6 +127,7 @@ guild: staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] + attachment_repost: *MODLOG roles: admin: &ADMIN_ROLE 267628507062992896 -- cgit v1.2.3 From 6e57f41d727c9cde51d36adc04393c3723d62472 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 28 Oct 2019 15:54:31 +0100 Subject: Enhance the output of the user command https://github.com/python-discord/bot/issues/628 https://github.com/python-discord/bot/issues/339 This commit introduces several changes to the output of the `!user` command for moderation staff. The output for regular users has not changed. Changes: - When issued in a moderation channel, the infraction count of the user will now be broken down by type as described in #339. This allows moderators to get a quicker overview of someone's history by providing more information. The command will display the total number of infractions per type, with the number of active infractions in parentheses behind it if there are any. This change also means that there no longer a need for the `hidden` parameter: When issued in a moderation channel, hidden infractions are included by default; when issued outside of a mod channel, the command will be equal to what a regular user would get. In addition to broken-down infraction info, the command now also shows information about the nominations of a user when it's issued inside of a moderation channel. - The code has been refactored to smaller units that take care of a single action to make unit testing easier. I have included tests that cover the command and all of the new helper methods. Tests for the other methods/commands in the cog will be added in the specific issue calling for tests for this cog (#581) This commit closes #628 and closes #339 --- bot/cogs/information.py | 170 ++++++++++---- tests/bot/cogs/test_information.py | 444 +++++++++++++++++++++++++++++++++++-- 2 files changed, 552 insertions(+), 62 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 3a7ba0444..4a3af7edd 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -3,6 +3,7 @@ import logging import pprint import textwrap import typing +from collections import defaultdict from typing import Any, Mapping, Optional import discord @@ -10,7 +11,7 @@ from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, V from discord.ext import commands from discord.ext.commands import Bot, BucketType, Cog, Context, command, group -from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES +from bot import constants from bot.decorators import InChannelCheckFailure, in_channel, with_role from bot.utils.checks import cooldown_with_role_bypass, with_role_check from bot.utils.time import time_since @@ -24,7 +25,7 @@ class Information(Cog): def __init__(self, bot: Bot): self.bot = bot - @with_role(*MODERATION_ROLES) + @with_role(*constants.MODERATION_ROLES) @command(name="roles") async def roles_info(self, ctx: Context) -> None: """Returns a list of all roles and their corresponding IDs.""" @@ -48,7 +49,7 @@ class Information(Cog): await ctx.send(embed=embed) - @with_role(*MODERATION_ROLES) + @with_role(*constants.MODERATION_ROLES) @command(name="role") async def role_info(self, ctx: Context, *roles: typing.Union[Role, str]) -> None: """ @@ -148,10 +149,10 @@ class Information(Cog): Channel categories: {category_channels} **Members** - {Emojis.status_online} {online} - {Emojis.status_idle} {idle} - {Emojis.status_dnd} {dnd} - {Emojis.status_offline} {offline} + {constants.Emojis.status_online} {online} + {constants.Emojis.status_idle} {idle} + {constants.Emojis.status_dnd} {dnd} + {constants.Emojis.status_offline} {offline} """) ) @@ -160,59 +161,38 @@ class Information(Cog): await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False) -> None: + async def user_info(self, ctx: Context, user: Member = None) -> None: """Returns info about a user.""" if user is None: user = ctx.author # Do a role check if this is being executed on someone other than the caller - if user != ctx.author and not with_role_check(ctx, *MODERATION_ROLES): + if user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): await ctx.send("You may not use this command on users other than yourself.") return - # Non-moderators may only do this in #bot-commands and can't see hidden infractions. - if not with_role_check(ctx, *STAFF_ROLES): - if not ctx.channel.id == Channels.bot: - raise InChannelCheckFailure(Channels.bot) - # Hide hidden infractions for users without a moderation role - hidden = False + # 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) - # User information + embed = await self.create_user_embed(ctx, user) + + await ctx.send(embed=embed) + + async def create_user_embed(self, ctx: Context, user: Member) -> Embed: + """Creates an embed containing information on the `user`.""" created = time_since(user.created_at, max_units=3) name = str(user) if user.nick: name = f"{user.nick} ({name})" - # Member information joined = time_since(user.joined_at, precision="days") - - # You're welcome, Volcyyyyyyyyyyyyyyyy roles = ", ".join(role.mention for role in user.roles if role.name != "@everyone") - # Infractions - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'hidden': str(hidden), - 'user__id': str(user.id) - } - ) - - infr_total = 0 - infr_active = 0 - - # At least it's readable. - for infr in infractions: - if infr["active"]: - infr_active += 1 - - infr_total += 1 - - # Let's build the embed now - embed = Embed( - title=name, - description=textwrap.dedent(f""" + description = [ + textwrap.dedent(f""" **User Information** Created: {created} Profile: {user.mention} @@ -221,17 +201,109 @@ class Information(Cog): **Member Information** Joined: {joined} Roles: {roles or None} + """).strip() + ] - **Infractions** - Total: {infr_total} - Active: {infr_active} - """) + # Show more verbose output in moderation channels for infractions and nominations + if ctx.channel.id in constants.MODERATION_CHANNELS: + description.append(await self.expanded_user_infraction_counts(user)) + description.append(await self.user_nomination_counts(user)) + else: + description.append(await self.basic_user_infraction_counts(user)) + + # Let's build the embed now + embed = Embed( + title=name, + description="\n\n".join(description) ) embed.set_thumbnail(url=user.avatar_url_as(format="png")) embed.colour = user.top_role.colour if roles else Colour.blurple() - await ctx.send(embed=embed) + return embed + + async def basic_user_infraction_counts(self, member: Member) -> str: + """Gets the total and active infraction counts for the given `member`.""" + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'hidden': 'False', + 'user__id': str(member.id) + } + ) + + total_infractions = len(infractions) + active_infractions = sum(infraction['active'] for infraction in infractions) + + infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" + + return infraction_output + + async def expanded_user_infraction_counts(self, member: Member) -> str: + """ + Gets expanded infraction counts for the given `member`. + + The counts will be split by infraction type and the number of active infractions for each type will indicated + in the output as well. + """ + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'user__id': str(member.id) + } + ) + + infraction_output = ["**Infractions**"] + if not infractions: + infraction_output.append("This user has never received an infraction.") + else: + # Count infractions split by `type` and `active` status for this user + infraction_types = set() + infraction_counter = defaultdict(int) + for infraction in infractions: + infraction_type = infraction["type"] + infraction_active = 'active' if infraction["active"] else 'inactive' + + infraction_types.add(infraction_type) + infraction_counter[f"{infraction_active} {infraction_type}"] += 1 + + # Format the output of the infraction counts + for infraction_type in sorted(infraction_types): + active_count = infraction_counter[f"active {infraction_type}"] + total_count = active_count + infraction_counter[f"inactive {infraction_type}"] + + line = f"{infraction_type.capitalize()}s: {total_count}" + if active_count: + line += f" ({active_count} active)" + + infraction_output.append(line) + + return "\n".join(infraction_output) + + async def user_nomination_counts(self, member: Member) -> str: + """Gets the active and historical nomination counts for the given `member`.""" + nominations = await self.bot.api_client.get( + 'bot/nominations', + params={ + 'user__id': str(member.id) + } + ) + + output = ["**Nominations**"] + + if not nominations: + output.append("This user has never been nominated.") + else: + count = len(nominations) + is_currently_nominated = any(nomination["active"] for nomination in nominations) + nomination_noun = "nomination" if count == 1 else "nominations" + + if is_currently_nominated: + output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") + else: + output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") + + return "\n".join(output) def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: """Format a mapping to be readable to a human.""" @@ -268,9 +340,9 @@ class Information(Cog): # remove trailing whitespace return out.rstrip() - @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=STAFF_ROLES) + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) @group(invoke_without_command=True) - @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) + @in_channel(constants.Channels.bot, bypass_roles=constants.STAFF_ROLES) async def raw(self, ctx: Context, *, message: discord.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/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 9bbd35a91..5c34541d8 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -7,7 +7,11 @@ import discord from bot import constants from bot.cogs import information -from tests.helpers import AsyncMock, MockBot, MockContext, MockGuild, MockMember, MockRole +from bot.decorators import InChannelCheckFailure +from tests import helpers + + +COG_PATH = "bot.cogs.information.Information" class InformationCogTests(unittest.TestCase): @@ -15,22 +19,22 @@ class InformationCogTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.moderator_role = MockRole(name="Moderator", role_id=constants.Roles.moderator) + cls.moderator_role = helpers.MockRole(name="Moderator", role_id=constants.Roles.moderator) def setUp(self): """Sets up fresh objects for each test.""" - self.bot = MockBot() + self.bot = helpers.MockBot() self.cog = information.Information(self.bot) - self.ctx = MockContext() + self.ctx = helpers.MockContext() self.ctx.author.roles.append(self.moderator_role) def test_roles_command_command(self): """Test if the `role_info` command correctly returns the `moderator_role`.""" self.ctx.guild.roles.append(self.moderator_role) - self.cog.roles_info.can_run = AsyncMock() + self.cog.roles_info.can_run = helpers.AsyncMock() self.cog.roles_info.can_run.return_value = True coroutine = self.cog.roles_info.callback(self.cog, self.ctx) @@ -48,7 +52,7 @@ class InformationCogTests(unittest.TestCase): def test_role_info_command(self): """Tests the `role info` command.""" - dummy_role = MockRole( + dummy_role = helpers.MockRole( name="Dummy", role_id=112233445566778899, colour=discord.Colour.blurple(), @@ -57,7 +61,7 @@ class InformationCogTests(unittest.TestCase): permissions=discord.Permissions(0) ) - admin_role = MockRole( + admin_role = helpers.MockRole( name="Admins", role_id=998877665544332211, colour=discord.Colour.red(), @@ -68,7 +72,7 @@ class InformationCogTests(unittest.TestCase): self.ctx.guild.roles.append([dummy_role, admin_role]) - self.cog.role_info.can_run = AsyncMock() + self.cog.role_info.can_run = helpers.AsyncMock() self.cog.role_info.can_run.return_value = True coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) @@ -99,7 +103,7 @@ class InformationCogTests(unittest.TestCase): def test_server_info_command(self, time_since_patch): time_since_patch.return_value = '2 days ago' - self.ctx.guild = MockGuild( + self.ctx.guild = helpers.MockGuild( features=('lemons', 'apples'), region="The Moon", roles=[self.moderator_role], @@ -121,10 +125,10 @@ class InformationCogTests(unittest.TestCase): ) ], members=[ - *(MockMember(status='online') for _ in range(2)), - *(MockMember(status='idle') for _ in range(1)), - *(MockMember(status='dnd') for _ in range(4)), - *(MockMember(status='offline') for _ in range(3)), + *(helpers.MockMember(status='online') for _ in range(2)), + *(helpers.MockMember(status='idle') for _ in range(1)), + *(helpers.MockMember(status='dnd') for _ in range(4)), + *(helpers.MockMember(status='offline') for _ in range(3)), ], member_count=1_234, icon_url='a-lemon.jpg', @@ -162,3 +166,417 @@ class InformationCogTests(unittest.TestCase): ) ) self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') + + +class UserInfractionHelperMethodTests(unittest.TestCase): + """Tests for the helper methods of the `!user` command.""" + + def setUp(self): + """Common set-up steps done before for each test.""" + self.bot = helpers.MockBot() + self.bot.api_client.get = helpers.AsyncMock() + self.cog = information.Information(self.bot) + self.member = helpers.MockMember(user_id=1234) + + def test_user_command_helper_method_get_requests(self): + """The helper methods should form the correct get requests.""" + test_values = ( + { + "helper_method": self.cog.basic_user_infraction_counts, + "expected_args": ("bot/infractions", {'hidden': 'False', 'user__id': str(self.member.id)}), + }, + { + "helper_method": self.cog.expanded_user_infraction_counts, + "expected_args": ("bot/infractions", {'user__id': str(self.member.id)}), + }, + { + "helper_method": self.cog.user_nomination_counts, + "expected_args": ("bot/nominations", {'user__id': str(self.member.id)}), + }, + ) + + for test_value in test_values: + helper_method = test_value["helper_method"] + endpoint, params = test_value["expected_args"] + + with self.subTest(method=helper_method, endpoint=endpoint, params=params): + asyncio.run(helper_method(self.member)) + self.bot.api_client.get.assert_called_once_with(endpoint, params=params) + self.bot.api_client.get.reset_mock() + + def _method_subtests(self, method, test_values, default_header): + """Helper method that runs the subtests for the different helper methods.""" + for test_value in test_values: + api_response = test_value["api response"] + expected_lines = test_value["expected_lines"] + + with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): + self.bot.api_client.get.return_value = api_response + + expected_output = "\n".join(default_header + expected_lines) + actual_output = asyncio.run(method(self.member)) + + self.assertEqual(expected_output, actual_output) + + def test_basic_user_infraction_counts_returns_correct_strings(self): + """The method should correctly list both the total and active number of non-hidden infractions.""" + test_values = ( + # No infractions means zero counts + { + "api response": [], + "expected_lines": ["Total: 0", "Active: 0"], + }, + # Simple, single-infraction dictionaries + { + "api response": [{"type": "ban", "active": True}], + "expected_lines": ["Total: 1", "Active: 1"], + }, + { + "api response": [{"type": "ban", "active": False}], + "expected_lines": ["Total: 1", "Active: 0"], + }, + # Multiple infractions with various `active` status + { + "api response": [ + {"type": "ban", "active": True}, + {"type": "kick", "active": False}, + {"type": "ban", "active": True}, + {"type": "ban", "active": False}, + ], + "expected_lines": ["Total: 4", "Active: 2"], + }, + ) + + header = ["**Infractions**"] + + self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) + + def test_expanded_user_infraction_counts_returns_correct_strings(self): + """The method should correctly list the total and active number of all infractions split by infraction type.""" + test_values = ( + { + "api response": [], + "expected_lines": ["This user has never received an infraction."], + }, + # Shows non-hidden inactive infraction as expected + { + "api response": [{"type": "kick", "active": False, "hidden": False}], + "expected_lines": ["Kicks: 1"], + }, + # Shows non-hidden active infraction as expected + { + "api response": [{"type": "mute", "active": True, "hidden": False}], + "expected_lines": ["Mutes: 1 (1 active)"], + }, + # Shows hidden inactive infraction as expected + { + "api response": [{"type": "superstar", "active": False, "hidden": True}], + "expected_lines": ["Superstars: 1"], + }, + # Shows hidden active infraction as expected + { + "api response": [{"type": "ban", "active": True, "hidden": True}], + "expected_lines": ["Bans: 1 (1 active)"], + }, + # Correctly displays tally of multiple infractions of mixed properties in alphabetical order + { + "api response": [ + {"type": "kick", "active": False, "hidden": True}, + {"type": "ban", "active": True, "hidden": True}, + {"type": "superstar", "active": True, "hidden": True}, + {"type": "mute", "active": True, "hidden": True}, + {"type": "ban", "active": False, "hidden": False}, + {"type": "note", "active": False, "hidden": True}, + {"type": "note", "active": False, "hidden": True}, + {"type": "warn", "active": False, "hidden": False}, + {"type": "note", "active": False, "hidden": True}, + ], + "expected_lines": [ + "Bans: 2 (1 active)", + "Kicks: 1", + "Mutes: 1 (1 active)", + "Notes: 3", + "Superstars: 1 (1 active)", + "Warns: 1", + ], + }, + ) + + header = ["**Infractions**"] + + self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) + + def test_user_nomination_counts_returns_correct_strings(self): + """The method should list the number of active and historical nominations for the user.""" + test_values = ( + { + "api response": [], + "expected_lines": ["This user has never been nominated."], + }, + { + "api response": [{'active': True}], + "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], + }, + { + "api response": [{'active': True}, {'active': False}], + "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], + }, + { + "api response": [{'active': False}], + "expected_lines": ["This user has 1 historical nomination, but is currently not nominated."], + }, + { + "api response": [{'active': False}, {'active': False}], + "expected_lines": ["This user has 2 historical nominations, but is currently not nominated."], + }, + + ) + + header = ["**Nominations**"] + + self._method_subtests(self.cog.user_nomination_counts, test_values, header) + + +@unittest.mock.patch("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) +@unittest.mock.patch("bot.cogs.information.constants.MODERATION_CHANNELS", new=[50]) +class UserEmbedTests(unittest.TestCase): + """Tests for the creation of the `!user` embed.""" + + def setUp(self): + """Common set-up steps done before for each test.""" + self.bot = helpers.MockBot() + self.bot.api_client.get = helpers.AsyncMock() + self.cog = information.Information(self.bot) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): + """The embed should use the string representation of the user if they don't have a nick.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + user = helpers.MockMember() + user.nick = None + user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.title, "Mr. Hemlock") + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_nick_in_title_if_available(self): + """The embed should use the nick if it's available.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + user = helpers.MockMember() + user.nick = "Cat lover" + user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_ignores_everyone_role(self): + """Created `!user` embeds should not contain mention of the @everyone-role.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + admins_role = helpers.MockRole('Admins') + admins_role.colour = 100 + + # A `MockMember` has the @Everyone role by default; we add the Admins to that. + user = helpers.MockMember(roles=[admins_role], top_role=admins_role) + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertIn("&Admins", embed.description) + self.assertNotIn("&Everyone", embed.description) + + @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=helpers.AsyncMock) + @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=helpers.AsyncMock) + def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): + """The embed should contain expanded infractions and nomination info in mod channels.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=50)) + + moderators_role = helpers.MockRole('Moderators') + moderators_role.colour = 100 + + infraction_counts.return_value = "expanded infractions info" + nomination_counts.return_value = "nomination info" + + user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + infraction_counts.assert_called_once_with(user) + nomination_counts.assert_called_once_with(user) + + self.assertEqual( + textwrap.dedent(f""" + **User Information** + Created: {"1 year ago"} + Profile: {user.mention} + ID: {user.id} + + **Member Information** + Joined: {"1 year ago"} + Roles: &Moderators + + expanded infractions info + + nomination info + """).strip(), + embed.description + ) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=helpers.AsyncMock) + def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): + """The embed should contain only basic infraction data outside of mod channels.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=100)) + + moderators_role = helpers.MockRole('Moderators') + moderators_role.colour = 100 + + infraction_counts.return_value = "basic infractions info" + + user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + infraction_counts.assert_called_once_with(user) + + self.assertEqual( + textwrap.dedent(f""" + **User Information** + Created: {"1 year ago"} + Profile: {user.mention} + ID: {user.id} + + **Member Information** + Joined: {"1 year ago"} + Roles: &Moderators + + basic infractions info + """).strip(), + embed.description + ) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): + """The embed should be created with the colour of the top role, if a top role is available.""" + ctx = helpers.MockContext() + + moderators_role = helpers.MockRole('Moderators') + moderators_role.colour = 100 + + user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): + """The embed should be created with a blurple colour if the user has no assigned roles.""" + ctx = helpers.MockContext() + + user = helpers.MockMember(user_id=217) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.colour, discord.Colour.blurple()) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): + """The embed thumbnail should be set to the user's avatar in `png` format.""" + ctx = helpers.MockContext() + + user = helpers.MockMember(user_id=217) + user.avatar_url_as.return_value = "avatar url" + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + user.avatar_url_as.assert_called_once_with(format="png") + self.assertEqual(embed.thumbnail.url, "avatar url") + + +@unittest.mock.patch("bot.cogs.information.constants") +class UserCommandTests(unittest.TestCase): + """Tests for the `!user` command.""" + + def setUp(self): + """Set up steps executed before each test is run.""" + self.bot = helpers.MockBot() + self.cog = information.Information(self.bot) + + self.moderator_role = helpers.MockRole("Moderators", role_id=2, position=10) + self.flautist_role = helpers.MockRole("Flautists", role_id=3, position=2) + self.bassist_role = helpers.MockRole("Bassists", role_id=4, position=3) + + self.author = helpers.MockMember(user_id=1, name="syntaxaire") + self.moderator = helpers.MockMember(user_id=2, name="riffautae", roles=[self.moderator_role]) + self.target = helpers.MockMember(user_id=3, name="__fluzz__") + + def test_regular_member_cannot_target_another_member(self, constants): + """A regular user should not be able to use `!user` targeting another user.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + + ctx = helpers.MockContext(author=self.author) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + + ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") + + def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): + """A regular user should not be able to use this command outside of bot-commands.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=100)) + + msg = "Sorry, but you may only use this command within <#50>." + with self.assertRaises(InChannelCheckFailure, msg=msg): + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): + """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + create_embed.assert_called_once_with(ctx, self.author) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): + """A user should target itself with `!user` when a `user` argument was not provided.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) + + create_embed.assert_called_once_with(ctx, self.author) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): + """Staff members should be able to bypass the bot-commands channel restriction.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot = 50 + + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(channel_id=200)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + create_embed.assert_called_once_with(ctx, self.moderator) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + def test_moderators_can_target_another_member(self, create_embed, constants): + """A moderator should be able to use `!user` targeting another user.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + constants.STAFF_ROLES = [self.moderator_role.id] + + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(channel_id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + + create_embed.assert_called_once_with(ctx, self.target) + ctx.send.assert_called_once() -- cgit v1.2.3 From eee3d54318b3edd5eeb7229f4175323b139e754a Mon Sep 17 00:00:00 2001 From: Dan Herrera Date: Thu, 31 Oct 2019 21:59:50 -0400 Subject: Use timedelta to correctly calculate next midnight --- bot/cogs/reddit.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index f947a7d78..0d06e9c26 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -2,7 +2,7 @@ import asyncio import logging import random import textwrap -from datetime import datetime +from datetime import datetime, timedelta from typing import List from discord import Colour, Embed, TextChannel @@ -130,7 +130,8 @@ class Reddit(Cog): """Post the top 5 posts daily, and the top 5 posts weekly.""" # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter now = datetime.utcnow() - midnight_tomorrow = now.replace(day=now.day + 1, hour=0, minute=0, second=0) + tomorrow = now + timedelta(days=1) + midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) seconds_until = (midnight_tomorrow - now).total_seconds() await asyncio.sleep(seconds_until) -- cgit v1.2.3 From d1b35becef79540954adec4078ce4a2a47b34cfa Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 1 Nov 2019 20:49:10 +0100 Subject: Write tests for `bot.utils`. Closes #604. --- tests/bot/test_utils.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/bot/test_utils.py diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py new file mode 100644 index 000000000..0a32b8411 --- /dev/null +++ b/tests/bot/test_utils.py @@ -0,0 +1,49 @@ +import unittest + +from bot import utils + + +class CaseInsensitiveDictTests(unittest.TestCase): + """Tests for the `CaseInsensitiveDict` container.""" + + def test_case_insensitive_key_access(self): + """Tests case insensitive key access and storage.""" + instance = utils.CaseInsensitiveDict() + + key = 'LEMON' + value = 'trees' + + instance[key] = value + self.assertIn(key, instance) + self.assertEqual(instance.get(key), value) + self.assertEqual(instance.pop(key), value) + + instance.setdefault(key, value) + del instance[key] + self.assertNotIn(key, instance) + + def test_initialization_from_kwargs(self): + """Tests creating the dictionary from keyword arguments.""" + instance = utils.CaseInsensitiveDict({'FOO': 'bar'}) + self.assertEqual(instance['foo'], 'bar') + + def test_update_from_other_mapping(self): + """Tests updating the dictionary from another mapping.""" + instance = utils.CaseInsensitiveDict() + instance.update({'FOO': 'bar'}) + self.assertEqual(instance['foo'], 'bar') + + +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 38d9e705c77003008f1562188cba079a4245061b Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 1 Nov 2019 23:01:45 +0100 Subject: Add unit test for links antispam rule --- tests/bot/rules/test_links.py | 93 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/bot/rules/test_links.py diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py new file mode 100644 index 000000000..f71a8e6bb --- /dev/null +++ b/tests/bot/rules/test_links.py @@ -0,0 +1,93 @@ +import unittest +from typing import List, NamedTuple, Tuple + +from bot.rules import links +from tests.helpers import async_test + + +class FakeMessage(NamedTuple): + author: str + content: str + + +class Case(NamedTuple): + recent_messages: List[FakeMessage] + relevant_messages: Tuple[FakeMessage] + culprit: Tuple[str] + total_links: int + + +def msg(author: str, total_links: int) -> FakeMessage: + """Makes a message with `total_links` links.""" + content = " ".join(["https://pydis.com"] * total_links) + return FakeMessage(author=author, content=content) + + +class LinksTests(unittest.TestCase): + """Tests applying the `links` rule.""" + + def setUp(self): + self.config = { + "max": 2, + "interval": 10 + } + + @async_test + async def test_links_within_limit(self): + """Messages with an allowed amount of links.""" + cases = ( + [msg("bob", 0)], + [msg("bob", 2)], + [msg("bob", 3)], + [msg("bob", 3), msg("alice", 3)] + ) + + for recent_messages in cases: + last_message = recent_messages[0] + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + config=self.config + ): + self.assertIsNone( + await links.apply(last_message, recent_messages, self.config) + ) + + @async_test + async def test_links_exceeding_limit(self): + """Messages with a a higher than allowed amount of links.""" + cases = ( + Case( + [msg("bob", 1), msg("bob", 2)], + (msg("bob", 1), msg("bob", 2)), + ("bob",), + 3 + ), + Case( + [msg("alice", 2), msg("bob", 3), msg("alice", 1)], + (msg("alice", 2), msg("alice", 1)), + ("alice",), + 3 + ) + ) + + for recent_messages, relevant_messages, culprit, total_links in cases: + last_message = recent_messages[0] + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + relevant_messages=relevant_messages, + culprit=culprit, + total_links=total_links, + config=self.config + ): + desired_output = ( + f"sent {total_links} links in {self.config['interval']}s", + culprit, + relevant_messages + ) + self.assertTupleEqual( + await links.apply(last_message, recent_messages, self.config), + desired_output + ) -- cgit v1.2.3 From a21670459953599c8f13286595520e033da4199a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 1 Nov 2019 23:24:20 +0100 Subject: Add two more test cases for links rule unit test --- tests/bot/rules/test_links.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index f71a8e6bb..f043495cf 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -39,6 +39,7 @@ class LinksTests(unittest.TestCase): [msg("bob", 0)], [msg("bob", 2)], [msg("bob", 3)], + [msg("bob", 1), msg("bob", 1)], [msg("bob", 3), msg("alice", 3)] ) @@ -63,6 +64,12 @@ class LinksTests(unittest.TestCase): ("bob",), 3 ), + Case( + [msg("alice", 1), msg("alice", 1), msg("alice", 1)], + (msg("alice", 1), msg("alice", 1), msg("alice", 1)), + ("alice",), + 3 + ), Case( [msg("alice", 2), msg("bob", 3), msg("alice", 1)], (msg("alice", 2), msg("alice", 1)), -- cgit v1.2.3 From 9e16b0cf17e93295167956d31ad9f92396ca3402 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 2 Nov 2019 01:19:07 +0100 Subject: Annotate unclear test cases with inline comments --- tests/bot/rules/test_links.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index f043495cf..a04b90c13 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -38,9 +38,9 @@ class LinksTests(unittest.TestCase): cases = ( [msg("bob", 0)], [msg("bob", 2)], - [msg("bob", 3)], + [msg("bob", 3)], # Filter only applies if len(messages_with_links) > 1 [msg("bob", 1), msg("bob", 1)], - [msg("bob", 3), msg("alice", 3)] + [msg("bob", 3), msg("alice", 3)] # Only messages from latest author count ) for recent_messages in cases: -- cgit v1.2.3 From f6ed29c8692759dc0c8b003ebf2d4ce3d5ca6ed9 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 2 Nov 2019 01:34:46 +0100 Subject: Adjust case to only test a single aspect --- tests/bot/rules/test_links.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index a04b90c13..95f107f91 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -40,7 +40,7 @@ class LinksTests(unittest.TestCase): [msg("bob", 2)], [msg("bob", 3)], # Filter only applies if len(messages_with_links) > 1 [msg("bob", 1), msg("bob", 1)], - [msg("bob", 3), msg("alice", 3)] # Only messages from latest author count + [msg("bob", 2), msg("alice", 2)] # Only messages from latest author count ) for recent_messages in cases: -- cgit v1.2.3 From 9e825ed657cebc9f47208af7dc5fa46f31d9ef41 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 2 Nov 2019 11:04:57 +0100 Subject: Use `casefold` in some cases. --- tests/bot/test_utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py index 0a32b8411..58ae2a81a 100644 --- a/tests/bot/test_utils.py +++ b/tests/bot/test_utils.py @@ -16,7 +16,10 @@ class CaseInsensitiveDictTests(unittest.TestCase): instance[key] = value self.assertIn(key, instance) self.assertEqual(instance.get(key), value) - self.assertEqual(instance.pop(key), value) + self.assertEqual(instance.get(key.casefold()), value) + self.assertEqual(instance.pop(key.casefold()), value) + self.assertNotIn(key, instance) + self.assertNotIn(key.casefold(), instance) instance.setdefault(key, value) del instance[key] -- cgit v1.2.3 From efe592cc0420f325ab266afc822b8d4b8135d467 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sat, 2 Nov 2019 17:26:50 +0100 Subject: Do not cut off description in code blocks --- bot/cogs/doc.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 8b81b3053..4a095fa51 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -284,7 +284,13 @@ class Doc(commands.Cog): if len(description) > 1000: shortened = description[:1000] last_paragraph_end = shortened.rfind('\n\n') - description = description[:last_paragraph_end] + f"... [read more]({permalink})" + description = description[:last_paragraph_end] + + # If there is an incomplete code block, cut it out + if description.count("```") % 2: + codeblock_start = description.rfind('```py') + description = description[:codeblock_start].rstrip() + description += f"... [read more]({permalink})" description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description) -- cgit v1.2.3 From 82e1f3764ba0d102ede007ba6352406cfe3fb82a Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sat, 2 Nov 2019 17:37:42 +0100 Subject: Get symbol description by searching for a dd tag instead of traversing the siblings --- bot/cogs/doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 4a095fa51..96f737c03 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -260,7 +260,7 @@ class Doc(commands.Cog): if tag not in UNWANTED_SIGNATURE_SYMBOLS: signature_buffer.append(tag.replace('\\', '')) signature = ''.join(signature_buffer) - description = str(symbol_heading.next_sibling.next_sibling).replace('¶', '') + description = str(symbol_heading.find_next_sibling("dd")).replace('¶', '') return signature, description -- cgit v1.2.3 From ae8c862a353ddc10593d36d557fc7215232baf5b Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sat, 2 Nov 2019 17:44:02 +0100 Subject: Get up to 3 signatures of a symbol --- bot/cogs/doc.py | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 96f737c03..2987f7245 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -36,7 +36,7 @@ NO_OVERRIDE_GROUPS = ( NO_OVERRIDE_PACKAGES = ( "Python", ) -UNWANTED_SIGNATURE_SYMBOLS = ('[source]', '¶') +UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") @@ -218,7 +218,7 @@ class Doc(commands.Cog): ] await asyncio.gather(*coros) - async def get_symbol_html(self, symbol: str) -> Optional[Tuple[str, str]]: + async def get_symbol_html(self, symbol: str) -> Optional[Tuple[list, str]]: """ Given a Python symbol, return its signature and description. @@ -239,7 +239,7 @@ class Doc(commands.Cog): symbol_id = url.split('#')[-1] soup = BeautifulSoup(html, 'lxml') symbol_heading = soup.find(id=symbol_id) - signature_buffer = [] + signatures = [] if symbol_heading is None: return None @@ -253,16 +253,14 @@ class Doc(commands.Cog): description = ''.join(str(paragraph) for paragraph in info_paragraphs).replace('¶', '') else: - # Traverse the tags of the signature header and ignore any - # unwanted symbols from it. Add all of it to a temporary buffer. - - for tag in symbol_heading.strings: - if tag not in UNWANTED_SIGNATURE_SYMBOLS: - signature_buffer.append(tag.replace('\\', '')) - signature = ''.join(signature_buffer) + # Get text of up to 3 signatures, remove unwanted symbols + for element in [symbol_heading] + symbol_heading.find_next_siblings("dt", limit=2): + signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) + if signature: + signatures.append(signature) description = str(symbol_heading.find_next_sibling("dd")).replace('¶', '') - return signature, description + return signatures, description @async_cache(arg_offset=1) async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]: @@ -275,7 +273,7 @@ class Doc(commands.Cog): if scraped_html is None: return None - signature = scraped_html[0] + signatures = scraped_html[0] permalink = self.inventories[symbol] description = markdownify(scraped_html[1]) @@ -294,18 +292,18 @@ class Doc(commands.Cog): description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description) - if signature is None: + if signatures is None: # If symbol is a module, don't show signature. embed_description = description - elif not signature: + elif not signatures: # It's some "meta-page", for example: # https://docs.djangoproject.com/en/dev/ref/views/#module-django.views embed_description = "This appears to be a generic page not tied to a specific symbol." else: - signature = textwrap.shorten(signature, 500) - embed_description = f"```py\n{signature}```{description}" + embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures) + embed_description += description embed = discord.Embed( title=f'`{symbol}`', -- cgit v1.2.3 From 1aed2e4f4996f5546652bbb26e8fbf403e28aac4 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sat, 2 Nov 2019 18:28:04 +0100 Subject: Improve module description searching --- bot/cogs/doc.py | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 2987f7245..30a14f26c 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -9,7 +9,7 @@ from typing import Any, Callable, Optional, Tuple import discord from bs4 import BeautifulSoup -from bs4.element import PageElement +from bs4.element import PageElement, Tag from discord.errors import NotFound from discord.ext import commands from markdownify import MarkdownConverter @@ -37,6 +37,16 @@ NO_OVERRIDE_PACKAGES = ( "Python", ) UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") +SEARCH_END_TAG_ATTRS = ( + "data", + "function", + "class", + "exception", + "seealso", + "section", + "rubric", + "sphinxsidebar", +) WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") @@ -245,12 +255,21 @@ class Doc(commands.Cog): return None if symbol_id == f"module-{symbol}": - # Get all paragraphs until the first div after the section div - # if searched symbol is a module. - trailing_div = symbol_heading.findNext("div") - info_paragraphs = trailing_div.find_previous_siblings("p")[::-1] - signature = None - description = ''.join(str(paragraph) for paragraph in info_paragraphs).replace('¶', '') + search_html = str(soup) + # Get page content from the module headerlink to the + # first tag that has its class in `SEARCH_END_TAG_ATTRS` + start_tag = symbol_heading.find("a", attrs={"class": "headerlink"}) + if start_tag is None: + return [], "" + + end_tag = start_tag.find_next(self._match_end_tag) + if end_tag is None: + return [], "" + + description_start_index = search_html.find(str(start_tag.parent)) + len(str(start_tag.parent)) + description_end_index = search_html.find(str(end_tag)) + description = search_html[description_start_index:description_end_index].replace('¶', '') + signatures = None else: # Get text of up to 3 signatures, remove unwanted symbols @@ -422,6 +441,15 @@ class Doc(commands.Cog): await self.refresh_inventory() await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") + @staticmethod + def _match_end_tag(tag: Tag) -> bool: + """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table.""" + for attr in SEARCH_END_TAG_ATTRS: + if attr in tag.get("class", ()): + return True + + return tag.name == "table" + def setup(bot: commands.Bot) -> None: """Doc cog load.""" -- cgit v1.2.3 From ea5b01d1369faae5485e8514fb38f7ca8d9a24cc Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Sun, 3 Nov 2019 17:54:24 +0700 Subject: Refactor Using ternary to avoid if else --- bot/cogs/moderation/modlog.py | 96 +++++++++++++++---------------------------- 1 file changed, 32 insertions(+), 64 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 88f2b6c67..347b820de 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -635,39 +635,23 @@ class ModLog(Cog, name="ModLog"): author = before.author channel = before.channel + channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" + + before_response = ( + f"**Author:** {author} (`{author.id}`)\n" + f"**Channel:** {channel_name} (`{channel.id}`)\n" + f"**Message ID:** `{before.id}`\n" + "\n" + f"{before.clean_content}" + ) - if channel.category: - before_response = ( - f"**Author:** {author} (`{author.id}`)\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{before.id}`\n" - "\n" - f"{before.clean_content}" - ) - - after_response = ( - f"**Author:** {author} (`{author.id}`)\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{before.id}`\n" - "\n" - f"{after.clean_content}" - ) - else: - before_response = ( - f"**Author:** {author} (`{author.id}`)\n" - f"**Channel:** #{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{before.id}`\n" - "\n" - f"{before.clean_content}" - ) - - after_response = ( - f"**Author:** {author} (`{author.id}`)\n" - f"**Channel:** #{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{before.id}`\n" - "\n" - f"{after.clean_content}" - ) + after_response = ( + f"**Author:** {author} (`{author.id}`)\n" + f"**Channel:** {channel_name} (`{channel.id}`)\n" + f"**Message ID:** `{before.id}`\n" + "\n" + f"{after.clean_content}" + ) if before.edited_at: # Message was previously edited, to assist with self-bot detection, use the edited_at @@ -718,39 +702,23 @@ class ModLog(Cog, name="ModLog"): author = message.author channel = message.channel + channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" + + before_response = ( + f"**Author:** {author} (`{author.id}`)\n" + f"**Channel:** {channel_name} (`{channel.id}`)\n" + f"**Message ID:** `{message.id}`\n" + "\n" + "This message was not cached, so the message content cannot be displayed." + ) - if channel.category: - before_response = ( - f"**Author:** {author} (`{author.id}`)\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - "This message was not cached, so the message content cannot be displayed." - ) - - after_response = ( - f"**Author:** {author} (`{author.id}`)\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - f"{message.clean_content}" - ) - else: - before_response = ( - f"**Author:** {author} (`{author.id}`)\n" - f"**Channel:** #{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - "This message was not cached, so the message content cannot be displayed." - ) - - after_response = ( - f"**Author:** {author} (`{author.id}`)\n" - f"**Channel:** #{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - f"{message.clean_content}" - ) + after_response = ( + f"**Author:** {author} (`{author.id}`)\n" + f"**Channel:** {channel_name} (`{channel.id}`)\n" + f"**Message ID:** `{message.id}`\n" + "\n" + f"{message.clean_content}" + ) await self.send_log_message( Icons.message_edit, Colour.blurple(), "Message edited (Before)", -- cgit v1.2.3 From 2c19c1c7e1a433c639b6fba8aa10ad744e3827db Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Sun, 3 Nov 2019 18:05:55 +0700 Subject: Merge before & after response, show only differences - Merged `before_response` and `after_response`. - Only show the differences between `before.clean_content` and `after.clean_content` - Included a `jump to message` link. --- bot/cogs/moderation/modlog.py | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 347b820de..92b399874 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -1,4 +1,6 @@ import asyncio +import difflib +import itertools import logging import typing as t from datetime import datetime @@ -637,20 +639,36 @@ class ModLog(Cog, name="ModLog"): channel = before.channel channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" - before_response = ( + _before = before.clean_content + _after = after.clean_content + groups = tuple((g[0], tuple(g[1])) + for g in itertools.groupby(difflib.ndiff(_before.split(), _after.split()), key=lambda s: s[0])) + + for index, (name, values) in enumerate(groups): + sub = ' '.join(s[2:].strip() for s in values) + if name == '-': + _before = _before.replace(sub, f"[{sub}](http://.z)") + elif name == '+': + _after = _after.replace(sub, f"[{sub}](http://.z)") + else: + if len(values) > 2: + new = (f"{values[0].strip() if index > 0 else ''}" + " ... " + f"{values[-1].strip() if index < len(groups) - 1 else ''}") + else: + new = sub + _before = _before.replace(sub, new) + _after = _after.replace(sub, new) + + response = ( f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{before.id}`\n" "\n" - f"{before.clean_content}" - ) - - after_response = ( - f"**Author:** {author} (`{author.id}`)\n" - f"**Channel:** {channel_name} (`{channel.id}`)\n" - f"**Message ID:** `{before.id}`\n" + f"**Before**:\n{_before}\n" + f"**After**:\n{_after}\n" "\n" - f"{after.clean_content}" + f"[jump to message]({after.jump_url})" ) if before.edited_at: @@ -667,15 +685,10 @@ class ModLog(Cog, name="ModLog"): footer = None await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (Before)", before_response, + Icons.message_edit, Colour.blurple(), "Message edited", response, channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer ) - await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (After)", after_response, - channel_id=Channels.message_log, timestamp_override=after.edited_at - ) - @Cog.listener() async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> None: """Log raw message edit event to message change log.""" -- cgit v1.2.3 From 3140b01bff9c4912b9f89589e3b3f200dbad99ee Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 3 Nov 2019 18:36:16 +0100 Subject: Handle exceptions when fetching inventories --- bot/cogs/doc.py | 88 +++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 31 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 30a14f26c..55b69e9a4 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -13,8 +13,9 @@ from bs4.element import PageElement, Tag from discord.errors import NotFound from discord.ext import commands from markdownify import MarkdownConverter -from requests import ConnectionError +from requests import ConnectTimeout, ConnectionError, HTTPError from sphinx.ext import intersphinx +from urllib3.exceptions import ProtocolError from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL @@ -36,6 +37,7 @@ NO_OVERRIDE_GROUPS = ( NO_OVERRIDE_PACKAGES = ( "Python", ) +FAILED_REQUEST_RETRY_AMOUNT = 3 UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") SEARCH_END_TAG_ATTRS = ( "data", @@ -173,36 +175,37 @@ class Doc(commands.Cog): """ self.base_urls[package_name] = base_url - fetch_func = functools.partial(intersphinx.fetch_inventory, config, '', inventory_url) - for group, value in (await self.bot.loop.run_in_executor(None, fetch_func)).items(): - # Each value has a bunch of information in the form - # `(package_name, version, relative_url, ???)`, and we only - # need the package_name and the relative documentation URL. - for symbol, (package_name, _, relative_doc_url, _) in value.items(): - absolute_doc_url = base_url + relative_doc_url - - if symbol in self.inventories: - # get `group_name` from _:group_name - group_name = group.split(":")[1] - if (group_name in NO_OVERRIDE_GROUPS - # check if any package from `NO_OVERRIDE_PACKAGES` - # is in base URL of the symbol that would be overridden - or any(package in self.inventories[symbol].split("/", 3)[2] - for package in NO_OVERRIDE_PACKAGES)): - - symbol = f"{group_name}.{symbol}" - # if renamed `symbol` was already exists, add library name in front - if symbol in self.renamed_symbols: - # split `package_name` because of packages like Pillow that have spaces in them - symbol = f"{package_name.split()[0]}.{symbol}" - - self.inventories[symbol] = absolute_doc_url - self.renamed_symbols.add(symbol) - continue - - self.inventories[symbol] = absolute_doc_url - - log.trace(f"Fetched inventory for {package_name}.") + package = await self._fetch_inventory(inventory_url, config) + if package: + for group, value in package.items(): + # Each value has a bunch of information in the form + # `(package_name, version, relative_url, ???)`, and we only + # need the package_name and the relative documentation URL. + for symbol, (package_name, _, relative_doc_url, _) in value.items(): + absolute_doc_url = base_url + relative_doc_url + + if symbol in self.inventories: + # get `group_name` from _:group_name + group_name = group.split(":")[1] + if (group_name in NO_OVERRIDE_GROUPS + # check if any package from `NO_OVERRIDE_PACKAGES` + # is in base URL of the symbol that would be overridden + or any(package in self.inventories[symbol].split("/", 3)[2] + for package in NO_OVERRIDE_PACKAGES)): + + symbol = f"{group_name}.{symbol}" + # if renamed `symbol` was already exists, add library name in front + if symbol in self.renamed_symbols: + # split `package_name` because of packages like Pillow that have spaces in them + symbol = f"{package_name.split()[0]}.{symbol}" + + self.inventories[symbol] = absolute_doc_url + self.renamed_symbols.add(symbol) + continue + + self.inventories[symbol] = absolute_doc_url + + log.trace(f"Fetched inventory for {package_name}.") async def refresh_inventory(self) -> None: """Refresh internal documentation inventory.""" @@ -441,6 +444,29 @@ class Doc(commands.Cog): await self.refresh_inventory() await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") + async def _fetch_inventory(self, inventory_url: str, config: SphinxConfiguration) -> Optional[dict]: + """Get and return inventory from `inventory_url`. If fetching fails, return None.""" + fetch_func = functools.partial(intersphinx.fetch_inventory, config, '', inventory_url) + for retry in range(1, FAILED_REQUEST_RETRY_AMOUNT+1): + try: + package = await self.bot.loop.run_in_executor(None, fetch_func) + except ConnectTimeout: + log.error(f"Fetching of inventory {inventory_url} timed out," + f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})") + except ProtocolError: + log.error(f"Connection lost while fetching inventory {inventory_url}," + f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})") + except HTTPError as e: + log.error(f"Fetching of inventory {inventory_url} failed with status code {e.response.status_code}.") + return None + except ConnectionError: + log.error(f"Couldn't establish connection to inventory {inventory_url}.") + return None + else: + return package + log.error(f"Fetching of inventory {inventory_url} failed.") + return None + @staticmethod def _match_end_tag(tag: Tag) -> bool: """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table.""" -- cgit v1.2.3 From 0572b18e1986eee91c014fb38626b2fbfd571a47 Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Sun, 3 Nov 2019 17:49:17 +0000 Subject: Show custom statuses in !user output Resolves #647 --- bot/cogs/information.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 3a7ba0444..01d1a0994 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -9,6 +9,7 @@ import discord from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils from discord.ext import commands from discord.ext.commands import Bot, BucketType, Cog, Context, command, group +from discord.utils import escape_markdown from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES from bot.decorators import InChannelCheckFailure, in_channel, with_role @@ -180,6 +181,13 @@ class Information(Cog): # User information created = time_since(user.created_at, max_units=3) + # Custom status + custom_status = '' + for activity in user.activities: + if activity.name == 'Custom Status': + state = escape_markdown(activity.state) + custom_status = f'Status: {state}\n' + name = str(user) if user.nick: name = f"{user.nick} ({name})" @@ -217,7 +225,7 @@ class Information(Cog): Created: {created} Profile: {user.mention} ID: {user.id} - + {custom_status} **Member Information** Joined: {joined} Roles: {roles or None} -- cgit v1.2.3 From a8475f5fedb91c9e0f1c5c28c7d64aebbbef64f4 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 3 Nov 2019 20:06:15 +0100 Subject: Fix case for the python package name in `NO_OVERRIDE_PACKAGES` --- bot/cogs/doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 55b69e9a4..563f83040 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -35,7 +35,7 @@ NO_OVERRIDE_GROUPS = ( "term", ) NO_OVERRIDE_PACKAGES = ( - "Python", + "python", ) FAILED_REQUEST_RETRY_AMOUNT = 3 UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") -- cgit v1.2.3 From 1b0a8c8109240615e5d9309937a434e1d29bcf24 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 3 Nov 2019 20:06:41 +0100 Subject: Comment grammar --- bot/cogs/doc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 563f83040..934cb2a6d 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -194,9 +194,9 @@ class Doc(commands.Cog): for package in NO_OVERRIDE_PACKAGES)): symbol = f"{group_name}.{symbol}" - # if renamed `symbol` was already exists, add library name in front + # If renamed `symbol` already exists, add library name in front. if symbol in self.renamed_symbols: - # split `package_name` because of packages like Pillow that have spaces in them + # Split `package_name` because of packages like Pillow that have spaces in them. symbol = f"{package_name.split()[0]}.{symbol}" self.inventories[symbol] = absolute_doc_url -- cgit v1.2.3 From 254dfbb616651f875936598c9884761921de7b76 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 3 Nov 2019 20:28:07 +0100 Subject: Make sure only signatures belonging to the symbol are fetched --- bot/cogs/doc.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 934cb2a6d..dcbcfe3ad 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -253,12 +253,12 @@ class Doc(commands.Cog): soup = BeautifulSoup(html, 'lxml') symbol_heading = soup.find(id=symbol_id) signatures = [] + search_html = str(soup) if symbol_heading is None: return None if symbol_id == f"module-{symbol}": - search_html = str(soup) # Get page content from the module headerlink to the # first tag that has its class in `SEARCH_END_TAG_ATTRS` start_tag = symbol_heading.find("a", attrs={"class": "headerlink"}) @@ -275,12 +275,13 @@ class Doc(commands.Cog): signatures = None else: + description = str(symbol_heading.find_next_sibling("dd")).replace('¶', '') + description_pos = search_html.find(description) # Get text of up to 3 signatures, remove unwanted symbols for element in [symbol_heading] + symbol_heading.find_next_siblings("dt", limit=2): signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) - if signature: + if signature and search_html.find(signature) < description_pos: signatures.append(signature) - description = str(symbol_heading.find_next_sibling("dd")).replace('¶', '') return signatures, description -- cgit v1.2.3 From acb937dc24b30c84b4978f651a642208f562c36e Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 3 Nov 2019 22:34:16 +0100 Subject: Test is_staff and has_green_checkmark. --- tests/bot/cogs/test_duck_pond.py | 52 +++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 79f11843b..31c7e9f89 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -1,35 +1,53 @@ import logging import unittest -from unittest.mock import MagicMock from bot.cogs import duck_pond -from tests.helpers import MockBot, MockMessage +from tests.helpers import MockBot, MockMember, MockMessage, MockReaction, MockRole class DuckPondTest(unittest.TestCase): """Tests the `DuckPond` cog.""" def setUp(self): - """Adds the cog, a bot, and a message to the instance for usage in tests.""" + """Adds the cog, a bot, and the mocks we'll need for our tests.""" self.bot = MockBot() self.cog = duck_pond.DuckPond(bot=self.bot) - self.msg = MockMessage(message_id=555, content='') - self.msg.author.__str__ = MagicMock() - self.msg.author.__str__.return_value = 'lemon' - self.msg.author.bot = False - self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' - self.msg.author.id = 42 - self.msg.author.mention = '@lemon' - self.msg.channel.mention = "#lemonade-stand" + # Set up some roles + self.admin_role = MockRole(name="Admins", role_id=476190234653229056) + self.contrib_role = MockRole(name="Contributor", role_id=476190302659543061) - def test_is_staff_correctly_identifies_staff(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + # Set up some users + self.admin_member = MockMember(roles=(self.admin_role,)) + self.contrib_member = MockMember(roles=(self.contrib_role,)) + self.no_role_member = MockMember() - def test_has_green_checkmark(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + # Set up emojis + self.checkmark_emoji = "✅" + self.thumbs_up_emoji = "👍" + + # Set up reactions + self.checkmark_reaction = MockReaction(emoji=self.checkmark_emoji) + self.thumbs_up_reaction = MockReaction(emoji=self.thumbs_up_emoji) + + # Set up a messages + self.checkmark_message = MockMessage(reactions=(self.checkmark_reaction,)) + self.thumbs_up_message = MockMessage(reactions=(self.thumbs_up_reaction,)) + self.no_reaction_message = MockMessage() + + def test_is_staff_correctly_identifies_staff(self): + """Test that is_staff correctly identifies a staff member.""" + with self.subTest(): + self.assertTrue(duck_pond.DuckPond.is_staff(self.admin_member)) + self.assertFalse(duck_pond.DuckPond.is_staff(self.contrib_member)) + self.assertFalse(duck_pond.DuckPond.is_staff(self.no_role_member)) + + def test_has_green_checkmark_correctly_identifies_messages(self): + """Test that has_green_checkmark recognizes messages with checkmarks.""" + with self.subTest(): + self.assertTrue(duck_pond.DuckPond.has_green_checkmark(self.checkmark_message)) + self.assertFalse(duck_pond.DuckPond.has_green_checkmark(self.thumbs_up_message)) + self.assertFalse(duck_pond.DuckPond.has_green_checkmark(self.no_reaction_message)) def test_count_custom_duck_emojis(self): """A string decoding to numeric characters is a valid user ID.""" -- cgit v1.2.3 From 7fdb51b8cef9a8d7d2edf48bb2724cf9348a0f93 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 4 Nov 2019 12:15:52 +0800 Subject: Change invite filter message to accurately reflect the new rules --- bot/cogs/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index be9b95bc7..4195783f1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -63,7 +63,7 @@ class Filtering(Cog): "content_only": True, "user_notification": Filter.notify_user_invites, "notification_msg": ( - f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n" + f"Per Rule 6, your invite link has been removed. {_staff_mistake_str}\n\n" r"Our server rules can be found here: " ) }, -- cgit v1.2.3 From 4dccacaa5794751546336de1aa42cd5a9095b706 Mon Sep 17 00:00:00 2001 From: Derek Date: Mon, 4 Nov 2019 17:48:48 -0500 Subject: Reword periodic #checkpoint message --- bot/cogs/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 5b115deaa..3d85edae6 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -31,7 +31,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" Ping <@&{Roles.admin}> if you encounter any problems during the verification process." + f" If you encounter any problems during the verification process, ping the <@&{Roles.admin}> role in this channel." ) -- cgit v1.2.3 From 071bc9d775a48750cc8d44236523c7ec5a30f7f9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Oct 2019 17:29:28 -0700 Subject: Add logging for moderation functions --- bot/cogs/moderation/scheduler.py | 51 ++++++++++++++++++++++++++++++++++--- bot/cogs/moderation/superstarify.py | 21 +++++++++++---- bot/cogs/moderation/utils.py | 12 +++++++++ bot/utils/scheduling.py | 6 ++++- 4 files changed, 81 insertions(+), 9 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 7990df226..7a08fc236 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -39,6 +39,8 @@ class InfractionScheduler(Scheduler): """Schedule expiration for previous infractions.""" await self.bot.wait_until_ready() + log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") + infractions = await self.bot.api_client.get( 'bot/infractions', params={'active': 'true'} @@ -59,6 +61,10 @@ class InfractionScheduler(Scheduler): # Mark as inactive if less than a minute remains. if delta < 60: + log.info( + "Infraction will be deactivated instead of re-applied " + "because less than 1 minute remains." + ) await self.deactivate_infraction(infraction) return @@ -78,6 +84,9 @@ class InfractionScheduler(Scheduler): icon = utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] expiry = infraction["expires_at"] + _id = infraction['id'] + + log.trace(f"Applying {infr_type} infraction #{_id} to {user}.") if expiry: expiry = time.format_infraction(expiry) @@ -111,10 +120,20 @@ class InfractionScheduler(Scheduler): log_content = ctx.author.mention if infraction["actor"] == self.bot.user.id: + log.trace( + f"Infraction #{_id} actor is bot; including the reason in the confirmation message." + ) + end_msg = f" (reason: {infraction['reason']})" elif ctx.channel.id not in STAFF_CHANNELS: + log.trace( + f"Infraction #{_id} context is not in a staff channel; omitting infraction count." + ) + end_msg = "" else: + log.trace(f"Fetching total infraction count for {user}.") + infractions = await self.bot.api_client.get( "bot/infractions", params={"user__id": str(user.id)} @@ -124,6 +143,7 @@ class InfractionScheduler(Scheduler): # Execute the necessary actions to apply the infraction on Discord. if action_coro: + log.trace(f"Awaiting the infraction #{_id} application action coroutine.") try: await action_coro if expiry: @@ -136,12 +156,16 @@ class InfractionScheduler(Scheduler): log_content = ctx.author.mention log_title = "failed to apply" + log.warning(f"Failed to apply {infr_type} infraction #{_id} to {user}.") + # Send a confirmation message to the invoking context. + log.trace(f"Sending infraction #{_id} confirmation message.") await ctx.send( f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{end_msg}." ) # Send a log message to the mod log. + log.trace(f"Sending apply mod log for infraction #{_id}.") await self.mod_log.send_log_message( icon_url=icon, colour=Colours.soft_red, @@ -157,9 +181,14 @@ class InfractionScheduler(Scheduler): footer=f"ID {infraction['id']}" ) + log.info(f"Applied {infr_type} infraction #{_id} to {user}.") + async def pardon_infraction(self, ctx: Context, infr_type: str, user: MemberObject) -> None: """Prematurely end an infraction for a user and log the action in the mod log.""" + log.trace(f"Pardoning {infr_type} infraction for {user}.") + # Check the current active infraction + log.trace(f"Fetching active {infr_type} infractions for {user}.") response = await self.bot.api_client.get( 'bot/infractions', params={ @@ -170,6 +199,7 @@ class InfractionScheduler(Scheduler): ) if not response: + log.debug(f"No active {infr_type} infraction found for {user}.") await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.") return @@ -179,12 +209,16 @@ class InfractionScheduler(Scheduler): log_text["Member"] = f"{user.mention}(`{user.id}`)" log_text["Actor"] = str(ctx.message.author) log_content = None - footer = f"ID: {response[0]['id']}" + _id = response[0]['id'] + footer = f"ID: {_id}" # If multiple active infractions were found, mark them as inactive in the database # and cancel their expiration tasks. if len(response) > 1: - log.warning(f"Found more than one active {infr_type} infraction for user {user.id}") + log.warning( + f"Found more than one active {infr_type} infraction for user {user.id}; " + "deactivating the extra active infractions too." + ) footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" @@ -227,11 +261,16 @@ class InfractionScheduler(Scheduler): confirm_msg = ":x: failed to pardon" log_title = "pardon failed" log_content = ctx.author.mention + + log.warning(f"Failed to pardon {infr_type} infraction #{_id} for {user}.") else: confirm_msg = f":ok_hand: pardoned" log_title = "pardoned" + log.info(f"Pardoned {infr_type} infraction #{_id} for {user}.") + # Send a confirmation message to the invoking context. + log.trace(f"Sending infraction #{_id} pardon confirmation message.") await ctx.send( f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " f"{log_text.get('Failure', '')}" @@ -268,7 +307,7 @@ class InfractionScheduler(Scheduler): _type = infraction["type"] _id = infraction["id"] - log.debug(f"Marking infraction #{_id} as inactive (expired).") + log.info(f"Marking infraction #{_id} as inactive (expired).") log_content = None log_text = { @@ -278,7 +317,9 @@ class InfractionScheduler(Scheduler): } try: + log.trace("Awaiting the pardon action coroutine.") returned_log = await self._pardon_action(infraction) + if returned_log is not None: log_text = {**log_text, **returned_log} # Merge the logs together else: @@ -296,6 +337,8 @@ class InfractionScheduler(Scheduler): # Check if the user is currently being watched by Big Brother. try: + log.trace(f"Determining if user {user_id} is currently being watched by Big Brother.") + active_watch = await self.bot.api_client.get( "bot/infractions", params={ @@ -312,6 +355,7 @@ class InfractionScheduler(Scheduler): try: # Mark infraction as inactive in the database. + log.trace(f"Marking infraction #{_id} as inactive in the database.") await self.bot.api_client.patch( f"bot/infractions/{_id}", json={"active": False} @@ -335,6 +379,7 @@ class InfractionScheduler(Scheduler): if send_log: log_title = f"expiration failed" if "Failure" in log_text else "expired" + log.trace(f"Sending deactivation mod log for infraction #{_id}.") await self.mod_log.send_log_message( icon_url=utils.INFRACTION_ICONS[_type][1], colour=Colours.soft_green, diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index c66222e5a..9ab870823 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -34,8 +34,8 @@ class Superstarify(InfractionScheduler, Cog): return # User didn't change their nickname. Abort! log.trace( - f"{before.display_name} is trying to change their nickname to {after.display_name}. " - "Checking if the user is in superstar-prison..." + f"{before} ({before.display_name}) is trying to change their nickname to " + f"{after.display_name}. Checking if the user is in superstar-prison..." ) active_superstarifies = await self.bot.api_client.get( @@ -48,6 +48,7 @@ class Superstarify(InfractionScheduler, Cog): ) if not active_superstarifies: + log.trace(f"{before} has no active superstar infractions.") return infraction = active_superstarifies[0] @@ -132,15 +133,17 @@ class Superstarify(InfractionScheduler, Cog): # Post the infraction to the API reason = reason or f"old nick: {member.display_name}" infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration) + _id = infraction["id"] old_nick = member.display_name - forced_nick = self.get_nick(infraction["id"], member.id) + forced_nick = self.get_nick(_id, member.id) expiry_str = format_infraction(infraction["expires_at"]) # Apply the infraction and schedule the expiration task. + log.debug(f"Changing nickname of {member} to {forced_nick}.") self.mod_log.ignore(constants.Event.member_update, member.id) await member.edit(nick=forced_nick, reason=reason) - self.schedule_task(ctx.bot.loop, infraction["id"], infraction) + self.schedule_task(ctx.bot.loop, _id, infraction) # Send a DM to the user to notify them of their new infraction. await utils.notify_infraction( @@ -152,6 +155,7 @@ class Superstarify(InfractionScheduler, Cog): ) # Send an embed with the infraction information to the invoking context. + log.trace(f"Sending superstar #{_id} embed.") embed = Embed( title="Congratulations!", colour=constants.Colours.soft_orange, @@ -167,6 +171,7 @@ class Superstarify(InfractionScheduler, Cog): await ctx.send(embed=embed) # Log to the mod log channel. + log.trace(f"Sending apply mod log for superstar #{_id}.") await self.mod_log.send_log_message( icon_url=utils.INFRACTION_ICONS["superstar"][0], colour=Colour.gold(), @@ -180,7 +185,7 @@ class Superstarify(InfractionScheduler, Cog): Old nickname: `{old_nick}` New nickname: `{forced_nick}` """), - footer=f"ID {infraction['id']}" + footer=f"ID {_id}" ) @command(name="unsuperstarify", aliases=("release_nick", "unstar")) @@ -198,6 +203,10 @@ class Superstarify(InfractionScheduler, Cog): # Don't bother sending a notification if the user left the guild. if not user: + log.debug( + "User left the guild and therefore won't be notified about superstar " + f"{infraction['id']} pardon." + ) return {} # DM the user about the expiration. @@ -216,6 +225,8 @@ class Superstarify(InfractionScheduler, Cog): @staticmethod def get_nick(infraction_id: int, member_id: int) -> str: """Randomly select a nickname from the Superstarify nickname list.""" + log.trace(f"Choosing a random nickname for superstar #{infraction_id}.") + rng = random.Random(str(infraction_id) + str(member_id)) return rng.choice(STAR_NAMES) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 9179c0afb..325b9567a 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -37,6 +37,8 @@ def proxy_user(user_id: str) -> discord.Object: Used when a Member or User object cannot be resolved. """ + log.trace(f"Attempting to create a proxy user for the user id {user_id}.") + try: user_id = int(user_id) except ValueError: @@ -59,6 +61,8 @@ async def post_infraction( active: bool = True, ) -> t.Optional[dict]: """Posts an infraction to the API.""" + log.trace(f"Posting {infr_type} infraction for {user} to the API.") + payload = { "actor": ctx.message.author.id, "hidden": hidden, @@ -92,6 +96,8 @@ async def post_infraction( async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str) -> bool: """Checks if a user already has an active infraction of the given type.""" + log.trace(f"Checking if {user} has active infractions of type {infr_type}.") + active_infractions = await ctx.bot.api_client.get( 'bot/infractions', params={ @@ -101,12 +107,14 @@ async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str } ) if active_infractions: + log.trace(f"{user} has active infractions of type {infr_type}.") await ctx.send( f":x: According to my records, this user already has a {infr_type} infraction. " f"See infraction **#{active_infractions[0]['id']}**." ) return True else: + log.trace(f"{user} does not have active infractions of type {infr_type}.") return False @@ -118,6 +126,8 @@ async def notify_infraction( icon_url: str = Icons.token_removed ) -> bool: """DM a user about their new infraction and return True if the DM is successful.""" + log.trace(f"Sending {user} a DM about their {infr_type} infraction.") + embed = discord.Embed( description=textwrap.dedent(f""" **Type:** {infr_type.capitalize()} @@ -146,6 +156,8 @@ async def notify_pardon( icon_url: str = Icons.user_verified ) -> bool: """DM a user about their pardoned infraction and return True if the DM is successful.""" + log.trace(f"Sending {user} a DM about their pardoned infraction.") + embed = discord.Embed( description=content, colour=Colours.soft_green diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 08abd91d7..ee6c0a8e6 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -36,11 +36,15 @@ class Scheduler(metaclass=CogABCMeta): `task_data` is passed to `Scheduler._scheduled_expiration` """ if task_id in self.scheduled_tasks: + log.debug( + f"{self.cog_name}: did not schedule task #{task_id}; task was already scheduled." + ) return task: asyncio.Task = create_task(loop, self._scheduled_task(task_data)) self.scheduled_tasks[task_id] = task + log.debug(f"{self.cog_name}: scheduled task #{task_id}.") def cancel_task(self, task_id: str) -> None: """Un-schedules a task.""" @@ -51,7 +55,7 @@ class Scheduler(metaclass=CogABCMeta): return task.cancel() - log.debug(f"{self.cog_name}: Unscheduled {task_id}.") + log.debug(f"{self.cog_name}: unscheduled task #{task_id}.") del self.scheduled_tasks[task_id] -- cgit v1.2.3 From 818e48baec3564e73cc223eb91fbbfe768d73c67 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 31 Oct 2019 14:26:25 -0700 Subject: Moderation: use trailing _ instead of leading for variable names PEP 8 states the convention is a trailing underscore when used to prevent name conflicts. --- bot/cogs/moderation/scheduler.py | 54 ++++++++++++++++++------------------- bot/cogs/moderation/superstarify.py | 12 ++++----- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 7a08fc236..462c7fc7f 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -84,9 +84,9 @@ class InfractionScheduler(Scheduler): icon = utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] expiry = infraction["expires_at"] - _id = infraction['id'] + id_ = infraction['id'] - log.trace(f"Applying {infr_type} infraction #{_id} to {user}.") + log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") if expiry: expiry = time.format_infraction(expiry) @@ -121,13 +121,13 @@ class InfractionScheduler(Scheduler): if infraction["actor"] == self.bot.user.id: log.trace( - f"Infraction #{_id} actor is bot; including the reason in the confirmation message." + f"Infraction #{id_} actor is bot; including the reason in the confirmation message." ) end_msg = f" (reason: {infraction['reason']})" elif ctx.channel.id not in STAFF_CHANNELS: log.trace( - f"Infraction #{_id} context is not in a staff channel; omitting infraction count." + f"Infraction #{id_} context is not in a staff channel; omitting infraction count." ) end_msg = "" @@ -143,7 +143,7 @@ class InfractionScheduler(Scheduler): # Execute the necessary actions to apply the infraction on Discord. if action_coro: - log.trace(f"Awaiting the infraction #{_id} application action coroutine.") + log.trace(f"Awaiting the infraction #{id_} application action coroutine.") try: await action_coro if expiry: @@ -156,16 +156,16 @@ class InfractionScheduler(Scheduler): log_content = ctx.author.mention log_title = "failed to apply" - log.warning(f"Failed to apply {infr_type} infraction #{_id} to {user}.") + log.warning(f"Failed to apply {infr_type} infraction #{id_} to {user}.") # Send a confirmation message to the invoking context. - log.trace(f"Sending infraction #{_id} confirmation message.") + log.trace(f"Sending infraction #{id_} confirmation message.") await ctx.send( f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{end_msg}." ) # Send a log message to the mod log. - log.trace(f"Sending apply mod log for infraction #{_id}.") + log.trace(f"Sending apply mod log for infraction #{id_}.") await self.mod_log.send_log_message( icon_url=icon, colour=Colours.soft_red, @@ -181,7 +181,7 @@ class InfractionScheduler(Scheduler): footer=f"ID {infraction['id']}" ) - log.info(f"Applied {infr_type} infraction #{_id} to {user}.") + log.info(f"Applied {infr_type} infraction #{id_} to {user}.") async def pardon_infraction(self, ctx: Context, infr_type: str, user: MemberObject) -> None: """Prematurely end an infraction for a user and log the action in the mod log.""" @@ -209,8 +209,8 @@ class InfractionScheduler(Scheduler): log_text["Member"] = f"{user.mention}(`{user.id}`)" log_text["Actor"] = str(ctx.message.author) log_content = None - _id = response[0]['id'] - footer = f"ID: {_id}" + id_ = response[0]['id'] + footer = f"ID: {id_}" # If multiple active infractions were found, mark them as inactive in the database # and cancel their expiration tasks. @@ -232,15 +232,15 @@ class InfractionScheduler(Scheduler): # 1. Discord cannot store multiple active bans or assign multiples of the same role # 2. It would send a pardon DM for each active infraction, which is redundant for infraction in response[1:]: - _id = infraction['id'] + id_ = infraction['id'] try: # Mark infraction as inactive in the database. await self.bot.api_client.patch( - f"bot/infractions/{_id}", + f"bot/infractions/{id_}", json={"active": False} ) except ResponseCodeError: - log.exception(f"Failed to deactivate infraction #{_id} ({infr_type})") + log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})") # This is simpler and cleaner than trying to concatenate all the errors. log_text["Failure"] = "See bot's logs for details." @@ -262,15 +262,15 @@ class InfractionScheduler(Scheduler): log_title = "pardon failed" log_content = ctx.author.mention - log.warning(f"Failed to pardon {infr_type} infraction #{_id} for {user}.") + log.warning(f"Failed to pardon {infr_type} infraction #{id_} for {user}.") else: confirm_msg = f":ok_hand: pardoned" log_title = "pardoned" - log.info(f"Pardoned {infr_type} infraction #{_id} for {user}.") + log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.") # Send a confirmation message to the invoking context. - log.trace(f"Sending infraction #{_id} pardon confirmation message.") + log.trace(f"Sending infraction #{id_} pardon confirmation message.") await ctx.send( f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " f"{log_text.get('Failure', '')}" @@ -305,9 +305,9 @@ class InfractionScheduler(Scheduler): mod_role = guild.get_role(constants.Roles.moderator) user_id = infraction["user"] _type = infraction["type"] - _id = infraction["id"] + id_ = infraction["id"] - log.info(f"Marking infraction #{_id} as inactive (expired).") + log.info(f"Marking infraction #{id_} as inactive (expired).") log_content = None log_text = { @@ -324,14 +324,14 @@ class InfractionScheduler(Scheduler): log_text = {**log_text, **returned_log} # Merge the logs together else: raise ValueError( - f"Attempted to deactivate an unsupported infraction #{_id} ({_type})!" + f"Attempted to deactivate an unsupported infraction #{id_} ({_type})!" ) except discord.Forbidden: - log.warning(f"Failed to deactivate infraction #{_id} ({_type}): bot lacks permissions") + log.warning(f"Failed to deactivate infraction #{id_} ({_type}): bot lacks permissions") log_text["Failure"] = f"The bot lacks permissions to do this (role hierarchy?)" log_content = mod_role.mention except discord.HTTPException as e: - log.exception(f"Failed to deactivate infraction #{_id} ({_type})") + log.exception(f"Failed to deactivate infraction #{id_} ({_type})") log_text["Failure"] = f"HTTPException with code {e.code}." log_content = mod_role.mention @@ -355,13 +355,13 @@ class InfractionScheduler(Scheduler): try: # Mark infraction as inactive in the database. - log.trace(f"Marking infraction #{_id} as inactive in the database.") + log.trace(f"Marking infraction #{id_} as inactive in the database.") await self.bot.api_client.patch( - f"bot/infractions/{_id}", + f"bot/infractions/{id_}", json={"active": False} ) except ResponseCodeError as e: - log.exception(f"Failed to deactivate infraction #{_id} ({_type})") + log.exception(f"Failed to deactivate infraction #{id_} ({_type})") log_line = f"API request failed with code {e.status}." log_content = mod_role.mention @@ -379,13 +379,13 @@ class InfractionScheduler(Scheduler): if send_log: log_title = f"expiration failed" if "Failure" in log_text else "expired" - log.trace(f"Sending deactivation mod log for infraction #{_id}.") + log.trace(f"Sending deactivation mod log for infraction #{id_}.") await self.mod_log.send_log_message( icon_url=utils.INFRACTION_ICONS[_type][1], colour=Colours.soft_green, title=f"Infraction {log_title}: {_type}", text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=f"ID: {_id}", + footer=f"ID: {id_}", content=log_content, ) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 9ab870823..9b3c62403 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -133,17 +133,17 @@ class Superstarify(InfractionScheduler, Cog): # Post the infraction to the API reason = reason or f"old nick: {member.display_name}" infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration) - _id = infraction["id"] + id_ = infraction["id"] old_nick = member.display_name - forced_nick = self.get_nick(_id, member.id) + forced_nick = self.get_nick(id_, member.id) expiry_str = format_infraction(infraction["expires_at"]) # Apply the infraction and schedule the expiration task. log.debug(f"Changing nickname of {member} to {forced_nick}.") self.mod_log.ignore(constants.Event.member_update, member.id) await member.edit(nick=forced_nick, reason=reason) - self.schedule_task(ctx.bot.loop, _id, infraction) + self.schedule_task(ctx.bot.loop, id_, infraction) # Send a DM to the user to notify them of their new infraction. await utils.notify_infraction( @@ -155,7 +155,7 @@ class Superstarify(InfractionScheduler, Cog): ) # Send an embed with the infraction information to the invoking context. - log.trace(f"Sending superstar #{_id} embed.") + log.trace(f"Sending superstar #{id_} embed.") embed = Embed( title="Congratulations!", colour=constants.Colours.soft_orange, @@ -171,7 +171,7 @@ class Superstarify(InfractionScheduler, Cog): await ctx.send(embed=embed) # Log to the mod log channel. - log.trace(f"Sending apply mod log for superstar #{_id}.") + log.trace(f"Sending apply mod log for superstar #{id_}.") await self.mod_log.send_log_message( icon_url=utils.INFRACTION_ICONS["superstar"][0], colour=Colour.gold(), @@ -185,7 +185,7 @@ class Superstarify(InfractionScheduler, Cog): Old nickname: `{old_nick}` New nickname: `{forced_nick}` """), - footer=f"ID {_id}" + footer=f"ID {id_}" ) @command(name="unsuperstarify", aliases=("release_nick", "unstar")) -- cgit v1.2.3 From e90b47e37587fedde765cf78bf27eca15202314d Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Tue, 5 Nov 2019 10:27:27 +0700 Subject: un-monstrosify code ... I think? --- bot/cogs/moderation/modlog.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 92b399874..53ea4ebcb 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -641,20 +641,26 @@ class ModLog(Cog, name="ModLog"): _before = before.clean_content _after = after.clean_content - groups = tuple((g[0], tuple(g[1])) - for g in itertools.groupby(difflib.ndiff(_before.split(), _after.split()), key=lambda s: s[0])) - for index, (name, values) in enumerate(groups): - sub = ' '.join(s[2:].strip() for s in values) - if name == '-': + # Getting the difference per words and group them by type - add, remove, same + # Note that this is intended grouping without sorting + diff = difflib.ndiff(_before.split(), _after.split()) + diff_groups = tuple( + (diff_type, tuple(s[2:] for s in diff_words)) + for diff_type, diff_words in itertools.groupby(diff, key=lambda s: s[0]) + ) + + for index, (diff_type, words) in enumerate(diff_groups): + sub = ' '.join(words) + if diff_type == '-': _before = _before.replace(sub, f"[{sub}](http://.z)") - elif name == '+': + elif diff_type == '+': _after = _after.replace(sub, f"[{sub}](http://.z)") else: - if len(values) > 2: - new = (f"{values[0].strip() if index > 0 else ''}" + if len(words) > 2: + new = (f"{words[0] if index > 0 else ''}" " ... " - f"{values[-1].strip() if index < len(groups) - 1 else ''}") + f"{words[-1] if index < len(diff_groups) - 1 else ''}") else: new = sub _before = _before.replace(sub, new) -- cgit v1.2.3 From 7d10fcafa8302b137733d4cf84ffc15fe1a8f219 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Tue, 5 Nov 2019 10:29:54 +0700 Subject: remove unneccessary else --- bot/cogs/moderation/modlog.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 53ea4ebcb..4fbe39d7f 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -661,10 +661,8 @@ class ModLog(Cog, name="ModLog"): new = (f"{words[0] if index > 0 else ''}" " ... " f"{words[-1] if index < len(diff_groups) - 1 else ''}") - else: - new = sub - _before = _before.replace(sub, new) - _after = _after.replace(sub, new) + _before = _before.replace(sub, new) + _after = _after.replace(sub, new) response = ( f"**Author:** {author} (`{author.id}`)\n" -- cgit v1.2.3 From 990e216ea7cbb0acf7df1a6805c62d6243897a90 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Tue, 5 Nov 2019 23:59:19 +0700 Subject: Changed link used in hyperlink - A simple `http://.z` will show properly for PC client, but for android it completely broke -> changed to `http://o.hi` - minimum link to make discord think it's a link. --- bot/cogs/moderation/modlog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 4fbe39d7f..6ce83840d 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -653,9 +653,9 @@ class ModLog(Cog, name="ModLog"): for index, (diff_type, words) in enumerate(diff_groups): sub = ' '.join(words) if diff_type == '-': - _before = _before.replace(sub, f"[{sub}](http://.z)") + _before = _before.replace(sub, f"[{sub}](http://o.hi)") elif diff_type == '+': - _after = _after.replace(sub, f"[{sub}](http://.z)") + _after = _after.replace(sub, f"[{sub}](http://o.hi)") else: if len(words) > 2: new = (f"{words[0] if index > 0 else ''}" -- cgit v1.2.3 From 80967822c06f9ece1ad6989bd9448464dea73ece Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 6 Nov 2019 09:18:35 +0700 Subject: Merged `else` and its single `if`, changed style to be more consistent Following Mark's reviews: - The single `if` inside the `else` can be merged with its `else` - this will reduce the level of complexity and indentation. - Changed from style ```py new = ('hello' 'world') ``` to ```py new = ( 'hello' 'world' ) ``` to be more consistent with the rest of the code --- bot/cogs/moderation/modlog.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 6ce83840d..c86bf6faa 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -656,13 +656,14 @@ class ModLog(Cog, name="ModLog"): _before = _before.replace(sub, f"[{sub}](http://o.hi)") elif diff_type == '+': _after = _after.replace(sub, f"[{sub}](http://o.hi)") - else: - if len(words) > 2: - new = (f"{words[0] if index > 0 else ''}" - " ... " - f"{words[-1] if index < len(diff_groups) - 1 else ''}") - _before = _before.replace(sub, new) - _after = _after.replace(sub, new) + elif len(words) > 2: + new = ( + f"{words[0] if index > 0 else ''}" + " ... " + f"{words[-1] if index < len(diff_groups) - 1 else ''}" + ) + _before = _before.replace(sub, new) + _after = _after.replace(sub, new) response = ( f"**Author:** {author} (`{author.id}`)\n" -- cgit v1.2.3 From ad79540f058e5f04bde72dc9dba86533b1e296a2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 Nov 2019 17:36:03 -0800 Subject: Use trailing _ instead of leading for some variable names PEP 8 states the convention is a trailing underscore when used to prevent name conflicts. --- bot/cogs/eval.py | 4 ++-- bot/cogs/filtering.py | 8 ++++---- bot/cogs/moderation/scheduler.py | 14 +++++++------- bot/interpreter.py | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 9ce854f2c..00b988dde 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -148,7 +148,7 @@ class CodeEval(Cog): self.env.update(env) # Ignore this code, it works - _code = """ + code_ = """ async def func(): # (None,) -> Any try: with contextlib.redirect_stdout(self.stdout): @@ -162,7 +162,7 @@ async def func(): # (None,) -> Any """.format(textwrap.indent(code, ' ')) try: - exec(_code, self.env) # noqa: B102,S102 + exec(code_, self.env) # noqa: B102,S102 func = self.env['func'] res = await func() diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 4195783f1..1e7521054 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -43,7 +43,7 @@ class Filtering(Cog): def __init__(self, bot: Bot): self.bot = bot - _staff_mistake_str = "If you believe this was a mistake, please let staff know!" + staff_mistake_str = "If you believe this was a mistake, please let staff know!" self.filters = { "filter_zalgo": { "enabled": Filter.filter_zalgo, @@ -53,7 +53,7 @@ class Filtering(Cog): "user_notification": Filter.notify_user_zalgo, "notification_msg": ( "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). " - f"{_staff_mistake_str}" + f"{staff_mistake_str}" ) }, "filter_invites": { @@ -63,7 +63,7 @@ class Filtering(Cog): "content_only": True, "user_notification": Filter.notify_user_invites, "notification_msg": ( - f"Per Rule 6, your invite link has been removed. {_staff_mistake_str}\n\n" + f"Per Rule 6, your invite link has been removed. {staff_mistake_str}\n\n" r"Our server rules can be found here: " ) }, @@ -74,7 +74,7 @@ class Filtering(Cog): "content_only": True, "user_notification": Filter.notify_user_domains, "notification_msg": ( - f"Your URL has been removed because it matched a blacklisted domain. {_staff_mistake_str}" + f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}" ) }, "watch_rich_embeds": { diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 462c7fc7f..49b61f35e 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -304,7 +304,7 @@ class InfractionScheduler(Scheduler): guild = self.bot.get_guild(constants.Guild.id) mod_role = guild.get_role(constants.Roles.moderator) user_id = infraction["user"] - _type = infraction["type"] + type_ = infraction["type"] id_ = infraction["id"] log.info(f"Marking infraction #{id_} as inactive (expired).") @@ -324,14 +324,14 @@ class InfractionScheduler(Scheduler): log_text = {**log_text, **returned_log} # Merge the logs together else: raise ValueError( - f"Attempted to deactivate an unsupported infraction #{id_} ({_type})!" + f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!" ) except discord.Forbidden: - log.warning(f"Failed to deactivate infraction #{id_} ({_type}): bot lacks permissions") + log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions") log_text["Failure"] = f"The bot lacks permissions to do this (role hierarchy?)" log_content = mod_role.mention except discord.HTTPException as e: - log.exception(f"Failed to deactivate infraction #{id_} ({_type})") + log.exception(f"Failed to deactivate infraction #{id_} ({type_})") log_text["Failure"] = f"HTTPException with code {e.code}." log_content = mod_role.mention @@ -361,7 +361,7 @@ class InfractionScheduler(Scheduler): json={"active": False} ) except ResponseCodeError as e: - log.exception(f"Failed to deactivate infraction #{id_} ({_type})") + log.exception(f"Failed to deactivate infraction #{id_} ({type_})") log_line = f"API request failed with code {e.status}." log_content = mod_role.mention @@ -381,9 +381,9 @@ class InfractionScheduler(Scheduler): log.trace(f"Sending deactivation mod log for infraction #{id_}.") await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS[_type][1], + icon_url=utils.INFRACTION_ICONS[type_][1], colour=Colours.soft_green, - title=f"Infraction {log_title}: {_type}", + title=f"Infraction {log_title}: {type_}", text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=f"ID: {id_}", content=log_content, diff --git a/bot/interpreter.py b/bot/interpreter.py index a42b45a2d..76a3fc293 100644 --- a/bot/interpreter.py +++ b/bot/interpreter.py @@ -20,8 +20,8 @@ class Interpreter(InteractiveInterpreter): write_callable = None def __init__(self, bot: Bot): - _locals = {"bot": bot} - super().__init__(_locals) + locals_ = {"bot": bot} + super().__init__(locals_) async def run(self, code: str, ctx: Context, io: StringIO, *args, **kwargs) -> Any: """Execute the provided source code as the bot & return the output.""" -- cgit v1.2.3 From a916ec15867187907abe9214eb4bfa12a9e982f4 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Fri, 8 Nov 2019 11:58:21 +0800 Subject: Allow helpers to nominate people --- bot/cogs/watchchannels/talentpool.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 176c6f760..4ec42dcc1 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -7,14 +7,13 @@ from discord import Color, Embed, Member, User from discord.ext.commands import Bot, Cog, Context, group from bot.api import ResponseCodeError -from bot.constants import Channels, Guild, Roles, Webhooks +from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils import time from .watchchannel import WatchChannel, proxy_user log = logging.getLogger(__name__) -STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- In constants after the merge? class TalentPool(WatchChannel, Cog, name="Talentpool"): @@ -31,13 +30,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) - @with_role(Roles.owner, Roles.admin, Roles.moderator) + @with_role(*MODERATION_ROLES) async def nomination_group(self, ctx: Context) -> None: """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.invoke(self.bot.get_command("help"), "talentpool") @nomination_group.command(name='watched', aliases=('all', 'list')) - @with_role(Roles.owner, Roles.admin, Roles.moderator) + @with_role(*MODERATION_ROLES) async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: """ Shows the users that are currently being monitored in the talent pool. @@ -48,7 +47,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await self.list_watched_users(ctx, update_cache) @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) - @with_role(Roles.owner, Roles.admin, Roles.moderator) + @with_role(*STAFF_ROLES) async def watch_command(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#talent-pool` channel. @@ -113,7 +112,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.send(msg) @nomination_group.command(name='history', aliases=('info', 'search')) - @with_role(Roles.owner, Roles.admin, Roles.moderator) + @with_role(*MODERATION_ROLES) async def history_command(self, ctx: Context, user: Union[User, proxy_user]) -> None: """Shows the specified user's nomination history.""" result = await self.bot.api_client.get( @@ -142,7 +141,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) @nomination_group.command(name='unwatch', aliases=('end', )) - @with_role(Roles.owner, Roles.admin, Roles.moderator) + @with_role(*MODERATION_ROLES) async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. @@ -170,13 +169,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): self._remove_user(user.id) @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) - @with_role(Roles.owner, Roles.admin, Roles.moderator) + @with_role(*MODERATION_ROLES) async def nomination_edit_group(self, ctx: Context) -> None: """Commands to edit nominations.""" await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit") @nomination_edit_group.command(name='reason') - @with_role(Roles.owner, Roles.admin, Roles.moderator) + @with_role(*MODERATION_ROLES) async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: """ Edits the reason/unnominate reason for the nomination with the given `id` depending on the status. -- cgit v1.2.3 From 25f7f6019772e22072a2e1d16d6c4ff57862022f Mon Sep 17 00:00:00 2001 From: kosayoda Date: Fri, 8 Nov 2019 12:11:47 +0800 Subject: Refactor bigbrother to use constants --- bot/cogs/watchchannels/bigbrother.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index c516508ca..49783bb09 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -6,7 +6,7 @@ from discord import User from discord.ext.commands import Bot, Cog, Context, group from bot.cogs.moderation.utils import post_infraction -from bot.constants import Channels, Roles, Webhooks +from bot.constants import Channels, MODERATION_ROLES, Webhooks from bot.decorators import with_role from .watchchannel import WatchChannel, proxy_user @@ -27,13 +27,13 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): ) @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) - @with_role(Roles.owner, Roles.admin, Roles.moderator) + @with_role(*MODERATION_ROLES) async def bigbrother_group(self, ctx: Context) -> None: """Monitors users by relaying their messages to the Big Brother watch channel.""" await ctx.invoke(self.bot.get_command("help"), "bigbrother") @bigbrother_group.command(name='watched', aliases=('all', 'list')) - @with_role(Roles.owner, Roles.admin, Roles.moderator) + @with_role(*MODERATION_ROLES) async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: """ Shows the users that are currently being monitored by Big Brother. @@ -44,7 +44,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await self.list_watched_users(ctx, update_cache) @bigbrother_group.command(name='watch', aliases=('w',)) - @with_role(Roles.owner, Roles.admin, Roles.moderator) + @with_role(*MODERATION_ROLES) async def watch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#big-brother` channel. @@ -91,7 +91,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(msg) @bigbrother_group.command(name='unwatch', aliases=('uw',)) - @with_role(Roles.owner, Roles.admin, Roles.moderator) + @with_role(*MODERATION_ROLES) async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: """Stop relaying messages by the given `user`.""" active_watches = await self.bot.api_client.get( -- cgit v1.2.3 From 9000d92ab8ceaf855d23c0e6dfdb09d05a40e59a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 8 Nov 2019 22:07:00 +0100 Subject: Add whitespace for readability, consistency & allure --- tests/bot/rules/test_links.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index 95f107f91..40336beb0 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -45,6 +45,7 @@ class LinksTests(unittest.TestCase): for recent_messages in cases: last_message = recent_messages[0] + with self.subTest( last_message=last_message, recent_messages=recent_messages, -- cgit v1.2.3 From 760f265339b1462b60ea7d5af1fd8d4476c134f0 Mon Sep 17 00:00:00 2001 From: kwzrd <44734341+kwzrd@users.noreply.github.com> Date: Fri, 8 Nov 2019 22:52:47 +0100 Subject: Update docstring to use asterisks when referring to argument names Co-Authored-By: Kyle Stanley --- tests/bot/rules/test_links.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index 40336beb0..be832843b 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -18,7 +18,7 @@ class Case(NamedTuple): def msg(author: str, total_links: int) -> FakeMessage: - """Makes a message with `total_links` links.""" + """Makes a message with *total_links* links.""" content = " ".join(["https://pydis.com"] * total_links) return FakeMessage(author=author, content=content) -- cgit v1.2.3 From 0eade4697bd76b9cc99e74777531ba00df558ff5 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 10:45:24 +0100 Subject: Add unit test for mentions antispam rule --- tests/bot/rules/test_mentions.py | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/bot/rules/test_mentions.py diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py new file mode 100644 index 000000000..520184c2f --- /dev/null +++ b/tests/bot/rules/test_mentions.py @@ -0,0 +1,98 @@ +import unittest +from typing import List, NamedTuple, Tuple + +from bot.rules import mentions +from tests.helpers import async_test + + +class FakeMessage(NamedTuple): + author: str + mentions: List[None] + + +class Case(NamedTuple): + recent_messages: List[FakeMessage] + relevant_messages: Tuple[FakeMessage] + culprit: str + total_mentions: int + + +def msg(author: str, total_mentions: int) -> FakeMessage: + return FakeMessage(author=author, mentions=[None] * total_mentions) + + +class TestMentions(unittest.TestCase): + """Tests applying the `mentions` antispam rule.""" + + def setUp(self): + self.config = { + "max": 2, + "interval": 10 + } + + @async_test + async def test_mentions_within_limit(self): + """Messages with an allowed amount of mentions.""" + cases = ( + [msg("bob", 0)], + [msg("bob", 2)], + [msg("bob", 1), msg("bob", 1)], + [msg("bob", 1), msg("alice", 2)] + ) + + for recent_messages in cases: + last_message = recent_messages[0] + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + config=self.config + ): + self.assertIsNone( + await mentions.apply(last_message, recent_messages, self.config) + ) + + @async_test + async def test_mentions_exceeding_limit(self): + """Messages with a higher than allowed amount of mentions.""" + cases = ( + Case( + [msg("bob", 3)], + (msg("bob", 3),), + ("bob",), + 3 + ), + Case( + [msg("alice", 2), msg("alice", 0), msg("alice", 1)], + (msg("alice", 2), msg("alice", 0), msg("alice", 1)), + ("alice",), + 3 + ), + Case( + [msg("bob", 2), msg("alice", 3), msg("bob", 2)], + (msg("bob", 2), msg("bob", 2)), + ("bob",), + 4 + ) + ) + + for recent_messages, relevant_messages, culprit, total_mentions in cases: + last_message = recent_messages[0] + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + relevant_messages=relevant_messages, + culprit=culprit, + total_mentions=total_mentions, + cofig=self.config + ): + desired_output = ( + f"sent {total_mentions} mentions in {self.config['interval']}s", + culprit, + relevant_messages + ) + self.assertTupleEqual( + await mentions.apply(last_message, recent_messages, self.config), + desired_output + ) -- cgit v1.2.3 From 187d419810759992e19a792fd746f26960f3831a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 10:46:34 +0100 Subject: Add missing docstring --- tests/bot/rules/test_mentions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index 520184c2f..987a42c0a 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -18,6 +18,7 @@ class Case(NamedTuple): def msg(author: str, total_mentions: int) -> FakeMessage: + """Makes a message with `total_mentions` mentions.""" return FakeMessage(author=author, mentions=[None] * total_mentions) -- cgit v1.2.3 From 32caead77e4bad04689892a29005faa3ffde2e83 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 10:50:54 +0100 Subject: Adjust docstring asterisk to backtick for consistency --- tests/bot/rules/test_links.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index be832843b..40336beb0 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -18,7 +18,7 @@ class Case(NamedTuple): def msg(author: str, total_links: int) -> FakeMessage: - """Makes a message with *total_links* links.""" + """Makes a message with `total_links` links.""" content = " ".join(["https://pydis.com"] * total_links) return FakeMessage(author=author, content=content) -- cgit v1.2.3 From b87a98c7749edfeb9fbc368f2bf9a7ecb9434662 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 10:54:26 +0100 Subject: Use range to build mock mentions list --- tests/bot/rules/test_mentions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index 987a42c0a..e1a971dbb 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -19,7 +19,7 @@ class Case(NamedTuple): def msg(author: str, total_mentions: int) -> FakeMessage: """Makes a message with `total_mentions` mentions.""" - return FakeMessage(author=author, mentions=[None] * total_mentions) + return FakeMessage(author=author, mentions=list(range(total_mentions))) class TestMentions(unittest.TestCase): -- cgit v1.2.3 From b1c6b4e20578395a5766ac116fd4def1df777de7 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 13:49:55 +0100 Subject: Adjust type hint to correctly represent internal type --- tests/bot/rules/test_mentions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index e1a971dbb..08dd1d6d5 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -7,7 +7,7 @@ from tests.helpers import async_test class FakeMessage(NamedTuple): author: str - mentions: List[None] + mentions: List[int] class Case(NamedTuple): -- cgit v1.2.3 From f915782192fd7b631b23fa31e524cacdc8e72614 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 16:25:23 +0100 Subject: Use MockMessage instead of custom FakeMessage --- tests/bot/rules/test_mentions.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index 08dd1d6d5..e377f2164 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -2,24 +2,18 @@ import unittest from typing import List, NamedTuple, Tuple from bot.rules import mentions -from tests.helpers import async_test - - -class FakeMessage(NamedTuple): - author: str - mentions: List[int] +from tests.helpers import MockMessage, async_test class Case(NamedTuple): - recent_messages: List[FakeMessage] - relevant_messages: Tuple[FakeMessage] - culprit: str + recent_messages: List[MockMessage, ...] + culprit: Tuple[str] total_mentions: int -def msg(author: str, total_mentions: int) -> FakeMessage: +def msg(author: str, total_mentions: int) -> MockMessage: """Makes a message with `total_mentions` mentions.""" - return FakeMessage(author=author, mentions=list(range(total_mentions))) + return MockMessage(author=author, mentions=list(range(total_mentions))) class TestMentions(unittest.TestCase): @@ -59,26 +53,28 @@ class TestMentions(unittest.TestCase): cases = ( Case( [msg("bob", 3)], - (msg("bob", 3),), ("bob",), 3 ), Case( [msg("alice", 2), msg("alice", 0), msg("alice", 1)], - (msg("alice", 2), msg("alice", 0), msg("alice", 1)), ("alice",), 3 ), Case( [msg("bob", 2), msg("alice", 3), msg("bob", 2)], - (msg("bob", 2), msg("bob", 2)), ("bob",), 4 ) ) - for recent_messages, relevant_messages, culprit, total_mentions in cases: + for recent_messages, culprit, total_mentions in cases: last_message = recent_messages[0] + relevant_messages = tuple( + msg + for msg in recent_messages + if msg.author == last_message.author + ) with self.subTest( last_message=last_message, -- cgit v1.2.3 From 16e6c36c6b370300ef7507d2e01421ad0d83f407 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 16:27:02 +0100 Subject: Adjust incorrect type hint --- tests/bot/rules/test_mentions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index e377f2164..ad49ead32 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -6,7 +6,7 @@ from tests.helpers import MockMessage, async_test class Case(NamedTuple): - recent_messages: List[MockMessage, ...] + recent_messages: List[MockMessage] culprit: Tuple[str] total_mentions: int -- cgit v1.2.3 From 01ab4ad37dc68f38dd0fd15487dbff9bbd58c24e Mon Sep 17 00:00:00 2001 From: Derek Date: Sat, 9 Nov 2019 20:08:08 -0500 Subject: Forward user/role pings in checkpoint to mod-alerts [kaizen] limit on_message listener to verification channel --- bot/cogs/verification.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 3d85edae6..cd4311916 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,12 +1,16 @@ import logging from datetime import datetime -from discord import Message, NotFound, Object +from discord import Colour, Message, NotFound, Object from discord.ext import tasks from discord.ext.commands import Bot, Cog, Context, command from bot.cogs.moderation import ModLog -from bot.constants import Bot as BotConfig, Channels, Event, Roles +from bot.constants import ( + Bot as BotConfig, + Channels, Colours, Event, + Filter, Icons, Roles +) from bot.decorators import InChannelCheckFailure, in_channel, without_role log = logging.getLogger(__name__) @@ -53,6 +57,34 @@ class Verification(Cog): if message.author.bot: return # They're a bot, ignore + if message.channel.id != Channels.verification: + return # Only listen for #checkpoint messages + + # if a user mentions a role or guild member + # alert the mods in mod-alerts channel + if message.mentions or message.role_mentions: + log.debug( + f"{message.author} mentioned one or more users " + f"and/or roles in {message.channel.name}" + ) + + embed_text = ( + f"{message.author.mention} sent a message in " + f"{message.channel.mention} that contained user and/or role mentions." + f"\n\n**Original message:**\n>>> {message.content}" + ) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"User/Role mentioned in {message.channel.name}", + text=embed_text, + thumbnail=message.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone, + ) + ctx = await self.bot.get_context(message) # type: Context if ctx.command is not None and ctx.command.name == "accept": -- cgit v1.2.3 From 0425e6c49ecf091871a06762a064755ea3dda04c Mon Sep 17 00:00:00 2001 From: Derek Date: Sat, 9 Nov 2019 21:07:11 -0500 Subject: [kaizen] Remove now duplicate channel check Unindent subsequent lines after check --- bot/cogs/verification.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index cd4311916..b5e8d4357 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -90,27 +90,26 @@ class Verification(Cog): if ctx.command is not None and ctx.command.name == "accept": return # They used the accept command - if ctx.channel.id == Channels.verification: # We're in the verification channel - for role in ctx.author.roles: - if role.id == Roles.verified: - log.warning(f"{ctx.author} posted '{ctx.message.content}' " - "in the verification channel, but is already verified.") - return # They're already verified - - log.debug(f"{ctx.author} posted '{ctx.message.content}' in the verification " - "channel. We are providing instructions how to verify.") - await ctx.send( - f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, " - f"and gain access to the rest of the server.", - delete_after=20 - ) + for role in ctx.author.roles: + if role.id == Roles.verified: + log.warning(f"{ctx.author} posted '{ctx.message.content}' " + "in the verification channel, but is already verified.") + return # They're already verified + + log.debug(f"{ctx.author} posted '{ctx.message.content}' in the verification " + "channel. We are providing instructions how to verify.") + await ctx.send( + f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, " + f"and gain access to the rest of the server.", + delete_after=20 + ) - log.trace(f"Deleting the message posted by {ctx.author}") + log.trace(f"Deleting the message posted by {ctx.author}") - try: - await ctx.message.delete() - except NotFound: - log.trace("No message found, it must have been deleted by another bot.") + try: + await ctx.message.delete() + except NotFound: + log.trace("No message found, it must have been deleted by another bot.") @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) @without_role(Roles.verified) -- cgit v1.2.3 From 4d5d307f9a499cd874d90e6500f877ce560c012f Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 10 Nov 2019 19:34:28 +0100 Subject: fix signatures and descriptions not being found when present --- bot/cogs/doc.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index dcbcfe3ad..6e50cd27d 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -271,19 +271,19 @@ class Doc(commands.Cog): description_start_index = search_html.find(str(start_tag.parent)) + len(str(start_tag.parent)) description_end_index = search_html.find(str(end_tag)) - description = search_html[description_start_index:description_end_index].replace('¶', '') + description = search_html[description_start_index:description_end_index] signatures = None else: - description = str(symbol_heading.find_next_sibling("dd")).replace('¶', '') + description = str(symbol_heading.find_next_sibling("dd")) description_pos = search_html.find(description) # Get text of up to 3 signatures, remove unwanted symbols for element in [symbol_heading] + symbol_heading.find_next_siblings("dt", limit=2): signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) - if signature and search_html.find(signature) < description_pos: + if signature and search_html.find(str(element)) < description_pos: signatures.append(signature) - return signatures, description + return signatures, description.replace('¶', '') @async_cache(arg_offset=1) async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]: -- cgit v1.2.3 From 7de5156a7719f0639021e8186f7ea17f5b853af7 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 10 Nov 2019 19:39:14 +0100 Subject: Add a newline after signatures for readability --- bot/cogs/doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 6e50cd27d..653d48528 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -326,7 +326,7 @@ class Doc(commands.Cog): else: embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures) - embed_description += description + embed_description += f"\n{description}" embed = discord.Embed( title=f'`{symbol}`', -- cgit v1.2.3 From 4795da86d0fef72ac677ae0a8f9e988da1923e17 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 10 Nov 2019 19:43:56 +0100 Subject: Cut off description at 1000 chars if paragraph is not found --- bot/cogs/doc.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 653d48528..b04355e28 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -305,6 +305,8 @@ class Doc(commands.Cog): if len(description) > 1000: shortened = description[:1000] last_paragraph_end = shortened.rfind('\n\n') + if last_paragraph_end == -1: + last_paragraph_end = 1000 description = description[:last_paragraph_end] # If there is an incomplete code block, cut it out -- cgit v1.2.3 From 34510f52c6bbe5e2a8bbfc34f8e5d648d0d39a96 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 10 Nov 2019 20:03:48 +0100 Subject: Move paragraph search to not cut off long starting paragraphs Co-authored-by: scargly <29337040+scragly@users.noreply.github.com> --- bot/cogs/doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index b04355e28..73895e3eb 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -304,7 +304,7 @@ class Doc(commands.Cog): # of a double newline (interpreted as a paragraph) before index 1000. if len(description) > 1000: shortened = description[:1000] - last_paragraph_end = shortened.rfind('\n\n') + last_paragraph_end = shortened.rfind('\n\n', 100) if last_paragraph_end == -1: last_paragraph_end = 1000 description = description[:last_paragraph_end] -- cgit v1.2.3 From 219cde70f03476ac6ae4a7f84322757bebeec51e Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 10 Nov 2019 21:30:26 +0100 Subject: Add a command for refreshing inventories --- bot/cogs/doc.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 73895e3eb..8cf32fc7f 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -447,6 +447,28 @@ class Doc(commands.Cog): await self.refresh_inventory() await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") + @docs_group.command(name="refresh", aliases=("rfsh", "r")) + @with_role(*MODERATION_ROLES) + async def refresh_command(self, ctx: commands.Context) -> None: + """Refresh inventories and send differences to channel.""" + old_inventories = set(self.base_urls) + with ctx.typing(): + await self.refresh_inventory() + # Get differences of added and removed inventories + added = ', '.join(inv for inv in self.base_urls if inv not in old_inventories) + if added: + added = f"`+ {added}`" + + removed = ', '.join(inv for inv in old_inventories if inv not in self.base_urls) + if removed: + removed = f"`- {removed}`" + + embed = discord.Embed( + title="Inventories refreshed", + description=f"{added}\n{removed}" if added or removed else "" + ) + await ctx.send(embed=embed) + async def _fetch_inventory(self, inventory_url: str, config: SphinxConfiguration) -> Optional[dict]: """Get and return inventory from `inventory_url`. If fetching fails, return None.""" fetch_func = functools.partial(intersphinx.fetch_inventory, config, '', inventory_url) -- cgit v1.2.3 From 4f393d7b95101cc31269eb30742195e771deb705 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Sun, 10 Nov 2019 21:31:47 +0100 Subject: Move signatures definition --- bot/cogs/doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 8cf32fc7f..f7e8ae9d6 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -252,7 +252,6 @@ class Doc(commands.Cog): symbol_id = url.split('#')[-1] soup = BeautifulSoup(html, 'lxml') symbol_heading = soup.find(id=symbol_id) - signatures = [] search_html = str(soup) if symbol_heading is None: @@ -275,6 +274,7 @@ class Doc(commands.Cog): signatures = None else: + signatures = [] description = str(symbol_heading.find_next_sibling("dd")) description_pos = search_html.find(description) # Get text of up to 3 signatures, remove unwanted symbols -- cgit v1.2.3 From 6944175cea2c6595ec29b9ef67ff2ad9a8efb8ae Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 11 Nov 2019 00:58:32 +0100 Subject: clear renamed symbols on inventory refresh --- bot/cogs/doc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index f7e8ae9d6..90f496ceb 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -216,6 +216,7 @@ class Doc(commands.Cog): # Also, reset the cache used for fetching documentation. self.base_urls.clear() self.inventories.clear() + self.renamed_symbols.clear() async_cache.cache = OrderedDict() # Since Intersphinx is intended to be used with Sphinx, -- cgit v1.2.3 From 4a7de0bd155a4717f6cbc593a60dbec130e7ca40 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 11 Nov 2019 01:12:21 +0100 Subject: Do not cut off text arbitrarily but at last sentence to make sure no unfinished markdown is left in --- bot/cogs/doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 90f496ceb..bf6cee101 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -307,7 +307,7 @@ class Doc(commands.Cog): shortened = description[:1000] last_paragraph_end = shortened.rfind('\n\n', 100) if last_paragraph_end == -1: - last_paragraph_end = 1000 + last_paragraph_end = shortened.rfind('. ') description = description[:last_paragraph_end] # If there is an incomplete code block, cut it out -- cgit v1.2.3 From fb338545c4c2a133e23a664c77813d2ce9aba41c Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 11 Nov 2019 01:15:01 +0100 Subject: syntax highlight diff of reloaded inventories --- bot/cogs/doc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index bf6cee101..0d4884e8b 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -458,15 +458,15 @@ class Doc(commands.Cog): # Get differences of added and removed inventories added = ', '.join(inv for inv in self.base_urls if inv not in old_inventories) if added: - added = f"`+ {added}`" + added = f"+ {added}" removed = ', '.join(inv for inv in old_inventories if inv not in self.base_urls) if removed: - removed = f"`- {removed}`" + removed = f"- {removed}" embed = discord.Embed( title="Inventories refreshed", - description=f"{added}\n{removed}" if added or removed else "" + description=f"```diff\n{added}\n{removed}```" if added or removed else "" ) await ctx.send(embed=embed) -- cgit v1.2.3 From aac8404f65b419e212e5372015b63871fab7f3d1 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 11 Nov 2019 07:19:07 +0100 Subject: Adding ducky count tests and a new AsyncIteratorMock --- tests/bot/cogs/test_duck_pond.py | 70 ++++++++++++++++++++++++++++++++-------- tests/helpers.py | 38 +++++++++++++++++++++- 2 files changed, 93 insertions(+), 15 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 31c7e9f89..af8ef0e4d 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -1,8 +1,10 @@ +import asyncio import logging import unittest +from bot import constants from bot.cogs import duck_pond -from tests.helpers import MockBot, MockMember, MockMessage, MockReaction, MockRole +from tests.helpers import MockBot, MockEmoji, MockMember, MockMessage, MockReaction, MockRole class DuckPondTest(unittest.TestCase): @@ -13,49 +15,89 @@ class DuckPondTest(unittest.TestCase): self.bot = MockBot() self.cog = duck_pond.DuckPond(bot=self.bot) + # Override the constants we'll be needing + constants.STAFF_ROLES = (123,) + constants.DuckPond.custom_emojis = (789,) + constants.DuckPond.threshold = 1 + # Set up some roles - self.admin_role = MockRole(name="Admins", role_id=476190234653229056) - self.contrib_role = MockRole(name="Contributor", role_id=476190302659543061) + self.admin_role = MockRole(name="Admins", role_id=123) + self.contrib_role = MockRole(name="Contributor", role_id=456) # Set up some users - self.admin_member = MockMember(roles=(self.admin_role,)) + self.admin_member_1 = MockMember(roles=(self.admin_role,), id=1) + self.admin_member_2 = MockMember(roles=(self.admin_role,), id=2) self.contrib_member = MockMember(roles=(self.contrib_role,)) self.no_role_member = MockMember() # Set up emojis self.checkmark_emoji = "✅" self.thumbs_up_emoji = "👍" + self.unicode_duck_emoji = "🦆" + self.yellow_ducky_emoji = MockEmoji(id=789) # Set up reactions - self.checkmark_reaction = MockReaction(emoji=self.checkmark_emoji) - self.thumbs_up_reaction = MockReaction(emoji=self.thumbs_up_emoji) + self.checkmark_reaction = MockReaction( + emoji=self.checkmark_emoji, + user_list=[self.admin_member_1] + ) + self.thumbs_up_reaction = MockReaction( + emoji=self.thumbs_up_emoji, + user_list=[self.admin_member_1, self.contrib_member] + ) + self.yellow_ducky_reaction = MockReaction( + emoji=self.yellow_ducky_emoji, + user_list=[self.admin_member_1, self.contrib_member] + ) + self.unicode_duck_reaction_1 = MockReaction( + emoji=self.unicode_duck_emoji, + user_list=[self.admin_member_1] + ) + self.unicode_duck_reaction_2 = MockReaction( + emoji=self.unicode_duck_emoji, + user_list=[self.admin_member_2] + ) # Set up a messages self.checkmark_message = MockMessage(reactions=(self.checkmark_reaction,)) self.thumbs_up_message = MockMessage(reactions=(self.thumbs_up_reaction,)) + self.yellow_ducky_message = MockMessage(reactions=(self.yellow_ducky_reaction,)) + self.unicode_duck_message = MockMessage(reactions=(self.unicode_duck_reaction_1,)) + self.double_duck_message = MockMessage(reactions=(self.unicode_duck_reaction_1, self.unicode_duck_reaction_2)) self.no_reaction_message = MockMessage() def test_is_staff_correctly_identifies_staff(self): """Test that is_staff correctly identifies a staff member.""" with self.subTest(): - self.assertTrue(duck_pond.DuckPond.is_staff(self.admin_member)) - self.assertFalse(duck_pond.DuckPond.is_staff(self.contrib_member)) - self.assertFalse(duck_pond.DuckPond.is_staff(self.no_role_member)) + self.assertTrue(self.cog.is_staff(self.admin_member_1)) + self.assertFalse(self.cog.is_staff(self.contrib_member)) + self.assertFalse(self.cog.is_staff(self.no_role_member)) def test_has_green_checkmark_correctly_identifies_messages(self): """Test that has_green_checkmark recognizes messages with checkmarks.""" with self.subTest(): - self.assertTrue(duck_pond.DuckPond.has_green_checkmark(self.checkmark_message)) - self.assertFalse(duck_pond.DuckPond.has_green_checkmark(self.thumbs_up_message)) - self.assertFalse(duck_pond.DuckPond.has_green_checkmark(self.no_reaction_message)) + self.assertTrue(self.cog.has_green_checkmark(self.checkmark_message)) + self.assertFalse(self.cog.has_green_checkmark(self.thumbs_up_message)) + self.assertFalse(self.cog.has_green_checkmark(self.no_reaction_message)) def test_count_custom_duck_emojis(self): """A string decoding to numeric characters is a valid user ID.""" - pass + count_one_duck = self.cog.count_ducks(self.yellow_ducky_message) + count_no_ducks = self.cog.count_ducks(self.thumbs_up_message) + with self.subTest(): + self.assertEqual(asyncio.run(count_one_duck), 1) + self.assertEqual(asyncio.run(count_no_ducks), 0) def test_count_unicode_duck_emojis(self): """A string decoding to numeric characters is a valid user ID.""" - pass + count_no_ducks = self.cog.count_ducks(self.thumbs_up_message) + count_one_duck = self.cog.count_ducks(self.unicode_duck_message) + count_two_ducks = self.cog.count_ducks(self.double_duck_message) + + with self.subTest(): + self.assertEqual(asyncio.run(count_no_ducks), 0) + self.assertEqual(asyncio.run(count_one_duck), 1) + self.assertEqual(asyncio.run(count_two_ducks), 2) def test_count_mixed_duck_emojis(self): """A string decoding to numeric characters is a valid user ID.""" diff --git a/tests/helpers.py b/tests/helpers.py index 8496ba031..fd79141ec 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -102,8 +102,30 @@ class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): Python 3.8 will introduce an AsyncMock class in the standard library that will have some more features; this stand-in only overwrites the `__call__` method to an async version. """ + async def __call__(self, *args, **kwargs): - return super(AsyncMock, self).__call__(*args, **kwargs) + return super().__call__(*args, **kwargs) + + +class AsyncIteratorMock: + """ + A class to mock asyncronous iterators. + + This allows async for, which is used in certain Discord.py objects. For example, + an async iterator is returned by the Reaction.users() coroutine. + """ + + def __init__(self, sequence): + self.iter = iter(sequence) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.iter) + except StopIteration: + raise StopAsyncIteration # Create a guild instance to get a realistic Mock of `discord.Guild` @@ -155,6 +177,7 @@ class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ + def __init__( self, guild_id: int = 1, @@ -187,6 +210,7 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): Instances of this class will follow the specifications of `discord.Role` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) @@ -213,6 +237,7 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin Instances of this class will follow the specifications of `discord.Member` instances. For more information, see the `MockGuild` docstring. """ + def __init__( self, name: str = "member", @@ -243,6 +268,7 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) @@ -279,6 +305,7 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): Instances of this class will follow the specifications of `discord.TextChannel` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: super().__init__(spec=channel_instance, **kwargs) self.id = channel_id @@ -320,6 +347,7 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.ext.commands.Context` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec=context_instance, **kwargs) self.bot = kwargs.get('bot', MockBot()) @@ -336,6 +364,7 @@ class MockMessage(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Message` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec=message_instance, **kwargs) self.author = kwargs.get('author', MockMember()) @@ -353,6 +382,7 @@ class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Emoji` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec=emoji_instance, **kwargs) self.guild = kwargs.get('guild', MockGuild()) @@ -371,6 +401,7 @@ class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec=partial_emoji_instance, **kwargs) @@ -385,7 +416,12 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Reaction` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec=reaction_instance, **kwargs) self.emoji = kwargs.get('emoji', MockEmoji()) self.message = kwargs.get('message', MockMessage()) + self.user_list = AsyncIteratorMock(kwargs.get('user_list', [])) + + def users(self): + return self.user_list -- cgit v1.2.3 From 98ccfbc218dc762e45f0146d0503dba1fe06fdb9 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 11 Nov 2019 14:55:40 +0100 Subject: Implement a mixed duck test. Also gets started setting up for the final tests, which will require more mockwork. --- tests/bot/cogs/test_duck_pond.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index af8ef0e4d..211e8b084 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -20,6 +20,10 @@ class DuckPondTest(unittest.TestCase): constants.DuckPond.custom_emojis = (789,) constants.DuckPond.threshold = 1 + # Mock bot.get_all_channels() + CHANNEL_ID = 555 + USER_ID = 666 + # Set up some roles self.admin_role = MockRole(name="Admins", role_id=123) self.contrib_role = MockRole(name="Contributor", role_id=456) @@ -63,7 +67,12 @@ class DuckPondTest(unittest.TestCase): self.thumbs_up_message = MockMessage(reactions=(self.thumbs_up_reaction,)) self.yellow_ducky_message = MockMessage(reactions=(self.yellow_ducky_reaction,)) self.unicode_duck_message = MockMessage(reactions=(self.unicode_duck_reaction_1,)) - self.double_duck_message = MockMessage(reactions=(self.unicode_duck_reaction_1, self.unicode_duck_reaction_2)) + self.double_unicode_duck_message = MockMessage( + reactions=(self.unicode_duck_reaction_1, self.unicode_duck_reaction_2) + ) + self.double_mixed_duck_message = MockMessage( + reactions=(self.unicode_duck_reaction_1, self.yellow_ducky_reaction) + ) self.no_reaction_message = MockMessage() def test_is_staff_correctly_identifies_staff(self): @@ -81,27 +90,28 @@ class DuckPondTest(unittest.TestCase): self.assertFalse(self.cog.has_green_checkmark(self.no_reaction_message)) def test_count_custom_duck_emojis(self): - """A string decoding to numeric characters is a valid user ID.""" - count_one_duck = self.cog.count_ducks(self.yellow_ducky_message) + """Test that count_ducks counts custom ducks correctly.""" count_no_ducks = self.cog.count_ducks(self.thumbs_up_message) + count_one_duck = self.cog.count_ducks(self.yellow_ducky_message) with self.subTest(): - self.assertEqual(asyncio.run(count_one_duck), 1) self.assertEqual(asyncio.run(count_no_ducks), 0) + self.assertEqual(asyncio.run(count_one_duck), 1) def test_count_unicode_duck_emojis(self): - """A string decoding to numeric characters is a valid user ID.""" - count_no_ducks = self.cog.count_ducks(self.thumbs_up_message) + """Test that count_ducks counts unicode ducks correctly.""" count_one_duck = self.cog.count_ducks(self.unicode_duck_message) - count_two_ducks = self.cog.count_ducks(self.double_duck_message) + count_two_ducks = self.cog.count_ducks(self.double_unicode_duck_message) with self.subTest(): - self.assertEqual(asyncio.run(count_no_ducks), 0) self.assertEqual(asyncio.run(count_one_duck), 1) self.assertEqual(asyncio.run(count_two_ducks), 2) def test_count_mixed_duck_emojis(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + """Test that count_ducks counts mixed ducks correctly.""" + count_two_ducks = self.cog.count_ducks(self.double_mixed_duck_message) + + with self.subTest(): + self.assertEqual(asyncio.run(count_two_ducks), 2) def test_raw_reaction_add_rejects_bot(self): """A string decoding to numeric characters is a valid user ID.""" -- cgit v1.2.3 From 160962a56110ed970c7419ed650d9d8a84dbaa9a Mon Sep 17 00:00:00 2001 From: Numerlor Date: Tue, 12 Nov 2019 16:46:51 +0100 Subject: Adjust code style and comments --- bot/cogs/doc.py | 77 ++++++++++++++++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 0d4884e8b..b82eac5fe 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -176,36 +176,34 @@ class Doc(commands.Cog): self.base_urls[package_name] = base_url package = await self._fetch_inventory(inventory_url, config) - if package: - for group, value in package.items(): - # Each value has a bunch of information in the form - # `(package_name, version, relative_url, ???)`, and we only - # need the package_name and the relative documentation URL. - for symbol, (package_name, _, relative_doc_url, _) in value.items(): - absolute_doc_url = base_url + relative_doc_url - - if symbol in self.inventories: - # get `group_name` from _:group_name - group_name = group.split(":")[1] - if (group_name in NO_OVERRIDE_GROUPS - # check if any package from `NO_OVERRIDE_PACKAGES` - # is in base URL of the symbol that would be overridden - or any(package in self.inventories[symbol].split("/", 3)[2] - for package in NO_OVERRIDE_PACKAGES)): - - symbol = f"{group_name}.{symbol}" - # If renamed `symbol` already exists, add library name in front. - if symbol in self.renamed_symbols: - # Split `package_name` because of packages like Pillow that have spaces in them. - symbol = f"{package_name.split()[0]}.{symbol}" - - self.inventories[symbol] = absolute_doc_url - self.renamed_symbols.add(symbol) - continue - - self.inventories[symbol] = absolute_doc_url - - log.trace(f"Fetched inventory for {package_name}.") + if not package: + return None + + for group, value in package.items(): + for symbol, (package_name, _, relative_doc_url, _) in value.items(): + absolute_doc_url = base_url + relative_doc_url + + if symbol in self.inventories: + group_name = group.split(":")[1] + symbol_base_url = self.inventories[symbol].split("/", 3)[2] + if ( + group_name in NO_OVERRIDE_GROUPS + or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES) + ): + + symbol = f"{group_name}.{symbol}" + # If renamed `symbol` already exists, add library name in front to differentiate between them. + if symbol in self.renamed_symbols: + # Split `package_name` because of packages like Pillow that have spaces in them. + symbol = f"{package_name.split()[0]}.{symbol}" + + self.inventories[symbol] = absolute_doc_url + self.renamed_symbols.add(symbol) + continue + + self.inventories[symbol] = absolute_doc_url + + log.trace(f"Fetched inventory for {package_name}.") async def refresh_inventory(self) -> None: """Refresh internal documentation inventory.""" @@ -337,9 +335,10 @@ class Doc(commands.Cog): description=embed_description ) # Show all symbols with the same name that were renamed in the footer. - embed.set_footer(text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} - if renamed.endswith(f".{symbol}")) - ) + embed.set_footer( + text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} + if renamed.endswith(f".{symbol}")) + ) return embed @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) @@ -477,11 +476,15 @@ class Doc(commands.Cog): try: package = await self.bot.loop.run_in_executor(None, fetch_func) except ConnectTimeout: - log.error(f"Fetching of inventory {inventory_url} timed out," - f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})") + log.error( + f"Fetching of inventory {inventory_url} timed out," + f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" + ) except ProtocolError: - log.error(f"Connection lost while fetching inventory {inventory_url}," - f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})") + log.error( + f"Connection lost while fetching inventory {inventory_url}," + f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" + ) except HTTPError as e: log.error(f"Fetching of inventory {inventory_url} failed with status code {e.response.status_code}.") return None -- cgit v1.2.3 From a89349ee32bbf2b3506cc278999575db1fbfde74 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Tue, 12 Nov 2019 22:05:28 +0100 Subject: Add tests for on_raw_reaction_add. Basically I suck at this and I can't get this return_value thing to work. I'll have Ves look at it to resolve it. As of right now, multiple tests are failing. --- tests/bot/cogs/test_duck_pond.py | 84 +++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 14 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 211e8b084..088d8ac79 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -1,10 +1,11 @@ import asyncio import logging import unittest +from unittest.mock import MagicMock from bot import constants from bot.cogs import duck_pond -from tests.helpers import MockBot, MockEmoji, MockMember, MockMessage, MockReaction, MockRole +from tests.helpers import MockBot, MockEmoji, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel class DuckPondTest(unittest.TestCase): @@ -15,23 +16,27 @@ class DuckPondTest(unittest.TestCase): self.bot = MockBot() self.cog = duck_pond.DuckPond(bot=self.bot) + # Set up some constants + self.CHANNEL_ID = 555 + self.MESSAGE_ID = 666 + self.BOT_ID = 777 + self.CONTRIB_ID = 888 + self.ADMIN_ID = 999 + # Override the constants we'll be needing constants.STAFF_ROLES = (123,) constants.DuckPond.custom_emojis = (789,) constants.DuckPond.threshold = 1 - # Mock bot.get_all_channels() - CHANNEL_ID = 555 - USER_ID = 666 - # Set up some roles self.admin_role = MockRole(name="Admins", role_id=123) self.contrib_role = MockRole(name="Contributor", role_id=456) # Set up some users - self.admin_member_1 = MockMember(roles=(self.admin_role,), id=1) - self.admin_member_2 = MockMember(roles=(self.admin_role,), id=2) - self.contrib_member = MockMember(roles=(self.contrib_role,)) + self.admin_member_1 = MockMember(roles=(self.admin_role,), id=self.ADMIN_ID) + self.admin_member_2 = MockMember(roles=(self.admin_role,), id=911) + self.contrib_member = MockMember(roles=(self.contrib_role,), id=self.CONTRIB_ID) + self.bot_member = MockMember(roles=(self.contrib_role,), id=self.BOT_ID, bot=True) self.no_role_member = MockMember() # Set up emojis @@ -61,6 +66,14 @@ class DuckPondTest(unittest.TestCase): emoji=self.unicode_duck_emoji, user_list=[self.admin_member_2] ) + self.bot_reaction = MockReaction( + emoji=self.yellow_ducky_emoji, + user_list=[self.bot_member] + ) + self.contrib_reaction = MockReaction( + emoji=self.yellow_ducky_emoji, + user_list=[self.contrib_member] + ) # Set up a messages self.checkmark_message = MockMessage(reactions=(self.checkmark_reaction,)) @@ -73,8 +86,18 @@ class DuckPondTest(unittest.TestCase): self.double_mixed_duck_message = MockMessage( reactions=(self.unicode_duck_reaction_1, self.yellow_ducky_reaction) ) + + self.bot_message = MockMessage(reactions=(self.bot_reaction,)) + self.contrib_message = MockMessage(reactions=(self.contrib_reaction,)) self.no_reaction_message = MockMessage() + # Set up some channels + self.text_channel = MockTextChannel(id=self.CHANNEL_ID) + + @staticmethod + def _mock_send_webhook(content, username, avatar_url, embed): + """Mock for the send_webhook method in DuckPond""" + def test_is_staff_correctly_identifies_staff(self): """Test that is_staff correctly identifies a staff member.""" with self.subTest(): @@ -114,16 +137,49 @@ class DuckPondTest(unittest.TestCase): self.assertEqual(asyncio.run(count_two_ducks), 2) def test_raw_reaction_add_rejects_bot(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + """Test that send_webhook is not called if the user is a bot.""" + self.text_channel.fetch_message.return_value = self.bot_message + self.bot.get_all_channels.return_value = (self.text_channel,) + + payload = MagicMock( # RawReactionActionEvent + channel_id=self.CHANNEL_ID, + message_id=self.MESSAGE_ID, + user_id=self.BOT_ID, + ) + + with self.subTest(): + asyncio.run(self.cog.on_raw_reaction_add(payload)) + self.bot.cog.send_webhook.assert_not_called() def test_raw_reaction_add_rejects_non_staff(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + """Test that send_webhook is not called if the user is not a member of staff.""" + self.text_channel.fetch_message.return_value = self.contrib_message + self.bot.get_all_channels.return_value = (self.text_channel,) + + payload = MagicMock( # RawReactionActionEvent + channel_id=self.CHANNEL_ID, + message_id=self.MESSAGE_ID, + user_id=self.CONTRIB_ID, + ) + + with self.subTest(): + asyncio.run(self.cog.on_raw_reaction_add(payload)) + self.bot.cog.send_webhook.assert_not_called() def test_raw_reaction_add_sends_message_on_valid_input(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + """Test that send_webhook is called if payload is valid.""" + self.text_channel.fetch_message.return_value = self.unicode_duck_message + self.bot.get_all_channels.return_value = (self.text_channel,) + + payload = MagicMock( # RawReactionActionEvent + channel_id=self.CHANNEL_ID, + message_id=self.MESSAGE_ID, + user_id=self.ADMIN_ID, + ) + + with self.subTest(): + asyncio.run(self.cog.on_raw_reaction_add(payload)) + self.bot.cog.send_webhook.assert_called_once() def test_raw_reaction_remove_rejects_non_checkmarks(self): """A string decoding to numeric characters is a valid user ID.""" -- cgit v1.2.3 From 2ff711b5d7299baab84e20842cec856c5f17f992 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 13 Nov 2019 09:15:17 +0700 Subject: Switched to using list instead of `str.replace()` for much better control over each word. --- bot/cogs/moderation/modlog.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index c86bf6faa..3a7e0d3ce 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -639,39 +639,42 @@ class ModLog(Cog, name="ModLog"): channel = before.channel channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" - _before = before.clean_content - _after = after.clean_content - # Getting the difference per words and group them by type - add, remove, same # Note that this is intended grouping without sorting - diff = difflib.ndiff(_before.split(), _after.split()) + diff = difflib.ndiff(before.clean_content.split(), after.clean_content.split()) diff_groups = tuple( (diff_type, tuple(s[2:] for s in diff_words)) for diff_type, diff_words in itertools.groupby(diff, key=lambda s: s[0]) ) + _before = [] + _after = [] + for index, (diff_type, words) in enumerate(diff_groups): sub = ' '.join(words) if diff_type == '-': - _before = _before.replace(sub, f"[{sub}](http://o.hi)") + _before.append(f"[{sub}](http://o.hi)") elif diff_type == '+': - _after = _after.replace(sub, f"[{sub}](http://o.hi)") + _after.append(f"[{sub}](http://o.hi)") elif len(words) > 2: new = ( f"{words[0] if index > 0 else ''}" " ... " f"{words[-1] if index < len(diff_groups) - 1 else ''}" ) - _before = _before.replace(sub, new) - _after = _after.replace(sub, new) + _before.append(new) + _after.append(new) + elif diff_type == ' ': + _before.append(sub) + _after.append(sub) response = ( f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{before.id}`\n" "\n" - f"**Before**:\n{_before}\n" - f"**After**:\n{_after}\n" + f"**Before**:\n{' '.join(_before)}\n" + f"**After**:\n{' '.join(_after)}\n" "\n" f"[jump to message]({after.jump_url})" ) -- cgit v1.2.3 From dceafb83e829548638e8589c88f80364e8009821 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 13 Nov 2019 15:32:13 +0100 Subject: Prevent unwanted logging while running tests Previously, logging messages would output to std.out. when running individual test files (instead of running the entire suite). To prevent this, I've added a `for`-loop to `tests.helpers` that sets the level of all registered loggers to `CRITICAL`. The reason for adding this to `tests.helpers` is simple: It's the most common file to be imported in individual tests, increasing the chance of the code being run for individual test files. A small downside of this way of handling logging is that when we are trying to assert logging messages are being emitted, we need to set the logger explicitly in the `self.assertLogs` context manager. This is a small downside, though, and probably good practice anyway. There was one test in `tests.bot.test_api` that did not do this, so I have changed this to make the test compatible with the new set-up. --- tests/bot/test_api.py | 4 +++- tests/helpers.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py index e0ede0eb1..5a88adc5c 100644 --- a/tests/bot/test_api.py +++ b/tests/bot/test_api.py @@ -121,7 +121,9 @@ class LoggingHandlerTests(LoggingTestCase): def test_schedule_queued_tasks_for_nonempty_queue(self): """`APILoggingHandler` should schedule logs when the queue is not empty.""" - with self.assertLogs(level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task: + log = logging.getLogger("bot.api") + + with self.assertLogs(logger=log, level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task: self.log_handler.queue = [555] self.log_handler.schedule_queued_tasks() self.assertListEqual(self.log_handler.queue, []) diff --git a/tests/helpers.py b/tests/helpers.py index 8496ba031..8d661513d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import functools import inspect +import logging import unittest.mock from typing import Any, Iterable, Optional @@ -10,6 +11,16 @@ import discord from discord.ext.commands import Bot, Context +for logger in logging.Logger.manager.loggerDict.values(): + # Set all loggers to CRITICAL by default to prevent screen clutter during testing + + if not isinstance(logger, logging.Logger): + # There might be some logging.PlaceHolder objects in there + continue + + logger.setLevel(logging.CRITICAL) + + def async_test(wrapped): """ Run a test case via asyncio. -- cgit v1.2.3 From bf7720f16fa69716f15b16e3dcd0f20c186958b8 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 13 Nov 2019 16:06:51 +0100 Subject: Allow `name` attribute to be set during Mock init The `name` keyword argument has a special meaning for the default mockobjects provided by `unittest.mock`. This means that by default, the common d.py `name` attribute can't be set during initalization of one of our custom Mock-objects by passing it to the constructor. Since it's unlikely for us to make use of the special `name` feature of mocks and more likely to want to set the d.py `name` attribute, I added special handling of the `name` kwarg. --- tests/helpers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/helpers.py b/tests/helpers.py index 8d661513d..5dc7a0d2f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -74,7 +74,10 @@ class CustomMockMixin: child_mock_type = unittest.mock.MagicMock def __init__(self, spec: Any = None, **kwargs): + name = kwargs.pop('name', None) # `name` has special meaning for Mock classes, so we need to set it manually. super().__init__(spec=spec, **kwargs) + if name: + self.name = name if spec: self._extract_coroutine_methods_from_spec_instance(spec) -- cgit v1.2.3 From 36e9de480dcabb3c844090a4fd87561534536c04 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 13 Nov 2019 11:24:58 +0100 Subject: Prevent setting unknown attributes on d.py mocks Our custom `discord.py` now follow the specifications of the object they are mocking more strictly by using the `spec_set` instead of the `spec` kwarg to initialize the specifications. This means that trying to set an attribute that does not follow the specifications will now also result in an `AttributeError`. To make sure we are not trying to set illegal attributes during the default initialization of the mock objects, I've changed the way we handle default values of parameters. This does introduce a breaking change: Instead of passing a `suffix_id`, the `id` attribute should now be passed using the exact name. `id`. This commit also makes sure existing tests follow this change. --- tests/bot/cogs/test_information.py | 58 ++++++++++----------- tests/bot/cogs/test_token_remover.py | 2 +- tests/bot/utils/test_checks.py | 6 +-- tests/helpers.py | 94 +++++++++++++--------------------- tests/test_helpers.py | 99 ++++++++++++------------------------ 5 files changed, 101 insertions(+), 158 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 5c34541d8..4496a2ae0 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", role_id=constants.Roles.moderator) + cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderator) def setUp(self): """Sets up fresh objects for each test.""" @@ -54,7 +54,7 @@ class InformationCogTests(unittest.TestCase): """Tests the `role info` command.""" dummy_role = helpers.MockRole( name="Dummy", - role_id=112233445566778899, + id=112233445566778899, colour=discord.Colour.blurple(), position=10, members=[self.ctx.author], @@ -63,7 +63,7 @@ class InformationCogTests(unittest.TestCase): admin_role = helpers.MockRole( name="Admins", - role_id=998877665544332211, + id=998877665544332211, colour=discord.Colour.red(), position=3, members=[self.ctx.author], @@ -176,7 +176,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): self.bot = helpers.MockBot() self.bot.api_client.get = helpers.AsyncMock() self.cog = information.Information(self.bot) - self.member = helpers.MockMember(user_id=1234) + self.member = helpers.MockMember(id=1234) def test_user_command_helper_method_get_requests(self): """The helper methods should form the correct get requests.""" @@ -351,7 +351,7 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): """The embed should use the string representation of the user if they don't have a nick.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) user = helpers.MockMember() user.nick = None user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") @@ -363,7 +363,7 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) def test_create_user_embed_uses_nick_in_title_if_available(self): """The embed should use the nick if it's available.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) user = helpers.MockMember() user.nick = "Cat lover" user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") @@ -375,8 +375,8 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) def test_create_user_embed_ignores_everyone_role(self): """Created `!user` embeds should not contain mention of the @everyone-role.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) - admins_role = helpers.MockRole('Admins') + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) + admins_role = helpers.MockRole(name='Admins') admins_role.colour = 100 # A `MockMember` has the @Everyone role by default; we add the Admins to that. @@ -391,15 +391,15 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=helpers.AsyncMock) def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): """The embed should contain expanded infractions and nomination info in mod channels.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=50)) + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) - moderators_role = helpers.MockRole('Moderators') + moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 infraction_counts.return_value = "expanded infractions info" nomination_counts.return_value = "nomination info" - user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) infraction_counts.assert_called_once_with(user) @@ -426,14 +426,14 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=helpers.AsyncMock) def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): """The embed should contain only basic infraction data outside of mod channels.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=100)) + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) - moderators_role = helpers.MockRole('Moderators') + moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 infraction_counts.return_value = "basic infractions info" - user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) infraction_counts.assert_called_once_with(user) @@ -459,10 +459,10 @@ class UserEmbedTests(unittest.TestCase): """The embed should be created with the colour of the top role, if a top role is available.""" ctx = helpers.MockContext() - moderators_role = helpers.MockRole('Moderators') + moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 - user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) @@ -472,7 +472,7 @@ class UserEmbedTests(unittest.TestCase): """The embed should be created with a blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() - user = helpers.MockMember(user_id=217) + user = helpers.MockMember(id=217) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) self.assertEqual(embed.colour, discord.Colour.blurple()) @@ -482,7 +482,7 @@ class UserEmbedTests(unittest.TestCase): """The embed thumbnail should be set to the user's avatar in `png` format.""" ctx = helpers.MockContext() - user = helpers.MockMember(user_id=217) + user = helpers.MockMember(id=217) user.avatar_url_as.return_value = "avatar url" embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -499,13 +499,13 @@ class UserCommandTests(unittest.TestCase): self.bot = helpers.MockBot() self.cog = information.Information(self.bot) - self.moderator_role = helpers.MockRole("Moderators", role_id=2, position=10) - self.flautist_role = helpers.MockRole("Flautists", role_id=3, position=2) - self.bassist_role = helpers.MockRole("Bassists", role_id=4, position=3) + self.moderator_role = helpers.MockRole(name="Moderators", id=2, position=10) + self.flautist_role = helpers.MockRole(name="Flautists", id=3, position=2) + self.bassist_role = helpers.MockRole(name="Bassists", id=4, position=3) - self.author = helpers.MockMember(user_id=1, name="syntaxaire") - self.moderator = helpers.MockMember(user_id=2, name="riffautae", roles=[self.moderator_role]) - self.target = helpers.MockMember(user_id=3, name="__fluzz__") + self.author = helpers.MockMember(id=1, name="syntaxaire") + self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role]) + self.target = helpers.MockMember(id=3, name="__fluzz__") def test_regular_member_cannot_target_another_member(self, constants): """A regular user should not be able to use `!user` targeting another user.""" @@ -523,7 +523,7 @@ class UserCommandTests(unittest.TestCase): constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot = 50 - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=100)) + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) msg = "Sorry, but you may only use this command within <#50>." with self.assertRaises(InChannelCheckFailure, msg=msg): @@ -535,7 +535,7 @@ class UserCommandTests(unittest.TestCase): constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot = 50 - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=50)) + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) asyncio.run(self.cog.user_info.callback(self.cog, ctx)) @@ -548,7 +548,7 @@ class UserCommandTests(unittest.TestCase): constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot = 50 - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=50)) + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) @@ -561,7 +561,7 @@ class UserCommandTests(unittest.TestCase): constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot = 50 - ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(channel_id=200)) + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) asyncio.run(self.cog.user_info.callback(self.cog, ctx)) @@ -574,7 +574,7 @@ class UserCommandTests(unittest.TestCase): constants.MODERATION_ROLES = [self.moderator_role.id] constants.STAFF_ROLES = [self.moderator_role.id] - ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(channel_id=50)) + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index dfb1bafc9..3276cf5a5 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -24,7 +24,7 @@ class TokenRemoverTests(unittest.TestCase): self.bot.get_cog.return_value.send_log_message = AsyncMock() self.cog = TokenRemover(bot=self.bot) - self.msg = MockMessage(message_id=555, content='') + self.msg = MockMessage(id=555, content='') self.msg.author.__str__ = MagicMock() self.msg.author.__str__.return_value = 'lemon' self.msg.author.bot = False diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py index 19b758336..9610771e5 100644 --- a/tests/bot/utils/test_checks.py +++ b/tests/bot/utils/test_checks.py @@ -22,7 +22,7 @@ class ChecksTests(unittest.TestCase): def test_with_role_check_with_guild_and_required_role(self): """`with_role_check` returns `True` if `Context.author` has the required role.""" - self.ctx.author.roles.append(MockRole(role_id=10)) + self.ctx.author.roles.append(MockRole(id=10)) self.assertTrue(checks.with_role_check(self.ctx, 10)) def test_without_role_check_without_guild(self): @@ -33,13 +33,13 @@ class ChecksTests(unittest.TestCase): def test_without_role_check_returns_false_with_unwanted_role(self): """`without_role_check` returns `False` if `Context.author` has unwanted role.""" role_id = 42 - self.ctx.author.roles.append(MockRole(role_id=role_id)) + self.ctx.author.roles.append(MockRole(id=role_id)) self.assertFalse(checks.without_role_check(self.ctx, role_id)) def test_without_role_check_returns_true_without_unwanted_role(self): """`without_role_check` returns `True` if `Context.author` does not have unwanted role.""" role_id = 42 - self.ctx.author.roles.append(MockRole(role_id=role_id)) + self.ctx.author.roles.append(MockRole(id=role_id)) self.assertTrue(checks.without_role_check(self.ctx, role_id + 10)) def test_in_channel_check_for_correct_channel(self): diff --git a/tests/helpers.py b/tests/helpers.py index 5dc7a0d2f..35f2c288c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,8 +1,10 @@ from __future__ import annotations import asyncio +import collections import functools import inspect +import itertools import logging import unittest.mock from typing import Any, Iterable, Optional @@ -72,14 +74,16 @@ class CustomMockMixin: """ child_mock_type = unittest.mock.MagicMock + discord_id = itertools.count(0) - def __init__(self, spec: Any = None, **kwargs): + def __init__(self, spec_set: Any = None, **kwargs): name = kwargs.pop('name', None) # `name` has special meaning for Mock classes, so we need to set it manually. - super().__init__(spec=spec, **kwargs) + super().__init__(spec_set=spec_set, **kwargs) + if name: self.name = name - if spec: - self._extract_coroutine_methods_from_spec_instance(spec) + if spec_set: + self._extract_coroutine_methods_from_spec_instance(spec_set) def _get_child_mock(self, **kw): """ @@ -169,25 +173,14 @@ class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ - def __init__( - self, - guild_id: int = 1, - roles: Optional[Iterable[MockRole]] = None, - members: Optional[Iterable[MockMember]] = None, - **kwargs, - ) -> None: - super().__init__(spec=guild_instance, **kwargs) - - self.id = guild_id - - self.roles = [MockRole("@everyone", 1)] + def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: + default_kwargs = {'id': next(self.discord_id), 'members': []} + super().__init__(spec_set=guild_instance, **collections.ChainMap(kwargs, default_kwargs)) + + self.roles = [MockRole(name="@everyone", position=1, id=0)] if roles: self.roles.extend(roles) - self.members = [] - if members: - self.members.extend(members) - # Create a Role instance to get a realistic Mock of `discord.Role` role_data = {'name': 'role', 'id': 1} @@ -201,13 +194,12 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): Instances of this class will follow the specifications of `discord.Role` instances. For more information, see the `MockGuild` docstring. """ - def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: - super().__init__(spec=role_instance, **kwargs) + def __init__(self, **kwargs) -> None: + default_kwargs = {'id': next(self.discord_id), 'name': 'role', 'position': 1} + super().__init__(spec_set=role_instance, **collections.ChainMap(kwargs, default_kwargs)) - self.name = name - self.id = role_id - self.position = position - self.mention = f'&{self.name}' + if 'mention' not in kwargs: + self.mention = f'&{self.name}' def __lt__(self, other): """Simplified position-based comparisons similar to those of `discord.Role`.""" @@ -227,19 +219,11 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin Instances of this class will follow the specifications of `discord.Member` instances. For more information, see the `MockGuild` docstring. """ - def __init__( - self, - name: str = "member", - user_id: int = 1, - roles: Optional[Iterable[MockRole]] = None, - **kwargs, - ) -> None: - super().__init__(spec=member_instance, **kwargs) - - self.name = name - self.id = user_id - - self.roles = [MockRole("@everyone", 1)] + def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: + default_kwargs = {'name': 'member', 'id': next(self.discord_id)} + super().__init__(spec_set=member_instance, **collections.ChainMap(kwargs, default_kwargs)) + + self.roles = [MockRole(name="@everyone", position=1, id=0)] if roles: self.roles.extend(roles) @@ -248,6 +232,8 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin # 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 +bot_instance.api_client = None class MockBot(CustomMockMixin, unittest.mock.MagicMock): @@ -258,11 +244,7 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): For more information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - super().__init__(spec=bot_instance, **kwargs) - - # Our custom attributes and methods - self.http_session = unittest.mock.MagicMock() - self.api_client = unittest.mock.MagicMock() + super().__init__(spec_set=bot_instance, **kwargs) # 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 @@ -294,11 +276,11 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): more information, see the `MockGuild` docstring. """ def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: - super().__init__(spec=channel_instance, **kwargs) - self.id = channel_id - self.name = name - self.guild = kwargs.get('guild', MockGuild()) - self.mention = f"#{self.name}" + default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()} + super().__init__(spec_set=channel_instance, **collections.ChainMap(kwargs, default_kwargs)) + + if 'mention' not in kwargs: + self.mention = f"#{self.name}" # Create a Message instance to get a realistic MagicMock of `discord.Message` @@ -335,12 +317,11 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): instances. For more information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - super().__init__(spec=context_instance, **kwargs) + super().__init__(spec_set=context_instance, **kwargs) self.bot = kwargs.get('bot', MockBot()) self.guild = kwargs.get('guild', MockGuild()) self.author = kwargs.get('author', MockMember()) self.channel = kwargs.get('channel', MockTextChannel()) - self.command = kwargs.get('command', unittest.mock.MagicMock()) class MockMessage(CustomMockMixin, unittest.mock.MagicMock): @@ -351,7 +332,7 @@ class MockMessage(CustomMockMixin, unittest.mock.MagicMock): information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - super().__init__(spec=message_instance, **kwargs) + super().__init__(spec_set=message_instance, **kwargs) self.author = kwargs.get('author', MockMember()) self.channel = kwargs.get('channel', MockTextChannel()) @@ -368,12 +349,9 @@ class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - super().__init__(spec=emoji_instance, **kwargs) + super().__init__(spec_set=emoji_instance, **kwargs) self.guild = kwargs.get('guild', MockGuild()) - # Get all coroutine functions and set them as AsyncMock attributes - self._extract_coroutine_methods_from_spec_instance(emoji_instance) - partial_emoji_instance = discord.PartialEmoji(animated=False, name='guido') @@ -386,7 +364,7 @@ class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): more information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - super().__init__(spec=partial_emoji_instance, **kwargs) + super().__init__(spec_set=partial_emoji_instance, **kwargs) reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji()) @@ -400,6 +378,6 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): more information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - super().__init__(spec=reaction_instance, **kwargs) + super().__init__(spec_set=reaction_instance, **kwargs) self.emoji = kwargs.get('emoji', MockEmoji()) self.message = kwargs.get('message', MockMessage()) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 2b58634dd..e879ef97a 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -19,7 +19,6 @@ class DiscordMocksTests(unittest.TestCase): self.assertIsInstance(role, discord.Role) self.assertEqual(role.name, "role") - self.assertEqual(role.id, 1) self.assertEqual(role.position, 1) self.assertEqual(role.mention, "&role") @@ -27,7 +26,7 @@ class DiscordMocksTests(unittest.TestCase): """Test if MockRole initializes with the arguments provided.""" role = helpers.MockRole( name="Admins", - role_id=90210, + id=90210, position=10, ) @@ -67,22 +66,21 @@ class DiscordMocksTests(unittest.TestCase): self.assertIsInstance(member, discord.Member) self.assertEqual(member.name, "member") - self.assertEqual(member.id, 1) - self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1)]) + self.assertListEqual(member.roles, [helpers.MockRole(name="@everyone", position=1, id=0)]) self.assertEqual(member.mention, "@member") def test_mock_member_alternative_arguments(self): """Test if MockMember initializes with the arguments provided.""" - core_developer = helpers.MockRole("Core Developer", 2) + core_developer = helpers.MockRole(name="Core Developer", position=2) member = helpers.MockMember( name="Mark", - user_id=12345, + id=12345, roles=[core_developer] ) self.assertEqual(member.name, "Mark") self.assertEqual(member.id, 12345) - self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1), core_developer]) + self.assertListEqual(member.roles, [helpers.MockRole(name="@everyone", position=1, id=0), core_developer]) self.assertEqual(member.mention, "@Mark") def test_mock_member_accepts_dynamic_arguments(self): @@ -102,19 +100,19 @@ class DiscordMocksTests(unittest.TestCase): # The `spec` argument makes sure `isistance` checks with `discord.Guild` pass self.assertIsInstance(guild, discord.Guild) - self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1)]) + self.assertListEqual(guild.roles, [helpers.MockRole(name="@everyone", position=1, id=0)]) self.assertListEqual(guild.members, []) def test_mock_guild_alternative_arguments(self): """Test if MockGuild initializes with the arguments provided.""" - core_developer = helpers.MockRole("Core Developer", 2) + core_developer = helpers.MockRole(name="Core Developer", position=2) guild = helpers.MockGuild( roles=[core_developer], - members=[helpers.MockMember(user_id=54321)], + members=[helpers.MockMember(id=54321)], ) - self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1), core_developer]) - self.assertListEqual(guild.members, [helpers.MockMember(user_id=54321)]) + self.assertListEqual(guild.roles, [helpers.MockRole(name="@everyone", position=1, id=0), core_developer]) + self.assertListEqual(guild.members, [helpers.MockMember(id=54321)]) def test_mock_guild_accepts_dynamic_arguments(self): """Test if MockGuild accepts and sets abitrary keyword arguments.""" @@ -191,51 +189,18 @@ class DiscordMocksTests(unittest.TestCase): with self.assertRaises(AttributeError): mock.the_cake_is_a_lie - def test_custom_mock_methods_are_valid_discord_object_methods(self): - """The `AsyncMock` attributes of the mocks should be valid for the class they're mocking.""" - mocks = ( - (helpers.MockGuild, helpers.guild_instance), - (helpers.MockRole, helpers.role_instance), - (helpers.MockMember, helpers.member_instance), - (helpers.MockBot, helpers.bot_instance), - (helpers.MockContext, helpers.context_instance), - (helpers.MockTextChannel, helpers.channel_instance), - (helpers.MockMessage, helpers.message_instance), + def test_mocks_use_mention_when_provided_as_kwarg(self): + """The mock should use the passed `mention` instead of the default one if present.""" + test_cases = ( + (helpers.MockRole, "role mention"), + (helpers.MockMember, "member mention"), + (helpers.MockTextChannel, "channel mention"), ) - for mock_class, instance in mocks: - mock = mock_class() - async_methods = ( - attr for attr in dir(mock) if isinstance(getattr(mock, attr), helpers.AsyncMock) - ) - - # spec_mock = unittest.mock.MagicMock(spec=instance) - for method in async_methods: - with self.subTest(mock_class=mock_class, method=method): - try: - getattr(instance, method) - except AttributeError: - msg = f"method {method} is not a method attribute of {instance.__class__}" - self.fail(msg) - - @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') - def test_the_custom_mock_methods_test(self, subtest_mock): - """The custom method test should raise AssertionError for invalid methods.""" - class FakeMockBot(helpers.CustomMockMixin, unittest.mock.MagicMock): - """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" - - child_mock_type = unittest.mock.MagicMock - - def __init__(self, **kwargs): - super().__init__(spec=helpers.bot_instance, **kwargs) - - # Fake attribute - self.release_the_walrus = helpers.AsyncMock() - - with unittest.mock.patch("tests.helpers.MockBot", new=FakeMockBot): - msg = "method release_the_walrus is not a valid method of " - with self.assertRaises(AssertionError, msg=msg): - self.test_custom_mock_methods_are_valid_discord_object_methods() + for mock_type, mention in test_cases: + with self.subTest(mock_type=mock_type, mention=mention): + mock = mock_type(mention=mention) + self.assertEqual(mock.mention, mention) class MockObjectTests(unittest.TestCase): @@ -266,14 +231,14 @@ class MockObjectTests(unittest.TestCase): def test_hashable_mixin_uses_id_for_equality_comparison(self): """Test if the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + class MockScragly(helpers.HashableMixin): pass - scragly = MockScragly(spec=object) + scragly = MockScragly() scragly.id = 10 - eevee = MockScragly(spec=object) + eevee = MockScragly() eevee.id = 10 - python = MockScragly(spec=object) + python = MockScragly() python.id = 20 self.assertTrue(scragly == eevee) @@ -281,14 +246,14 @@ class MockObjectTests(unittest.TestCase): def test_hashable_mixin_uses_id_for_nonequality_comparison(self): """Test if the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + class MockScragly(helpers.HashableMixin): pass - scragly = MockScragly(spec=object) + scragly = MockScragly() scragly.id = 10 - eevee = MockScragly(spec=object) + eevee = MockScragly() eevee.id = 10 - python = MockScragly(spec=object) + python = MockScragly() python.id = 20 self.assertTrue(scragly != python) @@ -298,7 +263,7 @@ class MockObjectTests(unittest.TestCase): """Test if the MagicMock subclasses that implement the HashableMixin use id for hash.""" for mock in self.hashable_mocks: with self.subTest(mock_class=mock): - instance = helpers.MockRole(role_id=100) + instance = helpers.MockRole(id=100) self.assertEqual(hash(instance), instance.id) def test_mock_class_with_hashable_mixin_uses_id_for_equality(self): @@ -396,11 +361,11 @@ class MockObjectTests(unittest.TestCase): @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") def test_custom_mock_mixin_init_with_spec(self, extract_method_mock): """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" - spec = "pydis" + spec_set = "pydis" - helpers.CustomMockMixin(spec=spec) + helpers.CustomMockMixin(spec_set=spec_set) - extract_method_mock.assert_called_once_with(spec) + extract_method_mock.assert_called_once_with(spec_set) @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") -- cgit v1.2.3 From 7f4829e9fab007690d48188f499bfcc1a7baa437 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 13 Nov 2019 17:29:09 +0100 Subject: Prevent await warnings for MockBot's create_task Previously, the coroutine object passed to `MockBot.loop.create_task` would trigger a `RuntimeWarning` for not being awaited as we do not actually create a task for it. To prevent these warnings, coroutine objects passed will now automatically be closed. --- tests/helpers.py | 8 +++++++- tests/test_helpers.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/helpers.py b/tests/helpers.py index 35f2c288c..8a14aeef4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -227,7 +227,8 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin if roles: self.roles.extend(roles) - self.mention = f"@{self.name}" + if 'mention' not in kwargs: + self.mention = f"@{self.name}" # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` @@ -251,6 +252,11 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): # is technically incorrect, since it's a regular def.) self.wait_for = AsyncMock() + # Since calling `create_task` on our MockBot does not actually schedule the coroutine object + # as a task in the asyncio loop, this `side_effect` calls `close()` on the coroutine object + # to prevent "has not been awaited"-warnings. + self.loop.create_task.side_effect = lambda coroutine: coroutine.close() + # Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` channel_data = { diff --git a/tests/test_helpers.py b/tests/test_helpers.py index e879ef97a..7894e104a 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -202,6 +202,18 @@ class DiscordMocksTests(unittest.TestCase): mock = mock_type(mention=mention) self.assertEqual(mock.mention, mention) + def test_create_test_on_mock_bot_closes_passed_coroutine(self): + """`bot.loop.create_task` should close the passed coroutine object to prevent warnings.""" + async def dementati(): + """Dummy coroutine for testing purposes.""" + + coroutine_object = dementati() + + bot = helpers.MockBot() + bot.loop.create_task(coroutine_object) + with self.assertRaises(RuntimeError, msg="cannot reuse already awaited coroutine"): + asyncio.run(coroutine_object) + class MockObjectTests(unittest.TestCase): """Tests the mock objects and mixins we've defined.""" -- cgit v1.2.3 From 2a9b1fc24ffe9679a565c0f9f4678357e9c80e44 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 13 Nov 2019 22:17:18 +0100 Subject: Adjust links rule to use proper MockMessage --- tests/bot/rules/test_links.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index 40336beb0..02a5d5501 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -2,25 +2,19 @@ import unittest from typing import List, NamedTuple, Tuple from bot.rules import links -from tests.helpers import async_test - - -class FakeMessage(NamedTuple): - author: str - content: str +from tests.helpers import MockMessage, async_test class Case(NamedTuple): - recent_messages: List[FakeMessage] - relevant_messages: Tuple[FakeMessage] + recent_messages: List[MockMessage] culprit: Tuple[str] total_links: int -def msg(author: str, total_links: int) -> FakeMessage: +def msg(author: str, total_links: int) -> MockMessage: """Makes a message with `total_links` links.""" content = " ".join(["https://pydis.com"] * total_links) - return FakeMessage(author=author, content=content) + return MockMessage(author=author, content=content) class LinksTests(unittest.TestCase): @@ -61,26 +55,28 @@ class LinksTests(unittest.TestCase): cases = ( Case( [msg("bob", 1), msg("bob", 2)], - (msg("bob", 1), msg("bob", 2)), ("bob",), 3 ), Case( [msg("alice", 1), msg("alice", 1), msg("alice", 1)], - (msg("alice", 1), msg("alice", 1), msg("alice", 1)), ("alice",), 3 ), Case( [msg("alice", 2), msg("bob", 3), msg("alice", 1)], - (msg("alice", 2), msg("alice", 1)), ("alice",), 3 ) ) - for recent_messages, relevant_messages, culprit, total_links in cases: + for recent_messages, culprit, total_links in cases: last_message = recent_messages[0] + relevant_messages = tuple( + msg + for msg in recent_messages + if msg.author == last_message.author + ) with self.subTest( last_message=last_message, -- cgit v1.2.3 From a2617d197f4863123caa33076d89b7612a902d60 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 13 Nov 2019 22:42:25 +0100 Subject: Adjust attachments rule to use MockMessage, restructure test cases --- tests/bot/rules/test_attachments.py | 43 ++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 4bb0acf7c..2f8294922 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -1,26 +1,17 @@ import asyncio import unittest -from dataclasses import dataclass -from typing import Any, List from bot.rules import attachments +from tests.helpers import MockMessage -# Using `MagicMock` sadly doesn't work for this usecase -# since it's __eq__ compares the MagicMock's ID. We just -# want to compare the actual attributes we set. -@dataclass -class FakeMessage: - author: str - attachments: List[Any] - - -def msg(total_attachments: int) -> FakeMessage: - return FakeMessage(author='lemon', attachments=list(range(total_attachments))) +def msg(total_attachments: int) -> MockMessage: + """Builds a message with `total_attachments` attachments.""" + return MockMessage(author='lemon', attachments=list(range(total_attachments))) class AttachmentRuleTests(unittest.TestCase): - """Tests applying the `attachment` antispam rule.""" + """Tests applying the `attachments` antispam rule.""" def test_allows_messages_without_too_many_attachments(self): """Messages without too many attachments are allowed as-is.""" @@ -38,13 +29,25 @@ class AttachmentRuleTests(unittest.TestCase): def test_disallows_messages_with_too_many_attachments(self): """Messages with too many attachments trigger the rule.""" cases = ( - ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), - ((msg(6),), [msg(6)], 6), - ((msg(1),) * 6, [msg(1)] * 6, 6), + ([msg(4), msg(0), msg(6)], 10), + ([msg(6)], 6), + ([msg(1)] * 6, 6), ) - for messages, relevant_messages, total in cases: - with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): - last_message, *recent_messages = messages + for messages, total in cases: + last_message, *recent_messages = messages + relevant_messages = [last_message] + [ + msg + for msg in recent_messages + if msg.author == last_message.author + and len(msg.attachments) > 0 + ] + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + relevant_messages=relevant_messages, + total=total + ): coro = attachments.apply(last_message, recent_messages, {'max': 5}) self.assertEqual( asyncio.run(coro), -- cgit v1.2.3 From dd098d91e35c2e333af14919d7405fe47f298ac2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 13 Nov 2019 22:48:59 +0100 Subject: Use async_test helper to simplify coro testing --- tests/bot/rules/test_attachments.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 2f8294922..770dd3201 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -1,8 +1,7 @@ -import asyncio import unittest from bot.rules import attachments -from tests.helpers import MockMessage +from tests.helpers import MockMessage, async_test def msg(total_attachments: int) -> MockMessage: @@ -13,7 +12,8 @@ def msg(total_attachments: int) -> MockMessage: class AttachmentRuleTests(unittest.TestCase): """Tests applying the `attachments` antispam rule.""" - def test_allows_messages_without_too_many_attachments(self): + @async_test + async def test_allows_messages_without_too_many_attachments(self): """Messages without too many attachments are allowed as-is.""" cases = ( (msg(0), msg(0), msg(0)), @@ -22,17 +22,23 @@ class AttachmentRuleTests(unittest.TestCase): ) for last_message, *recent_messages in cases: - with self.subTest(last_message=last_message, recent_messages=recent_messages): - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - self.assertIsNone(asyncio.run(coro)) + with self.subTest( + last_message=last_message, + recent_messages=recent_messages + ): + self.assertIsNone( + await attachments.apply(last_message, recent_messages, {'max': 5}) + ) - def test_disallows_messages_with_too_many_attachments(self): + @async_test + async def test_disallows_messages_with_too_many_attachments(self): """Messages with too many attachments trigger the rule.""" cases = ( ([msg(4), msg(0), msg(6)], 10), ([msg(6)], 6), ([msg(1)] * 6, 6), ) + for messages, total in cases: last_message, *recent_messages = messages relevant_messages = [last_message] + [ @@ -48,8 +54,7 @@ class AttachmentRuleTests(unittest.TestCase): relevant_messages=relevant_messages, total=total ): - coro = attachments.apply(last_message, recent_messages, {'max': 5}) self.assertEqual( - asyncio.run(coro), + await attachments.apply(last_message, recent_messages, {'max': 5}), (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) ) -- cgit v1.2.3 From 501aa5655c8039f43b3cf3106474b8be16b4074a Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 14 Nov 2019 12:05:18 +0700 Subject: Condensed logic, now only check for `add` `remove` `same` diff_type only. --- bot/cogs/moderation/modlog.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 3a7e0d3ce..ce2a5e1f7 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -656,15 +656,13 @@ class ModLog(Cog, name="ModLog"): _before.append(f"[{sub}](http://o.hi)") elif diff_type == '+': _after.append(f"[{sub}](http://o.hi)") - elif len(words) > 2: - new = ( - f"{words[0] if index > 0 else ''}" - " ... " - f"{words[-1] if index < len(diff_groups) - 1 else ''}" - ) - _before.append(new) - _after.append(new) elif diff_type == ' ': + if len(words) > 2: + sub = ( + f"{words[0] if index > 0 else ''}" + " ... " + f"{words[-1] if index < len(diff_groups) - 1 else ''}" + ) _before.append(sub) _after.append(sub) -- cgit v1.2.3 From ccda39c5e42e94011c9c1bd14080d004d3d61f02 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 14 Nov 2019 10:50:49 +0100 Subject: Add bot=False default value to MockMember By default, a mocked value is considered `truthy` in Python, like all non-empty/non-zero/non-None values in Python. This means that if an attribute is not explicitly set on a mock, it will evaluate at as truthy in a boolean context, since the mock will provide a truthy mocked value by default. This is not the best default value for the `bot` attribute of our MockMember type, since members are rarely bots. It makes much more intuitive sense to me to consider a member to not be a bot, unless we explicitly set `bot=True`. This commit sets that sensible default value that can be overwritten by passing `bot=False` to the constructor or setting the `object.bot` attribute to `False` after the creation of the mock. --- tests/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/helpers.py b/tests/helpers.py index 22f07934f..199d45700 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -242,7 +242,7 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin information, see the `MockGuild` docstring. """ def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: - default_kwargs = {'name': 'member', 'id': next(self.discord_id)} + default_kwargs = {'name': 'member', 'id': next(self.discord_id), 'bot': False} super().__init__(spec_set=member_instance, **collections.ChainMap(kwargs, default_kwargs)) self.roles = [MockRole(name="@everyone", position=1, id=0)] -- cgit v1.2.3 From 61051f9cc5abbf571dfa13c49324109ef16f78fc Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 14 Nov 2019 10:58:40 +0100 Subject: Add MockAttachment type and attachments default for MockMessage As stated from the start, our intention is to add custom mock types as we need them for testing. While writing tests for DuckPond, I noticed that we did not have a mock type for Attachments, so I added one with this commit. In addition, I think it's a very sensible for MockMessage to have an empty list as a default value for the `attachements` attribute. This is equal to what `discord.Message` returns for a message without attachments and makes sure that if you don't explicitely add an attachment to a message, `MockMessage.attachments` tests as falsey. --- tests/helpers.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/helpers.py b/tests/helpers.py index 199d45700..3e43679fe 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -355,6 +355,20 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): self.channel = kwargs.get('channel', MockTextChannel()) +attachment_instance = discord.Attachment(data=unittest.mock.MagicMock(id=1), state=unittest.mock.MagicMock()) + + +class MockAttachment(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Attachment objects. + + Instances of this class will follow the specifications of `discord.Attachment` instances. For + more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec_set=attachment_instance, **kwargs) + + class MockMessage(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Message objects. @@ -364,7 +378,8 @@ class MockMessage(CustomMockMixin, unittest.mock.MagicMock): """ def __init__(self, **kwargs) -> None: - super().__init__(spec_set=message_instance, **kwargs) + default_kwargs = {'attachments': []} + super().__init__(spec_set=message_instance, **collections.ChainMap(kwargs, default_kwargs)) self.author = kwargs.get('author', MockMember()) self.channel = kwargs.get('channel', MockTextChannel()) -- cgit v1.2.3 From 54598dd769b320a4284594835c15eabf9875b7aa Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:15:32 +0100 Subject: Fix bug in attachments rule where last_message could potentially count twice in the sum of attachments --- bot/rules/attachments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/rules/attachments.py b/bot/rules/attachments.py index c550aed76..00bb2a949 100644 --- a/bot/rules/attachments.py +++ b/bot/rules/attachments.py @@ -7,14 +7,14 @@ async def apply( last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: """Detects total attachments exceeding the limit sent by a single user.""" - relevant_messages = [last_message] + [ + relevant_messages = tuple( msg for msg in recent_messages if ( msg.author == last_message.author and len(msg.attachments) > 0 ) - ] + ) total_recent_attachments = sum(len(msg.attachments) for msg in relevant_messages) if total_recent_attachments > config['max']: -- cgit v1.2.3 From eef447a2c4e237a56b8f3cb72ee3e4bc54e7961c Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:16:23 +0100 Subject: Adjust attachments rule unit test to correcty build the arguments for the tested rule --- tests/bot/rules/test_attachments.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 770dd3201..a43741fcc 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -21,7 +21,9 @@ class AttachmentRuleTests(unittest.TestCase): (msg(0),), ) - for last_message, *recent_messages in cases: + for recent_messages in cases: + last_message = recent_messages[0] + with self.subTest( last_message=last_message, recent_messages=recent_messages @@ -39,14 +41,16 @@ class AttachmentRuleTests(unittest.TestCase): ([msg(1)] * 6, 6), ) - for messages, total in cases: - last_message, *recent_messages = messages - relevant_messages = [last_message] + [ + for recent_messages, total in cases: + last_message = recent_messages[0] + relevant_messages = tuple( msg for msg in recent_messages - if msg.author == last_message.author - and len(msg.attachments) > 0 - ] + if ( + msg.author == last_message.author + and len(msg.attachments) > 0 + ) + ) with self.subTest( last_message=last_message, -- cgit v1.2.3 From 37b526f372ebc981f5691c5aca1ca8c721da77f6 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:18:57 +0100 Subject: Hold recent_messages in a list to respect type hint, set config in setUp --- tests/bot/rules/test_attachments.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index a43741fcc..fa6b63654 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -12,13 +12,16 @@ def msg(total_attachments: int) -> MockMessage: class AttachmentRuleTests(unittest.TestCase): """Tests applying the `attachments` antispam rule.""" + def setUp(self): + self.config = {"max": 5} + @async_test async def test_allows_messages_without_too_many_attachments(self): """Messages without too many attachments are allowed as-is.""" cases = ( - (msg(0), msg(0), msg(0)), - (msg(2), msg(2)), - (msg(0),), + [msg(0), msg(0), msg(0)], + [msg(2), msg(2)], + [msg(0)], ) for recent_messages in cases: @@ -29,7 +32,7 @@ class AttachmentRuleTests(unittest.TestCase): recent_messages=recent_messages ): self.assertIsNone( - await attachments.apply(last_message, recent_messages, {'max': 5}) + await attachments.apply(last_message, recent_messages, self.config) ) @async_test @@ -59,6 +62,6 @@ class AttachmentRuleTests(unittest.TestCase): total=total ): self.assertEqual( - await attachments.apply(last_message, recent_messages, {'max': 5}), + await attachments.apply(last_message, recent_messages, self.config), (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) ) -- cgit v1.2.3 From 01731a8873f13cc8a85d08147941ffba7284cf20 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:33:12 +0100 Subject: Make complex test cases namedtuples, recognize between various authors, pass config to subTest --- tests/bot/rules/test_attachments.py | 55 +++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index fa6b63654..d8d1b341f 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -1,12 +1,19 @@ import unittest +from typing import List, NamedTuple, Tuple from bot.rules import attachments from tests.helpers import MockMessage, async_test -def msg(total_attachments: int) -> MockMessage: +class Case(NamedTuple): + recent_messages: List[MockMessage] + culprit: Tuple[str] + total_attachments: int + + +def msg(author: str, total_attachments: int) -> MockMessage: """Builds a message with `total_attachments` attachments.""" - return MockMessage(author='lemon', attachments=list(range(total_attachments))) + return MockMessage(author=author, attachments=list(range(total_attachments))) class AttachmentRuleTests(unittest.TestCase): @@ -19,9 +26,9 @@ class AttachmentRuleTests(unittest.TestCase): async def test_allows_messages_without_too_many_attachments(self): """Messages without too many attachments are allowed as-is.""" cases = ( - [msg(0), msg(0), msg(0)], - [msg(2), msg(2)], - [msg(0)], + [msg("bob", 0), msg("bob", 0), msg("bob", 0)], + [msg("bob", 2), msg("bob", 2)], + [msg("bob", 2), msg("alice", 2), msg("bob", 2)], ) for recent_messages in cases: @@ -29,7 +36,8 @@ class AttachmentRuleTests(unittest.TestCase): with self.subTest( last_message=last_message, - recent_messages=recent_messages + recent_messages=recent_messages, + config=self.config ): self.assertIsNone( await attachments.apply(last_message, recent_messages, self.config) @@ -39,12 +47,29 @@ class AttachmentRuleTests(unittest.TestCase): async def test_disallows_messages_with_too_many_attachments(self): """Messages with too many attachments trigger the rule.""" cases = ( - ([msg(4), msg(0), msg(6)], 10), - ([msg(6)], 6), - ([msg(1)] * 6, 6), + Case( + [msg("bob", 4), msg("bob", 0), msg("bob", 6)], + ("bob",), + 10 + ), + Case( + [msg("bob", 4), msg("alice", 6), msg("bob", 2)], + ("bob",), + 6 + ), + Case( + [msg("alice", 6)], + ("alice",), + 6 + ), + ( + [msg("alice", 1) for _ in range(6)], + ("alice",), + 6 + ), ) - for recent_messages, total in cases: + for recent_messages, culprit, total_attachments in cases: last_message = recent_messages[0] relevant_messages = tuple( msg @@ -59,9 +84,15 @@ class AttachmentRuleTests(unittest.TestCase): last_message=last_message, recent_messages=recent_messages, relevant_messages=relevant_messages, - total=total + total_attachments=total_attachments, + config=self.config ): + desired_output = ( + f"sent {total_attachments} attachments in {self.config['max']}s", + culprit, + relevant_messages + ) self.assertEqual( await attachments.apply(last_message, recent_messages, self.config), - (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) + desired_output ) -- cgit v1.2.3 From c74a6c4fb16052c00041b94c3a3e2ef10efe9827 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:34:00 +0100 Subject: Specify assertion to be a tuple comparison --- tests/bot/rules/test_attachments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index d8d1b341f..d7187f315 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -92,7 +92,7 @@ class AttachmentRuleTests(unittest.TestCase): culprit, relevant_messages ) - self.assertEqual( + self.assertTupleEqual( await attachments.apply(last_message, recent_messages, self.config), desired_output ) -- cgit v1.2.3 From 8c64fc637dda73cfa4b79d1f3541d067380e51d8 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:02:40 +0100 Subject: Check only for bot's green checkmark in DuckPond Previously, the presence of any green checkmark as a reaction would prevent a message from being relayed to the duck pond, regardless of the actor of that reaction. Since we only want to check if the bot has already processed this message, we should check for a checkmark added by the bot. This commit adds such a user check. --- bot/cogs/duck_pond.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 45bbc410b..aac023a2e 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -37,12 +37,13 @@ class DuckPond(Cog): return True return False - @staticmethod - def has_green_checkmark(message: Message) -> bool: + async def has_green_checkmark(self, message: Message) -> bool: """Check if the message has a green checkmark reaction.""" for reaction in message.reactions: if reaction.emoji == "✅": - return True + async for user in reaction.users(): + if user == self.bot.user: + return True return False async def send_webhook( @@ -115,7 +116,7 @@ class DuckPond(Cog): return # Does the message already have a green checkmark? - if self.has_green_checkmark(message): + if await self.has_green_checkmark(message): return # Time to count our ducks! -- cgit v1.2.3 From f56f6cebc5300ec3c1b52ec8988ae9c27571c14e Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:09:22 +0100 Subject: Refactor DuckPond msg relay to separate method To allow for separate testing of the code that relays messages to the duck pond, I have moved this part of the code from the event listener to a separate method. The overall logic has remained unchanged. In addition, I've kaizened to things: - Removed unnecessary f-string without interpolation; - Removed double negative (not item not in list) --- bot/cogs/duck_pond.py | 62 +++++++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index aac023a2e..b2b4ad0c2 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -62,7 +62,7 @@ class DuckPond(Cog): embed=embed ) except discord.HTTPException: - log.exception(f"Failed to send a message to the Duck Pool webhook") + log.exception("Failed to send a message to the Duck Pool webhook") async def count_ducks(self, message: Message) -> int: """ @@ -76,8 +76,8 @@ class DuckPond(Cog): for reaction in message.reactions: async for user in reaction.users(): - # Is the user or member a staff member? - if not self.is_staff(user) or not user.id not in duck_reactors: + # Is the user a staff member and not already counted as reactor? + if not self.is_staff(user) or user.id in duck_reactors: continue # Is the emoji a duck? @@ -91,6 +91,35 @@ class DuckPond(Cog): duck_reactors.append(user.id) return duck_count + async def relay_message_to_duck_pond(self, message: Message) -> None: + """Relays the message's content and attachments to the duck pond channel.""" + clean_content = message.clean_content + + if clean_content: + await self.send_webhook( + content=message.clean_content, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + + if message.attachments: + try: + await send_attachments(message, self.webhook) + except (errors.Forbidden, errors.NotFound): + e = Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await self.send_webhook( + embed=e, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + except discord.HTTPException: + log.exception(f"Failed to send an attachment to the webhook") + + await message.add_reaction("✅") + @Cog.listener() async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: """ @@ -124,32 +153,7 @@ class DuckPond(Cog): # If we've got more than the required amount of ducks, send the message to the duck_pond. if duck_count >= constants.DuckPond.threshold: - clean_content = message.clean_content - - if clean_content: - await self.send_webhook( - content=message.clean_content, - username=message.author.display_name, - avatar_url=message.author.avatar_url - ) - - if message.attachments: - try: - await send_attachments(message, self.webhook) - except (errors.Forbidden, errors.NotFound): - e = Embed( - description=":x: **This message contained an attachment, but it could not be retrieved**", - color=Color.red() - ) - await self.send_webhook( - embed=e, - username=message.author.display_name, - avatar_url=message.author.avatar_url - ) - except discord.HTTPException: - log.exception(f"Failed to send an attachment to the webhook") - - await message.add_reaction("✅") + await self.relay_message_to_duck_pond(message) @Cog.listener() async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: -- cgit v1.2.3 From 89890d6e1b673622cba918be48f325540e45db9e Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:15:01 +0100 Subject: Move payload checks to start of DuckPond.on_raw_reaction_add The `DuckPond.on_raw_message_add` event listener makes an API call to fetch the message the reaction was added to. However, we don't need to fetch the message if the reaction that was added is not relevant to the duck pond. To prevent such unnecessary API calls, I have moved the code that checks for the relevance of the reaction event to before the code that fetches the message. --- bot/cogs/duck_pond.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index b2b4ad0c2..68fb09408 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -129,6 +129,13 @@ class DuckPond(Cog): amount of ducks specified in the config under duck_pond/threshold, it will send the message off to the duck pond. """ + # Is the emoji in the reaction a duck? + if payload.emoji.is_custom_emoji(): + if payload.emoji.id not in constants.DuckPond.custom_emojis: + return + elif payload.emoji.name != "🦆": + return + channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) message = await channel.fetch_message(payload.message_id) member = discord.utils.get(message.guild.members, id=payload.user_id) @@ -137,13 +144,6 @@ class DuckPond(Cog): if not self.is_staff(member) or member.bot: return - # Is the emoji in the reaction a duck? - if payload.emoji.is_custom_emoji(): - if payload.emoji.id not in constants.DuckPond.custom_emojis: - return - elif payload.emoji.name != "🦆": - return - # Does the message already have a green checkmark? if await self.has_green_checkmark(message): return -- cgit v1.2.3 From 2779a912a8fbe29453543a8fd2888a842c3beb47 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:21:33 +0100 Subject: Add `return_value` support and assertions to AsyncIteratorMock The AsyncIteratorMock included in Python 3.8 will work similarly to the mocks of callabes. This means that it allows you to set the items it will yield using the `return_value` attribute. It will also have support for the common Mock-specific assertions. This commit introduces some backports of those features in a slightly simplified way to make the transition to Python 3.8 easier in the future. --- tests/helpers.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 3e43679fe..50652ef9a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -127,14 +127,20 @@ class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): class AsyncIteratorMock: """ - A class to mock asyncronous iterators. + A class to mock asynchronous iterators. This allows async for, which is used in certain Discord.py objects. For example, - an async iterator is returned by the Reaction.users() coroutine. + an async iterator is returned by the Reaction.users() method. """ - def __init__(self, sequence): - self.iter = iter(sequence) + def __init__(self, iterable: Iterable = None): + if iterable is None: + iterable = [] + + self.iter = iter(iterable) + self.iterable = iterable + + self.call_count = 0 def __aiter__(self): return self @@ -145,6 +151,50 @@ class AsyncIteratorMock: except StopIteration: raise StopAsyncIteration + def __call__(self): + """ + Keeps track of the number of times an instance has been called. + + This is useful, since it typically shows that the iterator has actually been used somewhere after we have + instantiated the mock for an attribute that normally returns an iterator when called. + """ + self.call_count += 1 + return self + + @property + def return_value(self): + """Makes `self.iterable` accessible as self.return_value.""" + return self.iterable + + @return_value.setter + def return_value(self, iterable): + """Stores the `return_value` as `self.iterable` and its iterator as `self.iter`.""" + self.iter = iter(iterable) + self.iterable = iterable + + def assert_called(self): + """Asserts if the AsyncIteratorMock instance has been called at least once.""" + if self.call_count == 0: + raise AssertionError("Expected AsyncIteratorMock to have been called.") + + def assert_called_once(self): + """Asserts if the AsyncIteratorMock instance has been called exactly once.""" + if self.call_count != 1: + raise AssertionError( + f"Expected AsyncIteratorMock to have been called once. Called {self.call_count} times." + ) + + def assert_not_called(self): + """Asserts if the AsyncIteratorMock instance has not been called.""" + if self.call_count != 0: + raise AssertionError( + f"Expected AsyncIteratorMock to not have been called once. Called {self.call_count} times." + ) + + def reset_mock(self): + """Resets the call count, but not the return value or iterator.""" + self.call_count = 0 + # Create a guild instance to get a realistic Mock of `discord.Guild` guild_data = { -- cgit v1.2.3 From 2c77288eb3ff081e70508094bb8d030900860259 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:28:56 +0100 Subject: Add MockUser to mock `discord.User` objects I have added a special mock that follows the specifications of a `discord.User` instance. This is useful, since `Users` have less attributes available than `discord.Members`. Since this difference in availability of information can be important, we should not use a `MockMember` to mock a `discord.user`. --- tests/helpers.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/helpers.py b/tests/helpers.py index 50652ef9a..4da6bf84d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -303,6 +303,25 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin self.mention = f"@{self.name}" +# Create a User instance to get a realistic Mock of `discord.User` +user_instance = discord.User(data=unittest.mock.MagicMock(), state=unittest.mock.MagicMock()) + + +class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock User objects. + + Instances of this class will follow the specifications of `discord.User` instances. For more + information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + default_kwargs = {'name': 'user', 'id': next(self.discord_id), 'bot': False} + super().__init__(spec_set=user_instance, **collections.ChainMap(kwargs, default_kwargs)) + + if 'mention' not in kwargs: + self.mention = f"@{self.name}" + + # 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 -- cgit v1.2.3 From 647370d7881d1ab242186599adb76a56a0815150 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:34:46 +0100 Subject: Adjust MockReaction for new AsyncIteratorMock protocol The new AsyncIteratorMock no longer needs an additional method to be used with a Mock object. --- tests/helpers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 4da6bf84d..13852397f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -500,7 +500,5 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): super().__init__(spec_set=reaction_instance, **kwargs) self.emoji = kwargs.get('emoji', MockEmoji()) self.message = kwargs.get('message', MockMessage()) - self.user_list = AsyncIteratorMock(kwargs.get('user_list', [])) + self.users = AsyncIteratorMock(kwargs.get('users', [])) - def users(self): - return self.user_list -- cgit v1.2.3 From b42a7b5b7f2c1c9f9924eeb9d39f7767306824ec Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:37:02 +0100 Subject: Add MockAsyncWebhook to mock `discord.Webhook` objects I have added a mock type to mock `discord.Webhook` instances. Note that the current type is specifically meant to mock webhooks that use an AsyncAdaptor and therefore has AsyncMock/coroutine mocks for the "maybe-coroutine" methods specified in the `discord.py` docs. --- tests/helpers.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/helpers.py b/tests/helpers.py index 13852397f..b2daae92d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -502,3 +502,24 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): self.message = kwargs.get('message', MockMessage()) self.users = AsyncIteratorMock(kwargs.get('users', [])) + +webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), adapter=unittest.mock.MagicMock()) + + +class MockAsyncWebhook(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Webhook objects using an AsyncWebhookAdapter. + + Instances of this class will follow the specifications of `discord.Webhook` instances. For + more information, see the `MockGuild` docstring. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(spec_set=webhook_instance, **kwargs) + + # Because Webhooks can also use a synchronous "WebhookAdapter", the methods are not defined + # as coroutines. That's why we need to set the methods manually. + self.send = AsyncMock() + self.edit = AsyncMock() + self.delete = AsyncMock() + self.execute = AsyncMock() -- cgit v1.2.3 From a692a95896328adf1d52c5a5548e0c72540d6cbc Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:39:51 +0100 Subject: Add unit tests with full coverage for `bot.cogs.duck_pond` This commit adds unit tests that provide a full branch coverage of the `bot.cogs.duck_pond` file. --- tests/bot/cogs/test_duck_pond.py | 649 +++++++++++++++++++++++++++++---------- 1 file changed, 490 insertions(+), 159 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 088d8ac79..ceefc286f 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -1,193 +1,524 @@ import asyncio import logging +import typing import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch + +import discord from bot import constants from bot.cogs import duck_pond -from tests.helpers import MockBot, MockEmoji, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel +from tests import base +from tests import helpers + +MODULE_PATH = "bot.cogs.duck_pond" + + +class DuckPondTests(base.LoggingTestCase): + """Tests for DuckPond functionality.""" + + @classmethod + def setUpClass(cls): + """Sets up the objects that only have to be initialized once.""" + cls.nonstaff_member = helpers.MockMember(name="Non-staffer") + cls.staff_role = helpers.MockRole(name="Staff role", id=constants.STAFF_ROLES[0]) + cls.staff_member = helpers.MockMember(name="staffer", roles=[cls.staff_role]) -class DuckPondTest(unittest.TestCase): - """Tests the `DuckPond` cog.""" + cls.checkmark_emoji = "\N{White Heavy Check Mark}" + cls.thumbs_up_emoji = "\N{Thumbs Up Sign}" + cls.unicode_duck_emoji = "\N{Duck}" + cls.duck_pond_emoji = helpers.MockPartialEmoji(id=constants.DuckPond.custom_emojis[0]) + cls.non_duck_custom_emoji = helpers.MockPartialEmoji(id=123) def setUp(self): - """Adds the cog, a bot, and the mocks we'll need for our tests.""" - self.bot = MockBot() + """Sets up the objects that need to be refreshed before each test.""" + self.bot = helpers.MockBot(user=helpers.MockMember(id=46692)) self.cog = duck_pond.DuckPond(bot=self.bot) - # Set up some constants - self.CHANNEL_ID = 555 - self.MESSAGE_ID = 666 - self.BOT_ID = 777 - self.CONTRIB_ID = 888 - self.ADMIN_ID = 999 - - # Override the constants we'll be needing - constants.STAFF_ROLES = (123,) - constants.DuckPond.custom_emojis = (789,) - constants.DuckPond.threshold = 1 - - # Set up some roles - self.admin_role = MockRole(name="Admins", role_id=123) - self.contrib_role = MockRole(name="Contributor", role_id=456) - - # Set up some users - self.admin_member_1 = MockMember(roles=(self.admin_role,), id=self.ADMIN_ID) - self.admin_member_2 = MockMember(roles=(self.admin_role,), id=911) - self.contrib_member = MockMember(roles=(self.contrib_role,), id=self.CONTRIB_ID) - self.bot_member = MockMember(roles=(self.contrib_role,), id=self.BOT_ID, bot=True) - self.no_role_member = MockMember() - - # Set up emojis - self.checkmark_emoji = "✅" - self.thumbs_up_emoji = "👍" - self.unicode_duck_emoji = "🦆" - self.yellow_ducky_emoji = MockEmoji(id=789) - - # Set up reactions - self.checkmark_reaction = MockReaction( - emoji=self.checkmark_emoji, - user_list=[self.admin_member_1] - ) - self.thumbs_up_reaction = MockReaction( - emoji=self.thumbs_up_emoji, - user_list=[self.admin_member_1, self.contrib_member] - ) - self.yellow_ducky_reaction = MockReaction( - emoji=self.yellow_ducky_emoji, - user_list=[self.admin_member_1, self.contrib_member] - ) - self.unicode_duck_reaction_1 = MockReaction( - emoji=self.unicode_duck_emoji, - user_list=[self.admin_member_1] + def test_duck_pond_correctly_initializes(self): + """`__init__ should set `bot` and `webhook_id` attributes and schedule `fetch_webhook`.""" + bot = helpers.MockBot() + cog = MagicMock() + + duck_pond.DuckPond.__init__(cog, bot) + + self.assertEqual(cog.bot, bot) + self.assertEqual(cog.webhook_id, constants.Webhooks.duck_pond) + bot.loop.create_loop.called_once_with(cog.fetch_webhook()) + + def test_fetch_webhook_succeeds_without_connectivity_issues(self): + """The `fetch_webhook` method waits until `READY` event and sets the `webhook` attribute.""" + self.bot.fetch_webhook.return_value = "dummy webhook" + self.cog.webhook_id = 1 + + asyncio.run(self.cog.fetch_webhook()) + + self.bot.wait_until_ready.assert_called_once() + self.bot.fetch_webhook.assert_called_once_with(1) + self.assertEqual(self.cog.webhook, "dummy webhook") + + def test_fetch_webhook_logs_when_unable_to_fetch_webhook(self): + """The `fetch_webhook` method should log an exception when it fails to fetch the webhook.""" + self.bot.fetch_webhook.side_effect = discord.HTTPException(response=MagicMock(), message="Not found.") + self.cog.webhook_id = 1 + + log = logging.getLogger('bot.cogs.duck_pond') + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + asyncio.run(self.cog.fetch_webhook()) + + self.bot.wait_until_ready.assert_called_once() + self.bot.fetch_webhook.assert_called_once_with(1) + + self.assertEqual(len(log_watcher.records), 1) + + [record] = log_watcher.records + self.assertEqual(record.message, f"Failed to fetch webhook with id `{self.cog.webhook_id}`") + self.assertEqual(record.levelno, logging.ERROR) + + def test_is_staff_returns_correct_values_based_on_instance_passed(self): + """The `is_staff` method should return correct values based on the instance passed.""" + test_cases = ( + (helpers.MockUser(name="User instance"), False), + (helpers.MockMember(name="Member instance without staff role"), False), + (helpers.MockMember(name="Member instance with staff role", roles=[self.staff_role]), True) ) - self.unicode_duck_reaction_2 = MockReaction( - emoji=self.unicode_duck_emoji, - user_list=[self.admin_member_2] + + for user, expected_return in test_cases: + actual_return = self.cog.is_staff(user) + with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return): + self.assertEqual(expected_return, actual_return) + + @helpers.async_test + async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self): + """The `has_green_checkmark` method should only return `True` if one is present.""" + test_cases = ( + ( + "No reactions", helpers.MockMessage(), False + ), + ( + "No green check mark reactions", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji), + helpers.MockReaction(emoji=self.thumbs_up_emoji) + ]), + False + ), + ( + "Green check mark reaction, but not from our bot", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji), + helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member]) + ]), + False + ), + ( + "Green check mark reaction, with one from the bot", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji), + helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) + ]), + True + ) ) - self.bot_reaction = MockReaction( - emoji=self.yellow_ducky_emoji, - user_list=[self.bot_member] + + for description, message, expected_return in test_cases: + actual_return = await self.cog.has_green_checkmark(message) + with self.subTest( + test_case=description, + expected_return=expected_return, + actual_return=actual_return + ): + self.assertEqual(expected_return, actual_return) + + def test_send_webhook_correctly_passes_on_arguments(self): + """The `send_webhook` method should pass the arguments to the webhook correctly.""" + self.cog.webhook = helpers.MockAsyncWebhook() + + content = "fake content" + username = "fake username" + avatar_url = "fake avatar_url" + embed = "fake embed" + + asyncio.run(self.cog.send_webhook(content, username, avatar_url, embed)) + + self.cog.webhook.send.assert_called_once_with( + content=content, + username=username, + avatar_url=avatar_url, + embed=embed ) - self.contrib_reaction = MockReaction( - emoji=self.yellow_ducky_emoji, - user_list=[self.contrib_member] + + def test_send_webhook_logs_when_sending_message_fails(self): + """The `send_webhook` method should catch a `discord.HTTPException` and log accordingly.""" + self.cog.webhook = helpers.MockAsyncWebhook() + self.cog.webhook.send.side_effect = discord.HTTPException(response=MagicMock(), message="Something failed.") + + log = logging.getLogger('bot.cogs.duck_pond') + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + asyncio.run(self.cog.send_webhook()) + + self.assertEqual(len(log_watcher.records), 1) + + [record] = log_watcher.records + self.assertEqual(record.message, "Failed to send a message to the Duck Pool webhook") + self.assertEqual(record.levelno, logging.ERROR) + + def _get_reaction( + self, + emoji: typing.Union[str, helpers.MockEmoji], + staff: int = 0, + nonstaff: int = 0 + ) -> helpers.MockReaction: + staffers = [helpers.MockMember(roles=[self.staff_role]) for _ in range(staff)] + nonstaffers = [helpers.MockMember() for _ in range(nonstaff)] + return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers) + + @helpers.async_test + async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self): + """The `count_ducks` method should return the number of unique staffers who gave a duck.""" + test_cases = ( + # Simple test cases + # A message without reactions should return 0 + ( + "No reactions", + helpers.MockMessage(), + 0 + ), + # A message with a non-duck reaction from a non-staffer should return 0 + ( + "Non-duck reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, nonstaff=1)]), + 0 + ), + # A message with a non-duck reaction from a staffer should return 0 + ( + "Non-duck reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.non_duck_custom_emoji, staff=1)]), + 0 + ), + # A message with a non-duck reaction from a non-staffer and staffer should return 0 + ( + "Non-duck reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, staff=1, nonstaff=1)]), + 0 + ), + # A message with a unicode duck reaction from a non-staffer should return 0 + ( + "Unicode Duck Reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, nonstaff=1)]), + 0 + ), + # A message with a unicode duck reaction from a staffer should return 1 + ( + "Unicode Duck Reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1)]), + 1 + ), + # A message with a unicode duck reaction from a non-staffer and staffer should return 1 + ( + "Unicode Duck Reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1, nonstaff=1)]), + 1 + ), + # A message with a duckpond duck reaction from a non-staffer should return 0 + ( + "Duckpond Duck Reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, nonstaff=1)]), + 0 + ), + # A message with a duckpond duck reaction from a staffer should return 1 + ( + "Duckpond Duck Reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1)]), + 1 + ), + # A message with a duckpond duck reaction from a non-staffer and staffer should return 1 + ( + "Duckpond Duck Reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1, nonstaff=1)]), + 1 + ), + + # Complex test cases + # A message with duckpond duck reactions from 3 staffers and 2 non-staffers returns 3 + ( + "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2)]), + 3 + ), + # A staffer with multiple duck reactions only counts once + ( + "Two different duck reactions from the same staffer", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), + ]), + 1 + ), + # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif) + ( + "Reaction with non-Emoji/str emoij from 3 staffers + 2 non-staffers", + helpers.MockMessage(reactions=[self._get_reaction(emoji=100, staff=3, nonstaff=2)]), + 0 + ), + # We correctly sum when multiple reactions are provided. + ( + "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", + helpers.MockMessage(reactions=[ + self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), + self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), + ]), + 3+4 + ), ) - # Set up a messages - self.checkmark_message = MockMessage(reactions=(self.checkmark_reaction,)) - self.thumbs_up_message = MockMessage(reactions=(self.thumbs_up_reaction,)) - self.yellow_ducky_message = MockMessage(reactions=(self.yellow_ducky_reaction,)) - self.unicode_duck_message = MockMessage(reactions=(self.unicode_duck_reaction_1,)) - self.double_unicode_duck_message = MockMessage( - reactions=(self.unicode_duck_reaction_1, self.unicode_duck_reaction_2) + for description, message, expected_count in test_cases: + actual_count = await self.cog.count_ducks(message) + with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count): + self.assertEqual(expected_count, actual_count) + + @helpers.async_test + async def test_relay_message_to_duck_pond_correctly_relays_content_and_attachments(self): + """The `relay_message_to_duck_pond` method should correctly relay message content and attachments.""" + send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook" + send_attachments_path = f"{MODULE_PATH}.send_attachments" + + self.cog.webhook = helpers.MockAsyncWebhook() + + test_values = ( + (helpers.MockMessage(clean_content="", attachments=[]), False, False), + (helpers.MockMessage(clean_content="message", attachments=[]), True, False), + (helpers.MockMessage(clean_content="", attachments=["attachment"]), False, True), + (helpers.MockMessage(clean_content="message", attachments=["attachment"]), True, True), ) - self.double_mixed_duck_message = MockMessage( - reactions=(self.unicode_duck_reaction_1, self.yellow_ducky_reaction) + + for message, expect_webhook_call, expect_attachment_call in test_values: + with patch(send_webhook_path, new_callable=helpers.AsyncMock) as send_webhook: + with patch(send_attachments_path, new_callable=helpers.AsyncMock) as send_attachments: + with self.subTest(clean_content=message.clean_content, attachments=message.attachments): + await self.cog.relay_message_to_duck_pond(message) + + self.assertEqual(expect_webhook_call, send_webhook.called) + self.assertEqual(expect_attachment_call, send_attachments.called) + + message.add_reaction.assert_called_once_with(self.checkmark_emoji) + message.reset_mock() + + @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) + @helpers.async_test + async def test_relay_message_to_duck_pond_handles_send_attachments_exceptions(self, send_attachments, send_webhook): + """The `relay_message_to_duck_pond` method should handle exceptions when calling `send_attachment`.""" + + message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) + side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) + + self.cog.webhook = helpers.MockAsyncWebhook() + log = logging.getLogger("bot.cogs.duck_pond") + + # Subtests for the first `except` block + for side_effect in side_effects: + send_attachments.side_effect = side_effect + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertNotLogs(logger=log, level=logging.ERROR): + await self.cog.relay_message_to_duck_pond(message) + + self.assertEqual(send_webhook.call_count, 2) + send_webhook.reset_mock() + + # Subtests for the second `except` block + side_effect = discord.HTTPException(MagicMock(), "") + send_attachments.side_effect = side_effect + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + await self.cog.relay_message_to_duck_pond(message) + + send_webhook.assert_called_once_with( + content=message.clean_content, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + + self.assertEqual(len(log_watcher.records), 1) + + [record] = log_watcher.records + self.assertEqual(record.message, "Failed to send an attachment to the webhook") + self.assertEqual(record.levelno, logging.ERROR) + + def _raw_reaction_mocks(self, channel_id, message_id, user_id): + """Sets up mocks for tests of the `on_raw_reaction_add` event listener.""" + channel = helpers.MockTextChannel(id=channel_id) + self.bot.get_all_channels.return_value = (channel,) + + message = helpers.MockMessage(id=message_id) + + channel.fetch_message.return_value = message + + member = helpers.MockMember(id=user_id, roles=[self.staff_role]) + message.guild.members = (member,) + + payload = MagicMock(channel_id=channel_id, message_id=message_id, user_id=user_id) + + return channel, message, member, payload + + @helpers.async_test + async def test_on_raw_reaction_add_returns_for_non_relevant_emojis(self): + """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" + payload_custom_emoji = MagicMock(label="Non-Duck Custom Emoji") + payload_custom_emoji.emoji.is_custom_emoji.return_value = True + payload_custom_emoji.emoji.id = 12345 + + payload_unicode_emoji = MagicMock(label="Non-Duck Unicode Emoji") + payload_unicode_emoji.emoji.is_custom_emoji.return_value = False + payload_unicode_emoji.emoji.name = self.thumbs_up_emoji + + for payload in (payload_custom_emoji, payload_unicode_emoji): + with self.subTest(case=payload.label), patch(f"{MODULE_PATH}.discord.utils.get") as discord_utils_get: + self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) + discord_utils_get.assert_not_called() + + @helpers.async_test + async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): + """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" + channel_id = 1234 + message_id = 2345 + user_id = 3456 + + channel, message, _, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) + + test_cases = ( + ("non-staff member", helpers.MockMember(id=user_id)), + ("bot staff member", helpers.MockMember(id=user_id, roles=[self.staff_role], bot=True)), ) - self.bot_message = MockMessage(reactions=(self.bot_reaction,)) - self.contrib_message = MockMessage(reactions=(self.contrib_reaction,)) - self.no_reaction_message = MockMessage() - - # Set up some channels - self.text_channel = MockTextChannel(id=self.CHANNEL_ID) - - @staticmethod - def _mock_send_webhook(content, username, avatar_url, embed): - """Mock for the send_webhook method in DuckPond""" - - def test_is_staff_correctly_identifies_staff(self): - """Test that is_staff correctly identifies a staff member.""" - with self.subTest(): - self.assertTrue(self.cog.is_staff(self.admin_member_1)) - self.assertFalse(self.cog.is_staff(self.contrib_member)) - self.assertFalse(self.cog.is_staff(self.no_role_member)) - - def test_has_green_checkmark_correctly_identifies_messages(self): - """Test that has_green_checkmark recognizes messages with checkmarks.""" - with self.subTest(): - self.assertTrue(self.cog.has_green_checkmark(self.checkmark_message)) - self.assertFalse(self.cog.has_green_checkmark(self.thumbs_up_message)) - self.assertFalse(self.cog.has_green_checkmark(self.no_reaction_message)) - - def test_count_custom_duck_emojis(self): - """Test that count_ducks counts custom ducks correctly.""" - count_no_ducks = self.cog.count_ducks(self.thumbs_up_message) - count_one_duck = self.cog.count_ducks(self.yellow_ducky_message) - with self.subTest(): - self.assertEqual(asyncio.run(count_no_ducks), 0) - self.assertEqual(asyncio.run(count_one_duck), 1) - - def test_count_unicode_duck_emojis(self): - """Test that count_ducks counts unicode ducks correctly.""" - count_one_duck = self.cog.count_ducks(self.unicode_duck_message) - count_two_ducks = self.cog.count_ducks(self.double_unicode_duck_message) - - with self.subTest(): - self.assertEqual(asyncio.run(count_one_duck), 1) - self.assertEqual(asyncio.run(count_two_ducks), 2) - - def test_count_mixed_duck_emojis(self): - """Test that count_ducks counts mixed ducks correctly.""" - count_two_ducks = self.cog.count_ducks(self.double_mixed_duck_message) - - with self.subTest(): - self.assertEqual(asyncio.run(count_two_ducks), 2) - - def test_raw_reaction_add_rejects_bot(self): - """Test that send_webhook is not called if the user is a bot.""" - self.text_channel.fetch_message.return_value = self.bot_message - self.bot.get_all_channels.return_value = (self.text_channel,) - - payload = MagicMock( # RawReactionActionEvent - channel_id=self.CHANNEL_ID, - message_id=self.MESSAGE_ID, - user_id=self.BOT_ID, + payload.emoji = self.duck_pond_emoji + + for description, member in test_cases: + message.guild.members = (member, ) + with self.subTest(test_case=description), patch(f"{MODULE_PATH}.DuckPond.has_green_checkmark") as checkmark: + checkmark.side_effect = AssertionError( + "Expected method to return before calling `self.has_green_checkmark`." + ) + self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) + + # Check that we did make it past the payload checks + channel.fetch_message.assert_called_once() + channel.fetch_message.reset_mock() + + @patch(f"{MODULE_PATH}.DuckPond.is_staff") + @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) + def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff): + """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot.""" + channel_id = 31415926535 + message_id = 27182818284 + user_id = 16180339887 + + channel, message, member, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) + + payload.emoji = helpers.MockPartialEmoji(name=self.unicode_duck_emoji) + payload.emoji.is_custom_emoji.return_value = False + + message.reactions = [helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.bot.user])] + + is_staff.return_value = True + count_ducks.side_effect = AssertionError("Expected method to return before calling `self.count_ducks`") + + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload))) + + # Assert that we've made it past `self.is_staff` + is_staff.assert_called_once() + + @patch(f"{MODULE_PATH}.DuckPond.relay_message_to_duck_pond", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) + @helpers.async_test + async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self, count_ducks, message_relay): + """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" + test_cases = ( + (constants.DuckPond.threshold-1, False), + (constants.DuckPond.threshold, True), + (constants.DuckPond.threshold+1, True), ) - with self.subTest(): - asyncio.run(self.cog.on_raw_reaction_add(payload)) - self.bot.cog.send_webhook.assert_not_called() + channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5) + + payload.emoji = self.duck_pond_emoji + + for duck_count, should_relay in test_cases: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_relay=should_relay): + await self.cog.on_raw_reaction_add(payload) + + # Confirm that we've made it past counting + count_ducks.assert_called_once() + count_ducks.reset_mock() + + # Did we relay a message? + has_relayed = message_relay.called + self.assertEqual(has_relayed, should_relay) + + if should_relay: + message_relay.assert_called_once_with(message) + message_relay.reset_mock() - def test_raw_reaction_add_rejects_non_staff(self): - """Test that send_webhook is not called if the user is not a member of staff.""" - self.text_channel.fetch_message.return_value = self.contrib_message - self.bot.get_all_channels.return_value = (self.text_channel,) + @helpers.async_test + async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): + """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks.""" + checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji) - payload = MagicMock( # RawReactionActionEvent - channel_id=self.CHANNEL_ID, - message_id=self.MESSAGE_ID, - user_id=self.CONTRIB_ID, + message = helpers.MockMessage(id=1234) + + channel = helpers.MockTextChannel(id=98765) + channel.fetch_message.return_value = message + + self.bot.get_all_channels.return_value = (channel, ) + + payload = MagicMock(channel_id=channel.id, message_id=message.id, emoji=checkmark) + + test_cases = ( + (constants.DuckPond.threshold - 1, False), + (constants.DuckPond.threshold, True), + (constants.DuckPond.threshold + 1, True), ) + for duck_count, should_readd_checkmark in test_cases: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_readd_checkmark=should_readd_checkmark): + await self.cog.on_raw_reaction_remove(payload) + + # Check if we fetched the message + channel.fetch_message.assert_called_once_with(message.id) - with self.subTest(): - asyncio.run(self.cog.on_raw_reaction_add(payload)) - self.bot.cog.send_webhook.assert_not_called() + # Check if we actually counted the number of ducks + count_ducks.assert_called_once_with(message) - def test_raw_reaction_add_sends_message_on_valid_input(self): - """Test that send_webhook is called if payload is valid.""" - self.text_channel.fetch_message.return_value = self.unicode_duck_message - self.bot.get_all_channels.return_value = (self.text_channel,) + has_readded_checkmark = message.add_reaction.called + self.assertEqual(should_readd_checkmark, has_readded_checkmark) - payload = MagicMock( # RawReactionActionEvent - channel_id=self.CHANNEL_ID, - message_id=self.MESSAGE_ID, - user_id=self.ADMIN_ID, + if should_readd_checkmark: + message.add_reaction.assert_called_once_with(self.checkmark_emoji) + message.add_reaction.reset_mock() + + # reset mocks + channel.fetch_message.reset_mock() + count_ducks.reset_mock() + message.reset_mock() + + def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self): + """The `on_raw_reaction_remove` listener should ignore the removal of non-check mark emojis.""" + channel = helpers.MockTextChannel(id=98765) + + channel.fetch_message.side_effect = AssertionError( + "Expected method to return before calling `channel.fetch_message`" ) - with self.subTest(): - asyncio.run(self.cog.on_raw_reaction_add(payload)) - self.bot.cog.send_webhook.assert_called_once() + self.bot.get_all_channels.return_value = (channel, ) + + payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id) - def test_raw_reaction_remove_rejects_non_checkmarks(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload))) - def test_raw_reaction_remove_prevents_checkmark_removal(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + channel.fetch_message.assert_not_called() class DuckPondSetupTests(unittest.TestCase): @@ -195,7 +526,7 @@ class DuckPondSetupTests(unittest.TestCase): def test_setup(self): """Setup of the cog should log a message at `INFO` level.""" - bot = MockBot() + bot = helpers.MockBot() log = logging.getLogger('bot.cogs.duck_pond') with self.assertLogs(logger=log, level=logging.INFO) as log_watcher: -- cgit v1.2.3 From ded5bbe5bb6547b50647a8ed040e9803bd58a76e Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 15 Nov 2019 10:33:09 +0100 Subject: Use a context manager for the buffer Co-authored-by: Shirayuki Nekomata --- bot/cogs/moderation/modlog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 6d4b66644..3c6731de6 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -127,10 +127,10 @@ class ModLog(Cog, name="ModLog"): channel = self.bot.get_channel(channel_id) out = [] for attachment in message.attachments: - buffer = BytesIO() - await attachment.save(buffer, use_cached=True) - reupload = await channel.send(file=discord.File(buffer, filename=attachment.filename)) - out.append(reupload.attachments[0].url) + with BytesIO() as buffer: + await attachment.save(buffer, use_cached=True) + reupload: discord.Message = await channel.send(file=discord.File(buffer, filename=attachment.filename)) + out.append(reupload.attachments[0].url) return out @Cog.listener() -- cgit v1.2.3 From f212ddeea4de54d6eb75081c13162c2ad64bfeff Mon Sep 17 00:00:00 2001 From: Numerlor Date: Fri, 15 Nov 2019 13:10:19 +0100 Subject: join extra newline --- bot/cogs/doc.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index b82eac5fe..20bc010d9 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -336,8 +336,7 @@ class Doc(commands.Cog): ) # Show all symbols with the same name that were renamed in the footer. embed.set_footer( - text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} - if renamed.endswith(f".{symbol}")) + text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} if renamed.endswith(f".{symbol}")) ) return embed -- cgit v1.2.3 From a0ed0c1d6c6d3ba32df4d9bb355ffe1a59e8f76b Mon Sep 17 00:00:00 2001 From: Numerlor Date: Fri, 15 Nov 2019 13:13:18 +0100 Subject: Add variable info after comment was deleted Co-authored-by: scargly <29337040+scragly@users.noreply.github.com> --- bot/cogs/doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 20bc010d9..76fdcd831 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -180,7 +180,7 @@ class Doc(commands.Cog): return None for group, value in package.items(): - for symbol, (package_name, _, relative_doc_url, _) in value.items(): + for symbol, (package_name, _version, relative_doc_url, _) in value.items(): absolute_doc_url = base_url + relative_doc_url if symbol in self.inventories: -- cgit v1.2.3 From f1180d9cd05329f61439c8a45dedb47e841e7216 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Fri, 15 Nov 2019 13:35:44 +0100 Subject: group and order constants --- bot/cogs/doc.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 76fdcd831..dc53937ee 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -26,7 +26,6 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) logging.getLogger('urllib3').setLevel(logging.WARNING) -NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay NO_OVERRIDE_GROUPS = ( "2to3fixer", "token", @@ -37,8 +36,7 @@ NO_OVERRIDE_GROUPS = ( NO_OVERRIDE_PACKAGES = ( "python", ) -FAILED_REQUEST_RETRY_AMOUNT = 3 -UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") + SEARCH_END_TAG_ATTRS = ( "data", "function", @@ -49,8 +47,12 @@ SEARCH_END_TAG_ATTRS = ( "rubric", "sphinxsidebar", ) +UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") +FAILED_REQUEST_RETRY_AMOUNT = 3 +NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay + def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: """ -- cgit v1.2.3 From e66ed4b4f534e6fa4178d8b2a82bc486b97affd5 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Sat, 16 Nov 2019 08:26:38 +0700 Subject: Renamed variables to be more explicit, added type hinting for `content_before` and `content_after` --- bot/cogs/moderation/modlog.py | 48 +++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index ce2a5e1f7..41d7709e4 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -620,42 +620,42 @@ class ModLog(Cog, name="ModLog"): ) @Cog.listener() - async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None: + async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: """Log message edit event to message change log.""" if ( - not before.guild - or before.guild.id != GuildConstant.id - or before.channel.id in GuildConstant.ignored - or before.author.bot + not msg_before.guild + or msg_before.guild.id != GuildConstant.id + or msg_before.channel.id in GuildConstant.ignored + or msg_before.author.bot ): return - self._cached_edits.append(before.id) + self._cached_edits.append(msg_before.id) - if before.content == after.content: + if msg_before.content == msg_after.content: return - author = before.author - channel = before.channel + author = msg_before.author + channel = msg_before.channel channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" # Getting the difference per words and group them by type - add, remove, same # Note that this is intended grouping without sorting - diff = difflib.ndiff(before.clean_content.split(), after.clean_content.split()) + diff = difflib.ndiff(msg_before.clean_content.split(), msg_after.clean_content.split()) diff_groups = tuple( (diff_type, tuple(s[2:] for s in diff_words)) for diff_type, diff_words in itertools.groupby(diff, key=lambda s: s[0]) ) - _before = [] - _after = [] + content_before: t.List[str] = [] + content_after: t.List[str] = [] for index, (diff_type, words) in enumerate(diff_groups): sub = ' '.join(words) if diff_type == '-': - _before.append(f"[{sub}](http://o.hi)") + content_before.append(f"[{sub}](http://o.hi)") elif diff_type == '+': - _after.append(f"[{sub}](http://o.hi)") + content_after.append(f"[{sub}](http://o.hi)") elif diff_type == ' ': if len(words) > 2: sub = ( @@ -663,31 +663,31 @@ class ModLog(Cog, name="ModLog"): " ... " f"{words[-1] if index < len(diff_groups) - 1 else ''}" ) - _before.append(sub) - _after.append(sub) + content_before.append(sub) + content_after.append(sub) response = ( f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" - f"**Message ID:** `{before.id}`\n" + f"**Message ID:** `{msg_before.id}`\n" "\n" - f"**Before**:\n{' '.join(_before)}\n" - f"**After**:\n{' '.join(_after)}\n" + f"**Before**:\n{' '.join(content_before)}\n" + f"**After**:\n{' '.join(content_after)}\n" "\n" - f"[jump to message]({after.jump_url})" + f"[jump to message]({msg_after.jump_url})" ) - if before.edited_at: + if msg_before.edited_at: # Message was previously edited, to assist with self-bot detection, use the edited_at # datetime as the baseline and create a human-readable delta between this edit event # and the last time the message was edited - timestamp = before.edited_at - delta = humanize_delta(relativedelta(after.edited_at, before.edited_at)) + timestamp = msg_before.edited_at + delta = humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at)) footer = f"Last edited {delta} ago" else: # Message was not previously edited, use the created_at datetime as the baseline, no # delta calculation needed - timestamp = before.created_at + timestamp = msg_before.created_at footer = None await self.send_log_message( -- cgit v1.2.3 From e67822a46621d20ff0a1a27de1322b14432e4eb9 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sat, 16 Nov 2019 17:04:27 +0100 Subject: Apply suggestions from code review Co-Authored-By: Mark --- tests/bot/cogs/test_duck_pond.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index ceefc286f..8f0c4f068 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -269,7 +269,7 @@ class DuckPondTests(base.LoggingTestCase): self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), ]), - 3+4 + 3 + 4 ), ) @@ -310,7 +310,6 @@ class DuckPondTests(base.LoggingTestCase): @helpers.async_test async def test_relay_message_to_duck_pond_handles_send_attachments_exceptions(self, send_attachments, send_webhook): """The `relay_message_to_duck_pond` method should handle exceptions when calling `send_attachment`.""" - message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) @@ -435,9 +434,9 @@ class DuckPondTests(base.LoggingTestCase): async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self, count_ducks, message_relay): """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" test_cases = ( - (constants.DuckPond.threshold-1, False), + (constants.DuckPond.threshold - 1, False), (constants.DuckPond.threshold, True), - (constants.DuckPond.threshold+1, True), + (constants.DuckPond.threshold + 1, True), ) channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5) -- cgit v1.2.3 From f96631eba92c2c00b831004f8a70b5de5709a4cd Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Tue, 19 Nov 2019 10:31:48 +1000 Subject: Relock to d.py 1.2.5 due to API breaking change for emoji. --- Pipfile.lock | 175 ++++++++++++++++++++++++++++++++--------------------------- 1 file changed, 95 insertions(+), 80 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 95955ff89..69caf4646 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:1dcec3e3e3309e277511dc0d7d157676d0165c174a6a745673fc9cf0510db8f0", - "sha256:dd5a23ca26a4872ee73bd107e4c545bace572cdec2a574aeb61f4062c7774b2a" + "sha256:1da038b3d2c1b49e0e816d87424e702912bb77f9b5197f2bf279217915b4f7ed", + "sha256:29fe851374b86c997a22174c04352b5941bc1c2e36bbf542918ac18a76cfc9d3" ], "index": "pypi", - "version": "==6.1.3" + "version": "==6.3.0" }, "aiodns": { "hashes": [ @@ -62,10 +62,10 @@ }, "aiormq": { "hashes": [ - "sha256:c3e4dd01a2948a75f739fb637334dbb8c6f1a4cecf74d5ed662dc3bab7f39973", - "sha256:e220d3f9477bb2959b729b79bec815148ddb8a7686fc6c3d05d41c88ebd7c59e" + "sha256:afc0d46837b121585e4faec0a7646706429b4e2f5110ae8d0b5cdc3708b4b0e5", + "sha256:dc0fbbc7f8ad5af6a2cc18e00ccc5f925984cde3db6e8fe952c07b7ef157b5f2" ], - "version": "==2.8.0" + "version": "==2.9.1" }, "alabaster": { "hashes": [ @@ -83,10 +83,10 @@ }, "attrs": { "hashes": [ - "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2", - "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396" + "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", + "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], - "version": "==19.2.0" + "version": "==19.3.0" }, "babel": { "hashes": [ @@ -112,36 +112,41 @@ }, "cffi": { "hashes": [ - "sha256:041c81822e9f84b1d9c401182e174996f0bae9991f33725d059b771744290774", - "sha256:046ef9a22f5d3eed06334d01b1e836977eeef500d9b78e9ef693f9380ad0b83d", - "sha256:066bc4c7895c91812eff46f4b1c285220947d4aa46fa0a2651ff85f2afae9c90", - "sha256:066c7ff148ae33040c01058662d6752fd73fbc8e64787229ea8498c7d7f4041b", - "sha256:2444d0c61f03dcd26dbf7600cf64354376ee579acad77aef459e34efcb438c63", - "sha256:300832850b8f7967e278870c5d51e3819b9aad8f0a2c8dbe39ab11f119237f45", - "sha256:34c77afe85b6b9e967bd8154e3855e847b70ca42043db6ad17f26899a3df1b25", - "sha256:46de5fa00f7ac09f020729148ff632819649b3e05a007d286242c4882f7b1dc3", - "sha256:4aa8ee7ba27c472d429b980c51e714a24f47ca296d53f4d7868075b175866f4b", - "sha256:4d0004eb4351e35ed950c14c11e734182591465a33e960a4ab5e8d4f04d72647", - "sha256:4e3d3f31a1e202b0f5a35ba3bc4eb41e2fc2b11c1eff38b362de710bcffb5016", - "sha256:50bec6d35e6b1aaeb17f7c4e2b9374ebf95a8975d57863546fa83e8d31bdb8c4", - "sha256:55cad9a6df1e2a1d62063f79d0881a414a906a6962bc160ac968cc03ed3efcfb", - "sha256:5662ad4e4e84f1eaa8efce5da695c5d2e229c563f9d5ce5b0113f71321bcf753", - "sha256:59b4dc008f98fc6ee2bb4fd7fc786a8d70000d058c2bbe2698275bc53a8d3fa7", - "sha256:73e1ffefe05e4ccd7bcea61af76f36077b914f92b76f95ccf00b0c1b9186f3f9", - "sha256:a1f0fd46eba2d71ce1589f7e50a9e2ffaeb739fb2c11e8192aa2b45d5f6cc41f", - "sha256:a2e85dc204556657661051ff4bab75a84e968669765c8a2cd425918699c3d0e8", - "sha256:a5457d47dfff24882a21492e5815f891c0ca35fefae8aa742c6c263dac16ef1f", - "sha256:a8dccd61d52a8dae4a825cdbb7735da530179fea472903eb871a5513b5abbfdc", - "sha256:ae61af521ed676cf16ae94f30fe202781a38d7178b6b4ab622e4eec8cefaff42", - "sha256:b012a5edb48288f77a63dba0840c92d0504aa215612da4541b7b42d849bc83a3", - "sha256:d2c5cfa536227f57f97c92ac30c8109688ace8fa4ac086d19d0af47d134e2909", - "sha256:d42b5796e20aacc9d15e66befb7a345454eef794fdb0737d1af593447c6c8f45", - "sha256:dee54f5d30d775f525894d67b1495625dd9322945e7fee00731952e0368ff42d", - "sha256:e070535507bd6aa07124258171be2ee8dfc19119c28ca94c9dfb7efd23564512", - "sha256:e1ff2748c84d97b065cc95429814cdba39bcbd77c9c85c89344b317dc0d9cbff", - "sha256:ed851c75d1e0e043cbf5ca9a8e1b13c4c90f3fbd863dacb01c0808e2b5204201" - ], - "version": "==1.12.3" + "sha256:0b49274afc941c626b605fb59b59c3485c17dc776dc3cc7cc14aca74cc19cc42", + "sha256:0e3ea92942cb1168e38c05c1d56b0527ce31f1a370f6117f1d490b8dcd6b3a04", + "sha256:135f69aecbf4517d5b3d6429207b2dff49c876be724ac0c8bf8e1ea99df3d7e5", + "sha256:19db0cdd6e516f13329cba4903368bff9bb5a9331d3410b1b448daaadc495e54", + "sha256:2781e9ad0e9d47173c0093321bb5435a9dfae0ed6a762aabafa13108f5f7b2ba", + "sha256:291f7c42e21d72144bb1c1b2e825ec60f46d0a7468f5346841860454c7aa8f57", + "sha256:2c5e309ec482556397cb21ede0350c5e82f0eb2621de04b2633588d118da4396", + "sha256:2e9c80a8c3344a92cb04661115898a9129c074f7ab82011ef4b612f645939f12", + "sha256:32a262e2b90ffcfdd97c7a5e24a6012a43c61f1f5a57789ad80af1d26c6acd97", + "sha256:3c9fff570f13480b201e9ab69453108f6d98244a7f495e91b6c654a47486ba43", + "sha256:415bdc7ca8c1c634a6d7163d43fb0ea885a07e9618a64bda407e04b04333b7db", + "sha256:42194f54c11abc8583417a7cf4eaff544ce0de8187abaf5d29029c91b1725ad3", + "sha256:4424e42199e86b21fc4db83bd76909a6fc2a2aefb352cb5414833c030f6ed71b", + "sha256:4a43c91840bda5f55249413037b7a9b79c90b1184ed504883b72c4df70778579", + "sha256:599a1e8ff057ac530c9ad1778293c665cb81a791421f46922d80a86473c13346", + "sha256:5c4fae4e9cdd18c82ba3a134be256e98dc0596af1e7285a3d2602c97dcfa5159", + "sha256:5ecfa867dea6fabe2a58f03ac9186ea64da1386af2159196da51c4904e11d652", + "sha256:62f2578358d3a92e4ab2d830cd1c2049c9c0d0e6d3c58322993cc341bdeac22e", + "sha256:6471a82d5abea994e38d2c2abc77164b4f7fbaaf80261cb98394d5793f11b12a", + "sha256:6d4f18483d040e18546108eb13b1dfa1000a089bcf8529e30346116ea6240506", + "sha256:71a608532ab3bd26223c8d841dde43f3516aa5d2bf37b50ac410bb5e99053e8f", + "sha256:74a1d8c85fb6ff0b30fbfa8ad0ac23cd601a138f7509dc617ebc65ef305bb98d", + "sha256:7b93a885bb13073afb0aa73ad82059a4c41f4b7d8eb8368980448b52d4c7dc2c", + "sha256:7d4751da932caaec419d514eaa4215eaf14b612cff66398dd51129ac22680b20", + "sha256:7f627141a26b551bdebbc4855c1157feeef18241b4b8366ed22a5c7d672ef858", + "sha256:8169cf44dd8f9071b2b9248c35fc35e8677451c52f795daa2bb4643f32a540bc", + "sha256:aa00d66c0fab27373ae44ae26a66a9e43ff2a678bf63a9c7c1a9a4d61172827a", + "sha256:ccb032fda0873254380aa2bfad2582aedc2959186cce61e3a17abc1a55ff89c3", + "sha256:d754f39e0d1603b5b24a7f8484b22d2904fa551fe865fd0d4c3332f078d20d4e", + "sha256:d75c461e20e29afc0aee7172a0950157c704ff0dd51613506bd7d82b718e7410", + "sha256:dcd65317dd15bc0451f3e01c80da2216a31916bdcffd6221ca1202d96584aa25", + "sha256:e570d3ab32e2c2861c4ebe6ffcad6a8abf9347432a37608fe1fbd157b3f0036b", + "sha256:fd43a88e045cf992ed09fa724b5315b790525f2676883a6ea64e3263bae6549d" + ], + "version": "==1.13.2" }, "chardet": { "hashes": [ @@ -152,18 +157,18 @@ }, "deepdiff": { "hashes": [ - "sha256:1123762580af0904621136d117c8397392a244d3ff0fa0a50de57a7939582476", - "sha256:6ab13e0cbb627dadc312deaca9bef38de88a737a9bbdbfbe6e3857748219c127" + "sha256:3457ea7cecd51ba48015d89edbb569358af4d9b9e65e28bdb3209608420627f9", + "sha256:5e2343398e90538edaa59c0c99207e996a3a834fdc878c666376f632a760c35a" ], "index": "pypi", - "version": "==4.0.7" + "version": "==4.0.9" }, "discord-py": { "hashes": [ - "sha256:4684733fa137cc7def18087ae935af615212e423e3dbbe3e84ef01d7ae8ed17d" + "sha256:7c843b523bb011062b453864e75c7b675a03faf573c58d14c9f096e85984329d" ], "index": "pypi", - "version": "==1.2.3" + "version": "==1.2.5" }, "docutils": { "hashes": [ @@ -221,6 +226,7 @@ "sha256:02ca7bf899da57084041bb0f6095333e4d239948ad3169443f454add9f4e9cb4", "sha256:096b82c5e0ea27ce9138bcbb205313343ee66a6e132f25c5ed67e2c8d960a1bc", "sha256:0a920ff98cf1aac310470c644bc23b326402d3ef667ddafecb024e1713d485f1", + "sha256:1409b14bf83a7d729f92e2a7fbfe7ec929d4883ca071b06e95c539ceedb6497c", "sha256:17cae1730a782858a6e2758fd20dd0ef7567916c47757b694a06ffafdec20046", "sha256:17e3950add54c882e032527795c625929613adbd2ce5162b94667334458b5a36", "sha256:1f4f214337f6ee5825bf90a65d04d70aab05526c08191ab888cb5149501923c5", @@ -231,11 +237,14 @@ "sha256:760c12276fee05c36f95f8040180abc7fbebb9e5011447a97cdc289b5d6ab6fc", "sha256:796685d3969815a633827c818863ee199440696b0961e200b011d79b9394bbe7", "sha256:891fe897b49abb7db470c55664b198b1095e4943b9f82b7dcab317a19116cd38", + "sha256:9277562f175d2334744ad297568677056861070399cec56ff06abbe2564d1232", "sha256:a471628e20f03dcdfde00770eeaf9c77811f0c331c8805219ca7b87ac17576c5", "sha256:a63b4fd3e2cabdcc9d918ed280bdde3e8e9641e04f3c59a2a3109644a07b9832", + "sha256:ae88588d687bd476be588010cbbe551e9c2872b816f2da8f01f6f1fda74e1ef0", "sha256:b0b84408d4eabc6de9dd1e1e0bc63e7731e890c0b378a62443e5741cfd0ae90a", "sha256:be78485e5d5f3684e875dab60f40cddace2f5b2a8f7fede412358ab3214c3a6f", "sha256:c27eaed872185f047bb7f7da2d21a7d8913457678c9a100a50db6da890bc28b9", + "sha256:c7fccd08b14aa437fe096c71c645c0f9be0655a9b1a4b7cffc77bcb23b3d61d2", "sha256:c81cb40bff373ab7a7446d6bbca0190bccc5be3448b47b51d729e37799bb5692", "sha256:d11874b3c33ee441059464711cd365b89fa1a9cf19ae75b0c189b01fbf735b84", "sha256:e9c028b5897901361d81a4718d1db217b716424a0283afe9d6735fe0caf70f79", @@ -379,18 +388,18 @@ }, "pyparsing": { "hashes": [ - "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", - "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4" + "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f", + "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a" ], - "version": "==2.4.2" + "version": "==2.4.5" }, "python-dateutil": { "hashes": [ - "sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", - "sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e" + "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", + "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" ], "index": "pypi", - "version": "==2.8.0" + "version": "==2.8.1" }, "python-json-logger": { "hashes": [ @@ -434,10 +443,10 @@ }, "six": { "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", + "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" ], - "version": "==1.12.0" + "version": "==1.13.0" }, "snowballstemmer": { "hashes": [ @@ -448,18 +457,18 @@ }, "soupsieve": { "hashes": [ - "sha256:605f89ad5fdbfefe30cdc293303665eff2d188865d4dbe4eb510bba1edfbfce3", - "sha256:b91d676b330a0ebd5b21719cb6e9b57c57d433671f65b9c28dd3461d9a1ed0b6" + "sha256:bdb0d917b03a1369ce964056fc195cfdff8819c40de04695a80bc813c3cfa1f5", + "sha256:e2c1c5dee4a1c36bcb790e0fabd5492d874b8ebd4617622c4f6a731701060dda" ], - "version": "==1.9.4" + "version": "==1.9.5" }, "sphinx": { "hashes": [ - "sha256:0d586b0f8c2fc3cc6559c5e8fd6124628110514fda0e5d7c82e682d749d2e845", - "sha256:839a3ed6f6b092bb60f492024489cc9e6991360fb9f52ed6361acd510d261069" + "sha256:31088dfb95359384b1005619827eaee3056243798c62724fd3fa4b84ee4d71bd", + "sha256:52286a0b9d7caa31efee301ec4300dbdab23c3b05da1c9024b4e84896fb73d79" ], "index": "pypi", - "version": "==2.2.0" + "version": "==2.2.1" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -564,10 +573,10 @@ }, "attrs": { "hashes": [ - "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2", - "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396" + "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", + "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], - "version": "==19.2.0" + "version": "==19.3.0" }, "certifi": { "hashes": [ @@ -658,11 +667,11 @@ }, "flake8": { "hashes": [ - "sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548", - "sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696" + "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", + "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca" ], "index": "pypi", - "version": "==3.7.8" + "version": "==3.7.9" }, "flake8-annotations": { "hashes": [ @@ -738,6 +747,7 @@ "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" ], + "markers": "python_version < '3.8'", "version": "==0.23" }, "mccabe": { @@ -770,11 +780,11 @@ }, "pre-commit": { "hashes": [ - "sha256:1d3c0587bda7c4e537a46c27f2c84aa006acc18facf9970bf947df596ce91f3f", - "sha256:fa78ff96e8e9ac94c748388597693f18b041a181c94a4f039ad20f45287ba44a" + "sha256:9f152687127ec90642a2cc3e4d9e1e6240c4eb153615cb02aa1ad41d331cbb6e", + "sha256:c2e4810d2d3102d354947907514a78c5d30424d299dc0fe48f5aa049826e9b50" ], "index": "pypi", - "version": "==1.18.3" + "version": "==1.20.0" }, "pycodestyle": { "hashes": [ @@ -799,10 +809,10 @@ }, "pyparsing": { "hashes": [ - "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", - "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4" + "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f", + "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a" ], - "version": "==2.4.2" + "version": "==2.4.5" }, "pyyaml": { "hashes": [ @@ -841,10 +851,10 @@ }, "six": { "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", + "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" ], - "version": "==1.12.0" + "version": "==1.13.0" }, "snowballstemmer": { "hashes": [ @@ -862,31 +872,36 @@ }, "typed-ast": { "hashes": [ + "sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161", "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", + "sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47", "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", + "sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2", + "sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e", "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", + "sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12" ], "version": "==1.4.0" }, "unittest-xml-reporting": { "hashes": [ - "sha256:140982e4b58e4052d9ecb775525b246a96bfc1fc26097806e05ea06e9166dd6c", - "sha256:d1fbc7a1b6c6680ccfe75b5e9701e5431c646970de049e687b4bb35ba4325d72" + "sha256:358bbdaf24a26d904cc1c26ef3078bca7fc81541e0a54c8961693cc96a6f35e0", + "sha256:9d28ddf6524cf0ff9293f61bd12e792de298f8561a5c945acea63fb437789e0e" ], "index": "pypi", - "version": "==2.5.1" + "version": "==2.5.2" }, "urllib3": { "hashes": [ @@ -898,10 +913,10 @@ }, "virtualenv": { "hashes": [ - "sha256:680af46846662bb38c5504b78bad9ed9e4f3ba2d54f54ba42494fdf94337fe30", - "sha256:f78d81b62d3147396ac33fc9d77579ddc42cc2a98dd9ea38886f616b33bc7fb2" + "sha256:11cb4608930d5fd3afb545ecf8db83fa50e1f96fc4fca80c94b07d2c83146589", + "sha256:d257bb3773e48cac60e475a19b608996c73f4d333b3ba2e4e57d5ac6134e0136" ], - "version": "==16.7.5" + "version": "==16.7.7" }, "zipp": { "hashes": [ -- cgit v1.2.3 From 173c5c256307b088165d6f3070336056fa673f8f Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 19 Nov 2019 09:28:10 +0100 Subject: Re-indent arguments --- bot/cogs/moderation/modlog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 3c6731de6..945d7822b 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -119,9 +119,9 @@ class ModLog(Cog, name="ModLog"): return await self.bot.get_context(log_message) # Optionally return for use with antispam async def reupload_attachments( - self, - message: discord.Message, - channel_id: int = GuildConstant.attachment_repost + self, + message: discord.Message, + channel_id: int = GuildConstant.attachment_repost ) -> t.List[str]: """Re-upload message's attachments to the the channel_id and return the list of re-posted attachments URLs.""" channel = self.bot.get_channel(channel_id) -- cgit v1.2.3 From d40b55841201b7546d49f9125fd54d181d67a43f Mon Sep 17 00:00:00 2001 From: Deniz Date: Mon, 25 Nov 2019 15:21:08 +0100 Subject: Update antimalware.py to be more consistent with other information messages (like the codeblock reminder) & improve code a slight bit --- bot/cogs/antimalware.py | 44 ++++++++++++++++++++------------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index ababd6f18..e0c127d9a 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -1,11 +1,12 @@ import logging -from discord import Message, NotFound +from discord import Message, Embed from discord.ext.commands import Bot, Cog from bot.constants import AntiMalware as AntiMalwareConfig, Channels log = logging.getLogger(__name__) +PASTE_URL = "https://paste.pythondiscord.com/" class AntiMalware(Cog): @@ -17,37 +18,32 @@ class AntiMalware(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Identify messages with prohibited attachments.""" - rejected_attachments = False - detected_pyfile = False + if len(message.attachments) == 0: + return + + embed = Embed() for attachment in message.attachments: if attachment.filename.lower().endswith('.py'): - detected_pyfile = True + embed.description = ( + "It looks like you tried to attach a Python file - please " + f"use a code-pasting service such as [{PASTE_URL}]" + f"({PASTE_URL}) instead." + ) break # Other detections irrelevant because we prioritize the .py message. if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)): - rejected_attachments = True - - if detected_pyfile or rejected_attachments: - # Send a message to the user indicating the problem (with special treatment for .py) - author = message.author - if detected_pyfile: - msg = ( - f"{author.mention}, it looks like you tried to attach a Python file - please " - f"use a code-pasting service such as https://paste.pythondiscord.com/ instead." - ) - else: meta_channel = self.bot.get_channel(Channels.meta) - msg = ( - f"{author.mention}, it looks like you tried to attach a file type we don't " - f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake." + embed.description = ( + "It looks like you tried to attach a file type that we " + "do not allow. We currently allow the following file " + f"types: **{', '.join(AntiMalwareConfig.whitelist)}**. \n\n" + f"Feel free to ask in {meta_channel.mention} if you think " + "this is a mistake." ) - - await message.channel.send(msg) + if embed.description: + await message.channel.send(message.author.mention, embed=embed) # Delete the offending message: - try: - await message.delete() - except NotFound: - log.info(f"Tried to delete message `{message.id}`, but message could not be found.") + await message.delete() def setup(bot: Bot) -> None: -- cgit v1.2.3 From 98f4aee9b5b49628b86d0b9e1c952abb9389a839 Mon Sep 17 00:00:00 2001 From: Deniz Date: Mon, 25 Nov 2019 15:24:22 +0100 Subject: Forgot the word 'hey' --- bot/cogs/antimalware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index e0c127d9a..4e8a3269b 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -40,7 +40,7 @@ class AntiMalware(Cog): "this is a mistake." ) if embed.description: - await message.channel.send(message.author.mention, embed=embed) + await message.channel.send(f"Hey {message.author.mention}!", embed=embed) # Delete the offending message: await message.delete() -- cgit v1.2.3 From bc96cf43825dcdfea5f819e2bf52de46371b0b58 Mon Sep 17 00:00:00 2001 From: Deniz Date: Mon, 25 Nov 2019 15:56:57 +0100 Subject: Change order of imports --- bot/cogs/antimalware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 4e8a3269b..24ce501c3 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -1,6 +1,6 @@ import logging -from discord import Message, Embed +from discord import Embed, Message from discord.ext.commands import Bot, Cog from bot.constants import AntiMalware as AntiMalwareConfig, Channels -- cgit v1.2.3 From 0a417f4828e229516d552d21ac678a8dc150beed Mon Sep 17 00:00:00 2001 From: Deniz Date: Mon, 25 Nov 2019 19:50:20 +0100 Subject: Update PASTE_URL constant to be pydis instead of pythondiscord --- bot/cogs/antimalware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 24ce501c3..72db5bc11 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -6,7 +6,7 @@ from discord.ext.commands import Bot, Cog from bot.constants import AntiMalware as AntiMalwareConfig, Channels log = logging.getLogger(__name__) -PASTE_URL = "https://paste.pythondiscord.com/" +PASTE_URL = "https://paste.pydis.com/" class AntiMalware(Cog): -- cgit v1.2.3 From c73c9bae2767da1a6dff5b4098d4af50a61aabe5 Mon Sep 17 00:00:00 2001 From: Deniz Date: Mon, 25 Nov 2019 20:17:08 +0100 Subject: Make requested tweaks: Use URL constant from constants.py, re-add try/except block and implement the changes requested by Ava --- bot/cogs/antimalware.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 72db5bc11..745dd8082 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -1,12 +1,11 @@ import logging -from discord import Embed, Message +from discord import Embed, Message, NotFound from discord.ext.commands import Bot, Cog -from bot.constants import AntiMalware as AntiMalwareConfig, Channels +from bot.constants import AntiMalware as AntiMalwareConfig, Channels, URLs log = logging.getLogger(__name__) -PASTE_URL = "https://paste.pydis.com/" class AntiMalware(Cog): @@ -18,32 +17,35 @@ class AntiMalware(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Identify messages with prohibited attachments.""" - if len(message.attachments) == 0: + if not message.attachments: return embed = Embed() for attachment in message.attachments: - if attachment.filename.lower().endswith('.py'): + filename = attachment.filename.lower() + if filename.endswith('.py'): embed.description = ( - "It looks like you tried to attach a Python file - please " - f"use a code-pasting service such as [{PASTE_URL}]" - f"({PASTE_URL}) instead." + f"It looks like you tried to attach a Python file - please " + f"use a code-pasting service such as {URLs.paste_service}" ) break # Other detections irrelevant because we prioritize the .py message. - if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)): + if not filename.endswith(tuple(AntiMalwareConfig.whitelist)): + whitelisted_types = ', '.join(AntiMalwareConfig.whitelist) meta_channel = self.bot.get_channel(Channels.meta) embed.description = ( - "It looks like you tried to attach a file type that we " - "do not allow. We currently allow the following file " - f"types: **{', '.join(AntiMalwareConfig.whitelist)}**. \n\n" - f"Feel free to ask in {meta_channel.mention} if you think " - "this is a mistake." + f"It looks like you tried to attach a file type that we " + f"do not allow. We currently allow the following file " + f"types: **{whitelisted_types}**. \n\n Feel free to ask " + f"in {meta_channel.mention} if you think this is a mistake." ) if embed.description: await message.channel.send(f"Hey {message.author.mention}!", embed=embed) # Delete the offending message: - await message.delete() + try: + await message.delete() + except NotFound: + log.info(f"Tried to delete message `{message.id}`, but message could not be found.") def setup(bot: Bot) -> None: -- cgit v1.2.3 From 686936646526332bcb018158488253b85b124350 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 13:54:41 +0700 Subject: Implemented `get_duration()` for `bot.utils.time` --- bot/utils/time.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index 2aea2c099..740ede0d3 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,12 +1,21 @@ import asyncio import datetime -from typing import Optional +from typing import List, Optional import dateutil.parser from dateutil.relativedelta import relativedelta RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" INFRACTION_FORMAT = "%Y-%m-%d %H:%M" +TIME_MARKS = ( + (60, 'second'), # 1 minute + (60, 'minute'), # 1 hour + (24, 'hour'), # 1 day + (7, 'day'), # 1 week + (4, 'week'), # 1 month + (12, 'month'), # 1 year + (999, 'year') # dumb the rest as year, max 999 +) def _stringify_time_unit(value: int, unit: str) -> str: @@ -111,3 +120,28 @@ async def wait_until(time: datetime.datetime, start: Optional[datetime.datetime] def format_infraction(timestamp: str) -> str: """Format an infraction timestamp to a more readable ISO 8601 format.""" return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT) + + +def get_duration(date_from: datetime.datetime, date_to: datetime.datetime) -> str: + """ + Get the duration between two datetime, in human readable format. + + Will return the two biggest units avaiable, for example: + - 11 hours, 59 minutes + - 1 week, 6 minutes + - 7 months, 2 weeks + - 3 years, 3 months + - 5 minutes + + :param date_from: A datetime.datetime object. + :param date_to: A datetime.datetime object. + """ + div = abs(date_from - date_to).total_seconds() + results: List[str] = [] + for unit, name in TIME_MARKS: + div, amount = divmod(div, unit) + if amount > 0: + plural = 's' if amount > 1 else '' + results.append(f"{amount:.0f} {name}{plural}") + # We have to reverse the order of units because currently it's smallest -> largest + return ', '.join(results[::-1][:2]) -- cgit v1.2.3 From 66ffef0c0901ff00a01081eca398fac6aac3ed67 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 14:00:55 +0700 Subject: Added pytest for `get_duration()` --- tests/utils/test_time.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py index 4baa6395c..29aca5cfe 100644 --- a/tests/utils/test_time.py +++ b/tests/utils/test_time.py @@ -60,3 +60,20 @@ def test_wait_until(sleep_patch): assert asyncio.run(time.wait_until(then, start)) is None sleep_patch.assert_called_once_with(10 * 60) + + +@pytest.mark.parametrize( + ('date_from', 'date_to', 'expected'), + ( + (datetime(2019, 12, 12, 0, 1), datetime(2019, 12, 12, 12, 0, 5), '11 hours, 59 minutes'), + (datetime(2019, 12, 12), datetime(2019, 12, 11, 23, 59), '1 minute'), + (datetime(2019, 11, 23, 20, 9), datetime(2019, 11, 30, 20, 15), '1 week, 6 minutes'), + (datetime(2019, 11, 23, 20, 9), datetime(2019, 4, 25, 20, 15), '7 months, 2 weeks'), + (datetime(2019, 11, 23, 20, 58), datetime(2019, 11, 23, 21, 3), '5 minutes'), + (datetime(2019, 11, 23, 23, 59), datetime(2019, 11, 24, 0, 0), '1 minute'), + (datetime(2019, 11, 23, 23, 59), datetime(2022, 11, 23, 23, 0), '3 years, 3 months'), + (datetime(2019, 11, 23, 23, 59), datetime(2019, 11, 23, 23, 49, 5), '9 minutes, 55 seconds'), + ) +) +def test_get_duration(date_from: datetime, date_to: datetime, expected: str): + assert time.get_duration(date_from, date_to) == expected -- cgit v1.2.3 From dadb91573c519c1444608ce0cce3de7b01b860a9 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 14:34:36 +0700 Subject: Implemented `get_duration_from_expiry()` which call `get_duration()` for `expiry` and `datetime.utcnow()` --- bot/utils/time.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bot/utils/time.py b/bot/utils/time.py index 740ede0d3..00f39b940 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -145,3 +145,21 @@ def get_duration(date_from: datetime.datetime, date_to: datetime.datetime) -> st results.append(f"{amount:.0f} {name}{plural}") # We have to reverse the order of units because currently it's smallest -> largest return ', '.join(results[::-1][:2]) + + +def get_duration_from_expiry(expiry: str) -> str: + """ + Get the duration between datetime.utcnow() and an expiry, in human readable format. + + Will return the two biggest units avaiable, for example: + - 11 hours, 59 minutes + - 1 week, 6 minutes + - 7 months, 2 weeks + - 3 years, 3 months + - 5 minutes + + :param expiry: A string. + """ + date_from = datetime.datetime.utcnow() + date_to = dateutil.parser.isoparse(expiry) + return get_duration(date_from, date_to) -- cgit v1.2.3 From 4ee01649786edcd9b0bbb88d55f1672953afc6fe Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 14:39:18 +0700 Subject: Fixed TypeError raised by substracting offset-naive and offset-aware datetimes ( removed tzinfo from expiry ) --- bot/utils/time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index 00f39b940..fc003f9e2 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -161,5 +161,5 @@ def get_duration_from_expiry(expiry: str) -> str: :param expiry: A string. """ date_from = datetime.datetime.utcnow() - date_to = dateutil.parser.isoparse(expiry) + date_to = dateutil.parser.isoparse(expiry).replace(tzinfo=None) return get_duration(date_from, date_to) -- cgit v1.2.3 From 1c84213045f778ef0739b474b8a2862ccf1a620b Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 15:05:43 +0700 Subject: Added test for `get_duration_from_expiry()` --- tests/utils/test_time.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py index 29aca5cfe..0afffd9b1 100644 --- a/tests/utils/test_time.py +++ b/tests/utils/test_time.py @@ -77,3 +77,20 @@ def test_wait_until(sleep_patch): ) def test_get_duration(date_from: datetime, date_to: datetime, expected: str): assert time.get_duration(date_from, date_to) == expected + + +@pytest.mark.parametrize( + ('expiry', 'date_from', 'expected'), + ( + ('2019-12-12T00:01:00Z', datetime(2019, 12, 12, 12, 0, 5), '11 hours, 59 minutes'), + ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), '1 minute'), + ('2019-11-23T20:09:00Z', datetime(2019, 11, 30, 20, 15), '1 week, 6 minutes'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), '7 months, 2 weeks'), + ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 21, 3), '5 minutes'), + ('2019-11-23T23:59:00Z', datetime(2019, 11, 24, 0, 0), '1 minute'), + ('2019-11-23T23:59:00Z', datetime(2022, 11, 23, 23, 0), '3 years, 3 months'), + ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), '9 minutes, 55 seconds'), + ) +) +def test_get_duration_from_expiry(expiry: str, date_from: datetime, expected: str): + assert time.get_duration_from_expiry(expiry, date_from) == expected -- cgit v1.2.3 From 44f5ae308f69aa1e3349e1a350590e58302076cb Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 15:06:47 +0700 Subject: Updated `bot.utils.time.get_duration_from_expiry()` to accept an optional `date_from` ( for pytest and more control over the behaviour ) --- bot/utils/time.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index fc003f9e2..533b7ef83 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -147,7 +147,7 @@ def get_duration(date_from: datetime.datetime, date_to: datetime.datetime) -> st return ', '.join(results[::-1][:2]) -def get_duration_from_expiry(expiry: str) -> str: +def get_duration_from_expiry(expiry: str, date_from: datetime = None) -> str: """ Get the duration between datetime.utcnow() and an expiry, in human readable format. @@ -160,6 +160,6 @@ def get_duration_from_expiry(expiry: str) -> str: :param expiry: A string. """ - date_from = datetime.datetime.utcnow() + date_from = date_from or datetime.datetime.utcnow() date_to = dateutil.parser.isoparse(expiry).replace(tzinfo=None) return get_duration(date_from, date_to) -- cgit v1.2.3 From 91b213227bb83a3e4d8be1f526b45c3c6d73fbc0 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 15:07:21 +0700 Subject: Added expiry duration when applying infraction ( including in the embed sent to user ) --- bot/cogs/moderation/scheduler.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 49b61f35e..9e987d9ee 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -84,12 +84,15 @@ class InfractionScheduler(Scheduler): icon = utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] expiry = infraction["expires_at"] + expiry_at = expiry id_ = infraction['id'] log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") if expiry: + duration = time.get_duration_from_expiry(expiry) expiry = time.format_infraction(expiry) + expiry_at = f"{expiry} ({duration})" # Default values for the confirmation message and mod log. confirm_msg = f":ok_hand: applied" @@ -98,11 +101,11 @@ class InfractionScheduler(Scheduler): if infr_type in ("note", "warning"): expiry_msg = "" else: - expiry_msg = f" until {expiry}" if expiry else " permanently" + expiry_msg = f" until {expiry_at}" if expiry else " permanently" dm_result = "" dm_log_text = "" - expiry_log_text = f"Expires: {expiry}" if expiry else "" + expiry_log_text = f"Expires: {expiry_at}" if expiry else "" log_title = "applied" log_content = None @@ -112,7 +115,7 @@ class InfractionScheduler(Scheduler): user = await self.bot.fetch_user(user.id) # Accordingly display whether the user was successfully notified via DM. - if await utils.notify_infraction(user, infr_type, expiry, reason, icon): + if await utils.notify_infraction(user, infr_type, expiry_at, reason, icon): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" else: -- cgit v1.2.3 From f737fd4f6e0a351a95af856af7addf596f65ee5b Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 15:32:39 +0700 Subject: Fixed "14 minutes, 60 seconds" by rounding `.total_seconds()` in `bot.utils.time.get_durations()` --- bot/utils/time.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/utils/time.py b/bot/utils/time.py index 533b7ef83..873de21f0 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -137,6 +137,7 @@ def get_duration(date_from: datetime.datetime, date_to: datetime.datetime) -> st :param date_to: A datetime.datetime object. """ div = abs(date_from - date_to).total_seconds() + div = round(div, 0) # to avoid (14 minutes, 60 seconds) results: List[str] = [] for unit, name in TIME_MARKS: div, amount = divmod(div, unit) -- cgit v1.2.3 From 2dc74fc6d97e32cbb9cad1dd2797b02a669b3793 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 15:42:19 +0700 Subject: Added duration until expiration for infraction searching. --- bot/cogs/moderation/management.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 44a508436..5c63b19ce 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -2,6 +2,7 @@ import asyncio import logging import textwrap import typing as t +from datetime import datetime import discord from discord.ext import commands @@ -97,7 +98,8 @@ class ModManagement(commands.Cog): elif duration is not None: request_data['expires_at'] = duration.isoformat() expiry = duration.strftime(time.INFRACTION_FORMAT) - confirm_messages.append(f"set to expire on {expiry}") + duration_string = time.get_duration(duration, datetime.utcnow()) + confirm_messages.append(f"set to expire on {expiry} ({duration_string})") else: confirm_messages.append("expiry unchanged") @@ -234,7 +236,8 @@ class ModManagement(commands.Cog): if infraction["expires_at"] is None: expires = "*Permanent*" else: - expires = time.format_infraction(infraction["expires_at"]) + duration = time.get_duration_from_expiry(infraction["expires_at"]) + expires = f"{time.format_infraction(infraction['expires_at'])} ({duration})" lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} -- cgit v1.2.3 From f5f92b76fb536beedbbfacd97f2977ed1c2c8606 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 17:16:06 +0700 Subject: Changed `get_duration_from_expiry()` to return the `time (duration)` or a `''` --- bot/utils/time.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index 873de21f0..311a0a576 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -148,7 +148,7 @@ def get_duration(date_from: datetime.datetime, date_to: datetime.datetime) -> st return ', '.join(results[::-1][:2]) -def get_duration_from_expiry(expiry: str, date_from: datetime = None) -> str: +def get_duration_from_expiry(expiry: str = None, date_from: datetime = None) -> Optional[str]: """ Get the duration between datetime.utcnow() and an expiry, in human readable format. @@ -161,6 +161,15 @@ def get_duration_from_expiry(expiry: str, date_from: datetime = None) -> str: :param expiry: A string. """ + if not expiry: + return None + date_from = date_from or datetime.datetime.utcnow() date_to = dateutil.parser.isoparse(expiry).replace(tzinfo=None) - return get_duration(date_from, date_to) + + expiry_formatted = format_infraction(expiry) + + duration = get_duration(date_from, date_to) + duration_formatted = f" ({duration})" if duration else '' + + return f"{expiry_formatted}{duration_formatted}" -- cgit v1.2.3 From 0898ce98b6b2a9ac59369d8665ff51a077405c03 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 17:16:43 +0700 Subject: Refactored `management.py` to use the new `get_duration_from_expiry()` --- bot/cogs/moderation/management.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 5c63b19ce..5221baa81 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -97,9 +97,8 @@ class ModManagement(commands.Cog): confirm_messages.append("marked as permanent") elif duration is not None: request_data['expires_at'] = duration.isoformat() - expiry = duration.strftime(time.INFRACTION_FORMAT) - duration_string = time.get_duration(duration, datetime.utcnow()) - confirm_messages.append(f"set to expire on {expiry} ({duration_string})") + expiry = time.get_duration_from_expiry(request_data['expires_at']) + confirm_messages.append(f"set to expire on {expiry}") else: confirm_messages.append("expiry unchanged") @@ -236,8 +235,8 @@ class ModManagement(commands.Cog): if infraction["expires_at"] is None: expires = "*Permanent*" else: - duration = time.get_duration_from_expiry(infraction["expires_at"]) - expires = f"{time.format_infraction(infraction['expires_at'])} ({duration})" + date_from = datetime.strptime(created, time.INFRACTION_FORMAT) + expires = time.get_duration_from_expiry(infraction["expires_at"], date_from) lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} -- cgit v1.2.3 From 2147adc592cf62a9cc21b3ebf5adeec544b4cac2 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 17:17:06 +0700 Subject: Refactored `scheduler.py` to use the new `get_duration_from_expiry()` --- bot/cogs/moderation/scheduler.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 9e987d9ee..729763322 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -83,17 +83,11 @@ class InfractionScheduler(Scheduler): infr_type = infraction["type"] icon = utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] - expiry = infraction["expires_at"] - expiry_at = expiry + expiry = time.get_duration_from_expiry(infraction["expires_at"]) id_ = infraction['id'] log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") - if expiry: - duration = time.get_duration_from_expiry(expiry) - expiry = time.format_infraction(expiry) - expiry_at = f"{expiry} ({duration})" - # Default values for the confirmation message and mod log. confirm_msg = f":ok_hand: applied" @@ -101,11 +95,11 @@ class InfractionScheduler(Scheduler): if infr_type in ("note", "warning"): expiry_msg = "" else: - expiry_msg = f" until {expiry_at}" if expiry else " permanently" + expiry_msg = f" until {expiry}" if expiry else " permanently" dm_result = "" dm_log_text = "" - expiry_log_text = f"Expires: {expiry_at}" if expiry else "" + expiry_log_text = f"Expires: {expiry}" if expiry else "" log_title = "applied" log_content = None @@ -115,7 +109,7 @@ class InfractionScheduler(Scheduler): user = await self.bot.fetch_user(user.id) # Accordingly display whether the user was successfully notified via DM. - if await utils.notify_infraction(user, infr_type, expiry_at, reason, icon): + if await utils.notify_infraction(user, infr_type, expiry, reason, icon): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" else: -- cgit v1.2.3 From 493cd411ce4d7f5dbddfe40003af0049015d0ebb Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 17:21:23 +0700 Subject: Updated test cases for `get_duration_from_expiry()` --- tests/utils/test_time.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py index 0afffd9b1..1df96beb8 100644 --- a/tests/utils/test_time.py +++ b/tests/utils/test_time.py @@ -82,14 +82,15 @@ def test_get_duration(date_from: datetime, date_to: datetime, expected: str): @pytest.mark.parametrize( ('expiry', 'date_from', 'expected'), ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 12, 12, 0, 5), '11 hours, 59 minutes'), - ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), '1 minute'), - ('2019-11-23T20:09:00Z', datetime(2019, 11, 30, 20, 15), '1 week, 6 minutes'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), '7 months, 2 weeks'), - ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 21, 3), '5 minutes'), - ('2019-11-23T23:59:00Z', datetime(2019, 11, 24, 0, 0), '1 minute'), - ('2019-11-23T23:59:00Z', datetime(2022, 11, 23, 23, 0), '3 years, 3 months'), - ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), '9 minutes, 55 seconds'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 12, 12, 0, 5), '2019-12-12 00:01 (11 hours, 59 minutes)'), + ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), '2019-12-12 00:00 (1 minute)'), + ('2019-11-23T20:09:00Z', datetime(2019, 11, 30, 20, 15), '2019-11-23 20:09 (1 week, 6 minutes)'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), '2019-11-23 20:09 (7 months, 2 weeks)'), + ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 21, 3), '2019-11-23 20:58 (5 minutes)'), + ('2019-11-23T23:59:00Z', datetime(2019, 11, 24, 0, 0), '2019-11-23 23:59 (1 minute)'), + ('2019-11-23T23:59:00Z', datetime(2022, 11, 23, 23, 0), '2019-11-23 23:59 (3 years, 3 months)'), + ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), '2019-11-23 23:59 (9 minutes, 55 seconds)'), + (None, datetime(2019, 11, 23, 23, 49, 5), None), ) ) def test_get_duration_from_expiry(expiry: str, date_from: datetime, expected: str): -- cgit v1.2.3 From f47ec6f65abe571110885e11cfc68d84e7f7b45e Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 17:52:08 +0700 Subject: Updated docstrings, allow passing `parts: Optional[int] = 2` to helper functions to return more than just 2 parts of the duration. --- bot/utils/time.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index 311a0a576..d3000a7c2 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -122,11 +122,11 @@ def format_infraction(timestamp: str) -> str: return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT) -def get_duration(date_from: datetime.datetime, date_to: datetime.datetime) -> str: +def get_duration(date_from: datetime.datetime, date_to: datetime.datetime, parts: Optional[int] = 2) -> str: """ Get the duration between two datetime, in human readable format. - Will return the two biggest units avaiable, for example: + Will return number of units if avaiable, for example: - 11 hours, 59 minutes - 1 week, 6 minutes - 7 months, 2 weeks @@ -135,6 +135,7 @@ def get_duration(date_from: datetime.datetime, date_to: datetime.datetime) -> st :param date_from: A datetime.datetime object. :param date_to: A datetime.datetime object. + :param parts: An int, defauted to two - the amount of units to return. """ div = abs(date_from - date_to).total_seconds() div = round(div, 0) # to avoid (14 minutes, 60 seconds) @@ -144,11 +145,16 @@ def get_duration(date_from: datetime.datetime, date_to: datetime.datetime) -> st if amount > 0: plural = 's' if amount > 1 else '' results.append(f"{amount:.0f} {name}{plural}") + parts = parts if parts is not None else len(results) # allow passing None directly to return all parts # We have to reverse the order of units because currently it's smallest -> largest - return ', '.join(results[::-1][:2]) + return ', '.join(results[::-1][:parts]) -def get_duration_from_expiry(expiry: str = None, date_from: datetime = None) -> Optional[str]: +def get_duration_from_expiry( + expiry: str = None, + date_from: datetime.datetime = None, + parts: Optional[int] = 2 +) -> Optional[str]: """ Get the duration between datetime.utcnow() and an expiry, in human readable format. @@ -159,7 +165,9 @@ def get_duration_from_expiry(expiry: str = None, date_from: datetime = None) -> - 3 years, 3 months - 5 minutes - :param expiry: A string. + :param expiry: A string. If not passed in, will early return a None ( Permanent infraction ). + :param date_from: A datetime.datetime object. If not passed in, will use datetime.utcnow(). + :param parts: An int, to show how many parts will be returned ( year - month or year - month - week - day ...). """ if not expiry: return None @@ -169,7 +177,7 @@ def get_duration_from_expiry(expiry: str = None, date_from: datetime = None) -> expiry_formatted = format_infraction(expiry) - duration = get_duration(date_from, date_to) + duration = get_duration(date_from, date_to, parts) duration_formatted = f" ({duration})" if duration else '' return f"{expiry_formatted}{duration_formatted}" -- cgit v1.2.3 From b12fe618f73a0dfc31cd5ba4a9572ac0401d65ea Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 27 Nov 2019 17:52:38 +0700 Subject: Updated test cases for `parts: Optional[int]` --- tests/utils/test_time.py | 55 ++++++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py index 1df96beb8..7bde92506 100644 --- a/tests/utils/test_time.py +++ b/tests/utils/test_time.py @@ -63,35 +63,44 @@ def test_wait_until(sleep_patch): @pytest.mark.parametrize( - ('date_from', 'date_to', 'expected'), + ('date_from', 'date_to', 'parts', 'expected'), ( - (datetime(2019, 12, 12, 0, 1), datetime(2019, 12, 12, 12, 0, 5), '11 hours, 59 minutes'), - (datetime(2019, 12, 12), datetime(2019, 12, 11, 23, 59), '1 minute'), - (datetime(2019, 11, 23, 20, 9), datetime(2019, 11, 30, 20, 15), '1 week, 6 minutes'), - (datetime(2019, 11, 23, 20, 9), datetime(2019, 4, 25, 20, 15), '7 months, 2 weeks'), - (datetime(2019, 11, 23, 20, 58), datetime(2019, 11, 23, 21, 3), '5 minutes'), - (datetime(2019, 11, 23, 23, 59), datetime(2019, 11, 24, 0, 0), '1 minute'), - (datetime(2019, 11, 23, 23, 59), datetime(2022, 11, 23, 23, 0), '3 years, 3 months'), - (datetime(2019, 11, 23, 23, 59), datetime(2019, 11, 23, 23, 49, 5), '9 minutes, 55 seconds'), + (datetime(2019, 12, 12, 0, 1), datetime(2019, 12, 12, 12, 0, 5), 2, '11 hours, 59 minutes'), + (datetime(2019, 12, 12, 0, 1), datetime(2019, 12, 12, 12, 0, 5), 1, '11 hours'), + (datetime(2019, 12, 12, 0, 1), datetime(2019, 12, 12, 12, 0, 5), None, '11 hours, 59 minutes, 5 seconds'), + (datetime(2019, 12, 12, 0, 0), datetime(2019, 12, 11, 23, 59), 2, '1 minute'), + (datetime(2019, 11, 23, 20, 9), datetime(2019, 11, 30, 20, 15), 2, '1 week, 6 minutes'), + (datetime(2019, 11, 23, 20, 9), datetime(2019, 4, 25, 20, 15), 2, '7 months, 2 weeks'), + (datetime(2019, 11, 23, 20, 9), datetime(2019, 4, 25, 20, 15), + None, '7 months, 2 weeks, 1 day, 23 hours, 54 minutes'), + (datetime(2019, 11, 23, 20, 58), datetime(2019, 11, 23, 21, 3), 2, '5 minutes'), + (datetime(2019, 11, 23, 23, 59), datetime(2019, 11, 24, 0, 0), 2, '1 minute'), + (datetime(2019, 11, 23, 23, 59), datetime(2022, 11, 23, 23, 0), 2, '3 years, 3 months'), + (datetime(2019, 11, 23, 23, 59), datetime(2019, 11, 23, 23, 49, 5), 2, '9 minutes, 55 seconds'), ) ) -def test_get_duration(date_from: datetime, date_to: datetime, expected: str): - assert time.get_duration(date_from, date_to) == expected +def test_get_duration(date_from: datetime, date_to: datetime, parts: int, expected: str): + assert time.get_duration(date_from, date_to, parts) == expected @pytest.mark.parametrize( - ('expiry', 'date_from', 'expected'), + ('expiry', 'date_from', 'parts', 'expected'), ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 12, 12, 0, 5), '2019-12-12 00:01 (11 hours, 59 minutes)'), - ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), '2019-12-12 00:00 (1 minute)'), - ('2019-11-23T20:09:00Z', datetime(2019, 11, 30, 20, 15), '2019-11-23 20:09 (1 week, 6 minutes)'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), '2019-11-23 20:09 (7 months, 2 weeks)'), - ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 21, 3), '2019-11-23 20:58 (5 minutes)'), - ('2019-11-23T23:59:00Z', datetime(2019, 11, 24, 0, 0), '2019-11-23 23:59 (1 minute)'), - ('2019-11-23T23:59:00Z', datetime(2022, 11, 23, 23, 0), '2019-11-23 23:59 (3 years, 3 months)'), - ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), '2019-11-23 23:59 (9 minutes, 55 seconds)'), - (None, datetime(2019, 11, 23, 23, 49, 5), None), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 12, 12, 0, 5), 2, '2019-12-12 00:01 (11 hours, 59 minutes)'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 12, 12, 0, 5), 1, '2019-12-12 00:01 (11 hours)'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 12, 12, 0, 5), + None, '2019-12-12 00:01 (11 hours, 59 minutes, 5 seconds)'), + ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'), + ('2019-11-23T20:09:00Z', datetime(2019, 11, 30, 20, 15), 2, '2019-11-23 20:09 (1 week, 6 minutes)'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (7 months, 2 weeks)'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), None, + '2019-11-23 20:09 (7 months, 2 weeks, 1 day, 23 hours, 54 minutes)'), + ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 21, 3), 2, '2019-11-23 20:58 (5 minutes)'), + ('2019-11-23T23:59:00Z', datetime(2019, 11, 24, 0, 0), 2, '2019-11-23 23:59 (1 minute)'), + ('2019-11-23T23:59:00Z', datetime(2022, 11, 23, 23, 0), 2, '2019-11-23 23:59 (3 years, 3 months)'), + ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, '2019-11-23 23:59 (9 minutes, 55 seconds)'), + (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), ) ) -def test_get_duration_from_expiry(expiry: str, date_from: datetime, expected: str): - assert time.get_duration_from_expiry(expiry, date_from) == expected +def test_get_duration_from_expiry(expiry: str, date_from: datetime, parts: int, expected: str): + assert time.get_duration_from_expiry(expiry, date_from, parts) == expected -- cgit v1.2.3 From 4be37c1486ccb0a8fb680cb6dce51f8ad8028569 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 27 Nov 2019 17:42:48 +0100 Subject: Move duckpond payload emoji check to method to create testable unit I moved the check that tests if a payload contains a duck emoji to a separate method. This makes it easier to test this part of the code as a separate unit than when it's contained in the larger event listener. In addition, I kaizened the name `relay_message_to_duckpond` to the less verbose `relay_message`; that's already clear enough. --- bot/cogs/duck_pond.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 68fb09408..2d25cd17e 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -91,7 +91,7 @@ class DuckPond(Cog): duck_reactors.append(user.id) return duck_count - async def relay_message_to_duck_pond(self, message: Message) -> None: + async def relay_message(self, message: Message) -> None: """Relays the message's content and attachments to the duck pond channel.""" clean_content = message.clean_content @@ -120,6 +120,17 @@ class DuckPond(Cog): await message.add_reaction("✅") + @staticmethod + def _payload_has_duckpond_emoji(payload: RawReactionActionEvent) -> bool: + """Test if the RawReactionActionEvent payload contains a duckpond emoji.""" + if payload.emoji.is_custom_emoji(): + if payload.emoji.id in constants.DuckPond.custom_emojis: + return True + elif payload.emoji.name == "🦆": + return True + + return False + @Cog.listener() async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: """ @@ -130,10 +141,7 @@ class DuckPond(Cog): send the message off to the duck pond. """ # Is the emoji in the reaction a duck? - if payload.emoji.is_custom_emoji(): - if payload.emoji.id not in constants.DuckPond.custom_emojis: - return - elif payload.emoji.name != "🦆": + if not self._payload_has_duckpond_emoji(payload): return channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) @@ -153,7 +161,7 @@ class DuckPond(Cog): # If we've got more than the required amount of ducks, send the message to the duck_pond. if duck_count >= constants.DuckPond.threshold: - await self.relay_message_to_duck_pond(message) + await self.relay_message(message) @Cog.listener() async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: -- cgit v1.2.3 From 80d84e19d6877d2cbf2a6ce5029c1fd96b286b1e Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 27 Nov 2019 17:46:56 +0100 Subject: Apply review comments to duckpond's unit tests https://github.com/python-discord/bot/pull/621 I've changed to unit tests according to the comments made on the issue. Most changes are straightforward enough, but, for context, see the PR linked above. --- tests/bot/cogs/test_duck_pond.py | 200 +++++++++++++++++++++++++-------------- 1 file changed, 128 insertions(+), 72 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 8f0c4f068..b801e86f1 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -72,8 +72,7 @@ class DuckPondTests(base.LoggingTestCase): self.assertEqual(len(log_watcher.records), 1) - [record] = log_watcher.records - self.assertEqual(record.message, f"Failed to fetch webhook with id `{self.cog.webhook_id}`") + record = log_watcher.records[0] self.assertEqual(record.levelno, logging.ERROR) def test_is_staff_returns_correct_values_based_on_instance_passed(self): @@ -99,15 +98,15 @@ class DuckPondTests(base.LoggingTestCase): ( "No green check mark reactions", helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji), - helpers.MockReaction(emoji=self.thumbs_up_emoji) + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), + helpers.MockReaction(emoji=self.thumbs_up_emoji, users=[self.bot.user]) ]), False ), ( "Green check mark reaction, but not from our bot", helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji), + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member]) ]), False @@ -115,7 +114,7 @@ class DuckPondTests(base.LoggingTestCase): ( "Green check mark reaction, with one from the bot", helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji), + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) ]), True @@ -160,8 +159,7 @@ class DuckPondTests(base.LoggingTestCase): self.assertEqual(len(log_watcher.records), 1) - [record] = log_watcher.records - self.assertEqual(record.message, "Failed to send a message to the Duck Pool webhook") + record = log_watcher.records[0] self.assertEqual(record.levelno, logging.ERROR) def _get_reaction( @@ -250,10 +248,12 @@ class DuckPondTests(base.LoggingTestCase): # A staffer with multiple duck reactions only counts once ( "Two different duck reactions from the same staffer", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), - ]), + helpers.MockMessage( + reactions=[ + helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), + ] + ), 1 ), # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif) @@ -265,10 +265,12 @@ class DuckPondTests(base.LoggingTestCase): # We correctly sum when multiple reactions are provided. ( "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", - helpers.MockMessage(reactions=[ - self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), - self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), - ]), + helpers.MockMessage( + reactions=[ + self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), + self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), + ] + ), 3 + 4 ), ) @@ -279,8 +281,8 @@ class DuckPondTests(base.LoggingTestCase): self.assertEqual(expected_count, actual_count) @helpers.async_test - async def test_relay_message_to_duck_pond_correctly_relays_content_and_attachments(self): - """The `relay_message_to_duck_pond` method should correctly relay message content and attachments.""" + async def test_relay_message_correctly_relays_content_and_attachments(self): + """The `relay_message` method should correctly relay message content and attachments.""" send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook" send_attachments_path = f"{MODULE_PATH}.send_attachments" @@ -297,41 +299,47 @@ class DuckPondTests(base.LoggingTestCase): with patch(send_webhook_path, new_callable=helpers.AsyncMock) as send_webhook: with patch(send_attachments_path, new_callable=helpers.AsyncMock) as send_attachments: with self.subTest(clean_content=message.clean_content, attachments=message.attachments): - await self.cog.relay_message_to_duck_pond(message) + await self.cog.relay_message(message) self.assertEqual(expect_webhook_call, send_webhook.called) self.assertEqual(expect_attachment_call, send_attachments.called) message.add_reaction.assert_called_once_with(self.checkmark_emoji) - message.reset_mock() - @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) @helpers.async_test - async def test_relay_message_to_duck_pond_handles_send_attachments_exceptions(self, send_attachments, send_webhook): - """The `relay_message_to_duck_pond` method should handle exceptions when calling `send_attachment`.""" + async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): + """The `relay_message` method should handle irretrievable attachments.""" message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) self.cog.webhook = helpers.MockAsyncWebhook() log = logging.getLogger("bot.cogs.duck_pond") - # Subtests for the first `except` block for side_effect in side_effects: send_attachments.side_effect = side_effect - with self.subTest(side_effect=type(side_effect).__name__): - with self.assertNotLogs(logger=log, level=logging.ERROR): - await self.cog.relay_message_to_duck_pond(message) + with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) as send_webhook: + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertNotLogs(logger=log, level=logging.ERROR): + await self.cog.relay_message(message) + + self.assertEqual(send_webhook.call_count, 2) + + @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) + @helpers.async_test + async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): + """The `relay_message` method should handle irretrievable attachments.""" + message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) - self.assertEqual(send_webhook.call_count, 2) - send_webhook.reset_mock() + self.cog.webhook = helpers.MockAsyncWebhook() + log = logging.getLogger("bot.cogs.duck_pond") - # Subtests for the second `except` block side_effect = discord.HTTPException(MagicMock(), "") send_attachments.side_effect = side_effect with self.subTest(side_effect=type(side_effect).__name__): with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - await self.cog.relay_message_to_duck_pond(message) + await self.cog.relay_message(message) send_webhook.assert_called_once_with( content=message.clean_content, @@ -341,10 +349,75 @@ class DuckPondTests(base.LoggingTestCase): self.assertEqual(len(log_watcher.records), 1) - [record] = log_watcher.records - self.assertEqual(record.message, "Failed to send an attachment to the webhook") + record = log_watcher.records[0] self.assertEqual(record.levelno, logging.ERROR) + def _mock_payload(self, label: str, is_custom_emoji: bool, id_: int, emoji_name: str): + """Creates a mock `on_raw_reaction_add` payload with the specified emoji data.""" + payload = MagicMock(name=label) + payload.emoji.is_custom_emoji.return_value = is_custom_emoji + payload.emoji.id = id_ + payload.emoji.name = emoji_name + return payload + + @helpers.async_test + async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self): + """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" + test_values = ( + # Custom Emojis + ( + self._mock_payload( + label="Custom Duckpond Emoji", + is_custom_emoji=True, + id_=constants.DuckPond.custom_emojis[0], + emoji_name="" + ), + True + ), + ( + self._mock_payload( + label="Custom Non-Duckpond Emoji", + is_custom_emoji=True, + id_=123, + emoji_name="" + ), + False + ), + # Unicode Emojis + ( + self._mock_payload( + label="Unicode Duck Emoji", + is_custom_emoji=False, + id_=1, + emoji_name=self.unicode_duck_emoji + ), + True + ), + ( + self._mock_payload( + label="Unicode Non-Duck Emoji", + is_custom_emoji=False, + id_=1, + emoji_name=self.thumbs_up_emoji + ), + False + ), + ) + + for payload, expected_return in test_values: + actual_return = self.cog._payload_has_duckpond_emoji(payload) + with self.subTest(case=payload._mock_name, expected_return=expected_return, actual_return=actual_return): + self.assertEqual(expected_return, actual_return) + + @patch(f"{MODULE_PATH}.discord.utils.get") + @patch(f"{MODULE_PATH}.DuckPond._payload_has_duckpond_emoji", new=MagicMock(return_value=False)) + def test_on_raw_reaction_add_returns_early_with_payload_without_duck_emoji(self, utils_get): + """The `on_raw_reaction_add` method should return early if the payload does not contain a duck emoji.""" + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload=MagicMock()))) + + # Ensure we've returned before making an unnecessary API call in the lines of code after the emoji check + utils_get.assert_not_called() + def _raw_reaction_mocks(self, channel_id, message_id, user_id): """Sets up mocks for tests of the `on_raw_reaction_add` event listener.""" channel = helpers.MockTextChannel(id=channel_id) @@ -361,22 +434,6 @@ class DuckPondTests(base.LoggingTestCase): return channel, message, member, payload - @helpers.async_test - async def test_on_raw_reaction_add_returns_for_non_relevant_emojis(self): - """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" - payload_custom_emoji = MagicMock(label="Non-Duck Custom Emoji") - payload_custom_emoji.emoji.is_custom_emoji.return_value = True - payload_custom_emoji.emoji.id = 12345 - - payload_unicode_emoji = MagicMock(label="Non-Duck Unicode Emoji") - payload_unicode_emoji.emoji.is_custom_emoji.return_value = False - payload_unicode_emoji.emoji.name = self.thumbs_up_emoji - - for payload in (payload_custom_emoji, payload_unicode_emoji): - with self.subTest(case=payload.label), patch(f"{MODULE_PATH}.discord.utils.get") as discord_utils_get: - self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) - discord_utils_get.assert_not_called() - @helpers.async_test async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" @@ -428,10 +485,8 @@ class DuckPondTests(base.LoggingTestCase): # Assert that we've made it past `self.is_staff` is_staff.assert_called_once() - @patch(f"{MODULE_PATH}.DuckPond.relay_message_to_duck_pond", new_callable=helpers.AsyncMock) - @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) @helpers.async_test - async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self, count_ducks, message_relay): + async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self): """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" test_cases = ( (constants.DuckPond.threshold - 1, False), @@ -444,21 +499,21 @@ class DuckPondTests(base.LoggingTestCase): payload.emoji = self.duck_pond_emoji for duck_count, should_relay in test_cases: - count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_relay=should_relay): - await self.cog.on_raw_reaction_add(payload) + with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=helpers.AsyncMock) as relay_message: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_relay=should_relay): + await self.cog.on_raw_reaction_add(payload) - # Confirm that we've made it past counting - count_ducks.assert_called_once() - count_ducks.reset_mock() + # Confirm that we've made it past counting + count_ducks.assert_called_once() - # Did we relay a message? - has_relayed = message_relay.called - self.assertEqual(has_relayed, should_relay) + # Did we relay a message? + has_relayed = relay_message.called + self.assertEqual(has_relayed, should_relay) - if should_relay: - message_relay.assert_called_once_with(message) - message_relay.reset_mock() + if should_relay: + relay_message.assert_called_once_with(message) @helpers.async_test async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): @@ -479,10 +534,10 @@ class DuckPondTests(base.LoggingTestCase): (constants.DuckPond.threshold, True), (constants.DuckPond.threshold + 1, True), ) - for duck_count, should_readd_checkmark in test_cases: + for duck_count, should_re_add_checkmark in test_cases: with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks: count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_readd_checkmark=should_readd_checkmark): + with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark): await self.cog.on_raw_reaction_remove(payload) # Check if we fetched the message @@ -491,16 +546,15 @@ class DuckPondTests(base.LoggingTestCase): # Check if we actually counted the number of ducks count_ducks.assert_called_once_with(message) - has_readded_checkmark = message.add_reaction.called - self.assertEqual(should_readd_checkmark, has_readded_checkmark) + has_re_added_checkmark = message.add_reaction.called + self.assertEqual(should_re_add_checkmark, has_re_added_checkmark) - if should_readd_checkmark: + if should_re_add_checkmark: message.add_reaction.assert_called_once_with(self.checkmark_emoji) message.add_reaction.reset_mock() # reset mocks channel.fetch_message.reset_mock() - count_ducks.reset_mock() message.reset_mock() def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self): @@ -530,7 +584,9 @@ class DuckPondSetupTests(unittest.TestCase): with self.assertLogs(logger=log, level=logging.INFO) as log_watcher: duck_pond.setup(bot) - line = log_watcher.output[0] + + self.assertEqual(len(log_watcher.records), 1) + record = log_watcher.records[0] + self.assertEqual(record.levelno, logging.INFO) bot.add_cog.assert_called_once() - self.assertIn("Cog loaded: DuckPond", line) -- cgit v1.2.3 From 461826cff98c66646d6f545fc1e711675ac6492f Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Wed, 27 Nov 2019 18:37:16 +0100 Subject: Move attachments re-uploading to DeletionContext.add() So they are re-uploaded before being deleted --- bot/cogs/antispam.py | 41 ++++++++++++++++++++++++++++++++++++----- bot/cogs/moderation/modlog.py | 30 +++++++++++------------------- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index a450c18ce..8009c9d42 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -1,11 +1,13 @@ +import asyncio import logging from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta +from io import BytesIO from operator import itemgetter from typing import Dict, Iterable, List, Set -from discord import Colour, Member, Message, NotFound, Object, TextChannel +from discord import Colour, File, Member, Message, NotFound, Object, TextChannel from discord.ext.commands import Bot, Cog from bot import rules @@ -40,11 +42,13 @@ class DeletionContext: """Represents a Deletion Context for a single spam event.""" channel: TextChannel + bot: Bot members: Dict[int, Member] = field(default_factory=dict) rules: Set[str] = field(default_factory=set) messages: Dict[int, Message] = field(default_factory=dict) + attachments: List[List[str]] = field(default_factory=list) - def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: + async def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: """Adds new rule violation events to the deletion context.""" self.rules.add(rule_name) @@ -56,6 +60,9 @@ class DeletionContext: if message.id not in self.messages: self.messages[message.id] = message + # Re-upload attachments : + self.attachments.append(await reupload_attachments(self.bot, message)) + async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: """Method that takes care of uploading the queue and posting modlog alert.""" triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) @@ -68,7 +75,7 @@ class DeletionContext: # For multiple messages or those with excessive newlines, use the logs API if len(self.messages) > 1 or 'newlines' in self.rules: - url = await modlog.upload_log(self.messages.values(), actor_id) + url = await modlog.upload_log(self.messages.values(), actor_id, attachments=self.attachments) mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" else: mod_alert_message += "Message:\n" @@ -180,13 +187,16 @@ class AntiSpam(Cog): # If there's no spam event going on for this channel, start a new Message Deletion Context if message.channel.id not in self.message_deletion_queue: log.trace(f"Creating queue for channel `{message.channel.id}`") - self.message_deletion_queue[message.channel.id] = DeletionContext(channel=message.channel) + self.message_deletion_queue[message.channel.id] = DeletionContext( + channel=message.channel, + bot=self.bot + ) self.queue_consumption_tasks = self.bot.loop.create_task( self._process_deletion_context(message.channel.id) ) # Add the relevant of this trigger to the Deletion Context - self.message_deletion_queue[message.channel.id].add( + await self.message_deletion_queue[message.channel.id].add( rule_name=rule_name, members=members, messages=relevant_messages @@ -242,6 +252,9 @@ class AntiSpam(Cog): async def _process_deletion_context(self, context_id: int) -> None: """Processes the Deletion Context queue.""" + log.trace("Sleeping before processing message deletion queue.") + await asyncio.sleep(10) + if context_id not in self.message_deletion_queue: log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!") return @@ -250,6 +263,24 @@ class AntiSpam(Cog): await deletion_context.upload_messages(self.bot.user.id, self.mod_log) +async def reupload_attachments( + bot: Bot, + message: Message, + channel_id: int = GuildConfig.attachment_repost +) -> List[str]: + """Re-upload message's attachments to the the channel_id and return the list of re-posted attachments URLs.""" + if not message.attachments: + return [] + channel = bot.get_channel(channel_id) + out = [] + for attachment in message.attachments: + with BytesIO() as buffer: + await attachment.save(buffer, use_cached=True) + reupload: Message = await channel.send(file=File(buffer, filename=attachment.filename)) + out.append(reupload.attachments[0].url) + return out + + def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: """Validates the antispam configs.""" validation_errors = {} diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 945d7822b..9251b79fb 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -2,7 +2,6 @@ import asyncio import logging import typing as t from datetime import datetime -from io import BytesIO import discord from dateutil.relativedelta import relativedelta @@ -35,7 +34,12 @@ class ModLog(Cog, name="ModLog"): self._cached_deletes = [] self._cached_edits = [] - async def upload_log(self, messages: t.List[discord.Message], actor_id: int) -> str: + async def upload_log( + self, + messages: t.List[discord.Message], + actor_id: int, + attachments: t.List[t.List[str]] = None + ) -> str: """ Uploads the log data to the database via an API endpoint for uploading logs. @@ -43,6 +47,9 @@ class ModLog(Cog, name="ModLog"): Returns a URL that can be used to view the log. """ + if attachments is None: + attachments = [] + response = await self.bot.api_client.post( 'bot/deleted-messages', json={ @@ -55,9 +62,9 @@ class ModLog(Cog, name="ModLog"): 'channel_id': message.channel.id, 'content': message.content, 'embeds': [embed.to_dict() for embed in message.embeds], - 'attachments': await self.reupload_attachments(message) if message.attachments else [], + 'attachments': attachment, } - for message in messages + for message, attachment in zip(messages, attachments) ] } ) @@ -118,21 +125,6 @@ class ModLog(Cog, name="ModLog"): return await self.bot.get_context(log_message) # Optionally return for use with antispam - async def reupload_attachments( - self, - message: discord.Message, - channel_id: int = GuildConstant.attachment_repost - ) -> t.List[str]: - """Re-upload message's attachments to the the channel_id and return the list of re-posted attachments URLs.""" - channel = self.bot.get_channel(channel_id) - out = [] - for attachment in message.attachments: - with BytesIO() as buffer: - await attachment.save(buffer, use_cached=True) - reupload: discord.Message = await channel.send(file=discord.File(buffer, filename=attachment.filename)) - out.append(reupload.attachments[0].url) - return out - @Cog.listener() async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None: """Log channel create event to mod log.""" -- cgit v1.2.3 From 4a1ca965e2b83ea8690b3d7408464c8205432482 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Wed, 27 Nov 2019 18:41:57 +0100 Subject: Add try/except for attachment saving --- bot/cogs/antispam.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 8009c9d42..3118e0a42 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -7,7 +7,7 @@ from io import BytesIO from operator import itemgetter from typing import Dict, Iterable, List, Set -from discord import Colour, File, Member, Message, NotFound, Object, TextChannel +from discord import Colour, File, HTTPException, Member, Message, NotFound, Object, TextChannel from discord.ext.commands import Bot, Cog from bot import rules @@ -274,10 +274,13 @@ async def reupload_attachments( channel = bot.get_channel(channel_id) out = [] for attachment in message.attachments: - with BytesIO() as buffer: - await attachment.save(buffer, use_cached=True) - reupload: Message = await channel.send(file=File(buffer, filename=attachment.filename)) - out.append(reupload.attachments[0].url) + try: + with BytesIO() as buffer: + await attachment.save(buffer, use_cached=True) + reupload: Message = await channel.send(file=File(buffer, filename=attachment.filename)) + out.append(reupload.attachments[0].url) + except (HTTPException, NotFound): + log.warning(f"Tried to re-upload attchment {attachment.id}, but it has failed.") return out -- cgit v1.2.3 From bfde96aa4ee91a800d03106855afa22b958a92a2 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 28 Nov 2019 08:32:42 +0100 Subject: Fix misspelling Co-Authored-By: Mark --- bot/cogs/antispam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 3118e0a42..0d3d0c4cc 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -280,7 +280,7 @@ async def reupload_attachments( reupload: Message = await channel.send(file=File(buffer, filename=attachment.filename)) out.append(reupload.attachments[0].url) except (HTTPException, NotFound): - log.warning(f"Tried to re-upload attchment {attachment.id}, but it has failed.") + log.warning(f"Tried to re-upload attachment {attachment.id}, but it has failed.") return out -- cgit v1.2.3 From 1c3220345c7b04956b584ccd072d596b1d790a5d Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 28 Nov 2019 09:13:32 +0100 Subject: Update try/except block in reupload_attachments() --- bot/cogs/antispam.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 0d3d0c4cc..6444b1f14 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -279,8 +279,11 @@ async def reupload_attachments( await attachment.save(buffer, use_cached=True) reupload: Message = await channel.send(file=File(buffer, filename=attachment.filename)) out.append(reupload.attachments[0].url) - except (HTTPException, NotFound): - log.warning(f"Tried to re-upload attachment {attachment.id}, but it has failed.") + except HTTPException: + log.warning( + f"Tried to re-upload attachment {attachment.filename} " + f"with ID {attachment.id} from message {message.id}, but it has failed." + ) return out -- cgit v1.2.3 From e669f282453b21a3e40564f4b28f9cb9454487b1 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 28 Nov 2019 09:17:19 +0100 Subject: Re-upload attachments to #attachment-log --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 9d2ee7941..6e8c01ad5 100644 --- a/config-default.yml +++ b/config-default.yml @@ -127,7 +127,7 @@ guild: staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] - attachment_repost: *MODLOG + attachment_repost: 649243850006855680 roles: admin: &ADMIN_ROLE 267628507062992896 -- cgit v1.2.3 From d4269b36bbe1a57c4e1b61671c28647267c608bc Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 30 Nov 2019 13:35:41 +0100 Subject: Update bot/cogs/moderation/modlog.py --- bot/cogs/moderation/modlog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 41d7709e4..0df752a97 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -674,7 +674,7 @@ class ModLog(Cog, name="ModLog"): f"**Before**:\n{' '.join(content_before)}\n" f"**After**:\n{' '.join(content_after)}\n" "\n" - f"[jump to message]({msg_after.jump_url})" + f"[Jump to message]({msg_after.jump_url})" ) if msg_before.edited_at: -- cgit v1.2.3 From ac09fa35c03f76f50d6f7310e3ff4959270aad2b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 1 Dec 2019 13:26:27 -0800 Subject: Allow snekbox in esoteric-python channel * Add a hidden_channels parameter to in_channel decorator to hide channels from the InChannelCheckFailure error message. --- bot/cogs/snekbox.py | 2 +- bot/constants.py | 1 + bot/decorators.py | 15 ++++++++++++--- config-default.yml | 1 + 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 362968bd0..55a187ac1 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -176,7 +176,7 @@ class Snekbox(Cog): @command(name="eval", aliases=("e",)) @guild_only() - @in_channel(Channels.bot, bypass_roles=EVAL_ROLES) + @in_channel(Channels.bot, 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/constants.py b/bot/constants.py index a65c9ffa4..89504a2e0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -353,6 +353,7 @@ class Channels(metaclass=YAMLGetter): defcon: int devlog: int devtest: int + esoteric: int help_0: int help_1: int help_2: int diff --git a/bot/decorators.py b/bot/decorators.py index 935df4af0..61587f406 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -27,11 +27,20 @@ class InChannelCheckFailure(CheckFailure): super().__init__(f"Sorry, but you may only use this command within {channels_str}.") -def in_channel(*channels: int, bypass_roles: Container[int] = None) -> Callable: - """Checks that the message is in a whitelisted channel or optionally has a bypass role.""" +def in_channel( + *channels: int, + hidden_channels: Container[int] = None, + bypass_roles: Container[int] = None +) -> Callable: + """ + Checks that the message is in a whitelisted channel or optionally has a bypass role. + + Hidden channels are channels which will not be displayed in the InChannelCheckFailure error + message. + """ def predicate(ctx: Context) -> bool: """In-channel checker predicate.""" - if ctx.channel.id in channels: + if ctx.channel.id in channels or ctx.channel.id in hidden_channels: log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " f"The command was used in a whitelisted channel.") return True diff --git a/config-default.yml b/config-default.yml index b2ee1361f..930a1a0e6 100644 --- a/config-default.yml +++ b/config-default.yml @@ -108,6 +108,7 @@ guild: defcon: &DEFCON 464469101889454091 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 + esoteric: 470884583684964352 help_0: 303906576991780866 help_1: 303906556754395136 help_2: 303906514266226689 -- cgit v1.2.3 From 4d702cb7783639e1e442409eed7306b4ddedbd81 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Tue, 3 Dec 2019 01:15:04 +0700 Subject: Removed pytest, getting ready to migrate to unittest in another PR --- tests/utils/test_time.py | 44 -------------------------------------------- 1 file changed, 44 deletions(-) diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py index 7bde92506..4baa6395c 100644 --- a/tests/utils/test_time.py +++ b/tests/utils/test_time.py @@ -60,47 +60,3 @@ def test_wait_until(sleep_patch): assert asyncio.run(time.wait_until(then, start)) is None sleep_patch.assert_called_once_with(10 * 60) - - -@pytest.mark.parametrize( - ('date_from', 'date_to', 'parts', 'expected'), - ( - (datetime(2019, 12, 12, 0, 1), datetime(2019, 12, 12, 12, 0, 5), 2, '11 hours, 59 minutes'), - (datetime(2019, 12, 12, 0, 1), datetime(2019, 12, 12, 12, 0, 5), 1, '11 hours'), - (datetime(2019, 12, 12, 0, 1), datetime(2019, 12, 12, 12, 0, 5), None, '11 hours, 59 minutes, 5 seconds'), - (datetime(2019, 12, 12, 0, 0), datetime(2019, 12, 11, 23, 59), 2, '1 minute'), - (datetime(2019, 11, 23, 20, 9), datetime(2019, 11, 30, 20, 15), 2, '1 week, 6 minutes'), - (datetime(2019, 11, 23, 20, 9), datetime(2019, 4, 25, 20, 15), 2, '7 months, 2 weeks'), - (datetime(2019, 11, 23, 20, 9), datetime(2019, 4, 25, 20, 15), - None, '7 months, 2 weeks, 1 day, 23 hours, 54 minutes'), - (datetime(2019, 11, 23, 20, 58), datetime(2019, 11, 23, 21, 3), 2, '5 minutes'), - (datetime(2019, 11, 23, 23, 59), datetime(2019, 11, 24, 0, 0), 2, '1 minute'), - (datetime(2019, 11, 23, 23, 59), datetime(2022, 11, 23, 23, 0), 2, '3 years, 3 months'), - (datetime(2019, 11, 23, 23, 59), datetime(2019, 11, 23, 23, 49, 5), 2, '9 minutes, 55 seconds'), - ) -) -def test_get_duration(date_from: datetime, date_to: datetime, parts: int, expected: str): - assert time.get_duration(date_from, date_to, parts) == expected - - -@pytest.mark.parametrize( - ('expiry', 'date_from', 'parts', 'expected'), - ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 12, 12, 0, 5), 2, '2019-12-12 00:01 (11 hours, 59 minutes)'), - ('2019-12-12T00:01:00Z', datetime(2019, 12, 12, 12, 0, 5), 1, '2019-12-12 00:01 (11 hours)'), - ('2019-12-12T00:01:00Z', datetime(2019, 12, 12, 12, 0, 5), - None, '2019-12-12 00:01 (11 hours, 59 minutes, 5 seconds)'), - ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'), - ('2019-11-23T20:09:00Z', datetime(2019, 11, 30, 20, 15), 2, '2019-11-23 20:09 (1 week, 6 minutes)'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (7 months, 2 weeks)'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), None, - '2019-11-23 20:09 (7 months, 2 weeks, 1 day, 23 hours, 54 minutes)'), - ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 21, 3), 2, '2019-11-23 20:58 (5 minutes)'), - ('2019-11-23T23:59:00Z', datetime(2019, 11, 24, 0, 0), 2, '2019-11-23 23:59 (1 minute)'), - ('2019-11-23T23:59:00Z', datetime(2022, 11, 23, 23, 0), 2, '2019-11-23 23:59 (3 years, 3 months)'), - ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, '2019-11-23 23:59 (9 minutes, 55 seconds)'), - (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), - ) -) -def test_get_duration_from_expiry(expiry: str, date_from: datetime, parts: int, expected: str): - assert time.get_duration_from_expiry(expiry, date_from, parts) == expected -- cgit v1.2.3 From 8fee0ca7fce8919ebf853c5572d988f047043fee Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Tue, 3 Dec 2019 01:15:36 +0700 Subject: Deleted `get_duration` and switched to using the already, nicely made `humanize_delta` --- bot/utils/time.py | 56 ++++++++----------------------------------------------- 1 file changed, 8 insertions(+), 48 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index d3000a7c2..ec47fce2e 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,21 +1,12 @@ import asyncio import datetime -from typing import List, Optional +from typing import Optional import dateutil.parser from dateutil.relativedelta import relativedelta RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" INFRACTION_FORMAT = "%Y-%m-%d %H:%M" -TIME_MARKS = ( - (60, 'second'), # 1 minute - (60, 'minute'), # 1 hour - (24, 'hour'), # 1 day - (7, 'day'), # 1 week - (4, 'week'), # 1 month - (12, 'month'), # 1 year - (999, 'year') # dumb the rest as year, max 999 -) def _stringify_time_unit(value: int, unit: str) -> str: @@ -122,48 +113,17 @@ def format_infraction(timestamp: str) -> str: return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT) -def get_duration(date_from: datetime.datetime, date_to: datetime.datetime, parts: Optional[int] = 2) -> str: - """ - Get the duration between two datetime, in human readable format. - - Will return number of units if avaiable, for example: - - 11 hours, 59 minutes - - 1 week, 6 minutes - - 7 months, 2 weeks - - 3 years, 3 months - - 5 minutes - - :param date_from: A datetime.datetime object. - :param date_to: A datetime.datetime object. - :param parts: An int, defauted to two - the amount of units to return. - """ - div = abs(date_from - date_to).total_seconds() - div = round(div, 0) # to avoid (14 minutes, 60 seconds) - results: List[str] = [] - for unit, name in TIME_MARKS: - div, amount = divmod(div, unit) - if amount > 0: - plural = 's' if amount > 1 else '' - results.append(f"{amount:.0f} {name}{plural}") - parts = parts if parts is not None else len(results) # allow passing None directly to return all parts - # We have to reverse the order of units because currently it's smallest -> largest - return ', '.join(results[::-1][:parts]) - - def get_duration_from_expiry( expiry: str = None, date_from: datetime.datetime = None, - parts: Optional[int] = 2 + max_units: int = 2 ) -> Optional[str]: """ - Get the duration between datetime.utcnow() and an expiry, in human readable format. + Returns a human-readable version of the the duration between datetime.utcnow() and an expiry. - Will return the two biggest units avaiable, for example: - - 11 hours, 59 minutes - - 1 week, 6 minutes - - 7 months, 2 weeks - - 3 years, 3 months - - 5 minutes + Unlike the original function, this function will force the precision to be 'seconds' by not passing it. + max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). + By default, max_units is 2 :param expiry: A string. If not passed in, will early return a None ( Permanent infraction ). :param date_from: A datetime.datetime object. If not passed in, will use datetime.utcnow(). @@ -173,11 +133,11 @@ def get_duration_from_expiry( return None date_from = date_from or datetime.datetime.utcnow() - date_to = dateutil.parser.isoparse(expiry).replace(tzinfo=None) + date_to = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0) expiry_formatted = format_infraction(expiry) - duration = get_duration(date_from, date_to, parts) + duration = humanize_delta(relativedelta(date_to, date_from), max_units=max_units) duration_formatted = f" ({duration})" if duration else '' return f"{expiry_formatted}{duration_formatted}" -- cgit v1.2.3 From 17cedcb89b7b009fab68f3b1d39e968ace0a7c91 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Dec 2019 15:38:33 -0800 Subject: ModLog: use more generic type annotations --- bot/cogs/moderation/modlog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 9251b79fb..6fffa7170 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -35,10 +35,10 @@ class ModLog(Cog, name="ModLog"): self._cached_edits = [] async def upload_log( - self, - messages: t.List[discord.Message], - actor_id: int, - attachments: t.List[t.List[str]] = None + self, + messages: t.Iterable[discord.Message], + actor_id: int, + attachments: t.Iterable[t.List[str]] = None ) -> str: """ Uploads the log data to the database via an API endpoint for uploading logs. -- cgit v1.2.3 From 6cf907a4ab1f632dbe0fb2445703a84b965d7bfa Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 4 Dec 2019 09:03:26 +0700 Subject: Renamed function and improved its docstring to better reflect its purposes. Changed from `get_duration_from_expiry` -> `format_infraction_with_duration` --- bot/cogs/moderation/management.py | 4 ++-- bot/cogs/moderation/scheduler.py | 2 +- bot/utils/time.py | 19 ++++++------------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 5221baa81..abfe5c2b3 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -97,7 +97,7 @@ class ModManagement(commands.Cog): confirm_messages.append("marked as permanent") elif duration is not None: request_data['expires_at'] = duration.isoformat() - expiry = time.get_duration_from_expiry(request_data['expires_at']) + expiry = time.format_infraction_with_duration(request_data['expires_at']) confirm_messages.append(f"set to expire on {expiry}") else: confirm_messages.append("expiry unchanged") @@ -236,7 +236,7 @@ class ModManagement(commands.Cog): expires = "*Permanent*" else: date_from = datetime.strptime(created, time.INFRACTION_FORMAT) - expires = time.get_duration_from_expiry(infraction["expires_at"], date_from) + expires = time.format_infraction_with_duration(infraction["expires_at"], date_from) lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 729763322..3e0968121 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -83,7 +83,7 @@ class InfractionScheduler(Scheduler): infr_type = infraction["type"] icon = utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] - expiry = time.get_duration_from_expiry(infraction["expires_at"]) + expiry = time.format_infraction_with_duration(infraction["expires_at"]) id_ = infraction['id'] log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") diff --git a/bot/utils/time.py b/bot/utils/time.py index ec47fce2e..a024674ac 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -113,21 +113,14 @@ def format_infraction(timestamp: str) -> str: return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT) -def get_duration_from_expiry( - expiry: str = None, - date_from: datetime.datetime = None, - max_units: int = 2 -) -> Optional[str]: +def format_infraction_with_duration(expiry: str, date_from: datetime.datetime = None, max_units: int = 2) -> str: """ - Returns a human-readable version of the the duration between datetime.utcnow() and an expiry. + Format an infraction timestamp to a more readable ISO 8601 format WITH the duration. - Unlike the original function, this function will force the precision to be 'seconds' by not passing it. - max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). - By default, max_units is 2 - - :param expiry: A string. If not passed in, will early return a None ( Permanent infraction ). - :param date_from: A datetime.datetime object. If not passed in, will use datetime.utcnow(). - :param parts: An int, to show how many parts will be returned ( year - month or year - month - week - day ...). + Returns a human-readable version of the duration between datetime.utcnow() and an expiry. + Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it. + `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). + By default, max_units is 2. """ if not expiry: return None -- cgit v1.2.3 From a92186f7218faf48b1ceb3b9f516b29d40e6efaf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Dec 2019 18:33:36 -0800 Subject: Antimalware: fix paste service URL showing replacement field --- bot/cogs/antimalware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 745dd8082..602819191 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -26,7 +26,7 @@ class AntiMalware(Cog): if filename.endswith('.py'): embed.description = ( f"It looks like you tried to attach a Python file - please " - f"use a code-pasting service such as {URLs.paste_service}" + f"use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}" ) break # Other detections irrelevant because we prioritize the .py message. if not filename.endswith(tuple(AntiMalwareConfig.whitelist)): -- cgit v1.2.3 From e29b65c5a2b48e8870819453581c50bbcc2326bd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Dec 2019 20:20:55 -0800 Subject: Utils: support returning URLs from send_attachments --- bot/utils/messages.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 549b33ca6..40c20c7ec 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,7 +1,7 @@ import asyncio import contextlib from io import BytesIO -from typing import Optional, Sequence, Union +from typing import List, Optional, Sequence, Union from discord import Client, Embed, File, Member, Message, Reaction, TextChannel, Webhook from discord.abc import Snowflake @@ -51,14 +51,15 @@ async def wait_for_deletion( await message.delete() -async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]) -> None: +async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]) -> List[str]: """ - Re-uploads each attachment in a message to the given channel or webhook. + Re-upload the message's attachments to the destination and return a list of their new URLs. Each attachment is sent as a separate message to more easily comply with the 8 MiB request size limit. If attachments are too large, they are instead grouped into a single embed which links to them. """ large = [] + urls = [] for attachment in message.attachments: try: # This should avoid most files that are too large, but some may get through hence the try-catch. @@ -69,7 +70,8 @@ async def send_attachments(message: Message, destination: Union[TextChannel, Web attachment_file = File(file, filename=attachment.filename) if isinstance(destination, TextChannel): - await destination.send(file=attachment_file) + msg = await destination.send(file=attachment_file) + urls.append(msg.attachments[0].url) else: await destination.send( file=attachment_file, @@ -95,3 +97,5 @@ async def send_attachments(message: Message, destination: Union[TextChannel, Web username=message.author.display_name, avatar_url=message.author.avatar_url ) + + return urls -- cgit v1.2.3 From 336c6d523031b611ebc4823583c7d8b4ed1964c2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Dec 2019 20:23:13 -0800 Subject: Utils: use the guild's filesize_limit to determine max attachment size --- bot/utils/messages.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 40c20c7ec..fa1ee80b5 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -9,8 +9,6 @@ from discord.errors import HTTPException from bot.constants import Emojis -MAX_SIZE = 1024 * 1024 * 8 # 8 Mebibytes - async def wait_for_deletion( message: Message, @@ -62,9 +60,10 @@ async def send_attachments(message: Message, destination: Union[TextChannel, Web urls = [] for attachment in message.attachments: try: - # This should avoid most files that are too large, but some may get through hence the try-catch. # Allow 512 bytes of leeway for the rest of the request. - if attachment.size <= MAX_SIZE - 512: + # This should avoid most files that are too large, + # but some may get through hence the try-catch. + if attachment.size <= destination.guild.filesize_limit - 512: with BytesIO() as file: await attachment.save(file) attachment_file = File(file, filename=attachment.filename) -- cgit v1.2.3 From 4e414108ef3e098c24b9ab14fd09550673d87207 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Dec 2019 20:28:20 -0800 Subject: Utils: add send_attachments param to disable linking to too-large files --- bot/utils/messages.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index fa1ee80b5..232d3cba6 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -49,12 +49,17 @@ async def wait_for_deletion( await message.delete() -async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]) -> List[str]: +async def send_attachments( + message: Message, + destination: Union[TextChannel, Webhook], + link_large: bool = True +) -> List[str]: """ Re-upload the message's attachments to the destination and return a list of their new URLs. - Each attachment is sent as a separate message to more easily comply with the 8 MiB request size limit. - If attachments are too large, they are instead grouped into a single embed which links to them. + Each attachment is sent as a separate message to more easily comply with the request/file size + limit. If link_large is True, attachments which are too large are instead grouped into a single + embed which links to them. """ large = [] urls = [] @@ -77,17 +82,19 @@ async def send_attachments(message: Message, destination: Union[TextChannel, Web username=message.author.display_name, avatar_url=message.author.avatar_url ) - else: + elif link_large: large.append(attachment) except HTTPException as e: - if e.status == 413: + if link_large and e.status == 413: large.append(attachment) else: raise - if large: - embed = Embed(description=f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)) + if link_large and large: + desc = f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large) + embed = Embed(description=desc) embed.set_footer(text="Attachments exceed upload size limit.") + if isinstance(destination, TextChannel): await destination.send(embed=embed) else: -- cgit v1.2.3 From 0f71c817320c077463a483701c6d51a8fa3e2164 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Dec 2019 20:29:23 -0800 Subject: Utils: log send_attachments failures instead of raising exceptions --- bot/utils/messages.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 232d3cba6..a39521b72 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,5 +1,6 @@ import asyncio import contextlib +import logging from io import BytesIO from typing import List, Optional, Sequence, Union @@ -9,6 +10,8 @@ from discord.errors import HTTPException from bot.constants import Emojis +log = logging.getLogger(__name__) + async def wait_for_deletion( message: Message, @@ -64,6 +67,10 @@ async def send_attachments( large = [] urls = [] for attachment in message.attachments: + failure_msg = ( + f"Failed to re-upload attachment {attachment.filename} from message {message.id}" + ) + try: # Allow 512 bytes of leeway for the rest of the request. # This should avoid most files that are too large, @@ -84,11 +91,13 @@ async def send_attachments( ) elif link_large: large.append(attachment) + else: + log.warning(f"{failure_msg} because it's too large.") except HTTPException as e: if link_large and e.status == 413: large.append(attachment) else: - raise + log.warning(f"{failure_msg} with status {e.status}.") if link_large and large: desc = f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large) -- cgit v1.2.3 From c4f80a54cdc02ea150d956d77bd739cf5c9564d6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Dec 2019 20:31:21 -0800 Subject: Utils: have send_attachments save attachments using the cached URL This makes it more likely to successfully save an attachment after it's been deleted. --- bot/utils/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index a39521b72..c4e2753e0 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -77,7 +77,7 @@ async def send_attachments( # but some may get through hence the try-catch. if attachment.size <= destination.guild.filesize_limit - 512: with BytesIO() as file: - await attachment.save(file) + await attachment.save(file, use_cached=True) attachment_file = File(file, filename=attachment.filename) if isinstance(destination, TextChannel): -- cgit v1.2.3 From 62d909b7bc0586a91c08b46c128a5eb5c5e6883e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Dec 2019 20:32:23 -0800 Subject: AntiSpam: replace reupload_attachments with send_attachments from utils --- bot/cogs/antispam.py | 34 ++++++---------------------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 6444b1f14..3a654cfaa 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -3,11 +3,10 @@ import logging from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta -from io import BytesIO from operator import itemgetter from typing import Dict, Iterable, List, Set -from discord import Colour, File, HTTPException, Member, Message, NotFound, Object, TextChannel +from discord import Colour, Member, Message, NotFound, Object, TextChannel from discord.ext.commands import Bot, Cog from bot import rules @@ -19,6 +18,7 @@ from bot.constants import ( STAFF_ROLES, ) from bot.converters import Duration +from bot.utils.messages import send_attachments log = logging.getLogger(__name__) @@ -60,8 +60,10 @@ class DeletionContext: if message.id not in self.messages: self.messages[message.id] = message - # Re-upload attachments : - self.attachments.append(await reupload_attachments(self.bot, message)) + # Re-upload attachments + destination = self.bot.get_channel(GuildConfig.attachment_repost) + urls = await send_attachments(message, destination, link_large=False) + self.attachments.append(urls) async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: """Method that takes care of uploading the queue and posting modlog alert.""" @@ -263,30 +265,6 @@ class AntiSpam(Cog): await deletion_context.upload_messages(self.bot.user.id, self.mod_log) -async def reupload_attachments( - bot: Bot, - message: Message, - channel_id: int = GuildConfig.attachment_repost -) -> List[str]: - """Re-upload message's attachments to the the channel_id and return the list of re-posted attachments URLs.""" - if not message.attachments: - return [] - channel = bot.get_channel(channel_id) - out = [] - for attachment in message.attachments: - try: - with BytesIO() as buffer: - await attachment.save(buffer, use_cached=True) - reupload: Message = await channel.send(file=File(buffer, filename=attachment.filename)) - out.append(reupload.attachments[0].url) - except HTTPException: - log.warning( - f"Tried to re-upload attachment {attachment.filename} " - f"with ID {attachment.id} from message {message.id}, but it has failed." - ) - return out - - def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: """Validates the antispam configs.""" validation_errors = {} -- cgit v1.2.3 From 2af995cdf483bf1b8a927768566d9a27cf8a07cf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Dec 2019 21:07:17 -0800 Subject: AntiSpam: correct a function annotation --- bot/cogs/antispam.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 3a654cfaa..1a60897c9 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -105,7 +105,7 @@ class DeletionContext: class AntiSpam(Cog): """Cog that controls our anti-spam measures.""" - def __init__(self, bot: Bot, validation_errors: bool) -> None: + def __init__(self, bot: Bot, validation_errors: Dict[str, str]) -> None: self.bot = bot self.validation_errors = validation_errors role_id = AntiSpamConfig.punishment['role_id'] @@ -265,10 +265,10 @@ class AntiSpam(Cog): await deletion_context.upload_messages(self.bot.user.id, self.mod_log) -def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: +def validate_config(_rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: """Validates the antispam configs.""" validation_errors = {} - for name, config in rules.items(): + for name, config in _rules.items(): if name not in RULE_FUNCTION_MAPPING: log.error( f"Unrecognized antispam rule `{name}`. " -- cgit v1.2.3 From 56c1e01e3059a7163e01df5282265c1053b419c3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Dec 2019 21:36:37 -0800 Subject: ModLog: fix 0 message logs uploaded when no attachments given --- bot/cogs/moderation/modlog.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 6fffa7170..801582a6b 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -2,6 +2,7 @@ import asyncio import logging import typing as t from datetime import datetime +from itertools import zip_longest import discord from dateutil.relativedelta import relativedelta @@ -40,13 +41,7 @@ class ModLog(Cog, name="ModLog"): actor_id: int, attachments: t.Iterable[t.List[str]] = None ) -> str: - """ - Uploads the log data to the database via an API endpoint for uploading logs. - - Used in several mod log embeds. - - Returns a URL that can be used to view the log. - """ + """Upload message logs to the database and return a URL to a page for viewing the logs.""" if attachments is None: attachments = [] @@ -64,7 +59,7 @@ class ModLog(Cog, name="ModLog"): 'embeds': [embed.to_dict() for embed in message.embeds], 'attachments': attachment, } - for message, attachment in zip(messages, attachments) + for message, attachment in zip_longest(messages, attachments) ] } ) -- cgit v1.2.3 From 400f7c85da237a118024a4fb4e73802c8700fc46 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Dec 2019 21:43:15 -0800 Subject: AntiSpam: remove bot field from DeletionContext The destination channel can be retrieved be accessing a message's guild. * Remove unused queue_consumption_tasks attribute. --- bot/cogs/antispam.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 1a60897c9..669c62c75 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -42,7 +42,6 @@ class DeletionContext: """Represents a Deletion Context for a single spam event.""" channel: TextChannel - bot: Bot members: Dict[int, Member] = field(default_factory=dict) rules: Set[str] = field(default_factory=set) messages: Dict[int, Message] = field(default_factory=dict) @@ -61,7 +60,7 @@ class DeletionContext: self.messages[message.id] = message # Re-upload attachments - destination = self.bot.get_channel(GuildConfig.attachment_repost) + destination = message.guild.get_channel(GuildConfig.attachment_repost) urls = await send_attachments(message, destination, link_large=False) self.attachments.append(urls) @@ -77,7 +76,7 @@ class DeletionContext: # For multiple messages or those with excessive newlines, use the logs API if len(self.messages) > 1 or 'newlines' in self.rules: - url = await modlog.upload_log(self.messages.values(), actor_id, attachments=self.attachments) + url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments) mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" else: mod_alert_message += "Message:\n" @@ -113,7 +112,6 @@ class AntiSpam(Cog): self.expiration_date_converter = Duration() self.message_deletion_queue = dict() - self.queue_consumption_tasks = dict() self.bot.loop.create_task(self.alert_on_validation_error()) @@ -187,15 +185,11 @@ class AntiSpam(Cog): full_reason = f"`{rule_name}` rule: {reason}" # If there's no spam event going on for this channel, start a new Message Deletion Context - if message.channel.id not in self.message_deletion_queue: - log.trace(f"Creating queue for channel `{message.channel.id}`") - self.message_deletion_queue[message.channel.id] = DeletionContext( - channel=message.channel, - bot=self.bot - ) - self.queue_consumption_tasks = self.bot.loop.create_task( - self._process_deletion_context(message.channel.id) - ) + channel = message.channel + if channel.id not in self.message_deletion_queue: + log.trace(f"Creating queue for channel `{channel.id}`") + self.message_deletion_queue[message.channel.id] = DeletionContext(channel) + self.bot.loop.create_task(self._process_deletion_context(message.channel.id)) # Add the relevant of this trigger to the Deletion Context await self.message_deletion_queue[message.channel.id].add( @@ -212,7 +206,7 @@ class AntiSpam(Cog): self.punish(message, member, full_reason) ) - await self.maybe_delete_messages(message.channel, relevant_messages) + await self.maybe_delete_messages(channel, relevant_messages) break async def punish(self, msg: Message, member: Member, reason: str) -> None: -- cgit v1.2.3 From 7e25475b78df01646cbc82176443f955bb6d1964 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 4 Dec 2019 15:01:40 +0700 Subject: Improved type hinting for `format_infraction_with_duration` --- bot/utils/time.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index a024674ac..9520b32f8 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -113,7 +113,11 @@ def format_infraction(timestamp: str) -> str: return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT) -def format_infraction_with_duration(expiry: str, date_from: datetime.datetime = None, max_units: int = 2) -> str: +def format_infraction_with_duration( + expiry: Optional[str], + date_from: datetime.datetime = None, + max_units: int = 2 +) -> Optional[str]: """ Format an infraction timestamp to a more readable ISO 8601 format WITH the duration. -- cgit v1.2.3 From 51f80015c5db9ab8e85ea2304789491d4c72c053 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 4 Dec 2019 15:03:16 +0700 Subject: Created `until_expiration` to get the remaining time until the infraction expires. --- bot/utils/time.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/bot/utils/time.py b/bot/utils/time.py index 9520b32f8..ac64865d6 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -138,3 +138,23 @@ def format_infraction_with_duration( duration_formatted = f" ({duration})" if duration else '' return f"{expiry_formatted}{duration_formatted}" + + +def until_expiration(expiry: Optional[str], max_units: int = 2) -> Optional[str]: + """ + Get the remaining time until infraction's expiration, in a human-readable version of the relativedelta. + + Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it. + `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). + By default, max_units is 2. + """ + if not expiry: + return None + + now = datetime.datetime.utcnow() + since = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0) + + if since < now: + return None + + return humanize_delta(relativedelta(since, now), max_units=max_units) -- cgit v1.2.3 From 82eb5e1c46e378a6f3778e17cc342193b910ded5 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 4 Dec 2019 15:04:20 +0700 Subject: Implemented remaining time until expiration for infraction searching. Will show the remaining time, `Expired.` or `Inactive.` based on the status of the infraction ( It can be inactive but not expired, like an early unmute ) --- bot/cogs/moderation/management.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index abfe5c2b3..2f5e09f1b 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -232,6 +232,12 @@ class ModManagement(commands.Cog): user_id = infraction["user"] hidden = infraction["hidden"] created = time.format_infraction(infraction["inserted_at"]) + + if active: + remaining = time.until_expiration(infraction["expires_at"]) or 'Expired.' + else: + remaining = 'Inactive.' + if infraction["expires_at"] is None: expires = "*Permanent*" else: @@ -247,6 +253,7 @@ class ModManagement(commands.Cog): Reason: {infraction["reason"] or "*None*"} Created: {created} Expires: {expires} + Remaining: {remaining} Actor: {actor.mention if actor else actor_id} ID: `{infraction["id"]}` {"**===============**" if active else "==============="} -- cgit v1.2.3 From c1aeb6d263172168f77845408e8d2756f6cb2813 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 4 Dec 2019 17:12:25 +0700 Subject: Apply suggestions from Mark - removing `.` at the end and use double quote instead of single. Co-Authored-By: Mark --- bot/cogs/moderation/management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 2f5e09f1b..74f75781d 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -234,9 +234,9 @@ class ModManagement(commands.Cog): created = time.format_infraction(infraction["inserted_at"]) if active: - remaining = time.until_expiration(infraction["expires_at"]) or 'Expired.' + remaining = time.until_expiration(infraction["expires_at"]) or "Expired" else: - remaining = 'Inactive.' + remaining = "Inactive" if infraction["expires_at"] is None: expires = "*Permanent*" -- cgit v1.2.3 From e07cf7342184b769d8c0655bc9b84be02809319a Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 4 Dec 2019 23:38:46 +0700 Subject: Added `unittest` for `bot.utils.time` --- tests/bot/utils/test_time.py | 87 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/bot/utils/test_time.py diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py new file mode 100644 index 000000000..0ef59292e --- /dev/null +++ b/tests/bot/utils/test_time.py @@ -0,0 +1,87 @@ +import asyncio +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + +from dateutil.relativedelta import relativedelta + +from bot.utils import time +from tests.helpers import AsyncMock + + +class TimeTests(unittest.TestCase): + """Test helper functions in bot.utils.time.""" + + def setUp(self): + pass + + def test_humanize_delta(self): + """Testing humanize delta.""" + test_cases = ( + (relativedelta(days=2), 'seconds', 1, '2 days'), + (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), + (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), + (relativedelta(days=2, hours=2), 'days', 2, '2 days'), + + # Does not abort for unknown units, as the unit name is checked + # against the attribute of the relativedelta instance. + (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'), + + # Very high maximum units, but it only ever iterates over + # each value the relativedelta might have. + (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), + ) + + for delta, precision, max_units, expected in test_cases: + self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + + def test_humanize_delta_raises_for_invalid_max_units(self): + test_cases = (-1, 0) + + for max_units in test_cases: + with self.assertRaises(ValueError) as error: + time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) + self.assertEqual(str(error), 'max_units must be positive') + + def test_parse_rfc1123(self): + """Testing parse_rfc1123.""" + test_cases = ( + ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)), + ) + + for stamp, expected in test_cases: + self.assertEqual(time.parse_rfc1123(stamp), expected) + + @patch('asyncio.sleep', new_callable=AsyncMock) + def test_wait_until(self, mock): + """Testing wait_until.""" + start = datetime(2019, 1, 1, 0, 0) + then = datetime(2019, 1, 1, 0, 10) + + # No return value + assert asyncio.run(time.wait_until(then, start)) is None + + mock.assert_called_once_with(10 * 60) + + def test_format_infraction_with_duration(self): + """Testing format_infraction_with_duration.""" + test_cases = ( + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '2019-12-12 00:01 (12 hours and 55 seconds)'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '2019-12-12 00:01 (12 hours)'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, + '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'), + ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'), + ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '2019-11-23 20:09 (7 days and 23 hours)'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (6 months and 28 days)'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 6, + '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)'), + ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '2019-11-23 20:58 (5 minutes)'), + ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '2019-11-24 00:00 (1 minute)'), + ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2019-11-23 23:59 (2 years and 4 months)'), + ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, + '2019-11-23 23:59 (9 minutes and 55 seconds)'), + (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), + ) + + for expiry, date_from, max_units, expected in test_cases: + self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) -- cgit v1.2.3 From b17dbe5e3e0dfa6ae44d660924455f709abefd0d Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:34:58 +0700 Subject: Splitting test cases for `humanize_delta` into proper, independent tests. --- tests/bot/utils/test_time.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 0ef59292e..5e5f2bf2f 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -15,18 +15,20 @@ class TimeTests(unittest.TestCase): def setUp(self): pass - def test_humanize_delta(self): - """Testing humanize delta.""" + def test_humanize_delta_handle_unknown_units(self): + """humanize_delta should be able to handle unknown units, and will not abort.""" test_cases = ( - (relativedelta(days=2), 'seconds', 1, '2 days'), - (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), - (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), - (relativedelta(days=2, hours=2), 'days', 2, '2 days'), - # Does not abort for unknown units, as the unit name is checked # against the attribute of the relativedelta instance. (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'), + ) + for delta, precision, max_units, expected in test_cases: + self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + + def test_humanize_delta_handle_high_units(self): + """humanize_delta should be able to handle very high units.""" + test_cases = ( # Very high maximum units, but it only ever iterates over # each value the relativedelta might have. (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), @@ -35,6 +37,18 @@ class TimeTests(unittest.TestCase): for delta, precision, max_units, expected in test_cases: self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + def test_humanize_delta_should_work_normally(self): + """Testing humanize delta.""" + test_cases = ( + (relativedelta(days=2), 'seconds', 1, '2 days'), + (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), + (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), + (relativedelta(days=2, hours=2), 'days', 2, '2 days'), + ) + + for delta, precision, max_units, expected in test_cases: + self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + def test_humanize_delta_raises_for_invalid_max_units(self): test_cases = (-1, 0) -- cgit v1.2.3 From 0aee728d6d23ef24f51834f39016f938f3f1b8a9 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:36:29 +0700 Subject: Added missing docstring for `test_humanize_delta_raises_for_invalid_max_units` --- tests/bot/utils/test_time.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 5e5f2bf2f..a929bee89 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -50,6 +50,7 @@ class TimeTests(unittest.TestCase): self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) def test_humanize_delta_raises_for_invalid_max_units(self): + """humanize_delta should raises ValueError('max_units must be positive') for invalid max units.""" test_cases = (-1, 0) for max_units in test_cases: -- cgit v1.2.3 From beed21355e7f0e25b69637768843c53d510b8969 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:40:38 +0700 Subject: Changed `assert` to `self.assertIs` for `test_wait_until` --- tests/bot/utils/test_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index a929bee89..0afabe400 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -74,7 +74,7 @@ class TimeTests(unittest.TestCase): then = datetime(2019, 1, 1, 0, 10) # No return value - assert asyncio.run(time.wait_until(then, start)) is None + self.assertIs(asyncio.run(time.wait_until(then, start)), None) mock.assert_called_once_with(10 * 60) -- cgit v1.2.3 From ccdd8363d75846f0841791ba54763dae28243c62 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:45:36 +0700 Subject: Splitting test cases for `format_infraction_with_duration` into proper, independent tests. --- tests/bot/utils/test_time.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 0afabe400..2a2a707d8 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -37,7 +37,7 @@ class TimeTests(unittest.TestCase): for delta, precision, max_units, expected in test_cases: self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) - def test_humanize_delta_should_work_normally(self): + def test_humanize_delta_should_normal_usage(self): """Testing humanize delta.""" test_cases = ( (relativedelta(days=2), 'seconds', 1, '2 days'), @@ -78,18 +78,38 @@ class TimeTests(unittest.TestCase): mock.assert_called_once_with(10 * 60) - def test_format_infraction_with_duration(self): - """Testing format_infraction_with_duration.""" + def test_format_infraction_with_duration_none_expiry(self): + """format_infraction_with_duration should work for None expiry.""" + self.assertEqual(time.format_infraction_with_duration(None), None) + + # To make sure that date_from and max_units are not touched + self.assertEqual(time.format_infraction_with_duration(None, date_from='Why hello there!'), None) + self.assertEqual(time.format_infraction_with_duration(None, max_units=float('inf')), None) + self.assertEqual( + time.format_infraction_with_duration(None, date_from='Why hello there!', max_units=float('inf')), + None + ) + + def test_format_infraction_with_duration_custom_units(self): + """format_infraction_with_duration should work for custom max_units.""" + self.assertEqual( + time.format_infraction_with_duration('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6), + '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)' + ) + + self.assertEqual( + time.format_infraction_with_duration('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20), + '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)' + ) + + def test_format_infraction_with_duration_normal_usage(self): + """format_infraction_with_duration should work for normal usage, across various durations.""" test_cases = ( ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '2019-12-12 00:01 (12 hours and 55 seconds)'), ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '2019-12-12 00:01 (12 hours)'), - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, - '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'), ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'), ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '2019-11-23 20:09 (7 days and 23 hours)'), ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (6 months and 28 days)'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 6, - '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)'), ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '2019-11-23 20:58 (5 minutes)'), ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '2019-11-24 00:00 (1 minute)'), ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2019-11-23 23:59 (2 years and 4 months)'), -- cgit v1.2.3 From fa66195dbb6f79bb7174084835499a61e8cb03a3 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:52:15 +0700 Subject: Introduced test for `test_format_infraction`, refactored `test_parse_rfc1123`, fixed typo. --- tests/bot/utils/test_time.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 2a2a707d8..09fb824e4 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -50,7 +50,7 @@ class TimeTests(unittest.TestCase): self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) def test_humanize_delta_raises_for_invalid_max_units(self): - """humanize_delta should raises ValueError('max_units must be positive') for invalid max units.""" + """humanize_delta should raises ValueError('max_units must be positive') for invalid max_units.""" test_cases = (-1, 0) for max_units in test_cases: @@ -60,12 +60,14 @@ class TimeTests(unittest.TestCase): def test_parse_rfc1123(self): """Testing parse_rfc1123.""" - test_cases = ( - ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)), + self.assertEqual( + time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'), + datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc) ) - for stamp, expected in test_cases: - self.assertEqual(time.parse_rfc1123(stamp), expected) + def test_format_infraction(self): + """Testing format_infraction.""" + self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '2019-12-12 00:01') @patch('asyncio.sleep', new_callable=AsyncMock) def test_wait_until(self, mock): -- cgit v1.2.3 From 5e0b19ae841f3f355931ad331f7aa861fbafc4d9 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 01:05:41 +0700 Subject: Added `self.subTest` for tests with multiple test cases & simplified single test case tests. --- tests/bot/utils/test_time.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 09fb824e4..c47a306f0 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -17,25 +17,15 @@ class TimeTests(unittest.TestCase): def test_humanize_delta_handle_unknown_units(self): """humanize_delta should be able to handle unknown units, and will not abort.""" - test_cases = ( - # Does not abort for unknown units, as the unit name is checked - # against the attribute of the relativedelta instance. - (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'), - ) - - for delta, precision, max_units, expected in test_cases: - self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + # Does not abort for unknown units, as the unit name is checked + # against the attribute of the relativedelta instance. + self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'elephants', 2), '2 days and 2 hours') def test_humanize_delta_handle_high_units(self): """humanize_delta should be able to handle very high units.""" - test_cases = ( - # Very high maximum units, but it only ever iterates over - # each value the relativedelta might have. - (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), - ) - - for delta, precision, max_units, expected in test_cases: - self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + # Very high maximum units, but it only ever iterates over + # each value the relativedelta might have. + self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'hours', 20), '2 days and 2 hours') def test_humanize_delta_should_normal_usage(self): """Testing humanize delta.""" @@ -47,14 +37,15 @@ class TimeTests(unittest.TestCase): ) for delta, precision, max_units, expected in test_cases: - self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + with self.subTest(delta=delta, precision=precision, max_units=max_units, expected=expected): + self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) def test_humanize_delta_raises_for_invalid_max_units(self): """humanize_delta should raises ValueError('max_units must be positive') for invalid max_units.""" test_cases = (-1, 0) for max_units in test_cases: - with self.assertRaises(ValueError) as error: + with self.subTest(max_units=max_units), self.assertRaises(ValueError) as error: time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) self.assertEqual(str(error), 'max_units must be positive') @@ -121,4 +112,5 @@ class TimeTests(unittest.TestCase): ) for expiry, date_from, max_units, expected in test_cases: - self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): + self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) -- cgit v1.2.3 From db341d927aab42c2e874cb499ab1c2e6c0e7647b Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 01:17:56 +0700 Subject: Moved all individual test cases into iterables and test with `self.subTest` context manager. --- tests/bot/utils/test_time.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index c47a306f0..25cd3f69f 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -73,27 +73,31 @@ class TimeTests(unittest.TestCase): def test_format_infraction_with_duration_none_expiry(self): """format_infraction_with_duration should work for None expiry.""" - self.assertEqual(time.format_infraction_with_duration(None), None) + test_cases = ( + (None, None, None, None), - # To make sure that date_from and max_units are not touched - self.assertEqual(time.format_infraction_with_duration(None, date_from='Why hello there!'), None) - self.assertEqual(time.format_infraction_with_duration(None, max_units=float('inf')), None) - self.assertEqual( - time.format_infraction_with_duration(None, date_from='Why hello there!', max_units=float('inf')), - None + # To make sure that date_from and max_units are not touched + (None, 'Why hello there!', None, None), + (None, None, float('inf'), None), + (None, 'Why hello there!', float('inf'), None), ) + for expiry, date_from, max_units, expected in test_cases: + with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): + self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + def test_format_infraction_with_duration_custom_units(self): """format_infraction_with_duration should work for custom max_units.""" - self.assertEqual( - time.format_infraction_with_duration('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6), - '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)' + test_cases = ( + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, + '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, + '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)') ) - self.assertEqual( - time.format_infraction_with_duration('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20), - '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)' - ) + for expiry, date_from, max_units, expected in test_cases: + with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): + self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) def test_format_infraction_with_duration_normal_usage(self): """format_infraction_with_duration should work for normal usage, across various durations.""" -- cgit v1.2.3 From 323306776b0312e2a32ada213a35159311a93a7f Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 01:42:23 +0700 Subject: Removed `setUp()` from `TimeTests` since it is not being used for anything. --- tests/bot/utils/test_time.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 25cd3f69f..7f55dc3ec 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -12,9 +12,6 @@ from tests.helpers import AsyncMock class TimeTests(unittest.TestCase): """Test helper functions in bot.utils.time.""" - def setUp(self): - pass - def test_humanize_delta_handle_unknown_units(self): """humanize_delta should be able to handle unknown units, and will not abort.""" # Does not abort for unknown units, as the unit name is checked -- cgit v1.2.3 From ad1a33e80152343a81eeeabf0117ced76b83e273 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 5 Dec 2019 10:35:55 -0600 Subject: Added optional channel parameter to !echo: - Added the option to specify a channel to have Python repeat what you said to it, as well as keeping the old functionality of having it repeat what you said in the current channel if no channel argument is given. Signed-off-by: Daniel Brown --- bot/cogs/bot.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 7583b2f2d..ee0a463de 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -4,7 +4,7 @@ import re import time from typing import Optional, Tuple -from discord import Embed, Message, RawMessageUpdateEvent +from discord import Embed, Message, RawMessageUpdateEvent, TextChannel from discord.ext.commands import Bot, Cog, Context, command, group from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs @@ -71,9 +71,12 @@ class Bot(Cog): @command(name='echo', aliases=('print',)) @with_role(*MODERATION_ROLES) - async def echo_command(self, ctx: Context, *, text: str) -> None: - """Send the input verbatim to the current channel.""" - await ctx.send(text) + async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: + """Repeat the given message in either a specified channel or the current channel.""" + if channel is None: + await ctx.send(text) + else: + await channel.send(text) @command(name='embed') @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 52163d775a6a0737f32a0c291e9275a910656fab Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Thu, 5 Dec 2019 17:49:28 +0000 Subject: Requested change Include the check about whether or not there is a token in the posted message in `parse_codeblock` boolean. --- bot/cogs/bot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 53221cd8b..f79e00454 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -236,9 +236,10 @@ class Bot(Cog): ) and not msg.author.bot and len(msg.content.splitlines()) > 3 + and not TokenRemover.is_token_in_message(msg) ) - if parse_codeblock and not TokenRemover.is_token_in_message(msg): # no token in the msg + if parse_codeblock: # no token in the msg on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 if not on_cooldown or DEBUG_MODE: try: -- cgit v1.2.3 From a9dc1000872f507a850798b204befed299b6f703 Mon Sep 17 00:00:00 2001 From: Jens Date: Thu, 5 Dec 2019 22:07:59 +0100 Subject: Keeps access token alive, only revokes it on extension unload. Hard-coded version number to 1.0.0. --- bot/cogs/reddit.py | 52 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 64a940af1..0ebf2e1a7 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -2,7 +2,8 @@ import asyncio import logging import random import textwrap -from datetime import datetime +from collections import namedtuple +from datetime import datetime, timedelta from typing import List from aiohttp import BasicAuth @@ -21,11 +22,7 @@ log = logging.getLogger(__name__) class Reddit(Cog): """Track subreddit posts and show detailed statistics about them.""" - # Change your client's User-Agent string to something unique and descriptive, - # including the target platform, a unique application identifier, a version string, - # and your username as contact information, in the following format: - # :: (by /u/) - USER_AGENT = "docker-python3:Discord Bot of PythonDiscord (https://pythondiscord.com/):v?.?.? (by /u/PythonDiscord)" + USER_AGENT = "docker-python3:Discord Bot of PythonDiscord (https://pythondiscord.com/):1.0.0 (by /u/PythonDiscord)" URL = "https://www.reddit.com" OAUTH_URL = "https://oauth.reddit.com" MAX_RETRIES = 3 @@ -33,7 +30,8 @@ class Reddit(Cog): def __init__(self, bot: Bot): self.bot = bot - self.webhook = None # set in on_ready + self.webhook = None + self.access_token = None bot.loop.create_task(self.init_reddit_ready()) self.auto_poster_loop.start() @@ -41,6 +39,8 @@ class Reddit(Cog): def cog_unload(self) -> None: """Stops the loops when the cog is unloaded.""" self.auto_poster_loop.cancel() + if self.access_token.expires_at < datetime.utcnow(): + self.revoke_access_token() async def init_reddit_ready(self) -> None: """Sets the reddit webhook when the cog is loaded.""" @@ -53,7 +53,7 @@ class Reddit(Cog): """Get the #reddit channel object from the bot's cache.""" return self.bot.get_channel(Channels.reddit) - async def get_access_tokens(self) -> None: + async def get_access_token(self) -> None: """Get Reddit access tokens.""" headers = {"User-Agent": self.USER_AGENT} data = { @@ -61,6 +61,7 @@ class Reddit(Cog): "duration": "temporary" } + log.info(f"{RedditConfig.client_id}, {RedditConfig.secret}") self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) for _ in range(self.MAX_RETRIES): @@ -72,9 +73,13 @@ class Reddit(Cog): ) if response.status == 200 and response.content_type == "application/json": content = await response.json() - self.access_token = content["access_token"] + AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) + self.access_token = AccessToken( + token=content["access_token"], + expires_at=datetime.utcnow() + timedelta(hours=1) + ) self.headers = { - "Authorization": "bearer " + self.access_token, + "Authorization": "bearer " + self.access_token.token, "User-Agent": self.USER_AGENT } return @@ -91,7 +96,7 @@ class Reddit(Cog): # The token should be revoked, since the API is called only once a day. headers = {"User-Agent": self.USER_AGENT} data = { - "token": self.access_token, + "token": self.access_token.token, "token_type_hint": "access_token" } @@ -200,7 +205,10 @@ class Reddit(Cog): if not self.webhook: await self.bot.fetch_webhook(Webhooks.reddit) - await self.get_access_tokens() + if not self.access_token: + await self.get_access_token() + elif self.access_token.expires_at < datetime.utcnow(): + await self.get_access_token() if datetime.utcnow().weekday() == 0: await self.top_weekly_posts() @@ -210,8 +218,6 @@ class Reddit(Cog): top_posts = await self.get_top_posts(subreddit=subreddit, time="day") await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts) - await self.revoke_access_token() - async def top_weekly_posts(self) -> None: """Post a summary of the top posts.""" for subreddit in RedditConfig.subreddits: @@ -242,32 +248,38 @@ class Reddit(Cog): @reddit_group.command(name="top") async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of all time from a given subreddit.""" + if not self.access_token: + await self.get_access_token() + elif self.access_token.expires_at < datetime.utcnow(): + await self.get_access_token() async with ctx.typing(): - await self.get_access_tokens() embed = await self.get_top_posts(subreddit=subreddit, time="all") await ctx.send(content=f"Here are the top {subreddit} posts of all time!", embed=embed) - await self.revoke_access_token() @reddit_group.command(name="daily") async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of today from a given subreddit.""" + if not self.access_token: + await self.get_access_token() + elif self.access_token.expires_at < datetime.utcnow(): + await self.get_access_token() async with ctx.typing(): - await self.get_access_tokens() embed = await self.get_top_posts(subreddit=subreddit, time="day") await ctx.send(content=f"Here are today's top {subreddit} posts!", embed=embed) - await self.revoke_access_token() @reddit_group.command(name="weekly") async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of this week from a given subreddit.""" + if not self.access_token: + await self.get_access_token() + elif self.access_token.expires_at < datetime.utcnow(): + await self.get_access_token() async with ctx.typing(): - await self.get_access_tokens() embed = await self.get_top_posts(subreddit=subreddit, time="week") await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed) - await self.revoke_access_token() @with_role(*STAFF_ROLES) @reddit_group.command(name="subreddits", aliases=("subs",)) -- cgit v1.2.3 From d913a91531ba6414741d745303f89cb687cf345b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Dec 2019 19:29:33 -0800 Subject: Subclass Bot --- bot/__main__.py | 26 ++------------------------ bot/bot.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 24 deletions(-) create mode 100644 bot/bot.py diff --git a/bot/__main__.py b/bot/__main__.py index ea7c43a12..84bc7094b 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,18 +1,11 @@ -import asyncio -import logging -import socket - import discord -from aiohttp import AsyncResolver, ClientSession, TCPConnector -from discord.ext.commands import Bot, when_mentioned_or +from discord.ext.commands import when_mentioned_or from bot import patches -from bot.api import APIClient, APILoggingHandler +from bot.bot import Bot from bot.constants import Bot as BotConfig, DEBUG_MODE -log = logging.getLogger('bot') - bot = Bot( command_prefix=when_mentioned_or(BotConfig.prefix), activity=discord.Game(name="Commands: !help"), @@ -20,18 +13,6 @@ bot = Bot( max_messages=10_000, ) -# Global aiohttp session for all cogs -# - Uses asyncio for DNS resolution instead of threads, so we don't spam threads -# - Uses AF_INET as its socket family to prevent https related problems both locally and in prod. -bot.http_session = ClientSession( - connector=TCPConnector( - resolver=AsyncResolver(), - family=socket.AF_INET, - ) -) -bot.api_client = APIClient(loop=asyncio.get_event_loop()) -log.addHandler(APILoggingHandler(bot.api_client)) - # Internal/debug bot.load_extension("bot.cogs.error_handler") bot.load_extension("bot.cogs.filtering") @@ -77,6 +58,3 @@ if not hasattr(discord.message.Message, '_handle_edited_timestamp'): patches.message_edited_at.apply_patch() bot.run(BotConfig.token) - -# This calls a coroutine, so it doesn't do anything at the moment. -# bot.http_session.close() # Close the aiohttp session when the bot finishes running diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 000000000..05734ac1d --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,30 @@ +import asyncio +import logging +import socket + +import aiohttp +from discord.ext import commands + +from bot import api + +log = logging.getLogger('bot') + + +class Bot(commands.Bot): + """A subclass of `discord.ext.commands.Bot` with an aiohttp session and an API client.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Global aiohttp session for all cogs + # - Uses asyncio for DNS resolution instead of threads, so we don't spam threads + # - Uses AF_INET as its socket family to prevent https related problems both locally and in prod. + self.http_session = aiohttp.ClientSession( + connector=aiohttp.TCPConnector( + resolver=aiohttp.AsyncResolver(), + family=socket.AF_INET, + ) + ) + + self.api_client = api.APIClient(loop=asyncio.get_event_loop()) + log.addHandler(api.APILoggingHandler(self.api_client)) -- cgit v1.2.3 From 6fe61e5919cb541a1651312a01ddf7e7f10d0f86 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Dec 2019 20:11:50 -0800 Subject: Change all Bot imports to use the subclass --- bot/cogs/alias.py | 3 ++- bot/cogs/antimalware.py | 3 ++- bot/cogs/antispam.py | 3 ++- bot/cogs/bot.py | 3 ++- bot/cogs/clean.py | 3 ++- bot/cogs/defcon.py | 3 ++- bot/cogs/doc.py | 5 +++-- bot/cogs/duck_pond.py | 3 ++- bot/cogs/error_handler.py | 3 ++- bot/cogs/eval.py | 3 ++- bot/cogs/extensions.py | 3 ++- bot/cogs/filtering.py | 3 ++- bot/cogs/free.py | 3 ++- bot/cogs/help.py | 3 ++- bot/cogs/information.py | 3 ++- bot/cogs/jams.py | 5 +++-- bot/cogs/logging.py | 3 ++- bot/cogs/moderation/__init__.py | 3 +-- bot/cogs/moderation/infractions.py | 3 ++- bot/cogs/moderation/management.py | 3 ++- bot/cogs/moderation/modlog.py | 3 ++- bot/cogs/moderation/scheduler.py | 3 ++- bot/cogs/moderation/superstarify.py | 3 ++- bot/cogs/off_topic_names.py | 3 ++- bot/cogs/reddit.py | 3 ++- bot/cogs/reminders.py | 3 ++- bot/cogs/security.py | 4 +++- bot/cogs/site.py | 3 ++- bot/cogs/snekbox.py | 3 ++- bot/cogs/sync/__init__.py | 3 +-- bot/cogs/sync/cog.py | 3 ++- bot/cogs/sync/syncers.py | 7 ++++--- bot/cogs/tags.py | 3 ++- bot/cogs/token_remover.py | 3 ++- bot/cogs/utils.py | 3 ++- bot/cogs/verification.py | 3 ++- bot/cogs/watchchannels/__init__.py | 3 +-- bot/cogs/watchchannels/bigbrother.py | 3 ++- bot/cogs/watchchannels/talentpool.py | 3 ++- bot/cogs/watchchannels/watchchannel.py | 3 ++- bot/cogs/wolfram.py | 7 ++++--- bot/interpreter.py | 4 +++- tests/helpers.py | 4 +++- 43 files changed, 92 insertions(+), 52 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 5190c559b..4ee5a2aed 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -3,8 +3,9 @@ import logging from typing import Union from discord import Colour, Embed, Member, User -from discord.ext.commands import Bot, Cog, Command, Context, clean_content, command, group +from discord.ext.commands import Cog, Command, Context, clean_content, command, group +from bot.bot import Bot from bot.cogs.extensions import Extension from bot.cogs.watchchannels.watchchannel import proxy_user from bot.converters import TagNameConverter diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 602819191..03c1e28a1 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -1,8 +1,9 @@ import logging from discord import Embed, Message, NotFound -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog +from bot.bot import Bot from bot.constants import AntiMalware as AntiMalwareConfig, Channels, URLs log = logging.getLogger(__name__) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 1340eb608..88912038a 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -7,9 +7,10 @@ from operator import itemgetter from typing import Dict, Iterable, List, Set from discord import Colour, Member, Message, NotFound, Object, TextChannel -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog from bot import rules +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( AntiSpam as AntiSpamConfig, Channels, diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index ee0a463de..a2edb7576 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -5,8 +5,9 @@ import time from typing import Optional, Tuple from discord import Embed, Message, RawMessageUpdateEvent, TextChannel -from discord.ext.commands import Bot, Cog, Context, command, group +from discord.ext.commands import Cog, Context, command, group +from bot.bot import Bot from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs from bot.decorators import with_role from bot.utils.messages import wait_for_deletion diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index dca411d01..3365d0934 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -4,8 +4,9 @@ import re from typing import Optional from discord import Colour, Embed, Message, User -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( Channels, CleanMessages, Colours, Event, diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index bedd70c86..f062a7546 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -6,8 +6,9 @@ from datetime import datetime, timedelta from enum import Enum from discord import Colour, Embed, Member -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles from bot.decorators import with_role diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index e5b3a4062..7df159fd9 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -17,6 +17,7 @@ from requests import ConnectTimeout, ConnectionError, HTTPError from sphinx.ext import intersphinx from urllib3.exceptions import ProtocolError +from bot.bot import Bot from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role @@ -147,7 +148,7 @@ class InventoryURL(commands.Converter): class Doc(commands.Cog): """A set of commands for querying & displaying documentation.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): self.base_urls = {} self.bot = bot self.inventories = {} @@ -506,7 +507,7 @@ class Doc(commands.Cog): return tag.name == "table" -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None: """Doc cog load.""" bot.add_cog(Doc(bot)) log.info("Cog loaded: Doc") diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 2d25cd17e..879071d1b 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -3,9 +3,10 @@ from typing import Optional, Union import discord from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog from bot import constants +from bot.bot import Bot from bot.utils.messages import send_attachments log = logging.getLogger(__name__) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 49411814c..cf90e9f48 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -14,9 +14,10 @@ from discord.ext.commands import ( NoPrivateMessage, UserInputError, ) -from discord.ext.commands import Bot, Cog, Context +from discord.ext.commands import Cog, Context from bot.api import ResponseCodeError +from bot.bot import Bot from bot.constants import Channels from bot.decorators import InChannelCheckFailure diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 00b988dde..5daec3e39 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -9,8 +9,9 @@ from io import StringIO from typing import Any, Optional, Tuple import discord -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.constants import Roles from bot.decorators import with_role from bot.interpreter import Interpreter diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index bb66e0b8e..4d77d8205 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -6,8 +6,9 @@ from pkgutil import iter_modules from discord import Colour, Embed from discord.ext import commands -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Context, group +from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs from bot.pagination import LinePaginator from bot.utils.checks import with_role_check diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 1e7521054..2e54ccecb 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -5,8 +5,9 @@ from typing import Optional, Union import discord.errors from dateutil.relativedelta import relativedelta from discord import Colour, DMChannel, Member, Message, TextChannel -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( Channels, Colours, diff --git a/bot/cogs/free.py b/bot/cogs/free.py index 82285656b..bbc9f063b 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -3,8 +3,9 @@ from datetime import datetime from operator import itemgetter from discord import Colour, Embed, Member, utils -from discord.ext.commands import Bot, Cog, Context, command +from discord.ext.commands import Cog, Context, command +from bot.bot import Bot from bot.constants import Categories, Channels, Free, STAFF_ROLES from bot.decorators import redirect_output diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 9607dbd8d..6385fa467 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -6,10 +6,11 @@ from typing import Union from discord import Colour, Embed, HTTPException, Message, Reaction, User from discord.ext import commands -from discord.ext.commands import Bot, CheckFailure, Cog as DiscordCog, Command, Context +from discord.ext.commands import CheckFailure, Cog as DiscordCog, Command, Context from fuzzywuzzy import fuzz, process from bot import constants +from bot.bot import Bot from bot.constants import Channels, STAFF_ROLES from bot.decorators import redirect_output from bot.pagination import ( diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 530453600..56bd37bec 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -9,10 +9,11 @@ from typing import Any, Mapping, Optional import discord from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils from discord.ext import commands -from discord.ext.commands import Bot, BucketType, Cog, Context, command, group +from discord.ext.commands import BucketType, Cog, Context, command, group from discord.utils import escape_markdown from bot import constants +from bot.bot import Bot from bot.decorators import InChannelCheckFailure, in_channel, with_role from bot.utils.checks import cooldown_with_role_bypass, with_role_check from bot.utils.time import time_since diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index be9d33e3e..0c82e7962 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -4,6 +4,7 @@ from discord import Member, PermissionOverwrite, utils from discord.ext import commands from more_itertools import unique_everseen +from bot.bot import Bot from bot.constants import Roles from bot.decorators import with_role @@ -13,7 +14,7 @@ log = logging.getLogger(__name__) class CodeJams(commands.Cog): """Manages the code-jam related parts of our server.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): self.bot = bot @commands.command() @@ -108,7 +109,7 @@ class CodeJams(commands.Cog): ) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None: """Code Jams cog load.""" bot.add_cog(CodeJams(bot)) log.info("Cog loaded: CodeJams") diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index c92b619ff..44c771b42 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -1,8 +1,9 @@ import logging from discord import Embed -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog +from bot.bot import Bot from bot.constants import Channels, DEBUG_MODE diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 7383ed44e..0cbdb3aa6 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -1,7 +1,6 @@ import logging -from discord.ext.commands import Bot - +from bot.bot import Bot from .infractions import Infractions from .management import ModManagement from .modlog import ModLog diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 2713a1b68..7478e19ef 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -7,6 +7,7 @@ from discord.ext import commands from discord.ext.commands import Context, command from bot import constants +from bot.bot import Bot from bot.constants import Event from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check @@ -25,7 +26,7 @@ class Infractions(InfractionScheduler, commands.Cog): category = "Moderation" category_description = "Server moderation tools." - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"}) self.category = "Moderation" diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index abfe5c2b3..feae00b7c 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -9,6 +9,7 @@ from discord.ext import commands from discord.ext.commands import Context from bot import constants +from bot.bot import Bot from bot.converters import InfractionSearchQuery from bot.pagination import LinePaginator from bot.utils import time @@ -36,7 +37,7 @@ class ModManagement(commands.Cog): category = "Moderation" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): self.bot = bot @property diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 0df752a97..35ef6cbcc 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -10,8 +10,9 @@ from dateutil.relativedelta import relativedelta from deepdiff import DeepDiff from discord import Colour from discord.abc import GuildChannel -from discord.ext.commands import Bot, Cog, Context +from discord.ext.commands import Cog, Context +from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs from bot.utils.time import humanize_delta from .utils import UserTypes diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 3e0968121..937113ef4 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -7,10 +7,11 @@ from gettext import ngettext import dateutil.parser import discord -from discord.ext.commands import Bot, Context +from discord.ext.commands import Context from bot import constants from bot.api import ResponseCodeError +from bot.bot import Bot from bot.constants import Colours, STAFF_CHANNELS from bot.utils import time from bot.utils.scheduling import Scheduler diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 9b3c62403..7631d9bbe 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -6,9 +6,10 @@ import typing as t from pathlib import Path from discord import Colour, Embed, Member -from discord.ext.commands import Bot, Cog, Context, command +from discord.ext.commands import Cog, Context, command from bot import constants +from bot.bot import Bot from bot.utils.checks import with_role_check from bot.utils.time import format_infraction from . import utils diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 78792240f..18d9cfb01 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -4,9 +4,10 @@ import logging from datetime import datetime, timedelta from discord import Colour, Embed -from discord.ext.commands import BadArgument, Bot, Cog, Context, Converter, group +from discord.ext.commands import BadArgument, Cog, Context, Converter, group from bot.api import ResponseCodeError +from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES from bot.decorators import with_role from bot.pagination import LinePaginator diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 0d06e9c26..c76fcd937 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -6,9 +6,10 @@ from datetime import datetime, timedelta from typing import List from discord import Colour, Embed, TextChannel -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group from discord.ext.tasks import loop +from bot.bot import Bot from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks from bot.converters import Subreddit from bot.decorators import with_role diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 81990704b..b805b24c5 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -8,8 +8,9 @@ from typing import Optional from dateutil.relativedelta import relativedelta from discord import Colour, Embed, Message -from discord.ext.commands import Bot, Cog, Context, group +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.converters import Duration from bot.pagination import LinePaginator diff --git a/bot/cogs/security.py b/bot/cogs/security.py index 316b33d6b..45d0eb2f5 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -1,6 +1,8 @@ import logging -from discord.ext.commands import Bot, Cog, Context, NoPrivateMessage +from discord.ext.commands import Cog, Context, NoPrivateMessage + +from bot.bot import Bot log = logging.getLogger(__name__) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 683613788..1d7bd03e4 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -1,8 +1,9 @@ import logging from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.constants import URLs from bot.pagination import LinePaginator diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 55a187ac1..1ea61a8da 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -5,8 +5,9 @@ import textwrap from signal import Signals from typing import Optional, Tuple -from discord.ext.commands import Bot, Cog, Context, command, guild_only +from discord.ext.commands import Cog, Context, command, guild_only +from bot.bot import Bot from bot.constants import Channels, Roles, URLs from bot.decorators import in_channel from bot.utils.messages import wait_for_deletion diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py index d4565f848..0da81c60e 100644 --- a/bot/cogs/sync/__init__.py +++ b/bot/cogs/sync/__init__.py @@ -1,7 +1,6 @@ import logging -from discord.ext.commands import Bot - +from bot.bot import Bot from .cog import Sync log = logging.getLogger(__name__) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index aaa581f96..90d4c40fe 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -3,10 +3,11 @@ from typing import Callable, Iterable from discord import Guild, Member, Role from discord.ext import commands -from discord.ext.commands import Bot, Cog, Context +from discord.ext.commands import Cog, Context from bot import constants from bot.api import ResponseCodeError +from bot.bot import Bot from bot.cogs.sync import syncers log = logging.getLogger(__name__) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 2cc5a66e1..14cf51383 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -2,7 +2,8 @@ from collections import namedtuple from typing import Dict, Set, Tuple from discord import Guild -from discord.ext.commands import Bot + +from bot.bot import Bot # These objects are declared as namedtuples because tuples are hashable, # something that we make use of when diffing site roles against guild roles. @@ -52,7 +53,7 @@ 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 (discord.ext.commands.Bot): + bot (bot.bot.Bot): The bot instance that we're running with. guild (discord.Guild): @@ -169,7 +170,7 @@ 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 (discord.ext.commands.Bot): + bot (bot.bot.Bot): The bot instance that we're running with. guild (discord.Guild): diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index cd70e783a..2ece0095d 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -2,8 +2,9 @@ import logging import time from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles from bot.converters import TagContentConverter, TagNameConverter from bot.decorators import with_role diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 5a0d20e57..7af7ed63a 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -6,9 +6,10 @@ import struct from datetime import datetime from discord import Colour, Message -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog from discord.utils import snowflake_time +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import Channels, Colours, Event, Icons diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 793fe4c1a..0ed996430 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -8,8 +8,9 @@ from typing import Tuple from dateutil import relativedelta from discord import Colour, Embed, Message, Role -from discord.ext.commands import Bot, Cog, Context, command +from discord.ext.commands import Cog, Context, command +from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES from bot.decorators import in_channel, with_role from bot.utils.time import humanize_delta diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index b5e8d4357..74eb0dbf8 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -3,8 +3,9 @@ from datetime import datetime from discord import Colour, Message, NotFound, Object from discord.ext import tasks -from discord.ext.commands import Bot, Cog, Context, command +from discord.ext.commands import Cog, Context, command +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( Bot as BotConfig, diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py index 86e1050fa..e18aea88a 100644 --- a/bot/cogs/watchchannels/__init__.py +++ b/bot/cogs/watchchannels/__init__.py @@ -1,7 +1,6 @@ import logging -from discord.ext.commands import Bot - +from bot.bot import Bot from .bigbrother import BigBrother from .talentpool import TalentPool diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 49783bb09..306ed4c64 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -3,8 +3,9 @@ from collections import ChainMap from typing import Union from discord import User -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.cogs.moderation.utils import post_infraction from bot.constants import Channels, MODERATION_ROLES, Webhooks from bot.decorators import with_role diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 4ec42dcc1..cc8feeeee 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -4,9 +4,10 @@ from collections import ChainMap from typing import Union from discord import Color, Embed, Member, User -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError +from bot.bot import Bot from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks from bot.decorators import with_role from bot.pagination import LinePaginator diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 0bf75a924..bd0622554 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -10,9 +10,10 @@ from typing import Optional import dateutil.parser import discord from discord import Color, Embed, HTTPException, Message, Object, errors -from discord.ext.commands import BadArgument, Bot, Cog, Context +from discord.ext.commands import BadArgument, Cog, Context from bot.api import ResponseCodeError +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons from bot.pagination import LinePaginator diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index ab0ed2472..c3c193cb9 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -7,8 +7,9 @@ import discord from dateutil.relativedelta import relativedelta from discord import Embed from discord.ext import commands -from discord.ext.commands import Bot, BucketType, Cog, Context, check, group +from discord.ext.commands import BucketType, Cog, Context, check, group +from bot.bot import Bot from bot.constants import Colours, STAFF_ROLES, Wolfram from bot.pagination import ImagePaginator from bot.utils.time import humanize_delta @@ -151,7 +152,7 @@ async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tup class Wolfram(Cog): """Commands for interacting with the Wolfram|Alpha API.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): self.bot = bot @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) @@ -266,7 +267,7 @@ class Wolfram(Cog): await send_embed(ctx, message, color) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None: """Wolfram cog load.""" bot.add_cog(Wolfram(bot)) log.info("Cog loaded: Wolfram") diff --git a/bot/interpreter.py b/bot/interpreter.py index 76a3fc293..8b7268746 100644 --- a/bot/interpreter.py +++ b/bot/interpreter.py @@ -2,7 +2,9 @@ from code import InteractiveInterpreter from io import StringIO from typing import Any -from discord.ext.commands import Bot, Context +from discord.ext.commands import Context + +from bot.bot import Bot CODE_TEMPLATE = """ async def _func(): diff --git a/tests/helpers.py b/tests/helpers.py index b2daae92d..5df796c23 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -10,7 +10,9 @@ import unittest.mock from typing import Any, Iterable, Optional import discord -from discord.ext.commands import Bot, Context +from discord.ext.commands import Context + +from bot.bot import Bot for logger in logging.Logger.manager.loggerDict.values(): -- cgit v1.2.3 From 52924051e27d34c3f7e32c281fbe8ae0587a9766 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Dec 2019 20:31:39 -0800 Subject: Override add_cog to log loading of cogs --- bot/bot.py | 5 +++++ bot/cogs/alias.py | 3 +-- bot/cogs/antimalware.py | 3 +-- bot/cogs/antispam.py | 3 +-- bot/cogs/bot.py | 3 +-- bot/cogs/clean.py | 3 +-- bot/cogs/defcon.py | 3 +-- bot/cogs/doc.py | 3 +-- bot/cogs/duck_pond.py | 3 +-- bot/cogs/error_handler.py | 3 +-- bot/cogs/eval.py | 3 +-- bot/cogs/extensions.py | 1 - bot/cogs/filtering.py | 3 +-- bot/cogs/free.py | 3 +-- bot/cogs/information.py | 3 +-- bot/cogs/jams.py | 3 +-- bot/cogs/logging.py | 3 +-- bot/cogs/moderation/__init__.py | 13 +------------ bot/cogs/off_topic_names.py | 3 +-- bot/cogs/reddit.py | 3 +-- bot/cogs/reminders.py | 3 +-- bot/cogs/security.py | 3 +-- bot/cogs/site.py | 3 +-- bot/cogs/snekbox.py | 3 +-- bot/cogs/sync/__init__.py | 7 +------ bot/cogs/tags.py | 3 +-- bot/cogs/token_remover.py | 3 +-- bot/cogs/utils.py | 3 +-- bot/cogs/verification.py | 3 +-- bot/cogs/watchchannels/__init__.py | 10 +--------- bot/cogs/wolfram.py | 3 +-- 31 files changed, 34 insertions(+), 80 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 05734ac1d..f39bfb50a 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -28,3 +28,8 @@ class Bot(commands.Bot): self.api_client = api.APIClient(loop=asyncio.get_event_loop()) log.addHandler(api.APILoggingHandler(self.api_client)) + + def add_cog(self, cog: commands.Cog) -> None: + """Adds a "cog" to the bot and logs the operation.""" + super().add_cog(cog) + log.info(f"Cog loaded: {cog.qualified_name}") diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 4ee5a2aed..c1db38462 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -148,6 +148,5 @@ class Alias (Cog): def setup(bot: Bot) -> None: - """Alias cog load.""" + """Load the Alias cog.""" bot.add_cog(Alias(bot)) - log.info("Cog loaded: Alias") diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 03c1e28a1..28e3e5d96 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -50,6 +50,5 @@ class AntiMalware(Cog): def setup(bot: Bot) -> None: - """Antimalware cog load.""" + """Load the AntiMalware cog.""" bot.add_cog(AntiMalware(bot)) - log.info("Cog loaded: AntiMalware") diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 88912038a..f454061a6 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -277,7 +277,6 @@ def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: def setup(bot: Bot) -> None: - """Antispam cog load.""" + """Validate the AntiSpam configs and load the AntiSpam cog.""" validation_errors = validate_config() bot.add_cog(AntiSpam(bot, validation_errors)) - log.info("Cog loaded: AntiSpam") diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index a2edb7576..b5642da82 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -378,6 +378,5 @@ class Bot(Cog): def setup(bot: Bot) -> None: - """Bot cog load.""" + """Load the Bot cog.""" bot.add_cog(Bot(bot)) - log.info("Cog loaded: Bot") diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 3365d0934..c7168122d 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -212,6 +212,5 @@ class Clean(Cog): def setup(bot: Bot) -> None: - """Clean cog load.""" + """Load the Clean cog.""" bot.add_cog(Clean(bot)) - log.info("Cog loaded: Clean") diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index f062a7546..3e7350fcc 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -237,6 +237,5 @@ class Defcon(Cog): def setup(bot: Bot) -> None: - """DEFCON cog load.""" + """Load the Defcon cog.""" bot.add_cog(Defcon(bot)) - log.info("Cog loaded: Defcon") diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 7df159fd9..9506b195a 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -508,6 +508,5 @@ class Doc(commands.Cog): def setup(bot: Bot) -> None: - """Doc cog load.""" + """Load the Doc cog.""" bot.add_cog(Doc(bot)) - log.info("Cog loaded: Doc") diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 879071d1b..345d2856c 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -178,6 +178,5 @@ class DuckPond(Cog): def setup(bot: Bot) -> None: - """Load the duck pond cog.""" + """Load the DuckPond cog.""" bot.add_cog(DuckPond(bot)) - log.info("Cog loaded: DuckPond") diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index cf90e9f48..700f903a6 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -144,6 +144,5 @@ class ErrorHandler(Cog): def setup(bot: Bot) -> None: - """Error handler cog load.""" + """Load the ErrorHandler cog.""" bot.add_cog(ErrorHandler(bot)) - log.info("Cog loaded: Events") diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 5daec3e39..9c729f28a 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -198,6 +198,5 @@ async def func(): # (None,) -> Any def setup(bot: Bot) -> None: - """Code eval cog load.""" + """Load the CodeEval cog.""" bot.add_cog(CodeEval(bot)) - log.info("Cog loaded: Eval") diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 4d77d8205..f16e79fb7 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -234,4 +234,3 @@ class Extensions(commands.Cog): def setup(bot: Bot) -> None: """Load the Extensions cog.""" bot.add_cog(Extensions(bot)) - log.info("Cog loaded: Extensions") diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 2e54ccecb..74538542a 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -371,6 +371,5 @@ class Filtering(Cog): def setup(bot: Bot) -> None: - """Filtering cog load.""" + """Load the Filtering cog.""" bot.add_cog(Filtering(bot)) - log.info("Cog loaded: Filtering") diff --git a/bot/cogs/free.py b/bot/cogs/free.py index bbc9f063b..49cab6172 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -99,6 +99,5 @@ class Free(Cog): def setup(bot: Bot) -> None: - """Free cog load.""" + """Load the Free cog.""" bot.add_cog(Free()) - log.info("Cog loaded: Free") diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 56bd37bec..1ede95ff4 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -392,6 +392,5 @@ class Information(Cog): def setup(bot: Bot) -> None: - """Information cog load.""" + """Load the Information cog.""" bot.add_cog(Information(bot)) - log.info("Cog loaded: Information") diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 0c82e7962..985f28ce5 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -110,6 +110,5 @@ class CodeJams(commands.Cog): def setup(bot: Bot) -> None: - """Code Jams cog load.""" + """Load the CodeJams cog.""" bot.add_cog(CodeJams(bot)) - log.info("Cog loaded: CodeJams") diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index 44c771b42..d1b7dcab3 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -38,6 +38,5 @@ class Logging(Cog): def setup(bot: Bot) -> None: - """Logging cog load.""" + """Load the Logging cog.""" bot.add_cog(Logging(bot)) - log.info("Cog loaded: Logging") diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 0cbdb3aa6..5243cb92d 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -1,24 +1,13 @@ -import logging - from bot.bot import Bot from .infractions import Infractions from .management import ModManagement from .modlog import ModLog from .superstarify import Superstarify -log = logging.getLogger(__name__) - def setup(bot: Bot) -> None: - """Load the moderation extension (Infractions, ModManagement, ModLog, & Superstarify cogs).""" + """Load the Infractions, ModManagement, ModLog, and Superstarify cogs.""" bot.add_cog(Infractions(bot)) - log.info("Cog loaded: Infractions") - bot.add_cog(ModLog(bot)) - log.info("Cog loaded: ModLog") - bot.add_cog(ModManagement(bot)) - log.info("Cog loaded: ModManagement") - bot.add_cog(Superstarify(bot)) - log.info("Cog loaded: Superstarify") diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 18d9cfb01..bf777ea5a 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -185,6 +185,5 @@ class OffTopicNames(Cog): def setup(bot: Bot) -> None: - """Off topic names cog load.""" + """Load the OffTopicNames cog.""" bot.add_cog(OffTopicNames(bot)) - log.info("Cog loaded: OffTopicNames") diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index c76fcd937..bec316ae7 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -218,6 +218,5 @@ class Reddit(Cog): def setup(bot: Bot) -> None: - """Reddit cog load.""" + """Load the Reddit cog.""" bot.add_cog(Reddit(bot)) - log.info("Cog loaded: Reddit") diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index b805b24c5..45bf9a8f4 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -291,6 +291,5 @@ class Reminders(Scheduler, Cog): def setup(bot: Bot) -> None: - """Reminders cog load.""" + """Load the Reminders cog.""" bot.add_cog(Reminders(bot)) - log.info("Cog loaded: Reminders") diff --git a/bot/cogs/security.py b/bot/cogs/security.py index 45d0eb2f5..c680c5e27 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -27,6 +27,5 @@ class Security(Cog): def setup(bot: Bot) -> None: - """Security cog load.""" + """Load the Security cog.""" bot.add_cog(Security(bot)) - log.info("Cog loaded: Security") diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 1d7bd03e4..2ea8c7a2e 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -139,6 +139,5 @@ class Site(Cog): def setup(bot: Bot) -> None: - """Site cog load.""" + """Load the Site cog.""" bot.add_cog(Site(bot)) - log.info("Cog loaded: Site") diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 1ea61a8da..da33e27b2 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -228,6 +228,5 @@ class Snekbox(Cog): def setup(bot: Bot) -> None: - """Snekbox cog load.""" + """Load the Snekbox cog.""" bot.add_cog(Snekbox(bot)) - log.info("Cog loaded: Snekbox") diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py index 0da81c60e..fe7df4e9b 100644 --- a/bot/cogs/sync/__init__.py +++ b/bot/cogs/sync/__init__.py @@ -1,12 +1,7 @@ -import logging - from bot.bot import Bot from .cog import Sync -log = logging.getLogger(__name__) - def setup(bot: Bot) -> None: - """Sync cog load.""" + """Load the Sync cog.""" bot.add_cog(Sync(bot)) - log.info("Cog loaded: Sync") diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 2ece0095d..970301013 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -161,6 +161,5 @@ class Tags(Cog): def setup(bot: Bot) -> None: - """Tags cog load.""" + """Load the Tags cog.""" bot.add_cog(Tags(bot)) - log.info("Cog loaded: Tags") diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 7af7ed63a..5d6618338 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -120,6 +120,5 @@ class TokenRemover(Cog): def setup(bot: Bot) -> None: - """Token Remover cog load.""" + """Load the TokenRemover cog.""" bot.add_cog(TokenRemover(bot)) - log.info("Cog loaded: TokenRemover") diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 0ed996430..47a59db66 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -177,6 +177,5 @@ class Utils(Cog): def setup(bot: Bot) -> None: - """Utils cog load.""" + """Load the Utils cog.""" bot.add_cog(Utils(bot)) - log.info("Cog loaded: Utils") diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 74eb0dbf8..b32b9a29e 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -225,6 +225,5 @@ class Verification(Cog): def setup(bot: Bot) -> None: - """Verification cog load.""" + """Load the Verification cog.""" bot.add_cog(Verification(bot)) - log.info("Cog loaded: Verification") diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py index e18aea88a..69d118df6 100644 --- a/bot/cogs/watchchannels/__init__.py +++ b/bot/cogs/watchchannels/__init__.py @@ -1,17 +1,9 @@ -import logging - from bot.bot import Bot from .bigbrother import BigBrother from .talentpool import TalentPool -log = logging.getLogger(__name__) - - def setup(bot: Bot) -> None: - """Monitoring cogs load.""" + """Load the BigBrother and TalentPool cogs.""" bot.add_cog(BigBrother(bot)) - log.info("Cog loaded: BigBrother") - bot.add_cog(TalentPool(bot)) - log.info("Cog loaded: TalentPool") diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index c3c193cb9..5d6b4630b 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -268,6 +268,5 @@ class Wolfram(Cog): def setup(bot: Bot) -> None: - """Wolfram cog load.""" + """Load the Wolfram cog.""" bot.add_cog(Wolfram(bot)) - log.info("Cog loaded: Wolfram") -- cgit v1.2.3 From a4a53f3b9d1cc9928ee03d4f0ecb8087a527e8ca Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Dec 2019 20:39:09 -0800 Subject: Fix name conflict with the Bot cog --- bot/cogs/bot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index b5642da82..e795e5960 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -17,7 +17,7 @@ log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') -class Bot(Cog): +class BotCog(Cog, name="Bot"): """Bot information commands.""" def __init__(self, bot: Bot): @@ -374,9 +374,9 @@ class Bot(Cog): bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) await bot_message.delete() del self.codeblock_message_ids[payload.message_id] - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") + log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") def setup(bot: Bot) -> None: """Load the Bot cog.""" - bot.add_cog(Bot(bot)) + bot.add_cog(BotCog(bot)) -- cgit v1.2.3 From 56578525ac4e5c6d20392d1208b74623c8524bcd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 8 Dec 2019 01:40:19 -0800 Subject: Properly create and close aiohttp sessions aiohttp throws a warning when a session is created outside of a running async event loop. In aiohttp 4.0 this actually changes to an error instead of merely a warning. Since discord.py manages the event loop with client.run(), some of the "internal" coroutines of the client were overwritten in the bot subclass to be able to hook into when the bot starts and stops. Sessions of both the bot and the API client can now potentially be None if accessed before the sessions have been created. However, if called, the API client's methods will wait for a session to be ready. It will attempt to create a session as soon as the event loop starts (i.e. the bot is running). --- bot/api.py | 41 +++++++++++++++++++++++++++++++++++++++-- bot/bot.py | 34 ++++++++++++++++++++++++++-------- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/bot/api.py b/bot/api.py index 7f26e5305..56db99828 100644 --- a/bot/api.py +++ b/bot/api.py @@ -32,7 +32,7 @@ class ResponseCodeError(ValueError): class APIClient: """Django Site API wrapper.""" - def __init__(self, **kwargs): + def __init__(self, loop: asyncio.AbstractEventLoop, **kwargs): auth_headers = { 'Authorization': f"Token {Keys.site_api}" } @@ -42,12 +42,39 @@ class APIClient: else: kwargs['headers'] = auth_headers - self.session = aiohttp.ClientSession(**kwargs) + self.session: Optional[aiohttp.ClientSession] = None + self.loop = loop + + self._ready = asyncio.Event(loop=loop) + self._creation_task = None + self._session_args = kwargs + + self.recreate() @staticmethod def _url_for(endpoint: str) -> str: return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" + async def _create_session(self) -> None: + """Create the aiohttp session and set the ready event.""" + self.session = aiohttp.ClientSession(**self._session_args) + self._ready.set() + + async def close(self) -> None: + """Close the aiohttp session and unset the ready event.""" + if not self._ready.is_set(): + return + + await self.session.close() + self._ready.clear() + + def recreate(self) -> None: + """Schedule the aiohttp session to be created if it's been closed.""" + if self.session is None or self.session.closed: + # Don't schedule a task if one is already in progress. + if self._creation_task is None or self._creation_task.done(): + self._creation_task = self.loop.create_task(self._create_session()) + async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None: """Raise ResponseCodeError for non-OK response if an exception should be raised.""" if should_raise and response.status >= 400: @@ -60,30 +87,40 @@ class APIClient: async def get(self, endpoint: str, *args, 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: 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: """Site API PATCH.""" + await self._ready.wait() + async with self.session.patch(self._url_for(endpoint), *args, **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: """Site API POST.""" + await self._ready.wait() + async with self.session.post(self._url_for(endpoint), *args, **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: """Site API PUT.""" + await self._ready.wait() + async with self.session.put(self._url_for(endpoint), *args, **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]: """Site API DELETE.""" + await self._ready.wait() + async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp: if resp.status == 204: return None diff --git a/bot/bot.py b/bot/bot.py index f39bfb50a..4b3b991a3 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -1,6 +1,6 @@ -import asyncio import logging import socket +from typing import Optional import aiohttp from discord.ext import commands @@ -16,6 +16,30 @@ class Bot(commands.Bot): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.http_session: Optional[aiohttp.ClientSession] = None + self.api_client = api.APIClient(loop=self.loop) + + log.addHandler(api.APILoggingHandler(self.api_client)) + + def add_cog(self, cog: commands.Cog) -> None: + """Adds a "cog" to the bot and logs the operation.""" + super().add_cog(cog) + log.info(f"Cog loaded: {cog.qualified_name}") + + def clear(self) -> None: + """Clears the internal state of the bot and resets the API client.""" + super().clear() + self.api_client.recreate() + + async def close(self) -> None: + """Close the aiohttp session after closing the Discord connection.""" + await super().close() + + await self.http_session.close() + await self.api_client.close() + + async def start(self, *args, **kwargs) -> None: + """Open an aiohttp session before logging in and connecting to Discord.""" # Global aiohttp session for all cogs # - Uses asyncio for DNS resolution instead of threads, so we don't spam threads # - Uses AF_INET as its socket family to prevent https related problems both locally and in prod. @@ -26,10 +50,4 @@ class Bot(commands.Bot): ) ) - self.api_client = api.APIClient(loop=asyncio.get_event_loop()) - log.addHandler(api.APILoggingHandler(self.api_client)) - - def add_cog(self, cog: commands.Cog) -> None: - """Adds a "cog" to the bot and logs the operation.""" - super().add_cog(cog) - log.info(f"Cog loaded: {cog.qualified_name}") + await super().start(*args, **kwargs) -- cgit v1.2.3 From 23acfce3521cd420a2df6eb51f036a2a54140ef6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 8 Dec 2019 02:01:01 -0800 Subject: Fix test failures for setup log messages --- tests/bot/cogs/test_duck_pond.py | 12 ++---------- tests/bot/cogs/test_security.py | 11 +++-------- tests/bot/cogs/test_token_remover.py | 8 ++------ 3 files changed, 7 insertions(+), 24 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index b801e86f1..d07b2bce1 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -578,15 +578,7 @@ class DuckPondSetupTests(unittest.TestCase): """Tests setup of the `DuckPond` cog.""" def test_setup(self): - """Setup of the cog should log a message at `INFO` level.""" + """Setup of the extension should call add_cog.""" bot = helpers.MockBot() - log = logging.getLogger('bot.cogs.duck_pond') - - with self.assertLogs(logger=log, level=logging.INFO) as log_watcher: - duck_pond.setup(bot) - - self.assertEqual(len(log_watcher.records), 1) - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.INFO) - + duck_pond.setup(bot) bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/test_security.py b/tests/bot/cogs/test_security.py index efa7a50b1..9d1a62f7e 100644 --- a/tests/bot/cogs/test_security.py +++ b/tests/bot/cogs/test_security.py @@ -1,4 +1,3 @@ -import logging import unittest from unittest.mock import MagicMock @@ -49,11 +48,7 @@ class SecurityCogLoadTests(unittest.TestCase): """Tests loading the `Security` cog.""" def test_security_cog_load(self): - """Cog loading logs a message at `INFO` level.""" + """Setup of the extension should call add_cog.""" bot = MagicMock() - with self.assertLogs(logger='bot.cogs.security', level=logging.INFO) as cm: - security.setup(bot) - bot.add_cog.assert_called_once() - - [line] = cm.output - self.assertIn("Cog loaded: Security", line) + security.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 3276cf5a5..a54b839d7 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -125,11 +125,7 @@ class TokenRemoverSetupTests(unittest.TestCase): """Tests setup of the `TokenRemover` cog.""" def test_setup(self): - """Setup of the cog should log a message at `INFO` level.""" + """Setup of the extension should call add_cog.""" bot = MockBot() - with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: - setup_cog(bot) - - [line] = cm.output + setup_cog(bot) bot.add_cog.assert_called_once() - self.assertIn("Cog loaded: TokenRemover", line) -- cgit v1.2.3 From 34bac05ccc6c11ea370aa14431e4d6d6cd28f1d6 Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Mon, 9 Dec 2019 02:37:30 -0300 Subject: Ensure hidden_channels and bypass_roles use a list when not passed. The in_channel decorator raised 'NoneType' is not iterable when it wasn't passed, due to the default value being None but not checked against before iterating over it. This edit ensures the arguments are set to an empty list in cases where they have a value of None instead. --- bot/decorators.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/decorators.py b/bot/decorators.py index 61587f406..2d18eaa6a 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -38,6 +38,9 @@ def in_channel( Hidden channels are channels which will not be displayed in the InChannelCheckFailure error message. """ + hidden_channels = hidden_channels or [] + bypass_roles = bypass_roles or [] + def predicate(ctx: Context) -> bool: """In-channel checker predicate.""" if ctx.channel.id in channels or ctx.channel.id in hidden_channels: -- cgit v1.2.3 From dbd7220caed5a6ba759d0cf9efaa0c0c0e57f391 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 8 Dec 2019 23:40:17 -0800 Subject: Use the AsyncResolver for APIClient and discord.py sessions too Active thread counts are observed to be lower with it in use. --- bot/bot.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 4b3b991a3..8f808272f 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -14,10 +14,18 @@ class Bot(commands.Bot): """A subclass of `discord.ext.commands.Bot` with an aiohttp session and an API client.""" def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + # Use asyncio for DNS resolution instead of threads so threads aren't spammed. + # Use AF_INET as its socket family to prevent HTTPS related problems both locally + # and in production. + self.connector = aiohttp.TCPConnector( + resolver=aiohttp.AsyncResolver(), + family=socket.AF_INET, + ) + + super().__init__(*args, connector=self.connector, **kwargs) self.http_session: Optional[aiohttp.ClientSession] = None - self.api_client = api.APIClient(loop=self.loop) + self.api_client = api.APIClient(loop=self.loop, connector=self.connector) log.addHandler(api.APILoggingHandler(self.api_client)) @@ -40,14 +48,6 @@ class Bot(commands.Bot): async def start(self, *args, **kwargs) -> None: """Open an aiohttp session before logging in and connecting to Discord.""" - # Global aiohttp session for all cogs - # - Uses asyncio for DNS resolution instead of threads, so we don't spam threads - # - Uses AF_INET as its socket family to prevent https related problems both locally and in prod. - self.http_session = aiohttp.ClientSession( - connector=aiohttp.TCPConnector( - resolver=aiohttp.AsyncResolver(), - family=socket.AF_INET, - ) - ) + self.http_session = aiohttp.ClientSession(connector=self.connector) await super().start(*args, **kwargs) -- cgit v1.2.3 From 1b938af27cb9901acdb86579029dc4a7cbae0b7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Dec 2019 23:18:32 -0800 Subject: Moderation: show HTTP status code in the log for deactivation failures --- bot/cogs/moderation/scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 3e0968121..703b09802 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -329,7 +329,7 @@ class InfractionScheduler(Scheduler): log_content = mod_role.mention except discord.HTTPException as e: log.exception(f"Failed to deactivate infraction #{id_} ({type_})") - log_text["Failure"] = f"HTTPException with code {e.code}." + log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." log_content = mod_role.mention # Check if the user is currently being watched by Big Brother. -- cgit v1.2.3 From d0e14dca855179bd71c46747ecf63d4038045881 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Dec 2019 23:34:30 -0800 Subject: Moderation: catch HTTPException when applying an infraction Only a warning is logged if it's a Forbidden error. Otherwise, the whole exception is logged. --- bot/cogs/moderation/scheduler.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 703b09802..8e5b4691f 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -146,14 +146,18 @@ class InfractionScheduler(Scheduler): if expiry: # Schedule the expiration of the infraction. self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - except discord.Forbidden: + except discord.HTTPException as e: # Accordingly display that applying the infraction failed. confirm_msg = f":x: failed to apply" expiry_msg = "" log_content = ctx.author.mention log_title = "failed to apply" - log.warning(f"Failed to apply {infr_type} infraction #{id_} to {user}.") + log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" + if isinstance(e, discord.Forbidden): + log.warning(f"{log_msg}: bot lacks permissions.") + else: + log.exception(log_msg) # Send a confirmation message to the invoking context. log.trace(f"Sending infraction #{id_} confirmation message.") @@ -324,7 +328,7 @@ class InfractionScheduler(Scheduler): f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!" ) except discord.Forbidden: - log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions") + log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions.") log_text["Failure"] = f"The bot lacks permissions to do this (role hierarchy?)" log_content = mod_role.mention except discord.HTTPException as e: -- cgit v1.2.3 From 4f7bbb249b07ed84cf1caf5a22349f1da7b33091 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 10 Dec 2019 08:37:29 +0100 Subject: Whitelist Discord Testers invite link --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index 930a1a0e6..f8337b6da 100644 --- a/config-default.yml +++ b/config-default.yml @@ -193,6 +193,7 @@ filter: - 544525886180032552 # kennethreitz.org - 590806733924859943 # Discord Hack Week - 423249981340778496 # Kivy + - 197038439483310086 # Discord Testers domain_blacklist: - pornhub.com -- cgit v1.2.3 From f0e993a3514c1ef7256c4b7593d4db94a6d34569 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Dec 2019 23:41:27 -0800 Subject: Infractions: kick user from voice after muting (#644) --- bot/cogs/moderation/infractions.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 2713a1b68..fe5150652 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -208,8 +208,13 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_update, user.id) - action = user.add_roles(self._muted_role, reason=reason) - await self.apply_infraction(ctx, infraction, user, action) + async def action() -> None: + await user.add_roles(self._muted_role, reason=reason) + + log.trace(f"Attempting to kick {user} from voice because they've been muted.") + await user.move_to(None, reason=reason) + + await self.apply_infraction(ctx, infraction, user, action()) @respect_role_hierarchy() async def apply_kick(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: -- cgit v1.2.3 From c2af146b676709029cc3a95347e7ab65aa24776a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 10 Dec 2019 00:15:17 -0800 Subject: Add constants for voice state logging * Add ID for the voice-log channel. * Add IDs for admins & staff voice channels and make the mod log ignore them. --- bot/constants.py | 1 + config-default.yml | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 89504a2e0..389326edd 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -379,6 +379,7 @@ class Channels(metaclass=YAMLGetter): userlog: int user_event_a: int verification: int + voice_log: int class Webhooks(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 930a1a0e6..8032b50a4 100644 --- a/config-default.yml +++ b/config-default.yml @@ -101,6 +101,7 @@ guild: channels: admins: &ADMINS 365960823622991872 admin_spam: &ADMIN_SPAM 563594791770914816 + admins_voice: &ADMINS_VOICE 500734494840717332 announcements: 354619224620138496 big_brother_logs: &BBLOGS 468507907357409333 bot: 267659945086812160 @@ -131,13 +132,15 @@ guild: python: 267624335836053506 reddit: 458224812528238616 staff_lounge: &STAFF_LOUNGE 464905259261755392 + staff_voice: &STAFF_VOICE 412375055910043655 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] + ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE] roles: admin: &ADMIN_ROLE 267628507062992896 -- cgit v1.2.3 From 58e13c16630f80b906bfe900d707d952f6a61232 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 10 Dec 2019 22:32:53 -0800 Subject: ModLog: log voice state updates * Add corresponding event to the Event enum so the event can be ignored --- bot/cogs/moderation/modlog.py | 49 +++++++++++++++++++++++++++++++++++++++++++ bot/constants.py | 2 ++ 2 files changed, 51 insertions(+) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 0df752a97..ac45782c9 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -25,6 +25,8 @@ CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status", "nick") ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") +VOICE_STATE_ATTRIBUTES = {"self_video": "Broadcasting", "afk": "AFK"} + class ModLog(Cog, name="ModLog"): """Logging for server events and staff actions.""" @@ -748,3 +750,50 @@ class ModLog(Cog, name="ModLog"): Icons.message_edit, Colour.blurple(), "Message edited (After)", after_response, channel_id=Channels.message_log ) + + @Cog.listener() + async def on_voice_state_update( + self, + member: discord.Member, + before: discord.VoiceState, + after: discord.VoiceState + ) -> None: + """Log member voice state changes to the voice log channel.""" + if member.guild.id != GuildConstant.id: + return + + if member.id in self._ignored[Event.voice_state_update]: + self._ignored[Event.voice_state_update].remove(member.id) + return + + diff = DeepDiff(before, after, exclude_paths="root.session_id") + + # A type change seems to always take precedent over a value change. Furthermore, it will + # include the value change along with the type change anyway. Therefore, it's OK to + # "overwrite" values_changed; in practice there will never even be anything to overwrite. + diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})} + + changes = [] + for attr, values in diff_values.items(): + if not attr: # Not sure why, but it happens + continue + + attr = attr[5:] # Remove "root." prefix + attr = VOICE_STATE_ATTRIBUTES.get(attr, attr.replace("_", " ").capitalize()) + + changes.append(f"**{attr}:** `{values['old_value']}` **->** `{values['new_value']}`") + + if not changes: + return + + message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes)) + message = f"**{member}** (`{member.id}`)\n{message}" + + await self.send_log_message( + icon_url=Icons.user_update, + colour=Colour.blurple(), + title="Voice state updated", + text=message, + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.voice_log + ) diff --git a/bot/constants.py b/bot/constants.py index 389326edd..b96f23f83 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -544,6 +544,8 @@ class Event(Enum): message_delete = "message_delete" message_edit = "message_edit" + voice_state_update = "voice_state_update" + # Debug mode DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False -- cgit v1.2.3 From cecf84ec894e44debb143d82703a0de5d4bf018c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 10 Dec 2019 22:36:11 -0800 Subject: ModLog: use Unicode arrow when displaying value changes --- bot/cogs/moderation/modlog.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index ac45782c9..ffdd5b79f 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -207,7 +207,7 @@ class ModLog(Cog, name="ModLog"): new = value["new_value"] old = value["old_value"] - changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") + changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) @@ -285,7 +285,7 @@ class ModLog(Cog, name="ModLog"): new = value["new_value"] old = value["old_value"] - changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") + changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) @@ -335,7 +335,7 @@ class ModLog(Cog, name="ModLog"): new = value["new_value"] old = value["old_value"] - changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") + changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) @@ -488,23 +488,23 @@ class ModLog(Cog, name="ModLog"): old = value.get("old_value") if new and old: - changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") + changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) if before.name != after.name: changes.append( - f"**Username:** `{before.name}` **->** `{after.name}`" + f"**Username:** `{before.name}` **→** `{after.name}`" ) if before.discriminator != after.discriminator: changes.append( - f"**Discriminator:** `{before.discriminator}` **->** `{after.discriminator}`" + f"**Discriminator:** `{before.discriminator}` **→** `{after.discriminator}`" ) if before.display_name != after.display_name: changes.append( - f"**Display name:** `{before.display_name}` **->** `{after.display_name}`" + f"**Display name:** `{before.display_name}` **→** `{after.display_name}`" ) if not changes: @@ -781,7 +781,7 @@ class ModLog(Cog, name="ModLog"): attr = attr[5:] # Remove "root." prefix attr = VOICE_STATE_ATTRIBUTES.get(attr, attr.replace("_", " ").capitalize()) - changes.append(f"**{attr}:** `{values['old_value']}` **->** `{values['new_value']}`") + changes.append(f"**{attr}:** `{values['old_value']}` **→** `{values['new_value']}`") if not changes: return -- cgit v1.2.3 From e2075a6455fdb47af50d6445d868b1c19187ba4f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 00:45:25 -0800 Subject: ModLog: make voice state event respect ignored channels --- bot/cogs/moderation/modlog.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index ffdd5b79f..00aba9872 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -759,7 +759,10 @@ class ModLog(Cog, name="ModLog"): after: discord.VoiceState ) -> None: """Log member voice state changes to the voice log channel.""" - if member.guild.id != GuildConstant.id: + if ( + member.guild.id != GuildConstant.id + or (before.channel and before.channel.id in GuildConstant.ignored) + ): return if member.id in self._ignored[Event.voice_state_update]: -- cgit v1.2.3 From 571608271a8d6198d11fdd7e79fb52243795e1b6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 00:51:28 -0800 Subject: ModLog: exclude most channel attributes from voice state diff --- bot/cogs/moderation/modlog.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 00aba9872..3f42388b1 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -25,7 +25,7 @@ CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status", "nick") ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") -VOICE_STATE_ATTRIBUTES = {"self_video": "Broadcasting", "afk": "AFK"} +VOICE_STATE_ATTRIBUTES = {"self_video": "Broadcasting", "afk": "AFK", "channel.name": "Channel"} class ModLog(Cog, name="ModLog"): @@ -769,7 +769,13 @@ class ModLog(Cog, name="ModLog"): self._ignored[Event.voice_state_update].remove(member.id) return - diff = DeepDiff(before, after, exclude_paths="root.session_id") + # Exclude all channel attributes except the name. + diff = DeepDiff( + before, + after, + exclude_paths="root.session_id", + exclude_regex_paths=r"root\.channel\.(?!name)", + ) # A type change seems to always take precedent over a value change. Furthermore, it will # include the value change along with the type change anyway. Therefore, it's OK to -- cgit v1.2.3 From 98b018dcc7325999756f8f46c5aa06f316acd2cc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 01:17:01 -0800 Subject: ModLog: exclude afk attribute from voice state log The log will already show that the channel changes to the AFK channel so showing the attribute change is redundant. If the channel were not clearly named "AFK" then it might've made sense to keep the attribute. --- bot/cogs/moderation/modlog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 3f42388b1..98d9c546f 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -25,7 +25,7 @@ CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status", "nick") ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") -VOICE_STATE_ATTRIBUTES = {"self_video": "Broadcasting", "afk": "AFK", "channel.name": "Channel"} +VOICE_STATE_ATTRIBUTES = {"self_video": "Broadcasting", "channel.name": "Channel"} class ModLog(Cog, name="ModLog"): @@ -773,7 +773,7 @@ class ModLog(Cog, name="ModLog"): diff = DeepDiff( before, after, - exclude_paths="root.session_id", + exclude_paths=("root.session_id", "root.afk"), exclude_regex_paths=r"root\.channel\.(?!name)", ) -- cgit v1.2.3 From 9a3e83116e145b720fc47b0686b357fa6ae9e488 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 02:05:18 -0800 Subject: ErrorHandler: fix #650 tag fallback not respecting checks --- bot/cogs/error_handler.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 49411814c..5fba9633b 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -75,6 +75,16 @@ class ErrorHandler(Cog): 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) -- cgit v1.2.3 From d84fc6346197d8176a7989b9b74e94d837d26882 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 12:39:39 -0800 Subject: Reddit: move token renewal inside fetch_posts This removes the duplicate code for renewing the token. Since fetch_posts is the only place where the token gets used, it can just be refreshed there directly. --- bot/cogs/reddit.py | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 22bb66bf0..0802c6102 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -122,6 +122,10 @@ class Reddit(Cog): if params is None: params = {} + # Renew the token if necessary. + if not self.access_token or self.access_token.expires_at < datetime.utcnow(): + await self.get_access_token() + url = f"{self.OAUTH_URL}/{route}" for _ in range(self.MAX_RETRIES): response = await self.bot.http_session.get( @@ -206,11 +210,6 @@ class Reddit(Cog): if not self.webhook: await self.bot.fetch_webhook(Webhooks.reddit) - if not self.access_token: - await self.get_access_token() - elif self.access_token.expires_at < datetime.utcnow(): - await self.get_access_token() - if datetime.utcnow().weekday() == 0: await self.top_weekly_posts() # if it's a monday send the top weekly posts @@ -249,10 +248,6 @@ class Reddit(Cog): @reddit_group.command(name="top") async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of all time from a given subreddit.""" - if not self.access_token: - await self.get_access_token() - elif self.access_token.expires_at < datetime.utcnow(): - await self.get_access_token() async with ctx.typing(): embed = await self.get_top_posts(subreddit=subreddit, time="all") @@ -261,10 +256,6 @@ class Reddit(Cog): @reddit_group.command(name="daily") async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of today from a given subreddit.""" - if not self.access_token: - await self.get_access_token() - elif self.access_token.expires_at < datetime.utcnow(): - await self.get_access_token() async with ctx.typing(): embed = await self.get_top_posts(subreddit=subreddit, time="day") @@ -273,10 +264,6 @@ class Reddit(Cog): @reddit_group.command(name="weekly") async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of this week from a given subreddit.""" - if not self.access_token: - await self.get_access_token() - elif self.access_token.expires_at < datetime.utcnow(): - await self.get_access_token() async with ctx.typing(): embed = await self.get_top_posts(subreddit=subreddit, time="week") -- cgit v1.2.3 From ddfbfe31b2c2d9e5bc5d46ab9ffffa5b35a63e5f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 12:44:12 -0800 Subject: Reddit: move BasicAuth instantiation to __init__ The object is basically just a namedtuple so there's no need to re-create it every time a token is obtained. * Remove log message which shows credentials. * Initialise headers attribute to None in __init__. --- bot/cogs/reddit.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 0802c6102..48f636159 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -32,8 +32,10 @@ class Reddit(Cog): self.webhook = None self.access_token = None - bot.loop.create_task(self.init_reddit_ready()) + self.headers = None + self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) + bot.loop.create_task(self.init_reddit_ready()) self.auto_poster_loop.start() def cog_unload(self) -> None: @@ -61,9 +63,6 @@ class Reddit(Cog): "duration": "temporary" } - log.info(f"{RedditConfig.client_id}, {RedditConfig.secret}") - self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) - for _ in range(self.MAX_RETRIES): response = await self.bot.http_session.post( url=f"{self.URL}/api/v1/access_token", -- cgit v1.2.3 From d0f6f794d4fa3dd78ac8be2b95cae669b4587fb3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 12:44:35 -0800 Subject: Reddit: use qualified_name attribute when removing the cog --- 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 48f636159..111f3b8ab 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -86,7 +86,7 @@ class Reddit(Cog): await asyncio.sleep(3) log.error("Authentication with Reddit API failed. Unloading extension.") - self.bot.remove_cog(self.__class__.__name__) + self.bot.remove_cog(self.qualified_name) return async def revoke_access_token(self) -> None: -- cgit v1.2.3 From 249a4c185ca9680258eb7a753307fbe8d0089b4b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 13:49:21 -0800 Subject: Reddit: use expires_in from the response to calculate token expiration --- bot/cogs/reddit.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 111f3b8ab..083f90573 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -56,7 +56,7 @@ class Reddit(Cog): return self.bot.get_channel(Channels.reddit) async def get_access_token(self) -> None: - """Get Reddit access tokens.""" + """Get a Reddit API OAuth2 access token.""" headers = {"User-Agent": self.USER_AGENT} data = { "grant_type": "client_credentials", @@ -72,10 +72,11 @@ class Reddit(Cog): ) if response.status == 200 and response.content_type == "application/json": content = await response.json() + expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) self.access_token = AccessToken( token=content["access_token"], - expires_at=datetime.utcnow() + timedelta(hours=1) + expires_at=datetime.utcnow() + timedelta(seconds=expiration) ) self.headers = { "Authorization": "bearer " + self.access_token.token, -- cgit v1.2.3 From e49d9d5429c8e1aedfde5a5d38750890b3361496 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 13:50:03 -0800 Subject: Reddit: define AccessToken type at the module level --- 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 083f90573..d9e1f0a39 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -18,6 +18,8 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) +AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) + class Reddit(Cog): """Track subreddit posts and show detailed statistics about them.""" @@ -73,7 +75,6 @@ class Reddit(Cog): if response.status == 200 and response.content_type == "application/json": content = await response.json() expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. - AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) self.access_token = AccessToken( token=content["access_token"], expires_at=datetime.utcnow() + timedelta(seconds=expiration) -- cgit v1.2.3 From 4889326af4f9251218e332f873349e3f8c7bea7b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 14:18:46 -0800 Subject: Reddit: revise docstrings --- bot/cogs/reddit.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index d9e1f0a39..0a0279a39 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -41,7 +41,7 @@ class Reddit(Cog): self.auto_poster_loop.start() def cog_unload(self) -> None: - """Stops the loops when the cog is unloaded.""" + """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(): self.revoke_access_token() @@ -58,7 +58,12 @@ class Reddit(Cog): return self.bot.get_channel(Channels.reddit) async def get_access_token(self) -> None: - """Get a Reddit API OAuth2 access token.""" + """ + Get a Reddit API OAuth2 access token and assign it to self.access_token. + + A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog + will be unloaded if retrieval was still unsuccessful. + """ headers = {"User-Agent": self.USER_AGENT} data = { "grant_type": "client_credentials", @@ -72,6 +77,7 @@ class Reddit(Cog): auth=self.client_auth, data=data ) + if response.status == 200 and response.content_type == "application/json": content = await response.json() expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. @@ -87,14 +93,16 @@ class Reddit(Cog): await asyncio.sleep(3) - log.error("Authentication with Reddit API failed. Unloading extension.") + log.error("Authentication with Reddit API failed. Unloading the cog.") self.bot.remove_cog(self.qualified_name) return async def revoke_access_token(self) -> None: - """Revoke the access token for Reddit API.""" - # Access tokens are valid for 1 hour. - # The token should be revoked, since the API is called only once a day. + """ + Revoke the OAuth2 access token for the Reddit API. + + For security reasons, it's good practice to revoke the token when it's no longer being used. + """ headers = {"User-Agent": self.USER_AGENT} data = { "token": self.access_token.token, @@ -107,12 +115,12 @@ class Reddit(Cog): auth=self.client_auth, data=data ) + if response.status == 204 and response.content_type == "application/json": self.access_token = None self.headers = None - return - - log.warning(f"Unable to revoke access token, status code {response.status}.") + else: + log.warning(f"Unable to revoke access token: status {response.status}.") async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: """A helper method to fetch a certain amount of Reddit posts at a given route.""" -- cgit v1.2.3 From 08881979e25749cb8da9efaccead64bb15b354ec Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 14:33:39 -0800 Subject: Reddit: create a dict constant for the User-Agent header --- bot/cogs/reddit.py | 39 ++++++++++++--------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 0a0279a39..6af33d9db 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -24,7 +24,7 @@ AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) class Reddit(Cog): """Track subreddit posts and show detailed statistics about them.""" - USER_AGENT = "docker-python3:Discord Bot of PythonDiscord (https://pythondiscord.com/):1.0.0 (by /u/PythonDiscord)" + HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} URL = "https://www.reddit.com" OAUTH_URL = "https://oauth.reddit.com" MAX_RETRIES = 3 @@ -34,7 +34,6 @@ class Reddit(Cog): self.webhook = None self.access_token = None - self.headers = None self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) bot.loop.create_task(self.init_reddit_ready()) @@ -64,18 +63,15 @@ class Reddit(Cog): A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog will be unloaded if retrieval was still unsuccessful. """ - headers = {"User-Agent": self.USER_AGENT} - data = { - "grant_type": "client_credentials", - "duration": "temporary" - } - for _ in range(self.MAX_RETRIES): response = await self.bot.http_session.post( url=f"{self.URL}/api/v1/access_token", - headers=headers, + headers=self.HEADERS, auth=self.client_auth, - data=data + data={ + "grant_type": "client_credentials", + "duration": "temporary" + } ) if response.status == 200 and response.content_type == "application/json": @@ -85,10 +81,6 @@ class Reddit(Cog): token=content["access_token"], expires_at=datetime.utcnow() + timedelta(seconds=expiration) ) - self.headers = { - "Authorization": "bearer " + self.access_token.token, - "User-Agent": self.USER_AGENT - } return await asyncio.sleep(3) @@ -103,22 +95,18 @@ class Reddit(Cog): For security reasons, it's good practice to revoke the token when it's no longer being used. """ - headers = {"User-Agent": self.USER_AGENT} - data = { - "token": self.access_token.token, - "token_type_hint": "access_token" - } - response = await self.bot.http_session.post( url=f"{self.URL}/api/v1/revoke_token", - headers=headers, + headers=self.HEADERS, auth=self.client_auth, - data=data + data={ + "token": self.access_token.token, + "token_type_hint": "access_token" + } ) if response.status == 204 and response.content_type == "application/json": self.access_token = None - self.headers = None else: log.warning(f"Unable to revoke access token: status {response.status}.") @@ -128,9 +116,6 @@ class Reddit(Cog): if not 25 >= amount > 0: raise ValueError("Invalid amount of subreddit posts requested.") - if params is None: - params = {} - # Renew the token if necessary. if not self.access_token or self.access_token.expires_at < datetime.utcnow(): await self.get_access_token() @@ -139,7 +124,7 @@ class Reddit(Cog): for _ in range(self.MAX_RETRIES): response = await self.bot.http_session.get( url=url, - headers=self.headers, + headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, params=params ) if response.status == 200 and response.content_type == 'application/json': -- cgit v1.2.3 From a4fb51bbeb9b15e1a3718038f280d9c633acfa66 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 14:45:56 -0800 Subject: Reddit: log retries when getting the access token --- bot/cogs/reddit.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 6af33d9db..15b4a108c 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -63,7 +63,7 @@ class Reddit(Cog): A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog will be unloaded if retrieval was still unsuccessful. """ - for _ in range(self.MAX_RETRIES): + for i in range(1, self.MAX_RETRIES + 1): response = await self.bot.http_session.post( url=f"{self.URL}/api/v1/access_token", headers=self.HEADERS, @@ -81,7 +81,15 @@ class Reddit(Cog): token=content["access_token"], expires_at=datetime.utcnow() + timedelta(seconds=expiration) ) + + log.debug(f"New token acquired; expires on {self.access_token.expires_at}") return + else: + log.debug( + f"Failed to get an access token: " + f"status {response.status} & content type {response.content_type}; " + f"retrying ({i}/{self.MAX_RETRIES})" + ) await asyncio.sleep(3) -- cgit v1.2.3 From 806ccf73dd896b4272726ce32edea7c882ce9a81 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 14:47:13 -0800 Subject: Reddit: raise ClientError when the token can't be retrieved Raising an exception allows the error handler to display a message to the user if the failure happened from a command invocation. --- bot/cogs/reddit.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 15b4a108c..96af90bc4 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -6,7 +6,7 @@ from collections import namedtuple from datetime import datetime, timedelta from typing import List -from aiohttp import BasicAuth +from aiohttp import BasicAuth, ClientError from discord import Colour, Embed, TextChannel from discord.ext.commands import Bot, Cog, Context, group from discord.ext.tasks import loop @@ -61,7 +61,7 @@ class Reddit(Cog): Get a Reddit API OAuth2 access token and assign it to self.access_token. A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog - will be unloaded if retrieval was still unsuccessful. + will be unloaded and a ClientError raised if retrieval was still unsuccessful. """ for i in range(1, self.MAX_RETRIES + 1): response = await self.bot.http_session.post( @@ -93,9 +93,8 @@ class Reddit(Cog): await asyncio.sleep(3) - log.error("Authentication with Reddit API failed. Unloading the cog.") self.bot.remove_cog(self.qualified_name) - return + raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") async def revoke_access_token(self) -> None: """ -- cgit v1.2.3 From 2d69e1293ad659b4f4fd7f5e5029b6591328ebc6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 18:07:06 -0800 Subject: Clean: un-hide from help and add purge alias --- bot/cogs/clean.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index dca411d01..a45d30142 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -167,7 +167,7 @@ class Clean(Cog): channel_id=Channels.modlog, ) - @group(invoke_without_command=True, name="clean", hidden=True) + @group(invoke_without_command=True, name="clean", aliases=["purge"]) @with_role(*MODERATION_ROLES) async def clean_group(self, ctx: Context) -> None: """Commands for cleaning messages in channels.""" -- cgit v1.2.3 From 7130de271ddfde568d2d008823656e5371b4dc45 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 18:13:05 -0800 Subject: Clean: support specifying a channel different than the context's --- bot/cogs/clean.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index a45d30142..312c7926d 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -3,7 +3,7 @@ import random import re from typing import Optional -from discord import Colour, Embed, Message, User +from discord import Colour, Embed, Message, TextChannel, User from discord.ext.commands import Bot, Cog, Context, group from bot.cogs.moderation import ModLog @@ -39,7 +39,8 @@ class Clean(Cog): async def _clean_messages( self, amount: int, ctx: Context, bots_only: bool = False, user: User = None, - regex: Optional[str] = None + regex: Optional[str] = None, + channel: Optional[TextChannel] = None ) -> None: """A helper function that does the actual message cleaning.""" def predicate_bots_only(message: Message) -> bool: @@ -104,6 +105,10 @@ class Clean(Cog): else: predicate = None # Delete all messages + # Default to using the invoking context's channel + if not channel: + channel = ctx.channel + # Look through the history and retrieve message data messages = [] message_ids = [] @@ -111,7 +116,7 @@ class Clean(Cog): invocation_deleted = False # To account for the invocation message, we index `amount + 1` messages. - async for message in ctx.channel.history(limit=amount + 1): + async for message in channel.history(limit=amount + 1): # If at any point the cancel command is invoked, we should stop. if not self.cleaning: @@ -135,7 +140,7 @@ class Clean(Cog): self.mod_log.ignore(Event.message_delete, *message_ids) # Use bulk delete to actually do the cleaning. It's far faster. - await ctx.channel.purge( + await channel.purge( limit=amount, check=predicate ) @@ -155,7 +160,7 @@ class Clean(Cog): # Build the embed and send it message = ( - f"**{len(message_ids)}** messages deleted in <#{ctx.channel.id}> by **{ctx.author.name}**\n\n" + f"**{len(message_ids)}** messages deleted in <#{channel.id}> by **{ctx.author.name}**\n\n" f"A log of the deleted messages can be found [here]({log_url})." ) @@ -175,27 +180,27 @@ class Clean(Cog): @clean_group.command(name="user", aliases=["users"]) @with_role(*MODERATION_ROLES) - async def clean_user(self, ctx: Context, user: User, amount: int = 10) -> None: + async def clean_user(self, ctx: Context, user: User, amount: int = 10, channel: TextChannel = None) -> None: """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, user=user) + await self._clean_messages(amount, ctx, user=user, channel=channel) @clean_group.command(name="all", aliases=["everything"]) @with_role(*MODERATION_ROLES) - async def clean_all(self, ctx: Context, amount: int = 10) -> None: + async def clean_all(self, ctx: Context, amount: int = 10, channel: TextChannel = None) -> None: """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx) + await self._clean_messages(amount, ctx, channel=channel) @clean_group.command(name="bots", aliases=["bot"]) @with_role(*MODERATION_ROLES) - async def clean_bots(self, ctx: Context, amount: int = 10) -> None: + async def clean_bots(self, ctx: Context, amount: int = 10, channel: TextChannel = None) -> None: """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, bots_only=True) + await self._clean_messages(amount, ctx, bots_only=True, channel=channel) @clean_group.command(name="regex", aliases=["word", "expression"]) @with_role(*MODERATION_ROLES) - async def clean_regex(self, ctx: Context, regex: str, amount: int = 10) -> None: + async def clean_regex(self, ctx: Context, regex: str, amount: int = 10, channel: TextChannel = None) -> None: """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, regex=regex) + await self._clean_messages(amount, ctx, regex=regex, channel=channel) @clean_group.command(name="stop", aliases=["cancel", "abort"]) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 0ddf9e66ea4e7a9753cc7044da4bc06d1e9cfb7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 19:21:31 -0800 Subject: Verification: allow mods+ to use commands in checkpoint (#688) --- bot/cogs/verification.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index b5e8d4357..b62a08db6 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -9,9 +9,10 @@ from bot.cogs.moderation import ModLog from bot.constants import ( Bot as BotConfig, Channels, Colours, Event, - Filter, Icons, Roles + Filter, Icons, MODERATION_ROLES, Roles ) from bot.decorators import InChannelCheckFailure, in_channel, without_role +from bot.utils.checks import without_role_check log = logging.getLogger(__name__) @@ -189,7 +190,7 @@ class Verification(Cog): @staticmethod def bot_check(ctx: Context) -> bool: """Block any command within the verification channel that is not !accept.""" - if ctx.channel.id == Channels.verification: + if ctx.channel.id == Channels.verification and without_role_check(ctx, *MODERATION_ROLES): return ctx.command.name == "accept" else: return True -- cgit v1.2.3 From 65c7319c7bd83e6f8833b323d5dd408ca771cf9d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 19:28:41 -0800 Subject: Verification: delete bots' messages (#689) Messages are deleted after a delay of 10 seconds. This helps keep the channel clean. The periodic ping is an exception; it will remain. --- bot/cogs/verification.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index b62a08db6..2d759f885 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -38,6 +38,7 @@ 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." ) +BOT_MESSAGE_DELETE_DELAY = 10 class Verification(Cog): @@ -56,7 +57,11 @@ class Verification(Cog): async def on_message(self, message: Message) -> None: """Check new message event for messages to the checkpoint channel & process.""" if message.author.bot: - return # They're a bot, ignore + # They're a bot, delete their message after the delay. + # But not the periodic ping; we like that one. + if message.content != PERIODIC_PING: + await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) + return if message.channel.id != Channels.verification: return # Only listen for #checkpoint messages -- cgit v1.2.3 From 9d551cc69c1935165389f26f52753895604dd3f5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 20:26:26 -0800 Subject: Add a generic converter for only allowing certain string values --- bot/cogs/moderation/management.py | 13 ++----------- bot/converters.py | 23 +++++++++++++++++++++-- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index abfe5c2b3..50bce3981 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -9,7 +9,7 @@ from discord.ext import commands from discord.ext.commands import Context from bot import constants -from bot.converters import InfractionSearchQuery +from bot.converters import InfractionSearchQuery, string from bot.pagination import LinePaginator from bot.utils import time from bot.utils.checks import in_channel_check, with_role_check @@ -22,15 +22,6 @@ log = logging.getLogger(__name__) UserConverter = t.Union[discord.User, utils.proxy_user] -def permanent_duration(expires_at: str) -> str: - """Only allow an expiration to be 'permanent' if it is a string.""" - expires_at = expires_at.lower() - if expires_at != "permanent": - raise commands.BadArgument - else: - return expires_at - - class ModManagement(commands.Cog): """Management of infractions.""" @@ -61,7 +52,7 @@ class ModManagement(commands.Cog): self, ctx: Context, infraction_id: int, - duration: t.Union[utils.Expiry, permanent_duration, None], + duration: t.Union[utils.Expiry, string("permanent"), None], *, reason: str = None ) -> None: diff --git a/bot/converters.py b/bot/converters.py index cf0496541..2cfc42903 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,8 +1,8 @@ import logging import re +import typing as t from datetime import datetime from ssl import CertificateError -from typing import Union import dateutil.parser import dateutil.tz @@ -15,6 +15,25 @@ from discord.ext.commands import BadArgument, Context, Converter log = logging.getLogger(__name__) +def string(*values, preserve_case: bool = False) -> t.Callable[[str], str]: + """ + Return a converter which only allows arguments equal to one of the given values. + + Unless preserve_case is True, the argument is converter to lowercase. All values are then + expected to have already been given in lowercase too. + """ + def converter(arg: str) -> str: + if not preserve_case: + arg = arg.lower() + + if arg not in values: + raise BadArgument(f"Only the following values are allowed:\n```{', '.join(values)}```") + else: + return arg + + return converter + + class ValidPythonIdentifier(Converter): """ A converter that checks whether the given string is a valid Python identifier. @@ -70,7 +89,7 @@ class InfractionSearchQuery(Converter): """A converter that checks if the argument is a Discord user, and if not, falls back to a string.""" @staticmethod - async def convert(ctx: Context, arg: str) -> Union[discord.Member, str]: + async def convert(ctx: Context, arg: str) -> t.Union[discord.Member, str]: """Check if the argument is a Discord user, and if not, falls back to a string.""" try: maybe_snowflake = arg.strip("<@!>") -- cgit v1.2.3 From 729ac3d83a3bd4620d1e9b24769466e219d45de6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 21:00:47 -0800 Subject: ModManagement: allow "recent" as ID to edit infraction (#624) It will attempt to find the most recent infraction authored by the invoker of the edit command. --- bot/cogs/moderation/management.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 50bce3981..35832ded5 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -51,7 +51,7 @@ class ModManagement(commands.Cog): async def infraction_edit( self, ctx: Context, - infraction_id: int, + infraction_id: t.Union[int, string("recent")], duration: t.Union[utils.Expiry, string("permanent"), None], *, reason: str = None @@ -69,6 +69,9 @@ class ModManagement(commands.Cog): \u2003`M` - minutes∗ \u2003`s` - seconds + Use "recent" as the infraction ID to specify that the ost recent infraction authored by the + command invoker should be edited. + Use "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 timestamp can be provided for the duration. """ @@ -77,7 +80,23 @@ class ModManagement(commands.Cog): raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") # Retrieve the previous infraction for its information. - old_infraction = await self.bot.api_client.get(f'bot/infractions/{infraction_id}') + if infraction_id == "recent": + params = { + "actor__id": ctx.author.id, + "ordering": "-inserted_at" + } + infractions = await self.bot.api_client.get(f"bot/infractions", params=params) + + if infractions: + old_infraction = infractions[0] + infraction_id = old_infraction["id"] + else: + await ctx.send( + f":x: Couldn't find most recent infraction; you have never given an infraction." + ) + return + else: + old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") request_data = {} confirm_messages = [] -- cgit v1.2.3 From c1bf0a48692d87c5cbe9ee310cd0120bda339a96 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 21:01:16 -0800 Subject: ModManagement: display ID of edited infraction in confirmation message --- bot/cogs/moderation/management.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 35832ded5..904611e13 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -139,7 +139,8 @@ class ModManagement(commands.Cog): New expiry: {new_infraction['expires_at'] or "Permanent"} """.rstrip() - await ctx.send(f":ok_hand: Updated infraction: {' & '.join(confirm_messages)}") + changes = ' & '.join(confirm_messages) + await ctx.send(f":ok_hand: Updated infraction #{infraction_id}: {changes}") # Get information about the infraction's user user_id = new_infraction['user'] -- cgit v1.2.3 From ce52c836ff2e14cd9cfade3a4fcfe8a0e5071e2e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 21:21:10 -0800 Subject: Moderation: show emoji for DM failure instead of mentioning actor (#534) --- bot/cogs/moderation/scheduler.py | 5 ++--- bot/constants.py | 2 ++ config-default.yml | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 3e0968121..0ab1fe997 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -113,8 +113,8 @@ class InfractionScheduler(Scheduler): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" else: + dm_result = f"{constants.Emojis.failmail} " dm_log_text = "\nDM: **Failed**" - log_content = ctx.author.mention if infraction["actor"] == self.bot.user.id: log.trace( @@ -250,8 +250,7 @@ class InfractionScheduler(Scheduler): if log_text.get("DM") == "Sent": dm_emoji = ":incoming_envelope: " elif "DM" in log_text: - # Mention the actor because the DM failed to send. - log_content = ctx.author.mention + dm_emoji = f"{constants.Emojis.failmail} " # Accordingly display whether the pardon failed. if "Failure" in log_text: diff --git a/bot/constants.py b/bot/constants.py index 89504a2e0..3b1ca2887 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -256,6 +256,8 @@ class Emojis(metaclass=YAMLGetter): status_idle: str status_dnd: str + failmail: str + bullet: str new: str pencil: str diff --git a/config-default.yml b/config-default.yml index 930a1a0e6..9e6ada3dd 100644 --- a/config-default.yml +++ b/config-default.yml @@ -27,6 +27,8 @@ style: status_dnd: "<:status_dnd:470326272082313216>" status_offline: "<:status_offline:470326266537705472>" + failmail: "<:failmail:633660039931887616>" + bullet: "\u2022" pencil: "\u270F" new: "\U0001F195" -- cgit v1.2.3 From 56833bbe99ce3cd93af87e9d33cec47a059b61f3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 21:52:57 -0800 Subject: ModManagement: add more aliases for "special" params of infraction edit --- bot/cogs/moderation/management.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 904611e13..37bdb1934 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -51,8 +51,8 @@ class ModManagement(commands.Cog): async def infraction_edit( self, ctx: Context, - infraction_id: t.Union[int, string("recent")], - duration: t.Union[utils.Expiry, string("permanent"), None], + infraction_id: t.Union[int, string("l", "last", "recent")], + duration: t.Union[utils.Expiry, string("p", "permanent"), None], *, reason: str = None ) -> None: @@ -69,18 +69,18 @@ class ModManagement(commands.Cog): \u2003`M` - minutes∗ \u2003`s` - seconds - Use "recent" as the infraction ID to specify that the ost recent infraction authored by the - command invoker should be edited. + Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction + authored by the command invoker should be edited. - Use "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 timestamp - can be provided for the duration. + Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 + timestamp can be provided for the duration. """ if duration is None and reason is None: # Unlike UserInputError, the error handler will show a specified message for BadArgument raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") # Retrieve the previous infraction for its information. - if infraction_id == "recent": + if isinstance(infraction_id, str): params = { "actor__id": ctx.author.id, "ordering": "-inserted_at" @@ -102,7 +102,7 @@ class ModManagement(commands.Cog): confirm_messages = [] log_text = "" - if duration == "permanent": + if isinstance(duration, str): request_data['expires_at'] = None confirm_messages.append("marked as permanent") elif duration is not None: -- cgit v1.2.3 From eb53a4594dff20372574058ec90062995362b098 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 22:03:20 -0800 Subject: Converters: rename string to allowed_strings --- bot/cogs/moderation/management.py | 6 +++--- bot/converters.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 37bdb1934..20ff25ba1 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -9,7 +9,7 @@ from discord.ext import commands from discord.ext.commands import Context from bot import constants -from bot.converters import InfractionSearchQuery, string +from bot.converters import InfractionSearchQuery, allowed_strings from bot.pagination import LinePaginator from bot.utils import time from bot.utils.checks import in_channel_check, with_role_check @@ -51,8 +51,8 @@ class ModManagement(commands.Cog): async def infraction_edit( self, ctx: Context, - infraction_id: t.Union[int, string("l", "last", "recent")], - duration: t.Union[utils.Expiry, string("p", "permanent"), None], + infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], + duration: t.Union[utils.Expiry, allowed_strings("p", "permanent"), None], *, reason: str = None ) -> None: diff --git a/bot/converters.py b/bot/converters.py index 2cfc42903..8d2ab7eb8 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -15,11 +15,11 @@ from discord.ext.commands import BadArgument, Context, Converter log = logging.getLogger(__name__) -def string(*values, preserve_case: bool = False) -> t.Callable[[str], str]: +def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], str]: """ Return a converter which only allows arguments equal to one of the given values. - Unless preserve_case is True, the argument is converter to lowercase. All values are then + Unless preserve_case is True, the argument is converted to lowercase. All values are then expected to have already been given in lowercase too. """ def converter(arg: str) -> str: -- cgit v1.2.3 From 38129d632648da21726a8158b76676695fd0b512 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 11 Dec 2019 22:50:44 -0800 Subject: Clean: allow amount argument to be skipped This make the channel specifiable without the amount. Co-Authored-By: scragly <29337040+scragly@users.noreply.github.com> --- bot/cogs/clean.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 312c7926d..ed1962565 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -180,25 +180,25 @@ class Clean(Cog): @clean_group.command(name="user", aliases=["users"]) @with_role(*MODERATION_ROLES) - async def clean_user(self, ctx: Context, user: User, amount: int = 10, channel: TextChannel = None) -> None: + async def clean_user(self, ctx: Context, user: User, amount: Optional[int] = 10, channel: TextChannel = None) -> None: """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, user=user, channel=channel) @clean_group.command(name="all", aliases=["everything"]) @with_role(*MODERATION_ROLES) - async def clean_all(self, ctx: Context, amount: int = 10, channel: TextChannel = None) -> None: + async def clean_all(self, ctx: Context, amount: Optional[int] = 10, channel: TextChannel = None) -> None: """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, channel=channel) @clean_group.command(name="bots", aliases=["bot"]) @with_role(*MODERATION_ROLES) - async def clean_bots(self, ctx: Context, amount: int = 10, channel: TextChannel = None) -> None: + async def clean_bots(self, ctx: Context, amount: Optional[int] = 10, channel: TextChannel = None) -> None: """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, bots_only=True, channel=channel) @clean_group.command(name="regex", aliases=["word", "expression"]) @with_role(*MODERATION_ROLES) - async def clean_regex(self, ctx: Context, regex: str, amount: int = 10, channel: TextChannel = None) -> None: + async def clean_regex(self, ctx: Context, regex: str, amount: Optional[int] = 10, channel: TextChannel = None) -> None: """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, regex=regex, channel=channel) -- cgit v1.2.3 From a657fd4ebfaebd2a419f81dbda14f93b395380ff Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Dec 2019 22:54:40 -0800 Subject: Clean: reformat arguments --- bot/cogs/clean.py | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index ed1962565..432c9e998 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -37,10 +37,13 @@ class Clean(Cog): return self.bot.get_cog("ModLog") async def _clean_messages( - self, amount: int, ctx: Context, - bots_only: bool = False, user: User = None, - regex: Optional[str] = None, - channel: Optional[TextChannel] = None + self, + amount: int, + ctx: Context, + bots_only: bool = False, + user: User = None, + regex: Optional[str] = None, + channel: Optional[TextChannel] = None ) -> None: """A helper function that does the actual message cleaning.""" def predicate_bots_only(message: Message) -> bool: @@ -180,25 +183,47 @@ class Clean(Cog): @clean_group.command(name="user", aliases=["users"]) @with_role(*MODERATION_ROLES) - async def clean_user(self, ctx: Context, user: User, amount: Optional[int] = 10, channel: TextChannel = None) -> None: + async def clean_user( + self, + ctx: Context, + user: User, + amount: Optional[int] = 10, + channel: TextChannel = None + ) -> None: """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, user=user, channel=channel) @clean_group.command(name="all", aliases=["everything"]) @with_role(*MODERATION_ROLES) - async def clean_all(self, ctx: Context, amount: Optional[int] = 10, channel: TextChannel = None) -> None: + async def clean_all( + self, + ctx: Context, + amount: Optional[int] = 10, + channel: TextChannel = None + ) -> None: """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, channel=channel) @clean_group.command(name="bots", aliases=["bot"]) @with_role(*MODERATION_ROLES) - async def clean_bots(self, ctx: Context, amount: Optional[int] = 10, channel: TextChannel = None) -> None: + async def clean_bots( + self, + ctx: Context, + amount: Optional[int] = 10, + channel: TextChannel = None + ) -> None: """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, bots_only=True, channel=channel) @clean_group.command(name="regex", aliases=["word", "expression"]) @with_role(*MODERATION_ROLES) - async def clean_regex(self, ctx: Context, regex: str, amount: Optional[int] = 10, channel: TextChannel = None) -> None: + async def clean_regex( + self, + ctx: Context, + regex: str, + amount: Optional[int] = 10, + channel: TextChannel = None + ) -> None: """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, regex=regex, channel=channel) -- cgit v1.2.3 From 97f0cb8efb82217d28123e834454d1316f04b031 Mon Sep 17 00:00:00 2001 From: Joseph Date: Fri, 13 Dec 2019 00:41:56 +0000 Subject: Revert "Use OAuth to be Reddit API compliant" --- azure-pipelines.yml | 2 +- bot/cogs/reddit.py | 91 ++++++----------------------------------------------- bot/constants.py | 2 -- config-default.yml | 2 -- docker-compose.yml | 2 -- 5 files changed, 11 insertions(+), 88 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0400ac4d2..da3b06201 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -30,7 +30,7 @@ jobs: - script: python -m flake8 displayName: 'Run linter' - - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz REDDIT_CLIENT_ID=spam REDDIT_SECRET=ham coverage run -m xmlrunner + - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz coverage run -m xmlrunner displayName: Run tests - script: coverage report -m && coverage xml -o coverage.xml diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index aa487f18e..bec316ae7 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -2,11 +2,9 @@ import asyncio import logging import random import textwrap -from collections import namedtuple from datetime import datetime, timedelta from typing import List -from aiohttp import BasicAuth, ClientError from discord import Colour, Embed, TextChannel from discord.ext.commands import Cog, Context, group from discord.ext.tasks import loop @@ -19,32 +17,25 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) -AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) - class Reddit(Cog): """Track subreddit posts and show detailed statistics about them.""" - HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} + HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"} URL = "https://www.reddit.com" - OAUTH_URL = "https://oauth.reddit.com" - MAX_RETRIES = 3 + MAX_FETCH_RETRIES = 3 def __init__(self, bot: Bot): self.bot = bot - self.webhook = None - self.access_token = None - self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) - + self.webhook = None # set in on_ready bot.loop.create_task(self.init_reddit_ready()) + self.auto_poster_loop.start() def cog_unload(self) -> None: - """Stop the loop task and revoke the access token when the cog is unloaded.""" + """Stops the loops when the cog is unloaded.""" self.auto_poster_loop.cancel() - if self.access_token.expires_at < datetime.utcnow(): - self.revoke_access_token() async def init_reddit_ready(self) -> None: """Sets the reddit webhook when the cog is loaded.""" @@ -57,82 +48,20 @@ class Reddit(Cog): """Get the #reddit channel object from the bot's cache.""" return self.bot.get_channel(Channels.reddit) - async def get_access_token(self) -> None: - """ - Get a Reddit API OAuth2 access token and assign it to self.access_token. - - A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog - will be unloaded and a ClientError raised if retrieval was still unsuccessful. - """ - for i in range(1, self.MAX_RETRIES + 1): - response = await self.bot.http_session.post( - url=f"{self.URL}/api/v1/access_token", - headers=self.HEADERS, - auth=self.client_auth, - data={ - "grant_type": "client_credentials", - "duration": "temporary" - } - ) - - if response.status == 200 and response.content_type == "application/json": - content = await response.json() - expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. - self.access_token = AccessToken( - token=content["access_token"], - expires_at=datetime.utcnow() + timedelta(seconds=expiration) - ) - - log.debug(f"New token acquired; expires on {self.access_token.expires_at}") - return - else: - log.debug( - f"Failed to get an access token: " - f"status {response.status} & content type {response.content_type}; " - f"retrying ({i}/{self.MAX_RETRIES})" - ) - - await asyncio.sleep(3) - - self.bot.remove_cog(self.qualified_name) - raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") - - async def revoke_access_token(self) -> None: - """ - Revoke the OAuth2 access token for the Reddit API. - - For security reasons, it's good practice to revoke the token when it's no longer being used. - """ - response = await self.bot.http_session.post( - url=f"{self.URL}/api/v1/revoke_token", - headers=self.HEADERS, - auth=self.client_auth, - data={ - "token": self.access_token.token, - "token_type_hint": "access_token" - } - ) - - if response.status == 204 and response.content_type == "application/json": - self.access_token = None - else: - log.warning(f"Unable to revoke access token: status {response.status}.") - async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: """A helper method to fetch a certain amount of Reddit posts at a given route.""" # Reddit's JSON responses only provide 25 posts at most. if not 25 >= amount > 0: raise ValueError("Invalid amount of subreddit posts requested.") - # Renew the token if necessary. - if not self.access_token or self.access_token.expires_at < datetime.utcnow(): - await self.get_access_token() + if params is None: + params = {} - url = f"{self.OAUTH_URL}/{route}" - for _ in range(self.MAX_RETRIES): + url = f"{self.URL}/{route}.json" + for _ in range(self.MAX_FETCH_RETRIES): response = await self.bot.http_session.get( url=url, - headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, + headers=self.HEADERS, params=params ) if response.status == 200 and response.content_type == 'application/json': diff --git a/bot/constants.py b/bot/constants.py index ed85adf6a..89504a2e0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -465,8 +465,6 @@ class Reddit(metaclass=YAMLGetter): section = "reddit" subreddits: list - client_id: str - secret: str class Wolfram(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index e6f0fda21..930a1a0e6 100644 --- a/config-default.yml +++ b/config-default.yml @@ -365,8 +365,6 @@ anti_malware: reddit: subreddits: - 'r/Python' - client_id: !ENV "REDDIT_CLIENT_ID" - secret: !ENV "REDDIT_SECRET" wolfram: diff --git a/docker-compose.yml b/docker-compose.yml index 7281c7953..f79fdba58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,5 +42,3 @@ services: environment: BOT_TOKEN: ${BOT_TOKEN} BOT_API_KEY: badbot13m0n8f570f942013fc818f234916ca531 - REDDIT_CLIENT_ID: ${REDDIT_CLIENT_ID} - REDDIT_SECRET: ${REDDIT_SECRET} -- cgit v1.2.3 From 8a545b495a19715cc519afc9a867e16787cd5212 Mon Sep 17 00:00:00 2001 From: Joseph Date: Fri, 13 Dec 2019 00:48:11 +0000 Subject: Revert "Revert "Use OAuth to be Reddit API compliant"" --- azure-pipelines.yml | 2 +- bot/cogs/reddit.py | 91 +++++++++++++++++++++++++++++++++++++++++++++++------ bot/constants.py | 2 ++ config-default.yml | 2 ++ docker-compose.yml | 2 ++ 5 files changed, 88 insertions(+), 11 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index da3b06201..0400ac4d2 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -30,7 +30,7 @@ jobs: - script: python -m flake8 displayName: 'Run linter' - - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz coverage run -m xmlrunner + - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz REDDIT_CLIENT_ID=spam REDDIT_SECRET=ham coverage run -m xmlrunner displayName: Run tests - script: coverage report -m && coverage xml -o coverage.xml diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index bec316ae7..aa487f18e 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -2,9 +2,11 @@ import asyncio import logging import random import textwrap +from collections import namedtuple from datetime import datetime, timedelta from typing import List +from aiohttp import BasicAuth, ClientError from discord import Colour, Embed, TextChannel from discord.ext.commands import Cog, Context, group from discord.ext.tasks import loop @@ -17,25 +19,32 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) +AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) + class Reddit(Cog): """Track subreddit posts and show detailed statistics about them.""" - HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"} + HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} URL = "https://www.reddit.com" - MAX_FETCH_RETRIES = 3 + OAUTH_URL = "https://oauth.reddit.com" + MAX_RETRIES = 3 def __init__(self, bot: Bot): self.bot = bot - self.webhook = None # set in on_ready - bot.loop.create_task(self.init_reddit_ready()) + self.webhook = None + self.access_token = None + self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) + bot.loop.create_task(self.init_reddit_ready()) self.auto_poster_loop.start() def cog_unload(self) -> None: - """Stops the loops when the cog is unloaded.""" + """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(): + self.revoke_access_token() async def init_reddit_ready(self) -> None: """Sets the reddit webhook when the cog is loaded.""" @@ -48,20 +57,82 @@ class Reddit(Cog): """Get the #reddit channel object from the bot's cache.""" return self.bot.get_channel(Channels.reddit) + async def get_access_token(self) -> None: + """ + Get a Reddit API OAuth2 access token and assign it to self.access_token. + + A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog + will be unloaded and a ClientError raised if retrieval was still unsuccessful. + """ + for i in range(1, self.MAX_RETRIES + 1): + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/access_token", + headers=self.HEADERS, + auth=self.client_auth, + data={ + "grant_type": "client_credentials", + "duration": "temporary" + } + ) + + if response.status == 200 and response.content_type == "application/json": + content = await response.json() + expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. + self.access_token = AccessToken( + token=content["access_token"], + expires_at=datetime.utcnow() + timedelta(seconds=expiration) + ) + + log.debug(f"New token acquired; expires on {self.access_token.expires_at}") + return + else: + log.debug( + f"Failed to get an access token: " + f"status {response.status} & content type {response.content_type}; " + f"retrying ({i}/{self.MAX_RETRIES})" + ) + + await asyncio.sleep(3) + + self.bot.remove_cog(self.qualified_name) + raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") + + async def revoke_access_token(self) -> None: + """ + Revoke the OAuth2 access token for the Reddit API. + + For security reasons, it's good practice to revoke the token when it's no longer being used. + """ + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/revoke_token", + headers=self.HEADERS, + auth=self.client_auth, + data={ + "token": self.access_token.token, + "token_type_hint": "access_token" + } + ) + + if response.status == 204 and response.content_type == "application/json": + self.access_token = None + else: + log.warning(f"Unable to revoke access token: status {response.status}.") + async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: """A helper method to fetch a certain amount of Reddit posts at a given route.""" # Reddit's JSON responses only provide 25 posts at most. if not 25 >= amount > 0: raise ValueError("Invalid amount of subreddit posts requested.") - if params is None: - params = {} + # Renew the token if necessary. + if not self.access_token or self.access_token.expires_at < datetime.utcnow(): + await self.get_access_token() - url = f"{self.URL}/{route}.json" - for _ in range(self.MAX_FETCH_RETRIES): + url = f"{self.OAUTH_URL}/{route}" + for _ in range(self.MAX_RETRIES): response = await self.bot.http_session.get( url=url, - headers=self.HEADERS, + headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, params=params ) if response.status == 200 and response.content_type == 'application/json': diff --git a/bot/constants.py b/bot/constants.py index 89504a2e0..ed85adf6a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -465,6 +465,8 @@ class Reddit(metaclass=YAMLGetter): section = "reddit" subreddits: list + client_id: str + secret: str class Wolfram(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 930a1a0e6..e6f0fda21 100644 --- a/config-default.yml +++ b/config-default.yml @@ -365,6 +365,8 @@ anti_malware: reddit: subreddits: - 'r/Python' + client_id: !ENV "REDDIT_CLIENT_ID" + secret: !ENV "REDDIT_SECRET" wolfram: diff --git a/docker-compose.yml b/docker-compose.yml index f79fdba58..7281c7953 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,3 +42,5 @@ services: environment: BOT_TOKEN: ${BOT_TOKEN} BOT_API_KEY: badbot13m0n8f570f942013fc818f234916ca531 + REDDIT_CLIENT_ID: ${REDDIT_CLIENT_ID} + REDDIT_SECRET: ${REDDIT_SECRET} -- cgit v1.2.3 From c5109844e45c37bc1cc38eb1c3da31d52ab2aa6d Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Fri, 13 Dec 2019 09:17:21 +0700 Subject: Adding an optional argument for `until_expiration`, update typehints for `format_infraction_with_duration` - `until_expiration` was being a pain to unittests without a `now` ( default to `datetime.utcnow()` ). Adding an optional argument for this will not only make writing tests easier, but also allow more control over the helper function should we need to calculate the remaining time between two dates in the past. - Changed typehint for `date_from` in `format_infraction_with_duration` to `Optional[datetime.datetime]` to better reflect what it is. --- bot/utils/time.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index ac64865d6..7416f36e0 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -115,7 +115,7 @@ def format_infraction(timestamp: str) -> str: def format_infraction_with_duration( expiry: Optional[str], - date_from: datetime.datetime = None, + date_from: Optional[datetime.datetime] = None, max_units: int = 2 ) -> Optional[str]: """ @@ -140,10 +140,15 @@ def format_infraction_with_duration( return f"{expiry_formatted}{duration_formatted}" -def until_expiration(expiry: Optional[str], max_units: int = 2) -> Optional[str]: +def until_expiration( + expiry: Optional[str], + now: Optional[datetime.datetime] = None, + max_units: int = 2 +) -> Optional[str]: """ Get the remaining time until infraction's expiration, in a human-readable version of the relativedelta. + Returns a human-readable version of the remaining duration between datetime.utcnow() and an expiry. Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it. `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). By default, max_units is 2. @@ -151,7 +156,7 @@ def until_expiration(expiry: Optional[str], max_units: int = 2) -> Optional[str] if not expiry: return None - now = datetime.datetime.utcnow() + now = now or datetime.datetime.utcnow() since = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0) if since < now: -- cgit v1.2.3 From 520346d0b472e5cb6c9091a8323b871d2e3821cc Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Fri, 13 Dec 2019 09:18:49 +0700 Subject: Added tests for `until_expiration` Similar to `format_infraction_with_duration` ( if not outright copying it ), added 3 tests for `until_expiration`: - None `expiry`. - Custom `max_units`. - Normal use cases. --- tests/bot/utils/test_time.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 7f55dc3ec..bd04de28b 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -115,3 +115,48 @@ class TimeTests(unittest.TestCase): for expiry, date_from, max_units, expected in test_cases: with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + + def test_until_expiration_with_duration_none_expiry(self): + """until_expiration should work for None expiry.""" + test_cases = ( + (None, None, None, None), + + # To make sure that date_from and max_units are not touched + (None, 'Why hello there!', None, None), + (None, None, float('inf'), None), + (None, 'Why hello there!', float('inf'), None), + ) + + for expiry, now, max_units, expected in test_cases: + with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): + self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + + def test_until_expiration_with_duration_custom_units(self): + """until_expiration should work for custom max_units.""" + test_cases = ( + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, '11 hours, 55 minutes and 55 seconds'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, '6 months, 28 days, 23 hours and 54 minutes') + ) + + for expiry, now, max_units, expected in test_cases: + with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): + self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + + def test_until_expiration_normal_usage(self): + """until_expiration should work for normal usage, across various durations.""" + test_cases = ( + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '12 hours and 55 seconds'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '12 hours'), + ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '1 minute'), + ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '7 days and 23 hours'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '6 months and 28 days'), + ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '5 minutes'), + ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '1 minute'), + ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2 years and 4 months'), + ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, '9 minutes and 55 seconds'), + (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), + ) + + for expiry, now, max_units, expected in test_cases: + with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): + self.assertEqual(time.until_expiration(expiry, now, max_units), expected) -- cgit v1.2.3 From 66d4b93593b95bfa6999b70aca53328d83710c44 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Fri, 13 Dec 2019 09:29:06 +0700 Subject: Fixed a typo ( due to poor copy pasta and eyeballing skills ) --- tests/bot/utils/test_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index bd04de28b..69f35f2f5 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -121,7 +121,7 @@ class TimeTests(unittest.TestCase): test_cases = ( (None, None, None, None), - # To make sure that date_from and max_units are not touched + # To make sure that now and max_units are not touched (None, 'Why hello there!', None, None), (None, None, float('inf'), None), (None, 'Why hello there!', float('inf'), None), -- cgit v1.2.3 From c9cc19c27f3a3458910012e4c15442b0974b9fb3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 12 Dec 2019 20:34:10 -0800 Subject: Verification: check channel before checking for bot messages --- bot/cogs/verification.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 2d759f885..ec0f9627e 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -56,6 +56,9 @@ class Verification(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Check new message event for messages to the checkpoint channel & process.""" + if message.channel.id != Channels.verification: + return # Only listen for #checkpoint messages + if message.author.bot: # They're a bot, delete their message after the delay. # But not the periodic ping; we like that one. @@ -63,9 +66,6 @@ class Verification(Cog): await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) return - if message.channel.id != Channels.verification: - return # Only listen for #checkpoint messages - # if a user mentions a role or guild member # alert the mods in mod-alerts channel if message.mentions or message.role_mentions: -- cgit v1.2.3 From 75f00f3574bcee36f204fc3e5806b3bd91c91829 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sat, 14 Dec 2019 12:06:42 +0100 Subject: Relay attchments to #attachment_log --- config-default.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index b2f513bd3..0ecf88702 100644 --- a/config-default.yml +++ b/config-default.yml @@ -102,6 +102,7 @@ guild: admins: &ADMINS 365960823622991872 admin_spam: &ADMIN_SPAM 563594791770914816 announcements: 354619224620138496 + attachment_log: &ATTCH_LOG 649243850006855680 big_brother_logs: &BBLOGS 468507907357409333 bot: 267659945086812160 checkpoint_test: 422077681434099723 @@ -138,7 +139,7 @@ guild: staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] - attachment_repost: 649243850006855680 + attachment_repost: *ATTCH_LOG roles: admin: &ADMIN_ROLE 267628507062992896 -- cgit v1.2.3 From d8a9261941e2cfc027f14f2f56aae0b4f55858dd Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Mon, 16 Dec 2019 01:44:38 -0300 Subject: Add FetchedUser to convert ids of absent users to `discord.User` This `discord.ext.commands.Converter` fetches a user from the Discord API and returns a `discord.User` object. This should replace the `proxy_user` function from the moderation `utils`. --- bot/converters.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/bot/converters.py b/bot/converters.py index 8d2ab7eb8..fbe9ecd90 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -278,3 +278,25 @@ class ISODateTime(Converter): dt = dt.replace(tzinfo=None) return dt + + +class FetchedUser(Converter): + """Fetches from the Discord API and returns a `discord.User` object.""" + + @staticmethod + async def convert(ctx: Context, user_id: str) -> discord.User: + """Converts `user_id` to a `discord.User` object, after fetching from the Discord API.""" + # TODO: add docstring + # TODO: add remaining exceptions + try: + user_id = int(user_id) + user = await ctx.bot.fetch_user(user_id) + except ValueError: + raise BadArgument(f"The provided argument can't be turned into integer: `{user_id}`") + except discord.HTTPException as e: + # If the Discord error isn't Unknown user, save it in the log + if e.code != 10013: + log.warning(f"Failed to fetch user:") + raise BadArgument + + return user -- cgit v1.2.3 From 051565d6778bea9893de21d7df1114526ec812ed Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Mon, 16 Dec 2019 01:57:18 -0300 Subject: Remove pointless comma after last argument --- bot/cogs/moderation/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 325b9567a..044c6f8d1 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -58,7 +58,7 @@ async def post_infraction( reason: str, expires_at: datetime = None, hidden: bool = False, - active: bool = True, + active: bool = True ) -> t.Optional[dict]: """Posts an infraction to the API.""" log.trace(f"Posting {infr_type} infraction for {user} to the API.") -- cgit v1.2.3 From 2798e2db4e37f538b94bd84fee473d4b31504f17 Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Mon, 16 Dec 2019 01:59:50 -0300 Subject: Add `post_user` function to POST a new user to the DB As it is now, this function is planned to be used a big-helper in `post_infraction`. Its interface is partially similar: it will return a "JSON" dictionary if everything went well, or `None` if it failed. If it fails, it will send a message to the channel and register the issue in the `log`. --- bot/cogs/moderation/utils.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 044c6f8d1..81a975ba4 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -31,6 +31,39 @@ Infraction = t.Dict[str, t.Union[str, int, bool]] Expiry = t.Union[Duration, ISODateTime] +async def post_user(ctx: Context, user: discord.User) -> t.Optional[dict]: + """ + Create a new user in the database. + + Used when an infraction needs to be applied on a user absent in the guild. + """ + log.warn("Attempting to add user to the database.") + + payload = { + 'avatar_hash': user.avatar, + 'discriminator': int(user.discriminator), + 'id': user.id, + 'in_guild': False, + 'name': user.name, + 'roles': [] + } + + try: + response = await ctx.bot.api_client.post('bot/users', json=payload) + except ResponseCodeError: + # TODO: Add details, and specific information per possible situation. + # Potential race condition if someone joins and the bot syncs before the API replies! + log.info("Couldn't post user.") + # TODO: Rewrite message. + await ctx.send("Tried to post user to the DB, couldn't be done.") + + return + + return response + + +# TODO: maybe delete proxy_user + def proxy_user(user_id: str) -> discord.Object: """ Create a proxy user object from the given id. -- cgit v1.2.3 From 447de16b5c62a6d473abb4bf1c61b34f8d5e0f26 Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Mon, 16 Dec 2019 02:11:55 -0300 Subject: Make post_infraction try to `post_user` if user doesn't exist Try twice to apply the infraction. If the user is not in the database, try to add it, then try to apply the infraction again. This allows any moderation function that uses `FetchedUser` as a converter to apply the infraction even when the user is absent in the local database. --- bot/cogs/moderation/infractions.py | 4 +--- bot/cogs/moderation/utils.py | 33 ++++++++++++++++----------------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 3536a3d38..253a8db5b 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -13,12 +13,10 @@ from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check from . import utils from .scheduler import InfractionScheduler -from .utils import MemberObject +from .utils import MemberObject, UserTypes as MemberConverter log = logging.getLogger(__name__) -MemberConverter = t.Union[utils.UserTypes, utils.proxy_user] - class Infractions(InfractionScheduler, commands.Cog): """Apply and pardon infractions on users for moderation purposes.""" diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 81a975ba4..994bdeaa3 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -9,7 +9,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.constants import Colours, Icons -from bot.converters import Duration, ISODateTime +from bot.converters import Duration, FetchedUser, ISODateTime log = logging.getLogger(__name__) @@ -25,7 +25,7 @@ INFRACTION_ICONS = { RULES_URL = "https://pythondiscord.com/pages/rules" APPEALABLE_INFRACTIONS = ("ban", "mute") -UserTypes = t.Union[discord.Member, discord.User] +UserTypes = t.Union[discord.Member, discord.User, FetchedUser] MemberObject = t.Union[UserTypes, discord.Object] Infraction = t.Dict[str, t.Union[str, int, bool]] Expiry = t.Union[Duration, ISODateTime] @@ -107,22 +107,21 @@ async def post_infraction( if expires_at: payload['expires_at'] = expires_at.isoformat() - try: - response = await ctx.bot.api_client.post('bot/infractions', json=payload) - except ResponseCodeError as exp: - if exp.status == 400 and 'user' in exp.response_json: - log.info( - f"{ctx.author} tried to add a {infr_type} infraction to `{user.id}`, " - "but that user id was not found in the database." - ) - await ctx.send( - f":x: Cannot add infraction, the specified user is not known to the database." - ) - return + # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. + for attempt in range(2): + try: + response = await ctx.bot.api_client.post('bot/infractions', json=payload) + except ResponseCodeError as exp: + if exp.status == 400 and 'user'in exp.response_json: + # Only once attempt to try to add the user to the database, not two: + if attempt > 0 or await post_user(ctx, user) is None: + return + else: + log.exception("An unexpected ResponseCodeError occurred while adding an infraction:") + await ctx.send(":x: There was an error adding the infraction.") + return else: - log.exception("An unexpected ResponseCodeError occurred while adding an infraction:") - await ctx.send(":x: There was an error adding the infraction.") - return + break return response -- cgit v1.2.3 From c469b82144eacca992647f8321992d66e6bad3c8 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 17 Dec 2019 23:18:00 +0100 Subject: Use on_user_update to properly sync users with db It's important to us that we keep the information we have about users in the database in sync with the actual user information the bot can observe in our guild. To do this, we relied on the `on_member_update` event listener to synchronize a user's information when an update of the information was detected. However, unfortunately, this does not work for user account information (i.e., the username, avatar, and discriminator of the user). The solution is to use the `on_user_update` event listener to watch for updates in the user settings and to use the `on_member_update` event listener to watch for updates in guild-related information for that user. (We currently only sync the roles the user has.) See: - https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_update - https://discordpy.readthedocs.io/en/stable/api.html#discord.on_user_update Note: The docs for `discord.py` make it *seem* like the `on_member_update` event does not fire for updates of theusername, discriminator, and avatar attributes. However, experimentation shows that this event *does* fire; it's just that the member objects provided as `before` and `after` to the listener will already have been updated in cache by the `on_user_update` event that fires *before* it. This means that if the only changes made were to the username, avatar, and discriminator, the `on_member_update` event does fire, but with two *equal* Member objects. This makes it appear as if you may be able to use `on_member_update`, since it fires, but it does not actually contain anything useful. --- bot/cogs/sync/cog.py | 55 +++++++++++++++++++++++++--------------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 90d4c40fe..4e6ed156b 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,7 +1,7 @@ import logging -from typing import Callable, Iterable +from typing import Callable, Dict, Iterable, Union -from discord import Guild, Member, Role +from discord import Guild, Member, Role, User from discord.ext import commands from discord.ext.commands import Cog, Context @@ -51,6 +51,15 @@ class Sync(Cog): f"deleted `{total_deleted}`." ) + async def patch_user(self, user_id: int, updated_information: Dict[str, Union[str, int]]) -> 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) + except ResponseCodeError as e: + if e.response.status != 404: + raise + log.warning("Unable to update user, got 404. Assuming race condition from join event.") + @Cog.listener() async def on_guild_role_create(self, role: Role) -> None: """Adds newly create role to the database table over the API.""" @@ -143,33 +152,21 @@ class Sync(Cog): @Cog.listener() async def on_member_update(self, before: Member, after: Member) -> None: - """Updates the user information if any of relevant attributes have changed.""" - if ( - before.name != after.name - or before.avatar != after.avatar - or before.discriminator != after.discriminator - or before.roles != after.roles - ): - try: - await self.bot.api_client.put( - 'bot/users/' + str(after.id), - json={ - 'avatar_hash': after.avatar, - 'discriminator': int(after.discriminator), - 'id': after.id, - 'in_guild': True, - 'name': after.name, - 'roles': sorted(role.id for role in after.roles) - } - ) - except ResponseCodeError as e: - if e.response.status != 404: - raise - - log.warning( - "Unable to update user, got 404. " - "Assuming race condition from join event." - ) + """Update the roles of the member in the database if a change is detected.""" + if before.roles != after.roles: + updated_information = {"roles": sorted(role.id for role in after.roles)} + await self.patch_user(after.id, updated_information=updated_information) + + @Cog.listener() + async def on_user_update(self, before: User, after: User) -> None: + """Update the user information in the database if a relevant change is detected.""" + if any(getattr(before, attr) != getattr(after, attr) for attr in ("name", "discriminator", "avatar")): + updated_information = { + "name": after.name, + "discriminator": int(after.discriminator), + "avatar_hash": after.avatar, + } + await self.patch_user(after.id, updated_information=updated_information) @commands.group(name='sync') @commands.has_permissions(administrator=True) -- cgit v1.2.3 From 4191bf859d726e3e605c62472bb2ce2bb8a419e2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 18 Dec 2019 08:55:26 -0800 Subject: Constants: add voice state emotes --- bot/constants.py | 4 ++++ config-default.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index b96f23f83..725792516 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -325,6 +325,10 @@ class Icons(metaclass=YAMLGetter): superstarify: str unsuperstarify: str + voice_state_blue: str + voice_state_green: str + voice_state_red: str + class CleanMessages(metaclass=YAMLGetter): section = "bot" diff --git a/config-default.yml b/config-default.yml index 8032b50a4..ae09db66a 100644 --- a/config-default.yml +++ b/config-default.yml @@ -92,6 +92,10 @@ style: superstarify: "https://cdn.discordapp.com/emojis/636288153044516874.png" unsuperstarify: "https://cdn.discordapp.com/emojis/636288201258172446.png" + voice_state_blue: "https://cdn.discordapp.com/emojis/656899769662439456.png" + voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png" + voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png" + guild: id: 267624335836053506 -- cgit v1.2.3 From adcafc3a2adeffc5998274d9dc73728178d64721 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 18 Dec 2019 09:13:09 -0800 Subject: ModLog: change voice state embed icon and colour Use a red icon when leaving or mute/deafened. Use a green icon when joining or unmuted/undeafened. Use a blue icon when changing channels or any other possible change. --- bot/cogs/moderation/modlog.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 98d9c546f..8dea44efe 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -782,15 +782,32 @@ class ModLog(Cog, name="ModLog"): # "overwrite" values_changed; in practice there will never even be anything to overwrite. diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})} + icon = Icons.voice_state_blue + colour = Colour.blurple() changes = [] + for attr, values in diff_values.items(): - if not attr: # Not sure why, but it happens + if not attr: # Not sure why, but it happens. continue - attr = attr[5:] # Remove "root." prefix + old = values["old_value"] + new = values["new_value"] + + attr = attr[5:] # Remove "root." prefix. attr = VOICE_STATE_ATTRIBUTES.get(attr, attr.replace("_", " ").capitalize()) - changes.append(f"**{attr}:** `{values['old_value']}` **→** `{values['new_value']}`") + changes.append(f"**{attr}:** `{old}` **→** `{new}`") + + # Set the embed icon and colour depending on which attribute changed. + if any(name in attr for name in ("Channel", "deaf", "mute")): + if new is None or new is True: + # Left a channel or was muted/deafened. + icon = Icons.voice_state_red + colour = Colours.soft_red + elif old is None or old is True: + # Joined a channel or was unmuted/undeafened. + icon = Icons.voice_state_green + colour = Colours.soft_green if not changes: return @@ -799,8 +816,8 @@ class ModLog(Cog, name="ModLog"): message = f"**{member}** (`{member.id}`)\n{message}" await self.send_log_message( - icon_url=Icons.user_update, - colour=Colour.blurple(), + icon_url=icon, + colour=colour, title="Voice state updated", text=message, thumbnail=member.avatar_url_as(static_format="png"), -- cgit v1.2.3 From 8210d839225d58e846260addcf9e298c04391bd8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 18 Dec 2019 09:28:18 -0800 Subject: ModLog: support self_stream voice state This feature will be available in discord.py 1.3. --- bot/cogs/moderation/modlog.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 8dea44efe..8509e9c07 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -25,7 +25,11 @@ CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status", "nick") ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") -VOICE_STATE_ATTRIBUTES = {"self_video": "Broadcasting", "channel.name": "Channel"} +VOICE_STATE_ATTRIBUTES = { + "channel.name": "Channel", + "self_stream": "Streaming", + "self_video": "Broadcasting", +} class ModLog(Cog, name="ModLog"): -- cgit v1.2.3 From c649b3b048e61e535bead99c4bbab25f2a82a66f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 19 Dec 2019 14:56:53 -0800 Subject: Moderation: explicitly set active value when posting infractions Required due to changes in the API making the active field required. See python-discord/site/pull/317 --- bot/cogs/moderation/infractions.py | 6 +++--- bot/cogs/moderation/superstarify.py | 2 +- bot/cogs/watchchannels/bigbrother.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 3536a3d38..fcfde1e68 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -203,7 +203,7 @@ class Infractions(InfractionScheduler, commands.Cog): if await utils.has_active_infraction(ctx, user, "mute"): return - infraction = await utils.post_infraction(ctx, user, "mute", reason, **kwargs) + infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) if infraction is None: return @@ -220,7 +220,7 @@ class Infractions(InfractionScheduler, commands.Cog): @respect_role_hierarchy() async def apply_kick(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: """Apply a kick infraction with kwargs passed to `post_infraction`.""" - infraction = await utils.post_infraction(ctx, user, "kick", reason, **kwargs) + infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) if infraction is None: return @@ -235,7 +235,7 @@ class Infractions(InfractionScheduler, commands.Cog): if await utils.has_active_infraction(ctx, user, "ban"): return - infraction = await utils.post_infraction(ctx, user, "ban", reason, **kwargs) + infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) if infraction is None: return diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 7631d9bbe..1e19e943e 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -133,7 +133,7 @@ class Superstarify(InfractionScheduler, Cog): # Post the infraction to the API reason = reason or f"old nick: {member.display_name}" - infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration) + infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) id_ = infraction["id"] old_nick = member.display_name diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 306ed4c64..fbee4f5d7 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -65,7 +65,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(":x: The specified user is already being watched.") return - response = await post_infraction(ctx, user, 'watch', reason, hidden=True) + response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True) if response is not None: self.watched_users[user.id] = response -- cgit v1.2.3 From e9ed6442e01f78f27ec7404759bb45999d8b8104 Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Fri, 20 Dec 2019 02:15:32 -0300 Subject: Refactor minor details in `post_infraction` --- bot/cogs/moderation/utils.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 994bdeaa3..1b683b4a3 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -108,13 +108,14 @@ async def post_infraction( payload['expires_at'] = expires_at.isoformat() # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. - for attempt in range(2): + for should_post_user in (True, False): try: response = await ctx.bot.api_client.post('bot/infractions', json=payload) + return response except ResponseCodeError as exp: if exp.status == 400 and 'user'in exp.response_json: - # Only once attempt to try to add the user to the database, not two: - if attempt > 0 or await post_user(ctx, user) is None: + # Only one attempt to add the user to the database, not two: + if not should_post_user or await post_user(ctx, user) is None: return else: log.exception("An unexpected ResponseCodeError occurred while adding an infraction:") @@ -123,8 +124,6 @@ async def post_infraction( else: break - return response - async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str) -> bool: """Checks if a user already has an active infraction of the given type.""" -- cgit v1.2.3 From 98096a4ada2d4d27edac24a606c04e74fb4dc45b Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Fri, 20 Dec 2019 02:28:34 -0300 Subject: Give `post_user` default values for `payload` if absent in `user` Now `post_user(...)` expects either a `discord.User` or a `discord.Object` object as `user`. Either way, it will try to take the relevant attributes from `user` to fill the DB columns. If it can't be done, `.avatar_hash`, `.discriminator`, and `name` will take default values. --- bot/cogs/moderation/utils.py | 45 +++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 1b683b4a3..0674e8d45 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -31,38 +31,45 @@ Infraction = t.Dict[str, t.Union[str, int, bool]] Expiry = t.Union[Duration, ISODateTime] -async def post_user(ctx: Context, user: discord.User) -> t.Optional[dict]: +async def post_user(ctx: Context, user: t.Union[discord.User, discord.Object]) -> t.Optional[dict]: """ Create a new user in the database. Used when an infraction needs to be applied on a user absent in the guild. """ - log.warn("Attempting to add user to the database.") + log.trace("Attempting to add user to the database.") - payload = { - 'avatar_hash': user.avatar, - 'discriminator': int(user.discriminator), - 'id': user.id, - 'in_guild': False, - 'name': user.name, - 'roles': [] - } + try: + payload = { + 'avatar_hash': user.avatar, + 'discriminator': int(user.discriminator), + 'id': user.id, + 'in_guild': False, + 'name': user.name, + 'roles': [] + } + except AttributeError: + log.trace("Couldn't take all the attributes for the user payload, taking just its ID.") + # XXX: Not sure if these default values are ideal. + payload = { + 'avatar_hash': 0, + 'discriminator': 0, + 'id': user.id, + 'in_guild': False, + 'name': 'Some name', + 'roles': [] + } try: response = await ctx.bot.api_client.post('bot/users', json=payload) - except ResponseCodeError: + return response + except ResponseCodeError as e: # TODO: Add details, and specific information per possible situation. # Potential race condition if someone joins and the bot syncs before the API replies! log.info("Couldn't post user.") - # TODO: Rewrite message. - await ctx.send("Tried to post user to the DB, couldn't be done.") - - return - - return response - + # NOTE: `e.status` is probably not enough for a good message + await ctx.send(f"The attempt to add the user to the DB failed: {e.status}") -# TODO: maybe delete proxy_user def proxy_user(user_id: str) -> discord.Object: """ -- cgit v1.2.3 From 64f2c23f862f78095069f44cc7762ebd1408b31c Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Fri, 20 Dec 2019 02:30:25 -0300 Subject: Make `FetchedUser` return a `discord.Object` if user *may* exist The FetchedUser Converter now counts with a `proxy_user` helper function (which SHOULD NOT be there) to return a user as a last resource, in case there was an issue fetching from the Discord API, as long as the error isn't that there's no user with the given ID. --- bot/converters.py | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index fbe9ecd90..b33229cc7 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -281,22 +281,50 @@ class ISODateTime(Converter): class FetchedUser(Converter): - """Fetches from the Discord API and returns a `discord.User` object.""" + """ + Fetches from the Discord API and returns a `discord.User` or `discord.Object` object, given an ID. + + If the fetching is successful, a `discord.User` object is returned. If it fails and + the error doesn't imply the user doesn't exist, then a `discord.Object` is returned + via the `user_proxy` function. + """ + + # XXX: `proxy_user` shouldn't be here as a helper. + # Should wait for PR #651 to import from bot.utils.whatever, maybe? + @staticmethod + def proxy_user(user_id: str) -> discord.Object: + """ + Create a proxy user object from the given id. + + Used when a Member or User object cannot be resolved. + """ + log.trace(f"Attempting to create a proxy user for the user id {user_id}.") + + try: + user_id = int(user_id) + except ValueError: + raise BadArgument + + user = discord.Object(user_id) + user.mention = user.id + user.avatar_url_as = lambda static_format: None + + return user @staticmethod - async def convert(ctx: Context, user_id: str) -> discord.User: - """Converts `user_id` to a `discord.User` object, after fetching from the Discord API.""" - # TODO: add docstring - # TODO: add remaining exceptions + async def convert(ctx: Context, user_id: str) -> t.Union[discord.User, discord.Object]: + """Convert `user_id` to a `discord.User` object, after fetching from the Discord API.""" try: user_id = int(user_id) user = await ctx.bot.fetch_user(user_id) except ValueError: raise BadArgument(f"The provided argument can't be turned into integer: `{user_id}`") except discord.HTTPException as e: - # If the Discord error isn't Unknown user, save it in the log + # If the Discord error isn't `Unknown user`, save it in the log and return a proxy if e.code != 10013: - log.warning(f"Failed to fetch user:") + log.warning("Failed to fetch user, returning a proxy instead.") + # XXX: + return FetchedUser.proxy_user(user_id) raise BadArgument return user -- cgit v1.2.3 From 8e9d19c7b73f44dac9482302bc34bc737a2fe3ed Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Fri, 20 Dec 2019 03:09:25 -0300 Subject: Make post_user take default values for payload from `getattr` --- bot/cogs/moderation/utils.py | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 0674e8d45..604ce5ce4 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -39,26 +39,18 @@ async def post_user(ctx: Context, user: t.Union[discord.User, discord.Object]) - """ log.trace("Attempting to add user to the database.") - try: - payload = { - 'avatar_hash': user.avatar, - 'discriminator': int(user.discriminator), - 'id': user.id, - 'in_guild': False, - 'name': user.name, - 'roles': [] - } - except AttributeError: - log.trace("Couldn't take all the attributes for the user payload, taking just its ID.") - # XXX: Not sure if these default values are ideal. - payload = { - 'avatar_hash': 0, - 'discriminator': 0, - 'id': user.id, - 'in_guild': False, - 'name': 'Some name', - 'roles': [] - } + if not isinstance(user, discord.User): + log.warn("The given user is not a discord.User object.") + + # XXX: Not sure if these default values are ideal. + payload = { + 'avatar_hash': getattr(user, 'avatar', 0), + 'discriminator': int(getattr(user, 'discriminator', 0)), + 'id': user.id, + 'in_guild': False, + 'name': getattr(user, 'name', 'No name'), + 'roles': [] + } try: response = await ctx.bot.api_client.post('bot/users', json=payload) -- cgit v1.2.3 From 3d6113e0eef031f0788a64de46395f3a4af4f921 Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Fri, 20 Dec 2019 23:04:07 -0300 Subject: Move `utils.proxy_user` to Converters and do minor refactoring The `proxy_user` function now belongs to the `Converters` module, since its use is directly related to it. `FetchedUser` uses this function if there's an error trying to fetch and it doesn't indicate a non existing user. Technically finished and working. --- bot/cogs/moderation/management.py | 4 ++-- bot/cogs/moderation/utils.py | 33 +++++---------------------- bot/converters.py | 47 ++++++++++++++++++--------------------- 3 files changed, 29 insertions(+), 55 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 9605d47b2..fff86e9ea 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -10,7 +10,7 @@ from discord.ext.commands import Context from bot import constants from bot.bot import Bot -from bot.converters import InfractionSearchQuery, allowed_strings +from bot.converters import InfractionSearchQuery, allowed_strings, proxy_user from bot.pagination import LinePaginator from bot.utils import time from bot.utils.checks import in_channel_check, with_role_check @@ -20,7 +20,7 @@ from .modlog import ModLog log = logging.getLogger(__name__) -UserConverter = t.Union[discord.User, utils.proxy_user] +UserConverter = t.Union[discord.User, proxy_user] class ModManagement(commands.Cog): diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 604ce5ce4..5b5aacb16 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -4,7 +4,6 @@ import typing as t from datetime import datetime import discord -from discord.ext import commands from discord.ext.commands import Context from bot.api import ResponseCodeError @@ -42,45 +41,23 @@ async def post_user(ctx: Context, user: t.Union[discord.User, discord.Object]) - if not isinstance(user, discord.User): log.warn("The given user is not a discord.User object.") - # XXX: Not sure if these default values are ideal. payload = { 'avatar_hash': getattr(user, 'avatar', 0), 'discriminator': int(getattr(user, 'discriminator', 0)), 'id': user.id, 'in_guild': False, - 'name': getattr(user, 'name', 'No name'), + 'name': getattr(user, 'name', 'Name unknown'), 'roles': [] } try: response = await ctx.bot.api_client.post('bot/users', json=payload) + log.trace(f"User {user.id} added to the DB.") return response except ResponseCodeError as e: - # TODO: Add details, and specific information per possible situation. - # Potential race condition if someone joins and the bot syncs before the API replies! - log.info("Couldn't post user.") - # NOTE: `e.status` is probably not enough for a good message - await ctx.send(f"The attempt to add the user to the DB failed: {e.status}") - - -def proxy_user(user_id: str) -> discord.Object: - """ - Create a proxy user object from the given id. - - Used when a Member or User object cannot be resolved. - """ - log.trace(f"Attempting to create a proxy user for the user id {user_id}.") - - try: - user_id = int(user_id) - except ValueError: - raise commands.BadArgument - - user = discord.Object(user_id) - user.mention = user.id - user.avatar_url_as = lambda static_format: None - - return user + log.warn("Couldn't post user.") + await ctx.send("The attempt to add the user to the DB failed: " + f"{e.status}, {e.response_text if e.response_text else 'no message received'}.") async def post_infraction( diff --git a/bot/converters.py b/bot/converters.py index b33229cc7..28bf58cf4 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -280,36 +280,34 @@ class ISODateTime(Converter): return dt -class FetchedUser(Converter): +def proxy_user(user_id: str) -> discord.Object: """ - Fetches from the Discord API and returns a `discord.User` or `discord.Object` object, given an ID. + Create a proxy user object from the given id. - If the fetching is successful, a `discord.User` object is returned. If it fails and - the error doesn't imply the user doesn't exist, then a `discord.Object` is returned - via the `user_proxy` function. + Used when a Member or User object cannot be resolved. """ + log.trace(f"Attempting to create a proxy user for the user id {user_id}.") - # XXX: `proxy_user` shouldn't be here as a helper. - # Should wait for PR #651 to import from bot.utils.whatever, maybe? - @staticmethod - def proxy_user(user_id: str) -> discord.Object: - """ - Create a proxy user object from the given id. + try: + user_id = int(user_id) + except ValueError: + raise BadArgument - Used when a Member or User object cannot be resolved. - """ - log.trace(f"Attempting to create a proxy user for the user id {user_id}.") + user = discord.Object(user_id) + user.mention = user.id + user.avatar_url_as = lambda static_format: None - try: - user_id = int(user_id) - except ValueError: - raise BadArgument + return user - user = discord.Object(user_id) - user.mention = user.id - user.avatar_url_as = lambda static_format: None - return user +class FetchedUser(Converter): + """ + Fetches from the Discord API and returns a `discord.User` or `discord.Object` object, given an ID. + + If the fetching is successful, a `discord.User` object is returned. If it fails and + the error doesn't imply the user doesn't exist, then a `discord.Object` is returned + via the `user_proxy` function. + """ @staticmethod async def convert(ctx: Context, user_id: str) -> t.Union[discord.User, discord.Object]: @@ -320,11 +318,10 @@ class FetchedUser(Converter): except ValueError: raise BadArgument(f"The provided argument can't be turned into integer: `{user_id}`") except discord.HTTPException as e: - # If the Discord error isn't `Unknown user`, save it in the log and return a proxy + # If the Discord error isn't `Unknown user`, save it in the log and return a proxy instead if e.code != 10013: log.warning("Failed to fetch user, returning a proxy instead.") - # XXX: - return FetchedUser.proxy_user(user_id) + return proxy_user(user_id) raise BadArgument return user -- cgit v1.2.3 From f1689cc8a193747ba0e048db3fa5d8b2c52a1ac7 Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Fri, 20 Dec 2019 23:14:49 -0300 Subject: "Correct indentation style" --- bot/cogs/moderation/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 5b5aacb16..3b39b52ac 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -56,8 +56,10 @@ async def post_user(ctx: Context, user: t.Union[discord.User, discord.Object]) - return response except ResponseCodeError as e: log.warn("Couldn't post user.") - await ctx.send("The attempt to add the user to the DB failed: " - f"{e.status}, {e.response_text if e.response_text else 'no message received'}.") + await ctx.send( + "The attempt to add the user to the DB failed: " + f"{e.status}, {e.response_text if e.response_text else 'no message received'}." + ) async def post_infraction( -- cgit v1.2.3 From 32a3fdff0adce1ae76ad6915f5cf6119dde04621 Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Sun, 22 Dec 2019 03:36:40 -0300 Subject: Correct log messages levels, add log messages and ctx.send message --- bot/cogs/moderation/utils.py | 10 +++++----- bot/converters.py | 9 +++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 3b39b52ac..8328e4c85 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -36,10 +36,10 @@ async def post_user(ctx: Context, user: t.Union[discord.User, discord.Object]) - Used when an infraction needs to be applied on a user absent in the guild. """ - log.trace("Attempting to add user to the database.") + log.trace(f"Attempting to add user {user.id} to the database.") if not isinstance(user, discord.User): - log.warn("The given user is not a discord.User object.") + log.warn("The user being added to the DB is not a Member or User object.") payload = { 'avatar_hash': getattr(user, 'avatar', 0), @@ -52,12 +52,12 @@ async def post_user(ctx: Context, user: t.Union[discord.User, discord.Object]) - try: response = await ctx.bot.api_client.post('bot/users', json=payload) - log.trace(f"User {user.id} added to the DB.") + log.info(f"User {user.id} added to the DB.") return response except ResponseCodeError as e: - log.warn("Couldn't post user.") + log.error(f"Failed to add user {user.id} to the DB. {e}") await ctx.send( - "The attempt to add the user to the DB failed: " + ":x: The attempt to add the user to the DB failed: " f"{e.status}, {e.response_text if e.response_text else 'no message received'}." ) diff --git a/bot/converters.py b/bot/converters.py index 28bf58cf4..5b33f6818 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -291,7 +291,8 @@ def proxy_user(user_id: str) -> discord.Object: try: user_id = int(user_id) except ValueError: - raise BadArgument + log.debug(f"Failed to create proxy user {user_id}: could not convert to int.") + raise BadArgument(f"User ID `{user_id}` is invalid - could not convert to an integer.") user = discord.Object(user_id) user.mention = user.id @@ -314,14 +315,18 @@ class FetchedUser(Converter): """Convert `user_id` to a `discord.User` object, after fetching from the Discord API.""" try: user_id = int(user_id) + log.trace(f"Fetching user {user_id}...") user = await ctx.bot.fetch_user(user_id) except ValueError: + log.debug(f"Failed to fetch user {user_id}: could not convert to int.") raise BadArgument(f"The provided argument can't be turned into integer: `{user_id}`") except discord.HTTPException as e: # If the Discord error isn't `Unknown user`, save it in the log and return a proxy instead if e.code != 10013: - log.warning("Failed to fetch user, returning a proxy instead.") + log.warning(f"Failed to fetch user, returning a proxy instead: status {e.status}") return proxy_user(user_id) + + log.debug(f"Failed to fetch user {user_id}: user does not exist.") raise BadArgument return user -- cgit v1.2.3 From 0fa6c443b4554b526fff5f1e80307b615cea900f Mon Sep 17 00:00:00 2001 From: manusaurio Date: Sun, 22 Dec 2019 03:45:15 -0300 Subject: Add space in condition Co-Authored-By: Mark --- bot/cogs/moderation/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 8328e4c85..d0eaba7c8 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -91,7 +91,7 @@ async def post_infraction( response = await ctx.bot.api_client.post('bot/infractions', json=payload) return response except ResponseCodeError as exp: - if exp.status == 400 and 'user'in exp.response_json: + if exp.status == 400 and 'user' in exp.response_json: # Only one attempt to add the user to the database, not two: if not should_post_user or await post_user(ctx, user) is None: return -- cgit v1.2.3 From 3747b8b56dc754ad9f7434e9a5d437133d70b4f3 Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Sun, 22 Dec 2019 03:51:48 -0300 Subject: Add descriptive message to exception if user from API doesn't exist --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 5b33f6818..028e7cb02 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -327,6 +327,6 @@ class FetchedUser(Converter): return proxy_user(user_id) log.debug(f"Failed to fetch user {user_id}: user does not exist.") - raise BadArgument + raise BadArgument(f"User `{user_id}` does not exist") return user -- cgit v1.2.3 From 0ec0ca1607d81eccd81c458827637eeedc31e9f7 Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Sun, 22 Dec 2019 13:51:15 -0300 Subject: Minor refactor moving `return ...` --- bot/converters.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 028e7cb02..aa2fa3cc2 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -316,7 +316,7 @@ class FetchedUser(Converter): try: user_id = int(user_id) log.trace(f"Fetching user {user_id}...") - user = await ctx.bot.fetch_user(user_id) + return await ctx.bot.fetch_user(user_id) except ValueError: log.debug(f"Failed to fetch user {user_id}: could not convert to int.") raise BadArgument(f"The provided argument can't be turned into integer: `{user_id}`") @@ -328,5 +328,3 @@ class FetchedUser(Converter): log.debug(f"Failed to fetch user {user_id}: user does not exist.") raise BadArgument(f"User `{user_id}` does not exist") - - return user -- cgit v1.2.3 From fcce6b5a7d56fddd0d015408257980b1bc56e4de Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Sun, 22 Dec 2019 14:08:35 -0300 Subject: Move type declarations for annotations and converters It turns out how it was originally was the best idea. Now the `infractions` module imports `FetchedUser` and makes a `typing.Union` between it and `utils.UserTypes`. The usage of `FetchedUser` isn't needed in `utils` at all, and it shouldn't be used for/as type hinting there. --- bot/cogs/moderation/infractions.py | 5 ++++- bot/cogs/moderation/utils.py | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 253a8db5b..5b6a63dbb 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -9,14 +9,17 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event +from bot.converters import FetchedUser from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check from . import utils from .scheduler import InfractionScheduler -from .utils import MemberObject, UserTypes as MemberConverter +from .utils import MemberObject, UserTypes log = logging.getLogger(__name__) +MemberConverter = t.Union[UserTypes, FetchedUser] + class Infractions(InfractionScheduler, commands.Cog): """Apply and pardon infractions on users for moderation purposes.""" diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index d0eaba7c8..d564d0699 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -8,7 +8,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.constants import Colours, Icons -from bot.converters import Duration, FetchedUser, ISODateTime +from bot.converters import Duration, ISODateTime log = logging.getLogger(__name__) @@ -24,13 +24,13 @@ INFRACTION_ICONS = { RULES_URL = "https://pythondiscord.com/pages/rules" APPEALABLE_INFRACTIONS = ("ban", "mute") -UserTypes = t.Union[discord.Member, discord.User, FetchedUser] +UserTypes = t.Union[discord.Member, discord.User] MemberObject = t.Union[UserTypes, discord.Object] Infraction = t.Dict[str, t.Union[str, int, bool]] Expiry = t.Union[Duration, ISODateTime] -async def post_user(ctx: Context, user: t.Union[discord.User, discord.Object]) -> t.Optional[dict]: +async def post_user(ctx: Context, user: MemberObject) -> t.Optional[dict]: """ Create a new user in the database. -- cgit v1.2.3 From 388cf752c0e41695644f532d5c4903bd821f0c29 Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Sun, 22 Dec 2019 14:14:00 -0300 Subject: Fix bug to log if `user` is either `Member` or `User` --- bot/cogs/moderation/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index d564d0699..6c28d8db5 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -38,7 +38,7 @@ async def post_user(ctx: Context, user: MemberObject) -> t.Optional[dict]: """ log.trace(f"Attempting to add user {user.id} to the database.") - if not isinstance(user, discord.User): + if not isinstance(user, (discord.Member, discord.User)): log.warn("The user being added to the DB is not a Member or User object.") payload = { -- cgit v1.2.3 From 6d42e1e3f33f595b264b17b4ab1d260f956051aa Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Sun, 22 Dec 2019 15:30:08 -0300 Subject: Make `watchchannels` use `FetchedUser` instead of `proxy_user` This changes also removes the original `proxy_user` used by `watchchannels` the attributes in its `discord.Object` object to the one returned by FetchedUser. --- bot/cogs/alias.py | 17 +++++++++++------ bot/cogs/watchchannels/bigbrother.py | 7 ++++--- bot/cogs/watchchannels/talentpool.py | 9 +++++---- bot/cogs/watchchannels/watchchannel.py | 20 ++------------------ bot/converters.py | 2 ++ 5 files changed, 24 insertions(+), 31 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index c1db38462..03c49c2f4 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -7,8 +7,7 @@ from discord.ext.commands import Cog, Command, Context, clean_content, command, from bot.bot import Bot from bot.cogs.extensions import Extension -from bot.cogs.watchchannels.watchchannel import proxy_user -from bot.converters import TagNameConverter +from bot.converters import FetchedUser, TagNameConverter from bot.pagination import LinePaginator log = logging.getLogger(__name__) @@ -61,12 +60,18 @@ class Alias (Cog): await self.invoke(ctx, "site tools") @command(name="watch", hidden=True) - async def bigbrother_watch_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + async def bigbrother_watch_alias( + self, + ctx: Context, + user: Union[Member, User, FetchedUser], + *, + reason: str + ) -> None: """Alias for invoking bigbrother watch [user] [reason].""" await self.invoke(ctx, "bigbrother watch", user, reason=reason) @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + async def bigbrother_unwatch_alias(self, ctx: Context, user: Union[User, FetchedUser], *, reason: str) -> None: """Alias for invoking bigbrother unwatch [user] [reason].""" await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) @@ -132,12 +137,12 @@ class Alias (Cog): await self.invoke(ctx, "docs get", symbol) @command(name="nominate", hidden=True) - async def nomination_add_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + async def nomination_add_alias(self, ctx: Context, user: Union[Member, User, FetchedUser], *, reason: str) -> None: """Alias for invoking talentpool add [user] [reason].""" await self.invoke(ctx, "talentpool add", user, reason=reason) @command(name="unnominate", hidden=True) - async def nomination_end_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + async def nomination_end_alias(self, ctx: Context, user: Union[User, FetchedUser], *, reason: str) -> None: """Alias for invoking nomination end [user] [reason].""" await self.invoke(ctx, "nomination end", user, reason=reason) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 306ed4c64..7a30d5033 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -8,8 +8,9 @@ from discord.ext.commands import Cog, Context, group from bot.bot import Bot from bot.cogs.moderation.utils import post_infraction from bot.constants import Channels, MODERATION_ROLES, Webhooks +from bot.converters import FetchedUser from bot.decorators import with_role -from .watchchannel import WatchChannel, proxy_user +from .watchchannel import WatchChannel log = logging.getLogger(__name__) @@ -46,7 +47,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='watch', aliases=('w',)) @with_role(*MODERATION_ROLES) - async def watch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: Union[User, FetchedUser], *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#big-brother` channel. @@ -93,7 +94,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: Union[User, FetchedUser], *, reason: str) -> None: """Stop relaying messages by the given `user`.""" active_watches = await self.bot.api_client.get( self.api_endpoint, diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index cc8feeeee..62be3bc3b 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -9,10 +9,11 @@ from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks +from bot.converters import FetchedUser from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils import time -from .watchchannel import WatchChannel, proxy_user +from .watchchannel import WatchChannel log = logging.getLogger(__name__) @@ -49,7 +50,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) @with_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: Union[Member, User, FetchedUser], *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#talent-pool` channel. @@ -114,7 +115,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='history', aliases=('info', 'search')) @with_role(*MODERATION_ROLES) - async def history_command(self, ctx: Context, user: Union[User, proxy_user]) -> None: + async def history_command(self, ctx: Context, user: Union[User, FetchedUser]) -> None: """Shows the specified user's nomination history.""" result = await self.bot.api_client.get( self.api_endpoint, @@ -143,7 +144,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='unwatch', aliases=('end', )) @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: Union[User, FetchedUser], *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index bd0622554..eb787b083 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -9,8 +9,8 @@ from typing import Optional import dateutil.parser import discord -from discord import Color, Embed, HTTPException, Message, Object, errors -from discord.ext.commands import BadArgument, Cog, Context +from discord import Color, Embed, HTTPException, Message, errors +from discord.ext.commands import Cog, Context from bot.api import ResponseCodeError from bot.bot import Bot @@ -25,22 +25,6 @@ log = logging.getLogger(__name__) URL_RE = re.compile(r"(https?://[^\s]+)") -def proxy_user(user_id: str) -> Object: - """A proxy user object that mocks a real User instance for when the later is not available.""" - try: - user_id = int(user_id) - except ValueError: - raise BadArgument - - user = Object(user_id) - user.mention = user.id - user.display_name = f"<@{user.id}>" - user.avatar_url_as = lambda static_format: None - user.bot = False - - return user - - @dataclass class MessageHistory: """Represents a watch channel's message history.""" diff --git a/bot/converters.py b/bot/converters.py index aa2fa3cc2..a2e445d74 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -296,7 +296,9 @@ def proxy_user(user_id: str) -> discord.Object: user = discord.Object(user_id) user.mention = user.id + user.display_name = f"<@{user.id}>" user.avatar_url_as = lambda static_format: None + user.bot = False return user -- cgit v1.2.3 From ac2938503da12bbe23d2fac7ab7ce7e2c170c465 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 22 Dec 2019 19:36:08 +0100 Subject: Allow PSD's and more on antimalware whitelist Also includes the following related formats: - .svg - .ai (Adobe Illustrator) - .aep (Adobe After Effects) - .xcf (GIMP --- config-default.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config-default.yml b/config-default.yml index 6ae07da93..4463ea8aa 100644 --- a/config-default.yml +++ b/config-default.yml @@ -362,6 +362,11 @@ anti_malware: - '.png' - '.tiff' - '.wmv' + - '.svg' + - '.psd' # Photoshop + - '.ai' # Illustrator + - '.aep' # After Effects + - '.xcf' # GIMP reddit: -- cgit v1.2.3 From e30cedc63e06ea136947934e579824f9e3b744bf Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Sun, 22 Dec 2019 17:27:42 -0300 Subject: Catch HTTPException in fetching, only fetch if isn't User or Member There's now a check to see if the `user` argument (possibly a `discord.Object`) needs to be made a `User`, instead of doing so directly, to avoid unnecessary requests to the Discord API. Besides that, a possible HTTPException is catched if it the fetch fails, cancelling the message to be send to the user (which would make the following calls fail later on for not being of the proper type.) --- bot/cogs/moderation/scheduler.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 01e4b1fe7..4b3d553c2 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -106,16 +106,20 @@ class InfractionScheduler(Scheduler): # DM the user about the infraction if it's not a shadow/hidden infraction. if not infraction["hidden"]: - # Sometimes user is a discord.Object; make it a proper user. - user = await self.bot.fetch_user(user.id) + dm_result = f"{constants.Emojis.failmail} " + dm_log_text = "\nDM: **Failed**" - # Accordingly display whether the user was successfully notified via DM. - if await utils.notify_infraction(user, infr_type, expiry, reason, icon): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" + # Sometimes user is a discord.Object; make it a proper user. + try: + if not isinstance(user, (discord.Member, discord.User)): + user = await self.bot.fetch_user(user.id) + except discord.HTTPException as e: + log.error(f"Failed to fetch user `{user.id}`: status {e.status}") else: - dm_result = f"{constants.Emojis.failmail} " - dm_log_text = "\nDM: **Failed**" + # Accordingly display whether the user was successfully notified via DM. + if await utils.notify_infraction(user, infr_type, expiry, reason, icon): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" if infraction["actor"] == self.bot.user.id: log.trace( -- cgit v1.2.3 From 442dd13e6042aa06e83934750863b2c567fc009b Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 22 Dec 2019 23:23:56 +0100 Subject: Add ducky constants to make duckpond work I have added the IDs of the new duckies we have to the constants --- config-default.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 4463ea8aa..c64430336 100644 --- a/config-default.yml +++ b/config-default.yml @@ -41,6 +41,12 @@ style: ducky_ninja: &DUCKY_NINJA 637923502535606293 ducky_devil: &DUCKY_DEVIL 637925314982576139 ducky_tube: &DUCKY_TUBE 637881368008851456 + ducky_hunt: &DUCKY_HUNT 639355090909528084 + ducky_wizard: &DUCKY_WIZARD 639355996954689536 + ducky_party: &DUCKY_PARTY 639468753440210977 + ducky_angel: &DUCKY_ANGEL 640121935610511361 + ducky_maul: &DUCKY_MAUL 640137724958867467 + ducky_santa: &DUCKY_SANTA 655360331002019870 upvotes: "<:upvotes:638729835245731840>" comments: "<:comments:638729835073765387>" @@ -405,7 +411,7 @@ redirect_output: duck_pond: threshold: 5 - custom_emojis: [*DUCKY_YELLOW, *DUCKY_BLURPLE, *DUCKY_CAMO, *DUCKY_DEVIL, *DUCKY_NINJA, *DUCKY_REGAL, *DUCKY_TUBE] + 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 9f3efb023c4bed8987f2b90454462ed37cdbb597 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 22 Dec 2019 14:25:36 -0800 Subject: Remove unreachable break in post_infraction loop * Show the user in the post_infraction error log message --- bot/cogs/moderation/utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 6c28d8db5..160ced501 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -96,11 +96,9 @@ async def post_infraction( if not should_post_user or await post_user(ctx, user) is None: return else: - log.exception("An unexpected ResponseCodeError occurred while adding an infraction:") + log.exception(f"Unexpected error while adding an infraction for {user}:") await ctx.send(":x: There was an error adding the infraction.") return - else: - break async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str) -> bool: -- cgit v1.2.3 From 92d6543525bcdda1bb1ecdf6c86a0dd3013b2dda Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 22 Dec 2019 23:26:54 +0100 Subject: Add ducky attributes to bot.constants I have added attributes to the Emojis class in `bot.constants` for the newly added ducky emoji constants. --- bot/constants.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 8815ab983..2c0e3b10b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -270,6 +270,12 @@ class Emojis(metaclass=YAMLGetter): ducky_ninja: int ducky_devil: int ducky_tube: int + ducky_hunt: int + ducky_wizard: int + ducky_party: int + ducky_angel: int + ducky_maul: int + ducky_santa: int upvotes: str comments: str -- cgit v1.2.3 From cf7d4f9c334bb96b3dd95a083bdff12be9e7defe Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 22 Dec 2019 14:29:42 -0800 Subject: Use more specific error message for infraction DM user fetch --- bot/cogs/moderation/scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 4b3d553c2..732091c17 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -114,7 +114,7 @@ class InfractionScheduler(Scheduler): if not isinstance(user, (discord.Member, discord.User)): user = await self.bot.fetch_user(user.id) except discord.HTTPException as e: - log.error(f"Failed to fetch user `{user.id}`: status {e.status}") + log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") else: # Accordingly display whether the user was successfully notified via DM. if await utils.notify_infraction(user, infr_type, expiry, reason, icon): -- cgit v1.2.3 From ae6eefbdcad5dbbd02edc33adba8e481be14fa2b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 22 Dec 2019 14:43:24 -0800 Subject: Show only status code in Discord msg when infraction post fails When debugging, the response_text exceeds the character limit since it's basically an entire HTML document. --- bot/cogs/moderation/utils.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 160ced501..73335ca30 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -56,10 +56,7 @@ async def post_user(ctx: Context, user: MemberObject) -> t.Optional[dict]: return response except ResponseCodeError as e: log.error(f"Failed to add user {user.id} to the DB. {e}") - await ctx.send( - ":x: The attempt to add the user to the DB failed: " - f"{e.status}, {e.response_text if e.response_text else 'no message received'}." - ) + await ctx.send(f":x: The attempt to add the user to the DB failed: status {e.status}") async def post_infraction( @@ -90,14 +87,14 @@ async def post_infraction( try: response = await ctx.bot.api_client.post('bot/infractions', json=payload) return response - except ResponseCodeError as exp: - if exp.status == 400 and 'user' in exp.response_json: + except ResponseCodeError as e: + if e.status == 400 and 'user' in e.response_json: # Only one attempt to add the user to the database, not two: if not should_post_user or await post_user(ctx, user) is None: return else: log.exception(f"Unexpected error while adding an infraction for {user}:") - await ctx.send(":x: There was an error adding the infraction.") + await ctx.send(f":x: There was an error adding the infraction: status {e.status}.") return -- cgit v1.2.3 From 8946f7d05272c26141faaae5f9b4b5f278b269cf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 22 Dec 2019 19:12:01 -0800 Subject: Use log.warning instead of the deprecated log.warn --- bot/cogs/moderation/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 73335ca30..6f7ea9771 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -39,7 +39,7 @@ async def post_user(ctx: Context, user: MemberObject) -> t.Optional[dict]: log.trace(f"Attempting to add user {user.id} to the database.") if not isinstance(user, (discord.Member, discord.User)): - log.warn("The user being added to the DB is not a Member or User object.") + log.warning("The user being added to the DB is not a Member or User object.") payload = { 'avatar_hash': getattr(user, 'avatar', 0), -- cgit v1.2.3 From 562af4fbef7d2b093eb76f563c5f1c635124e299 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 22 Dec 2019 19:15:32 -0800 Subject: Make FetchedUser a subclass of UserConverter --- bot/cogs/alias.py | 17 ++++--------- bot/cogs/moderation/infractions.py | 18 ++++++-------- bot/cogs/moderation/management.py | 4 +-- bot/cogs/watchchannels/bigbrother.py | 6 ++--- bot/cogs/watchchannels/talentpool.py | 9 +++---- bot/converters.py | 48 ++++++++++++++++++++++++------------ 6 files changed, 52 insertions(+), 50 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 03c49c2f4..e709be85d 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -1,8 +1,7 @@ import inspect import logging -from typing import Union -from discord import Colour, Embed, Member, User +from discord import Colour, Embed from discord.ext.commands import Cog, Command, Context, clean_content, command, group from bot.bot import Bot @@ -60,18 +59,12 @@ class Alias (Cog): await self.invoke(ctx, "site tools") @command(name="watch", hidden=True) - async def bigbrother_watch_alias( - self, - ctx: Context, - user: Union[Member, User, FetchedUser], - *, - reason: str - ) -> None: + async def bigbrother_watch_alias(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: """Alias for invoking bigbrother watch [user] [reason].""" await self.invoke(ctx, "bigbrother watch", user, reason=reason) @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx: Context, user: Union[User, FetchedUser], *, reason: str) -> None: + async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: """Alias for invoking bigbrother unwatch [user] [reason].""" await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) @@ -137,12 +130,12 @@ class Alias (Cog): await self.invoke(ctx, "docs get", symbol) @command(name="nominate", hidden=True) - async def nomination_add_alias(self, ctx: Context, user: Union[Member, User, FetchedUser], *, reason: str) -> None: + async def nomination_add_alias(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: """Alias for invoking talentpool add [user] [reason].""" await self.invoke(ctx, "talentpool add", user, reason=reason) @command(name="unnominate", hidden=True) - async def nomination_end_alias(self, ctx: Context, user: Union[User, FetchedUser], *, reason: str) -> None: + async def nomination_end_alias(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: """Alias for invoking nomination end [user] [reason].""" await self.invoke(ctx, "nomination end", user, reason=reason) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 5b6a63dbb..264d8bcf5 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -14,12 +14,10 @@ from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check from . import utils from .scheduler import InfractionScheduler -from .utils import MemberObject, UserTypes +from .utils import MemberObject log = logging.getLogger(__name__) -MemberConverter = t.Union[UserTypes, FetchedUser] - class Infractions(InfractionScheduler, commands.Cog): """Apply and pardon infractions on users for moderation purposes.""" @@ -68,7 +66,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_kick(ctx, user, reason, active=False) @command() - async def ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: + async def ban(self, ctx: Context, user: FetchedUser, *, reason: str = None) -> None: """Permanently ban a user for the given reason.""" await self.apply_ban(ctx, user, reason) @@ -95,7 +93,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_mute(ctx, user, reason, expires_at=duration) @command() - async def tempban(self, ctx: Context, user: MemberConverter, duration: utils.Expiry, *, reason: str = None) -> None: + async def tempban(self, ctx: Context, user: FetchedUser, duration: utils.Expiry, *, reason: str = None) -> None: """ Temporarily ban a user for the given reason and duration. @@ -117,7 +115,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent shadow infractions @command(hidden=True) - async def note(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: + async def note(self, ctx: Context, user: FetchedUser, *, reason: str = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) if infraction is None: @@ -131,7 +129,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_kick(ctx, user, reason, hidden=True, active=False) @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: + async def shadow_ban(self, ctx: Context, user: FetchedUser, *, reason: str = None) -> None: """Permanently ban a user for the given reason without notifying the user.""" await self.apply_ban(ctx, user, reason, hidden=True) @@ -161,7 +159,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def shadow_tempban( self, ctx: Context, - user: MemberConverter, + user: FetchedUser, duration: utils.Expiry, *, reason: str = None @@ -187,12 +185,12 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Remove infractions (un- commands) @command() - async def unmute(self, ctx: Context, user: MemberConverter) -> None: + async def unmute(self, ctx: Context, user: FetchedUser) -> None: """Prematurely end the active mute infraction for the user.""" await self.pardon_infraction(ctx, "mute", user) @command() - async def unban(self, ctx: Context, user: MemberConverter) -> None: + async def unban(self, ctx: Context, user: FetchedUser) -> None: """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index fff86e9ea..2ec69960e 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -20,8 +20,6 @@ from .modlog import ModLog log = logging.getLogger(__name__) -UserConverter = t.Union[discord.User, proxy_user] - class ModManagement(commands.Cog): """Management of infractions.""" @@ -182,7 +180,7 @@ class ModManagement(commands.Cog): await ctx.invoke(self.search_reason, query) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: UserConverter) -> None: + async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions', diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 7a30d5033..b2cb99368 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -1,8 +1,6 @@ import logging from collections import ChainMap -from typing import Union -from discord import User from discord.ext.commands import Cog, Context, group from bot.bot import Bot @@ -47,7 +45,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='watch', aliases=('w',)) @with_role(*MODERATION_ROLES) - async def watch_command(self, ctx: Context, user: Union[User, FetchedUser], *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#big-brother` channel. @@ -94,7 +92,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: Union[User, FetchedUser], *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: """Stop relaying messages by the given `user`.""" active_watches = await self.bot.api_client.get( self.api_endpoint, diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 62be3bc3b..776f0ea87 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -1,9 +1,8 @@ import logging import textwrap from collections import ChainMap -from typing import Union -from discord import Color, Embed, Member, User +from discord import Color, Embed, Member from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError @@ -50,7 +49,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) @with_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: Union[Member, User, FetchedUser], *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#talent-pool` channel. @@ -115,7 +114,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='history', aliases=('info', 'search')) @with_role(*MODERATION_ROLES) - async def history_command(self, ctx: Context, user: Union[User, FetchedUser]) -> None: + async def history_command(self, ctx: Context, user: FetchedUser) -> None: """Shows the specified user's nomination history.""" result = await self.bot.api_client.get( self.api_endpoint, @@ -144,7 +143,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='unwatch', aliases=('end', )) @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: Union[User, FetchedUser], *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. diff --git a/bot/converters.py b/bot/converters.py index a2e445d74..4fb800a01 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -9,7 +9,7 @@ import dateutil.tz import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument, Context, Converter +from discord.ext.commands import BadArgument, Context, Converter, UserConverter log = logging.getLogger(__name__) @@ -303,30 +303,46 @@ def proxy_user(user_id: str) -> discord.Object: return user -class FetchedUser(Converter): +class FetchedUser(UserConverter): """ - Fetches from the Discord API and returns a `discord.User` or `discord.Object` object, given an ID. + Converts to a `discord.User` or, if it fails, a `discord.Object`. - If the fetching is successful, a `discord.User` object is returned. If it fails and - the error doesn't imply the user doesn't exist, then a `discord.Object` is returned - via the `user_proxy` function. + Unlike the default `UserConverter`, which only does lookups via the global user cache, this + converter attempts to fetch the user via an API call to Discord when the using the cache is + unsuccessful. + + If the fetch also fails and the error doesn't imply the user doesn't exist, then a + `discord.Object` is returned via the `user_proxy` converter. + + The lookup strategy is as follows (in order): + + 1. Lookup by ID. + 2. Lookup by mention. + 3. Lookup by name#discrim + 4. Lookup by name + 5. Lookup via API + 6. Create a proxy user with discord.Object """ - @staticmethod - async def convert(ctx: Context, user_id: str) -> t.Union[discord.User, discord.Object]: - """Convert `user_id` to a `discord.User` object, after fetching from the Discord API.""" + async def convert(self, ctx: Context, arg: str) -> t.Union[discord.User, discord.Object]: + """Convert the `arg` to a `discord.User` or `discord.Object`.""" + try: + return await super().convert(ctx, arg) + except BadArgument: + pass + try: - user_id = int(user_id) + user_id = int(arg) log.trace(f"Fetching user {user_id}...") return await ctx.bot.fetch_user(user_id) except ValueError: - log.debug(f"Failed to fetch user {user_id}: could not convert to int.") - raise BadArgument(f"The provided argument can't be turned into integer: `{user_id}`") + log.debug(f"Failed to fetch user {arg}: could not convert to int.") + raise BadArgument(f"The provided argument can't be turned into integer: `{arg}`") except discord.HTTPException as e: - # If the Discord error isn't `Unknown user`, save it in the log and return a proxy instead + # If the Discord error isn't `Unknown user`, return a proxy instead if e.code != 10013: log.warning(f"Failed to fetch user, returning a proxy instead: status {e.status}") - return proxy_user(user_id) + return proxy_user(arg) - log.debug(f"Failed to fetch user {user_id}: user does not exist.") - raise BadArgument(f"User `{user_id}` does not exist") + log.debug(f"Failed to fetch user {arg}: user does not exist.") + raise BadArgument(f"User `{arg}` does not exist") -- cgit v1.2.3 From c0293abbea7a6a6bed66221b40c827d71ca5ee22 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 23 Dec 2019 11:16:05 -0800 Subject: Create an alias for a Member + FetchedUser converter --- bot/cogs/alias.py | 10 +++++----- bot/cogs/moderation/infractions.py | 16 ++++++++-------- bot/cogs/watchchannels/bigbrother.py | 6 +++--- bot/cogs/watchchannels/talentpool.py | 8 ++++---- bot/converters.py | 3 +++ 5 files changed, 23 insertions(+), 20 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index e709be85d..197155456 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -6,7 +6,7 @@ from discord.ext.commands import Cog, Command, Context, clean_content, command, from bot.bot import Bot from bot.cogs.extensions import Extension -from bot.converters import FetchedUser, TagNameConverter +from bot.converters import FetchedMember, TagNameConverter from bot.pagination import LinePaginator log = logging.getLogger(__name__) @@ -59,12 +59,12 @@ class Alias (Cog): await self.invoke(ctx, "site tools") @command(name="watch", hidden=True) - async def bigbrother_watch_alias(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: + async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Alias for invoking bigbrother watch [user] [reason].""" await self.invoke(ctx, "bigbrother watch", user, reason=reason) @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: + async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Alias for invoking bigbrother unwatch [user] [reason].""" await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) @@ -130,12 +130,12 @@ class Alias (Cog): await self.invoke(ctx, "docs get", symbol) @command(name="nominate", hidden=True) - async def nomination_add_alias(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: + async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Alias for invoking talentpool add [user] [reason].""" await self.invoke(ctx, "talentpool add", user, reason=reason) @command(name="unnominate", hidden=True) - async def nomination_end_alias(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: + async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Alias for invoking nomination end [user] [reason].""" await self.invoke(ctx, "nomination end", user, reason=reason) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 264d8bcf5..87407f26b 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -9,7 +9,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import FetchedUser +from bot.converters import FetchedMember from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check from . import utils @@ -66,7 +66,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_kick(ctx, user, reason, active=False) @command() - async def ban(self, ctx: Context, user: FetchedUser, *, reason: str = None) -> None: + async def ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: """Permanently ban a user for the given reason.""" await self.apply_ban(ctx, user, reason) @@ -93,7 +93,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_mute(ctx, user, reason, expires_at=duration) @command() - async def tempban(self, ctx: Context, user: FetchedUser, duration: utils.Expiry, *, reason: str = None) -> None: + async def tempban(self, ctx: Context, user: FetchedMember, duration: utils.Expiry, *, reason: str = None) -> None: """ Temporarily ban a user for the given reason and duration. @@ -115,7 +115,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent shadow infractions @command(hidden=True) - async def note(self, ctx: Context, user: FetchedUser, *, reason: str = None) -> None: + async def note(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) if infraction is None: @@ -129,7 +129,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_kick(ctx, user, reason, hidden=True, active=False) @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: FetchedUser, *, reason: str = None) -> None: + async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: """Permanently ban a user for the given reason without notifying the user.""" await self.apply_ban(ctx, user, reason, hidden=True) @@ -159,7 +159,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def shadow_tempban( self, ctx: Context, - user: FetchedUser, + user: FetchedMember, duration: utils.Expiry, *, reason: str = None @@ -185,12 +185,12 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Remove infractions (un- commands) @command() - async def unmute(self, ctx: Context, user: FetchedUser) -> None: + async def unmute(self, ctx: Context, user: FetchedMember) -> None: """Prematurely end the active mute infraction for the user.""" await self.pardon_infraction(ctx, "mute", user) @command() - async def unban(self, ctx: Context, user: FetchedUser) -> None: + async def unban(self, ctx: Context, user: FetchedMember) -> None: """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index b2cb99368..41f933247 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -6,7 +6,7 @@ from discord.ext.commands import Cog, Context, group from bot.bot import Bot from bot.cogs.moderation.utils import post_infraction from bot.constants import Channels, MODERATION_ROLES, Webhooks -from bot.converters import FetchedUser +from bot.converters import FetchedMember from bot.decorators import with_role from .watchchannel import WatchChannel @@ -45,7 +45,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='watch', aliases=('w',)) @with_role(*MODERATION_ROLES) - async def watch_command(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#big-brother` channel. @@ -92,7 +92,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Stop relaying messages by the given `user`.""" active_watches = await self.bot.api_client.get( self.api_endpoint, diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 776f0ea87..329a78af4 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -8,7 +8,7 @@ from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks -from bot.converters import FetchedUser +from bot.converters import FetchedMember from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils import time @@ -49,7 +49,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) @with_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#talent-pool` channel. @@ -114,7 +114,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='history', aliases=('info', 'search')) @with_role(*MODERATION_ROLES) - async def history_command(self, ctx: Context, user: FetchedUser) -> None: + async def history_command(self, ctx: Context, user: FetchedMember) -> None: """Shows the specified user's nomination history.""" result = await self.bot.api_client.get( self.api_endpoint, @@ -143,7 +143,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='unwatch', aliases=('end', )) @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedUser, *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. diff --git a/bot/converters.py b/bot/converters.py index 4fb800a01..be972086d 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -346,3 +346,6 @@ class FetchedUser(UserConverter): log.debug(f"Failed to fetch user {arg}: user does not exist.") raise BadArgument(f"User `{arg}` does not exist") + + +FetchedMember = t.Union[discord.Member, FetchedUser] -- cgit v1.2.3 From 1b52d13ac313eda420a54de829750882cf2bb2ab Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 23 Dec 2019 11:16:51 -0800 Subject: Refactor user type aliases --- bot/cogs/moderation/infractions.py | 4 ++-- bot/cogs/moderation/modlog.py | 3 +-- bot/cogs/moderation/scheduler.py | 6 +++--- bot/cogs/moderation/utils.py | 17 +++++++++-------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 87407f26b..d7201bdb1 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -14,7 +14,7 @@ from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check from . import utils from .scheduler import InfractionScheduler -from .utils import MemberObject +from .utils import UserSnowflake log = logging.getLogger(__name__) @@ -229,7 +229,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action) @respect_role_hierarchy() - async def apply_ban(self, ctx: Context, user: MemberObject, reason: str, **kwargs) -> None: + async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: str, **kwargs) -> None: """Apply a ban infraction with kwargs passed to `post_infraction`.""" if await utils.has_active_infraction(ctx, user, "ban"): return diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 35ef6cbcc..1e031443c 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -15,7 +15,6 @@ from discord.ext.commands import Cog, Context from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs from bot.utils.time import humanize_delta -from .utils import UserTypes log = logging.getLogger(__name__) @@ -355,7 +354,7 @@ class ModLog(Cog, name="ModLog"): ) @Cog.listener() - async def on_member_ban(self, guild: discord.Guild, member: UserTypes) -> None: + async def on_member_ban(self, guild: discord.Guild, member: discord.Member) -> None: """Log ban event to user log.""" if guild.id != GuildConstant.id: return diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 732091c17..e14c302cb 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -17,7 +17,7 @@ from bot.utils import time from bot.utils.scheduling import Scheduler from . import utils from .modlog import ModLog -from .utils import MemberObject +from .utils import UserSnowflake log = logging.getLogger(__name__) @@ -77,7 +77,7 @@ class InfractionScheduler(Scheduler): self, ctx: Context, infraction: utils.Infraction, - user: MemberObject, + user: UserSnowflake, action_coro: t.Optional[t.Awaitable] = None ) -> None: """Apply an infraction to the user, log the infraction, and optionally notify the user.""" @@ -189,7 +189,7 @@ class InfractionScheduler(Scheduler): log.info(f"Applied {infr_type} infraction #{id_} to {user}.") - async def pardon_infraction(self, ctx: Context, infr_type: str, user: MemberObject) -> None: + async def pardon_infraction(self, ctx: Context, infr_type: str, user: UserSnowflake) -> None: """Prematurely end an infraction for a user and log the action in the mod log.""" log.trace(f"Pardoning {infr_type} infraction for {user}.") diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 6f7ea9771..79555369c 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -24,13 +24,14 @@ INFRACTION_ICONS = { RULES_URL = "https://pythondiscord.com/pages/rules" APPEALABLE_INFRACTIONS = ("ban", "mute") -UserTypes = t.Union[discord.Member, discord.User] -MemberObject = t.Union[UserTypes, discord.Object] +# Type aliases +UserObject = t.Union[discord.Member, discord.User] +UserSnowflake = t.Union[UserObject, discord.Object] Infraction = t.Dict[str, t.Union[str, int, bool]] Expiry = t.Union[Duration, ISODateTime] -async def post_user(ctx: Context, user: MemberObject) -> t.Optional[dict]: +async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: """ Create a new user in the database. @@ -61,7 +62,7 @@ async def post_user(ctx: Context, user: MemberObject) -> t.Optional[dict]: async def post_infraction( ctx: Context, - user: MemberObject, + user: UserSnowflake, infr_type: str, reason: str, expires_at: datetime = None, @@ -98,7 +99,7 @@ async def post_infraction( return -async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str) -> bool: +async def has_active_infraction(ctx: Context, user: UserSnowflake, infr_type: str) -> bool: """Checks if a user already has an active infraction of the given type.""" log.trace(f"Checking if {user} has active infractions of type {infr_type}.") @@ -123,7 +124,7 @@ async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str async def notify_infraction( - user: UserTypes, + user: UserObject, infr_type: str, expires_at: t.Optional[str] = None, reason: t.Optional[str] = None, @@ -154,7 +155,7 @@ async def notify_infraction( async def notify_pardon( - user: UserTypes, + user: UserObject, title: str, content: str, icon_url: str = Icons.user_verified @@ -172,7 +173,7 @@ async def notify_pardon( return await send_private_embed(user, embed) -async def send_private_embed(user: UserTypes, embed: discord.Embed) -> bool: +async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: """ A helper method for sending an embed to a user's DMs. -- cgit v1.2.3 From 35207f5b05e244b88c1c3cf02cd8385152821104 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 23 Dec 2019 11:37:50 -0800 Subject: Move Expiry converter alias to converters.py --- bot/cogs/moderation/infractions.py | 10 +++++----- bot/cogs/moderation/management.py | 4 ++-- bot/cogs/moderation/superstarify.py | 3 ++- bot/cogs/moderation/utils.py | 2 -- bot/converters.py | 1 + 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index d7201bdb1..f4159adb9 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -9,7 +9,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import FetchedMember +from bot.converters import Expiry, FetchedMember from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check from . import utils @@ -74,7 +74,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Temporary infractions @command(aliases=["mute"]) - async def tempmute(self, ctx: Context, user: Member, duration: utils.Expiry, *, reason: str = None) -> None: + async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: str = None) -> None: """ Temporarily mute a user for the given reason and duration. @@ -93,7 +93,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_mute(ctx, user, reason, expires_at=duration) @command() - async def tempban(self, ctx: Context, user: FetchedMember, duration: utils.Expiry, *, reason: str = None) -> None: + async def tempban(self, ctx: Context, user: FetchedMember, duration: Expiry, *, reason: str = None) -> None: """ Temporarily ban a user for the given reason and duration. @@ -137,7 +137,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Temporary shadow infractions @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) - async def shadow_tempmute(self, ctx: Context, user: Member, duration: utils.Expiry, *, reason: str = None) -> None: + async def shadow_tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: str = None) -> None: """ Temporarily mute a user for the given reason and duration without notifying the user. @@ -160,7 +160,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: FetchedMember, - duration: utils.Expiry, + duration: Expiry, *, reason: str = None ) -> None: diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 2ec69960e..0636422d3 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -10,7 +10,7 @@ from discord.ext.commands import Context from bot import constants from bot.bot import Bot -from bot.converters import InfractionSearchQuery, allowed_strings, proxy_user +from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user from bot.pagination import LinePaginator from bot.utils import time from bot.utils.checks import in_channel_check, with_role_check @@ -51,7 +51,7 @@ class ModManagement(commands.Cog): self, ctx: Context, infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], - duration: t.Union[utils.Expiry, allowed_strings("p", "permanent"), None], + duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], *, reason: str = None ) -> None: diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 7631d9bbe..be1e7b2d9 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -10,6 +10,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot +from bot.converters import Expiry from bot.utils.checks import with_role_check from bot.utils.time import format_infraction from . import utils @@ -107,7 +108,7 @@ class Superstarify(InfractionScheduler, Cog): self, ctx: Context, member: Member, - duration: utils.Expiry, + duration: Expiry, reason: str = None ) -> None: """ diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 79555369c..5052b9048 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -8,7 +8,6 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.constants import Colours, Icons -from bot.converters import Duration, ISODateTime log = logging.getLogger(__name__) @@ -28,7 +27,6 @@ APPEALABLE_INFRACTIONS = ("ban", "mute") UserObject = t.Union[discord.Member, discord.User] UserSnowflake = t.Union[UserObject, discord.Object] Infraction = t.Dict[str, t.Union[str, int, bool]] -Expiry = t.Union[Duration, ISODateTime] async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: diff --git a/bot/converters.py b/bot/converters.py index be972086d..cca57a02d 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -348,4 +348,5 @@ class FetchedUser(UserConverter): raise BadArgument(f"User `{arg}` does not exist") +Expiry = t.Union[Duration, ISODateTime] FetchedMember = t.Union[discord.Member, FetchedUser] -- cgit v1.2.3 From 77edf9a2950ebb96eed56d3a6a858a561d6a72e7 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 26 Dec 2019 21:35:59 +1000 Subject: Use a static discord shield on the readme. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7a7f1b992..1e7b21271 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Python Utility Bot -[![Discord](https://img.shields.io/discord/267624335836053506?color=%237289DA&label=Python%20Discord&logo=discord&logoColor=white)](https://discord.gg/2B963hn) +[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E30k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn) [![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1&branchName=master) [![Tests](https://img.shields.io/azure-devops/tests/python-discord/Python%20Discord/1?compact_message)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master) [![Coverage](https://img.shields.io/azure-devops/coverage/python-discord/Python%20Discord/1/master)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master) -- cgit v1.2.3 From 18076745d45f808e729f9c4a12252efaed901f3a Mon Sep 17 00:00:00 2001 From: Ava Date: Mon, 30 Dec 2019 00:42:18 +0200 Subject: Update filetype whitelist to include audio formats Discord has a built-in player for all of these and they are really just stripped down videos, which are allowed. --- config-default.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config-default.yml b/config-default.yml index f28c79712..15d0e51b8 100644 --- a/config-default.yml +++ b/config-default.yml @@ -374,6 +374,9 @@ anti_malware: - '.ai' # Illustrator - '.aep' # After Effects - '.xcf' # GIMP + - '.mp3' + - '.wav' + - '.ogg' reddit: -- cgit v1.2.3 From d5fad742bcc9566405a331f10203d175c2882001 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 30 Dec 2019 14:52:30 -0800 Subject: Watchchannels: show username in response for already watched users Makes it clear which user it is in the case of an ID being used to invoke the command. --- bot/cogs/watchchannels/bigbrother.py | 2 +- bot/cogs/watchchannels/talentpool.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 306ed4c64..1e53b98ea 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -62,7 +62,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): return if user.id in self.watched_users: - await ctx.send(":x: The specified user is already being watched.") + await ctx.send(f":x: {user} is already being watched.") return response = await post_infraction(ctx, user, 'watch', reason, hidden=True) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index cc8feeeee..f990ccff8 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -69,7 +69,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): return if user.id in self.watched_users: - await ctx.send(":x: The specified user is already being watched in the talent pool") + await ctx.send(f":x: {user} is already being watched in the talent pool") return # Manual request with `raise_for_status` as False because we want the actual response -- cgit v1.2.3 From 90b8a8fb8b5a32169ddc1d331eaa38d9c6bf270e Mon Sep 17 00:00:00 2001 From: Numerlor Date: Tue, 31 Dec 2019 23:51:46 +0100 Subject: Make sure description is truncated to max 1000 chars --- bot/cogs/doc.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 9506b195a..c1bb4ad41 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -307,8 +307,15 @@ class Doc(commands.Cog): if len(description) > 1000: shortened = description[:1000] last_paragraph_end = shortened.rfind('\n\n', 100) + # Search the shortened version for cutoff points in decreasing desirability, + # cutoff at 1000 if none are found. if last_paragraph_end == -1: - last_paragraph_end = shortened.rfind('. ') + for string in (". ", ", ", ",", " "): + last_paragraph_end = shortened.rfind(string) + if last_paragraph_end != -1: + break + else: + last_paragraph_end = 1000 description = description[:last_paragraph_end] # If there is an incomplete code block, cut it out @@ -318,7 +325,6 @@ class Doc(commands.Cog): description += f"... [read more]({permalink})" description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description) - if signatures is None: # If symbol is a module, don't show signature. embed_description = description -- cgit v1.2.3 From 2b032a35fa64e770678b051c164dbfc53ef56893 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Wed, 1 Jan 2020 00:43:03 +0100 Subject: Rename last_paragraph_end variable to a more fitting name --- bot/cogs/doc.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index c1bb4ad41..16a690f39 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -306,17 +306,17 @@ class Doc(commands.Cog): # of a double newline (interpreted as a paragraph) before index 1000. if len(description) > 1000: shortened = description[:1000] - last_paragraph_end = shortened.rfind('\n\n', 100) + description_cutoff = shortened.rfind('\n\n', 100) # Search the shortened version for cutoff points in decreasing desirability, # cutoff at 1000 if none are found. - if last_paragraph_end == -1: + if description_cutoff == -1: for string in (". ", ", ", ",", " "): - last_paragraph_end = shortened.rfind(string) - if last_paragraph_end != -1: + description_cutoff = shortened.rfind(string) + if description_cutoff != -1: break else: - last_paragraph_end = 1000 - description = description[:last_paragraph_end] + description_cutoff = 1000 + description = description[:description_cutoff] # If there is an incomplete code block, cut it out if description.count("```") % 2: -- cgit v1.2.3 From 1da2c04e43551cf36646c85880647aa33c40cb24 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Wed, 1 Jan 2020 00:43:33 +0100 Subject: Move comment into if body --- bot/cogs/doc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 16a690f39..cb6f4f972 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -307,9 +307,9 @@ class Doc(commands.Cog): if len(description) > 1000: shortened = description[:1000] description_cutoff = shortened.rfind('\n\n', 100) - # Search the shortened version for cutoff points in decreasing desirability, - # cutoff at 1000 if none are found. if description_cutoff == -1: + # Search the shortened version for cutoff points in decreasing desirability, + # cutoff at 1000 if none are found. for string in (". ", ", ", ",", " "): description_cutoff = shortened.rfind(string) if description_cutoff != -1: -- cgit v1.2.3 From c398c6ecc50d7e78e729c1451c28801cfb771aab Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 9 Jan 2020 14:21:22 -0600 Subject: Adding PyGame and Ren'Py to the invite white list With the addition of the #game-development channel, I would expect we'll see an increase in people needing specific help with the various engines and libraries that Python has to offer. As part of this, I'm adding the servers for PyGame and Ren'Py to the white list to help people get to the proper resources they might need. --- config-default.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config-default.yml b/config-default.yml index 15d0e51b8..f303f6ff8 100644 --- a/config-default.yml +++ b/config-default.yml @@ -202,6 +202,8 @@ filter: - 590806733924859943 # Discord Hack Week - 423249981340778496 # Kivy - 197038439483310086 # Discord Testers + - 286633898581164032 # Ren'Py + - 349505959032389632 # PyGame domain_blacklist: - pornhub.com -- cgit v1.2.3 From 74d990540a1072c1782fa7593d7d1abe3c165f49 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 11 Jan 2020 18:58:33 +0100 Subject: Update Code Jam Participant ID --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index f303f6ff8..bf8509544 100644 --- a/config-default.yml +++ b/config-default.yml @@ -154,7 +154,7 @@ guild: contributor: 295488872404484098 core_developer: 587606783669829632 helpers: 267630620367257601 - jammer: 423054537079783434 + jammer: 591786436651646989 moderator: &MOD_ROLE 267629731250176001 muted: &MUTED_ROLE 277914926603829249 owner: &OWNER_ROLE 267627879762755584 -- cgit v1.2.3 From eaaebe963c0b9f79ce18142e343f57ef85fdd88c Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 11 Jan 2020 17:38:46 -0500 Subject: Add handling for empty PEP metadata values Some PEPs have a metadata field that is present but has an empty value (e.g. PEP 249), causing an exception to be raised when attempting to add it as an embed field value, which cannot be empty. This refactors the information parsing to prevent the field from being added if there is no value to provide, as well as cut down on copy+paste when populating fields in the embed. --- bot/cogs/utils.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 47a59db66..da278011a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -62,14 +62,12 @@ class Utils(Cog): pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") # Add the interesting information - if "Status" in pep_header: - pep_embed.add_field(name="Status", value=pep_header["Status"]) - if "Python-Version" in pep_header: - pep_embed.add_field(name="Python-Version", value=pep_header["Python-Version"]) - if "Created" in pep_header: - pep_embed.add_field(name="Created", value=pep_header["Created"]) - if "Type" in pep_header: - pep_embed.add_field(name="Type", value=pep_header["Type"]) + fields_to_check = ("Status", "Python-Version", "Created", "Type") + for field in fields_to_check: + # Check for a PEP metadata field that is present but has an empty value + # embed field values can't contain an empty string + if pep_header.get(field, ""): + pep_embed.add_field(name=field, value=pep_header[field]) elif response.status != 404: # any response except 200 and 404 is expected -- cgit v1.2.3 From 1fc68f1857a2f3abe00fe270bd050a3d2a6e2a03 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 10:41:08 +0100 Subject: Install `prometheus-async`. --- Pipfile | 1 + Pipfile.lock | 389 ++++++++++++++++++++++++++++++++--------------------------- 2 files changed, 211 insertions(+), 179 deletions(-) diff --git a/Pipfile b/Pipfile index 48d839fc3..68362ae78 100644 --- a/Pipfile +++ b/Pipfile @@ -19,6 +19,7 @@ deepdiff = "~=4.0" requests = "~=2.22" more_itertools = "~=7.2" urllib3 = ">=1.24.2,<1.25" +prometheus-async = {extras = ["aiohttp"],version = "~=19.2"} [dev-packages] coverage = "~=4.5" diff --git a/Pipfile.lock b/Pipfile.lock index 69caf4646..ab5dfb538 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c27d699b4aeeed204dee41f924f682ae2a670add8549a8826e58776594370582" + "sha256": "d9349e8c704b2b2403004039856d8d75aaebc76e4aa93390c4d177f583e73b71" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:1da038b3d2c1b49e0e816d87424e702912bb77f9b5197f2bf279217915b4f7ed", - "sha256:29fe851374b86c997a22174c04352b5941bc1c2e36bbf542918ac18a76cfc9d3" + "sha256:a5837277e53755078db3a9e8c45bbca605c8ba9ecba7a02d74a7a1779f444723", + "sha256:fa32e33b4b7d0804dcf439ae6ff24d2f0a83d1ba280ee9f555e647d71d394ff5" ], "index": "pypi", - "version": "==6.3.0" + "version": "==6.4.1" }, "aiodns": { "hashes": [ @@ -62,10 +62,10 @@ }, "aiormq": { "hashes": [ - "sha256:afc0d46837b121585e4faec0a7646706429b4e2f5110ae8d0b5cdc3708b4b0e5", - "sha256:dc0fbbc7f8ad5af6a2cc18e00ccc5f925984cde3db6e8fe952c07b7ef157b5f2" + "sha256:8c215a970133ab5ee7c478decac55b209af7731050f52d11439fe910fa0f9e9d", + "sha256:9210f3389200aee7d8067f6435f4a9eff2d3a30b88beb5eaae406ccc11c0fc01" ], - "version": "==2.9.1" + "version": "==3.2.0" }, "alabaster": { "hashes": [ @@ -90,25 +90,25 @@ }, "babel": { "hashes": [ - "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab", - "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28" + "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", + "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], - "version": "==2.7.0" + "version": "==2.8.0" }, "beautifulsoup4": { "hashes": [ - "sha256:5279c36b4b2ec2cb4298d723791467e3000e5384a43ea0cdf5d45207c7e97169", - "sha256:6135db2ba678168c07950f9a16c4031822c6f4aec75a65e0a97bc5ca09789931", - "sha256:dcdef580e18a76d54002088602eba453eec38ebbcafafeaabd8cab12b6155d57" + "sha256:05fd825eb01c290877657a56df4c6e4c311b3965bda790c613a3d6fb01a5462a", + "sha256:9fbb4d6e48ecd30bcacc5b63b94088192dcda178513b2ae3c394229f8911b887", + "sha256:e1505eeed31b0f4ce2dbb3bc8eb256c04cc2b3b72af7d551a4ab6efd5cbe5dae" ], - "version": "==4.8.1" + "version": "==4.8.2" }, "certifi": { "hashes": [ - "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", - "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" + "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", + "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" ], - "version": "==2019.9.11" + "version": "==2019.11.28" }, "cffi": { "hashes": [ @@ -195,10 +195,10 @@ }, "imagesize": { "hashes": [ - "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", - "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5" + "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", + "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], - "version": "==1.1.0" + "version": "==1.2.0" }, "jinja2": { "hashes": [ @@ -223,35 +223,35 @@ }, "lxml": { "hashes": [ - "sha256:02ca7bf899da57084041bb0f6095333e4d239948ad3169443f454add9f4e9cb4", - "sha256:096b82c5e0ea27ce9138bcbb205313343ee66a6e132f25c5ed67e2c8d960a1bc", - "sha256:0a920ff98cf1aac310470c644bc23b326402d3ef667ddafecb024e1713d485f1", - "sha256:1409b14bf83a7d729f92e2a7fbfe7ec929d4883ca071b06e95c539ceedb6497c", - "sha256:17cae1730a782858a6e2758fd20dd0ef7567916c47757b694a06ffafdec20046", - "sha256:17e3950add54c882e032527795c625929613adbd2ce5162b94667334458b5a36", - "sha256:1f4f214337f6ee5825bf90a65d04d70aab05526c08191ab888cb5149501923c5", - "sha256:2e8f77db25b0a96af679e64ff9bf9dddb27d379c9900c3272f3041c4d1327c9d", - "sha256:4dffd405390a45ecb95ab5ab1c1b847553c18b0ef8ed01e10c1c8b1a76452916", - "sha256:6b899931a5648862c7b88c795eddff7588fb585e81cecce20f8d9da16eff96e0", - "sha256:726c17f3e0d7a7200718c9a890ccfeab391c9133e363a577a44717c85c71db27", - "sha256:760c12276fee05c36f95f8040180abc7fbebb9e5011447a97cdc289b5d6ab6fc", - "sha256:796685d3969815a633827c818863ee199440696b0961e200b011d79b9394bbe7", - "sha256:891fe897b49abb7db470c55664b198b1095e4943b9f82b7dcab317a19116cd38", - "sha256:9277562f175d2334744ad297568677056861070399cec56ff06abbe2564d1232", - "sha256:a471628e20f03dcdfde00770eeaf9c77811f0c331c8805219ca7b87ac17576c5", - "sha256:a63b4fd3e2cabdcc9d918ed280bdde3e8e9641e04f3c59a2a3109644a07b9832", - "sha256:ae88588d687bd476be588010cbbe551e9c2872b816f2da8f01f6f1fda74e1ef0", - "sha256:b0b84408d4eabc6de9dd1e1e0bc63e7731e890c0b378a62443e5741cfd0ae90a", - "sha256:be78485e5d5f3684e875dab60f40cddace2f5b2a8f7fede412358ab3214c3a6f", - "sha256:c27eaed872185f047bb7f7da2d21a7d8913457678c9a100a50db6da890bc28b9", - "sha256:c7fccd08b14aa437fe096c71c645c0f9be0655a9b1a4b7cffc77bcb23b3d61d2", - "sha256:c81cb40bff373ab7a7446d6bbca0190bccc5be3448b47b51d729e37799bb5692", - "sha256:d11874b3c33ee441059464711cd365b89fa1a9cf19ae75b0c189b01fbf735b84", - "sha256:e9c028b5897901361d81a4718d1db217b716424a0283afe9d6735fe0caf70f79", - "sha256:fe489d486cd00b739be826e8c1be188ddb74c7a1ca784d93d06fda882a6a1681" - ], - "index": "pypi", - "version": "==4.4.1" + "sha256:00ac0d64949fef6b3693813fe636a2d56d97a5a49b5bbb86e4cc4cc50ebc9ea2", + "sha256:0571e607558665ed42e450d7bf0e2941d542c18e117b1ebbf0ba72f287ad841c", + "sha256:0e3f04a7615fdac0be5e18b2406529521d6dbdb0167d2a690ee328bef7807487", + "sha256:13cf89be53348d1c17b453867da68704802966c433b2bb4fa1f970daadd2ef70", + "sha256:217262fcf6a4c2e1c7cb1efa08bd9ebc432502abc6c255c4abab611e8be0d14d", + "sha256:223e544828f1955daaf4cefbb4853bc416b2ec3fd56d4f4204a8b17007c21250", + "sha256:277cb61fede2f95b9c61912fefb3d43fbd5f18bf18a14fae4911b67984486f5d", + "sha256:3213f753e8ae86c396e0e066866e64c6b04618e85c723b32ecb0909885211f74", + "sha256:4690984a4dee1033da0af6df0b7a6bde83f74e1c0c870623797cec77964de34d", + "sha256:4fcc472ef87f45c429d3b923b925704aa581f875d65bac80f8ab0c3296a63f78", + "sha256:61409bd745a265a742f2693e4600e4dbd45cc1daebe1d5fad6fcb22912d44145", + "sha256:678f1963f755c5d9f5f6968dded7b245dd1ece8cf53c1aa9d80e6734a8c7f41d", + "sha256:6c6d03549d4e2734133badb9ab1c05d9f0ef4bcd31d83e5d2b4747c85cfa21da", + "sha256:6e74d5f4d6ecd6942375c52ffcd35f4318a61a02328f6f1bd79fcb4ffedf969e", + "sha256:7b4fc7b1ecc987ca7aaf3f4f0e71bbfbd81aaabf87002558f5bc95da3a865bcd", + "sha256:7ed386a40e172ddf44c061ad74881d8622f791d9af0b6f5be20023029129bc85", + "sha256:8f54f0924d12c47a382c600c880770b5ebfc96c9fd94cf6f6bdc21caf6163ea7", + "sha256:ad9b81351fdc236bda538efa6879315448411a81186c836d4b80d6ca8217cdb9", + "sha256:bbd00e21ea17f7bcc58dccd13869d68441b32899e89cf6cfa90d624a9198ce85", + "sha256:c3c289762cc09735e2a8f8a49571d0e8b4f57ea831ea11558247b5bdea0ac4db", + "sha256:cf4650942de5e5685ad308e22bcafbccfe37c54aa7c0e30cd620c2ee5c93d336", + "sha256:cfcbc33c9c59c93776aa41ab02e55c288a042211708b72fdb518221cc803abc8", + "sha256:e301055deadfedbd80cf94f2f65ff23126b232b0d1fea28f332ce58137bcdb18", + "sha256:ebbfe24df7f7b5c6c7620702496b6419f6a9aa2fd7f005eb731cc80d7b4692b9", + "sha256:eff69ddbf3ad86375c344339371168640951c302450c5d3e9936e98d6459db06", + "sha256:f6ed60a62c5f1c44e789d2cf14009423cb1646b44a43e40a9cf6a21f077678a1" + ], + "index": "pypi", + "version": "==4.4.2" }, "markdownify": { "hashes": [ @@ -303,37 +303,25 @@ }, "multidict": { "hashes": [ - "sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f", - "sha256:041e9442b11409be5e4fc8b6a97e4bcead758ab1e11768d1e69160bdde18acc3", - "sha256:045b4dd0e5f6121e6f314d81759abd2c257db4634260abcfe0d3f7083c4908ef", - "sha256:047c0a04e382ef8bd74b0de01407e8d8632d7d1b4db6f2561106af812a68741b", - "sha256:068167c2d7bbeebd359665ac4fff756be5ffac9cda02375b5c5a7c4777038e73", - "sha256:148ff60e0fffa2f5fad2eb25aae7bef23d8f3b8bdaf947a65cdbe84a978092bc", - "sha256:1d1c77013a259971a72ddaa83b9f42c80a93ff12df6a4723be99d858fa30bee3", - "sha256:1d48bc124a6b7a55006d97917f695effa9725d05abe8ee78fd60d6588b8344cd", - "sha256:31dfa2fc323097f8ad7acd41aa38d7c614dd1960ac6681745b6da124093dc351", - "sha256:34f82db7f80c49f38b032c5abb605c458bac997a6c3142e0d6c130be6fb2b941", - "sha256:3d5dd8e5998fb4ace04789d1d008e2bb532de501218519d70bb672c4c5a2fc5d", - "sha256:4a6ae52bd3ee41ee0f3acf4c60ceb3f44e0e3bc52ab7da1c2b2aa6703363a3d1", - "sha256:4b02a3b2a2f01d0490dd39321c74273fed0568568ea0e7ea23e02bd1fb10a10b", - "sha256:4b843f8e1dd6a3195679d9838eb4670222e8b8d01bc36c9894d6c3538316fa0a", - "sha256:5de53a28f40ef3c4fd57aeab6b590c2c663de87a5af76136ced519923d3efbb3", - "sha256:61b2b33ede821b94fa99ce0b09c9ece049c7067a33b279f343adfe35108a4ea7", - "sha256:6a3a9b0f45fd75dc05d8e93dc21b18fc1670135ec9544d1ad4acbcf6b86781d0", - "sha256:76ad8e4c69dadbb31bad17c16baee61c0d1a4a73bed2590b741b2e1a46d3edd0", - "sha256:7ba19b777dc00194d1b473180d4ca89a054dd18de27d0ee2e42a103ec9b7d014", - "sha256:7c1b7eab7a49aa96f3db1f716f0113a8a2e93c7375dd3d5d21c4941f1405c9c5", - "sha256:7fc0eee3046041387cbace9314926aa48b681202f8897f8bff3809967a049036", - "sha256:8ccd1c5fff1aa1427100ce188557fc31f1e0a383ad8ec42c559aabd4ff08802d", - "sha256:8e08dd76de80539d613654915a2f5196dbccc67448df291e69a88712ea21e24a", - "sha256:c18498c50c59263841862ea0501da9f2b3659c00db54abfbf823a80787fde8ce", - "sha256:c49db89d602c24928e68c0d510f4fcf8989d77defd01c973d6cbe27e684833b1", - "sha256:ce20044d0317649ddbb4e54dab3c1bcc7483c78c27d3f58ab3d0c7e6bc60d26a", - "sha256:d1071414dd06ca2eafa90c85a079169bfeb0e5f57fd0b45d44c092546fcd6fd9", - "sha256:d3be11ac43ab1a3e979dac80843b42226d5d3cccd3986f2e03152720a4297cd7", - "sha256:db603a1c235d110c860d5f39988ebc8218ee028f07a7cbc056ba6424372ca31b" - ], - "version": "==4.5.2" + "sha256:13f3ebdb5693944f52faa7b2065b751cb7e578b8dd0a5bb8e4ab05ad0188b85e", + "sha256:26502cefa86d79b86752e96639352c7247846515c864d7c2eb85d036752b643c", + "sha256:4fba5204d32d5c52439f88437d33ad14b5f228e25072a192453f658bddfe45a7", + "sha256:527124ef435f39a37b279653ad0238ff606b58328ca7989a6df372fd75d7fe26", + "sha256:5414f388ffd78c57e77bd253cf829373721f450613de53dc85a08e34d806e8eb", + "sha256:5eee66f882ab35674944dfa0d28b57fa51e160b4dce0ce19e47f495fdae70703", + "sha256:63810343ea07f5cd86ba66ab66706243a6f5af075eea50c01e39b4ad6bc3c57a", + "sha256:6bd10adf9f0d6a98ccc792ab6f83d18674775986ba9bacd376b643fe35633357", + "sha256:83c6ddf0add57c6b8a7de0bc7e2d656be3eefeff7c922af9a9aae7e49f225625", + "sha256:93166e0f5379cf6cd29746989f8a594fa7204dcae2e9335ddba39c870a287e1c", + "sha256:9a7b115ee0b9b92d10ebc246811d8f55d0c57e82dbb6a26b23c9a9a6ad40ce0c", + "sha256:a38baa3046cce174a07a59952c9f876ae8875ef3559709639c17fdf21f7b30dd", + "sha256:a6d219f49821f4b2c85c6d426346a5d84dab6daa6f85ca3da6c00ed05b54022d", + "sha256:a8ed33e8f9b67e3b592c56567135bb42e7e0e97417a4b6a771e60898dfd5182b", + "sha256:d7d428488c67b09b26928950a395e41cc72bb9c3d5abfe9f0521940ee4f796d4", + "sha256:dcfed56aa085b89d644af17442cdc2debaa73388feba4b8026446d168ca8dad7", + "sha256:f29b885e4903bd57a7789f09fe9d60b6475a6c1a4c0eca874d8558f00f9d4b51" + ], + "version": "==4.7.4" }, "ordered-set": { "hashes": [ @@ -343,10 +331,10 @@ }, "packaging": { "hashes": [ - "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", - "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108" + "sha256:aec3fdbb8bc9e4bb65f0634b9f551ced63983a529d6a8931817d52fdd0816ddb", + "sha256:fe1d8331dfa7cc0a883b49d75fc76380b2ab2734b220fbb87d774e4fd4b851f8" ], - "version": "==19.2" + "version": "==20.0" }, "pamqp": { "hashes": [ @@ -355,23 +343,56 @@ ], "version": "==2.3.0" }, + "prometheus-async": { + "extras": [ + "aiohttp" + ], + "hashes": [ + "sha256:227f516e5bf98a0dc602348381e182358f8b2ed24a8db05e8e34d9cf027bab83", + "sha256:3cc68d1f39e9bbf16dbd0b51103d87671b3cbd1d75a72cda472cd9a35cc9d0d2" + ], + "index": "pypi", + "version": "==19.2.0" + }, + "prometheus-client": { + "hashes": [ + "sha256:71cd24a2b3eb335cb800c7159f423df1bd4dcd5171b234be15e3f31ec9f622da" + ], + "version": "==0.7.1" + }, "pycares": { "hashes": [ - "sha256:2ca080db265ea238dc45f997f94effb62b979a617569889e265c26a839ed6305", - "sha256:6f79c6afb6ce603009db2042fddc2e348ad093ece9784cbe2daa809499871a23", - "sha256:70918d06eb0603016d37092a5f2c0228509eb4e6c5a3faacb4184f6ab7be7650", - "sha256:755187d28d24a9ea63aa2b4c0638be31d65fbf7f0ce16d41261b9f8cb55a1b99", - "sha256:7baa4b1f2146eb8423ff8303ebde3a20fb444a60db761fba0430d104fe35ddbf", - "sha256:90b27d4df86395f465a171386bc341098d6d47b65944df46518814ae298f6cc6", - "sha256:9e090dd6b2afa65cb51c133883b2bf2240fd0f717b130b0048714b33fb0f47ce", - "sha256:a11b7d63c3718775f6e805d6464cb10943780395ab042c7e5a0a7a9f612735dd", - "sha256:b253f5dcaa0ac7076b79388a3ac80dd8f3bd979108f813baade40d3a9b8bf0bd", - "sha256:c7f4f65e44ba35e35ad3febc844270665bba21cfb0fb7d749434e705b556e087", - "sha256:cdb342e6a254f035bd976d95807a2184038fc088d957a5104dcaab8be602c093", - "sha256:cf08e164f8bfb83b9fe633feb56f2754fae6baefcea663593794fa0518f8f98c", - "sha256:df9bc694cf03673878ea8ce674082c5acd134991d64d6c306d4bd61c0c1df98f" - ], - "version": "==3.0.0" + "sha256:050f00b39ed77ea8a4e555f09417d4b1a6b5baa24bb9531a3e15d003d2319b3f", + "sha256:0a24d2e580a8eb567140d7b69f12cb7de90c836bd7b6488ec69394d308605ac3", + "sha256:0c5bd1f6f885a219d5e972788d6eef7b8043b55c3375a845e5399638436e0bba", + "sha256:11c628402cc8fc8ef461076d4e47f88afc1f8609989ebbff0dbffcd54c97239f", + "sha256:18dfd4fd300f570d6c4536c1d987b7b7673b2a9d14346592c5d6ed716df0d104", + "sha256:1917b82494907a4a342db420bc4dd5bac355a5fa3984c35ba9bf51422b020b48", + "sha256:1b90fa00a89564df059fb18e796458864cc4e00cb55e364dbf921997266b7c55", + "sha256:1d8d177c40567de78108a7835170f570ab04f09084bfd32df9919c0eaec47aa1", + "sha256:236286f81664658b32c141c8e79d20afc3d54f6e2e49dfc8b702026be7265855", + "sha256:2e4f74677542737fb5af4ea9a2e415ec5ab31aa67e7b8c3c969fdb15c069f679", + "sha256:48a7750f04e69e1f304f4332b755728067e7c4b1abe2760bba1cacd9ff7a847a", + "sha256:7d86e62b700b21401ffe7fd1bbfe91e08489416fecae99c6570ab023c6896022", + "sha256:7e2d7effd08d2e5a3cb95d98a7286ebab71ab2fbce84fa93cc2dd56caf7240dd", + "sha256:81edb016d9e43dde7473bc3999c29cdfee3a6b67308fed1ea21049f458e83ae0", + "sha256:96c90e11b4a4c7c0b8ff5aaaae969c5035493136586043ff301979aae0623941", + "sha256:9a0a1845f8cb2e62332bca0aaa9ad5494603ac43fb60d510a61d5b5b170d7216", + "sha256:a05bbfdfd41f8410a905a818f329afe7510cbd9ee65c60f8860a72b6c64ce5dc", + "sha256:a5089fd660f0b0d228b14cdaa110d0d311edfa5a63f800618dbf1321dcaef66b", + "sha256:c457a709e6f2befea7e2996c991eda6d79705dd075f6521593ba6ebc1485b811", + "sha256:c5cb72644b04e5e5abfb1e10a0e7eb75da6684ea0e60871652f348e412cf3b11", + "sha256:cce46dd4717debfd2aab79d6d7f0cbdf6b1e982dc4d9bebad81658d59ede07c2", + "sha256:cfdd1f90bcf373b00f4b2c55ea47868616fe2f779f792fc913fa82a3d64ffe43", + "sha256:d88a279cbc5af613f73e86e19b3f63850f7a2e2736e249c51995dedcc830b1bb", + "sha256:eba9a9227438da5e78fc8eee32f32eb35d9a50cf0a0bd937eb6275c7cc3015fe", + "sha256:eee7b6a5f5b5af050cb7d66ab28179287b416f06d15a8974ac831437fec51336", + "sha256:f41ac1c858687e53242828c9f59c2e7b0b95dbcd5bdd09c7e5d3c48b0f89a25a", + "sha256:f8deaefefc3a589058df1b177275f79233e8b0eeee6734cf4336d80164ecd022", + "sha256:fa78e919f3bd7d6d075db262aa41079b4c02da315c6043c6f43881e2ebcdd623", + "sha256:fadb97d2e02dabdc15a0091591a972a938850d79ddde23d385d813c1731983f0" + ], + "version": "==3.1.1" }, "pycparser": { "hashes": [ @@ -381,17 +402,17 @@ }, "pygments": { "hashes": [ - "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", - "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" + "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b", + "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe" ], - "version": "==2.4.2" + "version": "==2.5.2" }, "pyparsing": { "hashes": [ - "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f", - "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a" + "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f", + "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec" ], - "version": "==2.4.5" + "version": "==2.4.6" }, "python-dateutil": { "hashes": [ @@ -416,22 +437,20 @@ }, "pyyaml": { "hashes": [ - "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", - "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", - "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", - "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", - "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", - "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", - "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", - "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", - "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", - "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", - "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", - "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", - "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8" + "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", + "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", + "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", + "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", + "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", + "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", + "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", + "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", + "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", + "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", + "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" ], "index": "pypi", - "version": "==5.1.2" + "version": "==5.3" }, "requests": { "hashes": [ @@ -464,11 +483,11 @@ }, "sphinx": { "hashes": [ - "sha256:31088dfb95359384b1005619827eaee3056243798c62724fd3fa4b84ee4d71bd", - "sha256:52286a0b9d7caa31efee301ec4300dbdab23c3b05da1c9024b4e84896fb73d79" + "sha256:298537cb3234578b2d954ff18c5608468229e116a9757af3b831c2b2b4819159", + "sha256:e6e766b74f85f37a5f3e0773a1e1be8db3fcb799deb58ca6d18b70b0b44542a5" ], "index": "pypi", - "version": "==2.2.1" + "version": "==2.3.1" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -546,21 +565,33 @@ ], "version": "==6.0" }, - "yarl": { + "wrapt": { "hashes": [ - "sha256:024ecdc12bc02b321bc66b41327f930d1c2c543fa9a561b39861da9388ba7aa9", - "sha256:2f3010703295fbe1aec51023740871e64bb9664c789cba5a6bdf404e93f7568f", - "sha256:3890ab952d508523ef4881457c4099056546593fa05e93da84c7250516e632eb", - "sha256:3e2724eb9af5dc41648e5bb304fcf4891adc33258c6e14e2a7414ea32541e320", - "sha256:5badb97dd0abf26623a9982cd448ff12cb39b8e4c94032ccdedf22ce01a64842", - "sha256:73f447d11b530d860ca1e6b582f947688286ad16ca42256413083d13f260b7a0", - "sha256:7ab825726f2940c16d92aaec7d204cfc34ac26c0040da727cf8ba87255a33829", - "sha256:b25de84a8c20540531526dfbb0e2d2b648c13fd5dd126728c496d7c3fea33310", - "sha256:c6e341f5a6562af74ba55205dbd56d248daf1b5748ec48a0200ba227bb9e33f4", - "sha256:c9bb7c249c4432cd47e75af3864bc02d26c9594f49c82e2a28624417f0ae63b8", - "sha256:e060906c0c585565c718d1c3841747b61c5439af2211e185f6739a9412dfbde1" + "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" ], - "version": "==1.3.0" + "version": "==1.11.2" + }, + "yarl": { + "hashes": [ + "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce", + "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6", + "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce", + "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae", + "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d", + "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f", + "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b", + "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b", + "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb", + "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462", + "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea", + "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70", + "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1", + "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a", + "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b", + "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080", + "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2" + ], + "version": "==1.4.2" } }, "develop": { @@ -580,10 +611,10 @@ }, "certifi": { "hashes": [ - "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", - "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" + "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", + "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" ], - "version": "==2019.9.11" + "version": "==2019.11.28" }, "cfgv": { "hashes": [ @@ -646,10 +677,11 @@ }, "dodgy": { "hashes": [ - "sha256:65e13cf878d7aff129f1461c13cb5fd1bb6dfe66bb5327e09379c3877763280c" + "sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a", + "sha256:51f54c0fd886fa3854387f354b19f429d38c04f984f38bc572558b703c0542a6" ], "index": "pypi", - "version": "==0.1.9" + "version": "==0.2.1" }, "dparse": { "hashes": [ @@ -675,11 +707,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:6ac7ca1e706307686b60af8043ff1db31dc2cfc1233c8210d67a3d9b8f364736", - "sha256:b51131007000d67217608fa028a35ff80aa400b474e5972f1f99c2cf9d26bd2e" + "sha256:05b85538014c850a86dce7374bb6621c64481c24e35e8e90af1315f4d7a3dbaa", + "sha256:43e5233a76fda002b91a54a7cc4510f099c4bfd6279502ec70164016250eebd1" ], "index": "pypi", - "version": "==1.1.0" + "version": "==1.1.3" }, "flake8-bugbear": { "hashes": [ @@ -730,10 +762,10 @@ }, "identify": { "hashes": [ - "sha256:4f1fe9a59df4e80fcb0213086fcf502bc1765a01ea4fe8be48da3b65afd2a017", - "sha256:d8919589bd2a5f99c66302fec0ef9027b12ae150b0b0213999ad3f695fc7296e" + "sha256:6f44e637caa40d1b4cb37f6ed3b262ede74901d28b1cc5b1fc07360871edd65d", + "sha256:72e9c4ed3bc713c7045b762b0d2e2115c572b85abfc1f4604f5a4fd4c6642b71" ], - "version": "==1.4.7" + "version": "==1.4.9" }, "idna": { "hashes": [ @@ -744,11 +776,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", - "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" + "sha256:bdd9b7c397c273bcc9a11d6629a38487cd07154fa255a467bf704cd2c258e359", + "sha256:f17c015735e1a88296994c0697ecea7e11db24290941983b08c9feb30921e6d8" ], "markers": "python_version < '3.8'", - "version": "==0.23" + "version": "==1.4.0" }, "mccabe": { "hashes": [ @@ -767,24 +799,24 @@ }, "nodeenv": { "hashes": [ - "sha256:ad8259494cf1c9034539f6cced78a1da4840a4b157e23640bc4a0c0546b0cb7a" + "sha256:561057acd4ae3809e665a9aaaf214afff110bbb6a6d5c8a96121aea6878408b3" ], - "version": "==1.3.3" + "version": "==1.3.4" }, "packaging": { "hashes": [ - "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", - "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108" + "sha256:aec3fdbb8bc9e4bb65f0634b9f551ced63983a529d6a8931817d52fdd0816ddb", + "sha256:fe1d8331dfa7cc0a883b49d75fc76380b2ab2734b220fbb87d774e4fd4b851f8" ], - "version": "==19.2" + "version": "==20.0" }, "pre-commit": { "hashes": [ - "sha256:9f152687127ec90642a2cc3e4d9e1e6240c4eb153615cb02aa1ad41d331cbb6e", - "sha256:c2e4810d2d3102d354947907514a78c5d30424d299dc0fe48f5aa049826e9b50" + "sha256:8f48d8637bdae6fa70cc97db9c1dd5aa7c5c8bf71968932a380628c25978b850", + "sha256:f92a359477f3252452ae2e8d3029de77aec59415c16ae4189bcfba40b757e029" ], "index": "pypi", - "version": "==1.20.0" + "version": "==1.21.0" }, "pycodestyle": { "hashes": [ @@ -795,10 +827,10 @@ }, "pydocstyle": { "hashes": [ - "sha256:04c84e034ebb56eb6396c820442b8c4499ac5eb94a3bda88951ac3dc519b6058", - "sha256:66aff87ffe34b1e49bff2dd03a88ce6843be2f3346b0c9814410d34987fbab59" + "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586", + "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5" ], - "version": "==4.0.1" + "version": "==5.0.2" }, "pyflakes": { "hashes": [ @@ -809,29 +841,27 @@ }, "pyparsing": { "hashes": [ - "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f", - "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a" + "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f", + "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec" ], - "version": "==2.4.5" + "version": "==2.4.6" }, "pyyaml": { "hashes": [ - "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", - "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", - "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", - "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", - "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", - "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", - "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", - "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", - "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", - "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", - "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", - "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", - "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8" + "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", + "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", + "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", + "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", + "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", + "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", + "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", + "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", + "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", + "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", + "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" ], "index": "pypi", - "version": "==5.1.2" + "version": "==5.3" }, "requests": { "hashes": [ @@ -893,6 +923,7 @@ "sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12" ], + "markers": "python_version < '3.8'", "version": "==1.4.0" }, "unittest-xml-reporting": { @@ -913,10 +944,10 @@ }, "virtualenv": { "hashes": [ - "sha256:11cb4608930d5fd3afb545ecf8db83fa50e1f96fc4fca80c94b07d2c83146589", - "sha256:d257bb3773e48cac60e475a19b608996c73f4d333b3ba2e4e57d5ac6134e0136" + "sha256:0d62c70883c0342d59c11d0ddac0d954d0431321a41ab20851facf2b222598f3", + "sha256:55059a7a676e4e19498f1aad09b8313a38fcc0cdbe4fdddc0e9b06946d21b4bb" ], - "version": "==16.7.7" + "version": "==16.7.9" }, "zipp": { "hashes": [ -- cgit v1.2.3 From 4d6c28d5608c50dccb9d78ce6c8385e003f0a4b3 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 10:41:19 +0100 Subject: Start Prometheus HTTP server on bot start. --- bot/bot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index 8f808272f..930aaf70e 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -4,6 +4,7 @@ from typing import Optional import aiohttp from discord.ext import commands +from prometheus_async.aio.web import start_http_server as start_prometheus_http_server from bot import api @@ -50,4 +51,6 @@ class Bot(commands.Bot): """Open an aiohttp session before logging in and connecting to Discord.""" self.http_session = aiohttp.ClientSession(connector=self.connector) + await start_prometheus_http_server(addr="0.0.0.0", port=9330) + log.debug("Started Prometheus server on port 9330.") await super().start(*args, **kwargs) -- cgit v1.2.3 From 52813ee63c7aa558c75e1fad320b3fc3050d1155 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 11:05:23 +0100 Subject: Add the `metrics` cog. --- bot/__main__.py | 1 + bot/cogs/metrics.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 bot/cogs/metrics.py diff --git a/bot/__main__.py b/bot/__main__.py index 84bc7094b..61271a692 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -40,6 +40,7 @@ bot.load_extension("bot.cogs.duck_pond") bot.load_extension("bot.cogs.free") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") +bot.load_extension("bot.cogs.metrics") bot.load_extension("bot.cogs.moderation") bot.load_extension("bot.cogs.off_topic_names") bot.load_extension("bot.cogs.reddit") diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py new file mode 100644 index 000000000..79c754c21 --- /dev/null +++ b/bot/cogs/metrics.py @@ -0,0 +1,40 @@ +from collections import defaultdict + +from discord import Status +from discord.ext.commands import Cog +from prometheus_client import Gauge + +from bot.bot import Bot + + +class Metrics(Cog): + """Exports metrics for Prometheus.""" + + PREFIX = 'pydis_bot_' + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + self.guild_members = Gauge( + name=f'{self.PREFIX}server_members', + documentation="Total members by status.", + labelnames=('guild_id', 'status') + ) + + @Cog.listener() + async def on_ready(self) -> None: + members_by_status = defaultdict(lambda: defaultdict(int)) + + await self.bot.request_offline_members(*self.bot.guilds) + for guild in self.bot.guilds: + for member in guild.members: + members_by_status[guild.id][member.status] += 1 + + for guild_id, members in members_by_status.items(): + for status, count in members.items(): + self.guild_members.labels(guild_id=guild_id, status=str(status)).set(count) + + +def setup(bot: Bot) -> None: + """Load the Metrics cog.""" + bot.add_cog(Metrics(bot)) -- cgit v1.2.3 From 4fd5d86ce2cee62f3d1cad1cf70dca11a6546d8e Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 11:22:53 +0100 Subject: Only request offline members for large guilds. --- bot/cogs/metrics.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py index 79c754c21..e356ba294 100644 --- a/bot/cogs/metrics.py +++ b/bot/cogs/metrics.py @@ -25,8 +25,9 @@ class Metrics(Cog): async def on_ready(self) -> None: members_by_status = defaultdict(lambda: defaultdict(int)) - await self.bot.request_offline_members(*self.bot.guilds) for guild in self.bot.guilds: + if guild.large: + await self.bot.request_offline_members(guild) for member in guild.members: members_by_status[guild.id][member.status] += 1 -- cgit v1.2.3 From 189a5db5010b7337278b8b534b8e76ec0b1a6acb Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 11:23:15 +0100 Subject: Track member joins and leaves. --- bot/cogs/metrics.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py index e356ba294..ce58f763f 100644 --- a/bot/cogs/metrics.py +++ b/bot/cogs/metrics.py @@ -1,6 +1,6 @@ from collections import defaultdict -from discord import Status +from discord import Member from discord.ext.commands import Cog from prometheus_client import Gauge @@ -35,6 +35,20 @@ class Metrics(Cog): for status, count in members.items(): self.guild_members.labels(guild_id=guild_id, status=str(status)).set(count) + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + self.guild_members.labels(guild_id=member.guild.id, status=str(member.status)).inc() + + @Cog.listener() + async def on_member_leave(self, member: Member) -> None: + self.guild_members.labels(guild_id=member.guild.id, status=str(member.status)).dec() + + @Cog.listener() + async def on_member_update(self, before: Member, after: Member) -> None: + if before.status is not after.status: + self.guild_members.labels(guild_id=after.guild.id, status=str(before.status)).dec() + self.guild_members.labels(guild_id=after.guild.id, status=str(after.status)).inc() + def setup(bot: Bot) -> None: """Load the Metrics cog.""" -- cgit v1.2.3 From bf361f0474dadba0519bc9019153398761758124 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 11:27:57 +0100 Subject: Track messages by channel. --- bot/cogs/metrics.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py index ce58f763f..dc86f8e82 100644 --- a/bot/cogs/metrics.py +++ b/bot/cogs/metrics.py @@ -1,8 +1,8 @@ from collections import defaultdict -from discord import Member +from discord import Member, Message from discord.ext.commands import Cog -from prometheus_client import Gauge +from prometheus_client import Counter, Gauge from bot.bot import Bot @@ -16,10 +16,15 @@ class Metrics(Cog): self.bot = bot self.guild_members = Gauge( - name=f'{self.PREFIX}server_members', + name=f'{self.PREFIX}guild_members', documentation="Total members by status.", labelnames=('guild_id', 'status') ) + self.guild_messages = Counter( + name=f'{self.PREFIX}guild_messages', + documentation="Guild messages by channel.", + labelnames=('channel_id', 'channel_name') + ) @Cog.listener() async def on_ready(self) -> None: @@ -49,6 +54,12 @@ class Metrics(Cog): self.guild_members.labels(guild_id=after.guild.id, status=str(before.status)).dec() self.guild_members.labels(guild_id=after.guild.id, status=str(after.status)).inc() + @Cog.listener() + async def on_message(self, message: Message) -> None: + self.guild_messages.labels( + channel_id=message.channel.id, channel_name=message.channel.name + ).inc() + def setup(bot: Bot) -> None: """Load the Metrics cog.""" -- cgit v1.2.3 From 08ec201cafd1a276bf8f571c471dab911833cf44 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 11:32:33 +0100 Subject: Add documentation. --- bot/cogs/metrics.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py index dc86f8e82..5d0ce4f98 100644 --- a/bot/cogs/metrics.py +++ b/bot/cogs/metrics.py @@ -8,7 +8,11 @@ from bot.bot import Bot class Metrics(Cog): - """Exports metrics for Prometheus.""" + """ + Exports metrics for Prometheus. + + See https://github.com/prometheus/client_python for metric documentation. + """ PREFIX = 'pydis_bot_' @@ -28,6 +32,7 @@ class Metrics(Cog): @Cog.listener() async def on_ready(self) -> None: + """Initialize the guild member counter.""" members_by_status = defaultdict(lambda: defaultdict(int)) for guild in self.bot.guilds: @@ -42,20 +47,24 @@ class Metrics(Cog): @Cog.listener() async def on_member_join(self, member: Member) -> None: + """Increment the member gauge.""" self.guild_members.labels(guild_id=member.guild.id, status=str(member.status)).inc() @Cog.listener() async def on_member_leave(self, member: Member) -> None: + """Decrement the member gauge.""" self.guild_members.labels(guild_id=member.guild.id, status=str(member.status)).dec() @Cog.listener() async def on_member_update(self, before: Member, after: Member) -> None: + """Update member gauges for the new and old status if applicable.""" if before.status is not after.status: self.guild_members.labels(guild_id=after.guild.id, status=str(before.status)).dec() self.guild_members.labels(guild_id=after.guild.id, status=str(after.status)).inc() @Cog.listener() async def on_message(self, message: Message) -> None: + """Increment the guild message counter.""" self.guild_messages.labels( channel_id=message.channel.id, channel_name=message.channel.name ).inc() -- cgit v1.2.3 From 35ac7a4e02170819c34ad7626d785edab8198f33 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 11:43:39 +0100 Subject: Add Guild ID to message counter. --- bot/cogs/metrics.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py index 5d0ce4f98..0879e2221 100644 --- a/bot/cogs/metrics.py +++ b/bot/cogs/metrics.py @@ -21,13 +21,13 @@ class Metrics(Cog): self.guild_members = Gauge( name=f'{self.PREFIX}guild_members', - documentation="Total members by status.", + documentation="Total members by guild by status.", labelnames=('guild_id', 'status') ) self.guild_messages = Counter( name=f'{self.PREFIX}guild_messages', - documentation="Guild messages by channel.", - labelnames=('channel_id', 'channel_name') + documentation="Guild messages by guild by channel.", + labelnames=('channel_id', 'guild_id', 'channel_name') ) @Cog.listener() @@ -66,7 +66,9 @@ class Metrics(Cog): async def on_message(self, message: Message) -> None: """Increment the guild message counter.""" self.guild_messages.labels( - channel_id=message.channel.id, channel_name=message.channel.name + channel_id=message.channel.id, + channel_name=message.channel.name, + guild_id=message.guild.id, ).inc() -- cgit v1.2.3 From f464f8422a9d33fc09f9a977b011ce362e7e94c3 Mon Sep 17 00:00:00 2001 From: Thomas Petersson Date: Sun, 12 Jan 2020 16:14:05 +0100 Subject: feat(!rules): allow for throw away words after the command call --- bot/cogs/alias.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index c1db38462..2b499a537 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -3,7 +3,10 @@ import logging from typing import Union from discord import Colour, Embed, Member, User -from discord.ext.commands import Cog, Command, Context, clean_content, command, group +from discord.ext.commands import ( + Cog, Command, Context, clean_content, + command, Greedy, group, +) from bot.bot import Bot from bot.cogs.extensions import Extension @@ -81,7 +84,7 @@ class Alias (Cog): await self.invoke(ctx, "site faq") @command(name="rules", aliases=("rule",), hidden=True) - async def site_rules_alias(self, ctx: Context, *rules: int) -> None: + async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None: """Alias for invoking site rules.""" await self.invoke(ctx, "site rules", *rules) -- cgit v1.2.3 From 1016ac97c8ae8629387d064cdce88449b8e8d342 Mon Sep 17 00:00:00 2001 From: Thomas Petersson Date: Sun, 12 Jan 2020 16:23:11 +0100 Subject: fix(lint): wrong import order --- bot/cogs/alias.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 2b499a537..d05a6a715 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -4,8 +4,8 @@ from typing import Union from discord import Colour, Embed, Member, User from discord.ext.commands import ( - Cog, Command, Context, clean_content, - command, Greedy, group, + Cog, Command, Context, Greedy, + clean_content, command, group, ) from bot.bot import Bot -- cgit v1.2.3 From 4dd4efc49536210ad8eaea6449e204b2533d8438 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 16:51:23 +0100 Subject: Remove underline from `self.PREFIX`. --- bot/cogs/metrics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py index 0879e2221..6d3cc67bd 100644 --- a/bot/cogs/metrics.py +++ b/bot/cogs/metrics.py @@ -14,18 +14,18 @@ class Metrics(Cog): See https://github.com/prometheus/client_python for metric documentation. """ - PREFIX = 'pydis_bot_' + PREFIX = 'pydis_bot' def __init__(self, bot: Bot) -> None: self.bot = bot self.guild_members = Gauge( - name=f'{self.PREFIX}guild_members', + name=f'{self.PREFIX}_guild_members', documentation="Total members by guild by status.", labelnames=('guild_id', 'status') ) self.guild_messages = Counter( - name=f'{self.PREFIX}guild_messages', + name=f'{self.PREFIX}_guild_messages', documentation="Guild messages by guild by channel.", labelnames=('channel_id', 'guild_id', 'channel_name') ) -- cgit v1.2.3 From 439f70421dc3afd9ea2bc0cb3a966960b7062fdb Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 17:21:37 +0100 Subject: Empty commit for autodeploy. --- bot/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/__init__.py b/bot/__init__.py index 4a2df730d..789ace5c0 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -6,6 +6,7 @@ from pathlib import Path from logmatic import JsonFormatter + logging.TRACE = 5 logging.addLevelName(logging.TRACE, "TRACE") -- cgit v1.2.3 From 7805f8ad212bfc23391dd49f311501eed2c66166 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 20:02:17 +0100 Subject: Track command completions. --- bot/cogs/metrics.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py index 6d3cc67bd..aff1b0eb2 100644 --- a/bot/cogs/metrics.py +++ b/bot/cogs/metrics.py @@ -1,7 +1,7 @@ from collections import defaultdict from discord import Member, Message -from discord.ext.commands import Cog +from discord.ext.commands import Cog, Context from prometheus_client import Counter, Gauge from bot.bot import Bot @@ -29,6 +29,11 @@ class Metrics(Cog): documentation="Guild messages by guild by channel.", labelnames=('channel_id', 'guild_id', 'channel_name') ) + self.command_completions = Counter( + name=f'{self.PREFIX}_command_completions', + documentation="Completed commands by command by guild.", + labelnames=('guild_id', 'command') + ) @Cog.listener() async def on_ready(self) -> None: @@ -71,6 +76,27 @@ class Metrics(Cog): guild_id=message.guild.id, ).inc() + @Cog.listener() + async def on_command_completion(self, ctx: Context) -> None: + """Increment the command completion counter.""" + if ctx.message.guild is not None: + path = [] + if ( + ctx.command.root_parent is not None + and ctx.command.root_parent is not ctx.command.parent + ): + path.append(ctx.command.root_parent.name) + + if ctx.command.parent is not None: + path.append(ctx.command.parent.name) + + path.append(ctx.command.name) + + self.command_completions.labels( + guild_id=ctx.message.guild.id, + command=' '.join(path), + ).inc() + def setup(bot: Bot) -> None: """Load the Metrics cog.""" -- cgit v1.2.3 From b0b9cdd0d54230c50bf6b4620909477d4adde8b7 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 20:10:23 +0100 Subject: Use parent path properly. --- bot/cogs/metrics.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py index aff1b0eb2..fa858bbd6 100644 --- a/bot/cogs/metrics.py +++ b/bot/cogs/metrics.py @@ -80,21 +80,14 @@ class Metrics(Cog): async def on_command_completion(self, ctx: Context) -> None: """Increment the command completion counter.""" if ctx.message.guild is not None: - path = [] - if ( - ctx.command.root_parent is not None - and ctx.command.root_parent is not ctx.command.parent - ): - path.append(ctx.command.root_parent.name) - - if ctx.command.parent is not None: - path.append(ctx.command.parent.name) - - path.append(ctx.command.name) + if ctx.command.full_parent_name: + command = f'{ctx.command.full_parent_name} {ctx.command.name}' + else: + command = ctx.command.name self.command_completions.labels( guild_id=ctx.message.guild.id, - command=' '.join(path), + command=command, ).inc() -- cgit v1.2.3 From 82d2d5db0343ba0d2922727ebe8eae2eee57bac1 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 21:31:20 +0100 Subject: Track command completions by user. --- bot/cogs/metrics.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py index fa858bbd6..603057854 100644 --- a/bot/cogs/metrics.py +++ b/bot/cogs/metrics.py @@ -31,8 +31,8 @@ class Metrics(Cog): ) self.command_completions = Counter( name=f'{self.PREFIX}_command_completions', - documentation="Completed commands by command by guild.", - labelnames=('guild_id', 'command') + documentation="Completed commands by command by user by guild.", + labelnames=('guild_id', 'user_id', 'user_name', 'command') ) @Cog.listener() @@ -87,6 +87,8 @@ class Metrics(Cog): self.command_completions.labels( guild_id=ctx.message.guild.id, + user_id=ctx.author.id, + user_name=str(ctx.author), command=command, ).inc() -- cgit v1.2.3 From 99088f157b8cb818d333733c3c566e7a57c2de1b Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 12 Jan 2020 23:09:23 +0100 Subject: Use commas instead of `by`. --- bot/cogs/metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py index 603057854..47c3cc55e 100644 --- a/bot/cogs/metrics.py +++ b/bot/cogs/metrics.py @@ -31,7 +31,7 @@ class Metrics(Cog): ) self.command_completions = Counter( name=f'{self.PREFIX}_command_completions', - documentation="Completed commands by command by user by guild.", + documentation="Completed commands by command, user, and guild.", labelnames=('guild_id', 'user_id', 'user_name', 'command') ) -- cgit v1.2.3 From 09b93a9cabc7cf05f0d6b06d26086f713c927dbf Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 13 Jan 2020 09:20:57 +0100 Subject: Replace sphinx mock classes with SimpleNamespaces; add user_agent to config --- bot/cogs/doc.py | 41 +++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index cb6f4f972..6e7c00b6a 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -5,6 +5,7 @@ import re import textwrap from collections import OrderedDict from contextlib import suppress +from types import SimpleNamespace from typing import Any, Callable, Optional, Tuple import discord @@ -27,6 +28,16 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) logging.getLogger('urllib3').setLevel(logging.WARNING) +# Since Intersphinx is intended to be used with Sphinx, +# we need to mock its configuration. +SPHINX_MOCK_APP = SimpleNamespace( + config=SimpleNamespace( + intersphinx_timeout=3, + tls_verify=True, + user_agent="python3:python-discord/bot:1.0.0" + ) +) + NO_OVERRIDE_GROUPS = ( "2to3fixer", "token", @@ -102,18 +113,6 @@ def markdownify(html: str) -> DocMarkdownConverter: return DocMarkdownConverter(bullets='•').convert(html) -class DummyObject(object): - """A dummy object which supports assigning anything, which the builtin `object()` does not support normally.""" - - -class SphinxConfiguration: - """Dummy configuration for use with intersphinx.""" - - config = DummyObject() - config.intersphinx_timeout = 3 - config.tls_verify = True - - class InventoryURL(commands.Converter): """ Represents an Intersphinx inventory URL. @@ -128,7 +127,7 @@ class InventoryURL(commands.Converter): async def convert(ctx: commands.Context, url: str) -> str: """Convert url to Intersphinx inventory URL.""" try: - intersphinx.fetch_inventory(SphinxConfiguration(), '', url) + intersphinx.fetch_inventory(SPHINX_MOCK_APP, '', url) except AttributeError: raise commands.BadArgument(f"Failed to fetch Intersphinx inventory from URL `{url}`.") except ConnectionError: @@ -162,7 +161,7 @@ class Doc(commands.Cog): await self.refresh_inventory() async def update_single( - self, package_name: str, base_url: str, inventory_url: str, config: SphinxConfiguration + self, package_name: str, base_url: str, inventory_url: str ) -> None: """ Rebuild the inventory for a single package. @@ -173,12 +172,10 @@ class Doc(commands.Cog): absolute paths that link to specific symbols * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running `intersphinx.fetch_inventory` in an executor on the bot's event loop - * `config` is a `SphinxConfiguration` instance to mock the regular sphinx - project layout, required for use with intersphinx """ self.base_urls[package_name] = base_url - package = await self._fetch_inventory(inventory_url, config) + package = await self._fetch_inventory(inventory_url) if not package: return None @@ -220,15 +217,11 @@ class Doc(commands.Cog): self.renamed_symbols.clear() async_cache.cache = OrderedDict() - # Since Intersphinx is intended to be used with Sphinx, - # we need to mock its configuration. - config = SphinxConfiguration() - # Run all coroutines concurrently - since each of them performs a HTTP # request, this speeds up fetching the inventory data heavily. coros = [ self.update_single( - package["package"], package["base_url"], package["inventory_url"], config + package["package"], package["base_url"], package["inventory_url"] ) for package in await self.bot.api_client.get('bot/documentation-links') ] await asyncio.gather(*coros) @@ -476,9 +469,9 @@ class Doc(commands.Cog): ) await ctx.send(embed=embed) - async def _fetch_inventory(self, inventory_url: str, config: SphinxConfiguration) -> Optional[dict]: + async def _fetch_inventory(self, inventory_url: str) -> Optional[dict]: """Get and return inventory from `inventory_url`. If fetching fails, return None.""" - fetch_func = functools.partial(intersphinx.fetch_inventory, config, '', inventory_url) + fetch_func = functools.partial(intersphinx.fetch_inventory, SPHINX_MOCK_APP, '', inventory_url) for retry in range(1, FAILED_REQUEST_RETRY_AMOUNT+1): try: package = await self.bot.loop.run_in_executor(None, fetch_func) -- cgit v1.2.3 From e56db510cfd7238cffc2353b8bf79b7d0f709209 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 14 Jan 2020 12:08:38 +0000 Subject: Add new partners to invite whitelist --- config-default.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config-default.yml b/config-default.yml index bf8509544..18a2da869 100644 --- a/config-default.yml +++ b/config-default.yml @@ -204,6 +204,8 @@ filter: - 197038439483310086 # Discord Testers - 286633898581164032 # Ren'Py - 349505959032389632 # PyGame + - 438622377094414346 # Pyglet + - 524691714909274162 # Panda3D domain_blacklist: - pornhub.com -- cgit v1.2.3 From 1abe1768ed06c6e0c99ac2b008cc0b7f562f92d5 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Tue, 14 Jan 2020 10:27:16 -0600 Subject: Discord.py server added to whitelist Added discord.py's server to the invite whitelist --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index 18a2da869..f66ba8794 100644 --- a/config-default.yml +++ b/config-default.yml @@ -206,6 +206,7 @@ filter: - 349505959032389632 # PyGame - 438622377094414346 # Pyglet - 524691714909274162 # Panda3D + - 336642139381301249 # discord.py domain_blacklist: - pornhub.com -- cgit v1.2.3 From 76f976289aa6ae85979c2f8596d68db00555c2a5 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 15 Jan 2020 20:55:10 -0500 Subject: Fix user command error for empty custom status In the scenario where a user has a custom status set to only an emoji & blank text value, their activity status is set as `None`, which causes `discord.utils.escape_markdown` to raise an exception, as it's expecting something string-like. We can guard against this by checking for a `None` value & skipping the markdown escape if necessary. --- bot/cogs/information.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 1ede95ff4..125d7ce24 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -189,7 +189,11 @@ class Information(Cog): # Custom status custom_status = '' for activity in user.activities: - if activity.name == 'Custom Status': + # Check activity.state for None value if user has a custom status set + # This guards against a custom status with an emoji but no text, which will cause + # escape_markdown to raise an exception + # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class + if activity.name == 'Custom Status' and activity.state: state = escape_markdown(activity.state) custom_status = f'Status: {state}\n' -- cgit v1.2.3 From d169fbf9204b8a35ad09ab0141eedd873f8bdddf Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 30 Jan 2020 18:17:12 +0100 Subject: Add additional resources to the test readme --- tests/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/README.md b/tests/README.md index d052de2f6..c3551bd54 100644 --- a/tests/README.md +++ b/tests/README.md @@ -212,3 +212,9 @@ All in all, it's not only important to consider if all statements or branches we Another restriction of unit testing is that it tests, well, in units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we are work under the implicit assumption that we fully understand those external parts and utilize it correctly. What if our mocked `Context` object works with a `send` method, but `discord.py` has changed it to a `send_message` method in a recent update? It could mean our tests are passing, but the code it's testing still doesn't work in production. The answer to this is that we also need to make sure that the individual parts come together into a working application. In addition, we will also need to make sure that the application communicates correctly with external applications. Since we currently have no automated integration tests or functional tests, that means **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written. + +## Additional resources + +* [Corey Schafer video about unittest](https://youtu.be/6tNS--WetLI) +* [RealPython tutorial on unittest testing](https://realpython.com/python-testing/) +* [RealPython tutorial on mocking](https://realpython.com/python-mock-library/) -- cgit v1.2.3 From e518dc2193f0f1ed31217e6097c2c6cdbc36e0e1 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 30 Jan 2020 19:04:21 +0100 Subject: Merge the note with the additional resources section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the link to Ned Batchelder’s talk and link the note to the section --- tests/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index c3551bd54..be78821bf 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,7 +2,7 @@ Our bot is one of the most important tools we have for running our community. As we don't want that tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. This guide will help you get started with writing the tests needed to achieve that. -_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY)._ +_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you can take a look at the [Additional resources](#additional-resources) section at the bottom of this page._ ## Tools @@ -215,6 +215,7 @@ The answer to this is that we also need to make sure that the individual parts c ## Additional resources +* [Ned Batchelder's PyCon talk: Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY) * [Corey Schafer video about unittest](https://youtu.be/6tNS--WetLI) * [RealPython tutorial on unittest testing](https://realpython.com/python-testing/) * [RealPython tutorial on mocking](https://realpython.com/python-mock-library/) -- cgit v1.2.3 From 8647cb8db557267d1ef1a95ef97fe334256ba6af Mon Sep 17 00:00:00 2001 From: Joseph Date: Sun, 2 Feb 2020 20:11:51 +0000 Subject: Create CODEOWNERS --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..8aa168278 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @core-developers -- cgit v1.2.3 From e8c94afa3c88633a8ce30fdb849efd8b76cc1599 Mon Sep 17 00:00:00 2001 From: Thomas Petersson Date: Sun, 2 Feb 2020 21:13:18 +0100 Subject: Update config-default.yml --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index f842cf606..1a8aaedae 100644 --- a/config-default.yml +++ b/config-default.yml @@ -389,6 +389,7 @@ anti_malware: - '.mp3' - '.wav' - '.ogg' + - '.md' reddit: -- cgit v1.2.3 From 68709884494b755739723c725f9998d02d17dde3 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sun, 2 Feb 2020 22:20:44 +0000 Subject: Remove prometheus related code --- Pipfile | 1 - Pipfile.lock | 297 ++++++++++++++++++++++------------------------------ bot/__main__.py | 1 - bot/bot.py | 3 - bot/cogs/metrics.py | 98 ----------------- 5 files changed, 128 insertions(+), 272 deletions(-) delete mode 100644 bot/cogs/metrics.py diff --git a/Pipfile b/Pipfile index 68362ae78..48d839fc3 100644 --- a/Pipfile +++ b/Pipfile @@ -19,7 +19,6 @@ deepdiff = "~=4.0" requests = "~=2.22" more_itertools = "~=7.2" urllib3 = ">=1.24.2,<1.25" -prometheus-async = {extras = ["aiohttp"],version = "~=19.2"} [dev-packages] coverage = "~=4.5" diff --git a/Pipfile.lock b/Pipfile.lock index ab5dfb538..279480d2a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d9349e8c704b2b2403004039856d8d75aaebc76e4aa93390c4d177f583e73b71" + "sha256": "c27d699b4aeeed204dee41f924f682ae2a670add8549a8826e58776594370582" }, "pipfile-spec": 6, "requires": { @@ -34,31 +34,21 @@ }, "aiohttp": { "hashes": [ - "sha256:00d198585474299c9c3b4f1d5de1a576cc230d562abc5e4a0e81d71a20a6ca55", - "sha256:0155af66de8c21b8dba4992aaeeabf55503caefae00067a3b1139f86d0ec50ed", - "sha256:09654a9eca62d1bd6d64aa44db2498f60a5c1e0ac4750953fdd79d5c88955e10", - "sha256:199f1d106e2b44b6dacdf6f9245493c7d716b01d0b7fbe1959318ba4dc64d1f5", - "sha256:296f30dedc9f4b9e7a301e5cc963012264112d78a1d3094cd83ef148fdf33ca1", - "sha256:368ed312550bd663ce84dc4b032a962fcb3c7cae099dbbd48663afc305e3b939", - "sha256:40d7ea570b88db017c51392349cf99b7aefaaddd19d2c78368aeb0bddde9d390", - "sha256:629102a193162e37102c50713e2e31dc9a2fe7ac5e481da83e5bb3c0cee700aa", - "sha256:6d5ec9b8948c3d957e75ea14d41e9330e1ac3fed24ec53766c780f82805140dc", - "sha256:87331d1d6810214085a50749160196391a712a13336cd02ce1c3ea3d05bcf8d5", - "sha256:9a02a04bbe581c8605ac423ba3a74999ec9d8bce7ae37977a3d38680f5780b6d", - "sha256:9c4c83f4fa1938377da32bc2d59379025ceeee8e24b89f72fcbccd8ca22dc9bf", - "sha256:9cddaff94c0135ee627213ac6ca6d05724bfe6e7a356e5e09ec57bd3249510f6", - "sha256:a25237abf327530d9561ef751eef9511ab56fd9431023ca6f4803f1994104d72", - "sha256:a5cbd7157b0e383738b8e29d6e556fde8726823dae0e348952a61742b21aeb12", - "sha256:a97a516e02b726e089cffcde2eea0d3258450389bbac48cbe89e0f0b6e7b0366", - "sha256:acc89b29b5f4e2332d65cd1b7d10c609a75b88ef8925d487a611ca788432dfa4", - "sha256:b05bd85cc99b06740aad3629c2585bda7b83bd86e080b44ba47faf905fdf1300", - "sha256:c2bec436a2b5dafe5eaeb297c03711074d46b6eb236d002c13c42f25c4a8ce9d", - "sha256:cc619d974c8c11fe84527e4b5e1c07238799a8c29ea1c1285149170524ba9303", - "sha256:d4392defd4648badaa42b3e101080ae3313e8f4787cb517efd3f5b8157eaefd6", - "sha256:e1c3c582ee11af7f63a34a46f0448fca58e59889396ffdae1f482085061a2889" + "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e", + "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326", + "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a", + "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654", + "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a", + "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4", + "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17", + "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec", + "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd", + "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48", + "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59", + "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965" ], "index": "pypi", - "version": "==3.5.4" + "version": "==3.6.2" }, "aiormq": { "hashes": [ @@ -157,26 +147,25 @@ }, "deepdiff": { "hashes": [ - "sha256:3457ea7cecd51ba48015d89edbb569358af4d9b9e65e28bdb3209608420627f9", - "sha256:5e2343398e90538edaa59c0c99207e996a3a834fdc878c666376f632a760c35a" + "sha256:b3fa588d1eac7fa318ec1fb4f2004568e04cb120a1989feda8e5e7164bcbf07a", + "sha256:ed7342d3ed3c0c2058a3fb05b477c943c9959ef62223dca9baa3375718a25d87" ], "index": "pypi", - "version": "==4.0.9" + "version": "==4.2.0" }, "discord-py": { "hashes": [ - "sha256:7c843b523bb011062b453864e75c7b675a03faf573c58d14c9f096e85984329d" + "sha256:8bfe5628d31771744000f19135c386c74ac337479d7282c26cc1627b9d31f360" ], "index": "pypi", - "version": "==1.2.5" + "version": "==1.3.1" }, "docutils": { "hashes": [ - "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", - "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", - "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" + "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", + "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], - "version": "==0.15.2" + "version": "==0.16" }, "fuzzywuzzy": { "hashes": [ @@ -202,17 +191,10 @@ }, "jinja2": { "hashes": [ - "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", - "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de" + "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250", + "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49" ], - "version": "==2.10.3" - }, - "jsonpickle": { - "hashes": [ - "sha256:d0c5a4e6cb4e58f6d5406bdded44365c2bcf9c836c4f52910cc9ba7245a59dc2", - "sha256:d3e922d781b1d0096df2dad89a2e1f47177d7969b596aea806a9d91b4626b29b" - ], - "version": "==1.2" + "version": "==2.11.1" }, "logmatic-python": { "hashes": [ @@ -223,35 +205,36 @@ }, "lxml": { "hashes": [ - "sha256:00ac0d64949fef6b3693813fe636a2d56d97a5a49b5bbb86e4cc4cc50ebc9ea2", - "sha256:0571e607558665ed42e450d7bf0e2941d542c18e117b1ebbf0ba72f287ad841c", - "sha256:0e3f04a7615fdac0be5e18b2406529521d6dbdb0167d2a690ee328bef7807487", - "sha256:13cf89be53348d1c17b453867da68704802966c433b2bb4fa1f970daadd2ef70", - "sha256:217262fcf6a4c2e1c7cb1efa08bd9ebc432502abc6c255c4abab611e8be0d14d", - "sha256:223e544828f1955daaf4cefbb4853bc416b2ec3fd56d4f4204a8b17007c21250", - "sha256:277cb61fede2f95b9c61912fefb3d43fbd5f18bf18a14fae4911b67984486f5d", - "sha256:3213f753e8ae86c396e0e066866e64c6b04618e85c723b32ecb0909885211f74", - "sha256:4690984a4dee1033da0af6df0b7a6bde83f74e1c0c870623797cec77964de34d", - "sha256:4fcc472ef87f45c429d3b923b925704aa581f875d65bac80f8ab0c3296a63f78", - "sha256:61409bd745a265a742f2693e4600e4dbd45cc1daebe1d5fad6fcb22912d44145", - "sha256:678f1963f755c5d9f5f6968dded7b245dd1ece8cf53c1aa9d80e6734a8c7f41d", - "sha256:6c6d03549d4e2734133badb9ab1c05d9f0ef4bcd31d83e5d2b4747c85cfa21da", - "sha256:6e74d5f4d6ecd6942375c52ffcd35f4318a61a02328f6f1bd79fcb4ffedf969e", - "sha256:7b4fc7b1ecc987ca7aaf3f4f0e71bbfbd81aaabf87002558f5bc95da3a865bcd", - "sha256:7ed386a40e172ddf44c061ad74881d8622f791d9af0b6f5be20023029129bc85", - "sha256:8f54f0924d12c47a382c600c880770b5ebfc96c9fd94cf6f6bdc21caf6163ea7", - "sha256:ad9b81351fdc236bda538efa6879315448411a81186c836d4b80d6ca8217cdb9", - "sha256:bbd00e21ea17f7bcc58dccd13869d68441b32899e89cf6cfa90d624a9198ce85", - "sha256:c3c289762cc09735e2a8f8a49571d0e8b4f57ea831ea11558247b5bdea0ac4db", - "sha256:cf4650942de5e5685ad308e22bcafbccfe37c54aa7c0e30cd620c2ee5c93d336", - "sha256:cfcbc33c9c59c93776aa41ab02e55c288a042211708b72fdb518221cc803abc8", - "sha256:e301055deadfedbd80cf94f2f65ff23126b232b0d1fea28f332ce58137bcdb18", - "sha256:ebbfe24df7f7b5c6c7620702496b6419f6a9aa2fd7f005eb731cc80d7b4692b9", - "sha256:eff69ddbf3ad86375c344339371168640951c302450c5d3e9936e98d6459db06", - "sha256:f6ed60a62c5f1c44e789d2cf14009423cb1646b44a43e40a9cf6a21f077678a1" - ], - "index": "pypi", - "version": "==4.4.2" + "sha256:06d4e0bbb1d62e38ae6118406d7cdb4693a3fa34ee3762238bcb96c9e36a93cd", + "sha256:0701f7965903a1c3f6f09328c1278ac0eee8f56f244e66af79cb224b7ef3801c", + "sha256:1f2c4ec372bf1c4a2c7e4bb20845e8bcf8050365189d86806bad1e3ae473d081", + "sha256:4235bc124fdcf611d02047d7034164897ade13046bda967768836629bc62784f", + "sha256:5828c7f3e615f3975d48f40d4fe66e8a7b25f16b5e5705ffe1d22e43fb1f6261", + "sha256:585c0869f75577ac7a8ff38d08f7aac9033da2c41c11352ebf86a04652758b7a", + "sha256:5d467ce9c5d35b3bcc7172c06320dddb275fea6ac2037f72f0a4d7472035cea9", + "sha256:63dbc21efd7e822c11d5ddbedbbb08cd11a41e0032e382a0fd59b0b08e405a3a", + "sha256:7bc1b221e7867f2e7ff1933165c0cec7153dce93d0cdba6554b42a8beb687bdb", + "sha256:8620ce80f50d023d414183bf90cc2576c2837b88e00bea3f33ad2630133bbb60", + "sha256:8a0ebda56ebca1a83eb2d1ac266649b80af8dd4b4a3502b2c1e09ac2f88fe128", + "sha256:90ed0e36455a81b25b7034038e40880189169c308a3df360861ad74da7b68c1a", + "sha256:95e67224815ef86924fbc2b71a9dbd1f7262384bca4bc4793645794ac4200717", + "sha256:afdb34b715daf814d1abea0317b6d672476b498472f1e5aacbadc34ebbc26e89", + "sha256:b4b2c63cc7963aedd08a5f5a454c9f67251b1ac9e22fd9d72836206c42dc2a72", + "sha256:d068f55bda3c2c3fcaec24bd083d9e2eede32c583faf084d6e4b9daaea77dde8", + "sha256:d5b3c4b7edd2e770375a01139be11307f04341ec709cf724e0f26ebb1eef12c3", + "sha256:deadf4df349d1dcd7b2853a2c8796593cc346600726eff680ed8ed11812382a7", + "sha256:df533af6f88080419c5a604d0d63b2c33b1c0c4409aba7d0cb6de305147ea8c8", + "sha256:e4aa948eb15018a657702fee0b9db47e908491c64d36b4a90f59a64741516e77", + "sha256:e5d842c73e4ef6ed8c1bd77806bf84a7cb535f9c0cf9b2c74d02ebda310070e1", + "sha256:ebec08091a22c2be870890913bdadd86fcd8e9f0f22bcb398abd3af914690c15", + "sha256:edc15fcfd77395e24543be48871c251f38132bb834d9fdfdad756adb6ea37679", + "sha256:f2b74784ed7e0bc2d02bd53e48ad6ba523c9b36c194260b7a5045071abbb1012", + "sha256:fa071559f14bd1e92077b1b5f6c22cf09756c6de7139370249eb372854ce51e6", + "sha256:fd52e796fee7171c4361d441796b64df1acfceb51f29e545e812f16d023c4bbc", + "sha256:fe976a0f1ef09b3638778024ab9fb8cde3118f203364212c198f71341c0715ca" + ], + "index": "pypi", + "version": "==4.5.0" }, "markdownify": { "hashes": [ @@ -266,13 +249,16 @@ "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", + "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", + "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", + "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", @@ -289,7 +275,9 @@ "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" + "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", + "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", + "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], "version": "==1.1.1" }, @@ -331,10 +319,10 @@ }, "packaging": { "hashes": [ - "sha256:aec3fdbb8bc9e4bb65f0634b9f551ced63983a529d6a8931817d52fdd0816ddb", - "sha256:fe1d8331dfa7cc0a883b49d75fc76380b2ab2734b220fbb87d774e4fd4b851f8" + "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73", + "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334" ], - "version": "==20.0" + "version": "==20.1" }, "pamqp": { "hashes": [ @@ -343,23 +331,6 @@ ], "version": "==2.3.0" }, - "prometheus-async": { - "extras": [ - "aiohttp" - ], - "hashes": [ - "sha256:227f516e5bf98a0dc602348381e182358f8b2ed24a8db05e8e34d9cf027bab83", - "sha256:3cc68d1f39e9bbf16dbd0b51103d87671b3cbd1d75a72cda472cd9a35cc9d0d2" - ], - "index": "pypi", - "version": "==19.2.0" - }, - "prometheus-client": { - "hashes": [ - "sha256:71cd24a2b3eb335cb800c7159f423df1bd4dcd5171b234be15e3f31ec9f622da" - ], - "version": "==0.7.1" - }, "pycares": { "hashes": [ "sha256:050f00b39ed77ea8a4e555f09417d4b1a6b5baa24bb9531a3e15d003d2319b3f", @@ -462,10 +433,10 @@ }, "six": { "hashes": [ - "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", - "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" + "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", + "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" ], - "version": "==1.13.0" + "version": "==1.14.0" }, "snowballstemmer": { "hashes": [ @@ -541,35 +512,30 @@ }, "websockets": { "hashes": [ - "sha256:0e2f7d6567838369af074f0ef4d0b802d19fa1fee135d864acc656ceefa33136", - "sha256:2a16dac282b2fdae75178d0ed3d5b9bc3258dabfae50196cbb30578d84b6f6a6", - "sha256:5a1fa6072405648cb5b3688e9ed3b94be683ce4a4e5723e6f5d34859dee495c1", - "sha256:5c1f55a1274df9d6a37553fef8cff2958515438c58920897675c9bc70f5a0538", - "sha256:669d1e46f165e0ad152ed8197f7edead22854a6c90419f544e0f234cc9dac6c4", - "sha256:695e34c4dbea18d09ab2c258994a8bf6a09564e762655408241f6a14592d2908", - "sha256:6b2e03d69afa8d20253455e67b64de1a82ff8612db105113cccec35d3f8429f0", - "sha256:79ca7cdda7ad4e3663ea3c43bfa8637fc5d5604c7737f19a8964781abbd1148d", - "sha256:7fd2dd9a856f72e6ed06f82facfce01d119b88457cd4b47b7ae501e8e11eba9c", - "sha256:82c0354ac39379d836719a77ee360ef865377aa6fdead87909d50248d0f05f4d", - "sha256:8f3b956d11c5b301206382726210dc1d3bee1a9ccf7aadf895aaf31f71c3716c", - "sha256:91ec98640220ae05b34b79ee88abf27f97ef7c61cf525eec57ea8fcea9f7dddb", - "sha256:952be9540d83dba815569d5cb5f31708801e0bbfc3a8c5aef1890b57ed7e58bf", - "sha256:99ac266af38ba1b1fe13975aea01ac0e14bb5f3a3200d2c69f05385768b8568e", - "sha256:9fa122e7adb24232247f8a89f2d9070bf64b7869daf93ac5e19546b409e47e96", - "sha256:a0873eadc4b8ca93e2e848d490809e0123eea154aa44ecd0109c4d0171869584", - "sha256:cb998bd4d93af46b8b49ecf5a72c0a98e5cc6d57fdca6527ba78ad89d6606484", - "sha256:e02e57346f6a68523e3c43bbdf35dde5c440318d1f827208ae455f6a2ace446d", - "sha256:e79a5a896bcee7fff24a788d72e5c69f13e61369d055f28113e71945a7eb1559", - "sha256:ee55eb6bcf23ecc975e6b47c127c201b913598f38b6a300075f84eeef2d3baff", - "sha256:f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454" - ], - "version": "==6.0" - }, - "wrapt": { - "hashes": [ - "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" - ], - "version": "==1.11.2" + "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5", + "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5", + "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308", + "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb", + "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a", + "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c", + "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170", + "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422", + "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8", + "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485", + "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f", + "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8", + "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc", + "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779", + "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989", + "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1", + "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092", + "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824", + "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d", + "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55", + "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36", + "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b" + ], + "version": "==8.1" }, "yarl": { "hashes": [ @@ -762,10 +728,10 @@ }, "identify": { "hashes": [ - "sha256:6f44e637caa40d1b4cb37f6ed3b262ede74901d28b1cc5b1fc07360871edd65d", - "sha256:72e9c4ed3bc713c7045b762b0d2e2115c572b85abfc1f4604f5a4fd4c6642b71" + "sha256:1222b648251bdcb8deb240b294f450fbf704c7984e08baa92507e4ea10b436d5", + "sha256:d824ebe21f38325c771c41b08a95a761db1982f1fc0eee37c6c97df3f1636b96" ], - "version": "==1.4.9" + "version": "==1.4.11" }, "idna": { "hashes": [ @@ -776,11 +742,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:bdd9b7c397c273bcc9a11d6629a38487cd07154fa255a467bf704cd2c258e359", - "sha256:f17c015735e1a88296994c0697ecea7e11db24290941983b08c9feb30921e6d8" + "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302", + "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b" ], "markers": "python_version < '3.8'", - "version": "==1.4.0" + "version": "==1.5.0" }, "mccabe": { "hashes": [ @@ -789,14 +755,6 @@ ], "version": "==0.6.1" }, - "more-itertools": { - "hashes": [ - "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", - "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" - ], - "index": "pypi", - "version": "==7.2.0" - }, "nodeenv": { "hashes": [ "sha256:561057acd4ae3809e665a9aaaf214afff110bbb6a6d5c8a96121aea6878408b3" @@ -805,10 +763,10 @@ }, "packaging": { "hashes": [ - "sha256:aec3fdbb8bc9e4bb65f0634b9f551ced63983a529d6a8931817d52fdd0816ddb", - "sha256:fe1d8331dfa7cc0a883b49d75fc76380b2ab2734b220fbb87d774e4fd4b851f8" + "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73", + "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334" ], - "version": "==20.0" + "version": "==20.1" }, "pre-commit": { "hashes": [ @@ -881,10 +839,10 @@ }, "six": { "hashes": [ - "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", - "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" + "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", + "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" ], - "version": "==1.13.0" + "version": "==1.14.0" }, "snowballstemmer": { "hashes": [ @@ -902,29 +860,30 @@ }, "typed-ast": { "hashes": [ - "sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161", - "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", - "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", - "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", - "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", - "sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47", - "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", - "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", - "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", - "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", - "sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2", - "sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e", - "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", - "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", - "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", - "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", - "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", - "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", - "sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", - "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12" + "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", + "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", + "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", + "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", + "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", + "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", + "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", + "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", + "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", + "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", + "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", + "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", + "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", + "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", + "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", + "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", + "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", + "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", + "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", + "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", + "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" ], "markers": "python_version < '3.8'", - "version": "==1.4.0" + "version": "==1.4.1" }, "unittest-xml-reporting": { "hashes": [ @@ -951,10 +910,10 @@ }, "zipp": { "hashes": [ - "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", - "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335" + "sha256:ccc94ed0909b58ffe34430ea5451f07bc0c76467d7081619a454bf5c98b89e28", + "sha256:feae2f18633c32fc71f2de629bfb3bd3c9325cd4419642b1f1da42ee488d9b98" ], - "version": "==0.6.0" + "version": "==2.1.0" } } } diff --git a/bot/__main__.py b/bot/__main__.py index 61271a692..84bc7094b 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -40,7 +40,6 @@ bot.load_extension("bot.cogs.duck_pond") bot.load_extension("bot.cogs.free") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") -bot.load_extension("bot.cogs.metrics") bot.load_extension("bot.cogs.moderation") bot.load_extension("bot.cogs.off_topic_names") bot.load_extension("bot.cogs.reddit") diff --git a/bot/bot.py b/bot/bot.py index 930aaf70e..8f808272f 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -4,7 +4,6 @@ from typing import Optional import aiohttp from discord.ext import commands -from prometheus_async.aio.web import start_http_server as start_prometheus_http_server from bot import api @@ -51,6 +50,4 @@ class Bot(commands.Bot): """Open an aiohttp session before logging in and connecting to Discord.""" self.http_session = aiohttp.ClientSession(connector=self.connector) - await start_prometheus_http_server(addr="0.0.0.0", port=9330) - log.debug("Started Prometheus server on port 9330.") await super().start(*args, **kwargs) diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py deleted file mode 100644 index 47c3cc55e..000000000 --- a/bot/cogs/metrics.py +++ /dev/null @@ -1,98 +0,0 @@ -from collections import defaultdict - -from discord import Member, Message -from discord.ext.commands import Cog, Context -from prometheus_client import Counter, Gauge - -from bot.bot import Bot - - -class Metrics(Cog): - """ - Exports metrics for Prometheus. - - See https://github.com/prometheus/client_python for metric documentation. - """ - - PREFIX = 'pydis_bot' - - def __init__(self, bot: Bot) -> None: - self.bot = bot - - self.guild_members = Gauge( - name=f'{self.PREFIX}_guild_members', - documentation="Total members by guild by status.", - labelnames=('guild_id', 'status') - ) - self.guild_messages = Counter( - name=f'{self.PREFIX}_guild_messages', - documentation="Guild messages by guild by channel.", - labelnames=('channel_id', 'guild_id', 'channel_name') - ) - self.command_completions = Counter( - name=f'{self.PREFIX}_command_completions', - documentation="Completed commands by command, user, and guild.", - labelnames=('guild_id', 'user_id', 'user_name', 'command') - ) - - @Cog.listener() - async def on_ready(self) -> None: - """Initialize the guild member counter.""" - members_by_status = defaultdict(lambda: defaultdict(int)) - - for guild in self.bot.guilds: - if guild.large: - await self.bot.request_offline_members(guild) - for member in guild.members: - members_by_status[guild.id][member.status] += 1 - - for guild_id, members in members_by_status.items(): - for status, count in members.items(): - self.guild_members.labels(guild_id=guild_id, status=str(status)).set(count) - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """Increment the member gauge.""" - self.guild_members.labels(guild_id=member.guild.id, status=str(member.status)).inc() - - @Cog.listener() - async def on_member_leave(self, member: Member) -> None: - """Decrement the member gauge.""" - self.guild_members.labels(guild_id=member.guild.id, status=str(member.status)).dec() - - @Cog.listener() - async def on_member_update(self, before: Member, after: Member) -> None: - """Update member gauges for the new and old status if applicable.""" - if before.status is not after.status: - self.guild_members.labels(guild_id=after.guild.id, status=str(before.status)).dec() - self.guild_members.labels(guild_id=after.guild.id, status=str(after.status)).inc() - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Increment the guild message counter.""" - self.guild_messages.labels( - channel_id=message.channel.id, - channel_name=message.channel.name, - guild_id=message.guild.id, - ).inc() - - @Cog.listener() - async def on_command_completion(self, ctx: Context) -> None: - """Increment the command completion counter.""" - if ctx.message.guild is not None: - if ctx.command.full_parent_name: - command = f'{ctx.command.full_parent_name} {ctx.command.name}' - else: - command = ctx.command.name - - self.command_completions.labels( - guild_id=ctx.message.guild.id, - user_id=ctx.author.id, - user_name=str(ctx.author), - command=command, - ).inc() - - -def setup(bot: Bot) -> None: - """Load the Metrics cog.""" - bot.add_cog(Metrics(bot)) -- cgit v1.2.3 From 6ec42d8cc7fe25e56919374c2c987d1284806470 Mon Sep 17 00:00:00 2001 From: Joseph Date: Sun, 2 Feb 2020 22:23:01 +0000 Subject: Update CODEOWNERS --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8aa168278..cf5f1590d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @core-developers +* @python-discord/core-developers -- cgit v1.2.3 From ae388820dc45f12b6486dfa793aecb9ba5f0345b Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 3 Feb 2020 13:27:07 +0000 Subject: Replace constant attachment-repost by the actual ID of #attachment-log --- bot/cogs/antispam.py | 2 +- bot/constants.py | 2 +- config-default.yml | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 80f82ab91..bbc1a5359 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -61,7 +61,7 @@ class DeletionContext: self.messages[message.id] = message # Re-upload attachments - destination = message.guild.get_channel(GuildConfig.attachment_repost) + destination = message.guild.get_channel(Channels.attachment_log) urls = await send_attachments(message, destination, link_large=False) self.attachments.append(urls) diff --git a/bot/constants.py b/bot/constants.py index 9a4522262..2050359cd 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -347,6 +347,7 @@ class Channels(metaclass=YAMLGetter): admins: int admin_spam: int announcements: int + attachment_repost: int big_brother_logs: int bot: int checkpoint_test: int @@ -417,7 +418,6 @@ class Guild(metaclass=YAMLGetter): id: int ignored: List[int] staff_channels: List[int] - attachment_repost: int class Keys(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 0ecf88702..5e85c897d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -138,8 +138,7 @@ guild: verification: 352442727016693763 staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] - ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] - attachment_repost: *ATTCH_LOG + ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ATTCH_LOG] roles: admin: &ADMIN_ROLE 267628507062992896 -- cgit v1.2.3 From 891257d1d9fc903fc007272b32bf15f315fffe99 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 3 Feb 2020 13:40:49 +0000 Subject: Change typehint name for the attachment-log constant --- bot/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 223ebdaea..629985bdf 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -359,7 +359,7 @@ class Channels(metaclass=YAMLGetter): admins: int admin_spam: int announcements: int - attachment_repost: int + attachment_log: int big_brother_logs: int bot: int checkpoint_test: int -- cgit v1.2.3 From 2dc728e2d43448c67d87aa32e12598e5068c4353 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Tue, 4 Feb 2020 00:53:00 +1000 Subject: Use a trailing underscore to avoid name conflicts. Previously used a leading underscore, but that's usually meant for non-used names. --- bot/cogs/antispam.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index bbc1a5359..f67ef6f05 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -260,10 +260,10 @@ class AntiSpam(Cog): await deletion_context.upload_messages(self.bot.user.id, self.mod_log) -def validate_config(_rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: +def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: """Validates the antispam configs.""" validation_errors = {} - for name, config in _rules.items(): + for name, config in rules_.items(): if name not in RULE_FUNCTION_MAPPING: log.error( f"Unrecognized antispam rule `{name}`. " -- cgit v1.2.3 From 7f0e6733de8e2b6c3d13834916d790673547e1fb Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Tue, 4 Feb 2020 12:23:29 +0700 Subject: Fixed _last_fetch not being updated after each api call. - Changed type of `self._last_fetch` to `float` and give it the initial value of `0.0` instead of `None` - Assigned `time.time()` to `time_now` to avoid calling this function twice. - Added `self._last_fetch = time_now` after calling the api call. --- bot/cogs/tags.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 7effaf754..9e06b702c 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -27,14 +27,16 @@ class Tags(Cog): self.tag_cooldowns = {} self._cache = {} - self._last_fetch = None + self._last_fetch: float = 0.0 async def _get_tags(self, is_forced: bool = False) -> None: - """Getting all tags.""" - # Refresh only when there's a more than 5m gap from last call. - if is_forced or not self._last_fetch or time.time() - self._last_fetch > 5 * 60: + """Get all tags.""" + # refresh only when there's a more than 5m gap from last call. + time_now: float = time.time() + if is_forced or not self._last_fetch or time_now - self._last_fetch > 5 * 60: tags = await self.bot.api_client.get('bot/tags') self._cache = {tag['title'].lower(): tag for tag in tags} + self._last_fetch = time_now @staticmethod def _fuzzy_search(search: str, target: str) -> bool: -- cgit v1.2.3 From 868de4716c5b6a3120f665d460a8987bd6f16302 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Tue, 4 Feb 2020 12:26:27 +0700 Subject: Refactored _get_suggestions following Mark's suggestions about inefficiency. - Matching scores will be calculated once now and stored in the dict `scores`. - Allow `_get_suggestions()` to go through a list of score threshold and return the first list of matching tags that's not empty and above the threshold. This avoid calling the function multiple time like before ( `self._get_suggestions(tag_name, 100) or self._get_suggestions(tag_name, 80)` for example, is calling this function twice, and is inefficient ) - Deleted commented line. - Added `typing` module for more typehints. --- bot/cogs/tags.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 9e06b702c..8d3586b19 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,5 +1,6 @@ import logging import time +from typing import Dict, List, Optional from discord import Colour, Embed from discord.ext.commands import Cog, Context, group @@ -39,9 +40,9 @@ class Tags(Cog): self._last_fetch = time_now @staticmethod - def _fuzzy_search(search: str, target: str) -> bool: - found = 0 - index = 0 + def _fuzzy_search(search: str, target: str) -> int: + """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" + found, index = 0, 0 _search = search.lower().replace(' ', '') _target = target.lower().replace(' ', '') for letter in _search: @@ -51,19 +52,32 @@ class Tags(Cog): found += index > 0 return found / len(_search) * 100 - def _get_suggestions(self, tag_name: str, score: int) -> list: - return sorted( - (tag for tag in self._cache.values() if Tags._fuzzy_search(tag_name, tag['title']) >= score), - key=lambda tag: Tags._fuzzy_search(tag_name, tag['title']), - reverse=True - ) + def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]: + """Return a list of suggested tags.""" + scores: Dict[str, int] = { + tag_title: Tags._fuzzy_search(tag_name, tag['title']) + for tag_title, tag in self._cache.items() + } + + thresholds = thresholds or [100, 80] + + for threshold in thresholds: + suggestions = [ + self._cache[tag_title] + for tag_title, matching_score in scores.items() + if matching_score >= threshold + ] + if suggestions: + return suggestions + + return [] async def _get_tag(self, tag_name: str) -> list: """Get a specific tag.""" await self._get_tags() found = [self._cache.get(tag_name.lower(), None)] if not found[0]: - return self._get_suggestions(tag_name, 100) or self._get_suggestions(tag_name, 80) + return self._get_suggestions(tag_name, thresholds=[100, 80]) return found @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) @@ -102,7 +116,6 @@ class Tags(Cog): await self._get_tags() if tag_name is not None: - # tag = await self.bot.api_client.get(f'bot/tags/{tag_name}') founds = await self._get_tag(tag_name) if len(founds) == 1: @@ -120,7 +133,6 @@ class Tags(Cog): )) else: - # tags = await self.bot.api_client.get('bot/tags') tags = self._cache.values() if not tags: await ctx.send(embed=Embed( -- cgit v1.2.3 From a38926fe797cdcc13d64d836776f56db09e9efd2 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 5 Feb 2020 04:00:46 +0700 Subject: Removed non-alphabets from both search and tag_name when scoring. - Added a regex to remove non-alphabet ( `[^a-z]` with `re.IGNORECASE` ) --- bot/cogs/tags.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 8d3586b19..0e8cf0278 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,4 +1,5 @@ import logging +import re import time from typing import Dict, List, Optional @@ -19,6 +20,8 @@ TEST_CHANNELS = ( Channels.helpers ) +REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.IGNORECASE & re.MULTILINE) + class Tags(Cog): """Save new tags and fetch existing tags.""" @@ -43,8 +46,8 @@ class Tags(Cog): def _fuzzy_search(search: str, target: str) -> int: """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" found, index = 0, 0 - _search = search.lower().replace(' ', '') - _target = target.lower().replace(' ', '') + _search = REGEX_NON_ALPHABET.sub('', search.lower()) + _target = REGEX_NON_ALPHABET.sub('', target.lower()) for letter in _search: index = _target.find(letter, index) if index == -1: -- cgit v1.2.3 From a6341b13cb3b3fc3ea95942a51478e875205abc6 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 5 Feb 2020 04:01:41 +0700 Subject: Increased default thresholds from just [100, 80] to [100, 90, 80, 70, 60] - Since it is returning as soon as there are suggestions found for a threshold, this will give a better reflection of what the bot thinks user is searching for. --- bot/cogs/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 0e8cf0278..8122f739e 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -62,7 +62,7 @@ class Tags(Cog): for tag_title, tag in self._cache.items() } - thresholds = thresholds or [100, 80] + thresholds = thresholds or [100, 90, 80, 70, 60] for threshold in thresholds: suggestions = [ @@ -80,7 +80,7 @@ class Tags(Cog): await self._get_tags() found = [self._cache.get(tag_name.lower(), None)] if not found[0]: - return self._get_suggestions(tag_name, thresholds=[100, 80]) + return self._get_suggestions(tag_name) return found @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) -- cgit v1.2.3 From c054790975670ee9e2b1855590d01491f3732b33 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 5 Feb 2020 11:48:07 +0700 Subject: Removed regex, implemented a stricter letter searching. --- bot/cogs/tags.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 8122f739e..eaf307569 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,5 +1,4 @@ import logging -import re import time from typing import Dict, List, Optional @@ -20,8 +19,6 @@ TEST_CHANNELS = ( Channels.helpers ) -REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.IGNORECASE & re.MULTILINE) - class Tags(Cog): """Save new tags and fetch existing tags.""" @@ -46,13 +43,18 @@ class Tags(Cog): def _fuzzy_search(search: str, target: str) -> int: """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" found, index = 0, 0 - _search = REGEX_NON_ALPHABET.sub('', search.lower()) - _target = REGEX_NON_ALPHABET.sub('', target.lower()) - for letter in _search: - index = _target.find(letter, index) - if index == -1: - break - found += index > 0 + _search = search.lower().replace(' ', '') + _targets = iter(target.lower()) + _target = next(_targets) + try: + for letter in _search: + index = _target.find(letter, index) + while index == -1: + _target = next(_targets) + index = _target.find(letter) + found += 1 + except StopIteration: + pass return found / len(_search) * 100 def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]: -- cgit v1.2.3 From 8dd66bc12ecae678c2f17819b298b60823806b95 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 5 Feb 2020 14:03:54 +0700 Subject: Made searching even stricter by searching from start of each word - Added regex back to sub and split by non-alphabet. - Now use two pointers to move from words to words. --- bot/cogs/tags.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index eaf307569..54a51921c 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,4 +1,5 @@ import logging +import re import time from typing import Dict, List, Optional @@ -19,6 +20,8 @@ TEST_CHANNELS = ( Channels.helpers ) +REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) + class Tags(Cog): """Save new tags and fetch existing tags.""" @@ -42,20 +45,19 @@ class Tags(Cog): @staticmethod def _fuzzy_search(search: str, target: str) -> int: """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" - found, index = 0, 0 - _search = search.lower().replace(' ', '') - _targets = iter(target.lower()) + current, index = 0, 0 + _search = REGEX_NON_ALPHABET.sub('', search.lower()) + _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) _target = next(_targets) try: - for letter in _search: - index = _target.find(letter, index) - while index == -1: - _target = next(_targets) - index = _target.find(letter) - found += 1 - except StopIteration: + while True: + while index < len(_target) and _search[current] == _target[index]: + current += 1 + index += 1 + index, _target = 0, next(_targets) + except (StopIteration, IndexError): pass - return found / len(_search) * 100 + return current / len(_search) * 100 def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]: """Return a list of suggested tags.""" -- cgit v1.2.3 From df023e0b03795a2a7f30d3ab523fc2998f226234 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Wed, 5 Feb 2020 22:45:18 +1000 Subject: Move tools and questions guide to under resources After the wiki pages adjustment to the resources page, these two urls are needing to be updated to point to the new correct locations for each page. Tools will be under resources, and Asking Good Questions is a guide, so will be under Guides. --- bot/cogs/site.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 2ea8c7a2e..10180ebae 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -59,7 +59,7 @@ class Site(Cog): @site_group.command(name="tools") async def site_tools(self, ctx: Context) -> None: """Info about the site's Tools page.""" - tools_url = f"{PAGES_URL}/tools" + tools_url = f"{PAGES_URL}/resources/tools" embed = Embed(title="Tools") embed.set_footer(text=f"{tools_url}") @@ -74,7 +74,7 @@ class Site(Cog): @site_group.command(name="help") async def site_help(self, ctx: Context) -> None: """Info about the site's Getting Help page.""" - url = f"{PAGES_URL}/asking-good-questions" + url = f"{PAGES_URL}resources/guides/asking-good-questions" embed = Embed(title="Asking Good Questions") embed.set_footer(text=url) -- cgit v1.2.3 From b9c691646a8be52095f644ba2cd7ffd8eb36ae6e Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 6 Feb 2020 01:01:29 +1000 Subject: Add missing slash to asking good questions url. --- bot/cogs/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 10180ebae..853e29568 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -74,7 +74,7 @@ class Site(Cog): @site_group.command(name="help") async def site_help(self, ctx: Context) -> None: """Info about the site's Getting Help page.""" - url = f"{PAGES_URL}resources/guides/asking-good-questions" + url = f"{PAGES_URL}/resources/guides/asking-good-questions" embed = Embed(title="Asking Good Questions") embed.set_footer(text=url) -- cgit v1.2.3 From e70b29ef9549202c85c84011d5282eec2dcb2418 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 7 Feb 2020 12:23:20 -0800 Subject: Update discord.py to 1.3.1 --- Pipfile | 2 +- Pipfile.lock | 71 ++++++++++++++++++++++++++++-------------------------------- 2 files changed, 34 insertions(+), 39 deletions(-) diff --git a/Pipfile b/Pipfile index 48d839fc3..318e77438 100644 --- a/Pipfile +++ b/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -discord-py = "~=1.2" +discord-py = ">=1.3.1, ==1.*" aiodns = "~=2.0" logmatic-python = "~=0.1" aiohttp = "~=3.5" diff --git a/Pipfile.lock b/Pipfile.lock index 279480d2a..f7b19737c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c27d699b4aeeed204dee41f924f682ae2a670add8549a8826e58776594370582" + "sha256": "d344e66ca0980e7985dee8c168f9e0f9d7fd1b41b88778a094bacf5afb6d33bd" }, "pipfile-spec": 6, "requires": { @@ -102,41 +102,36 @@ }, "cffi": { "hashes": [ - "sha256:0b49274afc941c626b605fb59b59c3485c17dc776dc3cc7cc14aca74cc19cc42", - "sha256:0e3ea92942cb1168e38c05c1d56b0527ce31f1a370f6117f1d490b8dcd6b3a04", - "sha256:135f69aecbf4517d5b3d6429207b2dff49c876be724ac0c8bf8e1ea99df3d7e5", - "sha256:19db0cdd6e516f13329cba4903368bff9bb5a9331d3410b1b448daaadc495e54", - "sha256:2781e9ad0e9d47173c0093321bb5435a9dfae0ed6a762aabafa13108f5f7b2ba", - "sha256:291f7c42e21d72144bb1c1b2e825ec60f46d0a7468f5346841860454c7aa8f57", - "sha256:2c5e309ec482556397cb21ede0350c5e82f0eb2621de04b2633588d118da4396", - "sha256:2e9c80a8c3344a92cb04661115898a9129c074f7ab82011ef4b612f645939f12", - "sha256:32a262e2b90ffcfdd97c7a5e24a6012a43c61f1f5a57789ad80af1d26c6acd97", - "sha256:3c9fff570f13480b201e9ab69453108f6d98244a7f495e91b6c654a47486ba43", - "sha256:415bdc7ca8c1c634a6d7163d43fb0ea885a07e9618a64bda407e04b04333b7db", - "sha256:42194f54c11abc8583417a7cf4eaff544ce0de8187abaf5d29029c91b1725ad3", - "sha256:4424e42199e86b21fc4db83bd76909a6fc2a2aefb352cb5414833c030f6ed71b", - "sha256:4a43c91840bda5f55249413037b7a9b79c90b1184ed504883b72c4df70778579", - "sha256:599a1e8ff057ac530c9ad1778293c665cb81a791421f46922d80a86473c13346", - "sha256:5c4fae4e9cdd18c82ba3a134be256e98dc0596af1e7285a3d2602c97dcfa5159", - "sha256:5ecfa867dea6fabe2a58f03ac9186ea64da1386af2159196da51c4904e11d652", - "sha256:62f2578358d3a92e4ab2d830cd1c2049c9c0d0e6d3c58322993cc341bdeac22e", - "sha256:6471a82d5abea994e38d2c2abc77164b4f7fbaaf80261cb98394d5793f11b12a", - "sha256:6d4f18483d040e18546108eb13b1dfa1000a089bcf8529e30346116ea6240506", - "sha256:71a608532ab3bd26223c8d841dde43f3516aa5d2bf37b50ac410bb5e99053e8f", - "sha256:74a1d8c85fb6ff0b30fbfa8ad0ac23cd601a138f7509dc617ebc65ef305bb98d", - "sha256:7b93a885bb13073afb0aa73ad82059a4c41f4b7d8eb8368980448b52d4c7dc2c", - "sha256:7d4751da932caaec419d514eaa4215eaf14b612cff66398dd51129ac22680b20", - "sha256:7f627141a26b551bdebbc4855c1157feeef18241b4b8366ed22a5c7d672ef858", - "sha256:8169cf44dd8f9071b2b9248c35fc35e8677451c52f795daa2bb4643f32a540bc", - "sha256:aa00d66c0fab27373ae44ae26a66a9e43ff2a678bf63a9c7c1a9a4d61172827a", - "sha256:ccb032fda0873254380aa2bfad2582aedc2959186cce61e3a17abc1a55ff89c3", - "sha256:d754f39e0d1603b5b24a7f8484b22d2904fa551fe865fd0d4c3332f078d20d4e", - "sha256:d75c461e20e29afc0aee7172a0950157c704ff0dd51613506bd7d82b718e7410", - "sha256:dcd65317dd15bc0451f3e01c80da2216a31916bdcffd6221ca1202d96584aa25", - "sha256:e570d3ab32e2c2861c4ebe6ffcad6a8abf9347432a37608fe1fbd157b3f0036b", - "sha256:fd43a88e045cf992ed09fa724b5315b790525f2676883a6ea64e3263bae6549d" - ], - "version": "==1.13.2" + "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff", + "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b", + "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac", + "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0", + "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384", + "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26", + "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6", + "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b", + "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e", + "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd", + "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2", + "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66", + "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc", + "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8", + "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55", + "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4", + "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5", + "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d", + "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78", + "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa", + "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793", + "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f", + "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a", + "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f", + "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30", + "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f", + "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3", + "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c" + ], + "version": "==1.14.0" }, "chardet": { "hashes": [ @@ -757,9 +752,9 @@ }, "nodeenv": { "hashes": [ - "sha256:561057acd4ae3809e665a9aaaf214afff110bbb6a6d5c8a96121aea6878408b3" + "sha256:5b2438f2e42af54ca968dd1b374d14a1194848955187b0e5e4be1f73813a5212" ], - "version": "==1.3.4" + "version": "==1.3.5" }, "packaging": { "hashes": [ -- cgit v1.2.3 From 4e07c31fa018e3186e24f5d3d63b6a640821ed7a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 7 Feb 2020 12:50:54 -0800 Subject: Pin discord.py to 1.3.x --- Pipfile | 2 +- Pipfile.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Pipfile b/Pipfile index 318e77438..7fd3efae8 100644 --- a/Pipfile +++ b/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -discord-py = ">=1.3.1, ==1.*" +discord-py = "~=1.3.1" aiodns = "~=2.0" logmatic-python = "~=0.1" aiohttp = "~=3.5" diff --git a/Pipfile.lock b/Pipfile.lock index f7b19737c..bf8ff47e9 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d344e66ca0980e7985dee8c168f9e0f9d7fd1b41b88778a094bacf5afb6d33bd" + "sha256": "0a0354a8cbd25b19c61b68f928493a445e737dc6447c97f4c4b52fbf72d887ac" }, "pipfile-spec": 6, "requires": { -- cgit v1.2.3 From 9efd88047ed9201ab6bb6077de10b39490b9434f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 8 Feb 2020 07:40:26 -0800 Subject: Remove clear reaction from paginators It could be confused with the delete reaction. Clearing reactions manually is rarely a useful feature anyway. --- bot/cogs/help.py | 7 +------ bot/pagination.py | 12 +----------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index ecf14d131..fd5bbc3ca 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -14,7 +14,7 @@ from bot.bot import Bot from bot.constants import Channels, Emojis, STAFF_ROLES from bot.decorators import redirect_output from bot.pagination import ( - CLEAR_EMOJI, FIRST_EMOJI, LAST_EMOJI, + FIRST_EMOJI, LAST_EMOJI, LEFT_EMOJI, LinePaginator, RIGHT_EMOJI, ) @@ -25,7 +25,6 @@ REACTIONS = { LEFT_EMOJI: 'back', RIGHT_EMOJI: 'next', LAST_EMOJI: 'end', - CLEAR_EMOJI: 'clear', DELETE_EMOJI: 'stop', } @@ -499,10 +498,6 @@ class HelpSession: if not self.is_last_page: await self.update_page(len(self._pages)-1) - async def do_clear(self) -> None: - """Event that is called when the user clears the emojis from the pagination.""" - await self.message.clear_reactions() - async def do_stop(self) -> None: """Event that is called when the user requests to stop the help session.""" await self.message.delete() diff --git a/bot/pagination.py b/bot/pagination.py index a7938fe85..35870c040 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -12,10 +12,9 @@ FIRST_EMOJI = "\u23EE" # [:track_previous:] LEFT_EMOJI = "\u2B05" # [:arrow_left:] RIGHT_EMOJI = "\u27A1" # [:arrow_right:] LAST_EMOJI = "\u23ED" # [:track_next:] -CLEAR_EMOJI = "\u274c" # [:x:] DELETE_EMOJI = constants.Emojis.trashcan # [:trashcan:] -PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, CLEAR_EMOJI, DELETE_EMOJI] +PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI] log = logging.getLogger(__name__) @@ -206,10 +205,6 @@ class LinePaginator(Paginator): log.debug("Timed out waiting for a reaction") break # We're done, no reactions for the last 5 minutes - if reaction.emoji == CLEAR_EMOJI: - log.debug("Got clear reaction") - break - if str(reaction.emoji) == DELETE_EMOJI: log.debug("Got delete reaction") return await message.delete() @@ -395,11 +390,6 @@ class ImagePaginator(Paginator): # Deletes the users reaction await message.remove_reaction(reaction.emoji, user) - # Clear reaction press - [:x:] - if reaction.emoji == CLEAR_EMOJI: - log.debug("Got clear reaction") - break - # Delete reaction press - [:trashcan:] if str(reaction.emoji) == DELETE_EMOJI: log.debug("Got delete reaction") -- cgit v1.2.3