aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Sebastiaan Zeeff <[email protected]>2021-02-08 11:02:45 +0100
committerGravatar GitHub <[email protected]>2021-02-08 11:02:45 +0100
commit9c23fdede6070bb6741321fda8882a72c926613a (patch)
tree365702a1b63ee73b3b08536f724b3993059a3c17
parent"handle converting" -> "convert ... for you". (diff)
parentMerge pull request #1406 from python-discord/revert-1396-dynamic-available-he... (diff)
Merge branch 'master' into swfarnsworth/tag_messages
-rw-r--r--bot/converters.py2
-rw-r--r--bot/exts/backend/error_handler.py8
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py1
-rw-r--r--bot/exts/moderation/infraction/infractions.py15
-rw-r--r--bot/exts/moderation/modlog.py2
-rw-r--r--bot/pagination.py14
-rw-r--r--bot/resources/tags/defaultdict.md21
-rw-r--r--bot/resources/tags/floats.md20
-rw-r--r--bot/resources/tags/local-file.md23
-rw-r--r--bot/resources/tags/voice-verification.md3
-rw-r--r--bot/utils/messages.py10
-rw-r--r--tests/bot/exts/moderation/infraction/test_infractions.py50
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."""