diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index 28d3c252d..d4e857ca9 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -1285,6 +1285,9 @@ class Audio(commands.Cog): url_check = self._url_check(track["info"]["uri"]) if not url_check: continue + if track["info"]["uri"].startswith("localtracks/"): + if not os.path.isfile(track["info"]["uri"]): + continue player.add(author_obj, lavalink.rest_api.Track(data=track)) track_count = track_count + 1 embed = discord.Embed( @@ -2005,6 +2008,7 @@ class Audio(commands.Cog): async def seek(self, ctx, seconds: int = 30): """Seek ahead or behind on a track by seconds.""" dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + vote_enabled = await self.config.guild(ctx.guild).vote_enabled() if not self._player_check(ctx): return await self._embed_msg(ctx, _("Nothing playing.")) player = lavalink.get_player(ctx.guild.id) @@ -2017,6 +2021,13 @@ class Audio(commands.Cog): ctx, ctx.author ): return await self._embed_msg(ctx, _("You need the DJ role to use seek.")) + if vote_enabled: + if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone( + ctx, ctx.author + ): + return await self._embed_msg( + ctx, _("There are other people listening - vote to skip instead.") + ) if player.current: if player.current.is_stream: return await self._embed_msg(ctx, _("Can't seek on a stream.")) diff --git a/redbot/cogs/audio/manager.py b/redbot/cogs/audio/manager.py index 8cc988eff..ae10bb3be 100644 --- a/redbot/cogs/audio/manager.py +++ b/redbot/cogs/audio/manager.py @@ -71,13 +71,19 @@ async def get_java_version(loop) -> _JavaVersion: # ... version "MAJOR.MINOR.PATCH[_BUILD]" ... # ... # We only care about the major and minor parts though. - version_line_re = re.compile(r'version "(?P\d+).(?P\d+).\d+(?:_\d+)?"') + version_line_re = re.compile( + r'version "(?P\d+).(?P\d+).\d+(?:_\d+)?(?:-[A-Za-z0-9]+)?"' + ) + short_version_re = re.compile(r'version "(?P\d+)"') lines = version_info.splitlines() for line in lines: match = version_line_re.search(line) + short_match = short_version_re.search(line) if match: return int(match["major"]), int(match["minor"]) + elif short_match: + return int(short_match["major"]), 0 raise RuntimeError( "The output of `java -version` was unexpected. Please report this issue on Red's " diff --git a/redbot/cogs/cleanup/cleanup.py b/redbot/cogs/cleanup/cleanup.py index 5b941f3c5..67fcaefa7 100644 --- a/redbot/cogs/cleanup/cleanup.py +++ b/redbot/cogs/cleanup/cleanup.py @@ -133,8 +133,6 @@ class Cleanup(commands.Cog): def check(m): if text in m.content: return True - elif m == ctx.message: - return True else: return False @@ -145,6 +143,7 @@ class Cleanup(commands.Cog): before=ctx.message, delete_pinned=delete_pinned, ) + to_delete.append(ctx.message) reason = "{}({}) deleted {} messages containing '{}' in channel {}.".format( author.name, author.id, len(to_delete), text, channel.id @@ -188,8 +187,6 @@ class Cleanup(commands.Cog): def check(m): if m.author.id == _id: return True - elif m == ctx.message: - return True else: return False @@ -200,6 +197,8 @@ class Cleanup(commands.Cog): before=ctx.message, delete_pinned=delete_pinned, ) + to_delete.append(ctx.message) + reason = ( "{}({}) deleted {} messages " " made by {}({}) in channel {}." @@ -231,6 +230,7 @@ class Cleanup(commands.Cog): to_delete = await self.get_messages_for_deletion( channel=channel, number=None, after=after, delete_pinned=delete_pinned ) + to_delete.append(ctx.message) reason = "{}({}) deleted {} messages in channel {}.".format( author.name, author.id, len(to_delete), channel.name @@ -263,6 +263,7 @@ class Cleanup(commands.Cog): to_delete = await self.get_messages_for_deletion( channel=channel, number=number, before=before, delete_pinned=delete_pinned ) + to_delete.append(ctx.message) reason = "{}({}) deleted {} messages in channel {}.".format( author.name, author.id, len(to_delete), channel.name diff --git a/redbot/cogs/downloader/downloader.py b/redbot/cogs/downloader/downloader.py index ce3df16ae..be63c59ae 100644 --- a/redbot/cogs/downloader/downloader.py +++ b/redbot/cogs/downloader/downloader.py @@ -502,7 +502,7 @@ class Downloader(commands.Cog): if isinstance(cog_installable, Installable): made_by = ", ".join(cog_installable.author) or _("Missing from info.json") repo = self._repo_manager.get_repo(cog_installable.repo_name) - repo_url = repo.url + repo_url = _("Missing from installed repos") if repo is None else repo.url cog_name = cog_installable.name else: made_by = "26 & co." diff --git a/redbot/cogs/economy/economy.py b/redbot/cogs/economy/economy.py index 9e32351d1..e3138d454 100644 --- a/redbot/cogs/economy/economy.py +++ b/redbot/cogs/economy/economy.py @@ -388,7 +388,7 @@ class Economy(commands.Cog): @guild_only_check() async def payouts(self, ctx: commands.Context): """Show the payouts for the slot machine.""" - await ctx.author.send(SLOT_PAYOUTS_MSG()) + await ctx.author.send(SLOT_PAYOUTS_MSG) @commands.command() @guild_only_check() diff --git a/redbot/cogs/general/general.py b/redbot/cogs/general/general.py index 605cb75d7..ec7a35ac6 100644 --- a/redbot/cogs/general/general.py +++ b/redbot/cogs/general/general.py @@ -28,7 +28,7 @@ class RPSParser: elif argument == "scissors": self.choice = RPS.scissors else: - raise ValueError + self.choice = None @cog_i18n(_) @@ -121,6 +121,8 @@ class General(commands.Cog): """Play Rock Paper Scissors.""" author = ctx.author player_choice = your_choice.choice + if not player_choice: + return await ctx.send("This isn't a valid option. Try rock, paper, or scissors.") red_choice = choice((RPS.rock, RPS.paper, RPS.scissors)) cond = { (RPS.rock, RPS.paper): False, @@ -263,12 +265,13 @@ class General(commands.Cog): except aiohttp.ClientError: await ctx.send( - _("No Urban dictionary entries were found, or there was an error in the process") + _("No Urban Dictionary entries were found, or there was an error in the process.") ) return if data.get("error") != 404: - + if not data["list"]: + return await ctx.send(_("No Urban Dictionary entries were found.")) if await ctx.embed_requested(): # a list of embeds embeds = [] @@ -303,14 +306,14 @@ class General(commands.Cog): else: messages = [] for ud in data["list"]: - ud.set_default("example", "N/A") + ud.setdefault("example", "N/A") description = _("{definition}\n\n**Example:** {example}").format(**ud) if len(description) > 2048: description = "{}...".format(description[:2045]) message = _( "<{permalink}>\n {word} by {author}\n\n{description}\n\n" - "{thumbs_down} Down / {thumbs_up} Up, Powered by urban dictionary" + "{thumbs_down} Down / {thumbs_up} Up, Powered by Urban Dictionary." ).format(word=ud.pop("word").capitalize(), description=description, **ud) messages.append(message) @@ -325,6 +328,5 @@ class General(commands.Cog): ) else: await ctx.send( - _("No Urban dictionary entries were found, or there was an error in the process.") + _("No Urban Dictionary entries were found, or there was an error in the process.") ) - return diff --git a/redbot/cogs/mod/mod.py b/redbot/cogs/mod/mod.py index df81c73df..592339098 100644 --- a/redbot/cogs/mod/mod.py +++ b/redbot/cogs/mod/mod.py @@ -311,13 +311,15 @@ class Mod(commands.Cog): if not cur_setting: await self.settings.guild(guild).reinvite_on_unban.set(True) await ctx.send( - _("Users unbanned with {command} will be reinvited.").format(f"{ctx.prefix}unban") + _("Users unbanned with {command} will be reinvited.").format( + command=f"{ctx.prefix}unban" + ) ) else: await self.settings.guild(guild).reinvite_on_unban.set(False) await ctx.send( _("Users unbanned with {command} will not be reinvited.").format( - f"{ctx.prefix}unban" + command=f"{ctx.prefix}unban" ) ) @@ -864,20 +866,46 @@ class Mod(commands.Cog): @commands.guild_only() @commands.bot_has_permissions(manage_nicknames=True) @checks.admin_or_permissions(manage_nicknames=True) - async def rename(self, ctx: commands.Context, user: discord.Member, *, nickname=""): + async def rename(self, ctx: commands.Context, user: discord.Member, *, nickname: str = ""): """Change a user's nickname. Leaving the nickname empty will remove it. """ nickname = nickname.strip() - if nickname == "": + me = cast(discord.Member, ctx.me) + if not nickname: nickname = None - await user.edit(reason=get_audit_reason(ctx.author, None), nick=nickname) - await ctx.send("Done.") + elif not 2 <= len(nickname) <= 32: + await ctx.send(_("Nicknames must be between 2 and 32 characters long.")) + return + if not ( + (me.guild_permissions.manage_nicknames or me.guild_permissions.administrator) + and me.top_role > user.top_role + and user != ctx.guild.owner + ): + await ctx.send( + _( + "I do not have permission to rename that member. They may be higher than or " + "equal to me in the role hierarchy." + ) + ) + else: + try: + await user.edit(reason=get_audit_reason(ctx.author, None), nick=nickname) + except discord.Forbidden: + # Just in case we missed something in the permissions check above + await ctx.send(_("I do not have permission to rename that member.")) + except discord.HTTPException as exc: + if exc.status == 400: # BAD REQUEST + await ctx.send(_("That nickname is invalid.")) + else: + await ctx.send(_("An unexpected error has occured.")) + else: + await ctx.send(_("Done.")) @commands.group() @commands.guild_only() - @checks.mod_or_permissions(manage_channel=True) + @checks.mod_or_permissions(manage_channels=True) async def mute(self, ctx: commands.Context): """Mute users.""" pass @@ -1033,7 +1061,7 @@ class Mod(commands.Cog): @commands.group() @commands.guild_only() @commands.bot_has_permissions(manage_roles=True) - @checks.mod_or_permissions(manage_channel=True) + @checks.mod_or_permissions(manage_channels=True) async def unmute(self, ctx: commands.Context): """Unmute users.""" pass @@ -1306,8 +1334,8 @@ class Mod(commands.Cog): user = author # A special case for a special someone :^) - special_date = datetime(2016, 1, 10, 6, 8, 4, 443000) - is_special = user.id == 96130341705637888 and guild.id == 133049272517001216 + special_date = datetime(2016, 1, 10, 6, 8, 4, 443_000) + is_special = user.id == 96_130_341_705_637_888 and guild.id == 133_049_272_517_001_216 roles = sorted(user.roles)[1:] names, nicks = await self.get_names_and_nicks(user) @@ -1567,8 +1595,9 @@ class Mod(commands.Cog): """ An event for modlog case creation """ - mod_channel = await modlog.get_modlog_channel(case.guild) - if mod_channel is None: + try: + mod_channel = await modlog.get_modlog_channel(case.guild) + except RuntimeError: return use_embeds = await case.bot.embed_requested(mod_channel, case.guild.me) case_content = await case.message_content(use_embeds) diff --git a/redbot/cogs/permissions/converters.py b/redbot/cogs/permissions/converters.py index 1b1668dc6..cbf1db76d 100644 --- a/redbot/cogs/permissions/converters.py +++ b/redbot/cogs/permissions/converters.py @@ -1,10 +1,133 @@ -from typing import NamedTuple, Union, Optional, cast, Type +import itertools +import re +from typing import NamedTuple, Union, Optional + +import discord from redbot.core import commands from redbot.core.i18n import Translator _ = Translator("PermissionsConverters", __file__) +MENTION_RE = re.compile(r"^?$") + + +def _match_id(arg: str) -> Optional[int]: + m = MENTION_RE.match(arg) + if m: + return int(m.group(1)) + + +class GlobalUniqueObjectFinder(commands.Converter): + async def convert( + self, ctx: commands.Context, arg: str + ) -> Union[discord.Guild, discord.abc.GuildChannel, discord.abc.User, discord.Role]: + bot: commands.Bot = ctx.bot + _id = _match_id(arg) + + if _id is not None: + guild: discord.Guild = bot.get_guild(_id) + if guild is not None: + return guild + channel: discord.abc.GuildChannel = bot.get_channel(_id) + if channel is not None: + return channel + + user: discord.User = bot.get_user(_id) + if user is not None: + return user + + for guild in bot.guilds: + role: discord.Role = guild.get_role(_id) + if role is not None: + return role + + objects = itertools.chain( + bot.get_all_channels(), + bot.users, + bot.guilds, + *(filter(lambda r: not r.is_default(), guild.roles) for guild in bot.guilds), + ) + + maybe_matches = [] + for obj in objects: + if obj.name == arg or str(obj) == arg: + maybe_matches.append(obj) + + if ctx.guild is not None: + for member in ctx.guild.members: + if member.nick == arg and not any(obj.id == member.id for obj in maybe_matches): + maybe_matches.append(member) + + if not maybe_matches: + raise commands.BadArgument( + _( + '"{arg}" was not found. It must be the ID, mention, or name of a server, ' + "channel, user or role which the bot can see." + ).format(arg=arg) + ) + elif len(maybe_matches) == 1: + return maybe_matches[0] + else: + raise commands.BadArgument( + _( + '"{arg}" does not refer to a unique server, channel, user or role. Please use ' + "the ID for whatever/whoever you're trying to specify, or mention it/them." + ).format(arg=arg) + ) + + +class GuildUniqueObjectFinder(commands.Converter): + async def convert( + self, ctx: commands.Context, arg: str + ) -> Union[discord.abc.GuildChannel, discord.Member, discord.Role]: + guild: discord.Guild = ctx.guild + _id = _match_id(arg) + + if _id is not None: + channel: discord.abc.GuildChannel = guild.get_channel(_id) + if channel is not None: + return channel + + member: discord.Member = guild.get_member(_id) + if member is not None: + return member + + role: discord.Role = guild.get_role(_id) + if role is not None and not role.is_default(): + return role + + objects = itertools.chain( + guild.channels, guild.members, filter(lambda r: not r.is_default(), guild.roles) + ) + + maybe_matches = [] + for obj in objects: + if obj.name == arg or str(obj) == arg: + maybe_matches.append(obj) + try: + if obj.nick == arg: + maybe_matches.append(obj) + except AttributeError: + pass + + if not maybe_matches: + raise commands.BadArgument( + _( + '"{arg}" was not found. It must be the ID, mention, or name of a channel, ' + "user or role in this server." + ).format(arg=arg) + ) + elif len(maybe_matches) == 1: + return maybe_matches[0] + else: + raise commands.BadArgument( + _( + '"{arg}" does not refer to a unique channel, user or role. Please use the ID ' + "for whatever/whoever you're trying to specify, or mention it/them." + ).format(arg=arg) + ) + class CogOrCommand(NamedTuple): type: str diff --git a/redbot/cogs/permissions/permissions.py b/redbot/cogs/permissions/permissions.py index 00854d6f1..b1fd810fa 100644 --- a/redbot/cogs/permissions/permissions.py +++ b/redbot/cogs/permissions/permissions.py @@ -14,7 +14,13 @@ from redbot.core.utils.chat_formatting import box from redbot.core.utils.menus import start_adding_reactions from redbot.core.utils.predicates import ReactionPredicate, MessagePredicate -from .converters import CogOrCommand, RuleType, ClearableRuleType +from .converters import ( + CogOrCommand, + RuleType, + ClearableRuleType, + GuildUniqueObjectFinder, + GlobalUniqueObjectFinder, +) _ = Translator("Permissions", __file__) @@ -142,23 +148,20 @@ class Permissions(commands.Cog): if not command: return await ctx.send_help() - message = copy(ctx.message) - message.author = user - message.content = "{}{}".format(ctx.prefix, command) + fake_message = copy(ctx.message) + fake_message.author = user + fake_message.content = "{}{}".format(ctx.prefix, command) com = ctx.bot.get_command(command) if com is None: out = _("No such command") else: + fake_context = await ctx.bot.get_context(fake_message) try: - testcontext = await ctx.bot.get_context(message, cls=commands.Context) - to_check = [*reversed(com.parents)] + [com] - can = False - for cmd in to_check: - can = await cmd.can_run(testcontext) - if can is False: - break - except commands.CheckFailure: + can = await com.can_run( + fake_context, check_all_parents=True, change_permission_state=False + ) + except commands.CommandError: can = False out = ( @@ -275,7 +278,7 @@ class Permissions(commands.Cog): ctx: commands.Context, allow_or_deny: RuleType, cog_or_command: CogOrCommand, - who_or_what: commands.GlobalPermissionModel, + who_or_what: GlobalUniqueObjectFinder, ): """Add a global rule to a command. @@ -303,7 +306,7 @@ class Permissions(commands.Cog): ctx: commands.Context, allow_or_deny: RuleType, cog_or_command: CogOrCommand, - who_or_what: commands.GuildPermissionModel, + who_or_what: GuildUniqueObjectFinder, ): """Add a rule to a command in this server. @@ -328,7 +331,7 @@ class Permissions(commands.Cog): self, ctx: commands.Context, cog_or_command: CogOrCommand, - who_or_what: commands.GlobalPermissionModel, + who_or_what: GlobalUniqueObjectFinder, ): """Remove a global rule from a command. @@ -351,7 +354,7 @@ class Permissions(commands.Cog): ctx: commands.Context, cog_or_command: CogOrCommand, *, - who_or_what: commands.GuildPermissionModel, + who_or_what: GuildUniqueObjectFinder, ): """Remove a server rule from a command. diff --git a/redbot/cogs/reports/reports.py b/redbot/cogs/reports/reports.py index 61b000897..7a1d4bdc6 100644 --- a/redbot/cogs/reports/reports.py +++ b/redbot/cogs/reports/reports.py @@ -316,7 +316,7 @@ class Reports(commands.Cog): self.tunnel_store[k]["msgs"] = msgs @commands.guild_only() - @checks.mod_or_permissions(manage_members=True) + @checks.mod_or_permissions(manage_roles=True) @report.command(name="interact") async def response(self, ctx, ticket_number: int): """Open a message tunnel. diff --git a/redbot/cogs/streams/streams.py b/redbot/cogs/streams/streams.py index 9665b4755..548723ddb 100644 --- a/redbot/cogs/streams/streams.py +++ b/redbot/cogs/streams/streams.py @@ -28,7 +28,7 @@ from . import streamtypes as _streamtypes from collections import defaultdict import asyncio import re -from typing import Optional, List +from typing import Optional, List, Tuple CHECK_DELAY = 60 @@ -320,6 +320,7 @@ class Streams(commands.Cog): @commands.group() @checks.mod() async def streamset(self, ctx: commands.Context): + """Set tokens for accessing streams.""" pass @streamset.command() @@ -396,9 +397,6 @@ class Streams(commands.Cog): async def role(self, ctx: commands.Context, *, role: discord.Role): """Toggle a role mention.""" current_setting = await self.db.role(role).mention() - if not role.mentionable: - await ctx.send("That role is not mentionable!") - return if current_setting: await self.db.role(role).mention.set(False) await ctx.send( @@ -408,11 +406,17 @@ class Streams(commands.Cog): ) else: await self.db.role(role).mention.set(True) - await ctx.send( - _( - "When a stream or community is live, `@\u200b{role.name}` will be mentioned." - ).format(role=role) - ) + msg = _( + "When a stream or community is live, `@\u200b{role.name}` will be mentioned." + ).format(role=role) + if not role.mentionable: + msg += " " + _( + "Since the role is not mentionable, it will be momentarily made mentionable " + "when announcing a streamalert. Please make sure I have the correct " + "permissions to manage this role, or else members of this role won't receive " + "a notification." + ) + await ctx.send(msg) @streamset.command() @commands.guild_only() @@ -535,30 +539,46 @@ class Streams(commands.Cog): continue for channel_id in stream.channels: channel = self.bot.get_channel(channel_id) - mention_str = await self._get_mention_str(channel.guild) + mention_str, edited_roles = await self._get_mention_str(channel.guild) if mention_str: content = _("{mention}, {stream.name} is live!").format( mention=mention_str, stream=stream ) else: - content = _("{stream.name} is live!").format(stream=stream.name) + content = _("{stream.name} is live!").format(stream=stream) m = await channel.send(content, embed=embed) stream._messages_cache.append(m) + if edited_roles: + for role in edited_roles: + await role.edit(mentionable=False) await self.save_streams() - async def _get_mention_str(self, guild: discord.Guild): + async def _get_mention_str(self, guild: discord.Guild) -> Tuple[str, List[discord.Role]]: + """Returns a 2-tuple with the string containing the mentions, and a list of + all roles which need to have their `mentionable` property set back to False. + """ settings = self.db.guild(guild) mentions = [] + edited_roles = [] if await settings.mention_everyone(): mentions.append("@everyone") if await settings.mention_here(): mentions.append("@here") + can_manage_roles = guild.me.guild_permissions.manage_roles for role in guild.roles: if await self.db.role(role).mention(): + if can_manage_roles and not role.mentionable: + try: + await role.edit(mentionable=True) + except discord.Forbidden: + # Might still be unable to edit role based on hierarchy + pass + else: + edited_roles.append(role) mentions.append(role.mention) - return " ".join(mentions) + return " ".join(mentions), edited_roles async def check_communities(self): for community in self.communities: @@ -589,12 +609,15 @@ class Streams(commands.Cog): emb = await community.make_embed(streams) chn_msg = [m for m in community._messages_cache if m.channel == chn] if not chn_msg: - mentions = await self._get_mention_str(chn.guild) + mentions, roles = await self._get_mention_str(chn.guild) if mentions: msg = await chn.send(mentions, embed=emb) else: msg = await chn.send(embed=emb) community._messages_cache.append(msg) + if roles: + for role in roles: + await role.edit(mentionable=False) await self.save_communities() else: chn_msg = sorted(chn_msg, key=lambda x: x.created_at, reverse=True)[0] diff --git a/redbot/cogs/trivia/session.py b/redbot/cogs/trivia/session.py index 4cfcfd6a4..46274007f 100644 --- a/redbot/cogs/trivia/session.py +++ b/redbot/cogs/trivia/session.py @@ -114,7 +114,7 @@ class TriviaSession: async with self.ctx.typing(): await asyncio.sleep(3) self.count += 1 - msg = bold(_("**Question number {num}!").format(num=self.count)) + "\n\n" + question + msg = bold(_("Question number {num}!").format(num=self.count)) + "\n\n" + question await self.ctx.send(msg) continue_ = await self.wait_for_answer(answers, delay, timeout) if continue_ is False: diff --git a/redbot/cogs/trivia/trivia.py b/redbot/cogs/trivia/trivia.py index b04aa1141..5fe61ce6f 100644 --- a/redbot/cogs/trivia/trivia.py +++ b/redbot/cogs/trivia/trivia.py @@ -111,16 +111,14 @@ class Trivia(commands.Cog): await settings.allow_override.set(enabled) if enabled: await ctx.send( - _( - "Done. Trivia lists can now override the trivia settings for this server." - ).format(now=enabled) + _("Done. Trivia lists can now override the trivia settings for this server.") ) else: await ctx.send( _( "Done. Trivia lists can no longer override the trivia settings for this " "server." - ).format(now=enabled) + ) ) @triviaset.command(name="botplays", usage="") @@ -506,7 +504,7 @@ class Trivia(commands.Cog): with path.open(encoding="utf-8") as file: try: - dict_ = yaml.load(file) + dict_ = yaml.safe_load(file) except yaml.error.YAMLError as exc: raise InvalidListError("YAML parsing failed.") from exc else: diff --git a/redbot/core/bot.py b/redbot/core/bot.py index d09a87a5a..a26fd90f2 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -186,13 +186,23 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): async def is_admin(self, member: discord.Member): """Checks if a member is an admin of their guild.""" admin_role = await self.db.guild(member.guild).admin_role() - return any(role.id == admin_role for role in member.roles) + try: + if any(role.id == admin_role for role in member.roles): + return True + except AttributeError: # someone passed a webhook to this + pass + return False async def is_mod(self, member: discord.Member): """Checks if a member is a mod or admin of their guild.""" mod_role = await self.db.guild(member.guild).mod_role() admin_role = await self.db.guild(member.guild).admin_role() - return any(role.id in (mod_role, admin_role) for role in member.roles) + try: + if any(role.id in (mod_role, admin_role) for role in member.roles): + return True + except AttributeError: # someone passed a webhook to this + pass + return False async def get_context(self, message, *, cls=commands.Context): return await super().get_context(message, cls=cls) @@ -334,8 +344,14 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): ids_to_check = [to_check.id] else: author = getattr(to_check, "author", to_check) - ids_to_check = [r.id for r in author.roles] - ids_to_check.append(author.id) + try: + ids_to_check = [r.id for r in author.roles] + except AttributeError: + # webhook messages are a user not member, + # cheaper than isinstance + return True # webhooks require significant permissions to enable. + else: + ids_to_check.append(author.id) immune_ids = await self.db.guild(guild).autoimmune_ids() diff --git a/redbot/core/commands/commands.py b/redbot/core/commands/commands.py index 448f570cc..0c0a77323 100644 --- a/redbot/core/commands/commands.py +++ b/redbot/core/commands/commands.py @@ -157,12 +157,31 @@ class Command(CogCommandMixin, commands.Command): cmd = cmd.parent return sorted(entries, key=lambda x: len(x.qualified_name), reverse=True) - async def can_run(self, ctx: "Context") -> bool: + # noinspection PyMethodOverriding + async def can_run( + self, + ctx: "Context", + *, + check_all_parents: bool = False, + change_permission_state: bool = False, + ) -> bool: """Check if this command can be run in the given context. This function first checks if the command can be run using discord.py's method `discord.ext.commands.Command.can_run`, then will return the result of `Requires.verify`. + + Keyword Arguments + ----------------- + check_all_parents : bool + If ``True``, this will check permissions for all of this + command's parents and its cog as well as the command + itself. Defaults to ``False``. + change_permission_state : bool + Whether or not the permission state should be changed as + a result of this call. For most cases this should be + ``False``. Defaults to ``False``. + """ ret = await super().can_run(ctx) if ret is False: @@ -171,8 +190,21 @@ class Command(CogCommandMixin, commands.Command): # This is so contexts invoking other commands can be checked with # this command as well original_command = ctx.command + original_state = ctx.permission_state ctx.command = self + if check_all_parents is True: + # Since we're starting from the beginning, we should reset the state to normal + ctx.permission_state = PermState.NORMAL + for parent in reversed(self.parents): + try: + result = await parent.can_run(ctx, change_permission_state=True) + except commands.CommandError: + result = False + + if result is False: + return False + if self.parent is None and self.instance is not None: # For top-level commands, we need to check the cog's requires too ret = await self.instance.requires.verify(ctx) @@ -183,6 +215,17 @@ class Command(CogCommandMixin, commands.Command): return await self.requires.verify(ctx) finally: ctx.command = original_command + if not change_permission_state: + ctx.permission_state = original_state + + async def _verify_checks(self, ctx): + if not self.enabled: + raise commands.DisabledCommand(f"{self.name} command is disabled") + + if not (await self.can_run(ctx, change_permission_state=True)): + raise commands.CheckFailure( + f"The check functions for command {self.qualified_name} failed." + ) async def do_conversion( self, ctx: "Context", converter, argument: str, param: inspect.Parameter @@ -238,7 +281,9 @@ class Command(CogCommandMixin, commands.Command): if cmd.hidden: return False try: - can_run = await self.can_run(ctx) + can_run = await self.can_run( + ctx, check_all_parents=True, change_permission_state=False + ) except commands.CheckFailure: return False else: diff --git a/redbot/core/commands/requires.py b/redbot/core/commands/requires.py index 1cb293be4..2b98d69c7 100644 --- a/redbot/core/commands/requires.py +++ b/redbot/core/commands/requires.py @@ -281,12 +281,14 @@ class Requires: if isinstance(user_perms, dict): self.user_perms: Optional[discord.Permissions] = discord.Permissions.none() + _validate_perms_dict(user_perms) self.user_perms.update(**user_perms) else: self.user_perms = user_perms if isinstance(bot_perms, dict): self.bot_perms: discord.Permissions = discord.Permissions.none() + _validate_perms_dict(bot_perms) self.bot_perms.update(**bot_perms) else: self.bot_perms = bot_perms @@ -311,6 +313,7 @@ class Requires: if user_perms is None: func.requires.user_perms = None else: + _validate_perms_dict(user_perms) func.requires.user_perms.update(**user_perms) return func @@ -449,7 +452,20 @@ class Requires: should_invoke = await self._verify_user(ctx) elif isinstance(next_state, dict): # NORMAL to PASSIVE_ALLOW; should we proceed as normal or transition? - next_state = next_state[await self._verify_user(ctx)] + # We must check what would happen normally, if no explicit rules were set. + default_rule = PermState.NORMAL + if ctx.guild is not None: + default_rule = self.get_default_guild_rule(guild_id=ctx.guild.id) + if default_rule is PermState.NORMAL: + default_rule = self.default_global_rule + + if default_rule == PermState.ACTIVE_DENY: + would_invoke = False + elif default_rule == PermState.ACTIVE_ALLOW: + would_invoke = True + else: + would_invoke = await self._verify_user(ctx) + next_state = next_state[would_invoke] ctx.permission_state = next_state return should_invoke @@ -588,6 +604,7 @@ def bot_has_permissions(**perms: bool): if asyncio.iscoroutinefunction(func): func.__requires_bot_perms__ = perms else: + _validate_perms_dict(perms) func.requires.bot_perms.update(**perms) return func @@ -599,6 +616,8 @@ def has_permissions(**perms: bool): This check can be overridden by rules. """ + if perms is None: + raise TypeError("Must provide at least one keyword argument to has_permissions") return Requires.get_decorator(None, perms) @@ -670,3 +689,20 @@ class _IntKeyDict(Dict[int, _T]): if not isinstance(key, int): raise TypeError("Keys must be of type `int`") return super().__setitem__(key, value) + + +def _validate_perms_dict(perms: Dict[str, bool]) -> None: + for perm, value in perms.items(): + try: + attr = getattr(discord.Permissions, perm) + except AttributeError: + attr = None + + if attr is None or not isinstance(attr, property): + # We reject invalid permissions + raise TypeError(f"Unknown permission name '{perm}'") + + if value is not True: + # We reject any permission not specified as 'True', since this is the only value which + # makes practical sense. + raise TypeError(f"Permission {perm} may only be specified as 'True', not {value}") diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 125aaaa67..8341116f7 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -468,7 +468,7 @@ class Core(commands.Cog, CoreLogic): pred = MessagePredicate.yes_or_no(ctx) try: - await self.bot.wait_for("message", check=MessagePredicate.yes_or_no(ctx)) + await self.bot.wait_for("message", check=pred) except asyncio.TimeoutError: await ctx.send("Response timed out.") return @@ -1729,7 +1729,7 @@ class Core(commands.Cog, CoreLogic): await ctx.tick() @commands.guild_only() - @checks.guildowner_or_permissions(manage_server=True) + @checks.guildowner_or_permissions(manage_guild=True) @commands.group(name="autoimmune") async def autoimmune_group(self, ctx: commands.Context): """ diff --git a/redbot/core/data_manager.py b/redbot/core/data_manager.py index f21a79a66..3faf00b33 100644 --- a/redbot/core/data_manager.py +++ b/redbot/core/data_manager.py @@ -1,15 +1,15 @@ -import sys -import os -from pathlib import Path -from typing import List -from copy import deepcopy -import hashlib -import shutil +import inspect import logging +import os +import sys +import tempfile +from copy import deepcopy +from pathlib import Path import appdirs -import tempfile +from discord.utils import deprecated +from . import commands from .json_io import JsonIO __all__ = [ @@ -153,124 +153,28 @@ def core_data_path() -> Path: return core_path.resolve() -def _find_data_files(init_location: str) -> (Path, List[Path]): - """ - Discovers all files in the bundled data folder of an installed cog. - - Parameters - ---------- - init_location - - Returns - ------- - (pathlib.Path, list of pathlib.Path) - """ - init_file = Path(init_location) - if not init_file.is_file(): - return [] - - package_folder = init_file.parent.resolve() / "data" - if not package_folder.is_dir(): - return [] - - all_files = list(package_folder.rglob("*")) - - return package_folder, [p.resolve() for p in all_files if p.is_file()] - - -def _compare_and_copy(to_copy: List[Path], bundled_data_dir: Path, cog_data_dir: Path): - """ - Filters out files from ``to_copy`` that already exist, and are the - same, in ``data_dir``. The files that are different are copied into - ``data_dir``. - - Parameters - ---------- - to_copy : list of pathlib.Path - bundled_data_dir : pathlib.Path - cog_data_dir : pathlib.Path - """ - - def hash_bytestr_iter(bytesiter, hasher, ashexstr=False): - for block in bytesiter: - hasher.update(block) - return hasher.hexdigest() if ashexstr else hasher.digest() - - def file_as_blockiter(afile, blocksize=65536): - with afile: - block = afile.read(blocksize) - while len(block) > 0: - yield block - block = afile.read(blocksize) - - lookup = {p: cog_data_dir.joinpath(p.relative_to(bundled_data_dir)) for p in to_copy} - - for orig, poss_existing in lookup.items(): - if not poss_existing.is_file(): - poss_existing.parent.mkdir(exist_ok=True, parents=True) - exists_checksum = None - else: - exists_checksum = hash_bytestr_iter( - file_as_blockiter(poss_existing.open("rb")), hashlib.sha256() - ) - - orig_checksum = ... - if exists_checksum is not None: - orig_checksum = hash_bytestr_iter(file_as_blockiter(orig.open("rb")), hashlib.sha256()) - - if exists_checksum != orig_checksum: - shutil.copy(str(orig), str(poss_existing)) - log.debug("Copying {} to {}".format(orig, poss_existing)) - - +# noinspection PyUnusedLocal +@deprecated("bundled_data_path() without calling this function") def load_bundled_data(cog_instance, init_location: str): + pass + + +def bundled_data_path(cog_instance: commands.Cog) -> Path: """ - This function copies (and overwrites) data from the ``data/`` folder - of the installed cog. + Get the path to the "data" directory bundled with this cog. + + The bundled data folder must be located alongside the ``.py`` file + which contains the cog class. .. important:: - This function MUST be called from the ``setup()`` function of your - cog. - - Examples - -------- - >>> from redbot.core import data_manager - >>> - >>> def setup(bot): - >>> cog = MyCog() - >>> data_manager.load_bundled_data(cog, __file__) - >>> bot.add_cog(cog) - - Parameters - ---------- - cog_instance - An instance of your cog class. - init_location : str - The ``__file__`` attribute of the file where your ``setup()`` - function exists. - """ - bundled_data_folder, to_copy = _find_data_files(init_location) - - cog_data_folder = cog_data_path(cog_instance) / "bundled_data" - - _compare_and_copy(to_copy, bundled_data_folder, cog_data_folder) - - -def bundled_data_path(cog_instance) -> Path: - """ - The "data" directory that has been copied from installed cogs. - - .. important:: - - You should *NEVER* write to this directory. Data manager will - overwrite files in this directory each time `load_bundled_data` - is called. You should instead write to the directory provided by - `cog_data_path`. + You should *NEVER* write to this directory. Parameters ---------- cog_instance + An instance of your cog. If calling from a command or method of + your cog, this should be ``self``. Returns ------- @@ -280,10 +184,10 @@ def bundled_data_path(cog_instance) -> Path: Raises ------ FileNotFoundError - If no bundled data folder exists or if it hasn't been loaded yet. - """ + If no bundled data folder exists. - bundled_path = cog_data_path(cog_instance) / "bundled_data" + """ + bundled_path = Path(inspect.getfile(cog_instance.__class__)).parent / "data" if not bundled_path.is_dir(): raise FileNotFoundError("No such directory {}".format(bundled_path)) diff --git a/redbot/core/help_formatter.py b/redbot/core/help_formatter.py index 0270196d4..81e42755e 100644 --- a/redbot/core/help_formatter.py +++ b/redbot/core/help_formatter.py @@ -23,6 +23,7 @@ discord.py 1.0.0a This help formatter contains work by Rapptz (Danny) and SirThane#1780. """ +import contextlib from collections import namedtuple from typing import List, Optional, Union @@ -224,8 +225,8 @@ class Help(dpy_formatter.HelpFormatter): return ret - async def format_help_for(self, ctx, command_or_bot, reason: str = None): - """Formats the help page and handles the actual heavy lifting of how ### WTF HAPPENED? + async def format_help_for(self, ctx, command_or_bot, reason: str = ""): + """Formats the help page and handles the actual heavy lifting of how the help command looks like. To change the behaviour, override the :meth:`~.HelpFormatter.format` method. @@ -244,10 +245,24 @@ class Help(dpy_formatter.HelpFormatter): """ self.context = ctx self.command = command_or_bot + + # We want the permission state to be set as if the author had run the command he is + # requesting help for. This is so the subcommands shown in the help menu correctly reflect + # any permission rules set. + if isinstance(self.command, commands.Command): + with contextlib.suppress(commands.CommandError): + await self.command.can_run( + self.context, check_all_parents=True, change_permission_state=True + ) + elif isinstance(self.command, commands.Cog): + with contextlib.suppress(commands.CommandError): + # Cog's don't have a `can_run` method, so we use the `Requires` object directly. + await self.command.requires.verify(self.context) + emb = await self.format() if reason: - emb["embed"]["title"] = "{0}".format(reason) + emb["embed"]["title"] = reason ret = [] diff --git a/redbot/core/json_io.py b/redbot/core/json_io.py index c610d7bb1..a2d01f73f 100644 --- a/redbot/core/json_io.py +++ b/redbot/core/json_io.py @@ -3,6 +3,7 @@ import json import os import asyncio import logging +from copy import deepcopy from uuid import uuid4 # This is basically our old DataIO and just a base for much more elaborate classes @@ -69,7 +70,11 @@ class JsonIO: async def _threadsafe_save_json(self, data, settings=PRETTY): loop = asyncio.get_event_loop() - func = functools.partial(self._save_json, data, settings) + # the deepcopy is needed here. otherwise, + # the dict can change during serialization + # and this will break the encoder. + data_copy = deepcopy(data) + func = functools.partial(self._save_json, data_copy, settings) async with self._lock: await loop.run_in_executor(None, func) diff --git a/redbot/core/modlog.py b/redbot/core/modlog.py index eafe4ec97..9cbb31ad8 100644 --- a/redbot/core/modlog.py +++ b/redbot/core/modlog.py @@ -666,29 +666,30 @@ async def register_casetypes(new_types: List[dict]) -> List[CaseType]: return type_list -async def get_modlog_channel(guild: discord.Guild) -> Union[discord.TextChannel, None]: +async def get_modlog_channel(guild: discord.Guild) -> discord.TextChannel: """ - Get the current modlog channel + Get the current modlog channel. Parameters ---------- guild: `discord.Guild` - The guild to get the modlog channel for + The guild to get the modlog channel for. Returns ------- - `discord.TextChannel` or `None` - The channel object representing the modlog channel + `discord.TextChannel` + The channel object representing the modlog channel. Raises ------ RuntimeError - If the modlog channel is not found + If the modlog channel is not found. """ if hasattr(guild, "get_channel"): channel = guild.get_channel(await _conf.guild(guild).mod_log()) else: + # For unit tests only channel = await _conf.guild(guild).mod_log() if channel is None: raise RuntimeError("Failed to get the mod log channel!") diff --git a/tests/cogs/test_trivia.py b/tests/cogs/test_trivia.py index 4d82b7947..528c74304 100644 --- a/tests/cogs/test_trivia.py +++ b/tests/cogs/test_trivia.py @@ -10,7 +10,7 @@ def test_trivia_lists(): for l in list_names: with l.open() as f: try: - dict_ = yaml.load(f) + dict_ = yaml.safe_load(f) except yaml.error.YAMLError as e: problem_lists.append((l.stem, "YAML error:\n{!s}".format(e))) else: