Merge branch 'V3/develop' into V3/feature/mutes

This commit is contained in:
Michael H
2020-01-17 20:25:45 -05:00
75 changed files with 2811 additions and 964 deletions

View File

@@ -116,12 +116,19 @@ class Admin(commands.Cog):
:param role:
:return:
"""
return ctx.author.top_role > role
return ctx.author.top_role > role or ctx.author == ctx.guild.owner
async def _addrole(self, ctx: commands.Context, member: discord.Member, role: discord.Role):
if member is None:
member = ctx.author
if not self.pass_user_hierarchy_check(ctx, role):
async def _addrole(
self, ctx: commands.Context, member: discord.Member, role: discord.Role, *, check_user=True
):
if role in member.roles:
await ctx.send(
_("{member.display_name} already has the role {role.name}.").format(
role=role, member=member
)
)
return
if check_user and not self.pass_user_hierarchy_check(ctx, role):
await ctx.send(_(USER_HIERARCHY_ISSUE_ADD).format(role=role, member=member))
return
if not self.pass_hierarchy_check(ctx, role):
@@ -141,10 +148,17 @@ class Admin(commands.Cog):
)
)
async def _removerole(self, ctx: commands.Context, member: discord.Member, role: discord.Role):
if member is None:
member = ctx.author
if not self.pass_user_hierarchy_check(ctx, role):
async def _removerole(
self, ctx: commands.Context, member: discord.Member, role: discord.Role, *, check_user=True
):
if role not in member.roles:
await ctx.send(
_("{member.display_name} does not have the role {role.name}.").format(
role=role, member=member
)
)
return
if check_user and not self.pass_user_hierarchy_check(ctx, role):
await ctx.send(_(USER_HIERARCHY_ISSUE_REMOVE).format(role=role, member=member))
return
if not self.pass_hierarchy_check(ctx, role):
@@ -365,7 +379,7 @@ class Admin(commands.Cog):
NOTE: The role is case sensitive!
"""
# noinspection PyTypeChecker
await self._addrole(ctx, ctx.author, selfrole)
await self._addrole(ctx, ctx.author, selfrole, check_user=False)
@selfrole.command(name="remove")
async def selfrole_remove(self, ctx: commands.Context, *, selfrole: SelfRole):
@@ -376,7 +390,7 @@ class Admin(commands.Cog):
NOTE: The role is case sensitive!
"""
# noinspection PyTypeChecker
await self._removerole(ctx, ctx.author, selfrole)
await self._removerole(ctx, ctx.author, selfrole, check_user=False)
@selfrole.command(name="list")
async def selfrole_list(self, ctx: commands.Context):
@@ -406,6 +420,13 @@ class Admin(commands.Cog):
NOTE: The role is case sensitive!
"""
if not self.pass_user_hierarchy_check(ctx, role):
await ctx.send(
_(
"I cannot let you add {role.name} as a selfrole because that role is higher than or equal to your highest role in the Discord hierarchy."
).format(role=role)
)
return
async with self.conf.guild(ctx.guild).selfroles() as curr_selfroles:
if role.id not in curr_selfroles:
curr_selfroles.append(role.id)
@@ -421,6 +442,13 @@ class Admin(commands.Cog):
NOTE: The role is case sensitive!
"""
if not self.pass_user_hierarchy_check(ctx, role):
await ctx.send(
_(
"I cannot let you remove {role.name} from being a selfrole because that role is higher than or equal to your highest role in the Discord hierarchy."
).format(role=role)
)
return
async with self.conf.guild(ctx.guild).selfroles() as curr_selfroles:
curr_selfroles.remove(role.id)

View File

@@ -70,12 +70,12 @@ class Announcer:
failed.append(str(g.id))
await asyncio.sleep(0.5)
msg = (
_("I could not announce to the following server: ")
if len(failed) == 1
else _("I could not announce to the following servers: ")
)
if failed:
msg = (
_("I could not announce to the following server: ")
if len(failed) == 1
else _("I could not announce to the following servers: ")
)
msg += humanize_list(tuple(map(inline, failed)))
await self.ctx.bot.send_to_owners(msg)
await self.ctx.bot.send_to_owners(msg)
self.active = False

View File

@@ -90,7 +90,7 @@ class Alias(commands.Cog):
def is_command(self, alias_name: str) -> bool:
"""
The logic here is that if this returns true, the name shouldnt be used for an alias
The logic here is that if this returns true, the name should not be used for an alias
The function name can be changed when alias is reworked
"""
command = self.bot.get_command(alias_name)

View File

@@ -746,13 +746,17 @@ class MusicCache:
(val, update) = await self.database.fetch_one("lavalink", "data", {"query": query})
if update:
val = None
if val and not isinstance(val, str):
if val and isinstance(val, dict):
log.debug(f"Querying Local Database for {query}")
task = ("update", ("lavalink", {"query": query}))
self.append_task(ctx, *task)
if val and not forced:
else:
val = None
if val and not forced and isinstance(val, dict):
data = val
data["query"] = query
if data.get("loadType") == "V2_COMPACT":
data["loadType"] = "V2_COMPAT"
results = LoadResult(data)
called_api = False
if results.has_error:
@@ -778,21 +782,25 @@ class MusicCache:
):
with contextlib.suppress(SQLError):
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
task = (
"insert",
(
"lavalink",
[
{
"query": query,
"data": json.dumps(results._raw),
"last_updated": time_now,
"last_fetched": time_now,
}
],
),
)
self.append_task(ctx, *task)
data = json.dumps(results._raw)
if all(
k in data for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]
):
task = (
"insert",
(
"lavalink",
[
{
"query": query,
"data": data,
"last_updated": time_now,
"last_fetched": time_now,
}
],
),
)
self.append_task(ctx, *task)
return results, called_api
async def run_tasks(self, ctx: Optional[commands.Context] = None, _id=None):
@@ -853,10 +861,12 @@ class MusicCache:
query_data["maxage"] = maxage_int
vals = await self.database.fetch_all("lavalink", "data", query_data)
recently_played = [r.tracks for r in vals if r]
recently_played = [r.tracks for r in vals if r if isinstance(tracks, dict)]
if recently_played:
track = random.choice(recently_played)
if track.get("loadType") == "V2_COMPACT":
track["loadType"] = "V2_COMPAT"
results = LoadResult(track)
tracks = list(results.tracks)
except Exception:

View File

@@ -67,7 +67,7 @@ from .utils import *
_ = Translator("Audio", __file__)
__version__ = "1.1.0"
__version__ = "1.1.1"
__author__ = ["aikaterna", "Draper"]
log = logging.getLogger("red.audio")
@@ -245,15 +245,20 @@ class Audio(commands.Cog):
for t in tracks_in_playlist:
uri = t.get("info", {}).get("uri")
if uri:
t = {"loadType": "V2_COMPACT", "tracks": [t], "query": uri}
database_entries.append(
{
"query": uri,
"data": json.dumps(t),
"last_updated": time_now,
"last_fetched": time_now,
}
)
t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri}
data = json.dumps(t)
if all(
k in data
for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]
):
database_entries.append(
{
"query": uri,
"data": data,
"last_updated": time_now,
"last_fetched": time_now,
}
)
await asyncio.sleep(0)
if guild_playlist:
all_playlist[str(guild_id)] = guild_playlist
@@ -530,17 +535,18 @@ class Audio(commands.Cog):
player_check = await self._players_check()
await self._status_check(*player_check)
if not autoplay and event_type == lavalink.LavalinkEvents.QUEUE_END and notify:
notify_channel = player.fetch("channel")
if notify_channel:
notify_channel = self.bot.get_channel(notify_channel)
await self._embed_msg(notify_channel, title=_("Queue Ended."))
elif not autoplay and event_type == lavalink.LavalinkEvents.QUEUE_END and disconnect:
self.bot.dispatch("red_audio_audio_disconnect", guild)
await player.disconnect()
if event_type == lavalink.LavalinkEvents.QUEUE_END and status:
player_check = await self._players_check()
await self._status_check(*player_check)
if event_type == lavalink.LavalinkEvents.QUEUE_END:
if not autoplay:
notify_channel = player.fetch("channel")
if notify_channel and notify:
notify_channel = self.bot.get_channel(notify_channel)
await self._embed_msg(notify_channel, title=_("Queue Ended."))
if disconnect:
self.bot.dispatch("red_audio_audio_disconnect", guild)
await player.disconnect()
if status:
player_check = await self._players_check()
await self._status_check(*player_check)
if event_type in [
lavalink.LavalinkEvents.TRACK_EXCEPTION,
@@ -690,7 +696,7 @@ class Audio(commands.Cog):
async def dc(self, ctx: commands.Context):
"""Toggle the bot auto-disconnecting when done playing.
This setting takes precedence over [p]audioset emptydisconnect.
This setting takes precedence over `[p]audioset emptydisconnect`.
"""
disconnect = await self.config.guild(ctx.guild).disconnect()
@@ -699,7 +705,6 @@ class Audio(commands.Cog):
msg += _("Auto-disconnection at queue end: {true_or_false}.").format(
true_or_false=_("Enabled") if not disconnect else _("Disabled")
)
await self.config.guild(ctx.guild).repeat.set(not disconnect)
if disconnect is not True and autoplay is True:
msg += _("\nAuto-play has been disabled.")
await self.config.guild(ctx.guild).auto_play.set(False)
@@ -1117,7 +1122,7 @@ class Audio(commands.Cog):
"""Set a playlist to auto-play songs from.
**Usage**:
[p]audioset autoplay playlist_name_OR_id args
`[p]audioset autoplay playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@@ -1140,16 +1145,16 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]audioset autoplay MyGuildPlaylist
[p]audioset autoplay MyGlobalPlaylist --scope Global
[p]audioset autoplay PersonalPlaylist --scope User --author Draper
`[p]audioset autoplay MyGuildPlaylist`
`[p]audioset autoplay MyGlobalPlaylist --scope Global`
`[p]audioset autoplay PersonalPlaylist --scope User --author Draper`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -1253,7 +1258,10 @@ class Audio(commands.Cog):
@audioset.command()
@checks.mod_or_permissions(administrator=True)
async def emptydisconnect(self, ctx: commands.Context, seconds: int):
"""Auto-disconnect from channel when bot is alone in it for x seconds, 0 to disable."""
"""Auto-disconnect from channel when bot is alone in it for x seconds, 0 to disable.
`[p]audioset dc` takes precedence over this setting.
"""
if seconds < 0:
return await self._embed_msg(
ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.")
@@ -2443,7 +2451,11 @@ class Audio(commands.Cog):
if not await self._localtracks_check(ctx):
return
return audio_data.subfolders_in_tree() if search_subfolders else audio_data.subfolders()
return (
await audio_data.subfolders_in_tree()
if search_subfolders
else await audio_data.subfolders()
)
async def _folder_list(
self, ctx: commands.Context, query: audio_dataclasses.Query
@@ -2454,9 +2466,9 @@ class Audio(commands.Cog):
if not query.track.exists():
return
return (
query.track.tracks_in_tree()
await query.track.tracks_in_tree()
if query.search_subfolders
else query.track.tracks_in_folder()
else await query.track.tracks_in_folder()
)
async def _folder_tracks(
@@ -2495,9 +2507,9 @@ class Audio(commands.Cog):
return
return (
query.track.tracks_in_tree()
await query.track.tracks_in_tree()
if query.search_subfolders
else query.track.tracks_in_folder()
else await query.track.tracks_in_folder()
)
async def _localtracks_check(self, ctx: commands.Context) -> bool:
@@ -2948,8 +2960,7 @@ class Audio(commands.Cog):
return await self._embed_msg(ctx, embed=embed)
elif isinstance(tracks, discord.Message):
return
queue_dur = await queue_duration(ctx)
lavalink.utils.format_time(queue_dur)
queue_dur = await track_remaining_duration(ctx)
index = query.track_index
seek = 0
if query.start_time:
@@ -3822,7 +3833,7 @@ class Audio(commands.Cog):
author: discord.User,
guild: discord.Guild,
specified_user: bool = False,
) -> Tuple[Optional[int], str]:
) -> Tuple[Optional[int], str, str]:
"""
Parameters
----------
@@ -3851,34 +3862,57 @@ class Audio(commands.Cog):
"""
correct_scope_matches: List[Playlist]
original_input = matches.get("arg")
correct_scope_matches_temp: MutableMapping = matches.get(scope)
lazy_match = False
if scope is None:
correct_scope_matches_temp: MutableMapping = matches.get("all")
lazy_match = True
else:
correct_scope_matches_temp: MutableMapping = matches.get(scope)
guild_to_query = guild.id
user_to_query = author.id
correct_scope_matches_user = []
correct_scope_matches_guild = []
correct_scope_matches_global = []
if not correct_scope_matches_temp:
return None, original_input
if scope == PlaylistScope.USER.value:
correct_scope_matches = [
p for p in correct_scope_matches_temp if user_to_query == p.scope_id
return None, original_input, scope or PlaylistScope.GUILD.value
if lazy_match or (scope == PlaylistScope.USER.value):
correct_scope_matches_user = [
p for p in matches.get(PlaylistScope.USER.value) if user_to_query == p.scope_id
]
elif scope == PlaylistScope.GUILD.value:
if lazy_match or (scope == PlaylistScope.GUILD.value and not correct_scope_matches_user):
if specified_user:
correct_scope_matches = [
correct_scope_matches_guild = [
p
for p in correct_scope_matches_temp
for p in matches.get(PlaylistScope.GUILD.value)
if guild_to_query == p.scope_id and p.author == user_to_query
]
else:
correct_scope_matches = [
p for p in correct_scope_matches_temp if guild_to_query == p.scope_id
correct_scope_matches_guild = [
p
for p in matches.get(PlaylistScope.GUILD.value)
if guild_to_query == p.scope_id
]
else:
if lazy_match or (
scope == PlaylistScope.GLOBAL.value
and not correct_scope_matches_user
and not correct_scope_matches_guild
):
if specified_user:
correct_scope_matches = [
p for p in correct_scope_matches_temp if p.author == user_to_query
correct_scope_matches_global = [
p
for p in matches.get(PlaylistScope.USGLOBALER.value)
if p.author == user_to_query
]
else:
correct_scope_matches = [p for p in correct_scope_matches_temp]
correct_scope_matches_global = [p for p in matches.get(PlaylistScope.GLOBAL.value)]
correct_scope_matches = [
*correct_scope_matches_global,
*correct_scope_matches_guild,
*correct_scope_matches_user,
]
match_count = len(correct_scope_matches)
if match_count > 1:
correct_scope_matches2 = [
@@ -3905,14 +3939,15 @@ class Audio(commands.Cog):
).format(match_count=match_count, original_input=original_input)
)
elif match_count == 1:
return correct_scope_matches[0].id, original_input
return correct_scope_matches[0].id, original_input, correct_scope_matches[0].scope
elif match_count == 0:
return None, original_input
return None, original_input, scope
# TODO : Convert this section to a new paged reaction menu when Toby Menus are Merged
pos_len = 3
playlists = f"{'#':{pos_len}}\n"
number = 0
correct_scope_matches = sorted(correct_scope_matches, key=lambda x: x.name.lower())
for number, playlist in enumerate(correct_scope_matches, 1):
author = self.bot.get_user(playlist.author) or playlist.author or _("Unknown")
line = _(
@@ -3925,7 +3960,7 @@ class Audio(commands.Cog):
).format(
number=number,
playlist=playlist,
scope=humanize_scope(scope),
scope=humanize_scope(playlist.scope),
tracks=len(playlist.tracks),
author=author,
)
@@ -3961,7 +3996,11 @@ class Audio(commands.Cog):
)
with contextlib.suppress(discord.HTTPException):
await msg.delete()
return correct_scope_matches[pred.result].id, original_input
return (
correct_scope_matches[pred.result].id,
original_input,
correct_scope_matches[pred.result].scope,
)
@commands.group()
@commands.guild_only()
@@ -3996,7 +4035,7 @@ class Audio(commands.Cog):
The track(s) will be appended to the end of the playlist.
**Usage**:
[p]playlist append playlist_name_OR_id track_name_OR_url args
`[p]playlist append playlist_name_OR_id track_name_OR_url [args]`
**Args**:
The following are all optional:
@@ -4019,18 +4058,17 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist append MyGuildPlaylist Hello by Adele
[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global
[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global
--Author Draper#6666
`[p]playlist append MyGuildPlaylist Hello by Adele`
`[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global`
`[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global --Author Draper#6666`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
(scope, author, guild, specified_user) = scope_data
if not await self._playlist_check(ctx):
return
try:
(playlist_id, playlist_arg) = await self._get_correct_playlist_id(
(playlist_id, playlist_arg, scope) = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -4144,8 +4182,8 @@ class Audio(commands.Cog):
else None,
)
@commands.cooldown(1, 300, commands.BucketType.member)
@playlist.command(name="copy", usage="<id_or_name> [args]")
@commands.cooldown(1, 150, commands.BucketType.member)
@playlist.command(name="copy", usage="<id_or_name> [args]", cooldown_after_parsing=True)
async def _playlist_copy(
self,
ctx: commands.Context,
@@ -4157,7 +4195,7 @@ class Audio(commands.Cog):
"""Copy a playlist from one scope to another.
**Usage**:
[p]playlist copy playlist_name_OR_id args
`[p]playlist copy playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@@ -4184,11 +4222,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist copy MyGuildPlaylist --from-scope Guild --to-scope Global
[p]playlist copy MyGlobalPlaylist --from-scope Global --to-author Draper#6666
--to-scope User
[p]playlist copy MyPersonalPlaylist --from-scope user --to-author Draper#6666
--to-scope Guild --to-guild Red - Discord Bot
`[p]playlist copy MyGuildPlaylist --from-scope Guild --to-scope Global`
`[p]playlist copy MyGlobalPlaylist --from-scope Global --to-author Draper#6666 --to-scope User`
`[p]playlist copy MyPersonalPlaylist --from-scope user --to-author Draper#6666 --to-scope Guild --to-guild Red - Discord Bot`
"""
if scope_data is None:
@@ -4214,7 +4250,7 @@ class Audio(commands.Cog):
) = scope_data
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, from_scope, from_author, from_guild, specified_from_user
)
except TooManyMatches as e:
@@ -4284,8 +4320,8 @@ class Audio(commands.Cog):
).format(
name=from_playlist.name,
from_id=from_playlist.id,
from_scope=humanize_scope(from_scope, ctx=from_scope_name, the=True),
to_scope=humanize_scope(to_scope, ctx=to_scope_name, the=True),
from_scope=humanize_scope(from_scope, ctx=from_scope_name),
to_scope=humanize_scope(to_scope, ctx=to_scope_name),
to_id=to_playlist.id,
),
)
@@ -4297,7 +4333,7 @@ class Audio(commands.Cog):
"""Create an empty playlist.
**Usage**:
[p]playlist create playlist_name args
`[p]playlist create playlist_name [args]`
**Args**:
The following are all optional:
@@ -4320,9 +4356,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist create MyGuildPlaylist
[p]playlist create MyGlobalPlaylist --scope Global
[p]playlist create MyPersonalPlaylist --scope User
`[p]playlist create MyGuildPlaylist`
`[p]playlist create MyGlobalPlaylist --scope Global`
`[p]playlist create MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@@ -4364,7 +4400,7 @@ class Audio(commands.Cog):
"""Delete a saved playlist.
**Usage**:
[p]playlist delete playlist_name_OR_id args
`[p]playlist delete playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@@ -4387,16 +4423,16 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist delete MyGuildPlaylist
[p]playlist delete MyGlobalPlaylist --scope Global
[p]playlist delete MyPersonalPlaylist --scope User
`[p]playlist delete MyGuildPlaylist`
`[p]playlist delete MyGlobalPlaylist --scope Global`
`[p]playlist delete MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -4438,7 +4474,9 @@ class Audio(commands.Cog):
)
@commands.cooldown(1, 30, commands.BucketType.member)
@playlist.command(name="dedupe", usage="<playlist_name_OR_id> [args]")
@playlist.command(
name="dedupe", usage="<playlist_name_OR_id> [args]", cooldown_after_parsing=True
)
async def _playlist_remdupe(
self,
ctx: commands.Context,
@@ -4449,7 +4487,7 @@ class Audio(commands.Cog):
"""Remove duplicate tracks from a saved playlist.
**Usage**:
[p]playlist dedupe playlist_name_OR_id args
`[p]playlist dedupe playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@@ -4472,25 +4510,24 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist dedupe MyGuildPlaylist
[p]playlist dedupe MyGlobalPlaylist --scope Global
[p]playlist dedupe MyPersonalPlaylist --scope User
`[p]playlist dedupe MyGuildPlaylist`
`[p]playlist dedupe MyGlobalPlaylist --scope Global`
`[p]playlist dedupe MyPersonalPlaylist --scope User`
"""
async with ctx.typing():
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
ctx.command.reset_cooldown(ctx)
return await self._embed_msg(ctx, title=str(e))
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
if playlist_id is None:
ctx.command.reset_cooldown(ctx)
return await self._embed_msg(
@@ -4571,9 +4608,13 @@ class Audio(commands.Cog):
)
@checks.is_owner()
@playlist.command(name="download", usage="<playlist_name_OR_id> [v2=False] [args]")
@playlist.command(
name="download",
usage="<playlist_name_OR_id> [v2=False] [args]",
cooldown_after_parsing=True,
)
@commands.bot_has_permissions(attach_files=True)
@commands.cooldown(1, 60, commands.BucketType.guild)
@commands.cooldown(1, 30, commands.BucketType.guild)
async def _playlist_download(
self,
ctx: commands.Context,
@@ -4584,12 +4625,12 @@ class Audio(commands.Cog):
):
"""Download a copy of a playlist.
These files can be used with the [p]playlist upload command.
These files can be used with the `[p]playlist upload` command.
Red v2-compatible playlists can be generated by passing True
for the v2 variable.
**Usage**:
[p]playlist download playlist_name_OR_id [v2=True_OR_False] args
`[p]playlist download playlist_name_OR_id [v2=True_OR_False] [args]`
**Args**:
The following are all optional:
@@ -4612,16 +4653,16 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist download MyGuildPlaylist True
[p]playlist download MyGlobalPlaylist False --scope Global
[p]playlist download MyPersonalPlaylist --scope User
`[p]playlist download MyGuildPlaylist True`
`[p]playlist download MyGlobalPlaylist False --scope Global`
`[p]playlist download MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -4715,8 +4756,10 @@ class Audio(commands.Cog):
await ctx.send(file=discord.File(to_write, filename=f"{file_name}.txt"))
to_write.close()
@commands.cooldown(1, 20, commands.BucketType.member)
@playlist.command(name="info", usage="<playlist_name_OR_id> [args]")
@commands.cooldown(1, 10, commands.BucketType.member)
@playlist.command(
name="info", usage="<playlist_name_OR_id> [args]", cooldown_after_parsing=True
)
async def _playlist_info(
self,
ctx: commands.Context,
@@ -4727,7 +4770,7 @@ class Audio(commands.Cog):
"""Retrieve information from a saved playlist.
**Usage**:
[p]playlist info playlist_name_OR_id args
`[p]playlist info playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@@ -4750,24 +4793,24 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist info MyGuildPlaylist
[p]playlist info MyGlobalPlaylist --scope Global
[p]playlist info MyPersonalPlaylist --scope User
`[p]playlist info MyGuildPlaylist`
`[p]playlist info MyGlobalPlaylist --scope Global`
`[p]playlist info MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
ctx.command.reset_cooldown(ctx)
return await self._embed_msg(ctx, title=str(e))
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
if playlist_id is None:
ctx.command.reset_cooldown(ctx)
return await self._embed_msg(
@@ -4852,14 +4895,14 @@ class Audio(commands.Cog):
page_list.append(embed)
await menu(ctx, page_list, DEFAULT_CONTROLS)
@commands.cooldown(1, 30, commands.BucketType.guild)
@playlist.command(name="list", usage="[args]")
@commands.cooldown(1, 15, commands.BucketType.guild)
@playlist.command(name="list", usage="[args]", cooldown_after_parsing=True)
@commands.bot_has_permissions(add_reactions=True)
async def _playlist_list(self, ctx: commands.Context, *, scope_data: ScopeParser = None):
"""List saved playlists.
**Usage**:
[p]playlist list args
`[p]playlist list [args]`
**Args**:
The following are all optional:
@@ -4882,9 +4925,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist list
[p]playlist list --scope Global
[p]playlist list --scope User
`[p]playlist list`
`[p]playlist list --scope Global`
`[p]playlist list --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@@ -4976,15 +5019,15 @@ class Audio(commands.Cog):
)
return embed
@playlist.command(name="queue", usage="<name> [args]")
@commands.cooldown(1, 600, commands.BucketType.member)
@playlist.command(name="queue", usage="<name> [args]", cooldown_after_parsing=True)
@commands.cooldown(1, 300, commands.BucketType.member)
async def _playlist_queue(
self, ctx: commands.Context, playlist_name: str, *, scope_data: ScopeParser = None
):
"""Save the queue to a playlist.
**Usage**:
[p]playlist queue playlist_name
`[p]playlist queue playlist_name [args]`
**Args**:
The following are all optional:
@@ -5007,9 +5050,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist queue MyGuildPlaylist
[p]playlist queue MyGlobalPlaylist --scope Global
[p]playlist queue MyPersonalPlaylist --scope User
`[p]playlist queue MyGuildPlaylist`
`[p]playlist queue MyGlobalPlaylist --scope Global`
`[p]playlist queue MyPersonalPlaylist --scope User`
"""
async with ctx.typing():
if scope_data is None:
@@ -5087,7 +5130,7 @@ class Audio(commands.Cog):
"""Remove a track from a playlist by url.
**Usage**:
[p]playlist remove playlist_name_OR_id url args
`[p]playlist remove playlist_name_OR_id url [args]`
**Args**:
The following are all optional:
@@ -5110,25 +5153,23 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist remove MyGuildPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU
[p]playlist remove MyGlobalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU
--scope Global
[p]playlist remove MyPersonalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU
--scope User
`[p]playlist remove MyGuildPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU`
`[p]playlist remove MyGlobalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope Global`
`[p]playlist remove MyPersonalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
return await self._embed_msg(ctx, title=str(e))
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
if playlist_id is None:
return await self._embed_msg(
ctx,
@@ -5188,8 +5229,8 @@ class Audio(commands.Cog):
).format(playlist_name=playlist.name, id=playlist.id, scope=scope_name),
)
@playlist.command(name="save", usage="<name> <url> [args]")
@commands.cooldown(1, 120, commands.BucketType.member)
@playlist.command(name="save", usage="<name> <url> [args]", cooldown_after_parsing=True)
@commands.cooldown(1, 60, commands.BucketType.member)
async def _playlist_save(
self,
ctx: commands.Context,
@@ -5201,7 +5242,7 @@ class Audio(commands.Cog):
"""Save a playlist from a url.
**Usage**:
[p]playlist save name url args
`[p]playlist save name url [args]`
**Args**:
The following are all optional:
@@ -5224,12 +5265,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist save MyGuildPlaylist
https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM
[p]playlist save MyGlobalPlaylist
https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM --scope Global
[p]playlist save MyPersonalPlaylist
https://open.spotify.com/playlist/1RyeIbyFeIJVnNzlGr5KkR --scope User
`[p]playlist save MyGuildPlaylist https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM`
`[p]playlist save MyGlobalPlaylist https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM --scope Global`
`[p]playlist save MyPersonalPlaylist https://open.spotify.com/playlist/1RyeIbyFeIJVnNzlGr5KkR --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@@ -5282,8 +5320,13 @@ class Audio(commands.Cog):
else None,
)
@commands.cooldown(1, 60, commands.BucketType.member)
@playlist.command(name="start", aliases=["play"], usage="<playlist_name_OR_id> [args]")
@commands.cooldown(1, 30, commands.BucketType.member)
@playlist.command(
name="start",
aliases=["play"],
usage="<playlist_name_OR_id> [args]",
cooldown_after_parsing=True,
)
async def _playlist_start(
self,
ctx: commands.Context,
@@ -5294,7 +5337,7 @@ class Audio(commands.Cog):
"""Load a playlist into the queue.
**Usage**:
[p]playlist start playlist_name_OR_id args
` [p]playlist start playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@@ -5317,12 +5360,12 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist start MyGuildPlaylist
[p]playlist start MyGlobalPlaylist --scope Global
[p]playlist start MyPersonalPlaylist --scope User
`[p]playlist start MyGuildPlaylist`
`[p]playlist start MyGlobalPlaylist --scope Global`
`[p]playlist start MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
@@ -5338,7 +5381,7 @@ class Audio(commands.Cog):
return False
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -5451,7 +5494,9 @@ class Audio(commands.Cog):
return await ctx.invoke(self.play, query=playlist.url)
@commands.cooldown(1, 60, commands.BucketType.member)
@playlist.command(name="update", usage="<playlist_name_OR_id> [args]")
@playlist.command(
name="update", usage="<playlist_name_OR_id> [args]", cooldown_after_parsing=True
)
async def _playlist_update(
self,
ctx: commands.Context,
@@ -5462,7 +5507,7 @@ class Audio(commands.Cog):
"""Updates all tracks in a playlist.
**Usage**:
[p]playlist update playlist_name_OR_id args
`[p]playlist update playlist_name_OR_id [args]`
**Args**:
The following are all optional:
@@ -5485,16 +5530,16 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist update MyGuildPlaylist
[p]playlist update MyGlobalPlaylist --scope Global
[p]playlist update MyPersonalPlaylist --scope User
`[p]playlist update MyGuildPlaylist`
`[p]playlist update MyGlobalPlaylist --scope Global`
`[p]playlist update MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -5610,10 +5655,10 @@ class Audio(commands.Cog):
"""Uploads a playlist file as a playlist for the bot.
V2 and old V3 playlist will be slow.
V3 Playlist made with [p]playlist download will load a lot faster.
V3 Playlist made with `[p]playlist download` will load a lot faster.
**Usage**:
[p]playlist upload args
`[p]playlist upload [args]`
**Args**:
The following are all optional:
@@ -5636,9 +5681,9 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist upload
[p]playlist upload --scope Global
[p]playlist upload --scope User
`[p]playlist upload`
`[p]playlist upload --scope Global`
`[p]playlist upload --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
@@ -5728,7 +5773,9 @@ class Audio(commands.Cog):
)
@commands.cooldown(1, 60, commands.BucketType.member)
@playlist.command(name="rename", usage="<playlist_name_OR_id> <new_name> [args]")
@playlist.command(
name="rename", usage="<playlist_name_OR_id> <new_name> [args]", cooldown_after_parsing=True
)
async def _playlist_rename(
self,
ctx: commands.Context,
@@ -5740,7 +5787,7 @@ class Audio(commands.Cog):
"""Rename an existing playlist.
**Usage**:
[p]playlist rename playlist_name_OR_id new_name args
`[p]playlist rename playlist_name_OR_id new_name [args]`
**Args**:
The following are all optional:
@@ -5763,12 +5810,12 @@ class Audio(commands.Cog):
Exact guild name
Example use:
[p]playlist rename MyGuildPlaylist RenamedGuildPlaylist
[p]playlist rename MyGlobalPlaylist RenamedGlobalPlaylist --scope Global
[p]playlist rename MyPersonalPlaylist RenamedPersonalPlaylist --scope User
`[p]playlist rename MyGuildPlaylist RenamedGuildPlaylist`
`[p]playlist rename MyGlobalPlaylist RenamedGlobalPlaylist --scope Global`
`[p]playlist rename MyPersonalPlaylist RenamedPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
new_name = new_name.split(" ")[0].strip('"')[:32]
@@ -5784,7 +5831,7 @@ class Audio(commands.Cog):
)
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -5882,15 +5929,17 @@ class Audio(commands.Cog):
for t in track_list:
uri = t.get("info", {}).get("uri")
if uri:
t = {"loadType": "V2_COMPACT", "tracks": [t], "query": uri}
database_entries.append(
{
"query": uri,
"data": json.dumps(t),
"last_updated": time_now,
"last_fetched": time_now,
}
)
t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri}
data = json.dumps(t)
if all(k in data for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]):
database_entries.append(
{
"query": uri,
"data": data,
"last_updated": time_now,
"last_fetched": time_now,
}
)
if database_entries:
await self.music_cache.database.insert("lavalink", database_entries)
@@ -6793,8 +6842,8 @@ class Audio(commands.Cog):
async def search(self, ctx: commands.Context, *, query: str):
"""Pick a track with a search.
Use `[p]search list <search term>` to queue all tracks found on YouTube. `[p]search sc
<search term>` will search SoundCloud instead of YouTube.
Use `[p]search list <search term>` to queue all tracks found on YouTube.
`[p]search sc<search term>` will search SoundCloud instead of YouTube.
"""
async def _search_menu(
@@ -7357,8 +7406,8 @@ class Audio(commands.Cog):
async def _shuffle_bumpped(self, ctx: commands.Context):
"""Toggle bumped track shuffle.
Set this to disabled if you wish to avoid bumped songs being shuffled. This takes priority
over `[p]shuffle`.
Set this to disabled if you wish to avoid bumped songs being shuffled.
This takes priority over `[p]shuffle`.
"""
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()

View File

@@ -1,9 +1,12 @@
import asyncio
import contextlib
import glob
import ntpath
import os
import posixpath
import re
from pathlib import Path, PosixPath, WindowsPath
from typing import List, Optional, Union, MutableMapping
from typing import List, Optional, Union, MutableMapping, Iterator, AsyncIterator
from urllib.parse import urlparse
import lavalink
@@ -167,29 +170,48 @@ class LocalPath:
modified.path = modified.path.joinpath(*args)
return modified
def multiglob(self, *patterns):
paths = []
def rglob(self, pattern, folder=False) -> Iterator[str]:
if folder:
return glob.iglob(f"{self.path}{os.sep}**{os.sep}", recursive=True)
else:
return glob.iglob(f"{self.path}{os.sep}**{os.sep}{pattern}", recursive=True)
def glob(self, pattern, folder=False) -> Iterator[str]:
if folder:
return glob.iglob(f"{self.path}{os.sep}*{os.sep}", recursive=False)
else:
return glob.iglob(f"{self.path}{os.sep}*{pattern}", recursive=False)
async def multiglob(self, *patterns, folder=False) -> AsyncIterator["LocalPath"]:
for p in patterns:
paths.extend(list(self.path.glob(p)))
for p in self._filtered(paths):
yield p
for rp in self.glob(p):
rp = LocalPath(rp)
if folder and rp.is_dir() and rp.exists():
yield rp
await asyncio.sleep(0)
else:
if rp.suffix in self._all_music_ext and rp.is_file() and rp.exists():
yield rp
await asyncio.sleep(0)
def multirglob(self, *patterns):
paths = []
async def multirglob(self, *patterns, folder=False) -> AsyncIterator["LocalPath"]:
for p in patterns:
paths.extend(list(self.path.rglob(p)))
for p in self._filtered(paths):
yield p
def _filtered(self, paths: List[Path]):
for p in paths:
if p.suffix in self._all_music_ext:
yield p
for rp in self.rglob(p):
rp = LocalPath(rp)
if folder and rp.is_dir() and rp.exists():
yield rp
await asyncio.sleep(0)
else:
if rp.suffix in self._all_music_ext and rp.is_file() and rp.exists():
yield rp
await asyncio.sleep(0)
def __str__(self):
return self.to_string()
def __repr__(self):
return str(self)
def to_string(self):
try:
return str(self.path.absolute())
@@ -209,48 +231,56 @@ class LocalPath:
string = f"...{os.sep}{string}"
return string
def tracks_in_tree(self):
async def tracks_in_tree(self):
tracks = []
for track in self.multirglob(*[f"*{ext}" for ext in self._all_music_ext]):
if track.exists() and track.is_file() and track.parent != self.localtrack_folder:
tracks.append(Query.process_input(LocalPath(str(track.absolute()))))
async for track in self.multirglob(*[f"{ext}" for ext in self._all_music_ext]):
with contextlib.suppress(ValueError):
if track.path.parent != self.localtrack_folder and track.path.relative_to(
self.path
):
tracks.append(Query.process_input(track))
return sorted(tracks, key=lambda x: x.to_string_user().lower())
def subfolders_in_tree(self):
files = list(self.multirglob(*[f"*{ext}" for ext in self._all_music_ext]))
folders = []
for f in files:
if f.exists() and f.parent not in folders and f.parent != self.localtrack_folder:
folders.append(f.parent)
async def subfolders_in_tree(self):
return_folders = []
for folder in folders:
if folder.exists() and folder.is_dir():
return_folders.append(LocalPath(str(folder.absolute())))
async for f in self.multirglob("", folder=True):
with contextlib.suppress(ValueError):
if (
f not in return_folders
and f.path != self.localtrack_folder
and f.path.relative_to(self.path)
):
return_folders.append(f)
return sorted(return_folders, key=lambda x: x.to_string_user().lower())
def tracks_in_folder(self):
async def tracks_in_folder(self):
tracks = []
for track in self.multiglob(*[f"*{ext}" for ext in self._all_music_ext]):
if track.exists() and track.is_file() and track.parent != self.localtrack_folder:
tracks.append(Query.process_input(LocalPath(str(track.absolute()))))
async for track in self.multiglob(*[f"{ext}" for ext in self._all_music_ext]):
with contextlib.suppress(ValueError):
if track.path.parent != self.localtrack_folder and track.path.relative_to(
self.path
):
tracks.append(Query.process_input(track))
return sorted(tracks, key=lambda x: x.to_string_user().lower())
def subfolders(self):
files = list(self.multiglob(*[f"*{ext}" for ext in self._all_music_ext]))
folders = []
for f in files:
if f.exists() and f.parent not in folders and f.parent != self.localtrack_folder:
folders.append(f.parent)
async def subfolders(self):
return_folders = []
for folder in folders:
if folder.exists() and folder.is_dir():
return_folders.append(LocalPath(str(folder.absolute())))
async for f in self.multiglob("", folder=True):
with contextlib.suppress(ValueError):
if (
f not in return_folders
and f.path != self.localtrack_folder
and f.path.relative_to(self.path)
):
return_folders.append(f)
return sorted(return_folders, key=lambda x: x.to_string_user().lower())
def __eq__(self, other):
if not isinstance(other, LocalPath):
return NotImplemented
return self.path._cparts == other.path._cparts
if isinstance(other, LocalPath):
return self.path._cparts == other.path._cparts
elif isinstance(other, Path):
return self.path._cparts == other._cpart
return NotImplemented
def __hash__(self):
try:
@@ -260,24 +290,32 @@ class LocalPath:
return self._hash
def __lt__(self, other):
if not isinstance(other, LocalPath):
return NotImplemented
return self.path._cparts < other.path._cparts
if isinstance(other, LocalPath):
return self.path._cparts < other.path._cparts
elif isinstance(other, Path):
return self.path._cparts < other._cpart
return NotImplemented
def __le__(self, other):
if not isinstance(other, LocalPath):
return NotImplemented
return self.path._cparts <= other.path._cparts
if isinstance(other, LocalPath):
return self.path._cparts <= other.path._cparts
elif isinstance(other, Path):
return self.path._cparts <= other._cpart
return NotImplemented
def __gt__(self, other):
if not isinstance(other, LocalPath):
return NotImplemented
return self.path._cparts > other.path._cparts
if isinstance(other, LocalPath):
return self.path._cparts > other.path._cparts
elif isinstance(other, Path):
return self.path._cparts > other._cpart
return NotImplemented
def __ge__(self, other):
if not isinstance(other, LocalPath):
return NotImplemented
return self.path._cparts >= other.path._cparts
if isinstance(other, LocalPath):
return self.path._cparts >= other.path._cparts
elif isinstance(other, Path):
return self.path._cparts >= other._cpart
return NotImplemented
class Query:
@@ -378,6 +416,10 @@ class Query:
if isinstance(query, str):
query = query.strip("<>")
while "ytsearch:" in query:
query = query.replace("ytsearch:", "")
while "scsearch:" in query:
query = query.replace("scsearch:", "")
elif isinstance(query, Query):
for key, val in kwargs.items():

View File

@@ -158,6 +158,7 @@ class PlaylistConverter(commands.Converter):
PlaylistScope.GLOBAL.value: global_matches,
PlaylistScope.GUILD.value: guild_matches,
PlaylistScope.USER.value: user_matches,
"all": [*global_matches, *guild_matches, *user_matches],
"arg": arg,
}
@@ -170,7 +171,7 @@ class NoExitParser(argparse.ArgumentParser):
class ScopeParser(commands.Converter):
async def convert(
self, ctx: commands.Context, argument: str
) -> Tuple[str, discord.User, Optional[discord.Guild], bool]:
) -> Tuple[Optional[str], discord.User, Optional[discord.Guild], bool]:
target_scope: Optional[str] = None
target_user: Optional[Union[discord.Member, discord.User]] = None
@@ -261,7 +262,7 @@ class ScopeParser(commands.Converter):
elif any(x in argument for x in ["--author", "--user", "--member"]):
raise commands.ArgParserFailure("--scope", "Nothing", custom_help=_USER_HELP)
target_scope: str = target_scope or PlaylistScope.GUILD.value
target_scope: str = target_scope or None
target_user: Union[discord.Member, discord.User] = target_user or ctx.author
target_guild: discord.Guild = target_guild or ctx.guild

View File

@@ -43,6 +43,7 @@ __all__ = [
"CacheLevel",
"format_playlist_picker_data",
"get_track_description_unformatted",
"track_remaining_duration",
"Notifier",
"PlaylistScope",
]
@@ -126,6 +127,20 @@ async def queue_duration(ctx) -> int:
return queue_total_duration
async def track_remaining_duration(ctx) -> int:
player = lavalink.get_player(ctx.guild.id)
if not player.current:
return 0
try:
if not player.current.is_stream:
remain = player.current.length - player.position
else:
remain = 0
except AttributeError:
remain = 0
return remain
async def draw_time(ctx) -> str:
player = lavalink.get_player(ctx.guild.id)
paused = player.paused
@@ -213,7 +228,7 @@ async def clear_react(bot: Red, message: discord.Message, emoji: MutableMapping
def get_track_description(track) -> Optional[str]:
if track and getattr(track, "uri", None):
query = Query.process_input(track.uri)
if query.is_local:
if query.is_local or "localtracks/" in track.uri:
if track.title != "Unknown title":
return f'**{escape(f"{track.author} - {track.title}")}**' + escape(
f"\n{query.to_string_user()} "
@@ -229,7 +244,7 @@ def get_track_description(track) -> Optional[str]:
def get_track_description_unformatted(track) -> Optional[str]:
if track and hasattr(track, "uri"):
query = Query.process_input(track.uri)
if query.is_local:
if query.is_local or "localtracks/" in track.uri:
if track.title != "Unknown title":
return escape(f"{track.author} - {track.title}")
else:
@@ -521,8 +536,8 @@ class PlaylistScope(Enum):
def humanize_scope(scope, ctx=None, the=None):
if scope == PlaylistScope.GLOBAL.value:
return _("the ") if the else "" + _("Global")
return (_("the ") if the else "") + _("Global")
elif scope == PlaylistScope.GUILD.value:
return ctx.name if ctx else _("the ") if the else "" + _("Server")
return ctx.name if ctx else (_("the ") if the else "") + _("Server")
elif scope == PlaylistScope.USER.value:
return str(ctx) if ctx else _("the ") if the else "" + _("User")
return str(ctx) if ctx else (_("the ") if the else "") + _("User")

View File

@@ -227,6 +227,9 @@ class CustomCommands(commands.Cog):
await ctx.send(_("There already exists a bot command with the same name."))
return
responses = await self.commandobj.get_responses(ctx=ctx)
if not responses:
await ctx.send(_("Custom command process cancelled."))
return
try:
await self.commandobj.create(ctx=ctx, command=command, response=responses)
await ctx.send(_("Custom command successfully added."))

View File

@@ -3,5 +3,5 @@ from .downloader import Downloader
async def setup(bot):
cog = Downloader(bot)
await cog.initialize()
bot.add_cog(cog)
cog.create_init_task()

View File

@@ -15,6 +15,8 @@ class InstalledCog(InstalledModule):
cog = discord.utils.get(await downloader.installed_cogs(), name=arg)
if cog is None:
raise commands.BadArgument(_("That cog is not installed"))
raise commands.BadArgument(
_("Cog `{cog_name}` is not installed.").format(cog_name=arg)
)
return cog

View File

@@ -29,7 +29,7 @@ _ = Translator("Downloader", __file__)
DEPRECATION_NOTICE = _(
"\n**WARNING:** The following repos are using shared libraries"
" which are marked for removal in Red 3.3: {repo_list}.\n"
" which are marked for removal in Red 3.4: {repo_list}.\n"
" You should inform maintainers of these repos about this message."
)
@@ -53,6 +53,9 @@ class Downloader(commands.Cog):
self._create_lib_folder()
self._repo_manager = RepoManager()
self._ready = asyncio.Event()
self._init_task = None
self._ready_raised = False
def _create_lib_folder(self, *, remove_first: bool = False) -> None:
if remove_first:
@@ -62,9 +65,38 @@ class Downloader(commands.Cog):
with self.SHAREDLIB_INIT.open(mode="w", encoding="utf-8") as _:
pass
async def cog_before_invoke(self, ctx: commands.Context) -> None:
async with ctx.typing():
await self._ready.wait()
if self._ready_raised:
await ctx.send(
"There was an error during Downloader's initialization."
" Check logs for more information."
)
raise commands.CheckFailure()
def cog_unload(self):
if self._init_task is not None:
self._init_task.cancel()
def create_init_task(self):
def _done_callback(task: asyncio.Task) -> None:
exc = task.exception()
if exc is not None:
log.error(
"An unexpected error occurred during Downloader's initialization.",
exc_info=exc,
)
self._ready_raised = True
self._ready.set()
self._init_task = asyncio.create_task(self.initialize())
self._init_task.add_done_callback(_done_callback)
async def initialize(self) -> None:
await self._repo_manager.initialize()
await self._maybe_update_config()
self._ready.set()
async def _maybe_update_config(self) -> None:
schema_version = await self.conf.schema_version()
@@ -205,7 +237,7 @@ class Downloader(commands.Cog):
await self.conf.installed_libraries.set(installed_libraries)
async def _shared_lib_load_check(self, cog_name: str) -> Optional[Repo]:
# remove in Red 3.3
# remove in Red 3.4
is_installed, cog = await self.is_installed(cog_name)
# it's not gonna be None when `is_installed` is True
# if we'll use typing_extensions in future, `Literal` can solve this
@@ -418,6 +450,11 @@ class Downloader(commands.Cog):
elif target.is_file():
os.remove(str(target))
@staticmethod
async def send_pagified(target: discord.abc.Messageable, content: str) -> None:
for page in pagify(content):
await target.send(page)
@commands.command()
@checks.is_owner()
async def pipinstall(self, ctx: commands.Context, *deps: str) -> None:
@@ -425,7 +462,7 @@ class Downloader(commands.Cog):
if not deps:
await ctx.send_help()
return
repo = Repo("", "", "", "", Path.cwd(), loop=ctx.bot.loop)
repo = Repo("", "", "", "", Path.cwd())
async with ctx.typing():
success = await repo.install_raw_requirements(deps, self.LIB_PATH)
@@ -550,7 +587,7 @@ class Downloader(commands.Cog):
if failed:
message += "\n" + self.format_failed_repos(failed)
await ctx.send(message)
await self.send_pagified(ctx, message)
@commands.group()
@checks.is_owner()
@@ -596,12 +633,13 @@ class Downloader(commands.Cog):
tuple(map(inline, libnames))
)
if message:
await ctx.send(
await self.send_pagified(
ctx,
_(
"Cog requirements and shared libraries for all installed cogs"
" have been reinstalled but there were some errors:\n"
)
+ message
+ message,
)
else:
await ctx.send(
@@ -643,8 +681,7 @@ class Downloader(commands.Cog):
f"**{candidate.object_type} {candidate.rev}**"
f" - {candidate.description}\n"
)
for page in pagify(msg):
await ctx.send(msg)
await self.send_pagified(ctx, msg)
return
except errors.UnknownRevision:
await ctx.send(
@@ -658,14 +695,14 @@ class Downloader(commands.Cog):
async with repo.checkout(commit, exit_to_rev=repo.branch):
cogs, message = await self._filter_incorrect_cogs_by_names(repo, cog_names)
if not cogs:
await ctx.send(message)
await self.send_pagified(ctx, message)
return
failed_reqs = await self._install_requirements(cogs)
if failed_reqs:
message += _("\nFailed to install requirements: ") + humanize_list(
tuple(map(inline, failed_reqs))
)
await ctx.send(message)
await self.send_pagified(ctx, message)
return
installed_cogs, failed_cogs = await self._install_cogs(cogs)
@@ -711,7 +748,7 @@ class Downloader(commands.Cog):
+ message
)
# "---" added to separate cog install messages from Downloader's message
await ctx.send(f"{message}{deprecation_notice}\n---")
await self.send_pagified(ctx, f"{message}{deprecation_notice}\n---")
for cog in installed_cogs:
if cog.install_msg:
await ctx.send(cog.install_msg.replace("[p]", ctx.prefix))
@@ -748,14 +785,18 @@ class Downloader(commands.Cog):
message += _("Successfully uninstalled cogs: ") + humanize_list(uninstalled_cogs)
if failed_cogs:
message += (
_("\nThese cog were installed but can no longer be located: ")
_(
"\nDownloader has removed these cogs from the installed cogs list"
" but it wasn't able to find their files: "
)
+ humanize_list(tuple(map(inline, failed_cogs)))
+ _(
"\nYou may need to remove their files manually if they are still usable."
" Also make sure you've unloaded those cogs with `{prefix}unload {cogs}`."
"\nThey were most likely removed without using `{prefix}cog uninstall`.\n"
"You may need to remove those files manually if the cogs are still usable."
" If so, ensure the cogs have been unloaded with `{prefix}unload {cogs}`."
).format(prefix=ctx.prefix, cogs=" ".join(failed_cogs))
)
await ctx.send(message)
await self.send_pagified(ctx, message)
@cog.command(name="pin", usage="<cogs>")
async def _cog_pin(self, ctx: commands.Context, *cogs: InstalledCog) -> None:
@@ -778,7 +819,7 @@ class Downloader(commands.Cog):
message += _("Pinned cogs: ") + humanize_list(cognames)
if already_pinned:
message += _("\nThese cogs were already pinned: ") + humanize_list(already_pinned)
await ctx.send(message)
await self.send_pagified(ctx, message)
@cog.command(name="unpin", usage="<cogs>")
async def _cog_unpin(self, ctx: commands.Context, *cogs: InstalledCog) -> None:
@@ -801,7 +842,7 @@ class Downloader(commands.Cog):
message += _("Unpinned cogs: ") + humanize_list(cognames)
if not_pinned:
message += _("\nThese cogs weren't pinned: ") + humanize_list(not_pinned)
await ctx.send(message)
await self.send_pagified(ctx, message)
@cog.command(name="checkforupdates")
async def _cog_checkforupdates(self, ctx: commands.Context) -> None:
@@ -833,7 +874,7 @@ class Downloader(commands.Cog):
if failed:
message += "\n" + self.format_failed_repos(failed)
await ctx.send(message)
await self.send_pagified(ctx, message)
@cog.command(name="update")
async def _cog_update(self, ctx: commands.Context, *cogs: InstalledCog) -> None:
@@ -869,7 +910,6 @@ class Downloader(commands.Cog):
rev: Optional[str] = None,
cogs: Optional[List[InstalledModule]] = None,
) -> None:
message = ""
failed_repos = set()
updates_available = set()
@@ -882,7 +922,7 @@ class Downloader(commands.Cog):
await repo.update()
except errors.UpdateError:
message = self.format_failed_repos([repo.name])
await ctx.send(message)
await self.send_pagified(ctx, message)
return
try:
@@ -896,11 +936,10 @@ class Downloader(commands.Cog):
f"**{candidate.object_type} {candidate.rev}**"
f" - {candidate.description}\n"
)
for page in pagify(msg):
await ctx.send(msg)
await self.send_pagified(ctx, msg)
return
except errors.UnknownRevision:
message += _(
message = _(
"Error: there is no revision `{rev}` in repo `{repo.name}`"
).format(rev=rev, repo=repo)
await ctx.send(message)
@@ -917,6 +956,8 @@ class Downloader(commands.Cog):
pinned_cogs = {cog for cog in cogs_to_check if cog.pinned}
cogs_to_check -= pinned_cogs
message = ""
if not cogs_to_check:
cogs_to_update = libs_to_update = ()
message += _("There were no cogs to check.")
@@ -972,7 +1013,7 @@ class Downloader(commands.Cog):
if repos_with_libs:
message += DEPRECATION_NOTICE.format(repo_list=humanize_list(list(repos_with_libs)))
await ctx.send(message)
await self.send_pagified(ctx, message)
if updates_available and updated_cognames:
await self._ask_for_cog_reload(ctx, updated_cognames)

View File

@@ -38,6 +38,10 @@ class GitException(DownloaderException):
Generic class for git exceptions.
"""
def __init__(self, message: str, git_command: str) -> None:
self.git_command = git_command
super().__init__(f"Git command failed: {git_command}\nError message: {message}")
class InvalidRepoName(DownloaderException):
"""
@@ -138,8 +142,8 @@ class AmbiguousRevision(GitException):
Thrown when specified revision is ambiguous.
"""
def __init__(self, message: str, candidates: List[Candidate]) -> None:
super().__init__(message)
def __init__(self, message: str, git_command: str, candidates: List[Candidate]) -> None:
super().__init__(message, git_command)
self.candidates = candidates

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import json
import distutils.dir_util
import functools
import shutil
from enum import IntEnum
from pathlib import Path
@@ -127,15 +127,13 @@ class Installable(RepoJSONMixin):
if self._location.is_file():
copy_func = shutil.copy2
else:
# clear copy_tree's cache to make sure missing directories are created (GH-2690)
distutils.dir_util._path_created = {}
copy_func = distutils.dir_util.copy_tree
copy_func = functools.partial(shutil.copytree, dirs_exist_ok=True)
# noinspection PyBroadException
try:
copy_func(src=str(self._location), dst=str(target_dir / self._location.stem))
except: # noqa: E722
log.exception("Error occurred when copying path: {}".format(self._location))
log.exception("Error occurred when copying path: %s", self._location)
return False
return True

View File

@@ -135,7 +135,6 @@ class Repo(RepoJSONMixin):
commit: str,
folder_path: Path,
available_modules: Tuple[Installable, ...] = (),
loop: Optional[asyncio.AbstractEventLoop] = None,
):
self.url = url
self.branch = branch
@@ -154,8 +153,6 @@ class Repo(RepoJSONMixin):
self._repo_lock = asyncio.Lock()
self._loop = loop if loop is not None else asyncio.get_event_loop()
@property
def clean_url(self) -> str:
"""Sanitized repo URL (with removed HTTP Basic Auth)"""
@@ -203,21 +200,20 @@ class Repo(RepoJSONMixin):
"""
valid_exit_codes = (0, 1)
p = await self._run(
ProcessFormatter().format(
self.GIT_IS_ANCESTOR,
path=self.folder_path,
maybe_ancestor_rev=maybe_ancestor_rev,
descendant_rev=descendant_rev,
),
valid_exit_codes=valid_exit_codes,
git_command = ProcessFormatter().format(
self.GIT_IS_ANCESTOR,
path=self.folder_path,
maybe_ancestor_rev=maybe_ancestor_rev,
descendant_rev=descendant_rev,
)
p = await self._run(git_command, valid_exit_codes=valid_exit_codes)
if p.returncode in valid_exit_codes:
return not bool(p.returncode)
raise errors.GitException(
f"Git failed to determine if commit {maybe_ancestor_rev}"
f" is ancestor of {descendant_rev} for repo at path: {self.folder_path}"
f" is ancestor of {descendant_rev} for repo at path: {self.folder_path}",
git_command,
)
async def is_on_branch(self) -> bool:
@@ -253,15 +249,14 @@ class Repo(RepoJSONMixin):
"""
if new_rev is None:
new_rev = self.branch
p = await self._run(
ProcessFormatter().format(
self.GIT_DIFF_FILE_STATUS, path=self.folder_path, old_rev=old_rev, new_rev=new_rev
)
git_command = ProcessFormatter().format(
self.GIT_DIFF_FILE_STATUS, path=self.folder_path, old_rev=old_rev, new_rev=new_rev
)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.GitDiffError(
"Git diff failed for repo at path: {}".format(self.folder_path)
f"Git diff failed for repo at path: {self.folder_path}", git_command
)
stdout = p.stdout.strip(b"\t\n\x00 ").decode().split("\x00\t")
@@ -310,18 +305,17 @@ class Repo(RepoJSONMixin):
async with self.checkout(descendant_rev):
return discord.utils.get(self.available_modules, name=module_name)
p = await self._run(
ProcessFormatter().format(
self.GIT_GET_LAST_MODULE_OCCURRENCE_COMMIT,
path=self.folder_path,
descendant_rev=descendant_rev,
module_name=module_name,
)
git_command = ProcessFormatter().format(
self.GIT_GET_LAST_MODULE_OCCURRENCE_COMMIT,
path=self.folder_path,
descendant_rev=descendant_rev,
module_name=module_name,
)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.GitException(
"Git log failed for repo at path: {}".format(self.folder_path)
f"Git log failed for repo at path: {self.folder_path}", git_command
)
commit = p.stdout.decode().strip()
@@ -418,19 +412,18 @@ class Repo(RepoJSONMixin):
to get messages for.
:return: Git commit note log
"""
p = await self._run(
ProcessFormatter().format(
self.GIT_LOG,
path=self.folder_path,
old_rev=old_rev,
relative_file_path=relative_file_path,
)
git_command = ProcessFormatter().format(
self.GIT_LOG,
path=self.folder_path,
old_rev=old_rev,
relative_file_path=relative_file_path,
)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.GitException(
"An exception occurred while executing git log on"
" this repo: {}".format(self.folder_path)
f"An exception occurred while executing git log on this repo: {self.folder_path}",
git_command,
)
return p.stdout.decode().strip()
@@ -457,21 +450,24 @@ class Repo(RepoJSONMixin):
Full sha1 object name for provided revision.
"""
p = await self._run(
ProcessFormatter().format(self.GIT_GET_FULL_SHA1, path=self.folder_path, rev=rev)
git_command = ProcessFormatter().format(
self.GIT_GET_FULL_SHA1, path=self.folder_path, rev=rev
)
p = await self._run(git_command)
if p.returncode != 0:
stderr = p.stderr.decode().strip()
ambiguous_error = f"error: short SHA1 {rev} is ambiguous\nhint: The candidates are:\n"
if not stderr.startswith(ambiguous_error):
raise errors.UnknownRevision(f"Revision {rev} cannot be found.")
raise errors.UnknownRevision(f"Revision {rev} cannot be found.", git_command)
candidates = []
for match in self.AMBIGUOUS_ERROR_REGEX.finditer(stderr, len(ambiguous_error)):
candidates.append(Candidate(match["rev"], match["type"], match["desc"]))
if candidates:
raise errors.AmbiguousRevision(f"Short SHA1 {rev} is ambiguous.", candidates)
raise errors.UnknownRevision(f"Revision {rev} cannot be found.")
raise errors.AmbiguousRevision(
f"Short SHA1 {rev} is ambiguous.", git_command, candidates
)
raise errors.UnknownRevision(f"Revision {rev} cannot be found.", git_command)
return p.stdout.decode().strip()
@@ -530,7 +526,7 @@ class Repo(RepoJSONMixin):
env["LANGUAGE"] = "C"
kwargs["env"] = env
async with self._repo_lock:
p: CompletedProcess = await self._loop.run_in_executor(
p: CompletedProcess = await asyncio.get_running_loop().run_in_executor(
self._executor,
functools.partial(sp_run, *args, stdout=PIPE, stderr=PIPE, **kwargs),
)
@@ -554,17 +550,14 @@ class Repo(RepoJSONMixin):
return
exists, __ = self._existing_git_repo()
if not exists:
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
p = await self._run(
ProcessFormatter().format(self.GIT_CHECKOUT, path=self.folder_path, rev=rev)
)
git_command = ProcessFormatter().format(self.GIT_CHECKOUT, path=self.folder_path, rev=rev)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.UnknownRevision(
"Could not checkout to {}. This revision may not exist".format(rev)
f"Could not checkout to {rev}. This revision may not exist", git_command
)
await self._setup_repo()
@@ -619,25 +612,22 @@ class Repo(RepoJSONMixin):
"""
exists, path = self._existing_git_repo()
if exists:
raise errors.ExistingGitRepo("A git repo already exists at path: {}".format(path))
raise errors.ExistingGitRepo(f"A git repo already exists at path: {path}")
if self.branch is not None:
p = await self._run(
ProcessFormatter().format(
self.GIT_CLONE, branch=self.branch, url=self.url, folder=self.folder_path
)
git_command = ProcessFormatter().format(
self.GIT_CLONE, branch=self.branch, url=self.url, folder=self.folder_path
)
else:
p = await self._run(
ProcessFormatter().format(
self.GIT_CLONE_NO_BRANCH, url=self.url, folder=self.folder_path
)
git_command = ProcessFormatter().format(
self.GIT_CLONE_NO_BRANCH, url=self.url, folder=self.folder_path
)
p = await self._run(git_command)
if p.returncode:
# Try cleaning up folder
shutil.rmtree(str(self.folder_path), ignore_errors=True)
raise errors.CloningError("Error when running git clone.")
raise errors.CloningError("Error when running git clone.", git_command)
if self.branch is None:
self.branch = await self.current_branch()
@@ -657,17 +647,14 @@ class Repo(RepoJSONMixin):
"""
exists, __ = self._existing_git_repo()
if not exists:
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
p = await self._run(
ProcessFormatter().format(self.GIT_CURRENT_BRANCH, path=self.folder_path)
)
git_command = ProcessFormatter().format(self.GIT_CURRENT_BRANCH, path=self.folder_path)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.GitException(
"Could not determine current branch at path: {}".format(self.folder_path)
f"Could not determine current branch at path: {self.folder_path}", git_command
)
return p.stdout.decode().strip()
@@ -683,16 +670,13 @@ class Repo(RepoJSONMixin):
"""
exists, __ = self._existing_git_repo()
if not exists:
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
p = await self._run(
ProcessFormatter().format(self.GIT_CURRENT_COMMIT, path=self.folder_path)
)
git_command = ProcessFormatter().format(self.GIT_CURRENT_COMMIT, path=self.folder_path)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.CurrentHashError("Unable to determine commit hash.")
raise errors.CurrentHashError("Unable to determine commit hash.", git_command)
return p.stdout.decode().strip()
@@ -715,16 +699,15 @@ class Repo(RepoJSONMixin):
exists, __ = self._existing_git_repo()
if not exists:
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
p = await self._run(
ProcessFormatter().format(self.GIT_LATEST_COMMIT, path=self.folder_path, branch=branch)
git_command = ProcessFormatter().format(
self.GIT_LATEST_COMMIT, path=self.folder_path, branch=branch
)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.CurrentHashError("Unable to determine latest commit hash.")
raise errors.CurrentHashError("Unable to determine latest commit hash.", git_command)
return p.stdout.decode().strip()
@@ -751,10 +734,11 @@ class Repo(RepoJSONMixin):
if folder is None:
folder = self.folder_path
p = await self._run(ProcessFormatter().format(Repo.GIT_DISCOVER_REMOTE_URL, path=folder))
git_command = ProcessFormatter().format(Repo.GIT_DISCOVER_REMOTE_URL, path=folder)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.NoRemoteURL("Unable to discover a repo URL.")
raise errors.NoRemoteURL("Unable to discover a repo URL.", git_command)
return p.stdout.decode().strip()
@@ -773,19 +757,18 @@ class Repo(RepoJSONMixin):
await self.checkout(branch)
exists, __ = self._existing_git_repo()
if not exists:
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
raise errors.MissingGitRepo(f"A git repo does not exist at path: {self.folder_path}")
p = await self._run(
ProcessFormatter().format(self.GIT_HARD_RESET, path=self.folder_path, branch=branch)
git_command = ProcessFormatter().format(
self.GIT_HARD_RESET, path=self.folder_path, branch=branch
)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.HardResetError(
"Some error occurred when trying to"
" execute a hard reset on the repo at"
" the following path: {}".format(self.folder_path)
"Some error occurred when trying to execute a hard reset on the repo at"
f" the following path: {self.folder_path}",
git_command,
)
async def update(self) -> Tuple[str, str]:
@@ -795,7 +778,7 @@ class Repo(RepoJSONMixin):
-------
`tuple` of `str`
:py:code`(old commit hash, new commit hash)`
Raises
-------
`UpdateError` - if git pull results with non-zero exit code
@@ -804,12 +787,14 @@ class Repo(RepoJSONMixin):
await self.hard_reset()
p = await self._run(ProcessFormatter().format(self.GIT_PULL, path=self.folder_path))
git_command = ProcessFormatter().format(self.GIT_PULL, path=self.folder_path)
p = await self._run(git_command)
if p.returncode != 0:
raise errors.UpdateError(
"Git pull returned a non zero exit code"
" for the repo located at path: {}".format(self.folder_path)
f" for the repo located at path: {self.folder_path}",
git_command,
)
await self._setup_repo()
@@ -1114,7 +1099,7 @@ class RepoManager:
"""
repo = self.get_repo(name)
if repo is None:
raise errors.MissingGitRepo("There is no repo with the name {}".format(name))
raise errors.MissingGitRepo(f"There is no repo with the name {name}")
safe_delete(repo.folder_path)

View File

@@ -2,6 +2,7 @@ import datetime
import time
from enum import Enum
from random import randint, choice
from typing import Final
import aiohttp
import discord
from redbot.core import commands
@@ -31,6 +32,9 @@ class RPSParser:
self.choice = None
MAX_ROLL: Final[int] = 2 ** 64 - 1
@cog_i18n(_)
class General(commands.Cog):
"""General commands."""
@@ -87,15 +91,21 @@ class General(commands.Cog):
`<number>` defaults to 100.
"""
author = ctx.author
if number > 1:
if 1 < number <= MAX_ROLL:
n = randint(1, number)
await ctx.send(
"{author.mention} :game_die: {n} :game_die:".format(
author=author, n=humanize_number(n)
)
)
else:
elif number <= 1:
await ctx.send(_("{author.mention} Maybe higher than 1? ;P").format(author=author))
else:
await ctx.send(
_("{author.mention} Max allowed number is {maxamount}.").format(
author=author, maxamount=humanize_number(MAX_ROLL)
)
)
@commands.command()
async def flip(self, ctx, user: discord.Member = None):

View File

@@ -101,7 +101,7 @@ class Events(MixinMeta):
while None in name_list: # clean out null entries from a bug
name_list.remove(None)
if after.name in name_list:
# Ensure order is maintained without duplicates occuring
# Ensure order is maintained without duplicates occurring
name_list.remove(after.name)
name_list.append(after.name)
while len(name_list) > 20:

View File

@@ -7,7 +7,7 @@ from typing import cast, Optional, Union
import discord
from redbot.core import commands, i18n, checks, modlog
from redbot.core.utils.chat_formatting import pagify, humanize_number, format_perms_list
from redbot.core.utils.chat_formatting import pagify, humanize_number, bold, format_perms_list
from redbot.core.utils.mod import is_allowed_by_hierarchy, get_audit_reason
from .abc import MixinMeta
from .converters import RawUserIds
@@ -124,6 +124,19 @@ class KickBanMixin(MixinMeta):
elif not (0 <= days <= 7):
return _("Invalid days. Must be between 0 and 7.")
toggle = await self.settings.guild(guild).dm_on_kickban()
if toggle:
with contextlib.suppress(discord.HTTPException):
em = discord.Embed(
title=bold(_("You have been banned from {guild}.").format(guild=guild))
)
em.add_field(
name=_("**Reason**"),
value=reason if reason is not None else _("No reason was given."),
inline=False,
)
await user.send(embed=em)
audit_reason = get_audit_reason(author, reason)
queue_entry = (guild.id, user.id)
@@ -137,7 +150,7 @@ class KickBanMixin(MixinMeta):
except discord.Forbidden:
return _("I'm not allowed to do that.")
except Exception as e:
return e # TODO: impproper return type? Is this intended to be re-raised?
return e # TODO: improper return type? Is this intended to be re-raised?
if create_modlog_case:
try:
@@ -228,6 +241,18 @@ class KickBanMixin(MixinMeta):
await ctx.send(_("I cannot do that due to discord hierarchy rules"))
return
audit_reason = get_audit_reason(author, reason)
toggle = await self.settings.guild(guild).dm_on_kickban()
if toggle:
with contextlib.suppress(discord.HTTPException):
em = discord.Embed(
title=bold(_("You have been kicked from {guild}.").format(guild=guild))
)
em.add_field(
name=_("**Reason**"),
value=reason if reason is not None else _("No reason was given."),
inline=False,
)
await user.send(embed=em)
try:
await guild.kick(user, reason=audit_reason)
log.info("{}({}) kicked {}({})".format(author.name, author.id, user.name, user.id))
@@ -260,14 +285,19 @@ class KickBanMixin(MixinMeta):
self,
ctx: commands.Context,
user: discord.Member,
days: Optional[int] = 0,
days: Optional[int] = None,
*,
reason: str = None,
):
"""Ban a user from this server and optionally delete days of messages.
If days is not a number, it's treated as the first word of the reason.
Minimum 0 days, maximum 7. Defaults to 0."""
Minimum 0 days, maximum 7. If not specified, defaultdays setting will be used instead."""
author = ctx.author
guild = ctx.guild
if days is None:
days = await self.settings.guild(guild).default_days()
result = await self.ban_user(
user=user, ctx=ctx, days=days, reason=reason, create_modlog_case=True
@@ -286,7 +316,7 @@ class KickBanMixin(MixinMeta):
self,
ctx: commands.Context,
user_ids: commands.Greedy[RawUserIds],
days: Optional[int] = 0,
days: Optional[int] = None,
*,
reason: str = None,
):
@@ -294,7 +324,6 @@ class KickBanMixin(MixinMeta):
User IDs need to be provided in order to ban
using this command"""
days = cast(int, days)
banned = []
errors = {}
@@ -321,6 +350,9 @@ class KickBanMixin(MixinMeta):
await ctx.send_help()
return
if days is None:
days = await self.settings.guild(guild).default_days()
if not (0 <= days <= 7):
await ctx.send(_("Invalid days. Must be between 0 and 7."))
return

View File

@@ -51,6 +51,8 @@ class Mod(
"delete_delay": -1,
"reinvite_on_unban": False,
"current_tempbans": [],
"dm_on_kickban": False,
"default_days": 0,
}
default_channel_settings = {"ignored": False}

View File

@@ -21,11 +21,14 @@ class ModSettings(MixinMeta):
if ctx.invoked_subcommand is None:
guild = ctx.guild
# Display current settings
delete_repeats = await self.settings.guild(guild).delete_repeats()
ban_mention_spam = await self.settings.guild(guild).ban_mention_spam()
respect_hierarchy = await self.settings.guild(guild).respect_hierarchy()
delete_delay = await self.settings.guild(guild).delete_delay()
reinvite_on_unban = await self.settings.guild(guild).reinvite_on_unban()
data = await self.settings.guild(guild).all()
delete_repeats = data["delete_repeats"]
ban_mention_spam = data["ban_mention_spam"]
respect_hierarchy = data["respect_hierarchy"]
delete_delay = data["delete_delay"]
reinvite_on_unban = data["reinvite_on_unban"]
dm_on_kickban = data["dm_on_kickban"]
default_days = data["default_days"]
msg = ""
msg += _("Delete repeats: {num_repeats}\n").format(
num_repeats=_("after {num} repeats").format(num=delete_repeats)
@@ -48,6 +51,15 @@ class ModSettings(MixinMeta):
msg += _("Reinvite on unban: {yes_or_no}\n").format(
yes_or_no=_("Yes") if reinvite_on_unban else _("No")
)
msg += _("Send message to users on kick/ban: {yes_or_no}\n").format(
yes_or_no=_("Yes") if dm_on_kickban else _("No")
)
if default_days:
msg += _(
"Default message history delete on ban: Previous {num_days} days\n"
).format(num_days=default_days)
else:
msg += _("Default message history delete on ban: Don't delete any\n")
await ctx.send(box(msg))
@modset.command()
@@ -199,3 +211,43 @@ class ModSettings(MixinMeta):
command=f"{ctx.prefix}unban"
)
)
@modset.command()
@commands.guild_only()
async def dm(self, ctx: commands.Context, enabled: bool = None):
"""Toggle whether a message should be sent to a user when they are kicked/banned.
If this option is enabled, the bot will attempt to DM the user with the guild name
and reason as to why they were kicked/banned.
"""
guild = ctx.guild
if enabled is None:
setting = await self.settings.guild(guild).dm_on_kickban()
await ctx.send(
_("DM when kicked/banned is currently set to: {setting}").format(setting=setting)
)
return
await self.settings.guild(guild).dm_on_kickban.set(enabled)
if enabled:
await ctx.send(_("Bot will now attempt to send a DM to user before kick and ban."))
else:
await ctx.send(
_("Bot will no longer attempt to send a DM to user before kick and ban.")
)
@modset.command()
@commands.guild_only()
async def defaultdays(self, ctx: commands.Context, days: int = 0):
"""Set the default number of days worth of messages to be deleted when a user is banned.
The number of days must be between 0 and 7.
"""
guild = ctx.guild
if not (0 <= days <= 7):
return await ctx.send(_("Invalid number of days. Must be between 0 and 7."))
await self.settings.guild(guild).default_days.set(days)
await ctx.send(
_("{days} days worth of messages will be deleted when a user is banned.").format(
days=days
)
)

View File

@@ -26,6 +26,13 @@ class ModLog(commands.Cog):
"""Manage modlog settings."""
pass
@checks.is_owner()
@modlogset.command(hidden=True, name="fixcasetypes")
async def reapply_audittype_migration(self, ctx: commands.Context):
"""Command to fix misbehaving casetypes."""
await modlog.handle_auditype_key()
await ctx.tick()
@modlogset.command()
@commands.guild_only()
async def modlog(self, ctx: commands.Context, channel: discord.TextChannel = None):

View File

@@ -61,7 +61,7 @@ At which Arena can you unlock X-Bow?:
- 6
- Builder's Workshop
At which Arena do you get a chance for Legendary cards to appear in the shop?:
- Hog Mountian
- Hog Mountain
- A10
- 10
- Arena 10

View File

@@ -375,7 +375,7 @@ Porky Pig had a girlfriend named ________?:
Randy Travis said his love was 'deeper than the ______'?:
- Holler
Richard Strauss' majestic overture "Also Sprach Zarathustra" was the theme music for which Stanley Kubrick film?:
- "2001: A Space Odyessy"
- "2001: A Space Odyssey"
Rolling Stones first hit was written by what group?:
- The Beatles
Russian modernist Igor _________?:

View File

@@ -12,7 +12,6 @@ from redbot.cogs.warnings.helpers import (
from redbot.core import Config, checks, commands, modlog
from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.mod import is_admin_or_superior
from redbot.core.utils.chat_formatting import warning, pagify
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
@@ -342,30 +341,16 @@ class Warnings(commands.Cog):
@commands.command()
@commands.guild_only()
async def warnings(
self, ctx: commands.Context, user: Optional[Union[discord.Member, int]] = None
):
"""List the warnings for the specified user.
@checks.admin()
async def warnings(self, ctx: commands.Context, user: Union[discord.Member, int]):
"""List the warnings for the specified user."""
Omit `<user>` to see your own warnings.
Note that showing warnings for users other than yourself requires
appropriate permissions.
"""
if user is None:
user = ctx.author
else:
if not await is_admin_or_superior(self.bot, ctx.author):
return await ctx.send(
warning(_("You are not allowed to check warnings for other users!"))
)
try:
userid: int = user.id
except AttributeError:
userid: int = user
user = ctx.guild.get_member(userid)
user = user or namedtuple("Member", "id guild")(userid, ctx.guild)
try:
userid: int = user.id
except AttributeError:
userid: int = user
user = ctx.guild.get_member(userid)
user = user or namedtuple("Member", "id guild")(userid, ctx.guild)
msg = ""
member_settings = self.config.member(user)
@@ -389,6 +374,35 @@ class Warnings(commands.Cog):
pagify(msg, shorten_by=58), box_lang=_("Warnings for {user}").format(user=user)
)
@commands.command()
@commands.guild_only()
async def mywarnings(self, ctx: commands.Context):
"""List warnings for yourself."""
user = ctx.author
msg = ""
member_settings = self.config.member(user)
async with member_settings.warnings() as user_warnings:
if not user_warnings.keys(): # no warnings for the user
await ctx.send(_("You have no warnings!"))
else:
for key in user_warnings.keys():
mod_id = user_warnings[key]["mod"]
mod = ctx.bot.get_user(mod_id) or _("Unknown Moderator ({})").format(mod_id)
msg += _(
"{num_points} point warning {reason_name} issued by {user} for "
"{description}\n"
).format(
num_points=user_warnings[key]["points"],
reason_name=key,
user=mod,
description=user_warnings[key]["description"],
)
await ctx.send_interactive(
pagify(msg, shorten_by=58), box_lang=_("Warnings for {user}").format(user=user)
)
@commands.command()
@commands.guild_only()
@checks.admin_or_permissions(ban_members=True)