diff options
author | 2021-02-08 11:02:45 +0100 | |
---|---|---|
committer | 2021-02-08 11:02:45 +0100 | |
commit | 9c23fdede6070bb6741321fda8882a72c926613a (patch) | |
tree | 365702a1b63ee73b3b08536f724b3993059a3c17 | |
parent | "handle converting" -> "convert ... for you". (diff) | |
parent | Merge pull request #1406 from python-discord/revert-1396-dynamic-available-he... (diff) |
Merge branch 'master' into swfarnsworth/tag_messages
-rw-r--r-- | bot/converters.py | 2 | ||||
-rw-r--r-- | bot/exts/backend/error_handler.py | 8 | ||||
-rw-r--r-- | bot/exts/moderation/infraction/_scheduler.py | 1 | ||||
-rw-r--r-- | bot/exts/moderation/infraction/infractions.py | 15 | ||||
-rw-r--r-- | bot/exts/moderation/modlog.py | 2 | ||||
-rw-r--r-- | bot/pagination.py | 14 | ||||
-rw-r--r-- | bot/resources/tags/defaultdict.md | 21 | ||||
-rw-r--r-- | bot/resources/tags/floats.md | 20 | ||||
-rw-r--r-- | bot/resources/tags/local-file.md | 23 | ||||
-rw-r--r-- | bot/resources/tags/voice-verification.md | 3 | ||||
-rw-r--r-- | bot/utils/messages.py | 10 | ||||
-rw-r--r-- | tests/bot/exts/moderation/infraction/test_infractions.py | 50 |
12 files changed, 148 insertions, 21 deletions
diff --git a/bot/converters.py b/bot/converters.py index d0a9731d6..0d9a519df 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -350,7 +350,7 @@ class Duration(DurationDelta): try: return now + delta - except ValueError: + except (ValueError, OverflowError): raise BadArgument(f"`{duration}` results in a datetime outside the supported range.") diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index b8bb3757f..ed7962b06 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -85,8 +85,14 @@ class ErrorHandler(Cog): else: await self.handle_unexpected_error(ctx, e.original) return # Exit early to avoid logging. + elif isinstance(e, errors.ConversionError): + if isinstance(e.original, ResponseCodeError): + await self.handle_api_error(ctx, e.original) + else: + await self.handle_unexpected_error(ctx, e.original) + return # Exit early to avoid logging. elif not isinstance(e, errors.DisabledCommand): - # ConversionError, MaxConcurrencyReached, ExtensionError + # MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) return # Exit early to avoid logging. diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 242b2d30f..a73f2e8da 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -102,6 +102,7 @@ class InfractionScheduler: """ Apply an infraction to the user, log the infraction, and optionally notify the user. + `action_coro`, if not provided, will result in the infraction not getting scheduled for deletion. `user_reason`, if provided, will be sent to the user in place of the infraction reason. `additional_info` will be attached to the text field in the mod-log embed. diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index b3d069b34..7349d65f2 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -257,6 +257,10 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_update, user.id) async def action() -> None: + # Skip members that left the server + if not isinstance(user, Member): + return + await user.add_roles(self._muted_role, reason=reason) log.trace(f"Attempting to kick {user} from voice because they've been muted.") @@ -351,10 +355,15 @@ class Infractions(InfractionScheduler, commands.Cog): if reason: reason = textwrap.shorten(reason, width=512, placeholder="...") - await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + async def action() -> None: + # Skip members that left the server + if not isinstance(user, Member): + return - action = user.remove_roles(self._voice_verified_role, reason=reason) - await self.apply_infraction(ctx, infraction, user, action) + await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + await user.remove_roles(self._voice_verified_role, reason=reason) + + await self.apply_infraction(ctx, infraction, user, action()) # endregion # region: Base pardon functions diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index e4b119f41..2dae9d268 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -546,6 +546,7 @@ class ModLog(Cog, name="ModLog"): f"**Author:** {format_user(author)}\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" + f"[Jump to message]({message.jump_url})\n" "\n" ) else: @@ -553,6 +554,7 @@ class ModLog(Cog, name="ModLog"): f"**Author:** {format_user(author)}\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" + f"[Jump to message]({message.jump_url})\n" "\n" ) diff --git a/bot/pagination.py b/bot/pagination.py index 182b2fa76..3b16cc9ff 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -4,10 +4,12 @@ import typing as t from contextlib import suppress import discord +from discord import Member from discord.abc import User from discord.ext.commands import Context, Paginator from bot import constants +from bot.constants import MODERATION_ROLES FIRST_EMOJI = "\u23EE" # [:track_previous:] LEFT_EMOJI = "\u2B05" # [:arrow_left:] @@ -210,6 +212,9 @@ class LinePaginator(Paginator): Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds). + The interaction will be limited to `restrict_to_user` (ctx.author by default) or + to any user with a moderation role. + Example: >>> embed = discord.Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) @@ -218,10 +223,10 @@ class LinePaginator(Paginator): def event_check(reaction_: discord.Reaction, user_: discord.Member) -> bool: """Make sure that this reaction is what we want to operate on.""" no_restrictions = ( - # Pagination is not restricted - not restrict_to_user # The reaction was by a whitelisted user - or user_.id == restrict_to_user.id + user_.id == restrict_to_user.id + # The reaction was by a moderator + or isinstance(user_, Member) and any(role.id in MODERATION_ROLES for role in user_.roles) ) return ( @@ -242,6 +247,9 @@ class LinePaginator(Paginator): scale_to_size=scale_to_size) current_page = 0 + if not restrict_to_user: + restrict_to_user = ctx.author + if not lines: if exception_on_empty_embed: log.exception("Pagination asked for empty lines iterable") diff --git a/bot/resources/tags/defaultdict.md b/bot/resources/tags/defaultdict.md new file mode 100644 index 000000000..b6c3175fc --- /dev/null +++ b/bot/resources/tags/defaultdict.md @@ -0,0 +1,21 @@ +**[`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict)** + +The Python `defaultdict` type behaves almost exactly like a regular Python dictionary, but if you try to access or modify a missing key, the `defaultdict` will automatically insert the key and generate a default value for it. +While instantiating a `defaultdict`, we pass in a function that tells it how to create a default value for missing keys. + +```py +>>> from collections import defaultdict +>>> my_dict = defaultdict(int) +>>> my_dict +defaultdict(<class 'int'>, {}) +``` + +In this example, we've used the `int` class which returns 0 when called like a function, so any missing key will get a default value of 0. You can also get an empty list by default with `list` or an empty string with `str`. + +```py +>>> my_dict["foo"] +0 +>>> my_dict["bar"] += 5 +>>> my_dict +defaultdict(<class 'int'>, {'foo': 0, 'bar': 5}) +``` diff --git a/bot/resources/tags/floats.md b/bot/resources/tags/floats.md new file mode 100644 index 000000000..7129b91bb --- /dev/null +++ b/bot/resources/tags/floats.md @@ -0,0 +1,20 @@ +**Floating Point Arithmetic** +You may have noticed that when doing arithmetic with floats in Python you sometimes get strange results, like this: +```python +>>> 0.1 + 0.2 +0.30000000000000004 +``` +**Why this happens** +Internally your computer stores floats as as binary fractions. Many decimal values cannot be stored as exact binary fractions, which means an approximation has to be used. + +**How you can avoid this** + You can use [math.isclose](https://docs.python.org/3/library/math.html#math.isclose) to check if two floats are close, or to get an exact decimal representation, you can use the [decimal](https://docs.python.org/3/library/decimal.html) or [fractions](https://docs.python.org/3/library/fractions.html) module. Here are some examples: +```python +>>> math.isclose(0.1 + 0.2, 0.3) +True +>>> decimal.Decimal('0.1') + decimal.Decimal('0.2') +Decimal('0.3') +``` +Note that with `decimal.Decimal` we enter the number we want as a string so we don't pass on the imprecision from the float. + +For more details on why this happens check out this [page in the python docs](https://docs.python.org/3/tutorial/floatingpoint.html) or this [Computerphile video](https://www.youtube.com/watch/PZRI1IfStY0). diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md new file mode 100644 index 000000000..ae41d589c --- /dev/null +++ b/bot/resources/tags/local-file.md @@ -0,0 +1,23 @@ +Thanks to discord.py, sending local files as embed images is simple. You have to create an instance of [`discord.File`](https://discordpy.readthedocs.io/en/latest/api.html#discord.File) class: +```py +# When you know the file exact path, you can pass it. +file = discord.File("/this/is/path/to/my/file.png", filename="file.png") + +# When you have the file-like object, then you can pass this instead path. +with open("/this/is/path/to/my/file.png", "rb") as f: + file = discord.File(f) +``` +When using the file-like object, you have to open it in `rb` mode. Also, in this case, passing `filename` to it is not necessary. +Please note that `filename` can't contain underscores. This is a Discord limitation. + +[`discord.Embed`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed) instances have a [`set_image`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed.set_image) method which can be used to set an attachment as an image: +```py +embed = discord.Embed() +# Set other fields +embed.set_image(url="attachment://file.png") # Filename here must be exactly same as attachment filename. +``` +After this, you can send an embed with an attachment to Discord: +```py +await channel.send(file=file, embed=embed) +``` +This example uses [`discord.TextChannel`](https://discordpy.readthedocs.io/en/latest/api.html#discord.TextChannel) for sending, but any instance of [`discord.abc.Messageable`](https://discordpy.readthedocs.io/en/latest/api.html#discord.abc.Messageable) can be used for sending. diff --git a/bot/resources/tags/voice-verification.md b/bot/resources/tags/voice-verification.md new file mode 100644 index 000000000..3d88b0c71 --- /dev/null +++ b/bot/resources/tags/voice-verification.md @@ -0,0 +1,3 @@ +**Voice verification** + +Can’t talk in voice chat? Check out <#764802555427029012> to get access. The criteria for verifying are specified there. diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 42bde358d..077dd9569 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -11,7 +11,7 @@ from discord.errors import HTTPException from discord.ext.commands import Context import bot -from bot.constants import Emojis, NEGATIVE_REPLIES +from bot.constants import Emojis, MODERATION_ROLES, NEGATIVE_REPLIES log = logging.getLogger(__name__) @@ -22,12 +22,15 @@ async def wait_for_deletion( deletion_emojis: Sequence[str] = (Emojis.trashcan,), timeout: float = 60 * 5, attach_emojis: bool = True, + allow_moderation_roles: bool = True ) -> None: """ Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. An `attach_emojis` bool may be specified to determine whether to attach the given `deletion_emojis` to the message in the given `context`. + An `allow_moderation_roles` bool may also be specified to allow anyone with a role in `MODERATION_ROLES` to delete + the message. """ if message.guild is None: raise ValueError("Message must be sent on a guild") @@ -45,7 +48,10 @@ async def wait_for_deletion( return ( reaction.message.id == message.id and str(reaction.emoji) in deletion_emojis - and user.id in user_ids + and ( + user.id in user_ids + or allow_moderation_roles and any(role.id in MODERATION_ROLES for role in user.roles) + ) ) with contextlib.suppress(asyncio.TimeoutError): diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index bf557a484..86c2617ea 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,10 +1,12 @@ +import inspect import textwrap import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch from bot.constants import Event +from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction.infractions import Infractions -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockUser, autospec class TruncationTests(unittest.IsolatedAsyncioTestCase): @@ -132,20 +134,29 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) + async def action_tester(self, action, reason: str) -> None: + """Helper method to test voice ban action.""" + self.assertTrue(inspect.iscoroutine(action)) + await action + + self.user.move_to.assert_called_once_with(None, reason=ANY) + self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason=reason) + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock): """Should ignore Voice Verified role removing.""" self.cog.mod_log.ignore = MagicMock() self.cog.apply_infraction = AsyncMock() - self.user.remove_roles = MagicMock(return_value="my_return_value") get_active_infraction.return_value = None post_infraction_mock.return_value = {"foo": "bar"} - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) - self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar") - self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + reason = "foobar" + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, reason)) + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY) + + await self.action_tester(self.cog.apply_infraction.call_args[0][-1], reason) @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") @@ -153,16 +164,33 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Should truncate reason for voice ban.""" self.cog.mod_log.ignore = MagicMock() self.cog.apply_infraction = AsyncMock() - self.user.remove_roles = MagicMock(return_value="my_return_value") get_active_infraction.return_value = None post_infraction_mock.return_value = {"foo": "bar"} self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000)) - self.user.remove_roles.assert_called_once_with( - self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...") - ) - self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY) + + # Test action + action = self.cog.apply_infraction.call_args[0][-1] + await self.action_tester(action, textwrap.shorten("foobar" * 3000, 512, placeholder="...")) + + @autospec(_utils, "post_infraction", "get_active_infraction", return_value=None) + @autospec(Infractions, "apply_infraction") + async def test_voice_ban_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _): + """Should voice ban user that left the guild without throwing an error.""" + infraction = {"foo": "bar"} + post_infraction_mock.return_value = {"foo": "bar"} + + user = MockUser() + await self.cog.voiceban(self.cog, self.ctx, user, reason=None) + post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_ban", None, active=True) + apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY) + + # Test action + action = self.cog.apply_infraction.call_args[0][-1] + self.assertTrue(inspect.iscoroutine(action)) + await action async def test_voice_unban_user_not_found(self): """Should include info to return dict when user was not found from guild.""" |