mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2026-02-05 15:03:01 -05:00
Merge branch 'V3/develop' into V3/feature/mutes
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 _________?:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user