aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Boris Muratov <[email protected]>2021-04-20 22:14:28 +0300
committerGravatar GitHub <[email protected]>2021-04-20 22:14:28 +0300
commit757826dd694a889880df20bb07ad5d819bdde3b4 (patch)
treee1303269b8cdc33b9b9d6e9401e4551569168200
parentFix zen's negative indexing (diff)
parentMerge pull request #1543 from python-discord/test-async-mock (diff)
Merge branch 'main' into patch-1
-rw-r--r--bot/constants.py1
-rw-r--r--bot/exts/moderation/infraction/superstarify.py8
-rw-r--r--bot/exts/moderation/modlog.py9
-rw-r--r--bot/exts/moderation/modpings.py136
-rw-r--r--bot/exts/moderation/stream.py50
-rw-r--r--config-default.yml6
-rw-r--r--tests/README.md2
7 files changed, 198 insertions, 14 deletions
diff --git a/bot/constants.py b/bot/constants.py
index 6d14bbb3a..cc3aa41a5 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -491,6 +491,7 @@ class Roles(metaclass=YAMLGetter):
domain_leads: int
helpers: int
moderators: int
+ mod_team: int
owners: int
project_leads: int
diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index 704dddf9c..07e79b9fe 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -11,7 +11,7 @@ from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
-from bot.converters import Expiry
+from bot.converters import Duration, Expiry
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
from bot.utils.messages import format_user
@@ -19,6 +19,7 @@ from bot.utils.time import format_infraction
log = logging.getLogger(__name__)
NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy"
+SUPERSTARIFY_DEFAULT_DURATION = "1h"
with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file:
STAR_NAMES = json.load(stars_file)
@@ -109,7 +110,7 @@ class Superstarify(InfractionScheduler, Cog):
self,
ctx: Context,
member: Member,
- duration: Expiry,
+ duration: t.Optional[Expiry],
*,
reason: str = '',
) -> None:
@@ -134,6 +135,9 @@ class Superstarify(InfractionScheduler, Cog):
if await _utils.get_active_infraction(ctx, member, "superstar"):
return
+ # Set to default duration if none was provided.
+ duration = duration or await Duration().convert(ctx, SUPERSTARIFY_DEFAULT_DURATION)
+
# Post the infraction to the API
old_nick = member.display_name
infraction_reason = f'Old nickname: {old_nick}. {reason}'
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index 2dae9d268..e92f76c9a 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -14,7 +14,7 @@ from discord.abc import GuildChannel
from discord.ext.commands import Cog, Context
from bot.bot import Bot
-from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
+from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs
from bot.utils.messages import format_user
from bot.utils.time import humanize_delta
@@ -115,9 +115,9 @@ class ModLog(Cog, name="ModLog"):
if ping_everyone:
if content:
- content = f"@everyone\n{content}"
+ content = f"<@&{Roles.moderators}>\n{content}"
else:
- content = "@everyone"
+ content = f"<@&{Roles.moderators}>"
# Truncate content to 2000 characters and append an ellipsis.
if content and len(content) > 2000:
@@ -127,8 +127,7 @@ class ModLog(Cog, name="ModLog"):
log_message = await channel.send(
content=content,
embed=embed,
- files=files,
- allowed_mentions=discord.AllowedMentions(everyone=True)
+ files=files
)
if additional_embeds:
diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py
new file mode 100644
index 000000000..2f180e594
--- /dev/null
+++ b/bot/exts/moderation/modpings.py
@@ -0,0 +1,136 @@
+import datetime
+import logging
+
+from async_rediscache import RedisCache
+from dateutil.parser import isoparse
+from discord import Member
+from discord.ext.commands import Cog, Context, group, has_any_role
+
+from bot.bot import Bot
+from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles
+from bot.converters import Expiry
+from bot.utils.scheduling import Scheduler
+
+log = logging.getLogger(__name__)
+
+
+class ModPings(Cog):
+ """Commands for a moderator to turn moderator pings on and off."""
+
+ # RedisCache[discord.Member.id, 'Naïve ISO 8601 string']
+ # The cache's keys are mods who have pings off.
+ # The cache's values are the times when the role should be re-applied to them, stored in ISO format.
+ pings_off_mods = RedisCache()
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self._role_scheduler = Scheduler(self.__class__.__name__)
+
+ self.guild = None
+ self.moderators_role = None
+
+ self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule")
+
+ async def reschedule_roles(self) -> None:
+ """Reschedule moderators role re-apply times."""
+ await self.bot.wait_until_guild_available()
+ self.guild = self.bot.get_guild(Guild.id)
+ self.moderators_role = self.guild.get_role(Roles.moderators)
+
+ mod_team = self.guild.get_role(Roles.mod_team)
+ pings_on = self.moderators_role.members
+ pings_off = await self.pings_off_mods.to_dict()
+
+ log.trace("Applying the moderators role to the mod team where necessary.")
+ for mod in mod_team.members:
+ if mod in pings_on: # Make sure that on-duty mods aren't in the cache.
+ if mod in pings_off:
+ await self.pings_off_mods.delete(mod.id)
+ continue
+
+ # Keep the role off only for those in the cache.
+ if mod.id not in pings_off:
+ await self.reapply_role(mod)
+ else:
+ expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None)
+ self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod))
+
+ async def reapply_role(self, mod: Member) -> None:
+ """Reapply the moderator's role to the given moderator."""
+ log.trace(f"Re-applying role to mod with ID {mod.id}.")
+ await mod.add_roles(self.moderators_role, reason="Pings off period expired.")
+
+ @group(name='modpings', aliases=('modping',), invoke_without_command=True)
+ @has_any_role(*MODERATION_ROLES)
+ async def modpings_group(self, ctx: Context) -> None:
+ """Allow the removal and re-addition of the pingable moderators role."""
+ await ctx.send_help(ctx.command)
+
+ @modpings_group.command(name='off')
+ @has_any_role(*MODERATION_ROLES)
+ async def off_command(self, ctx: Context, duration: Expiry) -> None:
+ """
+ Temporarily removes the pingable moderators role for a set amount of time.
+
+ A unit of time should be appended to the duration.
+ Units (∗case-sensitive):
+ \u2003`y` - years
+ \u2003`m` - months∗
+ \u2003`w` - weeks
+ \u2003`d` - days
+ \u2003`h` - hours
+ \u2003`M` - minutes∗
+ \u2003`s` - seconds
+
+ Alternatively, an ISO 8601 timestamp can be provided for the duration.
+
+ The duration cannot be longer than 30 days.
+ """
+ duration: datetime.datetime
+ delta = duration - datetime.datetime.utcnow()
+ if delta > datetime.timedelta(days=30):
+ await ctx.send(":x: Cannot remove the role for longer than 30 days.")
+ return
+
+ mod = ctx.author
+
+ until_date = duration.replace(microsecond=0).isoformat() # Looks noisy with microseconds.
+ await mod.remove_roles(self.moderators_role, reason=f"Turned pings off until {until_date}.")
+
+ await self.pings_off_mods.set(mod.id, duration.isoformat())
+
+ # Allow rescheduling the task without cancelling it separately via the `on` command.
+ if mod.id in self._role_scheduler:
+ self._role_scheduler.cancel(mod.id)
+ self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod))
+
+ await ctx.send(f"{Emojis.check_mark} Moderators role has been removed until {until_date}.")
+
+ @modpings_group.command(name='on')
+ @has_any_role(*MODERATION_ROLES)
+ async def on_command(self, ctx: Context) -> None:
+ """Re-apply the pingable moderators role."""
+ mod = ctx.author
+ if mod in self.moderators_role.members:
+ await ctx.send(":question: You already have the role.")
+ return
+
+ await mod.add_roles(self.moderators_role, reason="Pings off period canceled.")
+
+ await self.pings_off_mods.delete(mod.id)
+
+ # We assume the task exists. Lack of it may indicate a bug.
+ self._role_scheduler.cancel(mod.id)
+
+ await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.")
+
+ def cog_unload(self) -> None:
+ """Cancel role tasks when the cog unloads."""
+ log.trace("Cog unload: canceling role tasks.")
+ self.reschedule_task.cancel()
+ self._role_scheduler.cancel_all()
+
+
+def setup(bot: Bot) -> None:
+ """Load the ModPings cog."""
+ bot.add_cog(ModPings(bot))
diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py
index 12e195172..1dbb2a46b 100644
--- a/bot/exts/moderation/stream.py
+++ b/bot/exts/moderation/stream.py
@@ -1,5 +1,6 @@
import logging
from datetime import timedelta, timezone
+from operator import itemgetter
import arrow
import discord
@@ -8,8 +9,9 @@ from async_rediscache import RedisCache
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import Colours, Emojis, Guild, Roles, STAFF_ROLES, VideoPermission
+from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, VideoPermission
from bot.converters import Expiry
+from bot.pagination import LinePaginator
from bot.utils.scheduling import Scheduler
from bot.utils.time import format_infraction_with_duration
@@ -69,7 +71,7 @@ class Stream(commands.Cog):
)
@commands.command(aliases=("streaming",))
- @commands.has_any_role(*STAFF_ROLES)
+ @commands.has_any_role(*MODERATION_ROLES)
async def stream(self, ctx: commands.Context, member: discord.Member, duration: Expiry = None) -> None:
"""
Temporarily grant streaming permissions to a member for a given duration.
@@ -126,7 +128,7 @@ class Stream(commands.Cog):
log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.")
@commands.command(aliases=("pstream",))
- @commands.has_any_role(*STAFF_ROLES)
+ @commands.has_any_role(*MODERATION_ROLES)
async def permanentstream(self, ctx: commands.Context, member: discord.Member) -> None:
"""Permanently grants the given member the permission to stream."""
log.trace(f"Attempting to give permanent streaming permission to {member} ({member.id}).")
@@ -153,7 +155,7 @@ class Stream(commands.Cog):
log.debug(f"Successfully gave {member} ({member.id}) permanent streaming permission.")
@commands.command(aliases=("unstream", "rstream"))
- @commands.has_any_role(*STAFF_ROLES)
+ @commands.has_any_role(*MODERATION_ROLES)
async def revokestream(self, ctx: commands.Context, member: discord.Member) -> None:
"""Revoke the permission to stream from the given member."""
log.trace(f"Attempting to remove streaming permission from {member} ({member.id}).")
@@ -173,6 +175,46 @@ class Stream(commands.Cog):
await ctx.send(f"{Emojis.cross_mark} This member doesn't have video permissions to remove!")
log.debug(f"{member} ({member.id}) didn't have the streaming permission to remove!")
+ @commands.command(aliases=('lstream',))
+ @commands.has_any_role(*MODERATION_ROLES)
+ async def liststream(self, ctx: commands.Context) -> None:
+ """Lists all non-staff users who have permission to stream."""
+ non_staff_members_with_stream = [
+ member
+ for member in ctx.guild.get_role(Roles.video).members
+ if not any(role.id in STAFF_ROLES for role in member.roles)
+ ]
+
+ # List of tuples (UtcPosixTimestamp, str)
+ # So that the list can be sorted on the UtcPosixTimestamp before the message is passed to the paginator.
+ streamer_info = []
+ for member in non_staff_members_with_stream:
+ if revoke_time := await self.task_cache.get(member.id):
+ # Member only has temporary streaming perms
+ revoke_delta = Arrow.utcfromtimestamp(revoke_time).humanize()
+ message = f"{member.mention} will have stream permissions revoked {revoke_delta}."
+ else:
+ message = f"{member.mention} has permanent streaming permissions."
+
+ # If revoke_time is None use max timestamp to force sort to put them at the end
+ streamer_info.append(
+ (revoke_time or Arrow.max.timestamp(), message)
+ )
+
+ if streamer_info:
+ # Sort based on duration left of streaming perms
+ streamer_info.sort(key=itemgetter(0))
+
+ # Only output the message in the pagination
+ lines = [line[1] for line in streamer_info]
+ embed = discord.Embed(
+ title=f"Members with streaming permission (`{len(lines)}` total)",
+ colour=Colours.soft_green
+ )
+ await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False)
+ else:
+ await ctx.send("No members with stream permissions found.")
+
def setup(bot: Bot) -> None:
"""Loads the Stream cog."""
diff --git a/config-default.yml b/config-default.yml
index 8c6e18470..b7c446889 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -260,7 +260,8 @@ guild:
devops: 409416496733880320
domain_leads: 807415650778742785
helpers: &HELPERS_ROLE 267630620367257601
- moderators: &MODS_ROLE 267629731250176001
+ moderators: &MODS_ROLE 831776746206265384
+ mod_team: &MOD_TEAM_ROLE 267629731250176001
owners: &OWNERS_ROLE 267627879762755584
project_leads: 815701647526330398
@@ -273,13 +274,14 @@ guild:
moderation_roles:
- *ADMINS_ROLE
+ - *MOD_TEAM_ROLE
- *MODS_ROLE
- *OWNERS_ROLE
staff_roles:
- *ADMINS_ROLE
- *HELPERS_ROLE
- - *MODS_ROLE
+ - *MOD_TEAM_ROLE
- *OWNERS_ROLE
webhooks:
diff --git a/tests/README.md b/tests/README.md
index 4f62edd68..092324123 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -114,7 +114,7 @@ class BotCogTests(unittest.TestCase):
### Mocking coroutines
-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.
+By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. The [`AsyncMock`](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.AsyncMock) that has been [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest) is an asynchronous version of `MagicMock` that can be used anywhere a coroutine is expected.
### Special mocks for some `discord.py` types