6 Commits
Author SHA1 Message Date
matthew bd0b04bf28 Merge pull request #44 from strNophix/lastfm-scrobbler
Plugins + lastfm scrobbler
2021-12-08 19:39:01 +01:00
niku 935383e7c5 Merge pull request #43 from strNophix/ensure-queue-size
fill_player_queue now recursively refetches until queue size is ensured
2021-12-07 06:41:16 +01:00
niku fdc9dc16ed Readjusted slash_command bot kwarg 2021-12-06 21:02:10 +01:00
niku 0dc2e0a7ca Added plugins key to the sample config 2021-12-06 20:22:40 +01:00
niku 2a24a4b5f0 Commit hoarding is a terrible practice 2021-12-06 20:14:24 +01:00
niku 0a14d9a93c fill_player_queue now recursively refetches until queue size is ensured 2021-12-03 21:24:57 +01:00
45 changed files with 2709 additions and 1058 deletions
-61
View File
@@ -1,61 +0,0 @@
server: # REST and WS server
port: 2333
address: 0.0.0.0
lavalink:
server:
password: "youshallnotpass"
sources:
youtube: true
bandcamp: true
soundcloud: true
twitch: true
vimeo: true
http: true
local: false
bufferDurationMs: 400 # The duration of the NAS buffer. Higher values fare better against longer GC pauses. Minimum of 40ms, lower values may introduce pauses.
frameBufferDurationMs: 5000 # How many milliseconds of audio to keep buffered
opusEncodingQuality: 10 # Opus encoder quality. Valid values range from 0 to 10, where 10 is best quality but is the most expensive on the CPU.
resamplingQuality: LOW # Quality of resampling operations. Valid values are LOW, MEDIUM and HIGH, where HIGH uses the most CPU.
trackStuckThresholdMs: 10000 # The threshold for how long a track can be stuck. A track is stuck if does not return any audio data.
useSeekGhosting: true # Seek ghosting is the effect where whilst a seek is in progress, the audio buffer is read from until empty, or until seek is ready.
youtubePlaylistLoadLimit: 6 # Number of pages at 100 each
playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds
youtubeSearchEnabled: true
soundcloudSearchEnabled: true
gc-warnings: true
#ratelimit:
#ipBlocks: ["1.0.0.0/8", "..."] # list of ip blocks
#excludedIps: ["...", "..."] # ips which should be explicit excluded from usage by lavalink
#strategy: "RotateOnBan" # RotateOnBan | LoadBalance | NanoSwitch | RotatingNanoSwitch
#searchTriggersFail: true # Whether a search 429 should trigger marking the ip as failing
#retryLimit: -1 # -1 = use default lavaplayer value | 0 = infinity | >0 = retry will happen this numbers times
#youtubeConfig: # Required for avoiding all age restrictions by YouTube, some restricted videos still can be played without.
#email: "" # Email of Google account
#password: "" # Password of Google account
#httpConfig: # Useful for blocking bad-actors from ip-grabbing your music node and attacking it, this way only the http proxy will be attacked
#proxyHost: "localhost" # Hostname of the proxy, (ip or domain)
#proxyPort: 3128 # Proxy port, 3128 is the default for squidProxy
#proxyUser: "" # Optional user for basic authentication fields, leave blank if you don't use basic auth
#proxyPassword: "" # Password for basic authentication
metrics:
prometheus:
enabled: false
endpoint: /metrics
sentry:
dsn: ""
environment: ""
logging:
file:
path: ./logs/
level:
root: INFO
lavalink: INFO
logback:
rollingpolicy:
max-file-size: 1GB
max-history: 30
+1 -1
View File
@@ -1,5 +1,5 @@
default_language_version:
python: python3.10
python: python3.9
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
+97 -70
View File
@@ -5,7 +5,6 @@ from typing import Dict
from typing import List
from typing import Sequence
from typing import TYPE_CHECKING
import itertools
import aioredis
import discord
@@ -23,69 +22,78 @@ from discord.ext.commands.errors import ExtensionNotFound
from discord.ext.commands.errors import NoEntryPointError
from context import CustomContext
from tunebot.plugins import FileSystemPluginLoader
from tunebot.plugins import SimplePluginManager
from tunebot.redis import GlobalRedisAutoJoin
from tunebot.redis import GlobalRedisPlaylist
from tunebot.redis import GlobalRedisPlaylistSource
from tunebot.redis import RedisAutoJoin
from tunebot.redis import RedisPlaylistSource
from utils.assets import process_colours
from lavalink_player import CustomPlayer
from utils.log import logger
from tunebot.redis import GlobalRedisUtils
if TYPE_CHECKING:
from tunebot import AutoJoin
from tunebot import PlaylistSource
from tunebot import PluginManagerBase
from tunebot import GlobalPlaylist
from tunebot import GlobalPlaylistSource
from tunebot import GlobalAutoJoin
from tunebot.context import ContextLike
from tunebot import PluginLoaderBase
from tunebot import PluginManagerBase
from tunebot import GlobalUtils
config_path = "config.json"
if len(sys.argv) > 1:
config_path = sys.argv[1]
config = json.load(open(config_path, "r", encoding="utf-8"))
redis_prefix = config["redis_prefix"]
colors: Dict[str, Color] = process_colours(config["colors"])
status_messages: Sequence[str] = config["info"]["status"]["messages"]
status_interval: int = config["info"]["status"]["interval"]
ColorDict = dict[str, "Color"]
class TuneBot(commands.Bot):
lavalink: "lavalink.Client"
invite_link: str
status_cycle = itertools.cycle(status_messages)
lavalink: lavalink.Client
invite_link: str = ""
initial_cog_names: list[str]
colors: ColorDict
global_autojoin: "GlobalAutoJoin"
global_playlist: "GlobalPlaylist"
global_playlist_source: "GlobalPlaylistSource"
global_utils: "GlobalUtils"
plugin_loader: "PluginLoaderBase"
plugin_manager: "PluginManagerBase"
def __init__(self, config: Dict[Any, Any]):
intents = discord.Intents(
voice_states=True, guild_messages=True, guilds=True, messages=True
voice_states=True,
guild_messages=True,
guilds=True,
messages=True,
members=True,
)
self.rpc_is_help_message = True
self.update_status.start()
self.config = config
self.initial_cog_names: List[str] = self.config.get("cogs", [])
self.initial_cog_names = self.config.get("cogs", [])
self.colors = self.process_colours(config.get("colors", []))
self.redis_prefix = self.config["redis_prefix"]
self._redis_client: Redis = aioredis.from_url(
self.config["redis_url"], encoding="utf-8", decode_responses=True
)
self.global_autojoin: GlobalAutoJoin = GlobalRedisAutoJoin(
self._redis_client,
self.redis_prefix,
self.global_autojoin = GlobalRedisAutoJoin(
self._redis_client, self.redis_prefix
)
self.global_playlist: GlobalPlaylist = GlobalRedisPlaylist(
self._redis_client,
self.redis_prefix,
self.global_playlist = GlobalRedisPlaylist(
self._redis_client, self.redis_prefix
)
self.global_playlist_source: GlobalPlaylistSource = GlobalRedisPlaylistSource(
self._redis_client,
self.redis_prefix,
self.global_playlist_source = GlobalRedisPlaylistSource(
self._redis_client, self.redis_prefix
)
self.global_utils = GlobalRedisUtils(self._redis_client, self.redis_prefix)
self.invite_link: str = ""
self.plugin_loader = FileSystemPluginLoader(self)
self.plugin_manager = SimplePluginManager(self)
self.colors = colors
slash_guilds = None
if len(self.config["slash_command_guilds"]) > 0:
slash_guilds = self.config["slash_command_guilds"]
super().__init__(
command_prefix=self.prefix_callable,
@@ -94,76 +102,95 @@ class TuneBot(commands.Bot):
case_insensitive=False,
fetch_offline_members=False,
intents=intents,
slash_commands=True,
slash_command_guilds=slash_guilds,
)
async def setup_hook(self) -> None:
await self.load_cogs(self.initial_cog_names)
self.update_status.start()
self.loop.create_task(self.async_init())
async def prefix_callable(self, _, msg: Message):
logger.info(f"{self.config['prefix']=}")
return commands.when_mentioned_or(self.config["prefix"])(self, msg)
async def async_init(self):
self.init_plugins()
self.load_cogs(self.initial_cog_names)
async def load_cogs(self, cog_names: Sequence[str]):
async def prefix_callable(self, _, msg: Message) -> List[str]:
return commands.when_mentioned_or(*self.config["prefixes"])(self, msg)
def load_cogs(self, cog_names: Sequence[str]):
for cog in cog_names:
try:
await self.load_extension(cog)
logger.info(f"Succesfully loaded extension {cog}.")
self.load_extension(cog)
print(f"[✓] loaded extension: {cog}.")
except (
ExtensionNotFound,
ExtensionAlreadyLoaded,
NoEntryPointError,
ExtensionFailed,
) as e:
logger.info(f"Failed to load extension {cog}.\n\t{e}")
await self.tree.sync()
print(f"[x] failed loading extension: {cog}.\n\t{e}", file=sys.stderr)
async def on_ready(self):
self.invite_link = f"https://discord.com/oauth2/authorize?client_id={self.user.id}&permissions=3230720&scope=bot%20applications.commands"
logger.info(f"Logged in as: {self.user}")
logger.info(f"Version: {discord.__version__}")
logger.info(f"Invite: {self.invite_link}")
print(f"Logged in as: {self.user}")
print(f"Version: {discord.__version__}")
print(f"Invite: {self.invite_link}")
self.lavalink = self.create_lavalink(self.user.id)
ll = self.config["lavalink"]
self.lavalink = lavalink.Client(self.user.id)
self.lavalink.add_node(
ll["host"], ll["port"], ll["password"], ll["region"], ll["name"]
)
def create_lavalink(self, user_id: int) -> "lavalink.Client":
nodes = self.config["nodes"]
client: lavalink.Client = lavalink.Client(user_id, player=CustomPlayer)
# get the lavalink nodes from the config
for node in nodes:
client.add_node(nodes[node]["host"], nodes[node]["port"], nodes[node]["password"], nodes[node]["region"], nodes[node]["name"])
return client
def process_colours(self, colors: Dict[str, str]) -> ColorDict:
colour_dict: Dict[str, Color] = {}
for name, color in colors.items():
colour_dict[name] = Color(int(color, 16))
return colour_dict
async def get_context(self, message: Message, *, cls=CustomContext):
return await super().get_context(message, cls=cls)
def autojoin_context(self, ctx: "ContextLike") -> "AutoJoin":
return RedisAutoJoin(ctx)
def init_plugins(self):
for plug_id, plug_conf in self.config["plugins"].items():
if not plug_conf.get("enabled"):
continue
def playlist_source_context(self, ctx: "ContextLike") -> "PlaylistSource":
return RedisPlaylistSource(ctx)
try:
plugin = self.plugin_loader.load_plugin(plug_conf)
self.plugin_manager.enable_plugin(plug_id, plugin)
print(f"[✓] loaded plugin: {plug_id}")
except Exception as e:
self.plugin_manager.remove_plugin(plug_id)
print(f"[x] failed loading plugin: {plug_id}\n{e}")
@tasks.loop(seconds=status_interval)
@tasks.loop(seconds=30)
async def update_status(self):
await self.wait_until_ready()
name = next(self.status_cycle)
activity = discord.Activity(name=name, type=ActivityType.listening)
bot_prefix = self.config["prefixes"][0]
if self.rpc_is_help_message:
title = f"for {bot_prefix}connect | {bot_prefix}help"
activity = discord.Activity(name=title, type=ActivityType.watching)
else:
activity = discord.Activity(name="Some song", type=ActivityType.playing)
self.rpc_is_help_message = not self.rpc_is_help_message
await self.change_presence(activity=activity)
client = TuneBot(config)
if __name__ == "__main__":
try:
import uvloop
uvloop.install()
logger.info("Succesfully initialized uvloop")
print("Succesfully initialized uvloop")
except ModuleNotFoundError:
pass
config_path = "config.json"
if len(sys.argv) > 1:
config_path = sys.argv[1]
config = json.load(open(config_path, "r", encoding="utf-8"))
token = config.pop("token")
client.run(token, reconnect=True)
TuneBot(config).run(token, reconnect=True)
+125
View File
@@ -0,0 +1,125 @@
import datetime
import time
from typing import Optional
import discord
import humanize
import lavalink
from discord.ext import commands
from discord.ext.commands import Context
from bot import TuneBot
from context import CustomContext
from utils.classes import BaseCog
from utils.EmbedGenerator import EmbedGenerator
from utils.paginator import HelpPaginator
class InformationCog(BaseCog, name="Information"):
@commands.command(name="ping", aliases=["pong"])
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
async def ping(self, ctx: Context):
"""Test the latency"""
avatar = ctx.author.avatar.with_static_format("jpeg")
emoji = discord.utils.get(ctx.bot.emojis, name="loading")
start = time.monotonic()
msg = await ctx.send(
embed=discord.Embed(description=f"{emoji} Calculating ping")
)
millis = (time.monotonic() - start) * 1000
heartbeat = ctx.bot.latency * 1000
embed = discord.Embed(color=discord.Color.blue())
embed.add_field(
name=":heartbeat: Heartbeat", value=f"`{heartbeat:,.2f}ms`", inline=True
)
embed.add_field(
name=":file_cabinet: ACK", value=f"`{millis:,.2f}ms`", inline=True
)
embed.set_footer(text=f"Requested by: {ctx.author}", icon_url=f"{avatar}")
await msg.edit(embed=embed)
@commands.command(name="invite")
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
async def invite(self, ctx: Context):
"""Gets the invite link"""
await EmbedGenerator.Message(
ctx, "Add our bot to your server:", self.bot.invite_link
)
@commands.command(name="wlinfo")
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
async def wlinfo(self, ctx: CustomContext):
"""Retrieve various node/server/player information"""
player = self.bot.lavalink.player_manager.get(ctx.guild.id)
nodes = self.bot.lavalink.node_manager.available_nodes
used = humanize.naturalsize(sum([n.stats.memory_used for n in nodes]))
total = humanize.naturalsize(sum([n.stats.memory_allocated for n in nodes]))
free = humanize.naturalsize(sum([n.stats.memory_free for n in nodes]))
cpu = sum([n.stats.cpu_cores for n in nodes])
fmt = (
f"**Lavalink:** `{lavalink.__version__}`\n\n"
f"Connected to `{len(self.bot.lavalink.node_manager.available_nodes)}` nodes.\n"
# f"Best available Node `{self.bot.lavalink.node_manager.find_ideal_node().name.__repr__()}`\n"
f"`{len(self.bot.lavalink.player_manager.players)}` players are distributed on nodes.\n"
f"`{sum([n.stats.players for n in nodes])}` players are distributed on server.\n"
f"`{sum([n.stats.playing_players for n in nodes])}` players are playing on server.\n\n"
f"Server Memory: `{used}/{total}` | `({free} free)`\n"
f"Server CPU: `{cpu}`\n\n"
# f"Server Uptime: `{datetime.timedelta(milliseconds=node.stats.uptime)}`"
)
await ctx.send(fmt)
@commands.command(name="help", aliases=["about", "info"])
@commands.cooldown(1, 1, commands.BucketType.user)
async def about(
self,
ctx: Context,
command: Optional[str] = commands.Option(
description="Show help for a command or category"
),
):
"""Retrieve a list of possible commands"""
if command:
entity = self.bot.get_cog(command) or self.bot.get_command(command)
if entity is None:
clean = command.replace("@", "@\u200b")
return await ctx.send(f'Command or category "{clean}" not found.')
elif isinstance(entity, discord.ext.commands.Command):
p = await HelpPaginator.from_command(ctx, entity)
else:
p = await HelpPaginator.from_cog(ctx, entity)
return await p.paginate()
info = self.bot.config["info"]
title = info["name"] + " Help"
descr = info["description"]
color = self.bot.colors["embed"]
embed = discord.Embed(color=color, title=title, description=descr)
display_cogs = {
"Information": ":information_source:",
"Music": ":musical_note:",
"Settings": ":gear:",
}
for cog_name, cog_icon in display_cogs.items():
cog = self.bot.cogs.get(cog_name)
if not cog:
continue
cogname_str = f"{cog_icon} {cog_name}"
commands = [f"`{cmd.name}`" for cmd in cog.get_commands() if not cmd.hidden]
commands_str = ", ".join(commands)
embed.add_field(name=cogname_str, value=commands_str, inline=False)
avatar = ctx.author.avatar.with_static_format("jpeg")
embed.set_footer(text=f"Requested by: {ctx.author}", icon_url=avatar)
await ctx.send(embed=embed)
def setup(bot: TuneBot):
bot.remove_command("help")
bot.add_cog(InformationCog(bot))
-17
View File
@@ -1,17 +0,0 @@
import typing
from cogs.information.interactions import InfoCommands
from utils.classes import BaseCog
if typing.TYPE_CHECKING:
from bot import TuneBot
class InformationCog(BaseCog, name="Information"):
pass
async def setup(bot: "TuneBot"):
bot.remove_command("help")
await bot.add_cog(InformationCog(bot))
bot.tree.add_command(InfoCommands(bot), override=True)
-64
View File
@@ -1,64 +0,0 @@
import typing
import humanize
import lavalink
from discord import app_commands
from discord import Interaction
from utils.embed import create_embed
if typing.TYPE_CHECKING:
from bot import TuneBot
class InfoCommands(app_commands.Group):
def __init__(self, bot: "TuneBot"):
super().__init__(name="bot", description="Some extra info")
self.bot: "TuneBot" = bot
@app_commands.command(name="invite", description="I'd happily join your server")
async def invite(self, ctx: Interaction):
embed = create_embed(ctx.user)
embed.title = "My invite link"
embed.description = f"[{self.bot.invite_link}]({self.bot.invite_link})"
await ctx.response.send_message(embed=embed)
@app_commands.command(
name="stats", description="Some node/server/player information"
)
async def wlinfo(self, ctx: Interaction):
embed = create_embed(ctx.user)
nodes = self.bot.lavalink.node_manager.available_nodes
used = humanize.naturalsize(sum([n.stats.memory_used for n in nodes]))
total = humanize.naturalsize(sum([n.stats.memory_allocated for n in nodes]))
free = humanize.naturalsize(sum([n.stats.memory_free for n in nodes]))
cpu = sum([n.stats.cpu_cores for n in nodes])
info = self.bot.config["info"]
embed.title = info["name"] + " stats"
embed.description = (
f"**Lavalink:** `{lavalink.__version__}`\n\n"
f"Connected to `{len(self.bot.lavalink.node_manager.available_nodes)}` nodes.\n"
f"`{len(self.bot.lavalink.player_manager.players)}` players are distributed on nodes.\n"
f"`{sum([n.stats.players for n in nodes])}` players are distributed on server.\n"
f"`{sum([n.stats.playing_players for n in nodes])}` players are playing on server.\n\n"
f"Server Memory: `{used}/{total}` | `({free} free)`\n"
f"Server CPU: `{cpu}`\n\n"
)
await ctx.response.send_message(embed=embed)
@app_commands.command(name="tos", description="Terms of Service")
async def tos(self, ctx: Interaction):
embed = create_embed(ctx.user)
embed.title = "Terms of Service (ToS)"
embed.description = "[https://exobot.site/static/tos.html](https://exobot.site/static/tos.html)"
await ctx.response.send_message(embed=embed)
@app_commands.command(name="privacy", description="Privacy Policy")
async def privacy(self, ctx: Interaction):
embed = create_embed(ctx.user)
embed.title = "Privacy Policy"
embed.description = "[https://exobot.site/static/privacy.html](https://exobot.site/static/privacy.html)"
await ctx.response.send_message(embed=embed)
+324
View File
@@ -0,0 +1,324 @@
import asyncio
import datetime
import re
from typing import Any
from typing import Optional
from typing import TYPE_CHECKING
import discord
import lavalink
from discord import Embed
from discord.channel import TextChannel
from discord.ext import commands
from discord.ext.commands.errors import CommandError
from lavalink.models import AudioTrack
from lavalink.models import DefaultPlayer
from bot import TuneBot
from context import CustomContext
from tunebot.plugins import ServiceEvent
from utils.classes import BaseCog
from utils.EmbedGenerator import EmbedGenerator
from utils.exceptions import EmbeddedCommandException
if TYPE_CHECKING:
from discord import VoiceChannel
url_rx = re.compile(r"https?://(?:www\.)?.+")
class LavalinkVoiceClient(discord.VoiceClient):
def __init__(self, client: discord.Client, channel: discord.abc.Connectable):
self.client = client
self.channel = channel
self.lavalink = self.client.lavalink
async def on_voice_server_update(self, data):
# the data needs to be transformed before being handed down to
# voice_update_handler
lavalink_data = {"t": "VOICE_SERVER_UPDATE", "d": data}
await self.lavalink.voice_update_handler(lavalink_data)
async def on_voice_state_update(self, data):
# the data needs to be transformed before being handed down to
# voice_update_handler
lavalink_data = {"t": "VOICE_STATE_UPDATE", "d": data}
await self.lavalink.voice_update_handler(lavalink_data)
async def connect(self, *, timeout: float, reconnect: bool) -> None:
"""
Connect the bot to the voice channel and create a player_manager
if it doesn't exist yet.
"""
# ensure there is a player_manager when creating a new voice_client
self.lavalink.player_manager.create(guild_id=self.channel.guild.id)
await self.channel.guild.change_voice_state(channel=self.channel)
async def disconnect(self, *, force: bool) -> None:
"""
Handles the disconnect.
Cleans up running player and leaves the voice client.
"""
player = self.lavalink.player_manager.get(self.channel.guild.id)
# no need to disconnect if we are not connected
if not force and not player.is_connected:
return
# None means disconnect
await self.channel.guild.change_voice_state(channel=None)
# update the channel_id of the player to None
# this must be done because the on_voice_state_update that
# would set channel_id to None doesn't get dispatched after the
# disconnect
player.channel_id = None
self.cleanup()
class Music(BaseCog):
@commands.Cog.listener()
async def on_ready(self):
while not self.is_lavalink_ready():
await asyncio.sleep(1)
self.bot.lavalink.add_event_hook(self.track_hook)
redis_result = await self.bot.global_autojoin.fetch_channels()
for guild_id, (voicechannel_id, textchannel_id) in redis_result.items():
player = self.bot.lavalink.player_manager.create(guild_id)
player.store("channel", textchannel_id)
voice_channel = await self.bot.fetch_channel(voicechannel_id)
await voice_channel.connect(cls=LavalinkVoiceClient)
if not player.is_playing:
await self.fill_player_queue(
player, self.bot.config["queue_buffer_size"]
)
await player.play()
textchannel = await self.bot.fetch_channel(textchannel_id)
await textchannel.send("Automatically joined the voice channel")
async def fill_player_queue(self, player: DefaultPlayer, buffer: Optional[int] = 1):
queries = await self.bot.global_playlist.pick_random(buffer)
failed_queries: list[str] = []
for query in queries:
result = await player.node.get_tracks(query)
if not result or not result["tracks"]:
failed_queries.append(query)
continue
track = lavalink.models.AudioTrack(
result["tracks"][0], self.bot.user.id, recommended=False
)
player.add(requester=self.bot.user.id, track=track)
if len(failed_queries) > 0:
await self.bot.global_playlist.remove_tracks(failed_queries)
await self.fill_player_queue(player, len(failed_queries))
async def create_track_embed(self, track: AudioTrack) -> Embed:
embed_color = self.bot.colors["embed"]
embed = discord.Embed(
title=f"Now playing...",
colour=embed_color,
)
embed.description = f"[{track.title}]({track.uri})"
embed.set_thumbnail(
url=f"https://i3.ytimg.com/vi/{track.identifier}/mqdefault.jpg"
)
try:
duration = str(datetime.timedelta(milliseconds=int(track.duration)))
except OverflowError:
duration = "🔴 LIVE"
embed.add_field(name="Duration", value=duration)
embed.add_field(name="Author", value=track.author)
return embed
def cog_unload(self):
"""Cog unload handler. This removes any event hooks that were registered."""
self.bot.lavalink._event_hooks.clear()
async def cog_before_invoke(self, ctx):
"""Command before-invoke handler."""
guild_check = ctx.guild is not None
# This is essentially the same as `@commands.guild_only()`
# except it saves us repeating ourselves (and also a few lines).
if not self.is_lavalink_ready():
await ctx.send("Still starting please wait a moment.")
if guild_check:
await self.ensure_voice(ctx)
# Ensure that the bot and command author share a mutual voicechannel.
return guild_check
async def cog_command_error(self, ctx: CustomContext, error: CommandError):
if isinstance(error, commands.CommandInvokeError):
await ctx.send(error.original)
# The above handles errors thrown in this cog and shows them to the user.
# This shouldn't be a problem as the only errors thrown in this cog are from `ensure_voice`
# which contain a reason string, such as "Join a voicechannel" etc. You can modify the above
# if you want to do things differently.
elif isinstance(error, EmbeddedCommandException):
await error.send(ctx)
async def ensure_voice(self, ctx):
"""This check ensures that the bot and command author are in the same voicechannel."""
player = self.bot.lavalink.player_manager.create(
ctx.guild.id, endpoint=str(ctx.guild.region)
)
# Create returns a player if one exists, otherwise creates.
# This line is important because it ensures that a player always exists for a guild.
# Most people might consider this a waste of resources for guilds that aren't playing, but this is
# the easiest and simplest way of ensuring players are created.
# These are commands that require the bot to join a voicechannel (i.e. initiating playback).
# Commands such as volume/skip etc don't require the bot to be in a voicechannel so don't need listing here.
should_connect = ctx.command.name in ("connect",)
if not ctx.author.voice or not ctx.author.voice.channel:
# Our cog_command_error handler catches this and sends it to the voicechannel.
# Exceptions allow us to "short-circuit" command invocation via checks so the
# execution state of the command goes no further.
raise commands.CommandInvokeError("Join a voicechannel first.")
if not player.is_connected:
if not should_connect:
bot_name = self.bot.config["info"]["name"]
embed = await EmbedGenerator.Message(
ctx,
f"{bot_name} is not connected",
"However you can start playing music using `/connect`",
no_send=True,
)
raise EmbeddedCommandException(embed)
permissions = ctx.author.voice.channel.permissions_for(ctx.me)
if (
not permissions.connect or not permissions.speak
): # Check user limit too?
raise commands.CommandInvokeError(
"I need the `CONNECT` and `SPEAK` permissions."
)
player.store("channel", ctx.channel.id)
await ctx.author.voice.channel.connect(cls=LavalinkVoiceClient)
else:
if int(player.channel_id) != ctx.author.voice.channel.id:
raise commands.CommandInvokeError("You need to be in my voicechannel.")
async def track_hook(self, event):
if isinstance(event, lavalink.events.QueueEndEvent):
# When this track_hook receives a "QueueEndEvent" from lavalink.py
# it indicates that there are no tracks left in the player's queue.
# To save on resources, we can tell the bot to disconnect from the voicechannel.
guild_id = int(event.player.guild_id)
guild = self.bot.get_guild(guild_id)
await guild.voice_client.disconnect(force=True)
elif isinstance(event, lavalink.events.TrackStartEvent):
if channel := event.player.fetch("channel"):
channel_id = int(channel)
channel: TextChannel = self.bot.get_channel(channel_id)
embed = await self.create_track_embed(event.player.current)
await channel.send(embed=embed)
elif isinstance(event, lavalink.events.TrackEndEvent):
await self.fill_player_queue(event.player, 1)
if event.reason == "FINISHED":
if event.player.channel_id:
channel_id = int(event.player.channel_id)
voice_channel: "VoiceChannel" = self.bot.get_channel(channel_id)
in_voice: list[int] = []
for member in voice_channel.members:
if member.bot:
continue
in_voice.append(member.id)
payload: dict[str, Any] = {
"last_track": event.track,
"in_voice": in_voice,
}
s = ServiceEvent.TRACK_ENDED
await self.bot.plugin_manager.dispatch(s, payload)
else:
fmt = f"Failed dispatching for TrackEnd event, missing channel_id on player"
print(fmt)
@commands.command(name="connect", aliases=["p", "play", "join"])
async def play(self, ctx: CustomContext):
"""Start the radio"""
# Get the player for this guild from cache.
if ctx.player.is_connected:
await ctx.send("Already connected")
return
await self.fill_player_queue(
ctx.player, self.bot.config["queue_buffer_size"] + 1
)
if not ctx.player.is_playing:
await ctx.player.play()
await EmbedGenerator.Title(ctx, "*⃣ | Connected.")
return
@commands.command(name="skip", aliases=["next"])
async def skip(self, ctx: CustomContext):
"""Skip the current song"""
await ctx.player.skip()
await ctx.send("Skipped current song")
@commands.command(name="queue")
async def queue(self, ctx: CustomContext):
"""Display the current radio queue"""
embed_color = self.bot.colors["embed"]
embed = Embed(title="Coming Up...", colour=embed_color)
if len(ctx.player.queue) > 0:
embed.description = "\n".join(
f"{index}. [{track.title}]({track.uri})"
for index, track in enumerate(ctx.player.queue, 1)
)
else:
embed.description = "We are still determining a playlist"
await ctx.send(embed=embed)
@commands.command(name="disconnect", aliases=["dc", "stop"])
async def disconnect(self, ctx: CustomContext):
"""Disconnects the radio from the channel"""
if not ctx.author.voice or (
ctx.player.is_connected
and ctx.author.voice.channel.id != int(ctx.player.channel_id)
):
# Abuse prevention. Users not in voice channels, or not in the same voice channel as the bot
# may not disconnect the bot.
await EmbedGenerator.Title(ctx, "You're not in my voicechannel!")
return
# Clear the queue to ensure old tracks don't start playing
# when someone else queues something.
ctx.player.queue.clear()
# Stop the current track so Lavalink consumes less resources.
await ctx.player.stop()
# Disconnect from the voice channel.
await ctx.voice_client.disconnect(force=True)
await EmbedGenerator.Title(ctx, "*⃣ | Disconnected.")
@commands.command(name="now")
async def now_playing(self, ctx: CustomContext):
"""Displays information about the currently played track"""
embed = await self.create_track_embed(ctx.player.current)
await ctx.send(embed=embed)
def setup(bot: TuneBot):
bot.add_cog(Music(bot))
-71
View File
@@ -1,71 +0,0 @@
import asyncio
import typing
import lavalink
from discord.channel import TextChannel
from discord.ext import commands
from bot import TuneBot
from cogs.music import helper
from cogs.music import track_embed
from cogs.music.interactions import MusicCommands
from cogs.music.interactions import QUEUE_SIZE
from cogs.music.voice_client import LavalinkVoiceClient
from utils.classes import BaseCog
from utils.log import logger
if typing.TYPE_CHECKING:
from bot import TuneBot
class MusicCog(BaseCog, name="Music"):
def __init__(self, bot: TuneBot):
super().__init__(bot)
if not hasattr(bot, "lavalink"):
self.bot.lavalink = self.bot.create_lavalink(bot.user.id)
self.bot.lavalink.add_event_hook(self.track_hook)
@commands.Cog.listener()
async def on_ready(self):
while not self.is_lavalink_ready():
await asyncio.sleep(1)
redis_result = await self.bot.global_autojoin.fetch_channels()
for guild_id, (voicechannel_id, textchannel_id) in redis_result.items():
try:
player = self.bot.lavalink.player_manager.create(guild_id)
player.store("channel", textchannel_id)
voice_channel = await self.bot.fetch_channel(voicechannel_id)
await voice_channel.connect(cls=LavalinkVoiceClient)
if not player.is_playing:
await helper.fill_player_queue(self.bot, player, QUEUE_SIZE)
await player.play()
except:
logger.error(f"Failed to autojoin guild {guild_id}.")
async def track_hook(self, event: lavalink.Event):
if isinstance(event, lavalink.events.QueueEndEvent):
guild_id = event.player.guild_id
guild = self.bot.get_guild(guild_id)
await guild.voice_client.disconnect(force=True)
elif isinstance(event, lavalink.events.TrackEndEvent):
channel_id = int(event.player.fetch("channel"))
channel: TextChannel = self.bot.get_channel(channel_id)
await helper.fill_player_queue(self.bot, event.player, 1)
event.player.append_history(event.track)
if event.reason == "FINISHED":
queue = event.player.queue[1:]
current = event.player.queue[0]
else:
queue = event.player.queue
current = event.player.current
embed = track_embed.create_track_embed(current, queue, event.player.history)
await channel.send(embed=embed)
async def setup(bot: "TuneBot"):
await bot.add_cog(MusicCog(bot))
bot.tree.add_command(MusicCommands(bot), override=True)
-63
View File
@@ -1,63 +0,0 @@
import datetime
import typing
import discord
import lavalink
from bot import colors
from cogs.music.voice_client import LavalinkVoiceClient
from utils.log import logger
if typing.TYPE_CHECKING:
from lavalink import DefaultPlayer
from lavalink_player import CustomPlayer
from bot import TuneBot
def get_player(bot: "TuneBot", guild_id: int) -> "CustomPlayer":
if player := bot.lavalink.player_manager.get(guild_id=guild_id):
return player
return bot.lavalink.player_manager.create(guild_id)
async def fill_player_queue(
bot: "TuneBot", player: "DefaultPlayer", amount: typing.Optional[int] = 1
):
queries = await bot.global_playlist.pick_random(amount)
logger.info(f"Got queries for {player.guild_id} {queries=} {amount=}")
for query in queries:
result = await player.node.get_tracks(query)
if not result or not result["tracks"]:
continue
track = lavalink.models.AudioTrack(
result["tracks"][0], bot.user.id, recommended=False
)
player.add(requester=bot.user.id, track=track)
async def ensure_voice(
permissions: discord.Permissions,
player: "DefaultPlayer",
author: discord.Member,
voice_client: typing.Any,
channel_id: int,
):
"""This ensures that the bot and command author are in the same voicechannel."""
if not author.voice or not author.voice.channel:
raise Exception("Join a voicechannel first.")
if not voice_client:
if not permissions.connect or not permissions.speak: # Check user limit too?
raise Exception("I need to be able to join your channel and speak")
player.store("channel", channel_id)
await author.voice.channel.connect(cls=LavalinkVoiceClient)
else:
if voice_client.channel.id != author.voice.channel.id:
raise Exception("You need to be in my voicechannel")
__all__ = ("fill_player_queue", "get_player", "ensure_voice")
-115
View File
@@ -1,115 +0,0 @@
import typing
from discord import app_commands
from discord import Interaction
from bot import config
from cogs.music import helper
from utils.embed import create_embed
from cogs.music import track_embed
if typing.TYPE_CHECKING:
from bot import TuneBot
QUEUE_SIZE = config["queue_buffer_size"]
class MusicCommands(app_commands.Group):
def __init__(self, bot: "TuneBot"):
super().__init__(name="radio", description="Never-ending stream of good vibes")
self.bot: "TuneBot" = bot
@app_commands.command(name="connect", description="Start the radio")
async def connect(self, ctx: Interaction):
embed = create_embed(ctx.user)
player = helper.get_player(ctx.client, ctx.guild_id)
try:
await helper.ensure_voice(
permissions=ctx.app_permissions,
player=player,
author=ctx.user,
voice_client=ctx.guild.voice_client,
channel_id=ctx.channel.id,
)
except Exception as exc:
embed.title = str(exc)
await ctx.response.send_message(embed=embed)
return
if player.is_connected:
embed.title = "Already connected."
await ctx.response.send_message(embed=embed)
return
await helper.fill_player_queue(ctx.client, player, QUEUE_SIZE)
if not player.is_playing:
await player.play()
embed = track_embed.create_track_embed(player.current, player.queue, [])
await ctx.response.send_message(embed=embed)
@app_commands.command(
name="disconnect", description="I've had enough music for a while"
)
async def disconnect(self, ctx: Interaction):
embed = create_embed(ctx.user)
player = helper.get_player(ctx.client, ctx.guild_id)
if not ctx.user.voice or (
player.is_connected and ctx.user.voice.channel.id != int(player.channel_id)
):
embed.title = "You're not in my voicechannel!"
await ctx.response.send_message(embed=embed)
return
# Clear the queue to ensure old tracks don't start playing
# when someone else queues something.
player.queue.clear()
# Stop the current track so Lavalink consumes less resources.
await player.stop()
# Disconnect from the voice channel.
await ctx.guild.voice_client.disconnect(force=True)
embed.title = "*⃣ | Disconnected."
await ctx.response.send_message(embed=embed)
@app_commands.command(
name="current", description="Gives more details about what's currently playing."
)
async def current(self, ctx: Interaction):
player = helper.get_player(ctx.client, ctx.guild_id)
if not player.current:
embed = create_embed(ctx.user)
embed.title = "You're not listening to anything right now."
await ctx.response.send_message(embed=embed)
return
embed = track_embed.create_track_embed(
player.current, player.queue, player.history
)
await ctx.response.send_message(embed=embed)
@app_commands.command(name="queue", description="See what's ahead")
async def queue(self, ctx: Interaction):
player = helper.get_player(ctx.client, ctx.guild_id)
embed = create_embed(ctx.user)
embed.title = "Coming up..."
if len(player.queue) > 0:
embed.description = "\n".join(
f"{index}. [{track.title}]({track.uri})"
for index, track in enumerate(player.queue, 1)
)
else:
embed.description = "We are still determining a playlist"
await ctx.response.send_message(embed=embed)
@app_commands.command(
name="skip", description="You might like the next track better"
)
async def skip(self, ctx: Interaction):
player = helper.get_player(ctx.client, ctx.guild_id)
embed = create_embed(ctx.user)
embed.title = "Skipped current song"
await player.skip()
await ctx.response.send_message(embed=embed)
-60
View File
@@ -1,60 +0,0 @@
import datetime
import typing
import discord
from bot import colors
from cogs.music import helper
if typing.TYPE_CHECKING:
from lavalink.models import AudioTrack
def format_track(track: "AudioTrack", max_length: int = 0):
try:
duration = str(datetime.timedelta(milliseconds=int(track.duration)))
except OverflowError:
duration = "0:00:00"
if max_length == 0:
return f"`{duration}` [{track.title}]({track.uri})"
max_length -= len(duration) + 1
if len(track.title) > max_length:
track_title = track.title[: max_length - 3] + "..."
else:
track_title = track.title
return f"`{duration}` [{track_title}]({track.uri})"
def create_track_embed(
current: "AudioTrack",
queue: typing.Sequence["AudioTrack"],
history: typing.Sequence["AudioTrack"],
) -> discord.Embed:
embed_width = 52
embed = discord.Embed(
title="Now playing...",
colour=colors["embed"],
)
embed.description = format_track(current, max_length=embed_width)
if len(queue) > 0:
upcoming_fmt = "\n".join(
format_track(track, max_length=embed_width) for track in queue
)
else:
upcoming_fmt = "No tracks have been queued yet..."
embed.add_field(name="Coming up", value=upcoming_fmt, inline=False)
if len(history) > 0:
history_fmt = "\n".join(
format_track(track, max_length=embed_width) for track in history
)
else:
history_fmt = "No history yet..."
embed.add_field(name="Previously played", value=history_fmt, inline=False)
embed.set_image(url=f"https://i3.ytimg.com/vi/{current.identifier}/mqdefault.jpg")
embed.set_footer(text=f"Uploaded by: {current.author}")
return embed
-64
View File
@@ -1,64 +0,0 @@
import typing
import discord
if typing.TYPE_CHECKING:
from bot import TuneBot
class LavalinkVoiceClient(discord.VoiceClient):
def __init__(self, client: "TuneBot", channel: discord.abc.Connectable):
self.client = client
self.channel = channel
self.lavalink = self.client.lavalink
async def on_voice_server_update(self, data):
# the data needs to be transformed before being handed down to
# voice_update_handler
lavalink_data = {"t": "VOICE_SERVER_UPDATE", "d": data}
await self.lavalink.voice_update_handler(lavalink_data)
async def on_voice_state_update(self, data):
# the data needs to be transformed before being handed down to
# voice_update_handler
lavalink_data = {"t": "VOICE_STATE_UPDATE", "d": data}
await self.lavalink.voice_update_handler(lavalink_data)
async def connect(
self,
*,
timeout: float,
reconnect: bool,
self_deaf: bool = False,
self_mute: bool = False,
) -> None:
"""
Connect the bot to the voice channel and create a player_manager
if it doesn't exist yet.
"""
# ensure there is a player_manager when creating a new voice_client
self.lavalink.player_manager.create(guild_id=self.channel.guild.id)
await self.channel.guild.change_voice_state(
channel=self.channel, self_mute=self_mute, self_deaf=self_deaf
)
async def disconnect(self, *, force: bool) -> None:
"""
Handles the disconnect.
Cleans up running player and leaves the voice client.
"""
player = self.lavalink.player_manager.get(self.channel.guild.id)
# no need to disconnect if we are not connected
if not force and not player.is_connected:
return
# None means disconnect
await self.channel.guild.change_voice_state(channel=None)
# update the channel_id of the player to None
# this must be done because the on_voice_state_update that
# would set channel_id to None doesn't get dispatched after the
# disconnect
player.channel_id = None
self.cleanup()
+213
View File
@@ -0,0 +1,213 @@
import asyncio
import io
import textwrap
import time
import traceback
from asyncio.subprocess import PIPE
from contextlib import redirect_stdout
from io import BytesIO
from platform import python_version
import discord
from discord.ext import commands
from discord.ext.commands.context import Context
from bot import TuneBot
from utils.classes import BaseCog
class OwnerCog(BaseCog):
def __init__(self, bot: TuneBot):
super().__init__(bot)
self._last_result = None
@staticmethod
def cleanup_code(content):
if content.startswith("```") and content.endswith("```"):
return "\n".join(content.split("\n")[1:-1])
return content.strip("` \n")
# Hidden means it won't show up on the default help.
@commands.command(name="load", hidden=True, slash_command=False)
@commands.is_owner()
async def _cog_load(self, ctx: Context, *, cog: str):
"""Command which Loads a Module."""
try:
self.bot.load_extension(cog)
except Exception as e:
await ctx.send(f"**`ERROR:`** {type(e).__name__} - {e}")
else:
await ctx.send("**`SUCCESS`**")
@commands.command(name="unload", hidden=True, slash_command=False)
@commands.is_owner()
async def _cog_unload(self, ctx, *, cog: str):
"""Command which Unloads a Module."""
try:
self.bot.unload_extension(cog)
except Exception as e:
await ctx.send(f"**`ERROR:`** {type(e).__name__} - {e}")
else:
await ctx.send("**`SUCCESS`**")
@commands.command(name="reload", hidden=True, slash_command=False)
@commands.is_owner()
async def _cog_reload(self, ctx, *, cog: str):
"""Command which Reloads a Module."""
try:
self.bot.unload_extension(cog)
self.bot.load_extension(cog)
except Exception as e:
await ctx.send(f"**`ERROR:`** {type(e).__name__} - {e}")
else:
await ctx.send("**`SUCCESS`**")
@commands.command(name="shutdown", hidden=True, slash_command=False)
@commands.is_owner()
async def shutdown(self, ctx):
"""Command which shutdowns the bot."""
await ctx.bot.logout()
@commands.is_owner()
@commands.command(
pass_context=True,
hidden=True,
name="eval",
aliases=["evaluate"],
slash_command=False,
)
async def _eval(self, ctx, *, body: str):
env = {
"bot": self.bot,
"ctx": ctx,
"channel": ctx.channel,
"author": ctx.author,
"guild": ctx.guild,
"message": ctx.message,
"_": self._last_result,
}
env.update(globals())
body = self.cleanup_code(body)
stdout = io.StringIO()
to_compile = f'async def func():\n{textwrap.indent(body, " ")}'
# await ctx.message.add_reaction('a:loading:452489773396000778')
try:
exec(to_compile, env)
except Exception as e:
# await ctx.message.add_reaction('naokoerror:447495055603662849')
fooem = discord.Embed(color=0xFF0000)
fooem.add_field(
name="Code evaluation was not successful.",
value=f"```\n{e.__class__.__name__}: {e}\n```",
)
fooem.set_footer(
text=f"Evaluated using Python {python_version()}",
icon_url="http://i.imgur.com/9EftiVK.png",
)
await ctx.send(embed=fooem)
# await ctx.message.remove_reaction('a:loading:452489773396000778', member=ctx.me)
func = env["func"]
try:
with redirect_stdout(stdout):
ret = await func()
except Exception as e:
value = stdout.getvalue()
# await ctx.message.add_reaction('naokoerror:447495055603662849')
fooem = discord.Embed(color=0xFF0000)
fooem.add_field(
name="Code evaluation was not successful.",
value=f"```\n{value}{traceback.format_exc()}\n```",
)
fooem.set_footer(
text=f"Evaluated using Python {python_version()}",
icon_url="http://i.imgur.com/9EftiVK.png",
)
await ctx.send(embed=fooem)
try:
# await ctx.message.remove_reaction('a:loading:452489773396000778', member=ctx.me)
# await ctx.message.add_reaction('naokotick:447494238872141827')
pass
except Exception:
pass
else:
value = stdout.getvalue()
try:
await ctx.message.remove_reaction(
"a:loading:452489773396000778", member=ctx.me
)
# await ctx.message.add_reaction('naokotick:447494238872141827')
except Exception:
pass
if ret is None:
if value:
sfooem = discord.Embed(color=0x170041)
sfooem.add_field(
name="Code evaluation was successful!",
value=f"```\n{value}\n```",
)
sfooem.set_footer(
text=f"Evaluated using Python {python_version()}",
icon_url="http://i.imgur.com/9EftiVK.png",
)
await ctx.send(embed=sfooem)
else:
self._last_result = ret
ssfooem = discord.Embed(color=0x170041)
ssfooem.add_field(
name="Code evaluation was successful!",
value=f"```\n{value}{ret}\n```",
)
ssfooem.set_footer(
text=f"Evaluated using Python {python_version()}",
icon_url="http://i.imgur.com/9EftiVK.png",
)
await ctx.send(embed=ssfooem)
@commands.is_owner()
@commands.command(hidden=True, aliases=["exec"], slash_command=False)
async def execute(self, ctx, *, text: str):
"""Do a shell command."""
message = await ctx.send(f"Loading...")
proc = await asyncio.create_subprocess_shell(
text, stdin=None, stderr=PIPE, stdout=PIPE
)
out = (await proc.stdout.read()).decode("utf-8").strip()
err = (await proc.stderr.read()).decode("utf-8").strip()
if not out and not err:
await message.delete()
return await ctx.message.add_reaction("👌")
content = ""
if err:
content += f"Error:\r\n{err}\r\n{'-' * 30}\r\n"
if out:
content += out
if len(content) > 1500:
try:
data = BytesIO(content.encode("utf-8"))
await message.delete()
await ctx.send(
content=f"The result was a bit too long..",
file=discord.File(data, filename=f"result_{int(time.time())}.txt"),
)
except asyncio.TimeoutError as e:
await message.delete()
return await ctx.send(e)
else:
await message.edit(content=f"```fix\n{content}\n```")
def setup(bot: TuneBot):
bot.add_cog(OwnerCog(bot))
+164
View File
@@ -0,0 +1,164 @@
from typing import Any
from discord.ext import commands
from discord.message import Message
from bot import TuneBot
from context import CustomContext
from utils.classes import BaseCog
from utils.decorators import source_manager_only
from utils.EmbedGenerator import EmbedGenerator
class SettingsCog(BaseCog, name="Settings"):
@commands.group(aliases=["aj"], invoke_without_command=True)
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
async def autojoin(self, ctx: CustomContext):
"""Enable/Disable the bot automatically joining"""
await EmbedGenerator.Message(
ctx,
"Autojoin",
f"Usage:\n\n`{ctx.prefix}autojoin enable`\n`{ctx.prefix}autojoin disable`",
)
@autojoin.command(name="enable", aliases=["set"])
@commands.has_permissions(manage_channels=True)
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
async def autojoin_set(self, ctx: CustomContext):
"""Enable the bot automatically joining"""
voice_state = ctx.author.voice
if not voice_state:
embed = ctx.create_embed()
embed.title = "Please join a voice channel before running this command."
await ctx.send(embed=embed)
return
await ctx.autojoin.update(voice_state.channel.id, ctx.message.channel.id)
embed = ctx.create_embed()
embed.title = f"AutoJoin enabled for #{voice_state.channel.name}"
await ctx.send(embed=embed)
@autojoin.command(name="disable", aliases=["unset"])
@commands.has_permissions(manage_channels=True)
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
async def autojoin_del(self, ctx: CustomContext):
"""Disable the bot automatically joining"""
await ctx.autojoin.disable()
embed = ctx.create_embed()
embed.title = f"AutoJoin disabled"
await ctx.send(embed=embed)
@source_manager_only()
@commands.group(
name="source",
aliases=["src"],
invoke_without_command=True,
slash_command=False,
hidden=True,
)
async def source(self, ctx: CustomContext):
"""Displays all possible options for the `source` command"""
prefix = self.bot.config["prefixes"][0]
embed = ctx.create_embed()
embed.title = "All options:"
embed.description = f"```{prefix}source list\n{prefix}source add <url>\n{prefix}source remove <url>\n{prefix}source sync```"
await ctx.send(embed=embed)
@source_manager_only()
@source.command(name="remove")
async def source_remove(self, ctx: CustomContext, source_url: str):
"""Removes a source from the bot"""
if await ctx.playlist_source.remove(source_url):
prefix = self.bot.config["prefixes"][0]
embed = ctx.create_embed()
embed.title = "Removed source succesfully"
embed.description = (
f"Please use `{prefix}source sync` to persist these changes."
)
await ctx.send(embed=embed)
return
embed = ctx.create_embed()
embed.title = "Could not remove source, the specified source might not exist"
await ctx.send(embed=embed)
@source_manager_only()
@source.command(name="add")
async def source_add(self, ctx: CustomContext, source_url: str):
"""
Add's a source to the bot
Supported sources: YouTube, SoundCloud, Bandcamp, Vimeo, Twitch and HTTP(S) URL's
"""
embed = ctx.create_embed()
embed.title = "Started processing source"
message = await ctx.send(embed=embed)
query_result: Any = await self.bot.lavalink.get_tracks(source_url)
if query_result["loadType"] == "LOAD_FAILED":
embed = ctx.create_embed()
embed.title = "The specified URL is not a valid source"
embed.description = f"Supported sources: YouTube, SoundCloud, Bandcamp, Vimeo, Twitch and HTTP(S) URL's"
if isinstance(message, Message):
await message.edit(embed=embed)
else:
await ctx.send(embed=embed)
return
await ctx.playlist_source.add(source_url)
track_urls = [str(track["info"]["uri"]) for track in query_result["tracks"]]
await self.bot.global_playlist.add_tracks(track_urls)
embed = ctx.create_embed()
embed.title = "Finished processing source"
embed.description = f"Added {len(track_urls)} tracks"
if isinstance(message, Message):
await message.edit(embed=embed)
else:
await ctx.send(embed=embed)
@source_manager_only()
@source.command(name="list", aliases=["ls"])
async def source_list(self, ctx: CustomContext):
"""Display a list of sources"""
# TODO: Implement pagination for sources
sources = await self.bot.global_playlist_source.fetch_sources()
if len(sources) > 0:
description = "\n".join([f"[{source}]({source})" for source in sources])
else:
description = "This bot has no sources yet"
embed = ctx.create_embed()
embed.title = "All sources:"
embed.description = description
await ctx.send(embed=embed)
@source_manager_only()
@source.command(name="sync")
async def source_sync(self, ctx: CustomContext):
"""Forcefully resyncs all sources"""
failed_sources: list[str] = []
await self.bot.global_playlist.clear()
sources = await self.bot.global_playlist_source.fetch_sources()
for source_url in sources:
query_result: Any = await self.bot.lavalink.get_tracks(source_url)
if query_result["loadType"] == "LOAD_FAILED":
failed_sources.append(source_url)
continue
track_urls = [str(track["info"]["uri"]) for track in query_result["tracks"]]
await self.bot.global_playlist.add_tracks(track_urls)
embed = ctx.create_embed()
embed.title = f"Finished sync ({len(failed_sources)} issues)"
if len(failed_sources) > 0:
embed.description = "\n".join(
[f"[{source_url}]({source_url})" for source_url in failed_sources]
)
await ctx.send(embed=embed)
def setup(bot: TuneBot):
bot.add_cog(SettingsCog(bot))
-17
View File
@@ -1,17 +0,0 @@
from discord.ext import commands
from bot import TuneBot
from cogs.settings.interactions import SettingCommands
from cogs.settings.interactions import SourceCommands
from context import CustomContext
from utils.classes import BaseCog
class SettingsCog(BaseCog, name="Settings"):
pass
async def setup(bot: TuneBot):
await bot.add_cog(SettingsCog(bot))
bot.tree.add_command(SettingCommands(bot), override=True)
bot.tree.add_command(SourceCommands(bot), override=True)
-145
View File
@@ -1,145 +0,0 @@
import typing
from discord import app_commands
from discord import Interaction
from tunebot.context import ContextLike
from utils.embed import create_embed
if typing.TYPE_CHECKING:
from discord import VoiceState
from bot import TuneBot
async def is_source_owner(ctx: Interaction) -> bool:
is_manager = ctx.user.id in ctx.client.config["manager_ids"]
is_owner: bool = await ctx.client.is_owner(ctx.user)
return is_manager or is_owner
class SourceCommands(app_commands.Group):
def __init__(self, bot: "TuneBot"):
super().__init__(name="source", description="Manage the radio sources")
self.bot: "TuneBot" = bot
@app_commands.command(name="list", description="List all radio sources")
@app_commands.check(is_source_owner)
async def list(self, ctx: Interaction):
sources = await self.bot.global_playlist_source.fetch_sources()
if len(sources) > 0:
description = "\n".join([f"[{source}]({source})" for source in sources])
else:
description = "This bot has no sources yet"
embed = create_embed(ctx.user)
embed.title = "All sources:"
embed.description = description
await ctx.response.send_message(embed=embed)
@app_commands.command(name="add", description="Add a new radio source")
@app_commands.describe(url="The URL of the to be added source")
@app_commands.check(is_source_owner)
async def add(self, ctx: Interaction, url: str):
embed = create_embed(ctx.user)
clike = ContextLike.from_discord_interaction(ctx)
playlist_source = self.bot.playlist_source_context(clike)
query_result: typing.Any = await self.bot.lavalink.get_tracks(url)
if query_result["loadType"] == "LOAD_FAILED":
embed = create_embed(ctx.user)
embed.title = "The specified URL is not a valid source"
embed.description = "Supported sources: YouTube, SoundCloud, Bandcamp, Vimeo, Twitch and HTTP(S) URL's"
await ctx.response.send_message(embed=embed)
return
await playlist_source.add(url)
track_urls = [str(track["info"]["uri"]) for track in query_result["tracks"]]
await self.bot.global_playlist.add_tracks(track_urls)
embed.title = "Finished processing source"
embed.description = f"Added {len(track_urls)} tracks"
await ctx.response.send_message(embed=embed)
@app_commands.command(name="remove", description="Remove a radio source")
@app_commands.describe(url="The URL of the to be removed source")
@app_commands.check(is_source_owner)
async def remove(self, ctx: Interaction, url: str):
embed = create_embed(ctx.user)
clike = ContextLike.from_discord_interaction(ctx)
playlist_source = self.bot.playlist_source_context(clike)
if await playlist_source.remove(url):
prefix = self.bot.config["prefixes"][0]
embed.title = "Removed source succesfully"
embed.description = (
f"Please use `{prefix}source sync` to persist these changes."
)
await ctx.response.send_message(embed=embed)
return
embed.title = "Could not remove source, the specified source might not exist"
await ctx.response.send_message(embed=embed)
@app_commands.command(name="sync", description="Synchronize all sources")
@app_commands.check(is_source_owner)
async def sync(self, ctx: Interaction):
embed = create_embed(ctx.user)
failed_sources: list[str] = []
await self.bot.global_playlist.clear()
sources = await self.bot.global_playlist_source.fetch_sources()
for source_url in sources:
query_result: typing.Any = await self.bot.lavalink.get_tracks(source_url)
if query_result["loadType"] == "LOAD_FAILED":
failed_sources.append(source_url)
continue
track_urls = [str(track["info"]["uri"]) for track in query_result["tracks"]]
await self.bot.global_playlist.add_tracks(track_urls)
embed.title = f"Finished sync ({len(failed_sources)} issues)"
if len(failed_sources) > 0:
embed.description = "\n".join(
[f"[{source_url}]({source_url})" for source_url in failed_sources]
)
await ctx.response.send_message(embed=embed)
class SettingCommands(app_commands.Group):
def __init__(self, bot: "TuneBot"):
super().__init__(name="settings", description="Tweak and customize")
self.bot: "TuneBot" = bot
@app_commands.command(
name="autojoin",
description="Let the bot automatically join a channel after occassional maintenance",
)
@app_commands.describe(state="enabled or disabled")
@app_commands.choices(
state=[
app_commands.Choice(name="enable", value=0),
app_commands.Choice(name="disable", value=1),
]
)
@app_commands.check(is_source_owner)
async def autojoin(self, ctx: Interaction, state: app_commands.Choice[int]):
voice_state: "VoiceState" = ctx.user.voice
embed = create_embed(ctx.user)
if not voice_state:
embed.title = "Please join a voice channel before running this command."
await ctx.response.send_message(embed=embed)
return
clike = ContextLike.from_discord_interaction(ctx)
autojoin = self.bot.autojoin_context(clike)
enabled = state.value
if not enabled:
await autojoin.update(voice_state.channel.id, ctx.channel_id)
embed.title = f"AutoJoin enabled for #{voice_state.channel.name}"
await ctx.response.send_message(embed=embed)
return
await autojoin.disable()
embed.title = "AutoJoin disabled"
await ctx.response.send_message(embed=embed)
+25 -29
View File
@@ -1,43 +1,39 @@
{
"token": "",
"owner_ids": [
194545408960102400,
190875175460405250
],
"owner_ids": [194545408960102400, 190875175460405249],
"manager_ids": [],
"prefix": "ck!",
"prefixes": ["ck!"],
"redis_url": "",
"redis_prefix": "",
"nodes": {
"example": {
"host": "",
"port": 2333,
"password": "",
"region": "",
"name": ""
}
"lavalink": {
"host": "",
"port": 2333,
"password": "",
"region": "",
"name": ""
},
"colors": {
"embed": "7289da"
},
"info": {
"name": "CloudKid Radio",
"description": "A sample bot description",
"status": {
"messages": [
"bangers",
"noises 🤖"
],
"interval": 12
}
"description": "A sample bot description"
},
"cogs": [
"jishaku",
"cogs.settings",
"cogs.information",
"cogs.music"
],
"cogs": ["cogs.owner", "cogs.settings", "cogs.information", "cogs.music"],
"slash_command_guilds": [],
"queue_buffer_size": 5,
"history_size": 2,
"slash_descriptions": {}
"slash_descriptions": {},
"plugins": {
"lastfm-scrobbler": {
"services": ["plugins.lastfm_scrobbler.service"],
"cogs": ["plugins.lastfm_scrobbler.cog"],
"config": {
"lastfm_api_key": "",
"lastfm_api_secret": "",
"session_key_table": "",
"auth_url": ""
},
"enabled": false
}
}
}
+13 -6
View File
@@ -1,3 +1,4 @@
from typing import Any
from typing import TYPE_CHECKING
from aioredis.client import Redis
@@ -6,7 +7,6 @@ from discord.ext import commands
from discord.ext.commands.errors import CommandInvokeError
from lavalink.models import DefaultPlayer
from tunebot.context import ContextLike
from tunebot.redis import RedisAutoJoin
from tunebot.redis import RedisPlaylistSource
@@ -15,19 +15,28 @@ if TYPE_CHECKING:
from tunebot import PlaylistSource
from tunebot import AutoJoin
from utils.classes import BaseCog
from tunebot.plugins import ServiceEvent
AnyDict = dict[Any, Any]
class CustomContext(commands.Context):
bot: "TuneBot"
cog: "BaseCog"
async def dispatch(self, event: "ServiceEvent", payload: AnyDict):
await self.bot.plugin_manager.dispatch(event, payload)
@property
def redis(self) -> Redis:
return self.bot._redis_client
@property
def player(self) -> DefaultPlayer:
return self.bot.lavalink.player_manager.get(self.guild.id)
if self.cog.is_lavalink_ready():
return self.bot.lavalink.player_manager.get(self.guild.id)
raise CommandInvokeError("Lavalink is still starting up.")
def create_embed(self) -> Embed:
bot: TuneBot = self.bot
@@ -44,13 +53,11 @@ class CustomContext(commands.Context):
@property
def playlist_source(self) -> "PlaylistSource":
if not hasattr(self, "_playlist_source"):
ctx = ContextLike.from_discord_context(self)
self._playlist_source = self.bot.playlist_source_context(ctx)
self._playlist_source = RedisPlaylistSource(self)
return self._playlist_source
@property
def autojoin(self) -> "AutoJoin":
if not hasattr(self, "_autojoin"):
ctx = ContextLike.from_discord_context(self)
self._autojoin = self.bot.autojoin_context(ctx)
self._autojoin = RedisAutoJoin(self)
return self._autojoin
-13
View File
@@ -1,13 +0,0 @@
version: '3.8'
services:
redis:
image: redis:7
ports:
- "6379:6379"
lavalink:
image: fredboat/lavalink:v3.6
volumes:
- ./.docker/lavalink.yml:/opt/Lavalink/application.yml
ports:
- "2333:2333"
-21
View File
@@ -1,21 +0,0 @@
import typing
from lavalink import DefaultPlayer
if typing.TYPE_CHECKING:
from lavalink import Node
from lavalink.models import AudioTrack
class CustomPlayer(DefaultPlayer):
def __init__(self, guild_id: int, node: "Node"):
from bot import config
self.history: typing.List["AudioTrack"] = []
self.max_size = config["history_size"]
super().__init__(guild_id, node)
def append_history(self, track: "AudioTrack"):
self.history.insert(0, track)
if len(self.history) > self.max_size:
self.history.pop(-1)
+46
View File
@@ -0,0 +1,46 @@
from typing import TYPE_CHECKING
from discord.ext import commands
from context import CustomContext
from utils.classes import PluginCog
if TYPE_CHECKING:
from bot import TuneBot
class LastFMScrobblerCog(PluginCog, name="LastFMScrobbler"):
def __init__(self, bot: "TuneBot") -> None:
super().__init__(bot)
self.plugin = self.get_plugin_instance("lastfm-scrobbler")
@commands.group(name="lfm", invoke_without_command=True)
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
async def lfm(self, ctx: CustomContext):
"""Enable/Disable LastFM Scrobbling"""
embed = ctx.create_embed()
embed.title = f"LastFM scrobbler usage:"
embed.description = f"```{ctx.prefix}lfm enable\n{ctx.prefix}lfm del```"
await ctx.send(embed=embed)
@lfm.command(name="enable", aliases=["set"])
async def lfm_aut(self, ctx: CustomContext):
"""Enable LastFM Scrobbling"""
embed = ctx.create_embed()
embed.title = f"Start scrobbling"
auth_url = self.plugin.config["auth_url"] + "?user_id=" + str(ctx.author.id)
embed.description = f"[Login]({auth_url})"
await ctx.send(embed=embed)
@lfm.command(name="delete", aliases=["del"])
async def lfm_del(self, ctx: CustomContext):
"""Disable LastFM Scrobbling"""
db_key = self.plugin.config["session_key_table"]
await self.bot.global_utils.raw_table_del_entry(db_key, [ctx.author.id])
embed = ctx.create_embed()
embed.title = f"Stopped scrobbling"
await ctx.send(embed=embed)
def setup(bot: "TuneBot"):
bot.add_cog(LastFMScrobblerCog(bot))
+84
View File
@@ -0,0 +1,84 @@
import hashlib
import time
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING
import aiohttp
from lavalink.models import AudioTrack
from tunebot.plugins import ServiceEvent
if TYPE_CHECKING:
from bot import TuneBot
AnyDict = dict[Any, Any]
@dataclass
class Track:
name: str
artist: str
class LastFMScrobbler:
api_url = "http://ws.audioscrobbler.com/2.0/"
def __init__(self, bot: "TuneBot", config: AnyDict) -> None:
self.bot = bot
self.plug_conf = config
async def on_dispatch(self, event: "ServiceEvent", payload: AnyDict):
if event == ServiceEvent.TRACK_ENDED:
_track: AudioTrack = payload.get("last_track", None)
if not _track:
print("LastFMScrobbler: Could not find required field `last_track`")
return
# TODO: expand parsing options
artist, track_name = _track.title.split(" - ", 1)
track = Track(track_name, artist)
in_voice = payload.get("in_voice", [])
for session_key in await self.fetch_lastfm_sessions(in_voice):
if not session_key:
continue
await self.scrobble(session_key, track)
async def fetch_lastfm_sessions(self, user_ids: list[int]) -> list[str]:
table_name = self.plug_conf["config"]["session_key_table"]
result: list[str] = await self.bot.global_utils.raw_table_lookup(
table_name, user_ids
)
return result
async def scrobble(self, session_key: str, track: Track):
params: AnyDict = {
"method": "track.scrobble",
"timestamp": str(int(time.time() - 30)),
"track": track.name,
"artist": track.artist,
"sk": session_key,
}
resp = await self.lastfm_request(params)
if resp.status != 200:
fmt = f"Failed to scrobble for user {session_key} on track: {track.artist} - {track.name}"
print(fmt)
async def lastfm_request(self, params: AnyDict) -> aiohttp.ClientResponse:
params["api_key"] = self.plug_conf["config"]["lastfm_api_key"]
params = {key: params[key] for key in sorted(params)}
secret = self.plug_conf["config"]["lastfm_api_secret"]
sig_str = "".join(key + params[key] for key in params.keys()) + secret
params["api_sig"] = hashlib.md5(sig_str.encode("utf8")).hexdigest()
params["format"] = "json"
async with aiohttp.ClientSession() as sess:
async with sess.post(self.api_url, params=params) as resp:
return resp
def setup(bot: "TuneBot", config: AnyDict):
return LastFMScrobbler(bot, config)
Generated
+414 -172
View File
@@ -1,18 +1,17 @@
[[package]]
name = "aiohttp"
version = "3.7.4.post0"
version = "3.6.3"
description = "Async http client/server framework (asyncio)"
category = "main"
optional = false
python-versions = ">=3.6"
python-versions = ">=3.5.3"
[package.dependencies]
async-timeout = ">=3.0,<4.0"
attrs = ">=17.3.0"
chardet = ">=2.0,<5.0"
multidict = ">=4.5,<7.0"
typing-extensions = ">=3.6.5"
yarl = ">=1.0,<2.0"
chardet = ">=2.0,<4.0"
multidict = ">=4.5,<5.0"
yarl = ">=1.0,<1.6.0"
[package.extras]
speedups = ["aiodns", "brotlipy", "cchardet"]
@@ -44,17 +43,6 @@ python-versions = ">=3.6"
pytube = "*"
urllib3 = "*"
[[package]]
name = "astunparse"
version = "1.6.3"
description = "An AST unparser for Python"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
six = ">=1.6.1,<2.0"
[[package]]
name = "async-timeout"
version = "3.0.1"
@@ -116,14 +104,6 @@ jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
python2 = ["typed-ast (>=1.4.3)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "braceexpand"
version = "0.1.7"
description = "Bash-style brace expansion for Python"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "certifi"
version = "2021.10.8"
@@ -134,7 +114,7 @@ python-versions = "*"
[[package]]
name = "cffi"
version = "1.15.1"
version = "1.15.0"
description = "Foreign Function Interface for Python calling C code."
category = "main"
optional = false
@@ -174,7 +154,7 @@ unicode_backport = ["unicodedata2"]
name = "click"
version = "8.0.3"
description = "Composable command line interface toolkit"
category = "main"
category = "dev"
optional = false
python-versions = ">=3.6"
@@ -185,27 +165,34 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
name = "colorama"
version = "0.4.4"
description = "Cross-platform colored terminal text."
category = "main"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "discord.py"
version = "2.0.1"
version = "2.0.0a3662+gd2adc6c0"
description = "A Python wrapper for the Discord API"
category = "main"
optional = false
python-versions = ">=3.8.0"
develop = false
[package.dependencies]
aiohttp = ">=3.7.4,<4"
PyNaCl = {version = ">=1.3.0,<1.6", optional = true, markers = "extra == \"voice\""}
aiohttp = ">=3.6.0,<3.8.0"
orjson = {version = ">=3.5.4", optional = true, markers = "extra == \"speed\""}
PyNaCl = {version = ">=1.3.0,<1.5", optional = true, markers = "extra == \"voice\""}
[package.extras]
docs = ["sphinx (==4.4.0)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport", "typing-extensions (>=4.3,<5)"]
speed = ["orjson (>=3.5.4)", "aiodns (>=1.1)", "brotli", "cchardet (==2.1.7)"]
test = ["coverage", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "typing-extensions (>=4.3,<5)"]
voice = ["PyNaCl (>=1.3.0,<1.6)"]
docs = ["sphinx (==4.0.2)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport"]
speed = ["orjson (>=3.5.4)"]
voice = ["PyNaCl (>=1.3.0,<1.5)"]
[package.source]
type = "git"
url = "https://github.com/iDevision/enhanced-discord.py"
reference = "2.0"
resolved_reference = "d2adc6c05fafa761f7b8005ba8469ef4b78c188a"
[[package]]
name = "distlib"
@@ -257,88 +244,20 @@ category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "import-expression"
version = "1.1.4"
description = "Parses a superset of Python allowing for inline module import expressions"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
astunparse = ">=1.6.3,<2.0.0"
[package.extras]
test = ["pytest", "pytest-cov"]
[[package]]
name = "importlib-metadata"
version = "5.0.0"
description = "Read metadata from Python packages"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
zipp = ">=0.5"
[package.extras]
docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"]
perf = ["ipython"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
[[package]]
name = "jishaku"
version = "2.5.1"
description = "A discord.py extension including useful tools for bot development and debugging."
category = "main"
optional = false
python-versions = ">=3.8.0"
[package.dependencies]
braceexpand = ">=0.1.7"
click = ">=8.0.1"
import-expression = ">=1.0.0,<2.0.0"
importlib-metadata = {version = ">=3.7.0", markers = "python_version < \"3.10\""}
line-profiler = ">=3.5.1"
typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""}
[package.extras]
discordpy = ["discord.py (>=1.7.3)"]
docs = ["Sphinx (>=4.4.0)", "sphinxcontrib-asyncio (>=0.3.0)"]
procinfo = ["psutil (>=5.8.0)"]
publish = ["Jinja2 (>=3.0.3)"]
test = ["coverage (>=6.3.2)", "flake8 (>=4.0.1)", "isort (>=5.10.1)", "pylint (>=2.11.1)", "pytest (>=7.0.1)", "pytest-asyncio (>=0.18.1)", "pytest-cov (>=3.0.0)", "pytest-mock (>=3.7.0)"]
voice = ["yt-dlp (>=2022.3.8)"]
[[package]]
name = "lavalink"
version = "4.0.4"
description = "A Lavalink WebSocket & API wrapper built around coverage, reliability and performance."
version = "3.1.4"
description = "A lavalink interface built for discord.py"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
aiohttp = ">=3.7.4,<3.9.0"
aiohttp = ">=3.6.0,<3.7.0"
[package.extras]
development = ["pylint", "flake8"]
docs = ["sphinx", "pygments", "guzzle-sphinx-theme", "enum-tools", "sphinx-toolbox"]
[[package]]
name = "line-profiler"
version = "3.5.1"
description = "Line-by-line profiler."
category = "main"
optional = false
python-versions = "*"
[package.extras]
all = ["cython", "scikit-build", "cmake", "ninja", "pytest (>=4.6.11)", "pytest-cov (>=2.10.1)", "coverage[toml] (>=5.3)", "ubelt (>=1.0.1)", "IPython (>=0.13,<7.17.0)", "IPython (>=0.13)"]
build = ["cython", "scikit-build", "cmake", "ninja"]
ipython = ["IPython (>=0.13,<7.17.0)", "IPython (>=0.13)"]
tests = ["pytest (>=4.6.11)", "pytest-cov (>=2.10.1)", "coverage[toml] (>=5.3)", "ubelt (>=1.0.1)", "IPython (>=0.13,<7.17.0)", "IPython (>=0.13)"]
docs = ["sphinx", "pygments", "guzzle-sphinx-theme"]
[[package]]
name = "multidict"
@@ -372,6 +291,14 @@ category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "orjson"
version = "3.6.4"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "pathspec"
version = "0.9.0"
@@ -426,14 +353,15 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pynacl"
version = "1.5.0"
version = "1.4.0"
description = "Python binding to the Networking and Cryptography (NaCl) library"
category = "main"
optional = false
python-versions = ">=3.6"
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.dependencies]
cffi = ">=1.4.1"
six = "*"
[package.extras]
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
@@ -550,6 +478,14 @@ dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=4.1.2,<4.2.0
docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"]
test = ["aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"]
[[package]]
name = "websockets"
version = "10.0"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "virtualenv"
version = "20.10.0"
@@ -569,14 +505,6 @@ six = ">=1.9.0,<2"
docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"]
testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"]
[[package]]
name = "websockets"
version = "10.0"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "yarl"
version = "1.5.1"
@@ -602,41 +530,119 @@ mutagen = "*"
pycryptodomex = "*"
websockets = "*"
[[package]]
name = "zipp"
version = "3.10.0"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "jaraco.functools", "more-itertools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.8"
content-hash = "7518d3e2ccfffad1b05bd93f5178cb700c0bbe71e177707b56f60bb2dc46c70d"
content-hash = "87b61ad577bb9413177b2749b0c4b30026aa512bf3a6eb62e74c0ef7e2470ed4"
[metadata.files]
aiohttp = []
aioredis = []
aiotube = []
astunparse = []
async-timeout = []
attrs = []
"backports.entry-points-selectable" = []
black = []
braceexpand = []
aiohttp = [
{file = "aiohttp-3.6.3-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:1a4160579ffbc1b69e88cb6ca8bb0fbd4947dfcbf9fb1e2a4fc4c7a4a986c1fe"},
{file = "aiohttp-3.6.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:fb83326d8295e8840e4ba774edf346e87eca78ba8a89c55d2690352842c15ba5"},
{file = "aiohttp-3.6.3-cp35-cp35m-win32.whl", hash = "sha256:470e4c90da36b601676fe50c49a60d34eb8c6593780930b1aa4eea6f508dfa37"},
{file = "aiohttp-3.6.3-cp35-cp35m-win_amd64.whl", hash = "sha256:a885432d3cabc1287bcf88ea94e1826d3aec57fd5da4a586afae4591b061d40d"},
{file = "aiohttp-3.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:c506853ba52e516b264b106321c424d03f3ddef2813246432fa9d1cefd361c81"},
{file = "aiohttp-3.6.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:797456399ffeef73172945708810f3277f794965eb6ec9bd3a0c007c0476be98"},
{file = "aiohttp-3.6.3-cp36-cp36m-win32.whl", hash = "sha256:60f4caa3b7f7a477f66ccdd158e06901e1d235d572283906276e3803f6b098f5"},
{file = "aiohttp-3.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:2ad493de47a8f926386fa6d256832de3095ba285f325db917c7deae0b54a9fc8"},
{file = "aiohttp-3.6.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:319b490a5e2beaf06891f6711856ea10591cfe84fe9f3e71a721aa8f20a0872a"},
{file = "aiohttp-3.6.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:66d64486172b032db19ea8522328b19cfb78a3e1e5b62ab6a0567f93f073dea0"},
{file = "aiohttp-3.6.3-cp37-cp37m-win32.whl", hash = "sha256:206c0ccfcea46e1bddc91162449c20c72f308aebdcef4977420ef329c8fcc599"},
{file = "aiohttp-3.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:687461cd974722110d1763b45c5db4d2cdee8d50f57b00c43c7590d1dd77fc5c"},
{file = "aiohttp-3.6.3.tar.gz", hash = "sha256:698cd7bc3c7d1b82bb728bae835724a486a8c376647aec336aa21a60113c3645"},
]
aioredis = [
{file = "aioredis-2.0.0-py3-none-any.whl", hash = "sha256:9921d68a3df5c5cdb0d5b49ad4fc88a4cfdd60c108325df4f0066e8410c55ffb"},
{file = "aioredis-2.0.0.tar.gz", hash = "sha256:3a2de4b614e6a5f8e104238924294dc4e811aefbe17ddf52c04a93cbf06e67db"},
]
aiotube = [
{file = "aiotube-1.3.5-py3-none-any.whl", hash = "sha256:4dcef22dd27e6229b82186555c7f4779b62f751d669b9517b88d8d7af977338f"},
{file = "aiotube-1.3.5.tar.gz", hash = "sha256:3b015060480136812249433b02b5d869ad62a82916a92fe741bf4f907b7a948b"},
]
async-timeout = [
{file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"},
{file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"},
]
attrs = [
{file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
{file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
]
"backports.entry-points-selectable" = [
{file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"},
{file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"},
]
black = [
{file = "black-21.10b0-py3-none-any.whl", hash = "sha256:6eb7448da9143ee65b856a5f3676b7dda98ad9abe0f87fce8c59291f15e82a5b"},
{file = "black-21.10b0.tar.gz", hash = "sha256:a9952229092e325fe5f3dae56d81f639b23f7131eb840781947e4b2886030f33"},
]
certifi = [
{file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
{file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
]
cffi = []
cfgv = []
chardet = []
charset-normalizer = []
cffi = [
{file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"},
{file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"},
{file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"},
{file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"},
{file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"},
{file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"},
{file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"},
{file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"},
{file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"},
{file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"},
{file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"},
{file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"},
{file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"},
{file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"},
{file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"},
{file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"},
{file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"},
{file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"},
{file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"},
{file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"},
{file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"},
{file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"},
{file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"},
{file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"},
{file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"},
]
cfgv = [
{file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"},
{file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
]
chardet = [
{file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
{file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
]
charset-normalizer = [
{file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"},
{file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"},
]
click = [
{file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"},
{file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"},
@@ -646,39 +652,157 @@ colorama = [
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
]
"discord.py" = []
distlib = []
filelock = []
humanize = []
identify = []
distlib = [
{file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"},
{file = "distlib-0.3.3.zip", hash = "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"},
]
filelock = [
{file = "filelock-3.3.2-py3-none-any.whl", hash = "sha256:bb2a1c717df74c48a2d00ed625e5a66f8572a3a30baacb7657add1d7bac4097b"},
{file = "filelock-3.3.2.tar.gz", hash = "sha256:7afc856f74fa7006a289fd10fa840e1eebd8bbff6bffb69c26c54a0512ea8cf8"},
]
humanize = [
{file = "humanize-3.12.0-py3-none-any.whl", hash = "sha256:4c71c4381f0209715cd993058e717c1b74d58ae2f8c6da7bdb59ab66473b9ab0"},
{file = "humanize-3.12.0.tar.gz", hash = "sha256:5ec1a66e230a3e31fb3f184aab9436ea13d4e37c168e0ffc345ae5bb57e58be6"},
]
identify = [
{file = "identify-2.3.4-py2.py3-none-any.whl", hash = "sha256:4de55a93e0ba72bf917c840b3794eb1055a67272a1732351c557c88ec42011b1"},
{file = "identify-2.3.4.tar.gz", hash = "sha256:595283a1c3a078ac5774ad4dc4d1bdd0c1602f60bcf11ae673b64cb2b1945762"},
]
idna = [
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
]
import-expression = []
importlib-metadata = []
jishaku = []
lavalink = []
line-profiler = []
multidict = []
mutagen = []
lavalink = [
{file = "lavalink-3.1.4.tar.gz", hash = "sha256:c030488391e27cdc1e3ee3093817c38848ebd0d1c7bcf0d6cd0f40b9b00a4e0c"},
]
multidict = [
{file = "multidict-4.7.6-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000"},
{file = "multidict-4.7.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a"},
{file = "multidict-4.7.6-cp35-cp35m-win32.whl", hash = "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5"},
{file = "multidict-4.7.6-cp35-cp35m-win_amd64.whl", hash = "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3"},
{file = "multidict-4.7.6-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87"},
{file = "multidict-4.7.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2"},
{file = "multidict-4.7.6-cp36-cp36m-win32.whl", hash = "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7"},
{file = "multidict-4.7.6-cp36-cp36m-win_amd64.whl", hash = "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463"},
{file = "multidict-4.7.6-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d"},
{file = "multidict-4.7.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255"},
{file = "multidict-4.7.6-cp37-cp37m-win32.whl", hash = "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507"},
{file = "multidict-4.7.6-cp37-cp37m-win_amd64.whl", hash = "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c"},
{file = "multidict-4.7.6-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b"},
{file = "multidict-4.7.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7"},
{file = "multidict-4.7.6-cp38-cp38-win32.whl", hash = "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d"},
{file = "multidict-4.7.6-cp38-cp38-win_amd64.whl", hash = "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19"},
{file = "multidict-4.7.6.tar.gz", hash = "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430"},
]
mutagen = [
{file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"},
{file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"},
]
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
nodeenv = []
nodeenv = [
{file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
{file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"},
]
orjson = [
{file = "orjson-3.6.4-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:fc01a15f3101628fd619158daec79b30d7461149735e73542ca8c13be6b835be"},
{file = "orjson-3.6.4-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:48a69fed90f551bf9e9bb7a63e363fed4f67fc7c6e6bfb057054dc78f6721e9e"},
{file = "orjson-3.6.4-cp310-cp310-manylinux_2_24_x86_64.whl", hash = "sha256:3722f02f50861d5e2a6be9d50bfe8da27a5155bb60043118a4e1ceb8c7040cf7"},
{file = "orjson-3.6.4-cp310-none-win_amd64.whl", hash = "sha256:231a99a728322d0271e970b149c57deb67315e6837e6cd4166cf51d30161700c"},
{file = "orjson-3.6.4-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:6cd300421b41f7e84e388b1792a18c3fc4c440ae3039434b9320956be05f0102"},
{file = "orjson-3.6.4-cp37-cp37m-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:e55ef66ee1d35b1c43db275aff3a1ba7e0408b31e624912a612bd799df14e73e"},
{file = "orjson-3.6.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef8d332af8e6f7d6d2c1f3b5384c8d239800c1405b136da5f1710e802918d57"},
{file = "orjson-3.6.4-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:8896e242a92733e454378e22711bd43a55fda4e80604fcefcc064ca977623673"},
{file = "orjson-3.6.4-cp37-cp37m-manylinux_2_24_x86_64.whl", hash = "sha256:bdfa6f29f7b6aad70ce14591b99fba651008afa6bc3759f158887bcdc568b452"},
{file = "orjson-3.6.4-cp37-none-win_amd64.whl", hash = "sha256:7c16c44872d33da0b97050a9ea8f7bc04e930c56e8185657bc200e1875a671da"},
{file = "orjson-3.6.4-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:b467551f3be1dd08aff70c261cc883b63483eb0e31861ffe2cd8dac4fec7cfa9"},
{file = "orjson-3.6.4-cp38-cp38-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:7bf61afef12f6416db3ea377f3491ca8ac677d3cac6db1ebffb7a5fe92cce3ca"},
{file = "orjson-3.6.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:014ea74d4a5dd6a7e98540768072d5bd8c2fedbcbbedcbbaecbb614e66080e81"},
{file = "orjson-3.6.4-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:705cb90c536b4b9336c06b4a62c3c62e50354ddf20a2e48eb62bf34fb93d5b1f"},
{file = "orjson-3.6.4-cp38-cp38-manylinux_2_24_x86_64.whl", hash = "sha256:159e2240fc36720a5cb51a1cbc9905dcb8758aad50b3e7f14f6178ce2e842004"},
{file = "orjson-3.6.4-cp38-none-win_amd64.whl", hash = "sha256:d2ae087866a1050de83c2a28490850badb41aeeb8a4605c84dd6004d4e58b5a4"},
{file = "orjson-3.6.4-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:b4a7efe039b1154b23e5df8787ac01e4621213aed303b6304a5f8ad89c01455d"},
{file = "orjson-3.6.4-cp39-cp39-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:7b24f97ed76005f447e152b0e493abce8c60f010131998295175446312a71caf"},
{file = "orjson-3.6.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1121187e2a721864b52e5dbb3cf8dd4a4546519a5fef1e13fa777347fb8884a2"},
{file = "orjson-3.6.4-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:4edffd9e2298ff4f4f939aa67248eba043dc65c9e7d940c28a62c5502c6f2aa8"},
{file = "orjson-3.6.4-cp39-cp39-manylinux_2_24_x86_64.whl", hash = "sha256:e236fe94d8a77532f0065870fe265bd53e229012f39af99f79f5f1d4a8b0067c"},
{file = "orjson-3.6.4-cp39-none-win_amd64.whl", hash = "sha256:5448cc1edd4c4bafc968404f92f0e9a582b4326ca442346bd1d1179a6faf52d9"},
{file = "orjson-3.6.4.tar.gz", hash = "sha256:f8dbc428fc6d7420f231a7133d8dff4c882e64acb585dcf2fda74bdcfe1a6d9d"},
]
pathspec = [
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
]
platformdirs = []
pre-commit = []
platformdirs = [
{file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"},
{file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"},
]
pre-commit = [
{file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"},
{file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"},
]
pycparser = [
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
]
pycryptodomex = []
pynacl = []
pytube = []
pycryptodomex = [
{file = "pycryptodomex-3.11.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:7abfd84a362e4411f7c5f5758c18cbf377a2a2be64b9232e78544d75640c677e"},
{file = "pycryptodomex-3.11.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:6a76d7821ae43df8a0e814cca32114875916b9fc2158603b364853de37eb9002"},
{file = "pycryptodomex-3.11.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:1580db5878b1d16a233550829f7c189c43005f7aa818f2f95c7dddbd6a7163cc"},
{file = "pycryptodomex-3.11.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:c825611a951baad63faeb9ef1517ef96a20202d6029ae2485b729152cc703fab"},
{file = "pycryptodomex-3.11.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:7cc5ee80b2d5ee8f59a761741cfb916a068c97cac5e700c8ce01e1927616aa2f"},
{file = "pycryptodomex-3.11.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:fbe09e3ae95f47c7551a24781d2e348974cde4a0b33bc3b1566f6216479db2b1"},
{file = "pycryptodomex-3.11.0-cp27-cp27m-win32.whl", hash = "sha256:9eace1e5420abc4f9e76de01e49caca349b7c80bda9c1643193e23a06c2a332c"},
{file = "pycryptodomex-3.11.0-cp27-cp27m-win_amd64.whl", hash = "sha256:adc25aa8cfc537373dd46ae97863f16fd955edee14bf54d3eb52bde4e4ac8c7b"},
{file = "pycryptodomex-3.11.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cf30b5e03d974874185b989839c396d799f6e2d4b4d5b2d8bd3ba464eb3cc33f"},
{file = "pycryptodomex-3.11.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:c91772cf6808cc2d80279e80b491c48cb688797b6d914ff624ca95d855c24ee5"},
{file = "pycryptodomex-3.11.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:c391ec5c423a374a36b90f7c8805fdf51a0410a2b5be9cebd8990e0021cb6da4"},
{file = "pycryptodomex-3.11.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:64a83ab6f54496ab968a6f21a41a620afe0a742573d609fd03dcab7210645153"},
{file = "pycryptodomex-3.11.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:252ac9c1e1ae1c256a75539e234be3096f2d100b9f4bae42ef88067787b9b249"},
{file = "pycryptodomex-3.11.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bf2ea67eaa1fff0aecef6da881144f0f91e314b4123491f9a4fa8df0598e48fe"},
{file = "pycryptodomex-3.11.0-cp35-abi3-manylinux1_i686.whl", hash = "sha256:fe2b8c464ba335e71aed74f830bf2b2881913f8905d166f9c0fe06ca44a1cb5e"},
{file = "pycryptodomex-3.11.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:ff0826f3886e85708a0e8ef7ec47020723b998cfed6ae47962d915fcb89ec780"},
{file = "pycryptodomex-3.11.0-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:1d4d13c59d2cfbc0863c725f5812d66ff0d6836ba738ef26a52e1291056a1c7c"},
{file = "pycryptodomex-3.11.0-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:2b586d13ef07fa6197b6348a48dbbe9525f4f496205de14edfa4e91d99e69672"},
{file = "pycryptodomex-3.11.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:f35ccfa44a1dd267e392cd76d8525cfcfabee61dd070e15ad2119c54c0c31ddf"},
{file = "pycryptodomex-3.11.0-cp35-abi3-win32.whl", hash = "sha256:5baf690d27f39f2ba22f06e8e32c5f1972573ca65db6bdbb8b2c7177a0112dab"},
{file = "pycryptodomex-3.11.0-cp35-abi3-win_amd64.whl", hash = "sha256:919cadcedad552e78349d1626115cfd246fc03ad469a4a62c91a12204f0f0d85"},
{file = "pycryptodomex-3.11.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:c10b2f6bcbaa9aa51fe08207654100074786d423b03482c0cbe44406ca92d146"},
{file = "pycryptodomex-3.11.0-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:91662b27f5aa8a6d2ad63be9a7d1a403e07bf3c2c5b265a7cc5cbadf6f988e06"},
{file = "pycryptodomex-3.11.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:207e53bdbf3a26de6e9dcf3ebaf67ba70a61f733f84c464eca55d278211c1b71"},
{file = "pycryptodomex-3.11.0-pp27-pypy_73-win32.whl", hash = "sha256:1dd4271d8d022216533c3547f071662b44d703fd5dbb632c4b5e77b3ee47567f"},
{file = "pycryptodomex-3.11.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c43ddcff251e8b427b3e414b026636617276e008a9d78a44a9195d4bdfcaa0fe"},
{file = "pycryptodomex-3.11.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:ef25d682d0d9ab25c5022a298b5cba9084c7b148a3e71846df2c67ea664eacc7"},
{file = "pycryptodomex-3.11.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:4c7c6418a3c08b2ebfc2cf50ce52de267618063b533083a2c73b40ec54a1b6f5"},
{file = "pycryptodomex-3.11.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:15d25c532de744648f0976c56bd10d07b2a44b7eb2a6261ffe2497980b1102d8"},
{file = "pycryptodomex-3.11.0.tar.gz", hash = "sha256:0398366656bb55ebdb1d1d493a7175fc48ade449283086db254ac44c7d318d6d"},
]
pynacl = [
{file = "PyNaCl-1.4.0-cp27-cp27m-macosx_10_10_x86_64.whl", hash = "sha256:ea6841bc3a76fa4942ce00f3bda7d436fda21e2d91602b9e21b7ca9ecab8f3ff"},
{file = "PyNaCl-1.4.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:d452a6746f0a7e11121e64625109bc4468fc3100452817001dbe018bb8b08514"},
{file = "PyNaCl-1.4.0-cp27-cp27m-win32.whl", hash = "sha256:2fe0fc5a2480361dcaf4e6e7cea00e078fcda07ba45f811b167e3f99e8cff574"},
{file = "PyNaCl-1.4.0-cp27-cp27m-win_amd64.whl", hash = "sha256:f8851ab9041756003119368c1e6cd0b9c631f46d686b3904b18c0139f4419f80"},
{file = "PyNaCl-1.4.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:7757ae33dae81c300487591c68790dfb5145c7d03324000433d9a2c141f82af7"},
{file = "PyNaCl-1.4.0-cp35-abi3-macosx_10_10_x86_64.whl", hash = "sha256:757250ddb3bff1eecd7e41e65f7f833a8405fede0194319f87899690624f2122"},
{file = "PyNaCl-1.4.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:30f9b96db44e09b3304f9ea95079b1b7316b2b4f3744fe3aaecccd95d547063d"},
{file = "PyNaCl-1.4.0-cp35-abi3-win32.whl", hash = "sha256:4e10569f8cbed81cb7526ae137049759d2a8d57726d52c1a000a3ce366779634"},
{file = "PyNaCl-1.4.0-cp35-abi3-win_amd64.whl", hash = "sha256:c914f78da4953b33d4685e3cdc7ce63401247a21425c16a39760e282075ac4a6"},
{file = "PyNaCl-1.4.0-cp35-cp35m-win32.whl", hash = "sha256:06cbb4d9b2c4bd3c8dc0d267416aaed79906e7b33f114ddbf0911969794b1cc4"},
{file = "PyNaCl-1.4.0-cp35-cp35m-win_amd64.whl", hash = "sha256:511d269ee845037b95c9781aa702f90ccc36036f95d0f31373a6a79bd8242e25"},
{file = "PyNaCl-1.4.0-cp36-cp36m-win32.whl", hash = "sha256:11335f09060af52c97137d4ac54285bcb7df0cef29014a1a4efe64ac065434c4"},
{file = "PyNaCl-1.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cd401ccbc2a249a47a3a1724c2918fcd04be1f7b54eb2a5a71ff915db0ac51c6"},
{file = "PyNaCl-1.4.0-cp37-cp37m-win32.whl", hash = "sha256:8122ba5f2a2169ca5da936b2e5a511740ffb73979381b4229d9188f6dcb22f1f"},
{file = "PyNaCl-1.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:537a7ccbea22905a0ab36ea58577b39d1fa9b1884869d173b5cf111f006f689f"},
{file = "PyNaCl-1.4.0-cp38-cp38-win32.whl", hash = "sha256:9c4a7ea4fb81536c1b1f5cc44d54a296f96ae78c1ebd2311bd0b60be45a48d96"},
{file = "PyNaCl-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:7c6092102219f59ff29788860ccb021e80fffd953920c4a8653889c029b2d420"},
{file = "PyNaCl-1.4.0.tar.gz", hash = "sha256:54e9a2c849c742006516ad56a88f5c74bf2ce92c9f67435187c3c5953b346505"},
]
pytube = [
{file = "pytube-11.0.1-py3-none-any.whl", hash = "sha256:d4dfea7394d7662edac3831432b349b2afac984e0d0bc4bdb611ec1f3fc16318"},
{file = "pytube-11.0.1.tar.gz", hash = "sha256:47643a6ff553cbc4d6be748ff14c9c45f79984e15005adff25d62b18110abe43"},
]
pyyaml = [
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
{file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
@@ -714,17 +838,86 @@ pyyaml = [
{file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
]
redis = []
regex = []
requests = []
redis = [
{file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"},
{file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"},
]
regex = [
{file = "regex-2021.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:897c539f0f3b2c3a715be651322bef2167de1cdc276b3f370ae81a3bda62df71"},
{file = "regex-2021.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:886f459db10c0f9d17c87d6594e77be915f18d343ee138e68d259eb385f044a8"},
{file = "regex-2021.11.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:075b0fdbaea81afcac5a39a0d1bb91de887dd0d93bf692a5dd69c430e7fc58cb"},
{file = "regex-2021.11.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6238d30dcff141de076344cf7f52468de61729c2f70d776fce12f55fe8df790"},
{file = "regex-2021.11.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fab29411d75c2eb48070020a40f80255936d7c31357b086e5931c107d48306e"},
{file = "regex-2021.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0148988af0182a0a4e5020e7c168014f2c55a16d11179610f7883dd48ac0ebe"},
{file = "regex-2021.11.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be30cd315db0168063a1755fa20a31119da91afa51da2907553493516e165640"},
{file = "regex-2021.11.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e9cec3a62d146e8e122d159ab93ac32c988e2ec0dcb1e18e9e53ff2da4fbd30c"},
{file = "regex-2021.11.2-cp310-cp310-win32.whl", hash = "sha256:41c66bd6750237a8ed23028a6c9173dc0c92dc24c473e771d3bfb9ee817700c3"},
{file = "regex-2021.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:0075fe4e2c2720a685fef0f863edd67740ff78c342cf20b2a79bc19388edf5db"},
{file = "regex-2021.11.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0ed3465acf8c7c10aa2e0f3d9671da410ead63b38a77283ef464cbb64275df58"},
{file = "regex-2021.11.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab1fea8832976ad0bebb11f652b692c328043057d35e9ebc78ab0a7a30cf9a70"},
{file = "regex-2021.11.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb1e44d860345ab5d4f533b6c37565a22f403277f44c4d2d5e06c325da959883"},
{file = "regex-2021.11.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9486ebda015913909bc28763c6b92fcc3b5e5a67dee4674bceed112109f5dfb8"},
{file = "regex-2021.11.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20605bfad484e1341b2cbfea0708e4b211d233716604846baa54b94821f487cb"},
{file = "regex-2021.11.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f20f9f430c33597887ba9bd76635476928e76cad2981643ca8be277b8e97aa96"},
{file = "regex-2021.11.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1d85ca137756d62c8138c971453cafe64741adad1f6a7e63a22a5a8abdbd19fa"},
{file = "regex-2021.11.2-cp36-cp36m-win32.whl", hash = "sha256:af23b9ca9a874ef0ec20e44467b8edd556c37b0f46f93abfa93752ea7c0e8d1e"},
{file = "regex-2021.11.2-cp36-cp36m-win_amd64.whl", hash = "sha256:070336382ca92c16c45b4066c4ba9fa83fb0bd13d5553a82e07d344df8d58a84"},
{file = "regex-2021.11.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ef4e53e2fdc997d91f5b682f81f7dc9661db9a437acce28745d765d251902d85"},
{file = "regex-2021.11.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35ed5714467fc606551db26f80ee5d6aa1f01185586a7bccd96f179c4b974a11"},
{file = "regex-2021.11.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee36d5113b6506b97f45f2e8447cb9af146e60e3f527d93013d19f6d0405f3b"},
{file = "regex-2021.11.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fba661a4966adbd2c3c08d3caad6822ecb6878f5456588e2475ae23a6e47929"},
{file = "regex-2021.11.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77f9d16f7970791f17ecce7e7f101548314ed1ee2583d4268601f30af3170856"},
{file = "regex-2021.11.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6a28e87ba69f3a4f30d775b179aac55be1ce59f55799328a0d9b6df8f16b39d"},
{file = "regex-2021.11.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9267e4fba27e6dd1008c4f2983cc548c98b4be4444e3e342db11296c0f45512f"},
{file = "regex-2021.11.2-cp37-cp37m-win32.whl", hash = "sha256:d4bfe3bc3976ccaeb4ae32f51e631964e2f0e85b2b752721b7a02de5ce3b7f27"},
{file = "regex-2021.11.2-cp37-cp37m-win_amd64.whl", hash = "sha256:2bb7cae741de1aa03e3dd3a7d98c304871eb155921ca1f0d7cc11f5aade913fd"},
{file = "regex-2021.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:23f93e74409c210de4de270d4bf88fb8ab736a7400f74210df63a93728cf70d6"},
{file = "regex-2021.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d8ee91e1c295beb5c132ebd78616814de26fedba6aa8687ea460c7f5eb289b72"},
{file = "regex-2021.11.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e3ff69ab203b54ce5c480c3ccbe959394ea5beef6bd5ad1785457df7acea92e"},
{file = "regex-2021.11.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3c00cb5c71da655e1e5161481455479b613d500dd1bd252aa01df4f037c641f"},
{file = "regex-2021.11.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4abf35e16f4b639daaf05a2602c1b1d47370e01babf9821306aa138924e3fe92"},
{file = "regex-2021.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb11c982a849dc22782210b01d0c1b98eb3696ce655d58a54180774e4880ac66"},
{file = "regex-2021.11.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e3755e0f070bc31567dfe447a02011bfa8444239b3e9e5cca6773a22133839"},
{file = "regex-2021.11.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0621c90f28d17260b41838b22c81a79ff436141b322960eb49c7b3f91d1cbab6"},
{file = "regex-2021.11.2-cp38-cp38-win32.whl", hash = "sha256:8fbe1768feafd3d0156556677b8ff234c7bf94a8110e906b2d73506f577a3269"},
{file = "regex-2021.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:f9ee98d658a146cb6507be720a0ce1b44f2abef8fb43c2859791d91aace17cd5"},
{file = "regex-2021.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b3794cea825f101fe0df9af8a00f9fad8e119c91e39a28636b95ee2b45b6c2e5"},
{file = "regex-2021.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3576e173e7b4f88f683b4de7db0c2af1b209bb48b2bf1c827a6f3564fad59a97"},
{file = "regex-2021.11.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b4f4810117a9072a5aa70f7fea5f86fa9efbe9a798312e0a05044bd707cc33"},
{file = "regex-2021.11.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5930d334c2f607711d54761956aedf8137f83f1b764b9640be21d25a976f3a4"},
{file = "regex-2021.11.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:956187ff49db7014ceb31e88fcacf4cf63371e6e44d209cf8816cd4a2d61e11a"},
{file = "regex-2021.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e095f7f96a4b9f24b93c2c915f31a5201a6316618d919b0593afb070a5270e"},
{file = "regex-2021.11.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a56735c35a3704603d9d7b243ee06139f0837bcac2171d9ba1d638ce1df0742a"},
{file = "regex-2021.11.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:adf35d88d9cffc202e6046e4c32e1e11a1d0238b2fcf095c94f109e510ececea"},
{file = "regex-2021.11.2-cp39-cp39-win32.whl", hash = "sha256:30fe317332de0e50195665bc61a27d46e903d682f94042c36b3f88cb84bd7958"},
{file = "regex-2021.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:85289c25f658e3260b00178757c87f033f3d4b3e40aa4abdd4dc875ff11a94fb"},
{file = "regex-2021.11.2.tar.gz", hash = "sha256:5e85dcfc5d0f374955015ae12c08365b565c6f1eaf36dd182476a4d8e5a1cdb7"},
]
requests = [
{file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
{file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
]
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
toml = []
tomli = []
typing-extensions = []
urllib3 = []
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
tomli = [
{file = "tomli-1.2.2-py3-none-any.whl", hash = "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"},
{file = "tomli-1.2.2.tar.gz", hash = "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee"},
]
typing-extensions = [
{file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
{file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},
{file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"},
]
urllib3 = [
{file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"},
{file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"},
]
uvloop = [
{file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d"},
{file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c"},
@@ -743,8 +936,57 @@ uvloop = [
{file = "uvloop-0.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861"},
{file = "uvloop-0.16.0.tar.gz", hash = "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228"},
]
virtualenv = []
websockets = []
yarl = []
yt-dlp = []
zipp = []
websockets = [
{file = "websockets-10.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cd8c6f2ec24aedace251017bc7a414525171d4e6578f914acab9349362def4da"},
{file = "websockets-10.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1f6b814cff6aadc4288297cb3a248614829c6e4ff5556593c44a115e9dd49939"},
{file = "websockets-10.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:01db0ecd1a0ca6702d02a5ed40413e18b7d22f94afb3bbe0d323bac86c42c1c8"},
{file = "websockets-10.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:82b17524b1ce6ae7f7dd93e4d18e9b9474071e28b65dbf1dfe9b5767778db379"},
{file = "websockets-10.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:8bbf8660c3f833ddc8b1afab90213f2e672a9ddac6eecb3cde968e6b2807c1c7"},
{file = "websockets-10.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b8176deb6be540a46695960a765a77c28ac8b2e3ef2ec95d50a4f5df901edb1c"},
{file = "websockets-10.0-cp37-cp37m-win32.whl", hash = "sha256:706e200fc7f03bed99ad0574cd1ea8b0951477dd18cc978ccb190683c69dba76"},
{file = "websockets-10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5b2600e01c7ca6f840c42c747ffbe0254f319594ed108db847eb3d75f4aacb80"},
{file = "websockets-10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:085bb8a6e780d30eaa1ba48ac7f3a6707f925edea787cfb761ce5a39e77ac09b"},
{file = "websockets-10.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:9a4d889162bd48588e80950e07fa5e039eee9deb76a58092e8c3ece96d7ef537"},
{file = "websockets-10.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:b4ade7569b6fd17912452f9c3757d96f8e4044016b6d22b3b8391e641ca50456"},
{file = "websockets-10.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:2a43072e434c041a99f2e1eb9b692df0232a38c37c61d00e9f24db79474329e4"},
{file = "websockets-10.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:7f79f02c7f9a8320aff7d3321cd1c7e3a7dbc15d922ac996cca827301ee75238"},
{file = "websockets-10.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:1ac35426fe3e7d3d0fac3d63c8965c76ed67a8fd713937be072bf0ce22808539"},
{file = "websockets-10.0-cp38-cp38-win32.whl", hash = "sha256:ff59c6bdb87b31f7e2d596f09353d5a38c8c8ff571b0e2238e8ee2d55ad68465"},
{file = "websockets-10.0-cp38-cp38-win_amd64.whl", hash = "sha256:d67646ddd17a86117ae21c27005d83c1895c0cef5d7be548b7549646372f868a"},
{file = "websockets-10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:82bd921885231f4a30d9bc550552495b3fc36b1235add6d374e7c65c3babd805"},
{file = "websockets-10.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:7d2e12e4f901f1bc062dfdf91831712c4106ed18a9a4cdb65e2e5f502124ca37"},
{file = "websockets-10.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:71358c7816e2762f3e4af3adf0040f268e219f5a38cb3487a9d0fc2e554fef6a"},
{file = "websockets-10.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:fe83b3ec9ef34063d86dfe1029160a85f24a5a94271036e5714a57acfdd089a1"},
{file = "websockets-10.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:eb282127e9c136f860c6068a4fba5756eb25e755baffb5940b6f1eae071928b2"},
{file = "websockets-10.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:62160772314920397f9d219147f958b33fa27a12c662d4455c9ccbba9a07e474"},
{file = "websockets-10.0-cp39-cp39-win32.whl", hash = "sha256:e42a1f1e03437b017af341e9bbfdc09252cd48ef32a8c3c3ead769eab3b17368"},
{file = "websockets-10.0-cp39-cp39-win_amd64.whl", hash = "sha256:c5880442f5fc268f1ef6d37b2c152c114deccca73f48e3a8c48004d2f16f4567"},
{file = "websockets-10.0.tar.gz", hash = "sha256:c4fc9a1d242317892590abe5b61a9127f1a61740477bfb121743f290b8054002"},
]
virtualenv = [
{file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"},
{file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"},
]
yarl = [
{file = "yarl-1.5.1-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb"},
{file = "yarl-1.5.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593"},
{file = "yarl-1.5.1-cp35-cp35m-win32.whl", hash = "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409"},
{file = "yarl-1.5.1-cp35-cp35m-win_amd64.whl", hash = "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317"},
{file = "yarl-1.5.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511"},
{file = "yarl-1.5.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e"},
{file = "yarl-1.5.1-cp36-cp36m-win32.whl", hash = "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f"},
{file = "yarl-1.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2"},
{file = "yarl-1.5.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a"},
{file = "yarl-1.5.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8"},
{file = "yarl-1.5.1-cp37-cp37m-win32.whl", hash = "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8"},
{file = "yarl-1.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d"},
{file = "yarl-1.5.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02"},
{file = "yarl-1.5.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a"},
{file = "yarl-1.5.1-cp38-cp38-win32.whl", hash = "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6"},
{file = "yarl-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692"},
{file = "yarl-1.5.1.tar.gz", hash = "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6"},
]
yt-dlp = [
{file = "yt-dlp-2021.10.22.tar.gz", hash = "sha256:a24b9666bd2234149e4da8c4f16bb8e5f746c29428d12ee04fc1c11b5247a307"},
{file = "yt_dlp-2021.10.22-py2.py3-none-any.whl", hash = "sha256:4900fdfffa3de0b09a74f3d7fe98e4d5e21f3ce74db0c106c942614f2ebc3368"},
]
+2 -3
View File
@@ -7,12 +7,11 @@ license = "GPL-v3.0"
[tool.poetry.dependencies]
python = "^3.8"
lavalink = "4.0.4"
lavalink = "^3.1.4"
humanize = "^3.12.0"
"discord.py" = { git = "https://github.com/iDevision/enhanced-discord.py", branch = "2.0", extras = ["voice", "speed"] }
uvloop = {version = "^0.16.0", optional = true}
aioredis = "^2.0.0"
"discord.py" = {version = "^2.0.1", extras = ["voice"]}
jishaku = "^2.5.1"
[tool.poetry.dev-dependencies]
black = {version = "^21.9b0", allow-prereleases = true}
+57
View File
@@ -1,6 +1,16 @@
from abc import ABC
from abc import abstractmethod
from typing import Any
from typing import Optional
from typing import Protocol
from typing import TYPE_CHECKING
from typing import Union
if TYPE_CHECKING:
from tunebot.plugins import ServiceEvent
from discord.ext.commands import Cog
AnyDict = dict[Any, Any]
class GlobalPlaylistSource(ABC):
@@ -32,6 +42,10 @@ class GlobalPlaylist(ABC):
async def clear(self):
pass
@abstractmethod
async def remove_tracks(self, track_urls: list[str]):
pass
class GlobalAutoJoin(ABC):
@abstractmethod
@@ -49,10 +63,53 @@ class AutoJoin(ABC):
pass
class ServiceBase(Protocol):
async def on_dispatch(self, event: "ServiceEvent", payload: AnyDict):
...
class PluginManagerBase(Protocol):
def get_plugin(self, name: str) -> Union["BasePluginInstance", None]:
...
def remove_plugin(self, name: str):
...
def enable_plugin(self, name: str, plugin: "BasePluginInstance"):
...
async def dispatch(self, event: "ServiceEvent", payload: AnyDict = {}):
...
class PluginLoaderBase(Protocol):
def load_plugin(self, plug_conf: AnyDict) -> "BasePluginInstance":
...
class GlobalUtils(Protocol):
async def raw_table_lookup(self, table_name: str, keys: list[Any]) -> list[Any]:
...
async def raw_table_del_entry(self, table_name: str, keys: list[Any]):
...
class BasePluginInstance(Protocol):
config: AnyDict
services: list["ServiceBase"]
cogs: list[str]
__all__ = (
"GlobalPlaylistSource",
"PlaylistSource",
"GlobalPlaylist",
"GlobalAutoJoin",
"AutoJoin",
"ServiceBase",
"PluginManagerBase",
"PluginLoaderBase",
"GlobalUtils",
"BasePluginInstance",
)
-28
View File
@@ -1,28 +0,0 @@
import typing
from dataclasses import dataclass
if typing.TYPE_CHECKING:
from aioredis import Redis
from bot import TuneBot
from discord import Interaction
from context import CustomContext
@dataclass
class ContextLike:
redis: "Redis"
bot: "TuneBot"
guild_id: int
@staticmethod
def from_discord_context(ctx: "CustomContext") -> "ContextLike":
return ContextLike(redis=ctx.redis, bot=ctx.bot, guild_id=ctx.guild.id)
@staticmethod
def from_discord_interaction(ctx: "Interaction") -> "ContextLike":
return ContextLike(
redis=ctx.client._redis_client, bot=ctx.client, guild_id=ctx.guild_id
)
__all__ = ("ContextLike",)
+6
View File
@@ -0,0 +1,6 @@
# noreorder
from tunebot.plugins.exceptions import *
from tunebot.plugins.events import *
from tunebot.plugins.plugin import *
from tunebot.plugins.loader import *
from tunebot.plugins.manager import *
+16
View File
@@ -0,0 +1,16 @@
from enum import auto
from enum import Enum
class ServiceEvent(Enum):
"""
This Enum contains all possible events that can be dispatched to services
Args:
Enum ([type]): [description]
"""
TRACK_ENDED = auto()
__all__ = ("ServiceEvent",)
+5
View File
@@ -0,0 +1,5 @@
class PluginInitFailed(Exception):
pass
__all__ = ("PluginInitFailed",)
+42
View File
@@ -0,0 +1,42 @@
import importlib
from typing import Any
from typing import TYPE_CHECKING
from tunebot.plugins import PluginInstance
from tunebot.plugins.exceptions import PluginInitFailed
if TYPE_CHECKING:
from tunebot import BasePluginInstance
from tunebot import ServiceBase
from bot import TuneBot
AnyDict = dict[Any, Any]
class FileSystemPluginLoader:
def __init__(self, bot: "TuneBot") -> None:
self.bot = bot
def load_plugin(self, plug_conf: AnyDict) -> "BasePluginInstance":
"""
Loads a plugin from configuration and returns a sequence of Services
Raises:
PluginInitFailed: [description]
Returns:
tuple[list["ServiceBase"], list[str]]: [description]
"""
services: list["ServiceBase"] = []
for service_location in plug_conf["services"]:
module = importlib.import_module(service_location)
if not hasattr(module, "setup"):
raise PluginInitFailed('Failed to find setup() for "{location}"')
services.append(module.setup(self.bot, plug_conf))
return PluginInstance(plug_conf["config"], services, plug_conf["cogs"])
__all__ = ("FileSystemPluginLoader",)
+79
View File
@@ -0,0 +1,79 @@
from typing import Any
from typing import TYPE_CHECKING
from typing import Union
from discord.ext.commands.errors import ExtensionAlreadyLoaded
from discord.ext.commands.errors import ExtensionFailed
from discord.ext.commands.errors import ExtensionNotFound
from discord.ext.commands.errors import NoEntryPointError
if TYPE_CHECKING:
from tunebot.abc import BasePluginInstance
from tunebot.plugins import ServiceEvent
from bot import TuneBot
AnyDict = dict[Any, Any]
class SimplePluginManager:
_plugins: dict[str, "BasePluginInstance"] = {}
def __init__(self, bot: "TuneBot") -> None:
self.bot = bot
def get_plugin(self, name: str) -> Union["BasePluginInstance", None]:
"""
Retrieves the corresponding `BasePluginInstance` if it exists
Returns:
Union["BasePluginInstance", None]: [description]
"""
return self._plugins.get(name)
def remove_plugin(self, name: str):
"""
Unloads/Removes all components related to the `BasePluginInstance`
Args:
name (str): [description]
"""
plugin = self.get_plugin(name)
if not plugin:
return
for cog in plugin.cogs:
try:
self.bot.unload_extension(cog)
except Exception:
pass
del self._plugins[name]
def enable_plugin(self, name: str, plugin: "BasePluginInstance"):
"""
Loads/Activates all cogs/services included within the plugin
Args:
plugin_name (str): [description]
services (list[): [description]
cog_names (list[str]): [description]
"""
self._plugins[name] = plugin
for cog_name in plugin.cogs:
self.bot.load_extension(cog_name)
async def dispatch(self, event: "ServiceEvent", payload: AnyDict = {}):
"""
Dispatches an event to all registered services
Args:
event (ServiceEvent): [description]
payload (AnyDict, optional): [description]. Defaults to {}.
"""
for plugin in self._plugins.values():
for service in plugin.services:
await service.on_dispatch(event, payload)
__all__ = ("SimplePluginManager",)
+18
View File
@@ -0,0 +1,18 @@
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from tunebot import ServiceBase
AnyDict = dict[Any, Any]
@dataclass
class PluginInstance:
config: AnyDict
services: list["ServiceBase"]
cogs: list[str]
__all__ = ("PluginInstance",)
+3 -1
View File
@@ -1,4 +1,6 @@
from tunebot.redis.entity import * # noreorder
# noreorder
from tunebot.redis.entity import *
from tunebot.redis.utils import *
from tunebot.redis.autojoin import *
from tunebot.redis.playlist import *
from tunebot.redis.playlist_source import *
+4 -4
View File
@@ -22,20 +22,20 @@ class RedisAutoJoin(RedisContextEntity, AutoJoin):
voice_channel_id (int): [description]
text_channel_id (int): [description]
"""
if not self.ctx.guild_id:
if not self.ctx.guild:
raise Exception("This method can only be invoked inside of a guild.")
value = f"{voice_channel_id}:{text_channel_id}"
await self.redis.hset(self.key("autojoin"), self.ctx.guild_id, value)
await self.redis.hset(self.key("autojoin"), self.ctx.guild.id, value)
async def disable(self):
"""
Removes the AutoJoin configuration of a guild.
"""
if not self.ctx.guild_id:
if not self.ctx.guild:
raise Exception("This method can only be invoked inside of a guild.")
await self.redis.hdel(self.key("autojoin"), self.ctx.guild_id)
await self.redis.hdel(self.key("autojoin"), self.ctx.guild.id)
__all__ = ("GlobalRedisAutoJoin", "RedisAutoJoin")
+2 -2
View File
@@ -3,7 +3,7 @@ from abc import abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from tunebot.context import ContextLike
from context import CustomContext
from aioredis.client import Redis
@@ -34,7 +34,7 @@ class RedisBotEntity(RedisEntity):
class RedisContextEntity(RedisEntity):
def __init__(self, ctx: "ContextLike") -> None:
def __init__(self, ctx: "CustomContext") -> None:
self.ctx = ctx
super().__init__()
+9
View File
@@ -29,5 +29,14 @@ class GlobalRedisPlaylist(RedisBotEntity, GlobalPlaylist):
"""
await self.redis.delete(self.key("playlist"))
async def remove_tracks(self, track_urls: list[str]):
"""
Removes a single track from the playlist
Args:
track_url (str): [description]
"""
await self.redis.srem(self.key("playlist"), *track_urls)
__all__ = ("GlobalRedisPlaylist",)
+18
View File
@@ -0,0 +1,18 @@
from typing import Any
from tunebot.redis import RedisBotEntity
class GlobalRedisUtils(RedisBotEntity):
async def raw_table_lookup(self, table_name: str, keys: list[Any]) -> list[Any]:
if len(keys) == 0:
return []
result: list[Any] = await self.redis.hmget(table_name, keys)
return result
async def raw_table_del_entry(self, table_name: str, keys: list[Any]):
if len(keys) == 0:
return
await self.redis.hdel(table_name, *keys)
+47
View File
@@ -0,0 +1,47 @@
from typing import Optional
from typing import Union
import discord
from discord import Embed
from discord.ext.commands import Context
class EmbedGenerator:
@staticmethod
async def Error(ctx: Context, message: str, **kwargs) -> Embed:
color = ctx.bot.colors["embed"]
em = Embed(title="Error:", description=message, color=color)
return await EmbedGenerator.SendWithFooter(ctx, em, **kwargs)
@staticmethod
async def Message(
ctx: Context, title: str, message: Optional[str] = "", **kwargs
) -> Embed:
color = ctx.bot.colors["embed"]
em = Embed(title=title, description=message, color=color)
return await EmbedGenerator.SendWithFooter(ctx, em, **kwargs)
@staticmethod
async def Image(
ctx: Context, title: str, url: str, message: Optional[str] = "", **kwargs
) -> Embed:
color = ctx.bot.colors["embed"]
em = Embed(title=title, description=message, url=url, color=color)
em.set_image(url=url)
return await EmbedGenerator.SendWithFooter(ctx, em, **kwargs)
@staticmethod
async def Title(ctx: Context, title: str, **kwargs) -> Embed:
color = ctx.bot.colors["embed"]
em = Embed(title=title, color=color)
return await EmbedGenerator.SendWithFooter(ctx, em, **kwargs)
@staticmethod
async def SendWithFooter(
ctx: Context, em: Embed, **kwargs
) -> Union[discord.Message, Embed]:
avatar = ctx.author.avatar.with_static_format("jpeg")
em.set_footer(text=f"Requested by: {ctx.author}", icon_url=avatar)
if kwargs.get("no_send", False):
return em
return await ctx.send(embed=em, **kwargs)
-10
View File
@@ -1,10 +0,0 @@
import typing
import discord
def process_colours(colors: typing.Dict[str, str]) -> typing.Dict[str, discord.Color]:
colour_dict: typing.Dict[str, discord.Color] = {}
for name, color in colors.items():
colour_dict[name] = discord.Color(int(color, 16))
return colour_dict
+13 -3
View File
@@ -1,13 +1,15 @@
import asyncio
from typing import Dict
from typing import TYPE_CHECKING
from discord.ext.commands import Cog
from bot import TuneBot
if TYPE_CHECKING:
from tunebot import BasePluginInstance
from bot import TuneBot
class BaseCog(Cog):
def __init__(self, bot: TuneBot) -> None:
def __init__(self, bot: "TuneBot") -> None:
self.bot = bot
slash_descriptions: Dict[str, str] = self.bot.config["slash_descriptions"]
@@ -20,3 +22,11 @@ class BaseCog(Cog):
hasattr(self.bot, "lavalink")
and len(self.bot.lavalink.node_manager.available_nodes) > 0
)
class PluginCog(BaseCog):
def get_plugin_instance(self, name: str) -> "BasePluginInstance":
if plugin := self.bot.plugin_manager.get_plugin(name):
return plugin
raise KeyError(f"Failed to retrieve plugin instance with name: {name}")
+27
View File
@@ -0,0 +1,27 @@
from typing import Callable
from typing import TYPE_CHECKING
from typing import TypeVar
from discord.ext.commands import check
from discord.ext.commands.errors import NotOwner
if TYPE_CHECKING:
from context import CustomContext
T = TypeVar("T")
def source_manager_only() -> Callable[[T], T]:
"""
A :func:`.check` that checks if the person invoking this command is allowed to modify the radio sources.
"""
async def predicate(ctx: "CustomContext") -> bool:
is_manager = ctx.author.id in ctx.bot.config["manager_ids"]
is_owner = await ctx.bot.is_owner(ctx.author)
if not is_manager and not is_owner:
raise NotOwner("You are not allowed to modify the sources.")
return True
return check(predicate)
-13
View File
@@ -1,13 +0,0 @@
import discord
from bot import colors
def create_embed(user: discord.Member | discord.User) -> discord.Embed:
avatar = None
if avatar_asset := user.avatar:
avatar = avatar_asset.with_static_format("jpeg")
embed = discord.Embed(color=colors["embed"])
embed.set_footer(text=f"Requested by: {user}", icon_url=avatar)
return embed
+12
View File
@@ -0,0 +1,12 @@
from discord.embeds import Embed
from discord.ext.commands import CommandError
from context import CustomContext
class EmbeddedCommandException(CommandError):
def __init__(self, embed: Embed) -> None:
self.embed = embed
async def send(self, ctx: CustomContext):
await ctx.send(embed=self.embed)
-5
View File
@@ -1,5 +0,0 @@
import logging
logger = logging.getLogger("discord")
__all__ = ("logger",)
+843
View File
@@ -0,0 +1,843 @@
# Original work Copyright (c) 2015 Rapptz (https://github.com/Rapptz/RoboDanny)
# Modified work Copyright (c) 2017 Perry Fraser
#
# Licensed under the MIT License. https://opensource.org/licenses/MIT
# Stolen line for line from paginator.py in R. Danny's code
# Added formatting and lots of blocking of inspections
import asyncio
import copy
import inspect
import itertools
import re
import discord
class CannotPaginate(Exception):
pass
class Pages:
"""Implements a paginator that queries the user for the
pagination interface.
Pages are 1-index based, not 0-index based.
If the user does not reply within 2 minutes then the pagination
interface exits automatically.
Parameters
------------
ctx: Context
The context of the command.
entries: List[str]
A list of entries to paginate.
per_page: int
How many entries show up per page.
show_entry_count: bool
Whether to show an entry count in the footer.
Attributes
-----------
embed: discord.Embed
The embed object that is being used to send pagination info.
Feel free to modify this externally. Only the description,
footer fields, and colour are internally modified.
permissions: discord.Permissions
Our permissions for the channel.
"""
def __init__(
self, ctx, *, entries, per_page=12, show_entry_count=True, hide_no_results=False
):
self.hide_no_results = hide_no_results
self.bot = ctx.bot
self.entries = entries
self.message = ctx.message
self.channel = ctx.channel
self.author = ctx.author
self.per_page = per_page
pages, left_over = divmod(len(self.entries), self.per_page)
if left_over:
pages += 1
self.maximum_pages = pages
self.embed = discord.Embed(color=0xDEADBF)
self.paginating = len(entries) > per_page
self.show_entry_count = show_entry_count
self.reaction_emojis = [
(
"\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}",
self.first_page,
),
("\N{BLACK LEFT-POINTING TRIANGLE}", self.previous_page),
("\N{BLACK RIGHT-POINTING TRIANGLE}", self.next_page),
(
"\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}",
self.last_page,
),
("\N{INPUT SYMBOL FOR NUMBERS}", self.numbered_page),
("\N{BLACK SQUARE FOR STOP}", self.stop_pages),
("\N{INFORMATION SOURCE}", self.show_help),
]
if ctx.guild is not None:
self.permissions = self.channel.permissions_for(ctx.guild.me)
else:
self.permissions = self.channel.permissions_for(ctx.bot.user)
if not self.permissions.embed_links:
raise CannotPaginate("Bot does not have embed links permission.")
if not self.permissions.send_messages:
raise CannotPaginate("Bot cannot send messages.")
if self.paginating:
# verify we can actually use the pagination session
if not self.permissions.add_reactions:
raise CannotPaginate("Bot does not have add reactions permission.")
if not self.permissions.read_message_history:
raise CannotPaginate(
"Bot does not have Read Message History permission."
)
def get_page(self, page):
base = (page - 1) * self.per_page
return self.entries[base : base + self.per_page]
async def show_page(self, page, *, first=False):
# noinspection PyAttributeOutsideInit
self.current_page = page
entries = self.get_page(page)
p = []
for index, entry in enumerate(entries, 1 + ((page - 1) * self.per_page)):
p.append(f"{index}. {entry}")
if self.maximum_pages > 1:
if self.show_entry_count:
text = (
f"Page {page}/{self.maximum_pages}"
f" ({len(self.entries)} entries)"
)
else:
text = f"Page {page}/{self.maximum_pages}"
self.embed.set_footer(text=text)
if not self.paginating:
self.embed.description = "\n".join(p)
return await self.channel.send(embed=self.embed)
if not first:
self.embed.description = "\n".join(p)
await self.message.edit(embed=self.embed)
return
p.append("")
p.append("Confused? React with \N{INFORMATION SOURCE} for more info.")
self.embed.description = "\n".join(p)
self.message = await self.channel.send(embed=self.embed)
await self.message.add_reaction("🔣")
async def add_rest_reactions(self):
await self.message.remove_reaction("🔣", self.message.guild.me)
for (reaction, _) in self.reaction_emojis:
if self.maximum_pages == 2 and reaction in ("\u23ed", "\u23ee"):
# no |<< or >>| buttons if we only have two pages
# we can't forbid it if someone ends up using it but remove
# it from the default set
continue
await self.message.add_reaction(reaction)
async def checked_show_page(self, page):
if page != 0 and page <= self.maximum_pages:
await self.show_page(page)
async def first_page(self):
"""goes to the first page"""
await self.show_page(1)
async def last_page(self):
"""goes to the last page"""
await self.show_page(self.maximum_pages)
async def next_page(self):
"""goes to the next page"""
await self.checked_show_page(self.current_page + 1)
async def previous_page(self):
"""goes to the previous page"""
await self.checked_show_page(self.current_page - 1)
async def show_current_page(self):
if self.paginating:
await self.show_page(self.current_page)
async def numbered_page(self):
"""lets you type a page number to go to"""
# noinspection PyListCreation
to_delete = []
to_delete.append(await self.channel.send("What page do you want to go to?"))
def message_check(m):
return (
m.author == self.author
and self.channel == m.channel
and m.content.isdigit()
)
try:
msg = await self.bot.wait_for("message", check=message_check, timeout=30.0)
except asyncio.TimeoutError:
to_delete.append(await self.channel.send("Took too long."))
await asyncio.sleep(5)
else:
page = int(msg.content)
to_delete.append(msg)
if page != 0 and page <= self.maximum_pages:
await self.show_page(page)
else:
to_delete.append(
await self.channel.send(
f"Invalid page given. ({page}/{self.maximum_pages})"
)
)
await asyncio.sleep(5)
# noinspection PyBroadException
try:
await self.channel.delete_messages(to_delete)
except Exception:
pass
async def show_help(self):
"""shows this message"""
messages = [
"Welcome to the interactive paginator!\n",
"This interactively allows you to see pages "
"of text by navigating with "
"reactions. They are as follows:\n",
]
for (emoji, func) in self.reaction_emojis:
messages.append(f"{emoji} {func.__doc__}")
self.embed.description = "\n".join(messages)
self.embed.clear_fields()
self.embed.set_footer(
text=f"We were on page {self.current_page} before this message."
)
await self.message.edit(embed=self.embed)
async def go_back_to_current_page():
await asyncio.sleep(60.0)
await self.show_current_page()
self.bot.loop.create_task(go_back_to_current_page())
async def stop_pages(self):
"""stops the interactive pagination session"""
await self.message.delete()
self.paginating = False
def react_check(self, reaction, user):
if user is None or user.id != self.author.id:
return False
if reaction.message.id != self.message.id:
return False
if reaction.emoji == "🔣":
self.match = self.add_rest_reactions
return True
for (emoji, func) in self.reaction_emojis:
if reaction.emoji == emoji:
# noinspection PyAttributeOutsideInit
self.match = func
return True
return False
async def paginate(self):
"""Actually paginate the entries and
run the interactive loop if necessary."""
if not self.entries and not self.hide_no_results:
# I just say no results found because that's my most common use
# case.
return await self.channel.send("No results found.")
first_page = self.show_page(1, first=True)
if not self.paginating:
await first_page
else:
# allow us to react to reactions right away if we're paginating
self.bot.loop.create_task(first_page)
while self.paginating:
try:
reaction, user = await self.bot.wait_for(
"reaction_add", check=self.react_check, timeout=120.0
)
except asyncio.TimeoutError:
self.paginating = False
# noinspection PyBroadException
try:
await self.message.clear_reactions()
except Exception:
pass
finally:
break
# noinspection PyBroadException
try:
await self.message.remove_reaction(reaction, user)
except Exception:
pass # can't remove it so don't bother doing so
await self.match()
class EmbedPages:
"""Similar to Pages, but you use [`discord.Embed`]"""
def __init__(self, ctx, *, embeds):
self.bot = ctx.bot
self.embeds = embeds
self.message = ctx.message
self.channel = ctx.channel
self.author = ctx.author
pages = len(self.embeds)
self.maximum_pages = pages
self.paginating = len(embeds) > 1
self.reaction_emojis = [
(
"\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}",
self.first_page,
),
("\N{BLACK LEFT-POINTING TRIANGLE}", self.previous_page),
("\N{BLACK RIGHT-POINTING TRIANGLE}", self.next_page),
(
"\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}",
self.last_page,
),
("\N{INPUT SYMBOL FOR NUMBERS}", self.numbered_page),
("\N{BLACK SQUARE FOR STOP}", self.stop_pages),
("\N{INFORMATION SOURCE}", self.show_help),
]
if ctx.guild is not None:
self.permissions = self.channel.permissions_for(ctx.guild.me)
else:
self.permissions = self.channel.permissions_for(ctx.bot.user)
if not self.permissions.embed_links:
raise CannotPaginate("Bot does not have embed links permission.")
if not self.permissions.send_messages:
raise CannotPaginate("Bot cannot send messages.")
if self.paginating:
# verify we can actually use the pagination session
if not self.permissions.add_reactions:
raise CannotPaginate("Bot does not have add reactions permission.")
if not self.permissions.read_message_history:
raise CannotPaginate(
"Bot does not have Read Message History permission."
)
async def show_page(self, page, *, first=False):
# noinspection PyAttributeOutsideInit
self.current_page = page
embed = copy.copy(self.embeds[page - 1])
p = []
if self.maximum_pages > 1:
text = f"Page {page}/{self.maximum_pages}"
embed.set_footer(text=text)
if not self.paginating:
return await self.channel.send(embed=embed)
if not first:
return await self.message.edit(embed=embed)
p.append("")
p.append("Confused? React with \N{INFORMATION SOURCE} for more info.")
embed.description = (
"" if embed.description == discord.Embed.Empty else embed.description
)
embed.description += "\n".join(p)
self.message = await self.channel.send(embed=embed)
await self.message.add_reaction("🔣")
async def add_rest_reactions(self):
await self.message.remove_reaction("🔣", self.message.guild.me)
for (reaction, _) in self.reaction_emojis:
if self.maximum_pages == 2 and reaction in ("\u23ed", "\u23ee"):
# no |<< or >>| buttons if we only have two pages
# we can't forbid it if someone ends up using it but remove
# it from the default set
continue
await self.message.add_reaction(reaction)
async def checked_show_page(self, page):
if page != 0 and page <= self.maximum_pages:
await self.show_page(page)
async def first_page(self):
"""goes to the first page"""
await self.show_page(1)
async def last_page(self):
"""goes to the last page"""
await self.show_page(self.maximum_pages)
async def next_page(self):
"""goes to the next page"""
await self.checked_show_page(self.current_page + 1)
async def previous_page(self):
"""goes to the previous page"""
await self.checked_show_page(self.current_page - 1)
async def show_current_page(self):
if self.paginating:
await self.show_page(self.current_page)
async def numbered_page(self):
"""lets you type a page number to go to"""
# noinspection PyListCreation
to_delete = []
to_delete.append(await self.channel.send("What page do you want to go to?"))
def message_check(m):
return (
m.author == self.author
and self.channel == m.channel
and m.content.isdigit()
)
try:
msg = await self.bot.wait_for("message", check=message_check, timeout=30.0)
except asyncio.TimeoutError:
to_delete.append(await self.channel.send("Took too long."))
await asyncio.sleep(5)
else:
page = int(msg.content)
to_delete.append(msg)
if page != 0 and page <= self.maximum_pages:
await self.show_page(page)
else:
to_delete.append(
await self.channel.send(
f"Invalid page given. ({page}/{self.maximum_pages})"
)
)
await asyncio.sleep(5)
# noinspection PyBroadException
try:
await self.channel.delete_messages(to_delete)
except Exception:
pass
async def show_help(self):
"""shows this message"""
messages = [
"Welcome to the interactive paginator!\n",
"This interactively allows you to see pages "
"of text by navigating with "
"reactions. They are as follows:\n",
]
for (emoji, func) in self.reaction_emojis:
messages.append(f"{emoji} {func.__doc__}")
embed = discord.Embed()
embed.description = "\n".join(messages)
embed.clear_fields()
embed.set_footer(
text=f"We were on page {self.current_page} before this message."
)
await self.message.edit(embed=embed)
async def go_back_to_current_page():
await asyncio.sleep(60.0)
await self.show_current_page()
self.bot.loop.create_task(go_back_to_current_page())
async def stop_pages(self):
"""stops the interactive pagination session"""
await self.message.delete()
self.paginating = False
def react_check(self, reaction, user):
if user is None or user.id != self.author.id:
return False
if reaction.message.id != self.message.id:
return False
if reaction.emoji == "🔣":
self.match = self.add_rest_reactions
return True
for (emoji, func) in self.reaction_emojis:
if reaction.emoji == emoji:
# noinspection PyAttributeOutsideInit
self.match = func
return True
return False
async def paginate(self):
"""Actually paginate the entries and
run the interactive loop if necessary."""
first_page = self.show_page(1, first=True)
if not self.paginating:
await first_page
else:
# allow us to react to reactions right away if we're paginating
self.bot.loop.create_task(first_page)
while self.paginating:
try:
reaction, user = await self.bot.wait_for(
"reaction_add", check=self.react_check, timeout=120.0
)
except asyncio.TimeoutError:
self.paginating = False
# noinspection PyBroadException
try:
await self.message.clear_reactions()
except Exception:
pass
finally:
break
# noinspection PyBroadException
try:
await self.message.remove_reaction(reaction, user)
except Exception:
pass # can't remove it so don't bother doing so
await self.match()
class FieldPages(Pages):
"""Similar to Pages except entries should be a list of
tuples having (key, value) to show as embed fields instead.
"""
async def show_page(self, page, *, first=False):
# noinspection PyAttributeOutsideInit
self.current_page = page
entries = self.get_page(page)
self.embed.clear_fields()
self.embed.description = discord.Embed.Empty
for key, value in entries:
self.embed.add_field(name=key, value=value, inline=False)
if self.maximum_pages > 1:
if self.show_entry_count:
text = (
f"Page {page}/{self.maximum_pages} "
f"({len(self.entries)} entries)"
)
else:
text = f"Page {page}/{self.maximum_pages}"
self.embed.set_footer(text=text)
if not self.paginating:
return await self.channel.send(embed=self.embed)
if not first:
await self.message.edit(embed=self.embed)
return
self.message = await self.channel.send(embed=self.embed)
for (reaction, _) in self.reaction_emojis:
if self.maximum_pages == 2 and reaction in ("\u23ed", "\u23ee"):
# no |<< or >>| buttons if we only have two pages
# we can't forbid it if someone ends up using it but remove
# it from the default set
continue
await self.message.add_reaction(reaction)
# ?help
# ?help Cog
# ?help command
# -> could be a subcommand
_mention = re.compile(r"<@!?([0-9]{1,19})>")
def cleanup_prefix(bot, prefix):
m = _mention.match(prefix)
if m:
user = bot.get_user(int(m.group(1)))
if user:
return f"@{user.name} "
return prefix
async def _can_run(cmd, ctx):
# noinspection PyBroadException
try:
return await cmd.can_run(ctx)
except Exception:
return False
def _command_signature(cmd):
# this is modified from discord.py source
# which I wrote myself
result = [cmd.qualified_name]
if cmd.usage:
result.append(cmd.usage)
return " ".join(result)
params = cmd.clean_params
if not params:
return " ".join(result)
for name, param in params.items():
if param.default is not param.empty:
# We don't want None or '' to trigger the [name=value] case and
# instead it should do [name] since [name=None] or [name=] are
# not exactly useful for the user.
should_print = (
param.default
if isinstance(param.default, str)
else param.default is not None
)
if should_print:
result.append(f"[{name}={param.default!r}]")
else:
result.append(f"[{name}]")
elif param.kind == param.VAR_POSITIONAL:
result.append(f"[{name}...]")
else:
result.append(f"<{name}>")
return " ".join(result)
class HelpPaginator(Pages):
def __init__(self, ctx, entries, *, per_page=4):
super().__init__(ctx, entries=entries, per_page=per_page, hide_no_results=True)
self.reaction_emojis.append(
("\N{WHITE QUESTION MARK ORNAMENT}", self.show_bot_help)
)
self.total = len(entries)
@classmethod
async def from_cog(cls, ctx, cog):
cog_name = cog.__class__.__name__
# get the commands
entries = sorted(cog.get_commands(), key=lambda c: c.name)
# remove the ones we can't run
entries = [
cmd for cmd in entries if (await _can_run(cmd, ctx)) and not cmd.hidden
]
self = cls(ctx, entries)
self.title = f"{cog_name} Commands".upper()
self.description = inspect.getdoc(cog)
self.prefix = cleanup_prefix(ctx.bot, ctx.prefix)
# no longer need the database
return self
@classmethod
async def from_command(cls, ctx, command):
try:
entries = sorted(command.commands, key=lambda c: c.name)
except AttributeError:
entries = []
else:
entries = [
cmd for cmd in entries if (await _can_run(cmd, ctx)) and not cmd.hidden
]
self = cls(ctx, entries)
self.title = command.signature
if command.description:
self.description = f"{command.description}\n\n{command.help}"
else:
self.description = command.help or "No help given."
self.prefix = cleanup_prefix(ctx.bot, ctx.prefix)
return self
@classmethod
async def from_bot(cls, ctx):
def key(c):
return c.cog_name or "\u200bMisc"
entries = sorted(ctx.bot.commands, key=key)
nested_pages = []
per_page = 9
# 0: (cog, desc, commands) (max len == 9)
# 1: (cog, desc, commands) (max len == 9)
# ...
for cog, commands in itertools.groupby(entries, key=key):
plausible = [
cmd for cmd in commands if (await _can_run(cmd, ctx)) and not cmd.hidden
]
if len(plausible) == 0:
continue
description = ctx.bot.get_cog(cog)
if description is None:
description = discord.Embed.Empty
else:
description = inspect.getdoc(description) or discord.Embed.Empty
nested_pages.extend(
(cog, description, plausible[i : i + per_page])
for i in range(0, len(plausible), per_page)
)
self = cls(ctx, nested_pages, per_page=1) # this forces the pagination session
self.prefix = cleanup_prefix(ctx.bot, ctx.prefix)
# swap the get_page implementation with
# one that supports our style of pagination
self.get_page = self.get_bot_page
self._is_bot = True
# replace the actual total
self.total = sum(len(o) for _, _, o in nested_pages)
return self
# noinspection PyAttributeOutsideInit
def get_bot_page(self, page):
cog, description, commands = self.entries[page - 1]
self.title = f"{cog} Commands"
self.description = description
return commands
async def show_page(self, page, *, first=False):
# noinspection PyAttributeOutsideInit
self.current_page = page
entries = self.get_page(page)
self.embed.clear_fields()
self.embed.description = self.description
self.embed.title = self.title
# noinspection PyUnresolvedReferences
self.embed.set_footer(
text=f'Use "{self.prefix}help command" for more info on a command.'
)
signature = _command_signature
for entry in entries:
self.embed.add_field(
name=signature(entry),
value=entry.short_doc or "No help given",
inline=False,
)
if self.maximum_pages:
self.embed.set_author(
name=f"Page {page}/{self.maximum_pages} ({self.total} commands)"
)
if not self.paginating:
return await self.channel.send(embed=self.embed)
if not first:
await self.message.edit(embed=self.embed)
return
self.message = await self.channel.send(embed=self.embed)
for (reaction, _) in self.reaction_emojis:
if self.maximum_pages == 2 and reaction in ("\u23ed", "\u23ee"):
# no |<< or >>| buttons if we only have two pages
# we can't forbid it if someone ends up using it but remove
# it from the default set
continue
await self.message.add_reaction(reaction)
async def show_help(self):
"""shows this message"""
self.embed.title = "Paginator help"
self.embed.description = "Hello! Welcome to the help page."
messages = [f"{emoji} {func.__doc__}" for emoji, func in self.reaction_emojis]
self.embed.clear_fields()
self.embed.add_field(
name="What are these reactions for?",
value="\n".join(messages),
inline=False,
)
self.embed.set_footer(
text=f"We were on page {self.current_page} before this message."
)
await self.message.edit(embed=self.embed)
async def go_back_to_current_page():
await asyncio.sleep(30.0)
await self.show_current_page()
self.bot.loop.create_task(go_back_to_current_page())
async def show_bot_help(self):
"""shows how to use the bot"""
self.embed.title = "Using the bot"
self.embed.description = "Hello! Welcome to the help page."
self.embed.clear_fields()
entries = (
("<argument>", "This means the argument is __**required**__."),
("[argument]", "This means the argument is __**optional**__."),
("[A|B]", "This means the it can be __**either A or B**__."),
(
"[argument...]",
"This means you can have multiple arguments.\n"
"Now that you know the basics, it should be "
"noted that...\n"
"__**You do not type in the brackets!**__",
),
)
self.embed.add_field(
name="How do I use this bot?",
value="Reading the bot signature is pretty simple.",
)
for name, value in entries:
self.embed.add_field(name=name, value=value, inline=False)
self.embed.set_footer(
text=f"We were on page {self.current_page} before this message."
)
await self.message.edit(embed=self.embed)
async def go_back_to_current_page():
await asyncio.sleep(30.0)
await self.show_current_page()
self.bot.loop.create_task(go_back_to_current_page())