Merge branch 'V3/release/3.0.0' into V3/develop

# Conflicts:
#	redbot/cogs/audio/audio.py
This commit is contained in:
Toby Harradine 2018-12-21 13:37:32 +11:00
commit bdcb69ad37
22 changed files with 422 additions and 204 deletions

View File

@ -1285,6 +1285,9 @@ class Audio(commands.Cog):
url_check = self._url_check(track["info"]["uri"]) url_check = self._url_check(track["info"]["uri"])
if not url_check: if not url_check:
continue 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)) player.add(author_obj, lavalink.rest_api.Track(data=track))
track_count = track_count + 1 track_count = track_count + 1
embed = discord.Embed( embed = discord.Embed(
@ -2005,6 +2008,7 @@ class Audio(commands.Cog):
async def seek(self, ctx, seconds: int = 30): async def seek(self, ctx, seconds: int = 30):
"""Seek ahead or behind on a track by seconds.""" """Seek ahead or behind on a track by seconds."""
dj_enabled = await self.config.guild(ctx.guild).dj_enabled() 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): if not self._player_check(ctx):
return await self._embed_msg(ctx, _("Nothing playing.")) return await self._embed_msg(ctx, _("Nothing playing."))
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
@ -2017,6 +2021,13 @@ class Audio(commands.Cog):
ctx, ctx.author ctx, ctx.author
): ):
return await self._embed_msg(ctx, _("You need the DJ role to use seek.")) 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:
if player.current.is_stream: if player.current.is_stream:
return await self._embed_msg(ctx, _("Can't seek on a stream.")) return await self._embed_msg(ctx, _("Can't seek on a stream."))

View File

@ -71,13 +71,19 @@ async def get_java_version(loop) -> _JavaVersion:
# ... version "MAJOR.MINOR.PATCH[_BUILD]" ... # ... version "MAJOR.MINOR.PATCH[_BUILD]" ...
# ... # ...
# We only care about the major and minor parts though. # We only care about the major and minor parts though.
version_line_re = re.compile(r'version "(?P<major>\d+).(?P<minor>\d+).\d+(?:_\d+)?"') version_line_re = re.compile(
r'version "(?P<major>\d+).(?P<minor>\d+).\d+(?:_\d+)?(?:-[A-Za-z0-9]+)?"'
)
short_version_re = re.compile(r'version "(?P<major>\d+)"')
lines = version_info.splitlines() lines = version_info.splitlines()
for line in lines: for line in lines:
match = version_line_re.search(line) match = version_line_re.search(line)
short_match = short_version_re.search(line)
if match: if match:
return int(match["major"]), int(match["minor"]) return int(match["major"]), int(match["minor"])
elif short_match:
return int(short_match["major"]), 0
raise RuntimeError( raise RuntimeError(
"The output of `java -version` was unexpected. Please report this issue on Red's " "The output of `java -version` was unexpected. Please report this issue on Red's "

View File

@ -133,8 +133,6 @@ class Cleanup(commands.Cog):
def check(m): def check(m):
if text in m.content: if text in m.content:
return True return True
elif m == ctx.message:
return True
else: else:
return False return False
@ -145,6 +143,7 @@ class Cleanup(commands.Cog):
before=ctx.message, before=ctx.message,
delete_pinned=delete_pinned, delete_pinned=delete_pinned,
) )
to_delete.append(ctx.message)
reason = "{}({}) deleted {} messages containing '{}' in channel {}.".format( reason = "{}({}) deleted {} messages containing '{}' in channel {}.".format(
author.name, author.id, len(to_delete), text, channel.id author.name, author.id, len(to_delete), text, channel.id
@ -188,8 +187,6 @@ class Cleanup(commands.Cog):
def check(m): def check(m):
if m.author.id == _id: if m.author.id == _id:
return True return True
elif m == ctx.message:
return True
else: else:
return False return False
@ -200,6 +197,8 @@ class Cleanup(commands.Cog):
before=ctx.message, before=ctx.message,
delete_pinned=delete_pinned, delete_pinned=delete_pinned,
) )
to_delete.append(ctx.message)
reason = ( reason = (
"{}({}) deleted {} messages " "{}({}) deleted {} messages "
" made by {}({}) in channel {}." " made by {}({}) in channel {}."
@ -231,6 +230,7 @@ class Cleanup(commands.Cog):
to_delete = await self.get_messages_for_deletion( to_delete = await self.get_messages_for_deletion(
channel=channel, number=None, after=after, delete_pinned=delete_pinned channel=channel, number=None, after=after, delete_pinned=delete_pinned
) )
to_delete.append(ctx.message)
reason = "{}({}) deleted {} messages in channel {}.".format( reason = "{}({}) deleted {} messages in channel {}.".format(
author.name, author.id, len(to_delete), channel.name 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( to_delete = await self.get_messages_for_deletion(
channel=channel, number=number, before=before, delete_pinned=delete_pinned channel=channel, number=number, before=before, delete_pinned=delete_pinned
) )
to_delete.append(ctx.message)
reason = "{}({}) deleted {} messages in channel {}.".format( reason = "{}({}) deleted {} messages in channel {}.".format(
author.name, author.id, len(to_delete), channel.name author.name, author.id, len(to_delete), channel.name

View File

@ -502,7 +502,7 @@ class Downloader(commands.Cog):
if isinstance(cog_installable, Installable): if isinstance(cog_installable, Installable):
made_by = ", ".join(cog_installable.author) or _("Missing from info.json") made_by = ", ".join(cog_installable.author) or _("Missing from info.json")
repo = self._repo_manager.get_repo(cog_installable.repo_name) 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 cog_name = cog_installable.name
else: else:
made_by = "26 & co." made_by = "26 & co."

View File

@ -388,7 +388,7 @@ class Economy(commands.Cog):
@guild_only_check() @guild_only_check()
async def payouts(self, ctx: commands.Context): async def payouts(self, ctx: commands.Context):
"""Show the payouts for the slot machine.""" """Show the payouts for the slot machine."""
await ctx.author.send(SLOT_PAYOUTS_MSG()) await ctx.author.send(SLOT_PAYOUTS_MSG)
@commands.command() @commands.command()
@guild_only_check() @guild_only_check()

View File

@ -28,7 +28,7 @@ class RPSParser:
elif argument == "scissors": elif argument == "scissors":
self.choice = RPS.scissors self.choice = RPS.scissors
else: else:
raise ValueError self.choice = None
@cog_i18n(_) @cog_i18n(_)
@ -121,6 +121,8 @@ class General(commands.Cog):
"""Play Rock Paper Scissors.""" """Play Rock Paper Scissors."""
author = ctx.author author = ctx.author
player_choice = your_choice.choice 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)) red_choice = choice((RPS.rock, RPS.paper, RPS.scissors))
cond = { cond = {
(RPS.rock, RPS.paper): False, (RPS.rock, RPS.paper): False,
@ -263,12 +265,13 @@ class General(commands.Cog):
except aiohttp.ClientError: except aiohttp.ClientError:
await ctx.send( 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 return
if data.get("error") != 404: if data.get("error") != 404:
if not data["list"]:
return await ctx.send(_("No Urban Dictionary entries were found."))
if await ctx.embed_requested(): if await ctx.embed_requested():
# a list of embeds # a list of embeds
embeds = [] embeds = []
@ -303,14 +306,14 @@ class General(commands.Cog):
else: else:
messages = [] messages = []
for ud in data["list"]: for ud in data["list"]:
ud.set_default("example", "N/A") ud.setdefault("example", "N/A")
description = _("{definition}\n\n**Example:** {example}").format(**ud) description = _("{definition}\n\n**Example:** {example}").format(**ud)
if len(description) > 2048: if len(description) > 2048:
description = "{}...".format(description[:2045]) description = "{}...".format(description[:2045])
message = _( message = _(
"<{permalink}>\n {word} by {author}\n\n{description}\n\n" "<{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) ).format(word=ud.pop("word").capitalize(), description=description, **ud)
messages.append(message) messages.append(message)
@ -325,6 +328,5 @@ class General(commands.Cog):
) )
else: else:
await ctx.send( 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

View File

@ -311,13 +311,15 @@ class Mod(commands.Cog):
if not cur_setting: if not cur_setting:
await self.settings.guild(guild).reinvite_on_unban.set(True) await self.settings.guild(guild).reinvite_on_unban.set(True)
await ctx.send( 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: else:
await self.settings.guild(guild).reinvite_on_unban.set(False) await self.settings.guild(guild).reinvite_on_unban.set(False)
await ctx.send( await ctx.send(
_("Users unbanned with {command} will not be reinvited.").format( _("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.guild_only()
@commands.bot_has_permissions(manage_nicknames=True) @commands.bot_has_permissions(manage_nicknames=True)
@checks.admin_or_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. """Change a user's nickname.
Leaving the nickname empty will remove it. Leaving the nickname empty will remove it.
""" """
nickname = nickname.strip() nickname = nickname.strip()
if nickname == "": me = cast(discord.Member, ctx.me)
if not nickname:
nickname = None nickname = None
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) await user.edit(reason=get_audit_reason(ctx.author, None), nick=nickname)
await ctx.send("Done.") 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.group()
@commands.guild_only() @commands.guild_only()
@checks.mod_or_permissions(manage_channel=True) @checks.mod_or_permissions(manage_channels=True)
async def mute(self, ctx: commands.Context): async def mute(self, ctx: commands.Context):
"""Mute users.""" """Mute users."""
pass pass
@ -1033,7 +1061,7 @@ class Mod(commands.Cog):
@commands.group() @commands.group()
@commands.guild_only() @commands.guild_only()
@commands.bot_has_permissions(manage_roles=True) @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): async def unmute(self, ctx: commands.Context):
"""Unmute users.""" """Unmute users."""
pass pass
@ -1306,8 +1334,8 @@ class Mod(commands.Cog):
user = author user = author
# A special case for a special someone :^) # A special case for a special someone :^)
special_date = datetime(2016, 1, 10, 6, 8, 4, 443000) special_date = datetime(2016, 1, 10, 6, 8, 4, 443_000)
is_special = user.id == 96130341705637888 and guild.id == 133049272517001216 is_special = user.id == 96_130_341_705_637_888 and guild.id == 133_049_272_517_001_216
roles = sorted(user.roles)[1:] roles = sorted(user.roles)[1:]
names, nicks = await self.get_names_and_nicks(user) names, nicks = await self.get_names_and_nicks(user)
@ -1567,8 +1595,9 @@ class Mod(commands.Cog):
""" """
An event for modlog case creation An event for modlog case creation
""" """
try:
mod_channel = await modlog.get_modlog_channel(case.guild) mod_channel = await modlog.get_modlog_channel(case.guild)
if mod_channel is None: except RuntimeError:
return return
use_embeds = await case.bot.embed_requested(mod_channel, case.guild.me) use_embeds = await case.bot.embed_requested(mod_channel, case.guild.me)
case_content = await case.message_content(use_embeds) case_content = await case.message_content(use_embeds)

View File

@ -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 import commands
from redbot.core.i18n import Translator from redbot.core.i18n import Translator
_ = Translator("PermissionsConverters", __file__) _ = Translator("PermissionsConverters", __file__)
MENTION_RE = re.compile(r"^<?(?:(?:@[!&]?)?|#)(\d{15,21})>?$")
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): class CogOrCommand(NamedTuple):
type: str type: str

View File

@ -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.menus import start_adding_reactions
from redbot.core.utils.predicates import ReactionPredicate, MessagePredicate 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__) _ = Translator("Permissions", __file__)
@ -142,23 +148,20 @@ class Permissions(commands.Cog):
if not command: if not command:
return await ctx.send_help() return await ctx.send_help()
message = copy(ctx.message) fake_message = copy(ctx.message)
message.author = user fake_message.author = user
message.content = "{}{}".format(ctx.prefix, command) fake_message.content = "{}{}".format(ctx.prefix, command)
com = ctx.bot.get_command(command) com = ctx.bot.get_command(command)
if com is None: if com is None:
out = _("No such command") out = _("No such command")
else: else:
fake_context = await ctx.bot.get_context(fake_message)
try: try:
testcontext = await ctx.bot.get_context(message, cls=commands.Context) can = await com.can_run(
to_check = [*reversed(com.parents)] + [com] fake_context, check_all_parents=True, change_permission_state=False
can = False )
for cmd in to_check: except commands.CommandError:
can = await cmd.can_run(testcontext)
if can is False:
break
except commands.CheckFailure:
can = False can = False
out = ( out = (
@ -275,7 +278,7 @@ class Permissions(commands.Cog):
ctx: commands.Context, ctx: commands.Context,
allow_or_deny: RuleType, allow_or_deny: RuleType,
cog_or_command: CogOrCommand, cog_or_command: CogOrCommand,
who_or_what: commands.GlobalPermissionModel, who_or_what: GlobalUniqueObjectFinder,
): ):
"""Add a global rule to a command. """Add a global rule to a command.
@ -303,7 +306,7 @@ class Permissions(commands.Cog):
ctx: commands.Context, ctx: commands.Context,
allow_or_deny: RuleType, allow_or_deny: RuleType,
cog_or_command: CogOrCommand, cog_or_command: CogOrCommand,
who_or_what: commands.GuildPermissionModel, who_or_what: GuildUniqueObjectFinder,
): ):
"""Add a rule to a command in this server. """Add a rule to a command in this server.
@ -328,7 +331,7 @@ class Permissions(commands.Cog):
self, self,
ctx: commands.Context, ctx: commands.Context,
cog_or_command: CogOrCommand, cog_or_command: CogOrCommand,
who_or_what: commands.GlobalPermissionModel, who_or_what: GlobalUniqueObjectFinder,
): ):
"""Remove a global rule from a command. """Remove a global rule from a command.
@ -351,7 +354,7 @@ class Permissions(commands.Cog):
ctx: commands.Context, ctx: commands.Context,
cog_or_command: CogOrCommand, cog_or_command: CogOrCommand,
*, *,
who_or_what: commands.GuildPermissionModel, who_or_what: GuildUniqueObjectFinder,
): ):
"""Remove a server rule from a command. """Remove a server rule from a command.

View File

@ -316,7 +316,7 @@ class Reports(commands.Cog):
self.tunnel_store[k]["msgs"] = msgs self.tunnel_store[k]["msgs"] = msgs
@commands.guild_only() @commands.guild_only()
@checks.mod_or_permissions(manage_members=True) @checks.mod_or_permissions(manage_roles=True)
@report.command(name="interact") @report.command(name="interact")
async def response(self, ctx, ticket_number: int): async def response(self, ctx, ticket_number: int):
"""Open a message tunnel. """Open a message tunnel.

View File

@ -28,7 +28,7 @@ from . import streamtypes as _streamtypes
from collections import defaultdict from collections import defaultdict
import asyncio import asyncio
import re import re
from typing import Optional, List from typing import Optional, List, Tuple
CHECK_DELAY = 60 CHECK_DELAY = 60
@ -320,6 +320,7 @@ class Streams(commands.Cog):
@commands.group() @commands.group()
@checks.mod() @checks.mod()
async def streamset(self, ctx: commands.Context): async def streamset(self, ctx: commands.Context):
"""Set tokens for accessing streams."""
pass pass
@streamset.command() @streamset.command()
@ -396,9 +397,6 @@ class Streams(commands.Cog):
async def role(self, ctx: commands.Context, *, role: discord.Role): async def role(self, ctx: commands.Context, *, role: discord.Role):
"""Toggle a role mention.""" """Toggle a role mention."""
current_setting = await self.db.role(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: if current_setting:
await self.db.role(role).mention.set(False) await self.db.role(role).mention.set(False)
await ctx.send( await ctx.send(
@ -408,11 +406,17 @@ class Streams(commands.Cog):
) )
else: else:
await self.db.role(role).mention.set(True) await self.db.role(role).mention.set(True)
await ctx.send( msg = _(
_(
"When a stream or community is live, `@\u200b{role.name}` will be mentioned." "When a stream or community is live, `@\u200b{role.name}` will be mentioned."
).format(role=role) ).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() @streamset.command()
@commands.guild_only() @commands.guild_only()
@ -535,30 +539,46 @@ class Streams(commands.Cog):
continue continue
for channel_id in stream.channels: for channel_id in stream.channels:
channel = self.bot.get_channel(channel_id) 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: if mention_str:
content = _("{mention}, {stream.name} is live!").format( content = _("{mention}, {stream.name} is live!").format(
mention=mention_str, stream=stream mention=mention_str, stream=stream
) )
else: 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) m = await channel.send(content, embed=embed)
stream._messages_cache.append(m) stream._messages_cache.append(m)
if edited_roles:
for role in edited_roles:
await role.edit(mentionable=False)
await self.save_streams() 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) settings = self.db.guild(guild)
mentions = [] mentions = []
edited_roles = []
if await settings.mention_everyone(): if await settings.mention_everyone():
mentions.append("@everyone") mentions.append("@everyone")
if await settings.mention_here(): if await settings.mention_here():
mentions.append("@here") mentions.append("@here")
can_manage_roles = guild.me.guild_permissions.manage_roles
for role in guild.roles: for role in guild.roles:
if await self.db.role(role).mention(): 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) mentions.append(role.mention)
return " ".join(mentions) return " ".join(mentions), edited_roles
async def check_communities(self): async def check_communities(self):
for community in self.communities: for community in self.communities:
@ -589,12 +609,15 @@ class Streams(commands.Cog):
emb = await community.make_embed(streams) emb = await community.make_embed(streams)
chn_msg = [m for m in community._messages_cache if m.channel == chn] chn_msg = [m for m in community._messages_cache if m.channel == chn]
if not chn_msg: if not chn_msg:
mentions = await self._get_mention_str(chn.guild) mentions, roles = await self._get_mention_str(chn.guild)
if mentions: if mentions:
msg = await chn.send(mentions, embed=emb) msg = await chn.send(mentions, embed=emb)
else: else:
msg = await chn.send(embed=emb) msg = await chn.send(embed=emb)
community._messages_cache.append(msg) community._messages_cache.append(msg)
if roles:
for role in roles:
await role.edit(mentionable=False)
await self.save_communities() await self.save_communities()
else: else:
chn_msg = sorted(chn_msg, key=lambda x: x.created_at, reverse=True)[0] chn_msg = sorted(chn_msg, key=lambda x: x.created_at, reverse=True)[0]

View File

@ -114,7 +114,7 @@ class TriviaSession:
async with self.ctx.typing(): async with self.ctx.typing():
await asyncio.sleep(3) await asyncio.sleep(3)
self.count += 1 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) await self.ctx.send(msg)
continue_ = await self.wait_for_answer(answers, delay, timeout) continue_ = await self.wait_for_answer(answers, delay, timeout)
if continue_ is False: if continue_ is False:

View File

@ -111,16 +111,14 @@ class Trivia(commands.Cog):
await settings.allow_override.set(enabled) await settings.allow_override.set(enabled)
if enabled: if enabled:
await ctx.send( await ctx.send(
_( _("Done. Trivia lists can now override the trivia settings for this server.")
"Done. Trivia lists can now override the trivia settings for this server."
).format(now=enabled)
) )
else: else:
await ctx.send( await ctx.send(
_( _(
"Done. Trivia lists can no longer override the trivia settings for this " "Done. Trivia lists can no longer override the trivia settings for this "
"server." "server."
).format(now=enabled) )
) )
@triviaset.command(name="botplays", usage="<true_or_false>") @triviaset.command(name="botplays", usage="<true_or_false>")
@ -506,7 +504,7 @@ class Trivia(commands.Cog):
with path.open(encoding="utf-8") as file: with path.open(encoding="utf-8") as file:
try: try:
dict_ = yaml.load(file) dict_ = yaml.safe_load(file)
except yaml.error.YAMLError as exc: except yaml.error.YAMLError as exc:
raise InvalidListError("YAML parsing failed.") from exc raise InvalidListError("YAML parsing failed.") from exc
else: else:

View File

@ -186,13 +186,23 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
async def is_admin(self, member: discord.Member): async def is_admin(self, member: discord.Member):
"""Checks if a member is an admin of their guild.""" """Checks if a member is an admin of their guild."""
admin_role = await self.db.guild(member.guild).admin_role() 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): async def is_mod(self, member: discord.Member):
"""Checks if a member is a mod or admin of their guild.""" """Checks if a member is a mod or admin of their guild."""
mod_role = await self.db.guild(member.guild).mod_role() mod_role = await self.db.guild(member.guild).mod_role()
admin_role = await self.db.guild(member.guild).admin_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): async def get_context(self, message, *, cls=commands.Context):
return await super().get_context(message, cls=cls) return await super().get_context(message, cls=cls)
@ -334,7 +344,13 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
ids_to_check = [to_check.id] ids_to_check = [to_check.id]
else: else:
author = getattr(to_check, "author", to_check) author = getattr(to_check, "author", to_check)
try:
ids_to_check = [r.id for r in author.roles] 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) ids_to_check.append(author.id)
immune_ids = await self.db.guild(guild).autoimmune_ids() immune_ids = await self.db.guild(guild).autoimmune_ids()

View File

@ -157,12 +157,31 @@ class Command(CogCommandMixin, commands.Command):
cmd = cmd.parent cmd = cmd.parent
return sorted(entries, key=lambda x: len(x.qualified_name), reverse=True) 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. """Check if this command can be run in the given context.
This function first checks if the command can be run using This function first checks if the command can be run using
discord.py's method `discord.ext.commands.Command.can_run`, discord.py's method `discord.ext.commands.Command.can_run`,
then will return the result of `Requires.verify`. 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) ret = await super().can_run(ctx)
if ret is False: 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 is so contexts invoking other commands can be checked with
# this command as well # this command as well
original_command = ctx.command original_command = ctx.command
original_state = ctx.permission_state
ctx.command = self 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: if self.parent is None and self.instance is not None:
# For top-level commands, we need to check the cog's requires too # For top-level commands, we need to check the cog's requires too
ret = await self.instance.requires.verify(ctx) ret = await self.instance.requires.verify(ctx)
@ -183,6 +215,17 @@ class Command(CogCommandMixin, commands.Command):
return await self.requires.verify(ctx) return await self.requires.verify(ctx)
finally: finally:
ctx.command = original_command 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( async def do_conversion(
self, ctx: "Context", converter, argument: str, param: inspect.Parameter self, ctx: "Context", converter, argument: str, param: inspect.Parameter
@ -238,7 +281,9 @@ class Command(CogCommandMixin, commands.Command):
if cmd.hidden: if cmd.hidden:
return False return False
try: 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: except commands.CheckFailure:
return False return False
else: else:

View File

@ -281,12 +281,14 @@ class Requires:
if isinstance(user_perms, dict): if isinstance(user_perms, dict):
self.user_perms: Optional[discord.Permissions] = discord.Permissions.none() self.user_perms: Optional[discord.Permissions] = discord.Permissions.none()
_validate_perms_dict(user_perms)
self.user_perms.update(**user_perms) self.user_perms.update(**user_perms)
else: else:
self.user_perms = user_perms self.user_perms = user_perms
if isinstance(bot_perms, dict): if isinstance(bot_perms, dict):
self.bot_perms: discord.Permissions = discord.Permissions.none() self.bot_perms: discord.Permissions = discord.Permissions.none()
_validate_perms_dict(bot_perms)
self.bot_perms.update(**bot_perms) self.bot_perms.update(**bot_perms)
else: else:
self.bot_perms = bot_perms self.bot_perms = bot_perms
@ -311,6 +313,7 @@ class Requires:
if user_perms is None: if user_perms is None:
func.requires.user_perms = None func.requires.user_perms = None
else: else:
_validate_perms_dict(user_perms)
func.requires.user_perms.update(**user_perms) func.requires.user_perms.update(**user_perms)
return func return func
@ -449,7 +452,20 @@ class Requires:
should_invoke = await self._verify_user(ctx) should_invoke = await self._verify_user(ctx)
elif isinstance(next_state, dict): elif isinstance(next_state, dict):
# NORMAL to PASSIVE_ALLOW; should we proceed as normal or transition? # 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 ctx.permission_state = next_state
return should_invoke return should_invoke
@ -588,6 +604,7 @@ def bot_has_permissions(**perms: bool):
if asyncio.iscoroutinefunction(func): if asyncio.iscoroutinefunction(func):
func.__requires_bot_perms__ = perms func.__requires_bot_perms__ = perms
else: else:
_validate_perms_dict(perms)
func.requires.bot_perms.update(**perms) func.requires.bot_perms.update(**perms)
return func return func
@ -599,6 +616,8 @@ def has_permissions(**perms: bool):
This check can be overridden by rules. 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) return Requires.get_decorator(None, perms)
@ -670,3 +689,20 @@ class _IntKeyDict(Dict[int, _T]):
if not isinstance(key, int): if not isinstance(key, int):
raise TypeError("Keys must be of type `int`") raise TypeError("Keys must be of type `int`")
return super().__setitem__(key, value) 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}")

View File

@ -468,7 +468,7 @@ class Core(commands.Cog, CoreLogic):
pred = MessagePredicate.yes_or_no(ctx) pred = MessagePredicate.yes_or_no(ctx)
try: try:
await self.bot.wait_for("message", check=MessagePredicate.yes_or_no(ctx)) await self.bot.wait_for("message", check=pred)
except asyncio.TimeoutError: except asyncio.TimeoutError:
await ctx.send("Response timed out.") await ctx.send("Response timed out.")
return return
@ -1729,7 +1729,7 @@ class Core(commands.Cog, CoreLogic):
await ctx.tick() await ctx.tick()
@commands.guild_only() @commands.guild_only()
@checks.guildowner_or_permissions(manage_server=True) @checks.guildowner_or_permissions(manage_guild=True)
@commands.group(name="autoimmune") @commands.group(name="autoimmune")
async def autoimmune_group(self, ctx: commands.Context): async def autoimmune_group(self, ctx: commands.Context):
""" """

View File

@ -1,15 +1,15 @@
import sys import inspect
import os
from pathlib import Path
from typing import List
from copy import deepcopy
import hashlib
import shutil
import logging import logging
import os
import sys
import tempfile
from copy import deepcopy
from pathlib import Path
import appdirs import appdirs
import tempfile from discord.utils import deprecated
from . import commands
from .json_io import JsonIO from .json_io import JsonIO
__all__ = [ __all__ = [
@ -153,124 +153,28 @@ def core_data_path() -> Path:
return core_path.resolve() return core_path.resolve()
def _find_data_files(init_location: str) -> (Path, List[Path]): # noinspection PyUnusedLocal
""" @deprecated("bundled_data_path() without calling this function")
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))
def load_bundled_data(cog_instance, init_location: str): 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 Get the path to the "data" directory bundled with this cog.
of the installed cog.
The bundled data folder must be located alongside the ``.py`` file
which contains the cog class.
.. important:: .. important::
This function MUST be called from the ``setup()`` function of your You should *NEVER* write to this directory.
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`.
Parameters Parameters
---------- ----------
cog_instance cog_instance
An instance of your cog. If calling from a command or method of
your cog, this should be ``self``.
Returns Returns
------- -------
@ -280,10 +184,10 @@ def bundled_data_path(cog_instance) -> Path:
Raises Raises
------ ------
FileNotFoundError 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(): if not bundled_path.is_dir():
raise FileNotFoundError("No such directory {}".format(bundled_path)) raise FileNotFoundError("No such directory {}".format(bundled_path))

View File

@ -23,6 +23,7 @@ discord.py 1.0.0a
This help formatter contains work by Rapptz (Danny) and SirThane#1780. This help formatter contains work by Rapptz (Danny) and SirThane#1780.
""" """
import contextlib
from collections import namedtuple from collections import namedtuple
from typing import List, Optional, Union from typing import List, Optional, Union
@ -224,8 +225,8 @@ class Help(dpy_formatter.HelpFormatter):
return ret return ret
async def format_help_for(self, ctx, command_or_bot, reason: str = None): async def format_help_for(self, ctx, command_or_bot, reason: str = ""):
"""Formats the help page and handles the actual heavy lifting of how ### WTF HAPPENED? """Formats the help page and handles the actual heavy lifting of how
the help command looks like. To change the behaviour, override the the help command looks like. To change the behaviour, override the
:meth:`~.HelpFormatter.format` method. :meth:`~.HelpFormatter.format` method.
@ -244,10 +245,24 @@ class Help(dpy_formatter.HelpFormatter):
""" """
self.context = ctx self.context = ctx
self.command = command_or_bot 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() emb = await self.format()
if reason: if reason:
emb["embed"]["title"] = "{0}".format(reason) emb["embed"]["title"] = reason
ret = [] ret = []

View File

@ -3,6 +3,7 @@ import json
import os import os
import asyncio import asyncio
import logging import logging
from copy import deepcopy
from uuid import uuid4 from uuid import uuid4
# This is basically our old DataIO and just a base for much more elaborate classes # 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): async def _threadsafe_save_json(self, data, settings=PRETTY):
loop = asyncio.get_event_loop() 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: async with self._lock:
await loop.run_in_executor(None, func) await loop.run_in_executor(None, func)

View File

@ -666,29 +666,30 @@ async def register_casetypes(new_types: List[dict]) -> List[CaseType]:
return type_list 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 Parameters
---------- ----------
guild: `discord.Guild` guild: `discord.Guild`
The guild to get the modlog channel for The guild to get the modlog channel for.
Returns Returns
------- -------
`discord.TextChannel` or `None` `discord.TextChannel`
The channel object representing the modlog channel The channel object representing the modlog channel.
Raises Raises
------ ------
RuntimeError RuntimeError
If the modlog channel is not found If the modlog channel is not found.
""" """
if hasattr(guild, "get_channel"): if hasattr(guild, "get_channel"):
channel = guild.get_channel(await _conf.guild(guild).mod_log()) channel = guild.get_channel(await _conf.guild(guild).mod_log())
else: else:
# For unit tests only
channel = await _conf.guild(guild).mod_log() channel = await _conf.guild(guild).mod_log()
if channel is None: if channel is None:
raise RuntimeError("Failed to get the mod log channel!") raise RuntimeError("Failed to get the mod log channel!")

View File

@ -10,7 +10,7 @@ def test_trivia_lists():
for l in list_names: for l in list_names:
with l.open() as f: with l.open() as f:
try: try:
dict_ = yaml.load(f) dict_ = yaml.safe_load(f)
except yaml.error.YAMLError as e: except yaml.error.YAMLError as e:
problem_lists.append((l.stem, "YAML error:\n{!s}".format(e))) problem_lists.append((l.stem, "YAML error:\n{!s}".format(e)))
else: else: