From 6b280b19ed5c564e824e55a1ec9bb13120c0193d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 16:13:49 -0700 Subject: Time: remove RFC1123 support It's not used anywhere and hasn't been for a very long time. --- bot/utils/time.py | 6 ------ tests/bot/utils/test_time.py | 7 ------- 2 files changed, 13 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index eaa9b72e9..545e50859 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -7,7 +7,6 @@ import arrow import dateutil.parser from dateutil.relativedelta import relativedelta -RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" DISCORD_TIMESTAMP_REGEX = re.compile(r"") _DURATION_REGEX = re.compile( @@ -167,11 +166,6 @@ def time_since(past_datetime: datetime.datetime) -> str: return discord_timestamp(past_datetime, TimestampFormats.RELATIVE) -def parse_rfc1123(stamp: str) -> datetime.datetime: - """Parse RFC1123 time string into datetime.""" - return datetime.datetime.strptime(stamp, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) - - def format_infraction(timestamp: str) -> str: """Format an infraction timestamp to a discord timestamp.""" return discord_timestamp(dateutil.parser.isoparse(timestamp)) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index a3dcbfc0a..9c52fed27 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -43,13 +43,6 @@ class TimeTests(unittest.TestCase): time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) self.assertEqual(str(error.exception), 'max_units must be positive') - def test_parse_rfc1123(self): - """Testing parse_rfc1123.""" - self.assertEqual( - time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'), - datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc) - ) - def test_format_infraction(self): """Testing format_infraction.""" self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '') -- cgit v1.2.3 From 469cd57693925e78bef6a1163b620a39da208670 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 16:27:13 -0700 Subject: Time: qualify uses of functions with the module name In cases where many time utility functions were being imported, this makes the imports shorter and cleaner. In other cases, the function names read better when they're qualified with "time"; the extra context it adds is helpful. --- bot/converters.py | 4 +-- bot/exts/info/information.py | 10 ++++---- bot/exts/moderation/defcon.py | 29 +++++++++++----------- bot/exts/moderation/infraction/management.py | 7 +++--- bot/exts/moderation/infraction/superstarify.py | 6 ++--- bot/exts/moderation/modlog.py | 6 ++--- bot/exts/moderation/modpings.py | 7 +++--- bot/exts/moderation/stream.py | 7 +++--- bot/exts/moderation/watchchannels/_watchchannel.py | 7 +++--- bot/exts/recruitment/talentpool/_cog.py | 3 +-- bot/exts/recruitment/talentpool/_review.py | 8 +++--- bot/exts/utils/reminders.py | 10 ++++---- bot/exts/utils/utils.py | 5 ++-- 13 files changed, 51 insertions(+), 58 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 559e759e1..b68c4d623 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -20,9 +20,9 @@ from bot.errors import InvalidInfraction from bot.exts.info.doc import _inventory_parser from bot.exts.info.tags import TagIdentifier from bot.log import get_logger +from bot.utils import time from bot.utils.extensions import EXTENSIONS, unqualify from bot.utils.regex import INVITE_RE -from bot.utils.time import parse_duration_string if t.TYPE_CHECKING: from bot.exts.info.source import SourceType @@ -338,7 +338,7 @@ class DurationDelta(Converter): The units need to be provided in descending order of magnitude. """ - if not (delta := parse_duration_string(duration)): + if not (delta := time.parse_duration_string(duration)): raise BadArgument(f"`{duration}` is not a valid duration string.") return delta diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 1f95c460f..a83ce4d53 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -18,10 +18,10 @@ from bot.decorators import in_whitelist from bot.errors import NonExistentRoleError from bot.log import get_logger from bot.pagination import LinePaginator +from bot.utils import time from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check from bot.utils.members import get_or_fetch_member -from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta log = get_logger(__name__) @@ -83,7 +83,7 @@ class Information(Cog): defcon_info = "" if cog := self.bot.get_cog("Defcon"): - threshold = humanize_delta(cog.threshold) if cog.threshold else "-" + threshold = time.humanize_delta(cog.threshold) if cog.threshold else "-" defcon_info = f"Defcon threshold: {threshold}\n" verification = f"Verification level: {ctx.guild.verification_level.name}\n" @@ -173,7 +173,7 @@ class Information(Cog): """Returns an embed full of server information.""" embed = Embed(colour=Colour.og_blurple(), title="Server Information") - created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE) + created = time.discord_timestamp(ctx.guild.created_at, time.TimestampFormats.RELATIVE) num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone # Server Features are only useful in certain channels @@ -249,7 +249,7 @@ class Information(Cog): """Creates an embed containing information on the `user`.""" on_server = bool(await get_or_fetch_member(ctx.guild, user.id)) - created = discord_timestamp(user.created_at, TimestampFormats.RELATIVE) + created = time.discord_timestamp(user.created_at, time.TimestampFormats.RELATIVE) name = str(user) if on_server and user.nick: @@ -272,7 +272,7 @@ class Information(Cog): if on_server: if user.joined_at: - joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE) + joined = time.discord_timestamp(user.joined_at, time.TimestampFormats.RELATIVE) else: joined = "Unable to get join date" diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 14db37367..048e0f990 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -17,12 +17,9 @@ from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_RO from bot.converters import DurationDelta, Expiry from bot.exts.moderation.modlog import ModLog from bot.log import get_logger -from bot.utils import scheduling +from bot.utils import scheduling, time from bot.utils.messages import format_user from bot.utils.scheduling import Scheduler -from bot.utils.time import ( - TimestampFormats, discord_timestamp, humanize_delta, parse_duration_string, relativedelta_to_timedelta -) log = get_logger(__name__) @@ -88,7 +85,7 @@ class Defcon(Cog): try: settings = await self.defcon_settings.to_dict() - self.threshold = parse_duration_string(settings["threshold"]) if settings.get("threshold") else None + self.threshold = time.parse_duration_string(settings["threshold"]) if settings.get("threshold") else None self.expiry = datetime.fromisoformat(settings["expiry"]) if settings.get("expiry") else None except RedisError: log.exception("Unable to get DEFCON settings!") @@ -102,7 +99,7 @@ class Defcon(Cog): self.scheduler.schedule_at(self.expiry, 0, self._remove_threshold()) self._update_notifier() - log.info(f"DEFCON synchronized: {humanize_delta(self.threshold) if self.threshold else '-'}") + log.info(f"DEFCON synchronized: {time.humanize_delta(self.threshold) if self.threshold else '-'}") self._update_channel_topic() @@ -112,7 +109,7 @@ class Defcon(Cog): if self.threshold: now = arrow.utcnow() - if now - member.created_at < relativedelta_to_timedelta(self.threshold): + if now - member.created_at < time.relativedelta_to_timedelta(self.threshold): log.info(f"Rejecting user {member}: Account is too new") message_sent = False @@ -151,11 +148,12 @@ class Defcon(Cog): @has_any_role(*MODERATION_ROLES) async def status(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" + expiry = time.discord_timestamp(self.expiry, time.TimestampFormats.RELATIVE) if self.expiry else "-" embed = Embed( colour=Colour.og_blurple(), title="DEFCON Status", description=f""" - **Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"} - **Expires:** {discord_timestamp(self.expiry, TimestampFormats.RELATIVE) if self.expiry else "-"} + **Threshold:** {time.humanize_delta(self.threshold) if self.threshold else "-"} + **Expires:** {expiry} **Verification level:** {ctx.guild.verification_level.name} """ ) @@ -213,7 +211,8 @@ class Defcon(Cog): def _update_channel_topic(self) -> None: """Update the #defcon channel topic with the current DEFCON status.""" - new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold) if self.threshold else '-'})" + threshold = time.humanize_delta(self.threshold) if self.threshold else '-' + new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {threshold})" self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) scheduling.create_task(self.channel.edit(topic=new_topic)) @@ -256,11 +255,11 @@ class Defcon(Cog): expiry_message = "" if expiry: activity_duration = relativedelta(expiry, arrow.utcnow().datetime) - expiry_message = f" for the next {humanize_delta(activity_duration, max_units=2)}" + expiry_message = f" for the next {time.humanize_delta(activity_duration, max_units=2)}" if self.threshold: channel_message = ( - f"updated; accounts must be {humanize_delta(self.threshold)} " + f"updated; accounts must be {time.humanize_delta(self.threshold)} " f"old to join the server{expiry_message}" ) else: @@ -290,7 +289,7 @@ class Defcon(Cog): def _log_threshold_stat(self, threshold: relativedelta) -> None: """Adds the threshold to the bot stats in days.""" - threshold_days = relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY + threshold_days = time.relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY self.bot.stats.gauge("defcon.threshold", threshold_days) async def _send_defcon_log(self, action: Action, actor: User) -> None: @@ -298,7 +297,7 @@ class Defcon(Cog): info = action.value log_msg: str = ( f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" - f"{info.template.format(threshold=(humanize_delta(self.threshold) if self.threshold else '-'))}" + f"{info.template.format(threshold=(time.humanize_delta(self.threshold) if self.threshold else '-'))}" ) status_msg = f"DEFCON {action.name.lower()}" @@ -317,7 +316,7 @@ class Defcon(Cog): @tasks.loop(hours=1) async def defcon_notifier(self) -> None: """Routinely notify moderators that DEFCON is active.""" - await self.channel.send(f"Defcon is on and is set to {humanize_delta(self.threshold)}.") + await self.channel.send(f"Defcon is on and is set to {time.humanize_delta(self.threshold)}.") def cog_unload(self) -> None: """Cancel the notifer and threshold removal tasks when the cog unloads.""" diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 9649ff852..fb5af9eaa 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -20,7 +20,6 @@ from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.channel import is_mod_channel from bot.utils.members import get_or_fetch_member -from bot.utils.time import humanize_delta, until_expiration log = get_logger(__name__) @@ -183,8 +182,8 @@ class ModManagement(commands.Cog): self.infractions_cog.schedule_expiration(new_infraction) log_text += f""" - Previous expiry: {until_expiration(infraction['expires_at']) or "Permanent"} - New expiry: {until_expiration(new_infraction['expires_at']) or "Permanent"} + Previous expiry: {time.until_expiration(infraction['expires_at']) or "Permanent"} + New expiry: {time.until_expiration(new_infraction['expires_at']) or "Permanent"} """.rstrip() changes = ' & '.join(confirm_messages) @@ -377,7 +376,7 @@ class ModManagement(commands.Cog): timezone.utc ) date_to = dateutil.parser.isoparse(expires_at) - duration = humanize_delta(relativedelta(date_to, date_from)) + duration = time.humanize_delta(relativedelta(date_to, date_from)) # Format `dm_sent` if dm_sent is None: diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 08c92b8f3..2e272dbb0 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -14,9 +14,9 @@ from bot.converters import Duration, Expiry from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler from bot.log import get_logger +from bot.utils import time from bot.utils.members import get_or_fetch_member from bot.utils.messages import format_user -from bot.utils.time import format_infraction log = get_logger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" @@ -73,7 +73,7 @@ class Superstarify(InfractionScheduler, Cog): notified = await _utils.notify_infraction( user=after, infr_type="Superstarify", - expires_at=format_infraction(infraction["expires_at"]), + expires_at=time.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 " @@ -150,7 +150,7 @@ class Superstarify(InfractionScheduler, Cog): id_ = infraction["id"] forced_nick = self.get_nick(id_, member.id) - expiry_str = format_infraction(infraction["expires_at"]) + expiry_str = time.format_infraction(infraction["expires_at"]) # Apply the infraction async def action() -> None: diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index fc9204998..d5e209d81 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -16,8 +16,8 @@ from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs from bot.log import get_logger +from bot.utils import time from bot.utils.messages import format_user -from bot.utils.time import humanize_delta log = get_logger(__name__) @@ -407,7 +407,7 @@ class ModLog(Cog, name="ModLog"): now = datetime.now(timezone.utc) difference = abs(relativedelta(now, member.created_at)) - message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference) + message = format_user(member) + "\n\n**Account age:** " + time.humanize_delta(difference) if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account! message = f"{Emojis.new} {message}" @@ -713,7 +713,7 @@ class ModLog(Cog, name="ModLog"): # datetime as the baseline and create a human-readable delta between this edit event # and the last time the message was edited timestamp = msg_before.edited_at - delta = humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at)) + delta = time.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 diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 20a8c39d7..b5cd29b12 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -11,9 +11,8 @@ from bot.bot import Bot from bot.constants import Colours, Emojis, Guild, Icons, MODERATION_ROLES, Roles from bot.converters import Expiry from bot.log import get_logger -from bot.utils import scheduling +from bot.utils import scheduling, time from bot.utils.scheduling import Scheduler -from bot.utils.time import TimestampFormats, discord_timestamp log = get_logger(__name__) @@ -233,8 +232,8 @@ class ModPings(Cog): await ctx.send( f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from " - f"{discord_timestamp(start, TimestampFormats.TIME)} to " - f"{discord_timestamp(end, TimestampFormats.TIME)}!" + f"{time.discord_timestamp(start, time.TimestampFormats.TIME)} to " + f"{time.discord_timestamp(end, time.TimestampFormats.TIME)}!" ) @schedule_modpings.command(name='delete', aliases=('del', 'd')) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 99bbd8721..5a7b12295 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -14,9 +14,8 @@ from bot.constants import ( from bot.converters import Expiry from bot.log import get_logger from bot.pagination import LinePaginator -from bot.utils import scheduling +from bot.utils import scheduling, time from bot.utils.members import get_or_fetch_member -from bot.utils.time import discord_timestamp, format_infraction_with_duration log = get_logger(__name__) @@ -131,10 +130,10 @@ class Stream(commands.Cog): await member.add_roles(discord.Object(Roles.video), reason="Temporary streaming access granted") - await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {discord_timestamp(duration)}.") + await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {time.discord_timestamp(duration)}.") # Convert here for nicer logging - revoke_time = format_infraction_with_duration(str(duration)) + revoke_time = time.format_infraction_with_duration(str(duration)) log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.") @commands.command(aliases=("pstream",)) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 34d445912..106483527 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -18,9 +18,8 @@ from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE from bot.exts.moderation.modlog import ModLog from bot.log import CustomLogger, get_logger from bot.pagination import LinePaginator -from bot.utils import CogABCMeta, messages, scheduling +from bot.utils import CogABCMeta, messages, scheduling, time from bot.utils.members import get_or_fetch_member -from bot.utils.time import get_time_delta log = get_logger(__name__) @@ -286,7 +285,7 @@ class WatchChannel(metaclass=CogABCMeta): actor = actor.display_name if actor else self.watched_users[user_id]['actor'] inserted_at = self.watched_users[user_id]['inserted_at'] - time_delta = get_time_delta(inserted_at) + time_delta = time.get_time_delta(inserted_at) reason = self.watched_users[user_id]['reason'] @@ -360,7 +359,7 @@ class WatchChannel(metaclass=CogABCMeta): if member: line += f" ({member.name}#{member.discriminator})" inserted_at = user_data['inserted_at'] - line += f", added {get_time_delta(inserted_at)}" + line += f", added {time.get_time_delta(inserted_at)}" if not member: # Cross off users who left the server. line = f"~~{line}~~" list_data["info"][user_id] = line diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 8fa0be5b1..80274eaea 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -17,7 +17,6 @@ from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import scheduling, time from bot.utils.members import get_or_fetch_member -from bot.utils.time import get_time_delta AUTOREVIEW_ENABLED_KEY = "autoreview_enabled" REASON_MAX_CHARS = 1000 @@ -181,7 +180,7 @@ class TalentPool(Cog, name="Talentpool"): if member: line += f" ({member.name}#{member.discriminator})" inserted_at = user_data['inserted_at'] - line += f", added {get_time_delta(inserted_at)}" + line += f", added {time.get_time_delta(inserted_at)}" if not member: # Cross off users who left the server. line = f"~~{line}~~" if user_data['reviewed']: diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 0e7194892..bbffbe6e3 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -17,10 +17,10 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Roles from bot.log import get_logger +from bot.utils import time from bot.utils.members import get_or_fetch_member from bot.utils.messages import count_unique_users_reaction, pin_no_system_message from bot.utils.scheduling import Scheduler -from bot.utils.time import get_time_delta, time_since if typing.TYPE_CHECKING: from bot.exts.recruitment.talentpool._cog import TalentPool @@ -273,7 +273,7 @@ class Reviewer: last_channel = user_activity["top_channel_activity"][-1] channels += f", and {last_channel[1]} in {last_channel[0]}" - joined_at_formatted = time_since(member.joined_at) + joined_at_formatted = time.time_since(member.joined_at) review = ( f"{member.name} joined the server **{joined_at_formatted}**" f" and has **{messages} messages**{channels}." @@ -321,7 +321,7 @@ class Reviewer: infractions += ", with the last infraction issued " # Infractions were ordered by time since insertion descending. - infractions += get_time_delta(infraction_list[0]['inserted_at']) + infractions += time.get_time_delta(infraction_list[0]['inserted_at']) return f"They have {infractions}." @@ -365,7 +365,7 @@ class Reviewer: nomination_times = f"{num_entries} times" if num_entries > 1 else "once" rejection_times = f"{len(history)} times" if len(history) > 1 else "once" - end_time = time_since(isoparse(history[0]['ended_at'])) + end_time = time.time_since(isoparse(history[0]['ended_at'])) review = ( f"They were nominated **{nomination_times}** before" diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 90677b2dd..dc7782727 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -13,13 +13,12 @@ from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Role from bot.converters import Duration, UnambiguousUser from bot.log import get_logger from bot.pagination import LinePaginator -from bot.utils import scheduling +from bot.utils import scheduling, time from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.lock import lock_arg from bot.utils.members import get_or_fetch_member from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler -from bot.utils.time import TimestampFormats, discord_timestamp log = get_logger(__name__) @@ -310,7 +309,8 @@ class Reminders(Cog): } ) - mention_string = f"Your reminder will arrive on {discord_timestamp(expiration, TimestampFormats.DAY_TIME)}" + formatted_time = time.discord_timestamp(expiration, time.TimestampFormats.DAY_TIME) + mention_string = f"Your reminder will arrive on {formatted_time}" if mentions: mention_string += f" and will mention {len(mentions)} other(s)" @@ -348,7 +348,7 @@ class Reminders(Cog): for content, remind_at, id_, mentions in reminders: # Parse and humanize the time, make it pretty :D remind_datetime = isoparse(remind_at) - time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE) + expiry = time.discord_timestamp(remind_datetime, time.TimestampFormats.RELATIVE) mentions = ", ".join([ # Both Role and User objects have the `name` attribute @@ -357,7 +357,7 @@ class Reminders(Cog): mention_string = f"\n**Mentions:** {mentions}" if mentions else "" text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires {time}* (ID: {id_}){mention_string} + **Reminder #{id_}:** *expires {expiry}* (ID: {id_}){mention_string} {content} """).strip() diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index f76eea516..00fa7a388 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -13,8 +13,7 @@ from bot.converters import Snowflake from bot.decorators import in_whitelist from bot.log import get_logger from bot.pagination import LinePaginator -from bot.utils import messages -from bot.utils.time import time_since +from bot.utils import messages, time log = get_logger(__name__) @@ -173,7 +172,7 @@ class Utils(Cog): lines = [] for snowflake in snowflakes: created_at = snowflake_time(snowflake) - lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at)}).") + lines.append(f"**{snowflake}**\nCreated at {created_at} ({time.time_since(created_at)}).") await LinePaginator.paginate( lines, -- cgit v1.2.3 From 0bfdc16fc74be8ead1e6b8784757b9202293b745 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 16:32:23 -0700 Subject: Time: rename time_since to format_relative While the function is basically just a wrapper for discord_timestamp now, it is very common to use the relative format. It's cumbersome to import the format enum and pass it to discord_timestamp calls, so keeping this function around will be nice. --- bot/exts/recruitment/talentpool/_review.py | 4 ++-- bot/exts/utils/utils.py | 2 +- bot/utils/time.py | 15 ++++++++++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index bbffbe6e3..474f669c6 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -273,7 +273,7 @@ class Reviewer: last_channel = user_activity["top_channel_activity"][-1] channels += f", and {last_channel[1]} in {last_channel[0]}" - joined_at_formatted = time.time_since(member.joined_at) + joined_at_formatted = time.format_relative(member.joined_at) review = ( f"{member.name} joined the server **{joined_at_formatted}**" f" and has **{messages} messages**{channels}." @@ -365,7 +365,7 @@ class Reviewer: nomination_times = f"{num_entries} times" if num_entries > 1 else "once" rejection_times = f"{len(history)} times" if len(history) > 1 else "once" - end_time = time.time_since(isoparse(history[0]['ended_at'])) + end_time = time.format_relative(isoparse(history[0]['ended_at'])) review = ( f"They were nominated **{nomination_times}** before" diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 00fa7a388..2a074788e 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -172,7 +172,7 @@ class Utils(Cog): lines = [] for snowflake in snowflakes: created_at = snowflake_time(snowflake) - lines.append(f"**{snowflake}**\nCreated at {created_at} ({time.time_since(created_at)}).") + lines.append(f"**{snowflake}**\nCreated at {created_at} ({time.format_relative(created_at)}).") await LinePaginator.paginate( lines, diff --git a/bot/utils/time.py b/bot/utils/time.py index 545e50859..e6dcdee15 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -125,7 +125,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: def get_time_delta(time_string: str) -> str: """Returns the time in human-readable time delta format.""" date_time = dateutil.parser.isoparse(time_string) - time_delta = time_since(date_time) + time_delta = format_relative(date_time) return time_delta @@ -161,9 +161,14 @@ def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta: return utcnow + delta - utcnow -def time_since(past_datetime: datetime.datetime) -> str: - """Takes a datetime and returns a discord timestamp that describes how long ago that datetime was.""" - return discord_timestamp(past_datetime, TimestampFormats.RELATIVE) +def format_relative(timestamp: ValidTimestamp) -> str: + """ + Format `timestamp` as a relative Discord timestamp. + + A relative timestamp describes how much time has elapsed since `timestamp` or how much time + remains until `timestamp` is reached. See `time.discord_timestamp`. + """ + return discord_timestamp(timestamp, TimestampFormats.RELATIVE) def format_infraction(timestamp: str) -> str: @@ -211,7 +216,7 @@ def until_expiration( Get the remaining time until infraction's expiration, in a discord timestamp. Returns a human-readable version of the remaining duration between arrow.utcnow() and an expiry. - Similar to time_since, except that this function doesn't error on a null input + Similar to format_relative, except that this function doesn't error on a null input and return null if the expiry is in the paste """ if not expiry: -- cgit v1.2.3 From ad1fcfbdab5be55f16ab157bcf86927c7996ed07 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 16:32:44 -0700 Subject: Time: replace discord_timestamp calls with format_relative Use the latter where the former was being called with the relative format type. --- bot/exts/info/information.py | 6 +++--- bot/exts/moderation/defcon.py | 2 +- bot/exts/utils/reminders.py | 2 +- bot/utils/time.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index a83ce4d53..29a00ec5d 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -173,7 +173,7 @@ class Information(Cog): """Returns an embed full of server information.""" embed = Embed(colour=Colour.og_blurple(), title="Server Information") - created = time.discord_timestamp(ctx.guild.created_at, time.TimestampFormats.RELATIVE) + created = time.format_relative(ctx.guild.created_at) num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone # Server Features are only useful in certain channels @@ -249,7 +249,7 @@ class Information(Cog): """Creates an embed containing information on the `user`.""" on_server = bool(await get_or_fetch_member(ctx.guild, user.id)) - created = time.discord_timestamp(user.created_at, time.TimestampFormats.RELATIVE) + created = time.format_relative(user.created_at) name = str(user) if on_server and user.nick: @@ -272,7 +272,7 @@ class Information(Cog): if on_server: if user.joined_at: - joined = time.discord_timestamp(user.joined_at, time.TimestampFormats.RELATIVE) + joined = time.format_relative(user.joined_at) else: joined = "Unable to get join date" diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 048e0f990..263e8136e 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -148,7 +148,7 @@ class Defcon(Cog): @has_any_role(*MODERATION_ROLES) async def status(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" - expiry = time.discord_timestamp(self.expiry, time.TimestampFormats.RELATIVE) if self.expiry else "-" + expiry = time.format_relative(self.expiry) if self.expiry else "-" embed = Embed( colour=Colour.og_blurple(), title="DEFCON Status", description=f""" diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index dc7782727..bfa294809 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -348,7 +348,7 @@ class Reminders(Cog): for content, remind_at, id_, mentions in reminders: # Parse and humanize the time, make it pretty :D remind_datetime = isoparse(remind_at) - expiry = time.discord_timestamp(remind_datetime, time.TimestampFormats.RELATIVE) + expiry = time.format_relative(remind_datetime) mentions = ", ".join([ # Both Role and User objects have the `name` attribute diff --git a/bot/utils/time.py b/bot/utils/time.py index e6dcdee15..da56bcea8 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -228,4 +228,4 @@ def until_expiration( if since < now: return None - return discord_timestamp(since, TimestampFormats.RELATIVE) + return format_relative(since) -- cgit v1.2.3 From 3ac1ef92b928c26985342a3f35934a1c7c08d2b4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 16:35:41 -0700 Subject: Time: remove absolute param from format_infraction_with_duration It's not used anywhere. Furthermore, a humanised duration with negative values wouldn't make sense. --- bot/utils/time.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index da56bcea8..190adf885 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -180,16 +180,12 @@ def format_infraction_with_duration( date_to: Optional[str], date_from: Optional[datetime.datetime] = None, max_units: int = 2, - absolute: bool = True ) -> Optional[str]: """ Return `date_to` formatted as a discord timestamp with the timestamp duration since `date_from`. `max_units` specifies the maximum number of units of time to include in the duration. For example, a value of 1 may include days but not hours. - - If `absolute` is True, the absolute value of the duration delta is used. This prevents negative - values in the case that `date_to` is in the past relative to `date_from`. """ if not date_to: return None @@ -199,10 +195,7 @@ def format_infraction_with_duration( date_from = date_from or datetime.datetime.now(datetime.timezone.utc) date_to = dateutil.parser.isoparse(date_to).replace(microsecond=0) - delta = relativedelta(date_to, date_from) - if absolute: - delta = abs(delta) - + delta = abs(relativedelta(date_to, date_from)) duration = humanize_delta(delta, max_units=max_units) duration_formatted = f" ({duration})" if duration else "" -- cgit v1.2.3 From 6ceb2c09114233f6db00e11ea85891adcdcf7f4f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 17:19:24 -0700 Subject: Time: remove broken enum type check in discord_timestamp First, the `args` attribute doesn't exist on enums. Even if it did, this check only works if the argument given is an enum member (of any enum). Such occurrence seems too rare to warrant an explicit check. --- bot/utils/time.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index 190adf885..ddcf5bac2 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -62,9 +62,6 @@ def _stringify_time_unit(value: int, unit: str) -> str: def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str: """Create and format a Discord flavored markdown timestamp.""" - if format not in TimestampFormats: - raise ValueError(f"Format can only be one of {', '.join(TimestampFormats.args)}, not {format}.") - # Convert each possible timestamp class to an integer. if isinstance(timestamp, datetime.datetime): timestamp = (timestamp - arrow.get(0)).total_seconds() -- cgit v1.2.3 From 24b28e264719e5bf40f565d553f7e9e57041b0a2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 17:20:31 -0700 Subject: Time: remove timedelta and relativedelta support from discord_timestamp When a delta is given, it is unknown what it's relative to. The function has to assume it's relative to the POSIX Epoch. However, using a delta for this would be quite odd, and would more likely be a mistake if anything. relativedelta support was broken anyway since it wasn't using the total seconds represented by the delta. --- bot/utils/time.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index ddcf5bac2..60720031a 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -20,7 +20,7 @@ _DURATION_REGEX = re.compile( ) -ValidTimestamp = Union[int, datetime.datetime, datetime.date, datetime.timedelta, relativedelta] +ValidTimestamp = Union[int, datetime.datetime, datetime.date] class TimestampFormats(Enum): @@ -67,10 +67,6 @@ def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = Time timestamp = (timestamp - arrow.get(0)).total_seconds() elif isinstance(timestamp, datetime.date): timestamp = (timestamp - arrow.get(0)).total_seconds() - elif isinstance(timestamp, datetime.timedelta): - timestamp = timestamp.total_seconds() - elif isinstance(timestamp, relativedelta): - timestamp = timestamp.seconds return f"" -- cgit v1.2.3 From 93742d718dcb4aee72ef5d20ca570b6200f07d2d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 19:14:08 -0700 Subject: Time: rename format_infraction_with_duration It's not necessarily tied to infractions anymore. --- bot/exts/moderation/infraction/_scheduler.py | 4 ++-- bot/exts/moderation/infraction/management.py | 2 +- bot/exts/moderation/stream.py | 2 +- bot/utils/time.py | 18 +++++++++--------- tests/bot/utils/test_time.py | 18 +++++++++--------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 57aa2d9b6..9d4d58e2e 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -136,7 +136,7 @@ class InfractionScheduler: infr_type = infraction["type"] icon = _utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] - expiry = time.format_infraction_with_duration(infraction["expires_at"]) + expiry = time.format_with_duration(infraction["expires_at"]) id_ = infraction['id'] if user_reason is None: @@ -387,7 +387,7 @@ class InfractionScheduler: log.info(f"Marking infraction #{id_} as inactive (expired).") expiry = dateutil.parser.isoparse(expiry) if expiry else None - created = time.format_infraction_with_duration(inserted_at, expiry) + created = time.format_with_duration(inserted_at, expiry) log_content = None log_text = { diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index fb5af9eaa..dd994a2d2 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -150,7 +150,7 @@ class ModManagement(commands.Cog): confirm_messages.append("marked as permanent") elif duration is not None: request_data['expires_at'] = duration.isoformat() - expiry = time.format_infraction_with_duration(request_data['expires_at']) + expiry = time.format_with_duration(request_data['expires_at']) confirm_messages.append(f"set to expire on {expiry}") else: confirm_messages.append("expiry unchanged") diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 5a7b12295..bc9d35714 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -133,7 +133,7 @@ class Stream(commands.Cog): await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {time.discord_timestamp(duration)}.") # Convert here for nicer logging - revoke_time = time.format_infraction_with_duration(str(duration)) + revoke_time = time.format_with_duration(str(duration)) log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.") @commands.command(aliases=("pstream",)) diff --git a/bot/utils/time.py b/bot/utils/time.py index 60720031a..13dfc6fb7 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -169,26 +169,26 @@ def format_infraction(timestamp: str) -> str: return discord_timestamp(dateutil.parser.isoparse(timestamp)) -def format_infraction_with_duration( - date_to: Optional[str], - date_from: Optional[datetime.datetime] = None, +def format_with_duration( + timestamp: Optional[str], + other_timestamp: Optional[datetime.datetime] = None, max_units: int = 2, ) -> Optional[str]: """ - Return `date_to` formatted as a discord timestamp with the timestamp duration since `date_from`. + Return `timestamp` formatted as a discord timestamp with the timestamp duration since `other_timestamp`. `max_units` specifies the maximum number of units of time to include in the duration. For example, a value of 1 may include days but not hours. """ - if not date_to: + if not timestamp: return None - date_to_formatted = format_infraction(date_to) + date_to_formatted = format_infraction(timestamp) - date_from = date_from or datetime.datetime.now(datetime.timezone.utc) - date_to = dateutil.parser.isoparse(date_to).replace(microsecond=0) + other_timestamp = other_timestamp or datetime.datetime.now(datetime.timezone.utc) + timestamp = dateutil.parser.isoparse(timestamp).replace(microsecond=0) - delta = abs(relativedelta(date_to, date_from)) + delta = abs(relativedelta(timestamp, other_timestamp)) duration = humanize_delta(delta, max_units=max_units) duration_formatted = f" ({duration})" if duration else "" diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 9c52fed27..02b5f8c17 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -47,8 +47,8 @@ class TimeTests(unittest.TestCase): """Testing format_infraction.""" self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '') - def test_format_infraction_with_duration_none_expiry(self): - """format_infraction_with_duration should work for None expiry.""" + def test_format_with_duration_none_expiry(self): + """format_with_duration should work for None expiry.""" test_cases = ( (None, None, None, None), @@ -60,10 +60,10 @@ 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) + self.assertEqual(time.format_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.""" + def test_format_with_duration_custom_units(self): + """format_with_duration should work for custom max_units.""" test_cases = ( ('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5, tzinfo=timezone.utc), 6, ' (11 hours, 55 minutes and 55 seconds)'), @@ -73,10 +73,10 @@ 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) + self.assertEqual(time.format_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.""" + def test_format_with_duration_normal_usage(self): + """format_with_duration should work for normal usage, across various durations.""" utc = timezone.utc test_cases = ( ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5, tzinfo=utc), 2, @@ -98,7 +98,7 @@ 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) + self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected) def test_until_expiration_with_duration_none_expiry(self): """until_expiration should work for None expiry.""" -- cgit v1.2.3 From ea7fc62ddc8d08b6acdb00ac2d9a024fee8ad634 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 19:26:26 -0700 Subject: Time: support more timestamp formats as arguments Remove the burden of conversion from the caller to clean up and simplify the call sites. Handle timestamp conversions internally with arrow.get. Remove format_infraction and get_time_delta because they're now obsolete. Replace the former with discord_timestamp and the latter with format_relative. --- bot/exts/moderation/infraction/_scheduler.py | 7 +- bot/exts/moderation/infraction/management.py | 4 +- bot/exts/moderation/infraction/superstarify.py | 4 +- bot/exts/moderation/stream.py | 2 +- bot/exts/moderation/watchchannels/_watchchannel.py | 4 +- bot/exts/recruitment/talentpool/_cog.py | 8 +- bot/exts/recruitment/talentpool/_review.py | 4 +- bot/exts/utils/reminders.py | 5 +- bot/utils/time.py | 94 +++++++++++----------- tests/bot/utils/test_time.py | 4 - 10 files changed, 63 insertions(+), 73 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 9d4d58e2e..47b639421 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -381,20 +381,15 @@ class InfractionScheduler: actor = infraction["actor"] type_ = infraction["type"] id_ = infraction["id"] - inserted_at = infraction["inserted_at"] - expiry = infraction["expires_at"] log.info(f"Marking infraction #{id_} as inactive (expired).") - expiry = dateutil.parser.isoparse(expiry) if expiry else None - created = time.format_with_duration(inserted_at, expiry) - log_content = None log_text = { "Member": f"<@{user_id}>", "Actor": f"<@{actor}>", "Reason": infraction["reason"], - "Created": created, + "Created": time.format_with_duration(infraction["inserted_at"], infraction["expires_at"]), } try: diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index dd994a2d2..23c6e8b92 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -150,7 +150,7 @@ class ModManagement(commands.Cog): confirm_messages.append("marked as permanent") elif duration is not None: request_data['expires_at'] = duration.isoformat() - expiry = time.format_with_duration(request_data['expires_at']) + expiry = time.format_with_duration(duration) confirm_messages.append(f"set to expire on {expiry}") else: confirm_messages.append("expiry unchanged") @@ -351,7 +351,7 @@ class ModManagement(commands.Cog): active = infraction["active"] user = infraction["user"] expires_at = infraction["expires_at"] - created = time.format_infraction(infraction["inserted_at"]) + created = time.discord_timestamp(infraction["inserted_at"]) dm_sent = infraction["dm_sent"] # Format the user string. diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 2e272dbb0..a037ca1be 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -73,7 +73,7 @@ class Superstarify(InfractionScheduler, Cog): notified = await _utils.notify_infraction( user=after, infr_type="Superstarify", - expires_at=time.format_infraction(infraction["expires_at"]), + expires_at=time.discord_timestamp(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 " @@ -150,7 +150,7 @@ class Superstarify(InfractionScheduler, Cog): id_ = infraction["id"] forced_nick = self.get_nick(id_, member.id) - expiry_str = time.format_infraction(infraction["expires_at"]) + expiry_str = time.discord_timestamp(infraction["expires_at"]) # Apply the infraction async def action() -> None: diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index bc9d35714..4dccc8a7e 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -133,7 +133,7 @@ class Stream(commands.Cog): await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {time.discord_timestamp(duration)}.") # Convert here for nicer logging - revoke_time = time.format_with_duration(str(duration)) + revoke_time = time.format_with_duration(duration) log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.") @commands.command(aliases=("pstream",)) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 106483527..ee9b6ba45 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -285,7 +285,7 @@ class WatchChannel(metaclass=CogABCMeta): actor = actor.display_name if actor else self.watched_users[user_id]['actor'] inserted_at = self.watched_users[user_id]['inserted_at'] - time_delta = time.get_time_delta(inserted_at) + time_delta = time.format_relative(inserted_at) reason = self.watched_users[user_id]['reason'] @@ -359,7 +359,7 @@ class WatchChannel(metaclass=CogABCMeta): if member: line += f" ({member.name}#{member.discriminator})" inserted_at = user_data['inserted_at'] - line += f", added {time.get_time_delta(inserted_at)}" + line += f", added {time.format_relative(inserted_at)}" if not member: # Cross off users who left the server. line = f"~~{line}~~" list_data["info"][user_id] = line diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 80274eaea..bbc135454 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -180,7 +180,7 @@ class TalentPool(Cog, name="Talentpool"): if member: line += f" ({member.name}#{member.discriminator})" inserted_at = user_data['inserted_at'] - line += f", added {time.get_time_delta(inserted_at)}" + line += f", added {time.format_relative(inserted_at)}" if not member: # Cross off users who left the server. line = f"~~{line}~~" if user_data['reviewed']: @@ -561,7 +561,7 @@ class TalentPool(Cog, name="Talentpool"): actor = await get_or_fetch_member(guild, actor_id) reason = site_entry["reason"] or "*None*" - created = time.format_infraction(site_entry["inserted_at"]) + created = time.discord_timestamp(site_entry["inserted_at"]) entries.append( f"Actor: {actor.mention if actor else actor_id}\nCreated: {created}\nReason: {reason}" ) @@ -570,7 +570,7 @@ class TalentPool(Cog, name="Talentpool"): active = nomination_object["active"] - start_date = time.format_infraction(nomination_object["inserted_at"]) + start_date = time.discord_timestamp(nomination_object["inserted_at"]) if active: lines = textwrap.dedent( f""" @@ -584,7 +584,7 @@ class TalentPool(Cog, name="Talentpool"): """ ) else: - end_date = time.format_infraction(nomination_object["ended_at"]) + end_date = time.discord_timestamp(nomination_object["ended_at"]) lines = textwrap.dedent( f""" =============== diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 474f669c6..b4d177622 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -321,7 +321,7 @@ class Reviewer: infractions += ", with the last infraction issued " # Infractions were ordered by time since insertion descending. - infractions += time.get_time_delta(infraction_list[0]['inserted_at']) + infractions += time.format_relative(infraction_list[0]['inserted_at']) return f"They have {infractions}." @@ -365,7 +365,7 @@ class Reviewer: nomination_times = f"{num_entries} times" if num_entries > 1 else "once" rejection_times = f"{len(history)} times" if len(history) > 1 else "once" - end_time = time.format_relative(isoparse(history[0]['ended_at'])) + end_time = time.format_relative(history[0]['ended_at']) review = ( f"They were nominated **{nomination_times}** before" diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index bfa294809..289d00356 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -168,7 +168,7 @@ class Reminders(Cog): self.schedule_reminder(reminder) @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) - async def send_reminder(self, reminder: dict, expected_time: datetime = None) -> None: + async def send_reminder(self, reminder: dict, expected_time: t.Optional[time.Timestamp] = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) if not is_valid: @@ -347,8 +347,7 @@ class Reminders(Cog): for content, remind_at, id_, mentions in reminders: # Parse and humanize the time, make it pretty :D - remind_datetime = isoparse(remind_at) - expiry = time.format_relative(remind_datetime) + expiry = time.format_relative(remind_at) mentions = ", ".join([ # Both Role and User objects have the `name` attribute diff --git a/bot/utils/time.py b/bot/utils/time.py index 13dfc6fb7..e927a5e63 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,10 +1,10 @@ import datetime import re from enum import Enum +from time import struct_time from typing import Optional, Union import arrow -import dateutil.parser from dateutil.relativedelta import relativedelta DISCORD_TIMESTAMP_REGEX = re.compile(r"") @@ -19,8 +19,18 @@ _DURATION_REGEX = re.compile( r"((?P\d+?) ?(seconds|second|S|s))?" ) - -ValidTimestamp = Union[int, datetime.datetime, datetime.date] +# All supported types for the single-argument overload of arrow.get(). tzinfo is excluded because +# it's too implicit of a way for the caller to specify that they want the current time. +Timestamp = Union[ + arrow.Arrow, + datetime.datetime, + datetime.date, + struct_time, + int, # POSIX timestamp + float, # POSIX timestamp + str, # ISO 8601-formatted string + tuple[int, int, int], # ISO calendar tuple +] class TimestampFormats(Enum): @@ -60,15 +70,14 @@ def _stringify_time_unit(value: int, unit: str) -> str: return f"{value} {unit}" -def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str: - """Create and format a Discord flavored markdown timestamp.""" - # Convert each possible timestamp class to an integer. - if isinstance(timestamp, datetime.datetime): - timestamp = (timestamp - arrow.get(0)).total_seconds() - elif isinstance(timestamp, datetime.date): - timestamp = (timestamp - arrow.get(0)).total_seconds() +def discord_timestamp(timestamp: Timestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str: + """ + Format a timestamp as a Discord-flavored Markdown timestamp. - return f"" + `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. + """ + timestamp = int(arrow.get(timestamp).timestamp()) + return f"" def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: @@ -115,14 +124,6 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: return humanized -def get_time_delta(time_string: str) -> str: - """Returns the time in human-readable time delta format.""" - date_time = dateutil.parser.isoparse(time_string) - time_delta = format_relative(date_time) - - return time_delta - - def parse_duration_string(duration: str) -> Optional[relativedelta]: """ Converts a `duration` string to a relativedelta object. @@ -154,64 +155,63 @@ def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta: return utcnow + delta - utcnow -def format_relative(timestamp: ValidTimestamp) -> str: +def format_relative(timestamp: Timestamp) -> str: """ Format `timestamp` as a relative Discord timestamp. A relative timestamp describes how much time has elapsed since `timestamp` or how much time - remains until `timestamp` is reached. See `time.discord_timestamp`. + remains until `timestamp` is reached. + + `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. """ return discord_timestamp(timestamp, TimestampFormats.RELATIVE) -def format_infraction(timestamp: str) -> str: - """Format an infraction timestamp to a discord timestamp.""" - return discord_timestamp(dateutil.parser.isoparse(timestamp)) - - def format_with_duration( - timestamp: Optional[str], - other_timestamp: Optional[datetime.datetime] = None, + timestamp: Optional[Timestamp], + other_timestamp: Optional[Timestamp] = None, max_units: int = 2, ) -> Optional[str]: """ Return `timestamp` formatted as a discord timestamp with the timestamp duration since `other_timestamp`. + `timestamp` and `other_timestamp` can be any type supported by the single-arg `arrow.get()`, + except for a `tzinfo`. Use the current time if `other_timestamp` is falsy or unspecified. + `max_units` specifies the maximum number of units of time to include in the duration. For example, a value of 1 may include days but not hours. + + Return None if `timestamp` is falsy. """ if not timestamp: return None - date_to_formatted = format_infraction(timestamp) - - other_timestamp = other_timestamp or datetime.datetime.now(datetime.timezone.utc) - timestamp = dateutil.parser.isoparse(timestamp).replace(microsecond=0) + timestamp = arrow.get(timestamp) + if not other_timestamp: + other_timestamp = arrow.utcnow() + else: + other_timestamp = arrow.get(other_timestamp) - delta = abs(relativedelta(timestamp, other_timestamp)) + formatted_timestamp = discord_timestamp(timestamp) + delta = abs(relativedelta(timestamp.datetime, other_timestamp.datetime)) duration = humanize_delta(delta, max_units=max_units) - duration_formatted = f" ({duration})" if duration else "" - return f"{date_to_formatted}{duration_formatted}" + return f"{formatted_timestamp} ({duration})" -def until_expiration( - expiry: Optional[str] -) -> Optional[str]: +def until_expiration(expiry: Optional[Timestamp]) -> Optional[str]: """ - Get the remaining time until infraction's expiration, in a discord timestamp. + Get the remaining time until an infraction's expiration as a Discord timestamp. - Returns a human-readable version of the remaining duration between arrow.utcnow() and an expiry. - Similar to format_relative, except that this function doesn't error on a null input - and return null if the expiry is in the paste + `expiry` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. + + Return None if `expiry` is falsy or is in the past. """ if not expiry: return None - now = arrow.utcnow() - since = dateutil.parser.isoparse(expiry).replace(microsecond=0) - - if since < now: + expiry = arrow.get(expiry) + if expiry < arrow.utcnow(): return None - return format_relative(since) + return format_relative(expiry) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 02b5f8c17..027e2052e 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -43,10 +43,6 @@ class TimeTests(unittest.TestCase): time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) self.assertEqual(str(error.exception), 'max_units must be positive') - def test_format_infraction(self): - """Testing format_infraction.""" - self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '') - def test_format_with_duration_none_expiry(self): """format_with_duration should work for None expiry.""" test_cases = ( -- cgit v1.2.3 From 75fabfc0f1a9d95a1167dd6f3d94b741768b72e8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 21:26:36 -0700 Subject: Time: remove DISCORD_TIMESTAMP_REGEX There's a saner way to parse the timestamp that relied on this regex. --- bot/exts/moderation/infraction/management.py | 15 ++++++--------- bot/utils/time.py | 2 -- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 23c6e8b92..fa1ebdadc 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -1,8 +1,7 @@ import textwrap import typing as t -from datetime import datetime, timezone -import dateutil.parser +import arrow import discord from dateutil.relativedelta import relativedelta from discord.ext import commands @@ -351,7 +350,8 @@ class ModManagement(commands.Cog): active = infraction["active"] user = infraction["user"] expires_at = infraction["expires_at"] - created = time.discord_timestamp(infraction["inserted_at"]) + inserted_at = infraction["inserted_at"] + created = time.discord_timestamp(inserted_at) dm_sent = infraction["dm_sent"] # Format the user string. @@ -371,12 +371,9 @@ class ModManagement(commands.Cog): if expires_at is None: duration = "*Permanent*" else: - date_from = datetime.fromtimestamp( - float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1)), - timezone.utc - ) - date_to = dateutil.parser.isoparse(expires_at) - duration = time.humanize_delta(relativedelta(date_to, date_from)) + start = arrow.get(inserted_at).datetime + end = arrow.get(expires_at).datetime + duration = time.humanize_delta(relativedelta(start, end)) # Format `dm_sent` if dm_sent is None: diff --git a/bot/utils/time.py b/bot/utils/time.py index e927a5e63..21d26db7d 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -7,8 +7,6 @@ from typing import Optional, Union import arrow from dateutil.relativedelta import relativedelta -DISCORD_TIMESTAMP_REGEX = re.compile(r"") - _DURATION_REGEX = re.compile( r"((?P\d+?) ?(years|year|Y|y) ?)?" r"((?P\d+?) ?(months|month|m) ?)?" -- cgit v1.2.3 From 2004477e12c72e4739ea1b1f192fb2c12eac69d0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 6 Aug 2021 14:20:52 -0700 Subject: Time: add overload to pass 2 timestamps to humanize_delta Remove the need for the caller to create a `relativedelta` from 2 timestamps before calling `humanize_delta`. This is especially convenient for cases where the original inputs aren't `datetime`s since `relativedelta` only accepts those. --- bot/exts/moderation/defcon.py | 4 +- bot/exts/moderation/infraction/management.py | 6 +- bot/exts/moderation/modlog.py | 2 +- bot/utils/time.py | 110 ++++++++++++++++++++++----- tests/bot/utils/test_time.py | 13 ++-- 5 files changed, 105 insertions(+), 30 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 263e8136e..178be734d 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -254,8 +254,8 @@ class Defcon(Cog): expiry_message = "" if expiry: - activity_duration = relativedelta(expiry, arrow.utcnow().datetime) - expiry_message = f" for the next {time.humanize_delta(activity_duration, max_units=2)}" + formatted_expiry = time.humanize_delta(expiry, max_units=2) + expiry_message = f" for the next {formatted_expiry}" if self.threshold: channel_message = ( diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index fa1ebdadc..0dfd2d759 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -1,9 +1,7 @@ import textwrap import typing as t -import arrow import discord -from dateutil.relativedelta import relativedelta from discord.ext import commands from discord.ext.commands import Context from discord.utils import escape_markdown @@ -371,9 +369,7 @@ class ModManagement(commands.Cog): if expires_at is None: duration = "*Permanent*" else: - start = arrow.get(inserted_at).datetime - end = arrow.get(expires_at).datetime - duration = time.humanize_delta(relativedelta(start, end)) + duration = time.humanize_delta(inserted_at, expires_at) # Format `dm_sent` if dm_sent is None: diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index d5e209d81..2c01a4a21 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -713,7 +713,7 @@ class ModLog(Cog, name="ModLog"): # datetime as the baseline and create a human-readable delta between this edit event # and the last time the message was edited timestamp = msg_before.edited_at - delta = time.humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at)) + delta = time.humanize_delta(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 diff --git a/bot/utils/time.py b/bot/utils/time.py index 21d26db7d..7e314a870 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -2,7 +2,7 @@ import datetime import re from enum import Enum from time import struct_time -from typing import Optional, Union +from typing import Optional, Union, overload import arrow from dateutil.relativedelta import relativedelta @@ -78,15 +78,99 @@ def discord_timestamp(timestamp: Timestamp, format: TimestampFormats = Timestamp return f"" -def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: +@overload +def humanize_delta( + arg1: Union[relativedelta, Timestamp], + /, + *, + precision: str = "seconds", + max_units: int = 6, + absolute: bool = True, +) -> str: + ... + + +@overload +def humanize_delta( + end: Timestamp, + start: Timestamp, + /, + *, + precision: str = "seconds", + max_units: int = 6, + absolute: bool = True, +) -> str: + ... + + +def humanize_delta( + *args, + precision: str = "seconds", + max_units: int = 6, + absolute: bool = True, +) -> str: """ - Returns a human-readable version of the relativedelta. + Return a human-readable version of a time duration. - precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). - max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). + `precision` is the smallest unit of time to include (e.g. "seconds", "minutes"). + + `max_units` is the maximum number of units of time to include. + Count units from largest to smallest (e.g. count days before months). + + Use the absolute value of the duration if `absolute` is True. + + Usage: + + **One** `relativedelta` object, to humanize the duration represented by it: + + >>> humanize_delta(relativedelta(years=12, months=6)) + '12 years and 6 months' + + Note that `leapdays` and absolute info (singular names) will be ignored during humanization. + + **One** timestamp of a type supported by the single-arg `arrow.get()`, except for `tzinfo`, + to humanize the duration between it and the current time: + + >>> humanize_delta('2021-08-06T12:43:01Z', absolute=True) # now = 2021-08-06T12:33:33Z + '9 minutes and 28 seconds' + + >>> humanize_delta('2021-08-06T12:43:01Z', absolute=False) # now = 2021-08-06T12:33:33Z + '-9 minutes and -28 seconds' + + **Two** timestamps, each of a type supported by the single-arg `arrow.get()`, except for + `tzinfo`, to humanize the duration between them: + + >>> humanize_delta(datetime.datetime(2020, 1, 1), '2021-01-01T12:00:00Z', absolute=False) + '1 year and 12 hours' + + >>> humanize_delta('2021-01-01T12:00:00Z', datetime.datetime(2020, 1, 1), absolute=False) + '-1 years and -12 hours' + + Note that order of the arguments can result in a different output even if `absolute` is True: + + >>> x = datetime.datetime(3000, 11, 1) + >>> y = datetime.datetime(3000, 9, 2) + >>> humanize_delta(y, x, absolute=True), humanize_delta(x, y, absolute=True) + ('1 month and 30 days', '1 month and 29 days') + + This is due to the nature of `relativedelta`; it does not represent a fixed period of time. + Instead, it's relative to the `datetime` to which it's added to get the other `datetime`. + In the example, the difference arises because all months don't have the same number of days. """ + if len(args) == 1 and isinstance(args[0], relativedelta): + delta = args[0] + elif 1 <= len(args) <= 2: + end = arrow.get(args[0]) + start = arrow.get(args[1]) if len(args) == 2 else arrow.utcnow() + + delta = relativedelta(end.datetime, start.datetime) + if absolute: + delta = abs(delta) + else: + raise ValueError(f"Received {len(args)} positional arguments, but expected 1 or 2.") + if max_units <= 0: - raise ValueError("max_units must be positive") + raise ValueError("max_units must be positive.") units = ( ("years", delta.years), @@ -174,25 +258,17 @@ def format_with_duration( Return `timestamp` formatted as a discord timestamp with the timestamp duration since `other_timestamp`. `timestamp` and `other_timestamp` can be any type supported by the single-arg `arrow.get()`, - except for a `tzinfo`. Use the current time if `other_timestamp` is falsy or unspecified. + except for a `tzinfo`. Use the current time if `other_timestamp` is None or unspecified. - `max_units` specifies the maximum number of units of time to include in the duration. For - example, a value of 1 may include days but not hours. + `max_units` is forwarded to `time.humanize_delta`. See its documentation for more information. Return None if `timestamp` is falsy. """ if not timestamp: return None - timestamp = arrow.get(timestamp) - if not other_timestamp: - other_timestamp = arrow.utcnow() - else: - other_timestamp = arrow.get(other_timestamp) - formatted_timestamp = discord_timestamp(timestamp) - delta = abs(relativedelta(timestamp.datetime, other_timestamp.datetime)) - duration = humanize_delta(delta, max_units=max_units) + duration = humanize_delta(timestamp, other_timestamp, max_units=max_units) return f"{formatted_timestamp} ({duration})" diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 027e2052e..e235f9b70 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -13,13 +13,15 @@ class TimeTests(unittest.TestCase): """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 # against the attribute of the relativedelta instance. - self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'elephants', 2), '2 days and 2 hours') + actual = time.humanize_delta(relativedelta(days=2, hours=2), precision='elephants', max_units=2) + self.assertEqual(actual, '2 days and 2 hours') def test_humanize_delta_handle_high_units(self): """humanize_delta should be able to handle very high units.""" # 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') + actual = time.humanize_delta(relativedelta(days=2, hours=2), precision='hours', max_units=20) + self.assertEqual(actual, '2 days and 2 hours') def test_humanize_delta_should_normal_usage(self): """Testing humanize delta.""" @@ -32,7 +34,8 @@ class TimeTests(unittest.TestCase): for delta, precision, max_units, expected in test_cases: with self.subTest(delta=delta, precision=precision, max_units=max_units, expected=expected): - self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + actual = time.humanize_delta(delta, precision=precision, max_units=max_units) + self.assertEqual(actual, 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.""" @@ -40,8 +43,8 @@ class TimeTests(unittest.TestCase): for max_units in test_cases: 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.exception), 'max_units must be positive') + time.humanize_delta(relativedelta(days=2, hours=2), precision='hours', max_units=max_units) + self.assertEqual(str(error.exception), 'max_units must be positive.') def test_format_with_duration_none_expiry(self): """format_with_duration should work for None expiry.""" -- cgit v1.2.3 From 07b345eed59e775977da202602ed1c9568cca494 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 6 Aug 2021 15:25:17 -0700 Subject: Time: add overload to pass relativedelta kwargs to humanize_delta --- bot/exts/moderation/slowmode.py | 3 +-- bot/utils/time.py | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index da04d1e98..b6a771441 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -39,8 +39,7 @@ class Slowmode(Cog): if channel is None: channel = ctx.channel - delay = relativedelta(seconds=channel.slowmode_delay) - humanized_delay = time.humanize_delta(delay) + humanized_delay = time.humanize_delta(seconds=channel.slowmode_delay) await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') diff --git a/bot/utils/time.py b/bot/utils/time.py index 7e314a870..6fc43ef6a 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -103,11 +103,29 @@ def humanize_delta( ... +@overload +def humanize_delta( + *, + years: int = 0, + months: int = 0, + weeks: float = 0, + days: float = 0, + hours: float = 0, + minutes: float = 0, + seconds: float = 0, + precision: str = "seconds", + max_units: int = 6, + absolute: bool = True, +) -> str: + ... + + def humanize_delta( *args, precision: str = "seconds", max_units: int = 6, absolute: bool = True, + **kwargs, ) -> str: """ Return a human-readable version of a time duration. @@ -121,6 +139,12 @@ def humanize_delta( Usage: + Keyword arguments specifying values for time units, to construct a `relativedelta` and humanize + the duration represented by it: + + >>> humanize_delta(days=2, hours=16, seconds=23) + '2 days, 16 hours and 23 seconds' + **One** `relativedelta` object, to humanize the duration represented by it: >>> humanize_delta(relativedelta(years=12, months=6)) @@ -157,9 +181,14 @@ def humanize_delta( Instead, it's relative to the `datetime` to which it's added to get the other `datetime`. In the example, the difference arises because all months don't have the same number of days. """ - if len(args) == 1 and isinstance(args[0], relativedelta): + if args and kwargs: + raise ValueError("Unsupported combination of positional and keyword arguments.") + + if len(args) == 0: + delta = relativedelta(**kwargs) + elif len(args) == 1 and isinstance(args[0], relativedelta): delta = args[0] - elif 1 <= len(args) <= 2: + elif len(args) <= 2: end = arrow.get(args[0]) start = arrow.get(args[1]) if len(args) == 2 else arrow.utcnow() -- cgit v1.2.3 From 40a2f71c41e5420b22e71a9d234bb83fd97729a5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 6 Aug 2021 15:38:48 -0700 Subject: Time: use typing.Literal for precision param of humanize_delta --- bot/utils/time.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index 6fc43ef6a..8ba49a455 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -2,7 +2,7 @@ import datetime import re from enum import Enum from time import struct_time -from typing import Optional, Union, overload +from typing import Literal, Optional, Union, overload import arrow from dateutil.relativedelta import relativedelta @@ -29,6 +29,7 @@ Timestamp = Union[ str, # ISO 8601-formatted string tuple[int, int, int], # ISO calendar tuple ] +_Precision = Literal["years", "months", "days", "hours", "minutes", "seconds"] class TimestampFormats(Enum): @@ -83,7 +84,7 @@ def humanize_delta( arg1: Union[relativedelta, Timestamp], /, *, - precision: str = "seconds", + precision: _Precision = "seconds", max_units: int = 6, absolute: bool = True, ) -> str: @@ -96,7 +97,7 @@ def humanize_delta( start: Timestamp, /, *, - precision: str = "seconds", + precision: _Precision = "seconds", max_units: int = 6, absolute: bool = True, ) -> str: @@ -113,7 +114,7 @@ def humanize_delta( hours: float = 0, minutes: float = 0, seconds: float = 0, - precision: str = "seconds", + precision: _Precision = "seconds", max_units: int = 6, absolute: bool = True, ) -> str: @@ -122,7 +123,7 @@ def humanize_delta( def humanize_delta( *args, - precision: str = "seconds", + precision: _Precision = "seconds", max_units: int = 6, absolute: bool = True, **kwargs, -- cgit v1.2.3 From 2209b9f1b95cbe2366ea2b316046ddd35ff6d3a9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Aug 2021 10:48:57 -0700 Subject: Fix create_user_embed tests Mock User.created_at and User.joined_at because `arrow.get()` doesn't work with Mock objects. The old implementation of `time.discord_timestamp` accepted mocks because it just did `int()` on any type it didn't explicitly check for. --- tests/bot/exts/info/test_information.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 30e5258fb..d896b7652 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -1,6 +1,7 @@ import textwrap import unittest import unittest.mock +from datetime import datetime import discord @@ -288,6 +289,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user.nick = None user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") user.colour = 0 + user.created_at = user.joined_at = datetime.utcnow() embed = await self.cog.create_user_embed(ctx, user, False) @@ -309,6 +311,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user.nick = "Cat lover" user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") user.colour = 0 + user.created_at = user.joined_at = datetime.utcnow() embed = await self.cog.create_user_embed(ctx, user, False) @@ -329,6 +332,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): # A `MockMember` has the @Everyone role by default; we add the Admins to that. user = helpers.MockMember(roles=[admins_role], colour=100) + user.created_at = user.joined_at = datetime.utcnow() embed = await self.cog.create_user_embed(ctx, user, False) @@ -355,6 +359,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): nomination_counts.return_value = ("Nominations", "nomination info") user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) + user.created_at = user.joined_at = datetime.utcfromtimestamp(1) embed = await self.cog.create_user_embed(ctx, user, False) infraction_counts.assert_called_once_with(user) @@ -394,6 +399,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user_messages.return_value = ("Messages", "user message counts") user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) + user.created_at = user.joined_at = datetime.utcfromtimestamp(1) embed = await self.cog.create_user_embed(ctx, user, False) infraction_counts.assert_called_once_with(user) @@ -440,6 +446,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): moderators_role = helpers.MockRole(name='Moderators') user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) + user.created_at = user.joined_at = datetime.utcnow() embed = await self.cog.create_user_embed(ctx, user, False) self.assertEqual(embed.colour, discord.Colour(100)) @@ -457,6 +464,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext() user = helpers.MockMember(id=217, colour=discord.Colour.default()) + user.created_at = user.joined_at = datetime.utcnow() embed = await self.cog.create_user_embed(ctx, user, False) self.assertEqual(embed.colour, discord.Colour.og_blurple()) @@ -474,6 +482,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext() user = helpers.MockMember(id=217, colour=0) + user.created_at = user.joined_at = datetime.utcnow() user.display_avatar.url = "avatar url" embed = await self.cog.create_user_embed(ctx, user, False) -- cgit v1.2.3 From 1af466753975b70effd5e600d0afc8b21f272dd0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Aug 2021 10:57:24 -0700 Subject: Time: return strings from until_expiration instead of ambiguous None None was returned for two separate cases: permanent infractions and expired infractions. This resulted in an ambiguity. --- bot/exts/moderation/infraction/management.py | 6 +++--- bot/utils/time.py | 8 ++++---- tests/bot/utils/test_time.py | 5 ++--- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 0dfd2d759..dda3fadae 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -179,8 +179,8 @@ class ModManagement(commands.Cog): self.infractions_cog.schedule_expiration(new_infraction) log_text += f""" - Previous expiry: {time.until_expiration(infraction['expires_at']) or "Permanent"} - New expiry: {time.until_expiration(new_infraction['expires_at']) or "Permanent"} + Previous expiry: {time.until_expiration(infraction['expires_at'])} + New expiry: {time.until_expiration(new_infraction['expires_at'])} """.rstrip() changes = ' & '.join(confirm_messages) @@ -362,7 +362,7 @@ class ModManagement(commands.Cog): user_str = f"<@{user['id']}> ({name}#{user['discriminator']:04})" if active: - remaining = time.until_expiration(expires_at) or "Expired" + remaining = time.until_expiration(expires_at) else: remaining = "Inactive" diff --git a/bot/utils/time.py b/bot/utils/time.py index 8ba49a455..4b2fbae2c 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -303,19 +303,19 @@ def format_with_duration( return f"{formatted_timestamp} ({duration})" -def until_expiration(expiry: Optional[Timestamp]) -> Optional[str]: +def until_expiration(expiry: Optional[Timestamp]) -> str: """ Get the remaining time until an infraction's expiration as a Discord timestamp. `expiry` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. - Return None if `expiry` is falsy or is in the past. + Return "Permanent" if `expiry` is falsy. Return "Expired" if `expiry` is in the past. """ if not expiry: - return None + return "Permanent" expiry = arrow.get(expiry) if expiry < arrow.utcnow(): - return None + return "Expired" return format_relative(expiry) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index e235f9b70..120d65176 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -100,8 +100,8 @@ class TimeTests(unittest.TestCase): self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected) def test_until_expiration_with_duration_none_expiry(self): - """until_expiration should work for None expiry.""" - self.assertEqual(time.until_expiration(None), None) + """until_expiration should return "Permanent" is expiry is None.""" + self.assertEqual(time.until_expiration(None), "Permanent") def test_until_expiration_with_duration_custom_units(self): """until_expiration should work for custom max_units.""" @@ -122,7 +122,6 @@ class TimeTests(unittest.TestCase): ('3000-12-12T00:00:00Z', ''), ('3000-11-23T20:09:00Z', ''), ('3000-11-23T20:09:00Z', ''), - (None, None), ) for expiry, expected in test_cases: -- cgit v1.2.3 From 38e0789890ce9d3c7307de158c9277f6aa20b848 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Aug 2021 11:01:30 -0700 Subject: Time: check timestamp for None only rather than if it's falsy Integers and floats which are 0 are considered valid timestamps, but are falsy. --- bot/utils/time.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index 4b2fbae2c..29fc46d56 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -292,9 +292,9 @@ def format_with_duration( `max_units` is forwarded to `time.humanize_delta`. See its documentation for more information. - Return None if `timestamp` is falsy. + Return None if `timestamp` is None. """ - if not timestamp: + if timestamp is None: return None formatted_timestamp = discord_timestamp(timestamp) @@ -309,9 +309,9 @@ def until_expiration(expiry: Optional[Timestamp]) -> str: `expiry` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. - Return "Permanent" if `expiry` is falsy. Return "Expired" if `expiry` is in the past. + Return "Permanent" if `expiry` is None. Return "Expired" if `expiry` is in the past. """ - if not expiry: + if expiry is None: return "Permanent" expiry = arrow.get(expiry) -- cgit v1.2.3 From e34d4cacb903cb155236c1c1c945d6159869fb59 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 23 Aug 2021 20:20:32 -0700 Subject: Time: put region comments around overloads --- bot/utils/time.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/utils/time.py b/bot/utils/time.py index 29fc46d56..005608beb 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -79,6 +79,7 @@ def discord_timestamp(timestamp: Timestamp, format: TimestampFormats = Timestamp return f"" +# region humanize_delta overloads @overload def humanize_delta( arg1: Union[relativedelta, Timestamp], @@ -119,6 +120,7 @@ def humanize_delta( absolute: bool = True, ) -> str: ... +# endregion def humanize_delta( -- cgit v1.2.3 From f6382a3eea81c1dd97b2e12fc81d42f8c77a4ae4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 21 Sep 2021 21:51:20 -0700 Subject: Time: fix format_with_duration's 2nd arg's default It wasn't passing the current time when `other_timestamp` was None. --- bot/utils/time.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/utils/time.py b/bot/utils/time.py index 005608beb..dfe65369e 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -299,6 +299,9 @@ def format_with_duration( if timestamp is None: return None + if other_timestamp is None: + other_timestamp = arrow.utcnow() + formatted_timestamp = discord_timestamp(timestamp) duration = humanize_delta(timestamp, other_timestamp, max_units=max_units) -- cgit v1.2.3 From 9f32831110ffa1d7c6cb9313c5bb56fa1c9f4d0b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 21 Sep 2021 22:02:16 -0700 Subject: TalentPool: fix typo in error message --- bot/exts/recruitment/talentpool/_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index bbc135454..0554bf37a 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -259,7 +259,7 @@ class TalentPool(Cog, name="Talentpool"): return if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") + await ctx.send(f":x: Maximum allowed characters for the reason is {REASON_MAX_CHARS}.") return # Manual request with `raise_for_status` as False because we want the actual response @@ -444,7 +444,7 @@ class TalentPool(Cog, name="Talentpool"): async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: """Edits the unnominate reason for the nomination with the given `id`.""" if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") + await ctx.send(f":x: Maximum allowed characters for the end reason is {REASON_MAX_CHARS}.") return try: -- cgit v1.2.3 From 3962e0e72e6bf6de4977092740ee79776159845e Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sat, 8 Jan 2022 12:53:23 -0700 Subject: Restrict allowed mentions for !eval results --- bot/exts/utils/snekbox.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index fbfc58d0b..ef24cbd77 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -7,7 +7,7 @@ from functools import partial from signal import Signals from typing import Optional, Tuple -from discord import HTTPException, Message, NotFound, Reaction, User +from discord import AllowedMentions, HTTPException, Message, NotFound, Reaction, User from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot @@ -218,7 +218,8 @@ class Snekbox(Cog): if filter_triggered: response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") else: - response = await ctx.send(msg) + allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author]) + response = await ctx.send(msg, allowed_mentions=allowed_mentions) scheduling.create_task(wait_for_deletion(response, (ctx.author.id,)), event_loop=self.bot.loop) log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") -- cgit v1.2.3 From f6b50c17b59f6ec02c9f7e8a7cf6f7ef1a426b7a Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sat, 8 Jan 2022 14:39:15 -0700 Subject: Fix snekbox tests with new allowed_mentions --- tests/bot/exts/utils/test_snekbox.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py index 321a92445..8bdeedd27 100644 --- a/tests/bot/exts/utils/test_snekbox.py +++ b/tests/bot/exts/utils/test_snekbox.py @@ -2,6 +2,7 @@ import asyncio import unittest from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch +from discord import AllowedMentions from discord.ext import commands from bot import constants @@ -201,7 +202,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() - ctx.author.mention = '@LemonLemonishBeard#0042' + ctx.author = MockUser(mention='@LemonLemonishBeard#0042') self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) @@ -213,9 +214,16 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.bot.get_cog.return_value = mocked_filter_cog await self.cog.send_eval(ctx, 'MyAwesomeCode') - ctx.send.assert_called_once_with( + + ctx.send.assert_called_once() + self.assertEqual( + ctx.send.call_args.args[0], '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```\n[No output]\n```' ) + allowed_mentions = ctx.send.call_args.kwargs['allowed_mentions'] + expected_allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author]) + self.assertEqual(allowed_mentions.to_dict(), expected_allowed_mentions.to_dict()) + self.cog.post_eval.assert_called_once_with('MyAwesomeCode') self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0}) self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}) @@ -238,10 +246,14 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.bot.get_cog.return_value = mocked_filter_cog await self.cog.send_eval(ctx, 'MyAwesomeCode') - ctx.send.assert_called_once_with( + + ctx.send.assert_called_once() + self.assertEqual( + ctx.send.call_args.args[0], '@LemonLemonishBeard#0042 :yay!: Return code 0.' '\n\n```\nWay too long beard\n```\nFull output: lookatmybeard.com' ) + self.cog.post_eval.assert_called_once_with('MyAwesomeCode') self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) @@ -263,9 +275,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.bot.get_cog.return_value = mocked_filter_cog await self.cog.send_eval(ctx, 'MyAwesomeCode') - ctx.send.assert_called_once_with( + + ctx.send.assert_called_once() + self.assertEqual( + ctx.send.call_args.args[0], '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```\nBeard got stuck in the eval\n```' ) + self.cog.post_eval.assert_called_once_with('MyAwesomeCode') self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) -- cgit v1.2.3 -- cgit v1.2.3 From 751f3865c007a2bec99444da39107e34df3df618 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 9 Jan 2022 16:08:42 +0000 Subject: Add bot-core as a dependancy --- poetry.lock | 155 +++++++++++++++++++++++++++++---------------------------- pyproject.toml | 2 + 2 files changed, 81 insertions(+), 76 deletions(-) diff --git a/poetry.lock b/poetry.lock index 68eebf8de..9c9cc97ad 100644 --- a/poetry.lock +++ b/poetry.lock @@ -114,29 +114,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "21.2.0" +version = "21.4.0" description = "Classes Without Boilerplate" category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] - -[[package]] -name = "backports.entry-points-selectable" -version = "1.1.1" -description = "Compatibility shim providing selectable entry points for older implementations" -category = "dev" -optional = false -python-versions = ">=2.7" - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] [[package]] name = "beautifulsoup4" @@ -153,6 +141,20 @@ soupsieve = ">1.2" html5lib = ["html5lib"] lxml = ["lxml"] +[[package]] +name = "bot-core" +version = "1.2.0" +description = "Bot-Core provides the core functionality and utilities for the bots of the Python Discord community." +category = "main" +optional = false +python-versions = "3.9.*" + +[package.dependencies] +"discord.py" = {url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip"} + +[package.source] +type = "url" +url = "https://github.com/python-discord/bot-core/archive/511bcba1b0196cd498c707a525ea56921bd971db.zip" [[package]] name = "certifi" version = "2021.10.8" @@ -190,7 +192,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "charset-normalizer" -version = "2.0.9" +version = "2.0.10" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false @@ -295,6 +297,7 @@ voice = ["PyNaCl (>=1.3.0,<1.5)"] [package.source] type = "url" url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip" + [[package]] name = "distlib" version = "0.3.4" @@ -364,11 +367,11 @@ sgmllib3k = "*" [[package]] name = "filelock" -version = "3.4.0" +version = "3.4.2" description = "A platform independent file lock." category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] @@ -506,7 +509,7 @@ pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_ve [[package]] name = "identify" -version = "2.4.0" +version = "2.4.2" description = "File identification library for Python" category = "dev" optional = false @@ -669,11 +672,11 @@ test = ["docutils", "pytest-cov", "pytest-pycodestyle", "pytest-runner"] [[package]] name = "platformdirs" -version = "2.4.0" +version = "2.4.1" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] @@ -709,7 +712,7 @@ virtualenv = ">=20.0.8" [[package]] name = "psutil" -version = "5.8.0" +version = "5.9.0" description = "Cross-platform lib for process and system monitoring in Python." category = "dev" optional = false @@ -952,7 +955,7 @@ python-versions = "*" [[package]] name = "requests" -version = "2.26.0" +version = "2.27.1" description = "Python HTTP for Humans." category = "main" optional = false @@ -1115,7 +1118,7 @@ python-versions = ">=3.6" [[package]] name = "urllib3" -version = "1.26.7" +version = "1.26.8" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false @@ -1128,14 +1131,13 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.10.0" +version = "20.13.0" description = "Virtual Python Environment builder" category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -"backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" filelock = ">=3.2,<4" platformdirs = ">=2,<3" @@ -1168,7 +1170,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "14ad70153b8c2f4a7e8492bf89f60bf7c468a939da36ce62871b677495f75302" +content-hash = "d625aaae916c07c21080bf504831f5bf4d2bf4f0e3696e404448b43719eff201" [metadata.files] aio-pika = [ @@ -1243,17 +1245,14 @@ atomicwrites = [ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, - {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, -] -"backports.entry-points-selectable" = [ - {file = "backports.entry_points_selectable-1.1.1-py2.py3-none-any.whl", hash = "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b"}, - {file = "backports.entry_points_selectable-1.1.1.tar.gz", hash = "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"}, + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] beautifulsoup4 = [ {file = "beautifulsoup4-4.10.0-py3-none-any.whl", hash = "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf"}, {file = "beautifulsoup4-4.10.0.tar.gz", hash = "sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891"}, ] +bot-core = [] certifi = [ {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, @@ -1319,8 +1318,8 @@ chardet = [ {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.9.tar.gz", hash = "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c"}, - {file = "charset_normalizer-2.0.9-py3-none-any.whl", hash = "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721"}, + {file = "charset-normalizer-2.0.10.tar.gz", hash = "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd"}, + {file = "charset_normalizer-2.0.10-py3-none-any.whl", hash = "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -1420,8 +1419,8 @@ feedparser = [ {file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"}, ] filelock = [ - {file = "filelock-3.4.0-py3-none-any.whl", hash = "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8"}, - {file = "filelock-3.4.0.tar.gz", hash = "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"}, + {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"}, + {file = "filelock-3.4.2.tar.gz", hash = "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80"}, ] flake8 = [ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, @@ -1506,8 +1505,8 @@ humanfriendly = [ {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, ] identify = [ - {file = "identify-2.4.0-py2.py3-none-any.whl", hash = "sha256:eba31ca80258de6bb51453084bff4a923187cd2193b9c13710f2516ab30732cc"}, - {file = "identify-2.4.0.tar.gz", hash = "sha256:a33ae873287e81651c7800ca309dc1f84679b763c9c8b30680e16fbfa82f0107"}, + {file = "identify-2.4.2-py2.py3-none-any.whl", hash = "sha256:67c1e66225870dce721228176637a8ef965e8dd58450bcc7592249d0dfc4da6c"}, + {file = "identify-2.4.2.tar.gz", hash = "sha256:93e8ec965e888f2212aa5c24b2b662f4832c39acb1d7196a70ea45acb626a05e"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, @@ -1697,8 +1696,8 @@ pip-licenses = [ {file = "pip_licenses-3.5.3-py3-none-any.whl", hash = "sha256:59c148d6a03784bf945d232c0dc0e9de4272a3675acaa0361ad7712398ca86ba"}, ] platformdirs = [ - {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, - {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, + {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, + {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -1709,34 +1708,38 @@ pre-commit = [ {file = "pre_commit-2.16.0.tar.gz", hash = "sha256:fe9897cac830aa7164dbd02a4e7b90cae49630451ce88464bca73db486ba9f65"}, ] psutil = [ - {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"}, - {file = "psutil-5.8.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:0ae6f386d8d297177fd288be6e8d1afc05966878704dad9847719650e44fc49c"}, - {file = "psutil-5.8.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:12d844996d6c2b1d3881cfa6fa201fd635971869a9da945cf6756105af73d2df"}, - {file = "psutil-5.8.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:02b8292609b1f7fcb34173b25e48d0da8667bc85f81d7476584d889c6e0f2131"}, - {file = "psutil-5.8.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6ffe81843131ee0ffa02c317186ed1e759a145267d54fdef1bc4ea5f5931ab60"}, - {file = "psutil-5.8.0-cp27-none-win32.whl", hash = "sha256:ea313bb02e5e25224e518e4352af4bf5e062755160f77e4b1767dd5ccb65f876"}, - {file = "psutil-5.8.0-cp27-none-win_amd64.whl", hash = "sha256:5da29e394bdedd9144c7331192e20c1f79283fb03b06e6abd3a8ae45ffecee65"}, - {file = "psutil-5.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:74fb2557d1430fff18ff0d72613c5ca30c45cdbfcddd6a5773e9fc1fe9364be8"}, - {file = "psutil-5.8.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:74f2d0be88db96ada78756cb3a3e1b107ce8ab79f65aa885f76d7664e56928f6"}, - {file = "psutil-5.8.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:99de3e8739258b3c3e8669cb9757c9a861b2a25ad0955f8e53ac662d66de61ac"}, - {file = "psutil-5.8.0-cp36-cp36m-win32.whl", hash = "sha256:36b3b6c9e2a34b7d7fbae330a85bf72c30b1c827a4366a07443fc4b6270449e2"}, - {file = "psutil-5.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:52de075468cd394ac98c66f9ca33b2f54ae1d9bff1ef6b67a212ee8f639ec06d"}, - {file = "psutil-5.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c6a5fd10ce6b6344e616cf01cc5b849fa8103fbb5ba507b6b2dee4c11e84c935"}, - {file = "psutil-5.8.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:61f05864b42fedc0771d6d8e49c35f07efd209ade09a5afe6a5059e7bb7bf83d"}, - {file = "psutil-5.8.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:0dd4465a039d343925cdc29023bb6960ccf4e74a65ad53e768403746a9207023"}, - {file = "psutil-5.8.0-cp37-cp37m-win32.whl", hash = "sha256:1bff0d07e76114ec24ee32e7f7f8d0c4b0514b3fae93e3d2aaafd65d22502394"}, - {file = "psutil-5.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:fcc01e900c1d7bee2a37e5d6e4f9194760a93597c97fee89c4ae51701de03563"}, - {file = "psutil-5.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6223d07a1ae93f86451d0198a0c361032c4c93ebd4bf6d25e2fb3edfad9571ef"}, - {file = "psutil-5.8.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d225cd8319aa1d3c85bf195c4e07d17d3cd68636b8fc97e6cf198f782f99af28"}, - {file = "psutil-5.8.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:28ff7c95293ae74bf1ca1a79e8805fcde005c18a122ca983abf676ea3466362b"}, - {file = "psutil-5.8.0-cp38-cp38-win32.whl", hash = "sha256:ce8b867423291cb65cfc6d9c4955ee9bfc1e21fe03bb50e177f2b957f1c2469d"}, - {file = "psutil-5.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:90f31c34d25b1b3ed6c40cdd34ff122b1887a825297c017e4cbd6796dd8b672d"}, - {file = "psutil-5.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6323d5d845c2785efb20aded4726636546b26d3b577aded22492908f7c1bdda7"}, - {file = "psutil-5.8.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:245b5509968ac0bd179287d91210cd3f37add77dad385ef238b275bad35fa1c4"}, - {file = "psutil-5.8.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:90d4091c2d30ddd0a03e0b97e6a33a48628469b99585e2ad6bf21f17423b112b"}, - {file = "psutil-5.8.0-cp39-cp39-win32.whl", hash = "sha256:ea372bcc129394485824ae3e3ddabe67dc0b118d262c568b4d2602a7070afdb0"}, - {file = "psutil-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3"}, - {file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"}, + {file = "psutil-5.9.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:55ce319452e3d139e25d6c3f85a1acf12d1607ddedea5e35fb47a552c051161b"}, + {file = "psutil-5.9.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:7336292a13a80eb93c21f36bde4328aa748a04b68c13d01dfddd67fc13fd0618"}, + {file = "psutil-5.9.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:cb8d10461c1ceee0c25a64f2dd54872b70b89c26419e147a05a10b753ad36ec2"}, + {file = "psutil-5.9.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:7641300de73e4909e5d148e90cc3142fb890079e1525a840cf0dfd39195239fd"}, + {file = "psutil-5.9.0-cp27-none-win32.whl", hash = "sha256:ea42d747c5f71b5ccaa6897b216a7dadb9f52c72a0fe2b872ef7d3e1eacf3ba3"}, + {file = "psutil-5.9.0-cp27-none-win_amd64.whl", hash = "sha256:ef216cc9feb60634bda2f341a9559ac594e2eeaadd0ba187a4c2eb5b5d40b91c"}, + {file = "psutil-5.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90a58b9fcae2dbfe4ba852b57bd4a1dded6b990a33d6428c7614b7d48eccb492"}, + {file = "psutil-5.9.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d41f8b3e9ebb6b6110057e40019a432e96aae2008951121ba4e56040b84f3"}, + {file = "psutil-5.9.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:742c34fff804f34f62659279ed5c5b723bb0195e9d7bd9907591de9f8f6558e2"}, + {file = "psutil-5.9.0-cp310-cp310-win32.whl", hash = "sha256:8293942e4ce0c5689821f65ce6522ce4786d02af57f13c0195b40e1edb1db61d"}, + {file = "psutil-5.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9b51917c1af3fa35a3f2dabd7ba96a2a4f19df3dec911da73875e1edaf22a40b"}, + {file = "psutil-5.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e9805fed4f2a81de98ae5fe38b75a74c6e6ad2df8a5c479594c7629a1fe35f56"}, + {file = "psutil-5.9.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c51f1af02334e4b516ec221ee26b8fdf105032418ca5a5ab9737e8c87dafe203"}, + {file = "psutil-5.9.0-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32acf55cb9a8cbfb29167cd005951df81b567099295291bcfd1027365b36591d"}, + {file = "psutil-5.9.0-cp36-cp36m-win32.whl", hash = "sha256:e5c783d0b1ad6ca8a5d3e7b680468c9c926b804be83a3a8e95141b05c39c9f64"}, + {file = "psutil-5.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d62a2796e08dd024b8179bd441cb714e0f81226c352c802fca0fd3f89eeacd94"}, + {file = "psutil-5.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3d00a664e31921009a84367266b35ba0aac04a2a6cad09c550a89041034d19a0"}, + {file = "psutil-5.9.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7779be4025c540d1d65a2de3f30caeacc49ae7a2152108adeaf42c7534a115ce"}, + {file = "psutil-5.9.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:072664401ae6e7c1bfb878c65d7282d4b4391f1bc9a56d5e03b5a490403271b5"}, + {file = "psutil-5.9.0-cp37-cp37m-win32.whl", hash = "sha256:df2c8bd48fb83a8408c8390b143c6a6fa10cb1a674ca664954de193fdcab36a9"}, + {file = "psutil-5.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1d7b433519b9a38192dfda962dd8f44446668c009833e1429a52424624f408b4"}, + {file = "psutil-5.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c3400cae15bdb449d518545cbd5b649117de54e3596ded84aacabfbb3297ead2"}, + {file = "psutil-5.9.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2237f35c4bbae932ee98902a08050a27821f8f6dfa880a47195e5993af4702d"}, + {file = "psutil-5.9.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1070a9b287846a21a5d572d6dddd369517510b68710fca56b0e9e02fd24bed9a"}, + {file = "psutil-5.9.0-cp38-cp38-win32.whl", hash = "sha256:76cebf84aac1d6da5b63df11fe0d377b46b7b500d892284068bacccf12f20666"}, + {file = "psutil-5.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:3151a58f0fbd8942ba94f7c31c7e6b310d2989f4da74fcbf28b934374e9bf841"}, + {file = "psutil-5.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:539e429da49c5d27d5a58e3563886057f8fc3868a5547b4f1876d9c0f007bccf"}, + {file = "psutil-5.9.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58c7d923dc209225600aec73aa2c4ae8ea33b1ab31bc11ef8a5933b027476f07"}, + {file = "psutil-5.9.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3611e87eea393f779a35b192b46a164b1d01167c9d323dda9b1e527ea69d697d"}, + {file = "psutil-5.9.0-cp39-cp39-win32.whl", hash = "sha256:4e2fb92e3aeae3ec3b7b66c528981fd327fb93fd906a77215200404444ec1845"}, + {file = "psutil-5.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:7d190ee2eaef7831163f254dc58f6d2e2a22e27382b936aab51c835fc080c3d3"}, + {file = "psutil-5.9.0.tar.gz", hash = "sha256:869842dbd66bb80c3217158e629d6fceaecc3a3166d3d1faee515b05dd26ca25"}, ] ptable = [ {file = "PTable-0.9.2.tar.gz", hash = "sha256:aa7fc151cb40f2dabcd2275ba6f7fd0ff8577a86be3365cd3fb297cbe09cc292"}, @@ -1963,8 +1966,8 @@ regex = [ {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"}, ] requests = [ - {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, - {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, + {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, + {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, ] requests-file = [ {file = "requests-file-1.5.1.tar.gz", hash = "sha256:07d74208d3389d01c38ab89ef403af0cfec63957d53a0081d8eca738d0247d8e"}, @@ -2018,12 +2021,12 @@ typing-extensions = [ {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, ] urllib3 = [ - {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, - {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, + {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, + {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, ] virtualenv = [ - {file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"}, - {file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"}, + {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"}, + {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"}, ] wrapt = [ {file = "wrapt-1.13.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e05e60ff3b2b0342153be4d1b597bbcfd8330890056b9619f4ad6b8d5c96a81a"}, diff --git a/pyproject.toml b/pyproject.toml index 928435975..19e5f78a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ license = "MIT" [tool.poetry.dependencies] python = "3.9.*" "discord.py" = {url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip"} +# See https://bot-core.pythondiscord.com/ for docs. +bot-core = {url = "https://github.com/python-discord/bot-core/archive/511bcba1b0196cd498c707a525ea56921bd971db.zip"} aio-pika = "~=6.1" aiodns = "~=2.0" aiohttp = "~=3.7" -- cgit v1.2.3 From e9b72ccdf7fb74331176418d12ab298c2e1426ab Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 9 Jan 2022 16:09:23 +0000 Subject: use regex from bot-core for discord invites --- bot/converters.py | 4 ++-- bot/exts/filters/filtering.py | 4 ++-- bot/utils/regex.py | 12 ------------ 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 559e759e1..cd33f5ed0 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -9,6 +9,7 @@ import dateutil.parser import dateutil.tz import discord from aiohttp import ClientConnectorError +from botcore.regex import DISCORD_INVITE from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, MemberConverter, UserConverter from discord.utils import escape_markdown, snowflake_time @@ -21,7 +22,6 @@ from bot.exts.info.doc import _inventory_parser from bot.exts.info.tags import TagIdentifier from bot.log import get_logger from bot.utils.extensions import EXTENSIONS, unqualify -from bot.utils.regex import INVITE_RE from bot.utils.time import parse_duration_string if t.TYPE_CHECKING: @@ -72,7 +72,7 @@ class ValidDiscordServerInvite(Converter): async def convert(self, ctx: Context, server_invite: str) -> dict: """Check whether the string is a valid Discord server invite.""" - invite_code = INVITE_RE.match(server_invite) + invite_code = DISCORD_INVITE.match(server_invite) if invite_code: response = await ctx.bot.http_session.get( f"{URLs.discord_invite_api}/{invite_code.group('invite')}" diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index ad904d147..1f83acf9b 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -10,6 +10,7 @@ import discord.errors import regex import tldextract from async_rediscache import RedisCache +from botcore.regex import DISCORD_INVITE from dateutil.relativedelta import relativedelta from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel from discord.ext.commands import Cog @@ -23,7 +24,6 @@ from bot.exts.moderation.modlog import ModLog from bot.log import get_logger from bot.utils import scheduling from bot.utils.messages import format_user -from bot.utils.regex import INVITE_RE log = get_logger(__name__) @@ -566,7 +566,7 @@ class Filtering(Cog): # discord\.gg/gdudes-pony-farm text = text.replace("\\", "") - invites = [m.group("invite") for m in INVITE_RE.finditer(text)] + invites = [m.group("invite") for m in DISCORD_INVITE.finditer(text)] invite_data = dict() for invite in invites: if invite in invite_data: diff --git a/bot/utils/regex.py b/bot/utils/regex.py index 9dc1eba9d..205c3ae34 100644 --- a/bot/utils/regex.py +++ b/bot/utils/regex.py @@ -1,15 +1,3 @@ import re -INVITE_RE = re.compile( - r"(discord([\.,]|dot)gg|" # Could be discord.gg/ - r"discord([\.,]|dot)com(\/|slash)invite|" # or discord.com/invite/ - r"discordapp([\.,]|dot)com(\/|slash)invite|" # or discordapp.com/invite/ - r"discord([\.,]|dot)me|" # or discord.me - r"discord([\.,]|dot)li|" # or discord.li - r"discord([\.,]|dot)io|" # or discord.io. - r"((?[a-zA-Z0-9\-]+)", # the invite code itself - flags=re.IGNORECASE -) MESSAGE_ID_RE = re.compile(r'(?P[0-9]{15,20})$') -- cgit v1.2.3 From 3ff21f1afe37e28b6a185e817691564df7e25821 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 9 Jan 2022 16:10:56 +0000 Subject: Move single-use message ID regex to inside file that uses it This moves the regex closer to the place actually using the regex, and removes the need for a regex.py file entirely. --- bot/monkey_patches.py | 3 ++- bot/utils/regex.py | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) delete mode 100644 bot/utils/regex.py diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py index b5c0de8d9..4840fa454 100644 --- a/bot/monkey_patches.py +++ b/bot/monkey_patches.py @@ -1,3 +1,4 @@ +import re from datetime import timedelta import arrow @@ -5,9 +6,9 @@ from discord import Forbidden, http from discord.ext import commands from bot.log import get_logger -from bot.utils.regex import MESSAGE_ID_RE log = get_logger(__name__) +MESSAGE_ID_RE = re.compile(r'(?P[0-9]{15,20})$') class Command(commands.Command): diff --git a/bot/utils/regex.py b/bot/utils/regex.py deleted file mode 100644 index 205c3ae34..000000000 --- a/bot/utils/regex.py +++ /dev/null @@ -1,3 +0,0 @@ -import re - -MESSAGE_ID_RE = re.compile(r'(?P[0-9]{15,20})$') -- cgit v1.2.3 From 8845e5bdbe195f2612b3652775378129383c1d6b Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 9 Jan 2022 17:35:18 +0000 Subject: Use codeblock regex from bot-core in snekbox cog --- bot/exts/utils/snekbox.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index ef24cbd77..cc3a2e1d7 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -7,6 +7,7 @@ from functools import partial from signal import Signals from typing import Optional, Tuple +from botcore.regex import FORMATTED_CODE_REGEX, RAW_CODE_REGEX from discord import AllowedMentions, HTTPException, Message, NotFound, Reaction, User from discord.ext.commands import Cog, Context, command, guild_only @@ -20,21 +21,6 @@ from bot.utils.messages import wait_for_deletion log = get_logger(__name__) ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") -FORMATTED_CODE_REGEX = re.compile( - r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block - r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) - r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all code inside the markup - r"\s*" # any more whitespace before the end of the code markup - r"(?P=delim)", # match the exact same delimiter from the start again - re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive -) -RAW_CODE_REGEX = re.compile( - r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all the rest as code - r"\s*$", # any trailing whitespace until the end of the string - re.DOTALL # "." also matches newlines -) MAX_PASTE_LEN = 10000 -- cgit v1.2.3 From dfa5af2036801124a820891dfa69c0c8884aaa03 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 9 Jan 2022 17:12:49 -0800 Subject: Converters: use datetime.timezone instead of dateutil.tz They're equivalent for UTC. Get rid of the extra import. --- bot/converters.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index b68c4d623..1865c705c 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -6,7 +6,6 @@ from datetime import datetime, timezone from ssl import CertificateError import dateutil.parser -import dateutil.tz import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta @@ -454,9 +453,9 @@ class ISODateTime(Converter): raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string") if dt.tzinfo: - dt = dt.astimezone(dateutil.tz.UTC) + dt = dt.astimezone(timezone.utc) else: # Without a timezone, assume it represents UTC. - dt = dt.replace(tzinfo=dateutil.tz.UTC) + dt = dt.replace(tzinfo=timezone.utc) return dt -- cgit v1.2.3 From bdf43f4ced428ce092ac2e24cdcf7d47c9995ff0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 9 Jan 2022 17:17:03 -0800 Subject: Scheduling: add Arrow to schedule_at's type annotations --- bot/utils/scheduling.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 7b4c8e2de..23acacf74 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -5,6 +5,8 @@ import typing as t from datetime import datetime from functools import partial +from arrow import Arrow + from bot.log import get_logger @@ -58,7 +60,7 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") - def schedule_at(self, time: datetime, task_id: t.Hashable, coroutine: t.Coroutine) -> None: + def schedule_at(self, time: t.Union[datetime, Arrow], task_id: t.Hashable, coroutine: t.Coroutine) -> None: """ Schedule `coroutine` to be executed at the given `time`. -- cgit v1.2.3 From 5092205a62ffe97855f377e46e4cfc63836cff19 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 9 Jan 2022 17:24:54 -0800 Subject: Time: revise docstrings --- bot/utils/time.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index dfe65369e..a0379c3ef 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -50,7 +50,7 @@ class TimestampFormats(Enum): def _stringify_time_unit(value: int, unit: str) -> str: """ - Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit. + Return a string to represent a value and time unit, ensuring the unit's correct plural form is used. >>> _stringify_time_unit(1, "seconds") "1 second" @@ -213,7 +213,7 @@ def humanize_delta( ("seconds", delta.seconds), ) - # Add the time units that are >0, but stop at accuracy or max_units. + # Add the time units that are >0, but stop at precision or max_units. time_strings = [] unit_count = 0 for unit, value in units: @@ -224,7 +224,7 @@ def humanize_delta( if unit == precision or unit_count >= max_units: break - # Add the 'and' between the last two units, if necessary + # Add the 'and' between the last two units, if necessary. if len(time_strings) > 1: time_strings[-1] = f"{time_strings[-2]} and {time_strings[-1]}" del time_strings[-2] @@ -240,9 +240,10 @@ def humanize_delta( def parse_duration_string(duration: str) -> Optional[relativedelta]: """ - Converts a `duration` string to a relativedelta object. + Convert a `duration` string to a relativedelta object. + + The following symbols are supported for each unit of time: - The function supports the following symbols for each unit of time: - years: `Y`, `y`, `year`, `years` - months: `m`, `month`, `months` - weeks: `w`, `W`, `week`, `weeks` @@ -250,8 +251,9 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]: - hours: `H`, `h`, `hour`, `hours` - minutes: `M`, `minute`, `minutes` - seconds: `S`, `s`, `second`, `seconds` + The units need to be provided in descending order of magnitude. - If the string does represent a durationdelta object, it will return None. + Return None if the `duration` string cannot be parsed according to the symbols above. """ match = _DURATION_REGEX.fullmatch(duration) if not match: @@ -264,7 +266,7 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]: def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta: - """Converts a relativedelta object to a timedelta object.""" + """Convert a relativedelta object to a timedelta object.""" utcnow = arrow.utcnow() return utcnow + delta - utcnow -- cgit v1.2.3 From 7740f2ed3ce8678da1717d3e75d6a341555707f6 Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Sun, 16 Jan 2022 18:05:52 -0500 Subject: Shorten the `TXT_EMBED_DESCRIPTION` message (#2048) --- bot/exts/filters/antimalware.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index d727f7940..674ca6c8b 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -18,14 +18,8 @@ PY_EMBED_DESCRIPTION = ( TXT_LIKE_FILES = {".txt", ".csv", ".json"} TXT_EMBED_DESCRIPTION = ( - "**Uh-oh!** It looks like your message got zapped by our spam filter. " - "We currently don't allow `{blocked_extension}` attachments, " - "so here are some tips to help you travel safely: \n\n" - "• If you attempted to send a message longer than 2000 characters, try shortening your message " - "to fit within the character limit or use a pasting service (see below) \n\n" - "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in " - "{cmd_channel_mention} for more information) or use a pasting service like: " - f"\n\n{URLs.site_schema}{URLs.site_paste}" + "You either uploaded a `{blocked_extension}` file or entered a message that was too long. " + f"Please use our [{URLs.site_schema}{URLs.site_paste}](paste bin) instead." ) DISALLOWED_EMBED_DESCRIPTION = ( -- cgit v1.2.3 From 0a0d20658f3f0ba76e50e211d4d17c6bc5726f7c Mon Sep 17 00:00:00 2001 From: Nipa-Code Date: Mon, 17 Jan 2022 10:48:43 +0200 Subject: Fix pastebin hyperlink to use correct markdown syntax Fix format from `[link](text)` to `[text](link)` so that the link will be formatted as it should be. --- bot/exts/filters/antimalware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 674ca6c8b..6cccf3680 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -19,7 +19,7 @@ PY_EMBED_DESCRIPTION = ( TXT_LIKE_FILES = {".txt", ".csv", ".json"} TXT_EMBED_DESCRIPTION = ( "You either uploaded a `{blocked_extension}` file or entered a message that was too long. " - f"Please use our [{URLs.site_schema}{URLs.site_paste}](paste bin) instead." + f"Please use our [paste bin]({URLs.site_schema}{URLs.site_paste}) instead." ) DISALLOWED_EMBED_DESCRIPTION = ( -- cgit v1.2.3 From e7f6c4e59ba125d1b50d0892448dc4b4b3cb09df Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Mon, 24 Jan 2022 12:35:27 +0000 Subject: Remove dev-contrib and bot-commands from features list --- bot/exts/info/information.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 29a00ec5d..5b25fd0c3 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -180,8 +180,6 @@ class Information(Cog): if ctx.channel.id in ( *constants.MODERATION_CHANNELS, constants.Channels.dev_core, - constants.Channels.dev_contrib, - constants.Channels.bot_commands ): features = f"\nFeatures: {', '.join(ctx.guild.features)}" else: -- cgit v1.2.3 From 1faadcffddc3d95511acd3b0de309df60d279715 Mon Sep 17 00:00:00 2001 From: Andrew Hong <35881688+NovialRiptide@users.noreply.github.com> Date: Tue, 25 Jan 2022 02:54:46 -0500 Subject: Rename `contributing guidelines` to `contribution guide` --- bot/resources/tags/contribute.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/contribute.md b/bot/resources/tags/contribute.md index 070975646..50c5cd11f 100644 --- a/bot/resources/tags/contribute.md +++ b/bot/resources/tags/contribute.md @@ -7,6 +7,6 @@ Looking to contribute to Open Source Projects for the first time? Want to add a • [Site](https://github.com/python-discord/site) - resources, guides, and more **Where to start** -1. Read our [contributing guidelines](https://pythondiscord.com/pages/guides/pydis-guides/contributing/) +1. Read our [contribution guide](https://pythondiscord.com/pages/guides/pydis-guides/contributing/) 2. Chat with us in <#635950537262759947> if you're ready to jump in or have any questions 3. Open an issue or ask to be assigned to an issue to work on -- cgit v1.2.3 From 61d652a32ce23373e67bb0e1cf985dd4ffc99a18 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 21 Jan 2022 21:30:41 +0000 Subject: Rename voice_ban type to voice_mute This commit changes all of the back-end so that it is in line with the new site API (see this PR https://github.com/python-discord/site/pull/608). This comes with no changes to commands, or functions definitions. --- bot/exts/moderation/infraction/_utils.py | 2 +- bot/exts/moderation/infraction/infractions.py | 20 ++++++++++---------- bot/exts/moderation/voice_gate.py | 4 ++-- .../exts/moderation/infraction/test_infractions.py | 8 ++++---- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index e683c9db4..4df833ffb 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -21,7 +21,7 @@ INFRACTION_ICONS = { "note": (Icons.user_warn, None), "superstar": (Icons.superstarify, Icons.unsuperstarify), "warning": (Icons.user_warn, None), - "voice_ban": (Icons.voice_state_red, Icons.voice_state_green), + "voice_mute": (Icons.voice_state_red, Icons.voice_state_green), } RULES_URL = "https://pythondiscord.com/pages/rules" diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index e495a94b3..72e09cbf4 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -27,7 +27,7 @@ class Infractions(InfractionScheduler, commands.Cog): category_description = "Server moderation tools." def __init__(self, bot: Bot): - super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning", "voice_ban"}) + super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning", "voice_mute"}) self.category = "Moderation" self._muted_role = discord.Object(constants.Roles.muted) @@ -273,7 +273,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command(aliases=("uvban",)) async def unvoiceban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: """Prematurely end the active voice ban infraction for the user.""" - await self.pardon_infraction(ctx, "voice_ban", user) + await self.pardon_infraction(ctx, "voice_mute", user) # endregion # region: Base apply functions @@ -397,10 +397,10 @@ class Infractions(InfractionScheduler, commands.Cog): @respect_role_hierarchy(member_arg=2) async def apply_voice_ban(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None: """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" - if await _utils.get_active_infraction(ctx, user, "voice_ban"): + if await _utils.get_active_infraction(ctx, user, "voice_mute"): return - infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) + infraction = await _utils.post_infraction(ctx, user, "voice_mute", reason, active=True, **kwargs) if infraction is None: return @@ -414,7 +414,7 @@ class Infractions(InfractionScheduler, commands.Cog): if not isinstance(user, Member): return - await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + await user.move_to(None, reason="Disconnected from voice to apply voice mute.") await user.remove_roles(self._voice_verified_role, reason=reason) await self.apply_infraction(ctx, infraction, user, action()) @@ -487,9 +487,9 @@ class Infractions(InfractionScheduler, commands.Cog): # DM user about infraction expiration notified = await _utils.notify_pardon( user=user, - title="Voice ban ended", - content="You have been unbanned and can verify yourself again in the server.", - icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] + title="Voice mute ended", + content="You have been unmuted and can verify yourself again in the server.", + icon_url=_utils.INFRACTION_ICONS["voice_mute"][1] ) log_text["DM"] = "Sent" if notified else "**Failed**" @@ -514,8 +514,8 @@ class Infractions(InfractionScheduler, commands.Cog): return await self.pardon_mute(user_id, guild, reason, notify=notify) elif infraction["type"] == "ban": return await self.pardon_ban(user_id, guild, reason) - elif infraction["type"] == "voice_ban": - return await self.pardon_voice_ban(user_id, guild, notify=notify) + elif infraction["type"] == "voice_mute": + return await self.pardon_voice_mute(user_id, guild, notify=notify) # endregion diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index a382b13d1..42505b8e7 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -30,7 +30,7 @@ FAILED_MESSAGE = ( MESSAGE_FIELD_MAP = { "joined_at": f"have been on the server for less than {GateConf.minimum_days_member} days", - "voice_banned": "have an active voice ban infraction", + "voice_muted": "have an active voice mute infraction", "total_messages": f"have sent less than {GateConf.minimum_messages} messages", "activity_blocks": f"have been active for fewer than {GateConf.minimum_activity_blocks} ten-minute blocks", } @@ -170,7 +170,7 @@ class VoiceGate(Cog): ctx.author.joined_at > arrow.utcnow() - timedelta(days=GateConf.minimum_days_member) ), "total_messages": data["total_messages"] < GateConf.minimum_messages, - "voice_banned": data["voice_banned"], + "voice_muted": data["voice_muted"], "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks, } diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 4d01e18a5..a796fd049 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -89,7 +89,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Should call infraction pardoning function.""" self.cog.pardon_infraction = AsyncMock() self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) - self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) + self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user) @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") @@ -97,7 +97,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Should return early when user already have Voice Ban infraction.""" get_active_infraction.return_value = {"foo": "bar"} self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) - get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_ban") + get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_mute") post_infraction_mock.assert_not_awaited() @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @@ -120,7 +120,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): post_infraction_mock.return_value = None self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23)) post_infraction_mock.assert_awaited_once_with( - self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23 + self.ctx, self.user, "voice_mute", "foobar", active=True, my_kwarg=23 ) @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @@ -187,7 +187,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): 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, expires_at=None) + post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_mute", None, active=True, expires_at=None) apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY) # Test action -- cgit v1.2.3 From 32d77fa9839eb9d373106700dcc4927851d94635 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 21 Jan 2022 21:34:41 +0000 Subject: Refactor voice_ban function definitions to voice_mute This changes all functions that reference voice_ban to voice_mute instead, which comes with breaking front-end changes. These front end changes are desirable, so that moderators get used to use voice_mute now, rather than voice_ban, in preparation for when we roll out real voice_bans. --- bot/exts/moderation/infraction/infractions.py | 42 ++++++------ .../exts/moderation/infraction/test_infractions.py | 78 +++++++++++----------- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 72e09cbf4..d6580bc14 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -107,8 +107,8 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, 1, expires_at=duration) - @command(aliases=('vban',)) - async def voiceban( + @command(aliases=("vmute",)) + async def voicemute( self, ctx: Context, user: UnambiguousMemberOrUser, @@ -117,11 +117,11 @@ class Infractions(InfractionScheduler, commands.Cog): reason: t.Optional[str] ) -> None: """ - Permanently ban user from using voice channels. + Permanently mute user in voice channels. - If duration is specified, it temporarily voice bans that user for the given duration. + If duration is specified, it temporarily voice mutes that user for the given duration. """ - await self.apply_voice_ban(ctx, user, reason, expires_at=duration) + await self.apply_voice_mute(ctx, user, reason, expires_at=duration) # endregion # region: Temporary infractions @@ -185,17 +185,17 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, expires_at=duration) - @command(aliases=("tempvban", "tvban")) - async def tempvoiceban( - self, - ctx: Context, - user: UnambiguousMemberOrUser, - duration: Expiry, - *, - reason: t.Optional[str] + @command(aliases=("tempvmute", "tvmute")) + async def tempvoicemute( + self, + ctx: Context, + user: UnambiguousMemberOrUser, + duration: Expiry, + *, + reason: t.Optional[str] ) -> None: """ - Temporarily voice ban a user for the given reason and duration. + Temporarily voice mute a user for the given reason and duration. A unit of time should be appended to the duration. Units (∗case-sensitive): @@ -209,7 +209,7 @@ class Infractions(InfractionScheduler, commands.Cog): Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - await self.apply_voice_ban(ctx, user, reason, expires_at=duration) + await self.apply_voice_mute(ctx, user, reason, expires_at=duration) # endregion # region: Permanent shadow infractions @@ -270,9 +270,9 @@ class Infractions(InfractionScheduler, commands.Cog): """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) - @command(aliases=("uvban",)) - async def unvoiceban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: - """Prematurely end the active voice ban infraction for the user.""" + @command(aliases=("uvmute",)) + async def unvoicemute(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: + """Prematurely end the active voice mute infraction for the user.""" await self.pardon_infraction(ctx, "voice_mute", user) # endregion @@ -395,8 +395,8 @@ class Infractions(InfractionScheduler, commands.Cog): await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) @respect_role_hierarchy(member_arg=2) - async def apply_voice_ban(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None: - """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" + async def apply_voice_mute(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None: + """Apply a voice mute infraction with kwargs passed to `post_infraction`.""" if await _utils.get_active_infraction(ctx, user, "voice_mute"): return @@ -471,7 +471,7 @@ class Infractions(InfractionScheduler, commands.Cog): return log_text - async def pardon_voice_ban( + async def pardon_voice_mute( self, user_id: int, guild: discord.Guild, diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index a796fd049..f89465f84 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -62,8 +62,8 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): @patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) -class VoiceBanTests(unittest.IsolatedAsyncioTestCase): - """Tests for voice ban related functions and commands.""" +class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): + """Tests for voice mute related functions and commands.""" def setUp(self): self.bot = MockBot() @@ -73,59 +73,59 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext(bot=self.bot, author=self.mod) self.cog = Infractions(self.bot) - async def test_permanent_voice_ban(self): - """Should call voice ban applying function without expiry.""" - self.cog.apply_voice_ban = AsyncMock() - self.assertIsNone(await self.cog.voiceban(self.cog, self.ctx, self.user, reason="foobar")) - self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at=None) + async def test_permanent_voice_mute(self): + """Should call voice mute applying function without expiry.""" + self.cog.apply_voice_mute = AsyncMock() + self.assertIsNone(await self.cog.voicemute(self.cog, self.ctx, self.user, reason="foobar")) + self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at=None) - async def test_temporary_voice_ban(self): - """Should call voice ban applying function with expiry.""" - self.cog.apply_voice_ban = AsyncMock() - self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar")) - self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") + async def test_temporary_voice_mute(self): + """Should call voice mute applying function with expiry.""" + self.cog.apply_voice_mute = AsyncMock() + self.assertIsNone(await self.cog.tempvoicemute(self.cog, self.ctx, self.user, "baz", reason="foobar")) + self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") - async def test_voice_unban(self): + async def test_voice_unmute(self): """Should call infraction pardoning function.""" self.cog.pardon_infraction = AsyncMock() - self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) + self.assertIsNone(await self.cog.unvoicemute(self.cog, self.ctx, self.user)) self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user) @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): - """Should return early when user already have Voice Ban infraction.""" + async def test_voice_mute_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): + """Should return early when user already have Voice Mute infraction.""" get_active_infraction.return_value = {"foo": "bar"} - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar")) get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_mute") post_infraction_mock.assert_not_awaited() @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_infraction_post_failed(self, get_active_infraction, post_infraction_mock): + async def test_voice_mute_infraction_post_failed(self, get_active_infraction, post_infraction_mock): """Should return early when posting infraction fails.""" self.cog.mod_log.ignore = MagicMock() get_active_infraction.return_value = None post_infraction_mock.return_value = None - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar")) post_infraction_mock.assert_awaited_once() self.cog.mod_log.ignore.assert_not_called() @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock): - """Should pass all kwargs passed to apply_voice_ban to post_infraction.""" + async def test_voice_mute_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock): + """Should pass all kwargs passed to apply_voice_mute to post_infraction.""" get_active_infraction.return_value = None # We don't want that this continue yet post_infraction_mock.return_value = None - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23)) + self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar", my_kwarg=23)) post_infraction_mock.assert_awaited_once_with( self.ctx, self.user, "voice_mute", "foobar", active=True, my_kwarg=23 ) @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infraction_mock): + async def test_voice_mute_mod_log_ignore(self, get_active_infraction, post_infraction_mock): """Should ignore Voice Verified role removing.""" self.cog.mod_log.ignore = MagicMock() self.cog.apply_infraction = AsyncMock() @@ -134,11 +134,11 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): 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.assertIsNone(await self.cog.apply_voice_mute(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.""" + """Helper method to test voice mute action.""" self.assertTrue(inspect.iscoroutine(action)) await action @@ -147,7 +147,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): @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): + async def test_voice_mute_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() @@ -156,22 +156,22 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): post_infraction_mock.return_value = {"foo": "bar"} reason = "foobar" - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, reason)) + self.assertIsNone(await self.cog.apply_voice_mute(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") - async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infraction_mock): - """Should truncate reason for voice ban.""" + async def test_voice_mute_truncate_reason(self, get_active_infraction, post_infraction_mock): + """Should truncate reason for voice mute.""" self.cog.mod_log.ignore = MagicMock() self.cog.apply_infraction = AsyncMock() 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.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar" * 3000)) self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY) # Test action @@ -180,13 +180,13 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): @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.""" + async def test_voice_mute_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _): + """Should voice mute 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) + await self.cog.voicemute(self.cog, self.ctx, user, reason=None) post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_mute", None, active=True, expires_at=None) apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY) @@ -195,22 +195,22 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(inspect.iscoroutine(action)) await action - async def test_voice_unban_user_not_found(self): + async def test_voice_unmute_user_not_found(self): """Should include info to return dict when user was not found from guild.""" self.guild.get_member.return_value = None self.guild.fetch_member.side_effect = NotFound(Mock(status=404), "Not found") - result = await self.cog.pardon_voice_ban(self.user.id, self.guild) + result = await self.cog.pardon_voice_mute(self.user.id, self.guild) self.assertEqual(result, {"Info": "User was not found in the guild."}) @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") @patch("bot.exts.moderation.infraction.infractions.format_user") - async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock): + async def test_voice_unmute_user_found(self, format_user_mock, notify_pardon_mock): """Should add role back with ignoring, notify user and return log dictionary..""" self.guild.get_member.return_value = self.user notify_pardon_mock.return_value = True format_user_mock.return_value = "my-user" - result = await self.cog.pardon_voice_ban(self.user.id, self.guild) + result = await self.cog.pardon_voice_mute(self.user.id, self.guild) self.assertEqual(result, { "Member": "my-user", "DM": "Sent" @@ -219,13 +219,13 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") @patch("bot.exts.moderation.infraction.infractions.format_user") - async def test_voice_unban_dm_fail(self, format_user_mock, notify_pardon_mock): + async def test_voice_unmute_dm_fail(self, format_user_mock, notify_pardon_mock): """Should add role back with ignoring, notify user and return log dictionary..""" self.guild.get_member.return_value = self.user notify_pardon_mock.return_value = False format_user_mock.return_value = "my-user" - result = await self.cog.pardon_voice_ban(self.user.id, self.guild) + result = await self.cog.pardon_voice_mute(self.user.id, self.guild) self.assertEqual(result, { "Member": "my-user", "DM": "**Failed**" -- cgit v1.2.3 From 07211bb6eaec2b18a5e13fdfc08ed0f4697a72b6 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 21 Jan 2022 21:35:49 +0000 Subject: Add voice_ban stub commands These stub commands are useful for moderators during the change over from voice_ban to voice_mute, to remind moderators that the command has been changed now. --- bot/exts/moderation/infraction/infractions.py | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index d6580bc14..7c0259b8e 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -107,6 +107,17 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, 1, expires_at=duration) + @command(aliases=("vban",)) + async def voiceban(self, ctx: Context) -> None: + """ + NOT IMPLEMENTED. + + Permanently ban a user from joining voice channels. + + If duration is specified, it temporarily voice bans that user for the given duration. + """ + await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `voicemute`?") + @command(aliases=("vmute",)) async def voicemute( self, @@ -185,6 +196,15 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, expires_at=duration) + @command(aliases=("tempvban", "tvban")) + async def tempvoiceban(self, ctx: Context) -> None: + """ + NOT IMPLEMENTED. + + Temporarily voice bans that user for the given duration. + """ + await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `tempvoicemute`?") + @command(aliases=("tempvmute", "tvmute")) async def tempvoicemute( self, @@ -270,6 +290,15 @@ class Infractions(InfractionScheduler, commands.Cog): """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) + @command(aliases=("uvban",)) + async def unvoiceban(self, ctx: Context) -> None: + """ + NOT IMPLEMENTED. + + Temporarily voice bans that user for the given duration. + """ + await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `unvoicemute`?") + @command(aliases=("uvmute",)) async def unvoicemute(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: """Prematurely end the active voice mute infraction for the user.""" -- cgit v1.2.3 From 10186afb0a376c82f71235b06f8f5af87f41bcb6 Mon Sep 17 00:00:00 2001 From: Izan Date: Sat, 22 Jan 2022 16:56:56 +0000 Subject: Add missing arguments to `notify_infraction` call Fixes an issue caused by #1951. --- bot/exts/moderation/infraction/superstarify.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index a037ca1be..3f1bffd76 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -57,7 +57,9 @@ class Superstarify(InfractionScheduler, Cog): return infraction = active_superstarifies[0] - forced_nick = self.get_nick(infraction["id"], before.id) + infr_id = infraction["id"] + + forced_nick = self.get_nick(infr_id, before.id) if after.display_name == forced_nick: return # Nick change was triggered by this event. Ignore. @@ -67,11 +69,13 @@ class Superstarify(InfractionScheduler, Cog): ) await after.edit( nick=forced_nick, - reason=f"Superstarified member tried to escape the prison: {infraction['id']}" + reason=f"Superstarified member tried to escape the prison: {infr_id}" ) notified = await _utils.notify_infraction( + bot=self.bot, user=after, + infr_id=infr_id, infr_type="Superstarify", expires_at=time.discord_timestamp(infraction["expires_at"]), reason=( -- cgit v1.2.3 From d7095c40e83800cc379115ef45bd987a5deb4649 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 25 Jan 2022 21:51:10 +0000 Subject: setuptools use stdlib distutils over embedded This is caused by an upstream issue with setuptools 60.* (via virtualenv) changeing the default to using the setuptools-embedded distutils rather than the stdlib distutils, which breaks within pip's isolated builds. This is explained quite well here https://github.com/pre-commit/pre-commit/issues/2178#issuecomment-1002163763 --- .github/workflows/lint-test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index f2c9dfb6c..57cc544d9 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -46,6 +46,10 @@ jobs: PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache + # See https://github.com/pre-commit/pre-commit/issues/2178#issuecomment-1002163763 + # for why we set this. + SETUPTOOLS_USE_DISTUTILS: stdlib + steps: - name: Add custom PYTHONUSERBASE to PATH run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH -- cgit v1.2.3 From 4383c139637025917645300cda8047f32926aa99 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 25 Jan 2022 21:51:56 +0000 Subject: Add missing restart-policy to metricity container --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 869d9acb6..ce78f65aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,7 @@ services: metricity: << : *logging + << : *restart_policy restart: on-failure # USE_METRICITY=false will stop the container, so this ensures it only restarts on error depends_on: postgres: -- cgit v1.2.3 From 30fee62d259e71619017420a356ed0f55bf21d2b Mon Sep 17 00:00:00 2001 From: minalike Date: Thu, 27 Jan 2022 00:32:59 -0500 Subject: Add embed message mentioning help channel claimant Update docstring --- bot/exts/help_channels/_cog.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 60209ba6e..541c791e5 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -105,10 +105,18 @@ class HelpChannels(commands.Cog): """ Claim the channel in which the question `message` was sent. - Move the channel to the In Use category and pin the `message`. Add a cooldown to the - claimant to prevent them from asking another question. Lastly, make a new channel available. + Send an embed stating the claimant, move the channel to the In Use category, and pin the `message`. + Add a cooldown to the claimant to prevent them from asking another question. + Lastly, make a new channel available. """ log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") + + embed = discord.Embed( + description=f"Channel claimed by {message.author.mention}.", + color=constants.Colours.bright_green, + ) + await message.channel.send(embed=embed) + await self.move_to_in_use(message.channel) # Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839) -- cgit v1.2.3 From 01df37482ffa39138173070f97b7c9ec6e92a055 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 27 Jan 2022 20:31:23 +0000 Subject: Use `voice_gate_blocked` field from API for voice_gate This new field is true when the user has any voice exception, which means the user is blocked from receiving the role. --- bot/exts/moderation/voice_gate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 42505b8e7..fa66b00dd 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -30,7 +30,7 @@ FAILED_MESSAGE = ( MESSAGE_FIELD_MAP = { "joined_at": f"have been on the server for less than {GateConf.minimum_days_member} days", - "voice_muted": "have an active voice mute infraction", + "voice_gate_blocked": "have an active voice infraction", "total_messages": f"have sent less than {GateConf.minimum_messages} messages", "activity_blocks": f"have been active for fewer than {GateConf.minimum_activity_blocks} ten-minute blocks", } @@ -170,7 +170,7 @@ class VoiceGate(Cog): ctx.author.joined_at > arrow.utcnow() - timedelta(days=GateConf.minimum_days_member) ), "total_messages": data["total_messages"] < GateConf.minimum_messages, - "voice_muted": data["voice_muted"], + "voice_gate_blocked": data["voice_gate_blocked"], "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks, } -- cgit v1.2.3 From 9f2d0839e4fb9d31d500db2c5bc5e22d59ed2cbb Mon Sep 17 00:00:00 2001 From: minalike Date: Sat, 5 Feb 2022 11:06:01 -0500 Subject: Add reported message author's username and profile picture in embed --- bot/exts/moderation/incidents.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 77dfad255..b579416a6 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -229,6 +229,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d ), timestamp=message.created_at ) + embed.set_author(name=message.author, icon_url=message.author.display_avatar.url) embed.add_field( name="Content", value=shorten_text(message.content) if message.content else "[No Message Content]" -- cgit v1.2.3 From d8db2e5cfe9285350b286427c05a1781303e8443 Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sun, 6 Feb 2022 17:34:26 -0700 Subject: Remove Coveralls dev dependency --- poetry.lock | 34 ++-------------------------------- pyproject.toml | 1 - 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9c9cc97ad..6d3bd44bb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -155,6 +155,7 @@ python-versions = "3.9.*" [package.source] type = "url" url = "https://github.com/python-discord/bot-core/archive/511bcba1b0196cd498c707a525ea56921bd971db.zip" + [[package]] name = "certifi" version = "2021.10.8" @@ -234,22 +235,6 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] toml = ["toml"] -[[package]] -name = "coveralls" -version = "2.2.0" -description = "Show coverage stats online via coveralls.io" -category = "dev" -optional = false -python-versions = ">= 3.5" - -[package.dependencies] -coverage = ">=4.1,<6.0" -docopt = ">=0.6.1" -requests = ">=1.0.0" - -[package.extras] -yaml = ["PyYAML (>=3.10)"] - [[package]] name = "deepdiff" version = "4.3.2" @@ -306,14 +291,6 @@ category = "dev" optional = false python-versions = "*" -[[package]] -name = "docopt" -version = "0.6.2" -description = "Pythonic argument parser, that will make you smile" -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "emoji" version = "0.6.0" @@ -1170,7 +1147,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "d625aaae916c07c21080bf504831f5bf4d2bf4f0e3696e404448b43719eff201" +content-hash = "0248fc7488c79af0cdb3a6db9528f4c3129db50b3a8d1dd3ba57dbc31b381c31" [metadata.files] aio-pika = [ @@ -1383,10 +1360,6 @@ coverage = [ {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] -coveralls = [ - {file = "coveralls-2.2.0-py2.py3-none-any.whl", hash = "sha256:2301a19500b06649d2ec4f2858f9c69638d7699a4c63027c5d53daba666147cc"}, - {file = "coveralls-2.2.0.tar.gz", hash = "sha256:b990ba1f7bc4288e63340be0433698c1efe8217f78c689d254c2540af3d38617"}, -] deepdiff = [ {file = "deepdiff-4.3.2-py3-none-any.whl", hash = "sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4"}, {file = "deepdiff-4.3.2.tar.gz", hash = "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"}, @@ -1400,9 +1373,6 @@ distlib = [ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, ] -docopt = [ - {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, -] emoji = [ {file = "emoji-0.6.0.tar.gz", hash = "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11"}, ] diff --git a/pyproject.toml b/pyproject.toml index 19e5f78a7..c764910c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,6 @@ tldextract = "^3.1.2" [tool.poetry.dev-dependencies] coverage = "~=5.0" -coveralls = "~=2.1" flake8 = "~=3.8" flake8-annotations = "~=2.0" flake8-bugbear = "~=20.1" -- cgit v1.2.3 From dd40e70db9c41c1a61faae5f2991cb841f2c6d10 Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sun, 6 Feb 2022 17:41:43 -0700 Subject: Remove Coveralls badge from readme --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 9df905dc8..06df4fd9a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ [![Lint & Test][1]][2] [![Build][3]][4] [![Deploy][5]][6] -[![Coverage Status](https://coveralls.io/repos/github/python-discord/bot/badge.svg)](https://coveralls.io/github/python-discord/bot) [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities -- cgit v1.2.3 From d7872d49f6355c811422ad39d299343c7fd7ef63 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Mon, 7 Feb 2022 05:45:34 +0200 Subject: Don't validate reminder author Validation relies on the cache which might not be properly filled. This can cause reminders to be sent for users who are no longer in the server, which seems negligible. --- bot/exts/utils/reminders.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 289d00356..ad82d49c9 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -66,20 +66,19 @@ class Reminders(Cog): else: self.schedule_reminder(reminder) - def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]: - """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" - user = self.bot.get_user(reminder['author']) + def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.TextChannel]: + """Ensure reminder channel can be fetched otherwise delete the reminder.""" channel = self.bot.get_channel(reminder['channel_id']) is_valid = True - if not user or not channel: + if not channel: is_valid = False log.info( f"Reminder {reminder['id']} invalid: " - f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." + f"Channel {reminder['channel_id']}={channel}." ) scheduling.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")) - return is_valid, user, channel + return is_valid, channel @staticmethod async def _send_confirmation( @@ -170,7 +169,7 @@ class Reminders(Cog): @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) async def send_reminder(self, reminder: dict, expected_time: t.Optional[time.Timestamp] = None) -> None: """Send the reminder.""" - is_valid, user, channel = self.ensure_valid_reminder(reminder) + is_valid, channel = self.ensure_valid_reminder(reminder) if not is_valid: # No need to cancel the task too; it'll simply be done once this coroutine returns. return @@ -206,7 +205,7 @@ class Reminders(Cog): f"There was an error when trying to reply to a reminder invocation message, {e}, " "fall back to using jump_url" ) - await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) + await channel.send(content=f"<@{reminder['author']}> {additional_mentions}", embed=embed) log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}") -- cgit v1.2.3 From f037942d3b2ba77a568a12fc4fa703fee3274d90 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Wed, 9 Feb 2022 07:58:04 +0200 Subject: Disable Reminders Cog (#2074) --- bot/exts/utils/reminders.py | 492 -------------------------------------------- 1 file changed, 492 deletions(-) delete mode 100644 bot/exts/utils/reminders.py diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py deleted file mode 100644 index 289d00356..000000000 --- a/bot/exts/utils/reminders.py +++ /dev/null @@ -1,492 +0,0 @@ -import random -import textwrap -import typing as t -from datetime import datetime, timezone -from operator import itemgetter - -import discord -from dateutil.parser import isoparse -from discord.ext.commands import Cog, Context, Greedy, group - -from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES -from bot.converters import Duration, UnambiguousUser -from bot.log import get_logger -from bot.pagination import LinePaginator -from bot.utils import scheduling, time -from bot.utils.checks import has_any_role_check, has_no_roles_check -from bot.utils.lock import lock_arg -from bot.utils.members import get_or_fetch_member -from bot.utils.messages import send_denial -from bot.utils.scheduling import Scheduler - -log = get_logger(__name__) - -LOCK_NAMESPACE = "reminder" -WHITELISTED_CHANNELS = Guild.reminder_whitelist -MAXIMUM_REMINDERS = 5 - -Mentionable = t.Union[discord.Member, discord.Role] -ReminderMention = t.Union[UnambiguousUser, discord.Role] - - -class Reminders(Cog): - """Provide in-channel reminder functionality.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - - scheduling.create_task(self.reschedule_reminders(), event_loop=self.bot.loop) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - async def reschedule_reminders(self) -> None: - """Get all current reminders from the API and reschedule them.""" - await self.bot.wait_until_guild_available() - response = await self.bot.api_client.get( - 'bot/reminders', - params={'active': 'true'} - ) - - now = datetime.now(timezone.utc) - - for reminder in response: - is_valid, *_ = self.ensure_valid_reminder(reminder) - if not is_valid: - continue - - remind_at = isoparse(reminder['expiration']) - - # If the reminder is already overdue ... - if remind_at < now: - await self.send_reminder(reminder, remind_at) - else: - self.schedule_reminder(reminder) - - def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]: - """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" - user = self.bot.get_user(reminder['author']) - channel = self.bot.get_channel(reminder['channel_id']) - is_valid = True - if not user or not channel: - is_valid = False - log.info( - f"Reminder {reminder['id']} invalid: " - f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." - ) - scheduling.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")) - - return is_valid, user, channel - - @staticmethod - async def _send_confirmation( - ctx: Context, - on_success: str, - reminder_id: t.Union[str, int] - ) -> None: - """Send an embed confirming the reminder change was made successfully.""" - embed = discord.Embed( - description=on_success, - colour=discord.Colour.green(), - title=random.choice(POSITIVE_REPLIES) - ) - - footer_str = f"ID: {reminder_id}" - - embed.set_footer(text=footer_str) - - await ctx.send(embed=embed) - - @staticmethod - async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t.Tuple[bool, str]: - """ - Returns whether or not the list of mentions is allowed. - - Conditions: - - Role reminders are Mods+ - - Reminders for other users are Helpers+ - - If mentions aren't allowed, also return the type of mention(s) disallowed. - """ - if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES): - return False, "members/roles" - elif await has_no_roles_check(ctx, *MODERATION_ROLES): - return all(isinstance(mention, (discord.User, discord.Member)) for mention in mentions), "roles" - else: - return True, "" - - @staticmethod - async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> bool: - """ - Filter mentions to see if the user can mention, and sends a denial if not allowed. - - Returns whether or not the validation is successful. - """ - mentions_allowed, disallowed_mentions = await Reminders._check_mentions(ctx, mentions) - - if not mentions or mentions_allowed: - return True - else: - await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") - return False - - async def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: - """Converts Role and Member ids to their corresponding objects if possible.""" - guild = self.bot.get_guild(Guild.id) - for mention_id in mention_ids: - member = await get_or_fetch_member(guild, mention_id) - if mentionable := (member or guild.get_role(mention_id)): - yield mentionable - - def schedule_reminder(self, reminder: dict) -> None: - """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" - reminder_datetime = isoparse(reminder['expiration']) - self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder)) - - async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: - """ - Edits a reminder in the database given the ID and payload. - - Returns the edited reminder. - """ - # Send the request to update the reminder in the database - reminder = await self.bot.api_client.patch( - 'bot/reminders/' + str(reminder_id), - json=payload - ) - return reminder - - async def _reschedule_reminder(self, reminder: dict) -> None: - """Reschedule a reminder object.""" - log.trace(f"Cancelling old task #{reminder['id']}") - self.scheduler.cancel(reminder["id"]) - - log.trace(f"Scheduling new task #{reminder['id']}") - self.schedule_reminder(reminder) - - @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) - async def send_reminder(self, reminder: dict, expected_time: t.Optional[time.Timestamp] = None) -> None: - """Send the reminder.""" - is_valid, user, channel = self.ensure_valid_reminder(reminder) - if not is_valid: - # No need to cancel the task too; it'll simply be done once this coroutine returns. - return - embed = discord.Embed() - if expected_time: - embed.colour = discord.Colour.red() - embed.set_author( - icon_url=Icons.remind_red, - name="Sorry, your reminder should have arrived earlier!" - ) - else: - embed.colour = discord.Colour.og_blurple() - embed.set_author( - icon_url=Icons.remind_blurple, - name="It has arrived!" - ) - - # Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway. - embed.description = f"Here's your reminder: {reminder['content']}" - - # Here the jump URL is in the format of base_url/guild_id/channel_id/message_id - additional_mentions = ' '.join([ - mentionable.mention async for mentionable in self.get_mentionables(reminder["mentions"]) - ]) - - jump_url = reminder.get("jump_url") - embed.description += f"\n[Jump back to when you created the reminder]({jump_url})" - partial_message = channel.get_partial_message(int(jump_url.split("/")[-1])) - try: - await partial_message.reply(content=f"{additional_mentions}", embed=embed) - except discord.HTTPException as e: - log.info( - f"There was an error when trying to reply to a reminder invocation message, {e}, " - "fall back to using jump_url" - ) - await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) - - log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") - await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}") - - @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) - async def remind_group( - self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None - ) -> None: - """ - Commands for managing your reminders. - - The `expiration` duration of `!remind new` supports the following symbols for each unit of time: - - years: `Y`, `y`, `year`, `years` - - months: `m`, `month`, `months` - - weeks: `w`, `W`, `week`, `weeks` - - days: `d`, `D`, `day`, `days` - - hours: `H`, `h`, `hour`, `hours` - - minutes: `M`, `minute`, `minutes` - - seconds: `S`, `s`, `second`, `seconds` - - For example, to set a reminder that expires in 3 days and 1 minute, you can do `!remind new 3d1M Do something`. - """ - await self.new_reminder(ctx, mentions=mentions, expiration=expiration, content=content) - - @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder( - self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None - ) -> None: - """ - Set yourself a simple reminder. - - The `expiration` duration supports the following symbols for each unit of time: - - years: `Y`, `y`, `year`, `years` - - months: `m`, `month`, `months` - - weeks: `w`, `W`, `week`, `weeks` - - days: `d`, `D`, `day`, `days` - - hours: `H`, `h`, `hour`, `hours` - - minutes: `M`, `minute`, `minutes` - - seconds: `S`, `s`, `second`, `seconds` - - For example, to set a reminder that expires in 3 days and 1 minute, you can do `!remind new 3d1M Do something`. - """ - # If the user is not staff, partner or part of the python community, - # we need to verify whether or not to make a reminder at all. - if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES): - - # If they don't have permission to set a reminder in this channel - if ctx.channel.id not in WHITELISTED_CHANNELS: - await send_denial(ctx, "Sorry, you can't do that here!") - return - - # Get their current active reminders - active_reminders = await self.bot.api_client.get( - 'bot/reminders', - params={ - 'author__id': str(ctx.author.id) - } - ) - - # Let's limit this, so we don't get 10 000 - # reminders from kip or something like that :P - if len(active_reminders) > MAXIMUM_REMINDERS: - await send_denial(ctx, "You have too many active reminders!") - return - - # Remove duplicate mentions - mentions = set(mentions) - mentions.discard(ctx.author) - - # Filter mentions to see if the user can mention members/roles - if not await self.validate_mentions(ctx, mentions): - return - - mention_ids = [mention.id for mention in mentions] - - # If `content` isn't provided then we try to get message content of a replied message - if not content: - if reference := ctx.message.reference: - if isinstance((resolved_message := reference.resolved), discord.Message): - content = resolved_message.content - # If we weren't able to get the content of a replied message - if content is None: - await send_denial(ctx, "Your reminder must have a content and/or reply to a message.") - return - - # If the replied message has no content (e.g. only attachments/embeds) - if content == "": - content = "See referenced message." - - # Now we can attempt to actually set the reminder. - reminder = await self.bot.api_client.post( - 'bot/reminders', - json={ - 'author': ctx.author.id, - 'channel_id': ctx.message.channel.id, - 'jump_url': ctx.message.jump_url, - 'content': content, - 'expiration': expiration.isoformat(), - 'mentions': mention_ids, - } - ) - - formatted_time = time.discord_timestamp(expiration, time.TimestampFormats.DAY_TIME) - mention_string = f"Your reminder will arrive on {formatted_time}" - - if mentions: - mention_string += f" and will mention {len(mentions)} other(s)" - mention_string += "!" - - # Confirm to the user that it worked. - await self._send_confirmation( - ctx, - on_success=mention_string, - reminder_id=reminder["id"] - ) - - self.schedule_reminder(reminder) - - @remind_group.command(name="list") - async def list_reminders(self, ctx: Context) -> None: - """View a paginated embed of all reminders for your user.""" - # Get all the user's reminders from the database. - data = await self.bot.api_client.get( - 'bot/reminders', - params={'author__id': str(ctx.author.id)} - ) - - # Make a list of tuples so it can be sorted by time. - reminders = sorted( - ( - (rem['content'], rem['expiration'], rem['id'], rem['mentions']) - for rem in data - ), - key=itemgetter(1) - ) - - lines = [] - - for content, remind_at, id_, mentions in reminders: - # Parse and humanize the time, make it pretty :D - expiry = time.format_relative(remind_at) - - mentions = ", ".join([ - # Both Role and User objects have the `name` attribute - mention.name async for mention in self.get_mentionables(mentions) - ]) - mention_string = f"\n**Mentions:** {mentions}" if mentions else "" - - text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires {expiry}* (ID: {id_}){mention_string} - {content} - """).strip() - - lines.append(text) - - embed = discord.Embed() - embed.colour = discord.Colour.og_blurple() - embed.title = f"Reminders for {ctx.author}" - - # Remind the user that they have no reminders :^) - if not lines: - embed.description = "No active reminders could be found." - await ctx.send(embed=embed) - return - - # Construct the embed and paginate it. - embed.colour = discord.Colour.og_blurple() - - await LinePaginator.paginate( - lines, - ctx, embed, - max_lines=3, - empty=True - ) - - @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) - async def edit_reminder_group(self, ctx: Context) -> None: - """ - Commands for modifying your current reminders. - - The `expiration` duration supports the following symbols for each unit of time: - - years: `Y`, `y`, `year`, `years` - - months: `m`, `month`, `months` - - weeks: `w`, `W`, `week`, `weeks` - - days: `d`, `D`, `day`, `days` - - hours: `H`, `h`, `hour`, `hours` - - minutes: `M`, `minute`, `minutes` - - seconds: `S`, `s`, `second`, `seconds` - - For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`. - """ - await ctx.send_help(ctx.command) - - @edit_reminder_group.command(name="duration", aliases=("time",)) - async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: - """ - Edit one of your reminder's expiration. - - The `expiration` duration supports the following symbols for each unit of time: - - years: `Y`, `y`, `year`, `years` - - months: `m`, `month`, `months` - - weeks: `w`, `W`, `week`, `weeks` - - days: `d`, `D`, `day`, `days` - - hours: `H`, `h`, `hour`, `hours` - - minutes: `M`, `minute`, `minutes` - - seconds: `S`, `s`, `second`, `seconds` - - For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`. - """ - await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) - - @edit_reminder_group.command(name="content", aliases=("reason",)) - async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: - """Edit one of your reminder's content.""" - await self.edit_reminder(ctx, id_, {"content": content}) - - @edit_reminder_group.command(name="mentions", aliases=("pings",)) - async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[ReminderMention]) -> None: - """Edit one of your reminder's mentions.""" - # Remove duplicate mentions - mentions = set(mentions) - mentions.discard(ctx.author) - - # Filter mentions to see if the user can mention members/roles - if not await self.validate_mentions(ctx, mentions): - return - - mention_ids = [mention.id for mention in mentions] - await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - - @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) - async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: - """Edits a reminder with the given payload, then sends a confirmation message.""" - if not await self._can_modify(ctx, id_): - return - reminder = await self._edit_reminder(id_, payload) - - # Send a confirmation message to the channel - await self._send_confirmation( - ctx, - on_success="That reminder has been edited successfully!", - reminder_id=id_, - ) - await self._reschedule_reminder(reminder) - - @remind_group.command("delete", aliases=("remove", "cancel")) - @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) - async def delete_reminder(self, ctx: Context, id_: int) -> None: - """Delete one of your active reminders.""" - if not await self._can_modify(ctx, id_): - return - - await self.bot.api_client.delete(f"bot/reminders/{id_}") - self.scheduler.cancel(id_) - - await self._send_confirmation( - ctx, - on_success="That reminder has been deleted successfully!", - reminder_id=id_ - ) - - async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool: - """ - Check whether the reminder can be modified by the ctx author. - - The check passes when the user is an admin, or if they created the reminder. - """ - if await has_any_role_check(ctx, Roles.admins): - return True - - api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") - if not api_response["author"] == ctx.author.id: - log.debug(f"{ctx.author} is not the reminder author and does not pass the check.") - await send_denial(ctx, "You can't modify reminders of other users!") - return False - - log.debug(f"{ctx.author} is the reminder author and passes the check.") - return True - - -def setup(bot: Bot) -> None: - """Load the Reminders cog.""" - bot.add_cog(Reminders(bot)) -- cgit v1.2.3 From 1296914615c72d597be1b0c4d00bfa84011c960c Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Sun, 13 Feb 2022 15:07:11 +0000 Subject: Add unsubscribe alias to subscribe command In quite a few places, such as #roles, we tell users to run the unsubscribe command to remove roles from them. However, this command no longer exists due to the rework of the subscribe command. Since the subscribe commands allows tyou to remove as well as add roles, I have added this as an alias. --- bot/exts/info/subscribe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 1299d5d59..eff0c13b8 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -171,7 +171,7 @@ class Subscribe(commands.Cog): self.assignable_roles.sort(key=operator.methodcaller("is_currently_available"), reverse=True) @commands.cooldown(1, 10, commands.BucketType.member) - @commands.command(name="subscribe") + @commands.command(name="subscribe", aliases=("unsubscribe",)) @redirect_output( destination_channel=constants.Channels.bot_commands, bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES, -- cgit v1.2.3 From 036fa718a64009ee37c6c8d0693ab9490ded3475 Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Sun, 13 Feb 2022 14:53:30 -0500 Subject: Traceback tag: Emphasize reason for sharing traceback (#2072) --- bot/resources/tags/traceback.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index 321737aac..a4fa8aba9 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -1,18 +1,16 @@ Please provide the full traceback for your exception in order to help us identify your issue. +While the last line of the error message tells us what kind of error you got, +the full traceback will tell us which line, and other critical information to solve your problem. +Please avoid screenshots so we can copy and paste parts of the message. A full traceback could look like: ```py Traceback (most recent call last): - File "tiny", line 3, in - do_something() - File "tiny", line 2, in do_something - a = 6 / b -ZeroDivisionError: division by zero + File "my_file.py", line 5, in + add_three("6") + File "my_file.py", line 2, in add_three + a = num + 3 +TypeError: can only concatenate str (not "int") to str ``` -The best way to read your traceback is bottom to top. -• Identify the exception raised (in this case `ZeroDivisionError`) -• Make note of the line number (in this case `2`), and navigate there in your program. -• Try to understand why the error occurred (in this case because `b` is `0`). - -To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/guides/pydis-guides/asking-good-questions/#examining-tracebacks) or the [official Python tutorial](https://docs.python.org/3.7/tutorial/errors.html). +If the traceback is long, use [our pastebin](https://paste.pythondiscord.com/). -- cgit v1.2.3 From 6cdbd56005f3795bcc9bc993c96f640b4e678a1d Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Sun, 13 Feb 2022 12:42:07 -0800 Subject: Removed extra newline in the traceback tag. (#2083) --- bot/resources/tags/traceback.md | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index a4fa8aba9..e21fa6c6e 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -12,5 +12,4 @@ Traceback (most recent call last): a = num + 3 TypeError: can only concatenate str (not "int") to str ``` - If the traceback is long, use [our pastebin](https://paste.pythondiscord.com/). -- cgit v1.2.3 From d10b7d2c56d3530b69bf96c02e55ed0248a3f026 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Mon, 14 Feb 2022 16:17:03 +0000 Subject: Fix ignoring of raw DM edits (#2085) --- bot/exts/moderation/modlog.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 2c01a4a21..54a08738c 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -729,6 +729,9 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> None: """Log raw message edit event to message change log.""" + if event.guild_id is None: + return # ignore DM edits + await self.bot.wait_until_guild_available() try: channel = self.bot.get_channel(int(event.data["channel_id"])) -- cgit v1.2.3 From da58a4a3338f78c9263947a3d4433c9c56d37a02 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 16 Feb 2022 22:06:54 +0000 Subject: Fix: `!raw` can now be used in threads (#2090) --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 5b25fd0c3..e616b9208 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -470,7 +470,7 @@ class Information(Cog): If `json` is True, send the information in a copy-pasteable Python format. """ - if ctx.author not in message.channel.members: + if not message.channel.permissions_for(ctx.author).read_messages: await ctx.send(":x: You do not have permissions to see the channel this message is in.") return -- cgit v1.2.3 From 465c2f5f73e743d11892ebd8dd4421d35e599dd4 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 31 Dec 2021 18:12:05 +0000 Subject: Reply with log url after cleaning messages If done outside a mod channel, it instead tags the invoker in #mods. --- bot/constants.py | 1 + bot/exts/moderation/clean.py | 36 ++++++++++++++++++++++++------------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 1b713a7e3..77c01bfa3 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -445,6 +445,7 @@ class Channels(metaclass=YAMLGetter): incidents_archive: int mod_alerts: int mod_meta: int + mods: int nominations: int nomination_voting: int organisation: int diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index e61ef7880..f8ba230b3 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -331,12 +331,17 @@ class Clean(Cog): return deleted - async def _modlog_cleaned_messages(self, messages: list[Message], channels: CleanChannels, ctx: Context) -> bool: - """Log the deleted messages to the modlog. Return True if logging was successful.""" + async def _modlog_cleaned_messages( + self, + messages: list[Message], + channels: CleanChannels, + ctx: Context + ) -> Optional[str]: + """Log the deleted messages to the modlog, returning the log url if logging was successful.""" if not messages: # Can't build an embed, nothing to clean! await self._send_expiring_message(ctx, ":x: No matching messages could be found.") - return False + return None # Reverse the list to have reverse chronological order log_messages = reversed(messages) @@ -362,7 +367,7 @@ class Clean(Cog): channel_id=Channels.mod_log, ) - return True + return log_url # endregion @@ -375,8 +380,8 @@ class Clean(Cog): regex: Optional[re.Pattern] = None, first_limit: Optional[CleanLimit] = None, second_limit: Optional[CleanLimit] = None, - ) -> None: - """A helper function that does the actual message cleaning.""" + ) -> Optional[str]: + """A helper function that does the actual message cleaning, returns the log url if logging was successful.""" self._validate_input(channels, bots_only, users, first_limit, second_limit) # Are we already performing a clean? @@ -384,7 +389,7 @@ class Clean(Cog): await self._send_expiring_message( ctx, ":x: Please wait for the currently ongoing clean operation to complete." ) - return + return None self.cleaning = True deletion_channels = self._channels_set(channels, ctx, first_limit, second_limit) @@ -418,7 +423,7 @@ class Clean(Cog): if not self.cleaning: # Means that the cleaning was canceled - return + return None # Now let's delete the actual messages with purge. self.mod_log.ignore(Event.message_delete, *message_ids) @@ -427,11 +432,18 @@ class Clean(Cog): if not channels: channels = deletion_channels - logged = await self._modlog_cleaned_messages(deleted_messages, channels, ctx) + log_url = await self._modlog_cleaned_messages(deleted_messages, channels, ctx) - if logged and is_mod_channel(ctx.channel): - with suppress(NotFound): # Can happen if the invoker deleted their own messages. - await ctx.message.add_reaction(Emojis.check_mark) + success_message = ( + f"{Emojis.ok_hand} Deleted {len(deleted_messages)} messages. " + f"A log of the deleted messages can be found here {log_url}." + ) + if log_url and is_mod_channel(ctx.channel): + await ctx.reply(success_message) + elif log_url: + if mods := self.bot.get_channel(Channels.mods): + await mods.send(f"{ctx.author.mention} {success_message}") + return log_url # region: Commands -- cgit v1.2.3 From caaf0fa6d73ff6c8cfe54c7f8b952caf0aec97e2 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 31 Dec 2021 18:13:09 +0000 Subject: Support not deleting invoking message of a clean task --- bot/exts/moderation/clean.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index f8ba230b3..cb6836258 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -380,6 +380,7 @@ class Clean(Cog): regex: Optional[re.Pattern] = None, first_limit: Optional[CleanLimit] = None, second_limit: Optional[CleanLimit] = None, + attempt_delete_invocation: bool = True, ) -> Optional[str]: """A helper function that does the actual message cleaning, returns the log url if logging was successful.""" self._validate_input(channels, bots_only, users, first_limit, second_limit) @@ -404,8 +405,9 @@ class Clean(Cog): # Needs to be called after standardizing the input. predicate = self._build_predicate(first_limit, second_limit, bots_only, users, regex) - # Delete the invocation first - await self._delete_invocation(ctx) + if attempt_delete_invocation: + # Delete the invocation first + await self._delete_invocation(ctx) if self._use_cache(first_limit): log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in the cache.") -- cgit v1.2.3 From 3a7871b839a1ff13fc95be562eb275676ed41b7f Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 31 Dec 2021 18:14:44 +0000 Subject: Update respect_role_hierarchy decorator to pass through return values --- bot/decorators.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 048a2a09a..f4331264f 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -188,7 +188,7 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable: """ def decorator(func: types.FunctionType) -> types.FunctionType: @command_wraps(func) - async def wrapper(*args, **kwargs) -> None: + async def wrapper(*args, **kwargs) -> t.Any: log.trace(f"{func.__name__}: respect role hierarchy decorator called") bound_args = function.get_bound_args(func, args, kwargs) @@ -196,8 +196,7 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable: if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") - await func(*args, **kwargs) - return + return await func(*args, **kwargs) ctx = function.get_arg_value(1, bound_args) cmd = ctx.command.name @@ -214,7 +213,7 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable: ) else: log.trace(f"{func.__name__}: {target.top_role=} < {actor.top_role=}; calling func") - await func(*args, **kwargs) + return await func(*args, **kwargs) return wrapper return decorator -- cgit v1.2.3 From 15fb882b49a2fb66b55b31aeb377ac03421de73d Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 31 Dec 2021 18:19:08 +0000 Subject: Change purgeban to use custom clean logic This migrates the purgeban command away from Discord's native purgeban to our custom logic. Discord's native purgeban does not leave us with any evidence or context of what messages were deleted. So when mods reference the infraction at a later date they are lacking information. Instead, we use our custom clean cog to delete all messages from the user in question for the last hour, and automatically append the link to the clean log to the infraction reason. . --- bot/exts/moderation/infraction/infractions.py | 70 ++++++++++++++++++++------- 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 7c0259b8e..20fcf28f9 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/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 Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser +from bot.converters import Age, Duration, Expiry, Infraction, MemberOrUser, UnambiguousMemberOrUser from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -19,6 +19,11 @@ from bot.utils.messages import format_user log = get_logger(__name__) +if t.TYPE_CHECKING: + from bot.exts.moderation.clean import Clean + from bot.exts.moderation.infraction.management import ModManagement + from bot.exts.moderation.watchchannels.bigbrother import BigBrother + class Infractions(InfractionScheduler, commands.Cog): """Apply and pardon infractions on users for moderation purposes.""" @@ -101,11 +106,44 @@ class Infractions(InfractionScheduler, commands.Cog): reason: t.Optional[str] = None ) -> None: """ - Same as ban but removes all their messages of the last 24 hours. + Same as ban, but also cleans all their messages from the last hour. If duration is specified, it temporarily bans that user for the given duration. """ - await self.apply_ban(ctx, user, reason, 1, expires_at=duration) + clean_cog: t.Optional[Clean] = self.bot.get_cog("Clean") + if clean_cog is None: + # If we can't get the clean cog, fall back to native purgeban. + await self.apply_ban(ctx, user, reason, 1, expires_at=duration) + return + + infraction = await self.apply_ban(ctx, user, reason, expires_at=duration) + if not infraction or not infraction.get("id"): + # Ban was unsuccessful, quit early. + return + + # Calling commands directly skips Discord.py's convertors, so we need to convert args manually. + clean_time = await Age().convert(ctx, "1h") + infraction = await Infraction().convert(ctx, infraction["id"]) + + log_url = await clean_cog._clean_messages( + ctx, + users=[user], + channels="*", + first_limit=clean_time, + attempt_delete_invocation=False, + ) + + infr_manage_cog: t.Optional[ModManagement] = self.bot.get_cog("ModManagement") + if infr_manage_cog is None: + # If we can't get the mod management cog, don't bother appending the log. + return + + # Overwrite the context's send function so infraction append + # doesn't output the update infraction confirmation message. + async def send(*args, **kwargs) -> None: + pass + ctx.send = send + await infr_manage_cog.infraction_append(ctx, infraction, None, reason=f"[Clean log]({log_url})") @command(aliases=("vban",)) async def voiceban(self, ctx: Context) -> None: @@ -368,7 +406,7 @@ class Infractions(InfractionScheduler, commands.Cog): reason: t.Optional[str], purge_days: t.Optional[int] = 0, **kwargs - ) -> None: + ) -> t.Optional[dict]: """ Apply a ban infraction with kwargs passed to `post_infraction`. @@ -376,7 +414,7 @@ class Infractions(InfractionScheduler, commands.Cog): """ if isinstance(user, Member) and user.top_role >= ctx.me.top_role: await ctx.send(":x: I can't ban users above or equal to me in the role hierarchy.") - return + return None # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active is_temporary = kwargs.get("expires_at") is not None @@ -385,19 +423,19 @@ class Infractions(InfractionScheduler, commands.Cog): if active_infraction: if is_temporary: log.trace("Tempban ignored as it cannot overwrite an active ban.") - return + return None if active_infraction.get('expires_at') is None: log.trace("Permaban already exists, notify.") await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).") - return + return None log.trace("Old tempban is being replaced by new permaban.") await self.pardon_infraction(ctx, "ban", user, send_msg=is_temporary) infraction = await _utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) if infraction is None: - return + return None infraction["purge"] = "purge " if purge_days else "" @@ -409,19 +447,17 @@ class Infractions(InfractionScheduler, commands.Cog): action = ctx.guild.ban(user, reason=reason, delete_message_days=purge_days) await self.apply_infraction(ctx, infraction, user, action) + bb_cog: t.Optional[BigBrother] = self.bot.get_cog("Big Brother") if infraction.get('expires_at') is not None: log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.") - return - - bb_cog = self.bot.get_cog("Big Brother") - if not bb_cog: + elif not bb_cog: log.error(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") - return - - log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") + else: + log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") + bb_reason = "User has been permanently banned from the server. Automatically removed." + await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) - bb_reason = "User has been permanently banned from the server. Automatically removed." - await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) + return infraction @respect_role_hierarchy(member_arg=2) async def apply_voice_mute(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None: -- cgit v1.2.3 From 954c1a963dc3917e02e0fb0ff4560131db8f20b4 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 31 Dec 2021 18:56:28 +0000 Subject: Add more aliases to purgeban --- bot/exts/moderation/infraction/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 20fcf28f9..e2c4c9ee4 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -96,8 +96,8 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, expires_at=duration) - @command(aliases=('pban',)) - async def purgeban( + @command(aliases=("cban", "purgeban", "pban")) + async def cleanban( self, ctx: Context, user: UnambiguousMemberOrUser, -- cgit v1.2.3 From fdcdaac97f055285cee82354a9a1352dca002194 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 31 Dec 2021 19:04:00 +0000 Subject: Don't append clean log if no clean was done from purge ban --- bot/exts/moderation/infraction/infractions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index e2c4c9ee4..32ff376cf 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -132,6 +132,9 @@ class Infractions(InfractionScheduler, commands.Cog): first_limit=clean_time, attempt_delete_invocation=False, ) + if not log_url: + # Cleaning failed, or there were no messages to clean, exit early. + return infr_manage_cog: t.Optional[ModManagement] = self.bot.get_cog("ModManagement") if infr_manage_cog is None: -- cgit v1.2.3 From 993529aa945a1f9ec8d769c770399dbe2cd8bd25 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 2 Jan 2022 20:13:53 +0000 Subject: Add tests for new CleanBan and Clean functionality --- .../exts/moderation/infraction/test_infractions.py | 90 +++++++++++++++++- tests/bot/exts/moderation/test_clean.py | 104 +++++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 tests/bot/exts/moderation/test_clean.py diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index f89465f84..57235ec6d 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,13 +1,15 @@ import inspect import textwrap import unittest -from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, DEFAULT, MagicMock, Mock, patch from discord.errors import NotFound from bot.constants import Event +from bot.exts.moderation.clean import Clean from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction.infractions import Infractions +from bot.exts.moderation.infraction.management import ModManagement from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockUser, autospec @@ -231,3 +233,89 @@ class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): "DM": "**Failed**" }) notify_pardon_mock.assert_awaited_once() + + +class CleanBanTests(unittest.IsolatedAsyncioTestCase): + """Tests for cleanban functionality.""" + + def setUp(self): + self.bot = MockBot() + self.mod = MockMember(roles=[MockRole(id=7890123, position=10)]) + self.user = MockMember(roles=[MockRole(id=123456, position=1)]) + self.guild = MockGuild() + self.ctx = MockContext(bot=self.bot, author=self.mod) + self.cog = Infractions(self.bot) + self.clean_cog = Clean(self.bot) + self.management_cog = ModManagement(self.bot) + + self.cog.apply_ban = AsyncMock(return_value={"id": 42}) + self.log_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + self.clean_cog._clean_messages = AsyncMock(return_value=self.log_url) + + def mock_get_cog(self, enable_clean, enable_manage): + def inner(name): + if name == "ModManagement": + return self.management_cog if enable_manage else None + elif name == "Clean": + return self.clean_cog if enable_clean else None + else: + return DEFAULT + return inner + + async def test_cleanban_falls_back_to_native_purge_without_clean_cog(self): + """Should fallback to native purge if the Clean cog is not available.""" + self.bot.get_cog.side_effect = self.mock_get_cog(False, False) + + self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) + self.cog.apply_ban.assert_awaited_once_with( + self.ctx, + self.user, + "FooBar", + 1, + expires_at=None, + ) + + async def test_cleanban_doesnt_purge_messages_if_clean_cog_available(self): + """Cleanban command should use the native purge messages if the clean cog is available.""" + self.bot.get_cog.side_effect = self.mock_get_cog(True, False) + + self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) + self.cog.apply_ban.assert_awaited_once_with( + self.ctx, + self.user, + "FooBar", + expires_at=None, + ) + + @patch("bot.exts.moderation.infraction.infractions.Age") + async def test_cleanban_uses_clean_cog_when_available(self, mocked_age_converter): + """Test cleanban uses the clean cog to clean messages if it's available.""" + self.bot.api_client.patch = AsyncMock() + self.bot.get_cog.side_effect = self.mock_get_cog(True, False) + + mocked_age_converter.return_value.convert = AsyncMock(return_value="81M") + self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) + + self.clean_cog._clean_messages.assert_awaited_once_with( + self.ctx, + users=[self.user], + channels="*", + first_limit="81M", + attempt_delete_invocation=False, + ) + + @patch("bot.exts.moderation.infraction.infractions.Infraction") + async def test_cleanban_edits_infraction_reason(self, mocked_infraction_converter): + """Ensure cleanban edits the ban reason with a link to the clean log.""" + self.bot.get_cog.side_effect = self.mock_get_cog(True, True) + + self.management_cog.infraction_append = AsyncMock() + mocked_infraction_converter.return_value.convert = AsyncMock(return_value=42) + self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) + + self.management_cog.infraction_append.assert_awaited_once_with( + self.ctx, + 42, + None, + reason=f"[Clean log]({self.log_url})" + ) diff --git a/tests/bot/exts/moderation/test_clean.py b/tests/bot/exts/moderation/test_clean.py new file mode 100644 index 000000000..83489ea00 --- /dev/null +++ b/tests/bot/exts/moderation/test_clean.py @@ -0,0 +1,104 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from bot.exts.moderation.clean import Clean +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockMessage, MockRole, MockTextChannel + + +class CleanTests(unittest.IsolatedAsyncioTestCase): + """Tests for clean cog functionality.""" + + def setUp(self): + self.bot = MockBot() + self.mod = MockMember(roles=[MockRole(id=7890123, position=10)]) + self.user = MockMember(roles=[MockRole(id=123456, position=1)]) + self.guild = MockGuild() + self.ctx = MockContext(bot=self.bot, author=self.mod) + self.cog = Clean(self.bot) + + self.log_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + self.cog._modlog_cleaned_messages = AsyncMock(return_value=self.log_url) + + self.cog._use_cache = MagicMock(return_value=True) + self.cog._delete_found = AsyncMock(return_value=[42, 84]) + + @patch("bot.exts.moderation.clean.is_mod_channel") + async def test_clean_deletes_invocation_in_non_mod_channel(self, mod_channel_check): + """Clean command should delete the invocation message if ran in a non mod channel.""" + mod_channel_check.return_value = False + self.ctx.message.delete = AsyncMock() + + self.assertIsNone(await self.cog._delete_invocation(self.ctx)) + + self.ctx.message.delete.assert_awaited_once() + + @patch("bot.exts.moderation.clean.is_mod_channel") + async def test_clean_doesnt_delete_invocation_in_mod_channel(self, mod_channel_check): + """Clean command should not delete the invocation message if ran in a mod channel.""" + mod_channel_check.return_value = True + self.ctx.message.delete = AsyncMock() + + self.assertIsNone(await self.cog._delete_invocation(self.ctx)) + + self.ctx.message.delete.assert_not_awaited() + + async def test_clean_doesnt_attempt_deletion_when_attempt_delete_invocation_is_false(self): + """Clean command should not attempt to delete the invocation message if attempt_delete_invocation is false.""" + self.cog._delete_invocation = AsyncMock() + self.bot.get_channel = MagicMock(return_value=False) + + self.assertEqual( + await self.cog._clean_messages( + self.ctx, + None, + first_limit=MockMessage(), + attempt_delete_invocation=False, + ), + self.log_url, + ) + + self.cog._delete_invocation.assert_not_awaited() + + @patch("bot.exts.moderation.clean.is_mod_channel") + async def test_clean_replies_with_success_message_when_ran_in_mod_channel(self, mod_channel_check): + """Clean command should reply to the message with a confirmation message if invoked in a mod channel.""" + mod_channel_check.return_value = True + self.ctx.reply = AsyncMock() + + self.assertEqual( + await self.cog._clean_messages( + self.ctx, + None, + first_limit=MockMessage(), + attempt_delete_invocation=False, + ), + self.log_url, + ) + + self.ctx.reply.assert_awaited_once() + sent_message = self.ctx.reply.await_args[0][0] + self.assertIn(self.log_url, sent_message) + self.assertIn("2 messages", sent_message) + + @patch("bot.exts.moderation.clean.is_mod_channel") + async def test_clean_send_success_message__to_mods_when_ran_in_non_mod_channel(self, mod_channel_check): + """Clean command should send a confirmation message to #mods if invoked in a non-mod channel.""" + mod_channel_check.return_value = False + mocked_mods = MockTextChannel(id=1234567) + mocked_mods.send = AsyncMock() + self.bot.get_channel = MagicMock(return_value=mocked_mods) + + self.assertEqual( + await self.cog._clean_messages( + self.ctx, + None, + first_limit=MockMessage(), + attempt_delete_invocation=False, + ), + self.log_url, + ) + + mocked_mods.send.assert_awaited_once() + sent_message = mocked_mods.send.await_args[0][0] + self.assertIn(self.log_url, sent_message) + self.assertIn("2 messages", sent_message) -- cgit v1.2.3 From 6c139905cca53f7810a100435955ec0c5fbc30e1 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 14 Feb 2022 01:51:23 +0000 Subject: Send error when cleanban fails to ban Co-authored-by: GDWR --- bot/exts/moderation/infraction/infractions.py | 4 +++- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 32ff376cf..09ee1a7b4 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -113,12 +113,14 @@ class Infractions(InfractionScheduler, commands.Cog): clean_cog: t.Optional[Clean] = self.bot.get_cog("Clean") if clean_cog is None: # If we can't get the clean cog, fall back to native purgeban. - await self.apply_ban(ctx, user, reason, 1, expires_at=duration) + await self.apply_ban(ctx, user, reason, purge_days=1, expires_at=duration) return infraction = await self.apply_ban(ctx, user, reason, expires_at=duration) if not infraction or not infraction.get("id"): # Ban was unsuccessful, quit early. + await ctx.send(":x: Failed to apply ban.") + log.error("Failed to apply ban to user %d", user.id) return # Calling commands directly skips Discord.py's convertors, so we need to convert args manually. diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 57235ec6d..8845fb382 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -271,7 +271,7 @@ class CleanBanTests(unittest.IsolatedAsyncioTestCase): self.ctx, self.user, "FooBar", - 1, + purge_days=1, expires_at=None, ) -- cgit v1.2.3 From 762b107056145d44b5219a929302455c9e6ed1d0 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 14 Feb 2022 01:53:33 +0000 Subject: Typo and docstrings in clean ban tests Co-authored-by: GDWR --- tests/bot/exts/moderation/infraction/test_infractions.py | 1 + tests/bot/exts/moderation/test_clean.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 8845fb382..8bed1e386 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -253,6 +253,7 @@ class CleanBanTests(unittest.IsolatedAsyncioTestCase): self.clean_cog._clean_messages = AsyncMock(return_value=self.log_url) def mock_get_cog(self, enable_clean, enable_manage): + """Mock get cog factory that allows the user to specify whether clean and manage cogs are enabled.""" def inner(name): if name == "ModManagement": return self.management_cog if enable_manage else None diff --git a/tests/bot/exts/moderation/test_clean.py b/tests/bot/exts/moderation/test_clean.py index 83489ea00..d7647fa48 100644 --- a/tests/bot/exts/moderation/test_clean.py +++ b/tests/bot/exts/moderation/test_clean.py @@ -81,7 +81,7 @@ class CleanTests(unittest.IsolatedAsyncioTestCase): self.assertIn("2 messages", sent_message) @patch("bot.exts.moderation.clean.is_mod_channel") - async def test_clean_send_success_message__to_mods_when_ran_in_non_mod_channel(self, mod_channel_check): + async def test_clean_send_success_message_to_mods_when_ran_in_non_mod_channel(self, mod_channel_check): """Clean command should send a confirmation message to #mods if invoked in a non-mod channel.""" mod_channel_check.return_value = False mocked_mods = MockTextChannel(id=1234567) -- cgit v1.2.3 From 7e8e95f07e343f1d7d9a8069b6cdb1a9fcbb00d7 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Wed, 16 Feb 2022 22:48:09 +0000 Subject: Remove unnecessary Infraction conversion in clean ban (#2092) --- bot/exts/moderation/infraction/infractions.py | 3 +-- tests/bot/exts/moderation/infraction/test_infractions.py | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 09ee1a7b4..af42ab1b8 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/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 Age, Duration, Expiry, Infraction, MemberOrUser, UnambiguousMemberOrUser +from bot.converters import Age, Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -125,7 +125,6 @@ class Infractions(InfractionScheduler, commands.Cog): # Calling commands directly skips Discord.py's convertors, so we need to convert args manually. clean_time = await Age().convert(ctx, "1h") - infraction = await Infraction().convert(ctx, infraction["id"]) log_url = await clean_cog._clean_messages( ctx, diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 8bed1e386..052048053 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -305,18 +305,16 @@ class CleanBanTests(unittest.IsolatedAsyncioTestCase): attempt_delete_invocation=False, ) - @patch("bot.exts.moderation.infraction.infractions.Infraction") - async def test_cleanban_edits_infraction_reason(self, mocked_infraction_converter): + async def test_cleanban_edits_infraction_reason(self): """Ensure cleanban edits the ban reason with a link to the clean log.""" self.bot.get_cog.side_effect = self.mock_get_cog(True, True) self.management_cog.infraction_append = AsyncMock() - mocked_infraction_converter.return_value.convert = AsyncMock(return_value=42) self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) self.management_cog.infraction_append.assert_awaited_once_with( self.ctx, - 42, + {"id": 42}, None, reason=f"[Clean log]({self.log_url})" ) -- cgit v1.2.3 From abfb0ed9f0b3f1c676dc75707e51b33ded86cdf5 Mon Sep 17 00:00:00 2001 From: Izan Date: Thu, 17 Feb 2022 11:01:53 +0000 Subject: Validate regex when adding to the filter_token filter --- bot/exts/filters/filter_lists.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index ee5bd89f3..d3e6393d3 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -1,3 +1,4 @@ +import re from typing import Optional from discord import Colour, Embed @@ -72,6 +73,18 @@ class FilterLists(Cog): elif list_type == "FILE_FORMAT" and not content.startswith("."): content = f".{content}" + # If it's a filter token, validate the passed regex + elif list_type == "FILTER_TOKEN": + try: + _ = re.compile(content) + except re.error: + await ctx.message.add_reaction("❌") + await ctx.send( + f"{ctx.author.mention} that's not a valid regex! " + "You may have forgotten to escape part of the regex." + ) + return + # Try to add the item to the database log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") payload = { -- cgit v1.2.3 From b0e21f0a5f342cd8c9e82ddc357b51e19c3fc9ad Mon Sep 17 00:00:00 2001 From: Izan Date: Thu, 17 Feb 2022 11:47:29 +0000 Subject: Include regex error in failure message f --- bot/exts/filters/filter_lists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index d3e6393d3..9d3a52942 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -77,11 +77,11 @@ class FilterLists(Cog): elif list_type == "FILTER_TOKEN": try: _ = re.compile(content) - except re.error: + except re.error as e: await ctx.message.add_reaction("❌") await ctx.send( f"{ctx.author.mention} that's not a valid regex! " - "You may have forgotten to escape part of the regex." + f"Regex error message: {e.msg}." ) return -- cgit v1.2.3 From 97ddd3a709e1a6b7e821a438b61cd7b64a76ba62 Mon Sep 17 00:00:00 2001 From: minalike Date: Thu, 17 Feb 2022 21:40:16 -0500 Subject: Add user ID to message content for all mod alerts This is a temporary quality of life improvement until filters rewrite. Largely benefits mobile moderators who cannot copy from embeds. --- bot/exts/filters/filtering.py | 1 + bot/exts/moderation/modlog.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 1f83acf9b..9d491baa5 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -439,6 +439,7 @@ class Filtering(Cog): # Send pretty mod log embed to mod-alerts await self.mod_log.send_log_message( + content=str(msg.author.id), # quality-of-life improvement for mobile moderators to copy & paste icon_url=Icons.filtering, colour=Colour(Colours.soft_red), title=f"{_filter['type'].title()} triggered!", diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 54a08738c..32ea0dc6a 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -116,7 +116,7 @@ class ModLog(Cog, name="ModLog"): if ping_everyone: if content: - content = f"<@&{Roles.moderators}>\n{content}" + content = f"<@&{Roles.moderators}> {content}" else: content = f"<@&{Roles.moderators}>" -- cgit v1.2.3 From 7c8458c8a1aa6443c31bcd319c439b86c7e1c384 Mon Sep 17 00:00:00 2001 From: Izan Date: Fri, 18 Feb 2022 10:37:18 +0000 Subject: Remove auto joining of new threads. --- bot/exts/utils/bot.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index 788692777..8f0094bc9 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -1,7 +1,6 @@ -from contextlib import suppress from typing import Optional -from discord import Embed, Forbidden, TextChannel, Thread +from discord import Embed, TextChannel from discord.ext.commands import Cog, Context, command, group, has_any_role from bot.bot import Bot @@ -17,20 +16,6 @@ class BotCog(Cog, name="Bot"): def __init__(self, bot: Bot): self.bot = bot - @Cog.listener() - async def on_thread_join(self, thread: Thread) -> None: - """ - Try to join newly created threads. - - Despite the event name being misleading, this is dispatched when new threads are created. - """ - if thread.me: - # We have already joined this thread - return - - with suppress(Forbidden): - await thread.join() - @group(invoke_without_command=True, name="bot", hidden=True) async def botinfo_group(self, ctx: Context) -> None: """Bot informational commands.""" -- cgit v1.2.3 From f500249dfe8c8a0e1a957640c85c5e23d2af335e Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 18 Feb 2022 13:10:10 +0000 Subject: Remove unnecessary assignment Co-authored-by: ChrisJL --- bot/exts/filters/filter_lists.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index 9d3a52942..a883ddf54 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -76,7 +76,7 @@ class FilterLists(Cog): # If it's a filter token, validate the passed regex elif list_type == "FILTER_TOKEN": try: - _ = re.compile(content) + re.compile(content) except re.error as e: await ctx.message.add_reaction("❌") await ctx.send( -- cgit v1.2.3 From 8283043d1b60be3f7ad9094983c2bf1b959fb70b Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 13 Feb 2022 21:26:47 +0000 Subject: Add a cog to bump threads Quite often we want threads such as event discussions, or moderation discussions to live beyond their maximum of 1 week of auto-archival. This cog allows staff to add a thread to a list that will get 'bumped' back open by the bot when they are auto-archived --- bot/exts/utils/thread_bumper.py | 114 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 bot/exts/utils/thread_bumper.py diff --git a/bot/exts/utils/thread_bumper.py b/bot/exts/utils/thread_bumper.py new file mode 100644 index 000000000..a10d151aa --- /dev/null +++ b/bot/exts/utils/thread_bumper.py @@ -0,0 +1,114 @@ +import typing as t + +import discord +from async_rediscache import RedisCache +from discord.ext import commands + +from bot import constants +from bot.bot import Bot +from bot.log import get_logger +from bot.pagination import LinePaginator +from bot.utils import scheduling + +log = get_logger(__name__) + + +class ThreadBumper(commands.Cog): + """Cog that allow users to add the current thread to a list that get reopened on archive.""" + + # RedisCache[discord.Thread.id, "sentinel"] + threads_to_bump = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + self.init_task = scheduling.create_task(self.ensure_bumped_threads_are_active(), event_loop=self.bot.loop) + + async def ensure_bumped_threads_are_active(self) -> None: + """Ensure bumped threads are active, since threads could have been archived while the bot was down.""" + await self.bot.wait_until_guild_available() + + for thread_id, _ in await self.threads_to_bump.items(): + if thread := self.bot.get_channel(thread_id): + if not thread.archived: + continue + + try: + thread = await self.bot.fetch_channel(thread_id) + except discord.NotFound: + log.info(f"Thread {thread_id} has been deleted, removing from bumped threads.") + await self.threads_to_bump.delete(thread_id) + if thread.archived: + await thread.edit(archived=False) + + @commands.group(name="bump") + async def thread_bump_group(self, ctx: commands.Context) -> None: + """A group of commands to manage the bumping of threads.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @thread_bump_group.command(name="add") + async def add_thread_to_bump_list(self, ctx: commands.Context, thread: t.Optional[discord.Thread]) -> None: + """Add a thread to the bump list.""" + await self.init_task + + if not thread: + if isinstance(ctx.channel, discord.Thread): + thread = ctx.channel + else: + raise commands.BadArgument("You must provide a thread, or run this command within a thread.") + + await self.threads_to_bump.set(thread.id, "sentinel") + await ctx.send(f":ok_hand:{thread.mention} has been added to the bump list.") + + @thread_bump_group.command(name="remove", aliases=("r", "rem", "d", "del", "delete")) + async def remove_thread_from_bump_list(self, ctx: commands.Context, thread: t.Optional[discord.Thread]) -> None: + """Remove a thread from the bump list.""" + await self.init_task + + if not thread: + if isinstance(ctx.channel, discord.Thread): + thread = ctx.channel + else: + raise commands.BadArgument("You must provide a thread, or run this command within a thread.") + + await self.threads_to_bump.delete(thread.id) + await ctx.send(f":ok_hand: {thread.mention} has been removed from the bump list.") + + @thread_bump_group.command(name="list", aliases=("get",)) + async def list_all_threads_in_bump_list(self, ctx: commands.Context) -> None: + """List all the threads in the bump list.""" + await self.init_task + + lines = [f"<#{k}>" for k, _ in await self.threads_to_bump.items()] + embed = discord.Embed( + title="Threads in the bump list", + colour=constants.Colours.blue + ) + await LinePaginator.paginate(lines, ctx, embed) + + @commands.Cog.listener() + async def on_thread_update(self, _: discord.Thread, after: discord.Thread) -> None: + """ + Listen for thread updates and check if the thread has been archived. + + If the thread has been archived, and is in the bump list, un-archive it. + """ + await self.init_task + + if not after.archived: + return + + bumped_threads = [k for k, _ in await self.threads_to_bump.items()] + if after.id in bumped_threads: + await after.edit(archived=False) + + async def cog_check(self, ctx: commands.Context) -> bool: + """Only allow staff & partner roles to invoke the commands in this cog.""" + return await commands.has_any_role( + *constants.STAFF_PARTNERS_COMMUNITY_ROLES + ).predicate(ctx) + + +def setup(bot: Bot) -> None: + """Load the ThreadBumper cog.""" + bot.add_cog(ThreadBumper(bot)) -- cgit v1.2.3 From abdfd0db7caa2961428e6cf6b601df4aaccd9151 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 17 Feb 2022 01:23:56 +0000 Subject: Add logic so that manually archived threads bypass the thread bump list --- bot/exts/utils/thread_bumper.py | 46 +++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/bot/exts/utils/thread_bumper.py b/bot/exts/utils/thread_bumper.py index a10d151aa..8c6f3518e 100644 --- a/bot/exts/utils/thread_bumper.py +++ b/bot/exts/utils/thread_bumper.py @@ -8,7 +8,7 @@ from bot import constants from bot.bot import Bot from bot.log import get_logger from bot.pagination import LinePaginator -from bot.utils import scheduling +from bot.utils import channel, scheduling log = get_logger(__name__) @@ -23,22 +23,50 @@ class ThreadBumper(commands.Cog): self.bot = bot self.init_task = scheduling.create_task(self.ensure_bumped_threads_are_active(), event_loop=self.bot.loop) + async def unarchive_threads_not_manually_archived(self, threads: list[discord.Thread]) -> None: + """ + Iterate through and unarchive any threads that weren't manually archived recently. + + This is done by extracting the manually archived threads from the audit log. + + Only the last 200 thread_update logs are checked, + as this is assumed to be more than enough to cover bot downtime. + """ + guild = self.bot.get_guild(constants.Guild.id) + + recent_manually_archived_thread_ids = [] + async for thread_update in guild.audit_logs(limit=200, action=discord.AuditLogAction.thread_update): + if getattr(thread_update.after, "archived", False): + recent_manually_archived_thread_ids.append(thread_update.target.id) + + for thread in threads: + if thread.id in recent_manually_archived_thread_ids: + log.info( + "#%s (%d) was manually archived. Leaving archived, and removing from bumped threads.", + thread.name, + thread.id + ) + await self.threads_to_bump.delete(thread.id) + else: + await thread.edit(archived=False) + async def ensure_bumped_threads_are_active(self) -> None: """Ensure bumped threads are active, since threads could have been archived while the bot was down.""" await self.bot.wait_until_guild_available() + threads_to_maybe_bump = [] for thread_id, _ in await self.threads_to_bump.items(): - if thread := self.bot.get_channel(thread_id): - if not thread.archived: - continue - try: - thread = await self.bot.fetch_channel(thread_id) + thread = await channel.get_or_fetch_channel(thread_id) except discord.NotFound: - log.info(f"Thread {thread_id} has been deleted, removing from bumped threads.") + log.info("Thread %d has been deleted, removing from bumped threads.", thread_id) await self.threads_to_bump.delete(thread_id) + continue + if thread.archived: - await thread.edit(archived=False) + threads_to_maybe_bump.append(thread) + + await self.unarchive_threads_not_manually_archived(threads_to_maybe_bump) @commands.group(name="bump") async def thread_bump_group(self, ctx: commands.Context) -> None: @@ -100,7 +128,7 @@ class ThreadBumper(commands.Cog): bumped_threads = [k for k, _ in await self.threads_to_bump.items()] if after.id in bumped_threads: - await after.edit(archived=False) + await self.unarchive_threads_not_manually_archived([after]) async def cog_check(self, ctx: commands.Context) -> bool: """Only allow staff & partner roles to invoke the commands in this cog.""" -- cgit v1.2.3 From 3346d71416fdb3223d0c4998f92e420886445fac Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 18 Feb 2022 17:13:31 +0000 Subject: fixup: implemeent code review comments --- bot/exts/utils/thread_bumper.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bot/exts/utils/thread_bumper.py b/bot/exts/utils/thread_bumper.py index 8c6f3518e..35057f1fe 100644 --- a/bot/exts/utils/thread_bumper.py +++ b/bot/exts/utils/thread_bumper.py @@ -74,7 +74,7 @@ class ThreadBumper(commands.Cog): if not ctx.invoked_subcommand: await ctx.send_help(ctx.command) - @thread_bump_group.command(name="add") + @thread_bump_group.command(name="add", aliases=("a",)) async def add_thread_to_bump_list(self, ctx: commands.Context, thread: t.Optional[discord.Thread]) -> None: """Add a thread to the bump list.""" await self.init_task @@ -85,6 +85,9 @@ class ThreadBumper(commands.Cog): else: raise commands.BadArgument("You must provide a thread, or run this command within a thread.") + if await self.threads_to_bump.contains(thread.id): + raise commands.BadArgument("This thread is already in the bump list.") + await self.threads_to_bump.set(thread.id, "sentinel") await ctx.send(f":ok_hand:{thread.mention} has been added to the bump list.") @@ -99,6 +102,9 @@ class ThreadBumper(commands.Cog): else: raise commands.BadArgument("You must provide a thread, or run this command within a thread.") + if not await self.threads_to_bump.contains(thread.id): + raise commands.BadArgument("This thread is not in the bump list.") + await self.threads_to_bump.delete(thread.id) await ctx.send(f":ok_hand: {thread.mention} has been removed from the bump list.") @@ -126,8 +132,7 @@ class ThreadBumper(commands.Cog): if not after.archived: return - bumped_threads = [k for k, _ in await self.threads_to_bump.items()] - if after.id in bumped_threads: + if await self.threads_to_bump.contains(after.id): await self.unarchive_threads_not_manually_archived([after]) async def cog_check(self, ctx: commands.Context) -> bool: -- cgit v1.2.3 From 7eea60288866322836956a7b3689e49a3e9c5c41 Mon Sep 17 00:00:00 2001 From: mina Date: Fri, 18 Feb 2022 17:22:16 -0500 Subject: Add user ID in message content for mod-alerts, but not for autobans --- bot/exts/filters/antispam.py | 1 + bot/exts/filters/filtering.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index ddfd11231..bcd845a43 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -103,6 +103,7 @@ class DeletionContext: mod_alert_message += content await modlog.send_log_message( + content=", ".join(str(m.id) for m in self.members), # quality-of-life improvement for mobile moderators icon_url=Icons.filtering, colour=Colour(Colours.soft_red), title="Spam detected!", diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 9d491baa5..f44b28125 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -256,6 +256,7 @@ class Filtering(Cog): ) await self.mod_log.send_log_message( + content=str(member.id), # quality-of-life improvement for mobile moderators icon_url=Icons.token_removed, colour=Colours.soft_red, title="Username filtering alert", @@ -423,9 +424,12 @@ class Filtering(Cog): # Allow specific filters to override ping_everyone ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True) - # If we are going to autoban, we don't want to ping + content = str(msg.author.id) # quality-of-life improvement for mobile moderators + + # If we are going to autoban, we don't want to ping and don't need the user ID if reason and "[autoban]" in reason: ping_everyone = False + content = None eval_msg = "using !eval " if is_eval else "" footer = f"Reason: {reason}" if reason else None @@ -439,7 +443,7 @@ class Filtering(Cog): # Send pretty mod log embed to mod-alerts await self.mod_log.send_log_message( - content=str(msg.author.id), # quality-of-life improvement for mobile moderators to copy & paste + content=content, icon_url=Icons.filtering, colour=Colour(Colours.soft_red), title=f"{_filter['type'].title()} triggered!", -- cgit v1.2.3 From d258203483e1c7d4d6044dbcc3b1628c8bc3a319 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Sun, 20 Feb 2022 19:55:41 +0000 Subject: Remove discord formatted timestamp from log message (#2100) --- bot/exts/moderation/stream.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 4dccc8a7e..985cc6eb1 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -133,8 +133,12 @@ class Stream(commands.Cog): await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {time.discord_timestamp(duration)}.") # Convert here for nicer logging - revoke_time = time.format_with_duration(duration) - log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.") + humanized_duration = time.humanize_delta(duration, arrow.utcnow(), max_units=2) + end_time = duration.strftime("%Y-%m-%d %H:%M:%S") + log.debug( + f"Successfully gave {member} ({member.id}) permission " + f"to stream for {humanized_duration} (until {end_time})." + ) @commands.command(aliases=("pstream",)) @commands.has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From 33203cbfe95f28501af28a42e0c49f4d42b6f021 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 9 Feb 2022 10:38:13 +0000 Subject: Cancel help channel claim on 500 from Discord If we get a 500 error from Discord when trying to move the help channel to in use, attempt to let the user know, then cancel the claim. --- bot/exts/help_channels/_cog.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 541c791e5..6d061c8ca 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -111,14 +111,31 @@ class HelpChannels(commands.Cog): """ log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") + try: + await self.move_to_in_use(message.channel) + except discord.DiscordServerError: + try: + await message.channel.send( + "The bot encountered a Discord API error while trying to move this channel, please try again later." + ) + except Exception as e: + log.warning("Error occurred while sending fail claim message:", exc_info=e) + log.info( + "500 error from Discord when moving #%s (%d) to in-use for %s (%d). Cancelling claim.", + message.channel.name, + message.channel.id, + message.author.name, + message.author.id, + ) + self.bot.stats.incr("help.failed_claims.500_on_move") + return + embed = discord.Embed( description=f"Channel claimed by {message.author.mention}.", color=constants.Colours.bright_green, ) await message.channel.send(embed=embed) - await self.move_to_in_use(message.channel) - # Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839) if not isinstance(message.author, discord.Member): log.debug(f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM.") -- cgit v1.2.3 From 6505ab00f0695148e0e52ed1dc6815b37d4b6cae Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 9 Feb 2022 12:55:55 +0000 Subject: Ensure each in-use channel has a cached claimant on init This avoids issues when a user tries to close a channel, but the cache is empty, so the author check fails. --- bot/exts/help_channels/_channel.py | 34 ++++++++++++++++++++++++++++++++++ bot/exts/help_channels/_cog.py | 1 + 2 files changed, 35 insertions(+) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index e43c1e789..ff9e6a347 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -1,3 +1,4 @@ +import re import typing as t from datetime import timedelta from enum import Enum @@ -16,6 +17,7 @@ log = get_logger(__name__) MAX_CHANNELS_PER_CATEGORY = 50 EXCLUDED_CHANNELS = (constants.Channels.cooldown,) +CLAIMED_BY_RE = re.compile(r"Channel claimed by <@!?(?P\d{17,20})>\.$") class ClosingReason(Enum): @@ -157,3 +159,35 @@ async def move_to_bottom(channel: discord.TextChannel, category_id: int, **optio # Now that the channel is moved, we can edit the other attributes if options: await channel.edit(**options) + + +async def ensure_cached_claimant(channel: discord.TextChannel) -> None: + """ + Ensure there is a claimant cached for each help channel. + + Check the redis cache first, return early if there is already a claimant cached. + If there isn't an entry in redis, search for the "Claimed by X." embed in channel history. + Stopping early if we discover a dormant message first. + + If a claimant could not be found, send a warning to #helpers and set the claimant to the bot. + """ + if await _caches.claimants.get(channel.id): + return + + async for message in channel.history(limit=1000): + if message.author.id != bot.instance.user.id: + # We only care about bot messages + continue + if message.embeds: + if _message._match_bot_embed(message, _message.DORMANT_MSG): + log.info("Hit the dormant message embed before finding a claimant in %s (%d).", channel, channel.id) + break + user_id = CLAIMED_BY_RE.match(message.embeds[0].description).group("user_id") + await _caches.claimants.set(channel.id, int(user_id)) + return + + await bot.instance.get_channel(constants.Channels.helpers).send( + f"I couldn't find a claimant for {channel.mention} in that last 1000 messages. " + "Please use your helper powers to close the channel if/when appropriate." + ) + await _caches.claimants.set(channel.id, bot.instance.user.id) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 6d061c8ca..b0f1a1dce 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -326,6 +326,7 @@ class HelpChannels(commands.Cog): log.trace("Moving or rescheduling in-use channels.") for channel in _channel.get_category_channels(self.in_use_category): + await _channel.ensure_cached_claimant(channel) await self.move_idle_channel(channel, has_task=False) # Prevent the command from being used until ready. -- cgit v1.2.3 From 4e0c6e73a5d430574ec73257734c78a8c6574fa2 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 20 Feb 2022 14:10:49 +0000 Subject: Handle uncached claimant on unclaim This could be possible during init_available. If there are too many available channels they are made dormant by calling unclaim_channel. However there may not be claimants cached and ensure_claimants wouldn't populate cache, since the channels weren't in use. --- bot/exts/help_channels/_cog.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index b0f1a1dce..f276a7993 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -452,18 +452,21 @@ class HelpChannels(commands.Cog): async def _unclaim_channel( self, channel: discord.TextChannel, - claimant_id: int, + claimant_id: t.Optional[int], closed_on: _channel.ClosingReason ) -> None: """Actual implementation of `unclaim_channel`. See that for full documentation.""" await _caches.claimants.delete(channel.id) await _caches.session_participants.delete(channel.id) - claimant = await members.get_or_fetch_member(self.guild, claimant_id) - if claimant is None: - log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") + if not claimant_id: + log.info("No claimant given when un-claiming %s (%d). Skipping role removal.", channel, channel.id) else: - await members.handle_role_change(claimant, claimant.remove_roles, self.cooldown_role) + claimant = await members.get_or_fetch_member(self.guild, claimant_id) + if claimant is None: + log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") + else: + await members.handle_role_change(claimant, claimant.remove_roles, self.cooldown_role) await _message.unpin(channel) await _stats.report_complete_session(channel.id, closed_on) -- cgit v1.2.3