diff --git a/docs/autostart_pm2.rst b/docs/autostart_pm2.rst index 27300ec3b..84ccfdbc5 100644 --- a/docs/autostart_pm2.rst +++ b/docs/autostart_pm2.rst @@ -35,6 +35,17 @@ You can add additional Red based arguments after the instance, such as :code:`-- The name of your Red instance. +If you used :code:`pyenv virtualenv` to create your virtual environment, please make the following changes to the above generated command + +.. code-block:: none + + + Run the following instead to get your Python interpreter + pyenv which python + + Replace the `redbot` part of `pm2 start redbot` with the output of the following (when ran inside your activated venv) + pyenv which redbot + ------------------------------ Ensuring that PM2 stays online ------------------------------ diff --git a/redbot/cogs/filter/__init__.py b/redbot/cogs/filter/__init__.py index 3d26e7198..1e1b9b7d0 100644 --- a/redbot/cogs/filter/__init__.py +++ b/redbot/cogs/filter/__init__.py @@ -2,5 +2,7 @@ from .filter import Filter from redbot.core.bot import Red -def setup(bot: Red): - bot.add_cog(Filter(bot)) +async def setup(bot: Red) -> None: + cog = Filter(bot) + await cog.initialize() + bot.add_cog(cog) diff --git a/redbot/cogs/filter/filter.py b/redbot/cogs/filter/filter.py index daf1769df..736b79355 100644 --- a/redbot/cogs/filter/filter.py +++ b/redbot/cogs/filter/filter.py @@ -37,7 +37,6 @@ class Filter(commands.Cog): self.config.register_guild(**default_guild_settings) self.config.register_member(**default_member_settings) self.config.register_channel(**default_channel_settings) - self.register_task = self.bot.loop.create_task(self.register_filterban()) self.pattern_cache = {} async def red_delete_data_for_user( @@ -55,17 +54,27 @@ class Filter(commands.Cog): if user_id in guild_data: await self.config.member_from_ids(guild_id, user_id).clear() - def cog_unload(self): - self.register_task.cancel() + async def initialize(self) -> None: + await self.register_casetypes() @staticmethod - async def register_filterban(): - try: - await modlog.register_casetype( - "filterban", False, ":filing_cabinet: :hammer:", "Filter ban" - ) - except RuntimeError: - pass + async def register_casetypes() -> None: + await modlog.register_casetypes( + [ + { + "name": "filterban", + "default_setting": False, + "image": "\N{FILE CABINET}\N{VARIATION SELECTOR-16} \N{HAMMER}", + "case_str": "Filter ban", + }, + { + "name": "filterhit", + "default_setting": False, + "image": "\N{FILE CABINET}\N{VARIATION SELECTOR-16}", + "case_str": "Filter hit", + }, + ] + ) @commands.group() @commands.guild_only() @@ -183,7 +192,7 @@ class Filter(commands.Cog): except discord.Forbidden: await ctx.send(_("I can't send direct messages to you.")) - @_filter_channel.command("add") + @_filter_channel.command(name="add", require_var_positional=True) async def filter_channel_add(self, ctx: commands.Context, *words: str): """Add words to the filter. @@ -205,7 +214,7 @@ class Filter(commands.Cog): else: await ctx.send(_("Words already in the filter.")) - @_filter_channel.command("remove") + @_filter_channel.command(name="delete", aliases=["remove", "del"], require_var_positional=True) async def filter_channel_remove(self, ctx: commands.Context, *words: str): """Remove words from the filter. @@ -227,7 +236,7 @@ class Filter(commands.Cog): else: await ctx.send(_("Those words weren't in the filter.")) - @_filter.command(name="add") + @_filter.command(name="add", require_var_positional=True) async def filter_add(self, ctx: commands.Context, *words: str): """Add words to the filter. @@ -249,7 +258,7 @@ class Filter(commands.Cog): else: await ctx.send(_("Those words were already in the filter.")) - @_filter.command(name="delete", aliases=["remove", "del"]) + @_filter.command(name="delete", aliases=["remove", "del"], require_var_positional=True) async def filter_remove(self, ctx: commands.Context, *words: str): """Remove words from the filter. @@ -391,6 +400,20 @@ class Filter(commands.Cog): hits = await self.filter_hits(message.content, message.channel) if hits: + await modlog.create_case( + bot=self.bot, + guild=guild, + created_at=message.created_at.replace(tzinfo=timezone.utc), + action_type="filterhit", + user=author, + moderator=guild.me, + reason=( + _("Filtered words used: {words}").format(words=humanize_list(list(hits))) + if len(hits) > 1 + else _("Filtered word used: {word}").format(word=list(hits)[0]) + ), + channel=message.channel, + ) try: await message.delete() except discord.HTTPException: @@ -471,7 +494,6 @@ class Filter(commands.Cog): await set_contextual_locales_from_guild(self.bot, guild) if await self.filter_hits(member.display_name, member.guild): - name_to_use = guild_data["filter_default_name"] reason = _("Filtered nickname") if member.nick else _("Filtered name") try: diff --git a/redbot/cogs/general/general.py b/redbot/cogs/general/general.py index 8fd3fcf62..ec11a03fc 100644 --- a/redbot/cogs/general/general.py +++ b/redbot/cogs/general/general.py @@ -80,7 +80,7 @@ class General(commands.Cog): """ Nothing to delete """ return - @commands.command() + @commands.command(usage=" ") async def choose(self, ctx, *choices): """Choose between multiple options. diff --git a/redbot/cogs/mod/kickban.py b/redbot/cogs/mod/kickban.py index e843bff58..985ee2acf 100644 --- a/redbot/cogs/mod/kickban.py +++ b/redbot/cogs/mod/kickban.py @@ -270,7 +270,14 @@ class KickBanMixin(MixinMeta): @commands.bot_has_permissions(kick_members=True) @checks.admin_or_permissions(kick_members=True) async def kick(self, ctx: commands.Context, user: discord.Member, *, reason: str = None): - """Kick a user. + """ + Kick a user. + + Examples: + - `[p]kick 428675506947227648 wanted to be kicked.` + This will kick Twentysix from the server. + - `[p]kick @Twentysix wanted to be kicked.` + This will kick Twentysix from the server. If a reason is specified, it will be the reason that shows up in the audit log. @@ -349,11 +356,18 @@ class KickBanMixin(MixinMeta): ): """Ban a user from this server and optionally delete days of messages. + `days` is the amount of days of messages to cleanup on ban. + + Examples: + - `[p]ban 428675506947227648 7 Continued to spam after told to stop.` + This will ban Twentysix and it will delete 7 days worth of messages. + - `[p]ban @Twentysix 7 Continued to spam after told to stop.` + This will ban Twentysix and it will delete 7 days worth of messages. + A user ID should be provided if the user is not a member of this server. - If days is not a number, it's treated as the first word of the reason. - - Minimum 0 days, maximum 7. If not specified, defaultdays setting will be used instead.""" + Minimum 0 days, maximum 7. If not specified, the defaultdays setting will be used instead. + """ guild = ctx.guild if days is None: days = await self.config.guild(guild).default_days() @@ -380,8 +394,15 @@ class KickBanMixin(MixinMeta): ): """Mass bans user(s) from the server. + `days` is the amount of days of messages to cleanup on massban. + + Example: + - `[p]massban 345628097929936898 57287406247743488 7 they broke all rules.` + This will ban all the added userids and delete 7 days of worth messages. + User IDs need to be provided in order to ban - using this command.""" + using this command. + """ banned = [] errors = {} upgrades = [] @@ -543,7 +564,19 @@ class KickBanMixin(MixinMeta): *, reason: str = None, ): - """Temporarily ban a user from this server.""" + """Temporarily ban a user from this server. + + `duration` is the amount of time the user should be banned for. + `days` is the amount of days of messages to cleanup on tempban. + + Examples: + - `[p]tempban @Twentysix Because I say so` + This will ban Twentysix for the default amount of time set by an administrator. + - `[p]tempban @Twentysix 15m You need a timeout` + This will ban Twentysix for 15 minutes. + - `[p]tempban 428675506947227648 1d2h15m 5 Evil person` + This will ban the user for 1 day 2 hours 15 minutes and will delete the last 5 days of their messages. + """ guild = ctx.guild author = ctx.author diff --git a/redbot/cogs/mod/utils.py b/redbot/cogs/mod/utils.py index 111440ff4..418b04319 100644 --- a/redbot/cogs/mod/utils.py +++ b/redbot/cogs/mod/utils.py @@ -10,4 +10,4 @@ async def is_allowed_by_hierarchy( if not await config.guild(guild).respect_hierarchy(): return True is_special = mod == guild.owner or await bot.is_owner(mod) - return mod.top_role.position > user.top_role.position or is_special + return mod.top_role > user.top_role or is_special diff --git a/redbot/cogs/modlog/modlog.py b/redbot/cogs/modlog/modlog.py index aa406c819..03f677d86 100644 --- a/redbot/cogs/modlog/modlog.py +++ b/redbot/cogs/modlog/modlog.py @@ -7,7 +7,7 @@ import discord from redbot.core import checks, commands, modlog from redbot.core.bot import Red from redbot.core.i18n import Translator, cog_i18n -from redbot.core.utils.chat_formatting import box +from redbot.core.utils.chat_formatting import box, pagify from redbot.core.utils.menus import DEFAULT_CONTROLS, menu _ = Translator("ModLog", __file__) @@ -129,39 +129,76 @@ class ModLog(commands.Cog): @commands.guild_only() async def casesfor(self, ctx: commands.Context, *, member: Union[discord.Member, int]): """Display cases for the specified member.""" - try: - if isinstance(member, int): - cases = await modlog.get_cases_for_member( - bot=ctx.bot, guild=ctx.guild, member_id=member + async with ctx.typing(): + try: + if isinstance(member, int): + cases = await modlog.get_cases_for_member( + bot=ctx.bot, guild=ctx.guild, member_id=member + ) + else: + cases = await modlog.get_cases_for_member( + bot=ctx.bot, guild=ctx.guild, member=member + ) + except discord.NotFound: + return await ctx.send(_("That user does not exist.")) + except discord.HTTPException: + return await ctx.send( + _("Something unexpected went wrong while fetching that user by ID.") ) - else: - cases = await modlog.get_cases_for_member( - bot=ctx.bot, guild=ctx.guild, member=member + + if not cases: + return await ctx.send(_("That user does not have any cases.")) + + embed_requested = await ctx.embed_requested() + if embed_requested: + rendered_cases = [await case.message_content(embed=True) for case in cases] + elif not embed_requested: + rendered_cases = [] + for case in cases: + message = _("{case}\n**Timestamp:** {timestamp}").format( + case=await case.message_content(embed=False), + timestamp=datetime.utcfromtimestamp(case.created_at).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ), + ) + rendered_cases.append(message) + + await menu(ctx, rendered_cases, DEFAULT_CONTROLS) + + @commands.command() + @commands.guild_only() + async def listcases(self, ctx: commands.Context, *, member: Union[discord.Member, int]): + """List cases for the specified member.""" + async with ctx.typing(): + try: + if isinstance(member, int): + cases = await modlog.get_cases_for_member( + bot=ctx.bot, guild=ctx.guild, member_id=member + ) + else: + cases = await modlog.get_cases_for_member( + bot=ctx.bot, guild=ctx.guild, member=member + ) + except discord.NotFound: + return await ctx.send(_("That user does not exist.")) + except discord.HTTPException: + return await ctx.send( + _("Something unexpected went wrong while fetching that user by ID.") ) - except discord.NotFound: - return await ctx.send(_("That user does not exist.")) - except discord.HTTPException: - return await ctx.send( - _("Something unexpected went wrong while fetching that user by ID.") - ) + if not cases: + return await ctx.send(_("That user does not have any cases.")) - if not cases: - return await ctx.send(_("That user does not have any cases.")) - - embed_requested = await ctx.embed_requested() - if embed_requested: - rendered_cases = [await case.message_content(embed=True) for case in cases] - elif not embed_requested: rendered_cases = [] + message = "" for case in cases: - message = _("{case}\n**Timestamp:** {timestamp}").format( + message += _("{case}\n**Timestamp:** {timestamp}\n\n").format( case=await case.message_content(embed=False), timestamp=datetime.utcfromtimestamp(case.created_at).strftime( "%Y-%m-%d %H:%M:%S UTC" ), ) - rendered_cases.append(message) - + for page in pagify(message, ["\n\n", "\n"], priority=True): + rendered_cases.append(page) await menu(ctx, rendered_cases, DEFAULT_CONTROLS) @commands.command() diff --git a/redbot/cogs/mutes/abc.py b/redbot/cogs/mutes/abc.py index 5403d9dc9..4bfc8e96d 100644 --- a/redbot/cogs/mutes/abc.py +++ b/redbot/cogs/mutes/abc.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import List, Tuple, Optional, Dict +from typing import List, Tuple, Optional, Dict, Union from datetime import datetime import discord @@ -25,3 +25,15 @@ class MixinMeta(ABC): ctx: commands.Context, user_voice_state: Optional[discord.VoiceState], **perms: bool ) -> bool: raise NotImplementedError() + + @abstractmethod + async def _send_dm_notification( + self, + user: Union[discord.User, discord.Member], + moderator: Optional[Union[discord.User, discord.Member]], + guild: discord.Guild, + mute_type: str, + reason: Optional[str], + duration=None, + ): + raise NotImplementedError() diff --git a/redbot/cogs/mutes/converters.py b/redbot/cogs/mutes/converters.py index fae51088a..dbdafd2ad 100644 --- a/redbot/cogs/mutes/converters.py +++ b/redbot/cogs/mutes/converters.py @@ -51,5 +51,5 @@ class MuteTime(Converter): time_data[k] = int(v) if time_data: result["duration"] = timedelta(**time_data) - result["reason"] = argument + result["reason"] = argument.strip() return result diff --git a/redbot/cogs/mutes/mutes.py b/redbot/cogs/mutes/mutes.py index ef1ae088e..4bc7cedfd 100644 --- a/redbot/cogs/mutes/mutes.py +++ b/redbot/cogs/mutes/mutes.py @@ -13,7 +13,7 @@ from .voicemutes import VoiceMutes from redbot.core.bot import Red from redbot.core import commands, checks, i18n, modlog, Config from redbot.core.utils import bounded_gather -from redbot.core.utils.chat_formatting import humanize_timedelta, humanize_list, pagify +from redbot.core.utils.chat_formatting import bold, humanize_timedelta, humanize_list, pagify from redbot.core.utils.mod import get_audit_reason from redbot.core.utils.menus import start_adding_reactions from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate @@ -28,6 +28,9 @@ MUTE_UNMUTE_ISSUES = { "hierarchy_problem": _( "I cannot let you do that. You are not higher than the user in the role hierarchy." ), + "assigned_role_hierarchy_problem": _( + "I cannot let you do that. You are not higher than the mute role in the role hierarchy." + ), "is_admin": _("That user cannot be muted, as they have the Administrator permission."), "permissions_issue_role": _( "Failed to mute or unmute user. I need the Manage Roles " @@ -74,6 +77,8 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): "notification_channel": None, "muted_users": {}, "default_time": 0, + "dm": False, + "show_mod": False, } self.config.register_global(force_role_mutes=True) # Tbh I would rather force everyone to use role mutes. @@ -145,7 +150,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): self, guild: discord.Guild, mod: discord.Member, user: discord.Member ): is_special = mod == guild.owner or await self.bot.is_owner(mod) - return mod.top_role.position > user.top_role.position or is_special + return mod.top_role > user.top_role or is_special async def _handle_automatic_unmute(self): """This is the core task creator and loop @@ -253,6 +258,9 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): _("Automatic unmute"), until=None, ) + await self._send_dm_notification( + member, author, guild, _("Server unmute"), _("Automatic unmute") + ) else: chan_id = await self.config.guild(guild).notification_channel() notification_channel = guild.get_channel(chan_id) @@ -360,6 +368,9 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): modlog_reason, until=None, ) + await self._send_dm_notification( + member, author, guild, _("Server unmute"), _("Automatic unmute") + ) self._channel_mute_events[guild.id].set() if any(results): reasons = {} @@ -421,8 +432,10 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): if create_case: if isinstance(channel, discord.VoiceChannel): unmute_type = "vunmute" + notification_title = _("Voice unmute") else: unmute_type = "cunmute" + notification_title = _("Channel unmute") await modlog.create_case( self.bot, channel.guild, @@ -434,6 +447,9 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): until=None, channel=channel, ) + await self._send_dm_notification( + member, author, channel.guild, notification_title, _("Automatic unmute") + ) return None else: error_msg = _( @@ -454,6 +470,72 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): else: return (member, channel, success["reason"]) + async def _send_dm_notification( + self, + user: Union[discord.User, discord.Member], + moderator: Optional[Union[discord.User, discord.Member]], + guild: discord.Guild, + mute_type: str, + reason: Optional[str], + duration=None, + ): + if not await self.config.guild(guild).dm(): + return + + show_mod = await self.config.guild(guild).show_mod() + title = bold(mute_type) + if duration: + duration_str = humanize_timedelta(timedelta=duration) + until = datetime.now(timezone.utc) + duration + until_str = until.strftime("%Y-%m-%d %H:%M:%S UTC") + + if moderator is None: + moderator_str = _("Unknown") + else: + moderator_str = str(moderator) + + if not reason: + reason = _("No reason provided.") + + # okay, this is some poor API to require PrivateChannel here... + if await self.bot.embed_requested(await user.create_dm(), user): + em = discord.Embed( + title=title, + description=reason, + color=await self.bot.get_embed_color(user), + ) + em.timestamp = datetime.utcnow() + if duration: + em.add_field(name=_("Until"), value=until_str) + em.add_field(name=_("Duration"), value=duration_str) + em.add_field(name=_("Guild"), value=guild.name, inline=False) + if show_mod: + em.add_field(name=_("Moderator"), value=moderator_str) + try: + await user.send(embed=em) + except discord.Forbidden: + pass + else: + message = f"{title}\n>>> " + message += reason + message += ( + _("\n**Moderator**: {moderator}").format(moderator=moderator_str) + if show_mod + else "" + ) + message += ( + _("\n**Until**: {until}\n**Duration**: {duration}").format( + until=until_str, duration=duration_str + ) + if duration + else "" + ) + message += _("\n**Guild**: {guild_name}").format(guild_name=guild.name) + try: + await user.send(message) + except discord.Forbidden: + pass + @commands.Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member): """ @@ -491,6 +573,9 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): ) del self._server_mutes[guild.id][after.id] should_save = True + await self._send_dm_notification( + after, None, guild, _("Server unmute"), _("Manually removed mute role") + ) elif mute_role in roles_added: # send modlog case for mute and add to cache if guild.id not in self._server_mutes: @@ -512,6 +597,9 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): "until": None, } should_save = True + await self._send_dm_notification( + after, None, guild, _("Server mute"), _("Manually applied mute role") + ) if should_save: await self.config.guild(guild).muted_users.set(self._server_mutes[guild.id]) @@ -552,15 +640,27 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): user_id not in after_perms or any((send_messages, speak)) ): user = after.guild.get_member(user_id) + send_dm_notification = True if not user: + send_dm_notification = False user = discord.Object(id=user_id) log.debug(f"{user} - {type(user)}") to_del.append(user_id) log.debug("creating case") if isinstance(after, discord.VoiceChannel): unmute_type = "vunmute" + notification_title = _("Voice unmute") else: unmute_type = "cunmute" + notification_title = _("Channel unmute") + if send_dm_notification: + await self._send_dm_notification( + user, + None, + after.guild, + notification_title, + _("Manually removed channel overwrites"), + ) await modlog.create_case( self.bot, after.guild, @@ -611,6 +711,34 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): """Mute settings.""" pass + @muteset.command() + @commands.guild_only() + async def senddm(self, ctx: commands.Context, true_or_false: bool): + """Set whether mute notifications should be sent to users in DMs.""" + await self.config.guild(ctx.guild).dm.set(true_or_false) + if true_or_false: + await ctx.send(_("I will now try to send mute notifications to users DMs.")) + else: + await ctx.send(_("Mute notifications will no longer be sent to users DMs.")) + + @muteset.command() + @commands.guild_only() + async def showmoderator(self, ctx, true_or_false: bool): + """Decide whether the name of the moderator muting a user should be included in the DM to that user.""" + await self.config.guild(ctx.guild).show_mod.set(true_or_false) + if true_or_false: + await ctx.send( + _( + "I will include the name of the moderator who issued the mute when sending a DM to a user." + ) + ) + else: + await ctx.send( + _( + "I will not include the name of the moderator who issued the mute when sending a DM to a user." + ) + ) + @muteset.command(name="forcerole") @commands.is_owner() async def force_role_mutes(self, ctx: commands.Context, force_role_mutes: bool): @@ -635,11 +763,17 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): notification_channel = ctx.guild.get_channel(data["notification_channel"]) default_time = timedelta(seconds=data["default_time"]) msg = _( - "Mute Role: {role}\nNotification Channel: {channel}\n" "Default Time: {time}" + "Mute Role: {role}\n" + "Notification Channel: {channel}\n" + "Default Time: {time}\n" + "Send DM: {dm}\n" + "Show moderator: {show_mod}" ).format( role=mute_role.mention if mute_role else _("None"), channel=notification_channel.mention if notification_channel else _("None"), time=humanize_timedelta(timedelta=default_time) if default_time else _("None"), + dm=data["dm"], + show_mod=data["show_mod"], ) await ctx.maybe_send_embed(msg) @@ -684,6 +818,11 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): # removed the mute role await ctx.send(_("Channel overwrites will be used for mutes instead.")) else: + if role >= ctx.author.top_role: + await ctx.send( + _("You can't set this role as it is not lower than you in the role hierarchy.") + ) + return await self.config.guild(ctx.guild).mute_role.set(role.id) self.mute_role_cache[ctx.guild.id] = role.id await ctx.send(_("Mute role set to {role}").format(role=role.name)) @@ -999,6 +1138,9 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): until=until, channel=None, ) + await self._send_dm_notification( + user, author, guild, _("Server mute"), reason, duration + ) else: issue_list.append(success) if success_list: @@ -1143,6 +1285,9 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): until=until, channel=channel, ) + await self._send_dm_notification( + user, author, guild, _("Channel mute"), reason, duration + ) async with self.config.member(user).perms_cache() as cache: cache[channel.id] = success["old_overs"] else: @@ -1209,6 +1354,9 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): reason, until=None, ) + await self._send_dm_notification( + user, author, guild, _("Server unmute"), reason + ) else: issue_list.append(success) self._channel_mute_events[guild.id].set() @@ -1273,6 +1421,9 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): until=None, channel=channel, ) + await self._send_dm_notification( + user, author, guild, _("Channel unmute"), reason + ) else: issue_list.append((user, success["reason"])) if success_list: @@ -1331,6 +1482,9 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): if not role: ret["reason"] = _(MUTE_UNMUTE_ISSUES["role_missing"]) return ret + if author != guild.owner and role >= author.top_role: + ret["reason"] = _(MUTE_UNMUTE_ISSUES["assigned_role_hierarchy_problem"]) + return ret if not guild.me.guild_permissions.manage_roles or role >= guild.me.top_role: ret["reason"] = _(MUTE_UNMUTE_ISSUES["permissions_issue_role"]) return ret diff --git a/redbot/cogs/mutes/voicemutes.py b/redbot/cogs/mutes/voicemutes.py index 074e6a4e1..f85de8e2a 100644 --- a/redbot/cogs/mutes/voicemutes.py +++ b/redbot/cogs/mutes/voicemutes.py @@ -1,10 +1,11 @@ -from typing import Optional, Tuple +from typing import Optional, Tuple, Union from datetime import timezone, timedelta, datetime from .abc import MixinMeta import discord from redbot.core import commands, checks, i18n, modlog from redbot.core.utils.chat_formatting import ( + bold, humanize_timedelta, humanize_list, pagify, @@ -143,6 +144,9 @@ class VoiceMutes(MixinMeta): until=until, channel=channel, ) + await self._send_dm_notification( + user, author, guild, _("Voice mute"), reason, duration + ) async with self.config.member(user).perms_cache() as cache: cache[channel.id] = success["old_overs"] else: @@ -216,6 +220,9 @@ class VoiceMutes(MixinMeta): until=None, channel=channel, ) + await self._send_dm_notification( + user, author, guild, _("Voice unmute"), reason + ) else: issue_list.append((user, success["reason"])) if success_list: diff --git a/redbot/cogs/streams/streamtypes.py b/redbot/cogs/streams/streamtypes.py index 0d6c45453..c1e315e59 100644 --- a/redbot/cogs/streams/streamtypes.py +++ b/redbot/cogs/streams/streamtypes.py @@ -4,7 +4,7 @@ import logging from dateutil.parser import parse as parse_time from random import choice from string import ascii_letters -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import xml.etree.ElementTree as ET from typing import ClassVar, Optional, List, Tuple @@ -35,7 +35,7 @@ YOUTUBE_CHANNEL_RSS = "https://www.youtube.com/feeds/videos.xml?channel_id={chan _ = Translator("Streams", __file__) -log = logging.getLogger("redbot.cogs.Streams") +log = logging.getLogger("red.core.cogs.Streams") def rnd(url): @@ -138,9 +138,7 @@ class YoutubeStream(Stream): scheduled = stream_data.get("scheduledStartTime", None) if scheduled is not None and actual_start_time is None: scheduled = parse_time(scheduled) - if ( - scheduled.replace(tzinfo=None) - datetime.now() - ).total_seconds() < -3600: + if (scheduled - datetime.now(timezone.utc)).total_seconds() < -3600: continue elif actual_start_time is None: continue @@ -178,7 +176,7 @@ class YoutubeStream(Stream): if vid_data["liveStreamingDetails"].get("scheduledStartTime", None) is not None: if "actualStartTime" not in vid_data["liveStreamingDetails"]: start_time = parse_time(vid_data["liveStreamingDetails"]["scheduledStartTime"]) - start_in = start_time.replace(tzinfo=None) - datetime.now() + start_in = start_time - datetime.now(timezone.utc) if start_in.total_seconds() > 0: embed.description = _("This stream will start in {time}").format( time=humanize_timedelta( diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 67f6e2d57..1bce5a9a1 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -110,6 +110,7 @@ class RedBase( help__delete_delay=0, help__use_menus=False, help__show_hidden=False, + help__show_aliases=True, help__verify_checks=True, help__verify_exists=False, help__tagline="", @@ -281,6 +282,92 @@ class RedBase( """ self._help_formatter = commands.help.RedHelpFormatter() + def add_dev_env_value(self, name: str, value: Callable[[commands.Context], Any]): + """ + Add a custom variable to the dev environment (``[p]debug``, ``[p]eval``, and ``[p]repl`` commands). + If dev mode is disabled, nothing will happen. + + Example + ------- + + .. code-block:: python + + class MyCog(commands.Cog): + def __init__(self, bot): + self.bot = bot + bot.add_dev_env_value("mycog", lambda ctx: self) + bot.add_dev_env_value("mycogdata", lambda ctx: self.settings[ctx.guild.id]) + + def cog_unload(self): + self.bot.remove_dev_env_value("mycog") + self.bot.remove_dev_env_value("mycogdata") + + Once your cog is loaded, the custom variables ``mycog`` and ``mycogdata`` + will be included in the environment of dev commands. + + Parameters + ---------- + name: str + The name of your custom variable. + value: Callable[[commands.Context], Any] + The function returning the value of the variable. + It must take a `commands.Context` as its sole parameter + + Raises + ------ + TypeError + ``value`` argument isn't a callable. + ValueError + The passed callable takes no or more than one argument. + RuntimeError + The name of the custom variable is either reserved by a variable + from the default environment or already taken by some other custom variable. + """ + signature = inspect.signature(value) + if len(signature.parameters) != 1: + raise ValueError("Callable must take exactly one argument for context") + dev = self.get_cog("Dev") + if dev is None: + return + if name in [ + "bot", + "ctx", + "channel", + "author", + "guild", + "message", + "asyncio", + "aiohttp", + "discord", + "commands", + "_", + "__name__", + "__builtins__", + ]: + raise RuntimeError(f"The name {name} is reserved for default environement.") + if name in dev.env_extensions: + raise RuntimeError(f"The name {name} is already used.") + dev.env_extensions[name] = value + + def remove_dev_env_value(self, name: str): + """ + Remove a custom variable from the dev environment. + + Parameters + ---------- + name: str + The name of the custom variable. + + Raises + ------ + KeyError + The custom variable was never set. + """ + dev = self.get_cog("Dev") + if dev is None: + return + del dev.env_extensions[name] + def get_command(self, name: str) -> Optional[commands.Command]: com = super().get_command(name) assert com is None or isinstance(com, commands.Command) @@ -383,6 +470,12 @@ class RedBase( self._red_before_invoke_objs.add(coro) return coro + async def before_identify_hook(self, shard_id, *, initial=False): + """A hook that is called before IDENTIFYing a session. + Same as in discord.py, but also dispatches "on_red_identify" bot event.""" + self.dispatch("red_before_identify", shard_id, initial) + return await super().before_identify_hook(shard_id, initial=initial) + @property def cog_mgr(self) -> NoReturn: raise AttributeError("Please don't mess with the cog manager internals.") diff --git a/redbot/core/commands/help.py b/redbot/core/commands/help.py index 47dbc347f..e98019914 100644 --- a/redbot/core/commands/help.py +++ b/redbot/core/commands/help.py @@ -42,7 +42,7 @@ from ..i18n import Translator from ..utils import menus from ..utils.mod import mass_purge from ..utils._internal_utils import fuzzy_command_search, format_fuzzy_results -from ..utils.chat_formatting import box, pagify, humanize_timedelta +from ..utils.chat_formatting import box, humanize_list, humanize_number, humanize_timedelta, pagify __all__ = ["red_help", "RedHelpFormatter", "HelpSettings", "HelpFormatterABC"] @@ -72,6 +72,7 @@ class HelpSettings: max_pages_in_guild: int = 2 use_menus: bool = False show_hidden: bool = False + show_aliases: bool = True verify_checks: bool = True verify_exists: bool = False tagline: str = "" @@ -300,10 +301,42 @@ class RedHelpFormatter(HelpFormatterABC): tagline = (help_settings.tagline) or self.get_default_tagline(ctx) signature = _( - "`Syntax: {ctx.clean_prefix}{command.qualified_name} {command.signature}`" + "Syntax: {ctx.clean_prefix}{command.qualified_name} {command.signature}" ).format(ctx=ctx, command=command) - subcommands = None + aliases = command.aliases + if help_settings.show_aliases and aliases: + alias_fmt = _("Aliases") if len(command.aliases) > 1 else _("Alias") + aliases = sorted(aliases, key=len) + + a_counter = 0 + valid_alias_list = [] + for alias in aliases: + if (a_counter := a_counter + len(alias)) < 500: + valid_alias_list.append(alias) + else: + break + + a_diff = len(aliases) - len(valid_alias_list) + aliases_list = [ + f"{ctx.clean_prefix}{command.parent.qualified_name + ' ' if command.parent else ''}{alias}" + for alias in valid_alias_list + ] + if len(valid_alias_list) < 10: + aliases_content = humanize_list(aliases_list) + else: + aliases_formatted_list = ", ".join(aliases_list) + if a_diff > 1: + aliases_content = _("{aliases} and {number} more aliases.").format( + aliases=aliases_formatted_list, number=humanize_number(a_diff) + ) + else: + aliases_content = _("{aliases} and one more alias.").format( + aliases=aliases_formatted_list + ) + signature += f"\n{alias_fmt}: {aliases_content}" + + subcommands = None if hasattr(command, "all_commands"): grp = cast(commands.Group, command) subcommands = await self.get_group_help_mapping(ctx, grp, help_settings=help_settings) @@ -315,7 +348,7 @@ class RedHelpFormatter(HelpFormatterABC): emb["embed"]["title"] = f"*{description[:250]}*" emb["footer"]["text"] = tagline - emb["embed"]["description"] = signature + emb["embed"]["description"] = box(signature) command_help = command.format_help_for_context(ctx) if command_help: @@ -375,7 +408,7 @@ class RedHelpFormatter(HelpFormatterABC): None, ( description, - signature[1:-1], + signature, command.format_help_for_context(ctx), subtext_header, subtext, diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index f6465e891..3411782fe 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -172,6 +172,20 @@ class CoreLogic: except errors.CogLoadError as e: failed_with_reason_packages.append((name, str(e))) except Exception as e: + if isinstance(e, commands.CommandRegistrationError): + if e.alias_conflict: + error_message = _( + "Alias {alias_name} is already an existing command" + " or alias in one of the loaded cogs." + ).format(alias_name=inline(e.name)) + else: + error_message = _( + "Command {command_name} is already an existing command" + " or alias in one of the loaded cogs." + ).format(command_name=inline(e.name)) + failed_with_reason_packages.append((name, error_message)) + continue + log.exception("Package loading failed", exc_info=e) exception_log = "Exception during loading of package\n" @@ -2094,7 +2108,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): if game: if len(game) > 128: - await ctx.send("The maximum length of game descriptions is 128 characters.") + await ctx.send(_("The maximum length of game descriptions is 128 characters.")) return game = discord.Game(name=game) else: @@ -2126,6 +2140,11 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online if listening: + if len(listening) > 128: + await ctx.send( + _("The maximum length of listening descriptions is 128 characters.") + ) + return activity = discord.Activity(name=listening, type=discord.ActivityType.listening) else: activity = None @@ -2157,6 +2176,9 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online if watching: + if len(watching) > 128: + await ctx.send(_("The maximum length of watching descriptions is 128 characters.")) + return activity = discord.Activity(name=watching, type=discord.ActivityType.watching) else: activity = None @@ -2186,6 +2208,11 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online if competing: + if len(competing) > 128: + await ctx.send( + _("The maximum length of competing descriptions is 128 characters.") + ) + return activity = discord.Activity(name=competing, type=discord.ActivityType.competing) else: activity = None @@ -2234,11 +2261,13 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): await ctx.bot.change_presence(status=status, activity=game) await ctx.send(_("Status changed to {}.").format(status)) - @_set.command(name="streaming", aliases=["stream"], usage="[( )]") + @_set.command( + name="streaming", aliases=["stream", "twitch"], usage="[( )]" + ) @checks.bot_in_a_guild() @checks.is_owner() async def stream(self, ctx: commands.Context, streamer=None, *, stream_title=None): - """Sets [botname]'s streaming status. + """Sets [botname]'s streaming status to a twitch stream. This will appear as `Streaming ` or `LIVE ON TWITCH` depending on the context. It will also include a `Watch` button with a twitch.tv url for the provided streamer. @@ -2262,6 +2291,12 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): stream_title = stream_title.strip() if "twitch.tv/" not in streamer: streamer = "https://www.twitch.tv/" + streamer + if len(streamer) > 511: + await ctx.send(_("The maximum length of the streamer url is 511 characters.")) + return + if len(stream_title) > 128: + await ctx.send(_("The maximum length of the stream title is 128 characters.")) + return activity = discord.Streaming(url=streamer, name=stream_title) await ctx.bot.change_presence(status=status, activity=activity) elif streamer is not None: @@ -2782,6 +2817,22 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): else: await ctx.send(_("Help will filter hidden commands.")) + @helpset.command(name="showaliases") + async def helpset_showaliases(self, ctx: commands.Context, show_aliases: bool = None): + """ + This allows the help command to show existing commands aliases if there is any. + + This defaults to True. + Using this without a setting will toggle. + """ + if show_aliases is None: + show_aliases = not await ctx.bot._config.help.show_aliases() + await ctx.bot._config.help.show_aliases.set(show_aliases) + if show_aliases: + await ctx.send(_("Help will show commands aliases.")) + else: + await ctx.send(_("Help will not show commands aliases.")) + @helpset.command(name="usetick") async def helpset_usetick(self, ctx: commands.Context, use_tick: bool = None): """ @@ -3275,8 +3326,10 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): """ uids = {getattr(user, "id", user) for user in users} await self.bot._whiteblacklist_cache.add_to_whitelist(None, uids) - - await ctx.send(_("Users added to allowlist.")) + if len(users) > 1: + await ctx.send(_("Users have been added to the allowlist.")) + else: + await ctx.send(_("User has been added to the allowlist.")) @allowlist.command(name="list") async def allowlist_list(self, ctx: commands.Context): @@ -3291,8 +3344,10 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): if not curr_list: await ctx.send("Allowlist is empty.") return - - msg = _("Users on allowlist:") + if len(curr_list) > 1: + msg = _("Users on the allowlist:") + else: + msg = _("User on the allowlist:") for user in curr_list: msg += "\n\t- {}".format(user) @@ -3316,8 +3371,10 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): """ uids = {getattr(user, "id", user) for user in users} await self.bot._whiteblacklist_cache.remove_from_whitelist(None, uids) - - await ctx.send(_("Users have been removed from the allowlist.")) + if len(users) > 1: + await ctx.send(_("Users have been removed from the allowlist.")) + else: + await ctx.send(_("User has been removed from the allowlist.")) @allowlist.command(name="clear") async def allowlist_clear(self, ctx: commands.Context): @@ -3366,8 +3423,10 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): uids = {getattr(user, "id", user) for user in users} await self.bot._whiteblacklist_cache.add_to_blacklist(None, uids) - - await ctx.send(_("User added to blocklist.")) + if len(users) > 1: + await ctx.send(_("Users have been added to the blocklist.")) + else: + await ctx.send(_("User has been added to the blocklist.")) @blocklist.command(name="list") async def blocklist_list(self, ctx: commands.Context): @@ -3382,8 +3441,10 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): if not curr_list: await ctx.send("Blocklist is empty.") return - - msg = _("Users on blocklist:") + if len(curr_list) > 1: + msg = _("Users on the blocklist:") + else: + msg = _("User on the blocklist:") for user in curr_list: msg += "\n\t- {}".format(user) @@ -3405,8 +3466,10 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): """ uids = {getattr(user, "id", user) for user in users} await self.bot._whiteblacklist_cache.remove_from_blacklist(None, uids) - - await ctx.send(_("Users have been removed from blocklist.")) + if len(users) > 1: + await ctx.send(_("Users have been removed from the blocklist.")) + else: + await ctx.send(_("User has been removed from the blocklist.")) @blocklist.command(name="clear") async def blocklist_clear(self, ctx: commands.Context): @@ -3468,7 +3531,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): @localallowlist.command(name="list") async def localallowlist_list(self, ctx: commands.Context): """ - Lists users and roles on the server allowlist. + Lists users and roles on the server allowlist. Example: - `[p]localallowlist list` @@ -3478,8 +3541,10 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): if not curr_list: await ctx.send("Server allowlist is empty.") return - - msg = _("Whitelisted Users and roles:") + if len(curr_list) > 1: + msg = _("Allowed users and/or roles:") + else: + msg = _("Allowed user or role:") for obj in curr_list: msg += "\n\t- {}".format(obj) @@ -3593,8 +3658,10 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): if not curr_list: await ctx.send("Server blocklist is empty.") return - - msg = _("Blacklisted Users and Roles:") + if len(curr_list) > 1: + msg = _("Blocked users and/or roles:") + else: + msg = _("Blocked user or role:") for obj in curr_list: msg += "\n\t- {}".format(obj) diff --git a/redbot/core/dev_commands.py b/redbot/core/dev_commands.py index 06ae6ceba..2ec5d0b50 100644 --- a/redbot/core/dev_commands.py +++ b/redbot/core/dev_commands.py @@ -45,6 +45,7 @@ class Dev(commands.Cog): super().__init__() self._last_result = None self.sessions = {} + self.env_extensions = {} @staticmethod def async_compile(source, filename, mode): @@ -92,6 +93,29 @@ class Dev(commands.Cog): token = ctx.bot.http.token return re.sub(re.escape(token), "[EXPUNGED]", input_, re.I) + def get_environment(self, ctx: commands.Context) -> dict: + env = { + "bot": ctx.bot, + "ctx": ctx, + "channel": ctx.channel, + "author": ctx.author, + "guild": ctx.guild, + "message": ctx.message, + "asyncio": asyncio, + "aiohttp": aiohttp, + "discord": discord, + "commands": commands, + "_": self._last_result, + "__name__": "__main__", + } + for name, value in self.env_extensions.items(): + try: + env[name] = value(ctx) + except Exception as e: + traceback.clear_frames(e.__traceback__) + env[name] = e + return env + @commands.command() @checks.is_owner() async def debug(self, ctx, *, code): @@ -115,21 +139,7 @@ class Dev(commands.Cog): commands - redbot.core.commands _ - The result of the last dev command. """ - env = { - "bot": ctx.bot, - "ctx": ctx, - "channel": ctx.channel, - "author": ctx.author, - "guild": ctx.guild, - "message": ctx.message, - "asyncio": asyncio, - "aiohttp": aiohttp, - "discord": discord, - "commands": commands, - "_": self._last_result, - "__name__": "__main__", - } - + env = self.get_environment(ctx) code = self.cleanup_code(code) try: @@ -169,21 +179,7 @@ class Dev(commands.Cog): commands - redbot.core.commands _ - The result of the last dev command. """ - env = { - "bot": ctx.bot, - "ctx": ctx, - "channel": ctx.channel, - "author": ctx.author, - "guild": ctx.guild, - "message": ctx.message, - "asyncio": asyncio, - "aiohttp": aiohttp, - "discord": discord, - "commands": commands, - "_": self._last_result, - "__name__": "__main__", - } - + env = self.get_environment(ctx) body = self.cleanup_code(body) stdout = io.StringIO() @@ -224,19 +220,6 @@ class Dev(commands.Cog): backtick. This includes codeblocks, and as such multiple lines can be evaluated. """ - variables = { - "ctx": ctx, - "bot": ctx.bot, - "message": ctx.message, - "guild": ctx.guild, - "channel": ctx.channel, - "author": ctx.author, - "asyncio": asyncio, - "_": None, - "__builtins__": __builtins__, - "__name__": "__main__", - } - if ctx.channel.id in self.sessions: if self.sessions[ctx.channel.id]: await ctx.send( @@ -250,6 +233,9 @@ class Dev(commands.Cog): ) return + env = self.get_environment(ctx) + env["__builtins__"] = __builtins__ + env["_"] = None self.sessions[ctx.channel.id] = True await ctx.send( _( @@ -287,8 +273,7 @@ class Dev(commands.Cog): await ctx.send(self.get_syntax_error(e)) continue - variables["message"] = response - + env["message"] = response stdout = io.StringIO() msg = "" @@ -296,9 +281,9 @@ class Dev(commands.Cog): try: with redirect_stdout(stdout): if executor is None: - result = types.FunctionType(code, variables)() + result = types.FunctionType(code, env)() else: - result = executor(code, variables) + result = executor(code, env) result = await self.maybe_await(result) except: value = stdout.getvalue() @@ -307,7 +292,7 @@ class Dev(commands.Cog): value = stdout.getvalue() if result is not None: msg = "{}{}".format(value, result) - variables["_"] = result + env["_"] = result elif value: msg = "{}".format(value) diff --git a/redbot/core/utils/mod.py b/redbot/core/utils/mod.py index 94d88432a..799907294 100644 --- a/redbot/core/utils/mod.py +++ b/redbot/core/utils/mod.py @@ -108,7 +108,7 @@ async def is_allowed_by_hierarchy( if not await settings.guild(guild).respect_hierarchy(): return True is_special = mod == guild.owner or await bot.is_owner(mod) - return mod.top_role.position > user.top_role.position or is_special + return mod.top_role > user.top_role or is_special async def is_mod_or_superior( diff --git a/redbot/core/utils/predicates.py b/redbot/core/utils/predicates.py index efe316fb2..09f3f4f0c 100644 --- a/redbot/core/utils/predicates.py +++ b/redbot/core/utils/predicates.py @@ -67,7 +67,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def same_context( cls, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the message fits the described context. @@ -104,7 +104,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def cancelled( cls, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the message is ``[p]cancel``. @@ -133,7 +133,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def yes_or_no( cls, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the message is "yes"/"y" or "no"/"n". @@ -176,7 +176,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def valid_int( cls, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is an integer. @@ -216,7 +216,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def valid_float( cls, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is a float. @@ -256,7 +256,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def positive( cls, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is a positive number. @@ -492,7 +492,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, value: str, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is equal to the specified value. @@ -522,7 +522,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, value: str, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response *as lowercase* is equal to the specified value. @@ -552,7 +552,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, value: Union[int, float], ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is less than the specified value. @@ -583,7 +583,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, value: Union[int, float], ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is greater than the specified value. @@ -614,7 +614,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, length: int, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response's length is less than the specified length. @@ -644,7 +644,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, length: int, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response's length is greater than the specified length. @@ -674,7 +674,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, collection: Sequence[str], ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is contained in the specified collection. @@ -718,7 +718,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, collection: Sequence[str], ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Same as :meth:`contained_in`, but the response is set to lowercase before matching. @@ -759,7 +759,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, pattern: Union[Pattern[str], str], ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response matches the specified regex pattern. diff --git a/redbot/logging.py b/redbot/logging.py index 9ae895723..694d91e4c 100644 --- a/redbot/logging.py +++ b/redbot/logging.py @@ -58,7 +58,7 @@ class RotatingFileHandler(logging.handlers.RotatingFileHandler): self.baseStem = stem self.directory = directory.resolve() # Scan for existing files in directory, append to last part of existing log - log_part_re = re.compile(rf"{stem}-part(?P\d+).log") + log_part_re = re.compile(rf"{stem}-part(?P\d)\.log") highest_part = 0 for path in directory.iterdir(): match = log_part_re.match(path.name) @@ -86,7 +86,7 @@ class RotatingFileHandler(logging.handlers.RotatingFileHandler): initial_path.replace(self.directory / f"{self.baseStem}-part1.log") match = re.match( - rf"{self.baseStem}(?:-part(?P\d+)?)?.log", pathlib.Path(self.baseFilename).name + rf"{self.baseStem}(?:-part(?P\d))?\.log", pathlib.Path(self.baseFilename).name ) latest_part_num = int(match.groupdict(default="1").get("part", "1")) if self.backupCount < 1: @@ -95,7 +95,7 @@ class RotatingFileHandler(logging.handlers.RotatingFileHandler): elif latest_part_num > self.backupCount: # Rotate files down one # red-part2.log becomes red-part1.log etc, a new log is added at the end. - for i in range(1, self.backupCount): + for i in range(1, self.backupCount + 1): next_log = self.directory / f"{self.baseStem}-part{i + 1}.log" if next_log.exists(): prev_log = self.directory / f"{self.baseStem}-part{i}.log"