aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/decorators.py45
-rw-r--r--bot/exts/backend/branding/_cog.py20
-rw-r--r--bot/exts/backend/error_handler.py4
-rw-r--r--bot/exts/filters/antispam.py19
-rw-r--r--bot/exts/moderation/infraction/superstarify.py8
-rw-r--r--bot/exts/moderation/stream.py50
-rw-r--r--bot/exts/utils/clean.py8
-rw-r--r--bot/exts/utils/snekbox.py10
-rw-r--r--bot/exts/utils/utils.py28
-rw-r--r--bot/resources/tags/customchecks.md21
-rw-r--r--bot/utils/checks.py8
11 files changed, 179 insertions, 42 deletions
diff --git a/bot/decorators.py b/bot/decorators.py
index 1d30317ef..e971a5bd3 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -11,7 +11,7 @@ from discord.ext.commands import Cog, Context
from bot.constants import Channels, DEBUG_MODE, RedirectOutput
from bot.utils import function
-from bot.utils.checks import in_whitelist_check
+from bot.utils.checks import ContextCheckFailure, in_whitelist_check
from bot.utils.function import command_wraps
log = logging.getLogger(__name__)
@@ -45,6 +45,49 @@ def in_whitelist(
return commands.check(predicate)
+class NotInBlacklistCheckFailure(ContextCheckFailure):
+ """Raised when the 'not_in_blacklist' check fails."""
+
+
+def not_in_blacklist(
+ *,
+ channels: t.Container[int] = (),
+ categories: t.Container[int] = (),
+ roles: t.Container[int] = (),
+ override_roles: t.Container[int] = (),
+ redirect: t.Optional[int] = Channels.bot_commands,
+ fail_silently: bool = False,
+) -> t.Callable:
+ """
+ Check if a command was not issued in a blacklisted context.
+
+ The blacklists that can be provided are:
+
+ - `channels`: a container with channel ids for blacklisted channels
+ - `categories`: a container with category ids for blacklisted categories
+ - `roles`: a container with role ids for blacklisted roles
+
+ If the command was invoked in a context that was blacklisted, the member is either
+ redirected to the `redirect` channel that was passed (default: #bot-commands) or simply
+ told that they're not allowed to use this particular command (if `None` was passed).
+
+ The blacklist can be overridden through the roles specified in `override_roles`.
+ """
+ def predicate(ctx: Context) -> bool:
+ """Check if command was issued in a blacklisted context."""
+ not_blacklisted = not in_whitelist_check(ctx, channels, categories, roles, fail_silently=True)
+ overridden = in_whitelist_check(ctx, roles=override_roles, fail_silently=True)
+
+ success = not_blacklisted or overridden
+
+ if not success and not fail_silently:
+ raise NotInBlacklistCheckFailure(redirect)
+
+ return success
+
+ return commands.check(predicate)
+
+
def has_no_roles(*roles: t.Union[str, int]) -> t.Callable:
"""
Returns True if the user does not have any of the roles specified.
diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py
index 0a4ddcc88..47c379a34 100644
--- a/bot/exts/backend/branding/_cog.py
+++ b/bot/exts/backend/branding/_cog.py
@@ -3,12 +3,13 @@ import contextlib
import logging
import random
import typing as t
-from datetime import datetime, time, timedelta
+from datetime import timedelta
from enum import Enum
from operator import attrgetter
import async_timeout
import discord
+from arrow import Arrow
from async_rediscache import RedisCache
from discord.ext import commands, tasks
@@ -57,6 +58,8 @@ def extract_event_duration(event: Event) -> str:
Extract a human-readable, year-agnostic duration string from `event`.
In the case that `event` is a fallback event, resolves to 'Fallback'.
+
+ For 1-day events, only the single date is shown, instead of a period.
"""
if event.meta.is_fallback:
return "Fallback"
@@ -65,6 +68,9 @@ def extract_event_duration(event: Event) -> str:
start_date = event.meta.start_date.strftime(fmt)
end_date = event.meta.end_date.strftime(fmt)
+ if start_date == end_date:
+ return start_date
+
return f"{start_date} - {end_date}"
@@ -208,7 +214,7 @@ class Branding(commands.Cog):
if success:
await self.cache_icons.increment(next_icon) # Push the icon into the next iteration.
- timestamp = datetime.utcnow().timestamp()
+ timestamp = Arrow.utcnow().timestamp()
await self.cache_information.set("last_rotation_timestamp", timestamp)
return success
@@ -229,8 +235,8 @@ class Branding(commands.Cog):
await self.rotate_icons()
return
- last_rotation = datetime.fromtimestamp(last_rotation_timestamp)
- difference = (datetime.utcnow() - last_rotation) + timedelta(minutes=5)
+ last_rotation = Arrow.utcfromtimestamp(last_rotation_timestamp)
+ difference = (Arrow.utcnow() - last_rotation) + timedelta(minutes=5)
log.trace(f"Icons last rotated at {last_rotation} (difference: {difference}).")
@@ -485,11 +491,11 @@ class Branding(commands.Cog):
await self.daemon_loop()
log.trace("Daemon before: calculating time to sleep before loop begins.")
- now = datetime.utcnow()
+ now = Arrow.utcnow()
# The actual midnight moment is offset into the future to prevent issues with imprecise sleep.
- tomorrow = now + timedelta(days=1)
- midnight = datetime.combine(tomorrow, time(minute=1))
+ tomorrow = now.shift(days=1)
+ midnight = tomorrow.replace(hour=0, minute=1, second=0, microsecond=0)
sleep_secs = (midnight - now).total_seconds()
log.trace(f"Daemon before: sleeping {sleep_secs} seconds before next-up midnight: {midnight}.")
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
index 76ab7dfc2..da0e94a7e 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -12,7 +12,7 @@ from bot.bot import Bot
from bot.constants import Colours, Icons, MODERATION_ROLES
from bot.converters import TagNameConverter
from bot.errors import InvalidInfractedUser, LockedResourceError
-from bot.utils.checks import InWhitelistCheckFailure
+from bot.utils.checks import ContextCheckFailure
log = logging.getLogger(__name__)
@@ -274,7 +274,7 @@ class ErrorHandler(Cog):
await ctx.send(
"Sorry, it looks like I don't have the permissions or roles I need to do that."
)
- elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)):
+ elif isinstance(e, (ContextCheckFailure, errors.NoPrivateMessage)):
ctx.bot.stats.incr("errors.wrong_channel_or_dm_error")
await ctx.send(e)
diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
index af8528a68..7555e25a2 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -3,7 +3,7 @@ import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime, timedelta
-from operator import itemgetter
+from operator import attrgetter, itemgetter
from typing import Dict, Iterable, List, Set
from discord import Colour, Member, Message, NotFound, Object, TextChannel
@@ -18,6 +18,7 @@ from bot.constants import (
)
from bot.converters import Duration
from bot.exts.moderation.modlog import ModLog
+from bot.utils import lock, scheduling
from bot.utils.messages import format_user, send_attachments
@@ -114,7 +115,7 @@ class AntiSpam(Cog):
self.message_deletion_queue = dict()
- self.bot.loop.create_task(self.alert_on_validation_error())
+ self.bot.loop.create_task(self.alert_on_validation_error(), name="AntiSpam.alert_on_validation_error")
@property
def mod_log(self) -> ModLog:
@@ -191,7 +192,10 @@ class AntiSpam(Cog):
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))
+ scheduling.create_task(
+ self._process_deletion_context(message.channel.id),
+ name=f"AntiSpam._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(
@@ -201,16 +205,15 @@ class AntiSpam(Cog):
)
for member in members:
-
- # Fire it off as a background task to ensure
- # that the sleep doesn't block further tasks
- self.bot.loop.create_task(
- self.punish(message, member, full_reason)
+ scheduling.create_task(
+ self.punish(message, member, full_reason),
+ name=f"AntiSpam.punish(message={message.id}, member={member.id}, rule={rule_name})"
)
await self.maybe_delete_messages(channel, relevant_messages)
break
+ @lock.lock_arg("antispam.punish", "member", attrgetter("id"))
async def punish(self, msg: Message, member: Member, reason: str) -> None:
"""Punishes the given member for triggering an antispam rule."""
if not any(role.id == self.muted_role.id for role in member.roles):
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/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/bot/exts/utils/clean.py b/bot/exts/utils/clean.py
index 8acaf9131..cb662e852 100644
--- a/bot/exts/utils/clean.py
+++ b/bot/exts/utils/clean.py
@@ -3,7 +3,7 @@ import random
import re
from typing import Iterable, Optional
-from discord import Colour, Embed, Message, TextChannel, User
+from discord import Colour, Embed, Message, TextChannel, User, errors
from discord.ext import commands
from discord.ext.commands import Cog, Context, group, has_any_role
@@ -115,7 +115,11 @@ class Clean(Cog):
# Delete the invocation first
self.mod_log.ignore(Event.message_delete, ctx.message.id)
- await ctx.message.delete()
+ try:
+ await ctx.message.delete()
+ except errors.NotFound:
+ # Invocation message has already been deleted
+ log.info("Tried to delete invocation message, but it was already deleted.")
messages = []
message_ids = []
diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py
index 9f480c067..da95240bb 100644
--- a/bot/exts/utils/snekbox.py
+++ b/bot/exts/utils/snekbox.py
@@ -13,7 +13,7 @@ from discord.ext.commands import Cog, Context, command, guild_only
from bot.bot import Bot
from bot.constants import Categories, Channels, Roles, URLs
-from bot.decorators import in_whitelist
+from bot.decorators import not_in_blacklist
from bot.utils import send_to_paste_service
from bot.utils.messages import wait_for_deletion
@@ -38,9 +38,9 @@ RAW_CODE_REGEX = re.compile(
MAX_PASTE_LEN = 10000
-# `!eval` command whitelists
-EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric)
-EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use, Categories.voice)
+# `!eval` command whitelists and blacklists.
+NO_EVAL_CHANNELS = (Channels.python_general,)
+NO_EVAL_CATEGORIES = ()
EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners)
SIGKILL = 9
@@ -280,7 +280,7 @@ class Snekbox(Cog):
@command(name="eval", aliases=("e",))
@guild_only()
- @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES)
+ @not_in_blacklist(channels=NO_EVAL_CHANNELS, categories=NO_EVAL_CATEGORIES, override_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/exts/utils/utils.py b/bot/exts/utils/utils.py
index cae7f2593..8d9d27c64 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -162,17 +162,27 @@ class Utils(Cog):
if len(snowflakes) > 1 and await has_no_roles_check(ctx, *STAFF_ROLES):
raise BadArgument("Cannot process more than one snowflake in one invocation.")
+ if not snowflakes:
+ raise BadArgument("At least one snowflake must be provided.")
+
+ embed = Embed(colour=Colour.blue())
+ embed.set_author(
+ name=f"Snowflake{'s'[:len(snowflakes)^1]}", # Deals with pluralisation
+ icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true"
+ )
+
+ lines = []
for snowflake in snowflakes:
created_at = snowflake_time(snowflake)
- embed = Embed(
- description=f"**Created at {created_at}** ({time_since(created_at, max_units=3)}).",
- colour=Colour.blue()
- )
- embed.set_author(
- name=f"Snowflake: {snowflake}",
- icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true"
- )
- await ctx.send(embed=embed)
+ lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at, max_units=3)}).")
+
+ await LinePaginator.paginate(
+ lines,
+ ctx=ctx,
+ embed=embed,
+ max_lines=5,
+ max_size=1000
+ )
@command(aliases=("poll",))
@has_any_role(*MODERATION_ROLES, Roles.project_leads, Roles.domain_leads)
diff --git a/bot/resources/tags/customchecks.md b/bot/resources/tags/customchecks.md
new file mode 100644
index 000000000..23ff7a66f
--- /dev/null
+++ b/bot/resources/tags/customchecks.md
@@ -0,0 +1,21 @@
+**Custom Command Checks in discord.py**
+
+Often you may find the need to use checks that don't exist by default in discord.py. Fortunately, discord.py provides `discord.ext.commands.check` which allows you to create you own checks like this:
+```py
+from discord.ext.commands import check, Context
+
+def in_any_channel(*channels):
+ async def predicate(ctx: Context):
+ return ctx.channel.id in channels
+ return check(predicate)
+```
+This check is to check whether the invoked command is in a given set of channels. The inner function, named `predicate` here, is used to perform the actual check on the command, and check logic should go in this function. It must be an async function, and always provides a single `commands.Context` argument which you can use to create check logic. This check function should return a boolean value indicating whether the check passed (return `True`) or failed (return `False`).
+
+The check can now be used like any other commands check as a decorator of a command, such as this:
+```py
[email protected](name="ping")
+@in_any_channel(728343273562701984)
+async def ping(ctx: Context):
+ ...
+```
+This would lock the `ping` command to only be used in the channel `728343273562701984`. If this check function fails it will raise a `CheckFailure` exception, which can be handled in your error handler.
diff --git a/bot/utils/checks.py b/bot/utils/checks.py
index 460a937d8..3d0c8a50c 100644
--- a/bot/utils/checks.py
+++ b/bot/utils/checks.py
@@ -20,8 +20,8 @@ from bot import constants
log = logging.getLogger(__name__)
-class InWhitelistCheckFailure(CheckFailure):
- """Raised when the `in_whitelist` check fails."""
+class ContextCheckFailure(CheckFailure):
+ """Raised when a context-specific check fails."""
def __init__(self, redirect_channel: Optional[int]) -> None:
self.redirect_channel = redirect_channel
@@ -36,6 +36,10 @@ class InWhitelistCheckFailure(CheckFailure):
super().__init__(error_message)
+class InWhitelistCheckFailure(ContextCheckFailure):
+ """Raised when the `in_whitelist` check fails."""
+
+
def in_whitelist_check(
ctx: Context,
channels: Container[int] = (),