Author SHA1 Message Date
matthew 73eb59f295 forgot something 2023-04-11 23:04:35 +02:00
matthew 0758690543 revert the autojoin stuff 2023-04-11 23:03:50 +02:00
matthew 50c4ce8bb7 add support for multiple nodes 2023-04-11 22:33:48 +02:00
matthew f81c89119c Gotta remove shit to be compliant yaknow 2022-12-07 00:13:52 +01:00
matthew d2658578b5 Merge pull request #48 from strNophix/improved-track-embed
Rework of player embed
2022-11-14 18:56:24 +01:00
matthew d831251f13 Merge pull request #47 from strNophix/customize-status-loop
Bot status is now more customizable
2022-11-14 18:56:17 +01:00
niku 80e699cb25 Updated config.json.sample 2022-11-13 19:01:37 +01:00
niku e0472bb545 Merge remote-tracking branch 'upstream/dev-2' into improved-track-embed 2022-11-13 18:57:50 +01:00
niku 62cb4d9f94 Some ordering magic 2022-11-13 18:55:42 +01:00
niku 7fd7e5ffa7 Added history support and changed track fmting 2022-11-13 18:08:44 +01:00
niku 765b23e8fe Removed controls 2022-11-13 16:07:01 +01:00
niku 74f2cf6017 Bot status is now more customizable 2022-11-13 15:57:44 +01:00
niku ffb7dc999f New embed with queue and image 2022-11-13 15:20:47 +01:00
niku 1b837c16a3 Removed unused utils 2022-11-13 14:00:54 +01:00
matthew b92300142a Dit moet later op een nettere manier gebeuren 2022-11-13 13:58:54 +01:00
matthew 19a44ea486 Merge pull request #45 from strNophix/migration-to-dpy
Migration (back) to dpy
2022-11-12 23:46:27 +01:00
niku 34cd05d421 Ran pre-commit 2022-11-12 22:44:30 +01:00
niku 89c2e12489 Migrated settings cog 2022-11-12 22:43:03 +01:00
niku c80d17ca6f Ran pre-commit 2022-11-12 20:38:52 +01:00
niku 42bd63f962 Removed help command 2022-11-12 20:38:35 +01:00
niku 20de0e8460 Converted information cog 2022-11-12 20:37:54 +01:00
niku f1ff10dc68 Updated pre-commit python version to 3.10 2022-11-12 20:37:44 +01:00
niku de31c6b273 Removed cog commands from music cog 2022-11-12 19:59:59 +01:00
niku 833bb61660 Removing owner cog from sample config 2022-11-12 19:53:39 +01:00
niku 416207aa9f Replaced owner cog with jishaku 2022-11-12 19:42:02 +01:00
niku 9f62a48bbd Partial migration to dpy, music cog 2022-11-12 19:38:22 +01:00
niku a4986a04fc Removed unused field from config.json 2022-11-12 19:37:56 +01:00
niku 2fb6d353f0 Added docker-compose for development 2022-11-12 19:37:37 +01:00
niku b702297b40 Merge pull request #41 from Matthww/dev
Release v1.1.0
2021-11-22 21:15:14 +01:00
matthew 76bfd719c3 Merge pull request #40 from strNophix/source-manager-setting
Source manager decorator
2021-11-22 21:10:12 +01:00
niku c45bd8ced3 Added decorator for checking if eligable for managing sources 2021-11-22 18:28:48 +01:00
niku b37c3f533a Merge pull request #39 from strNophix/remove-duplicate-commands
Removed duplicate commands
2021-11-22 11:15:55 +01:00
niku 9c55bcdc36 Removed duplicate commands 2021-11-22 11:11:17 +01:00
niku 1495d214a1 Merge pull request #37 from strNophix/database-rewrite
database rewrite + autojoin
2021-11-22 10:53:23 +01:00
niku 4f65522bd8 Merge branch 'dev' into database-rewrite 2021-11-22 10:53:00 +01:00
niku 3e6ee9ecc9 Merge pull request #38 from strNophix/decoupling-lavalink
decoupled lavalink creation from cogs.music
2021-11-21 20:54:52 +01:00
niku 8504d6abfd decoupled lavalink creation from cogs.music 2021-11-20 00:37:17 +01:00
niku d2ecbbf6ae prevent circular import in tunebot.redis.__init__.py 2021-11-19 21:58:47 +01:00
niku adb3fe11a6 Merge remote-tracking branch 'upstream/dev' into database-rewrite 2021-11-19 21:39:10 +01:00
niku ebb07960bf database rewrite 2021-11-19 21:10:29 +01:00
38 changed files with 1353 additions and 2288 deletions
+61
View File
@@ -0,0 +1,61 @@
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.9
python: python3.10
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
+127 -89
View File
@@ -4,6 +4,8 @@ from typing import Any
from typing import Dict
from typing import List
from typing import Sequence
from typing import TYPE_CHECKING
import itertools
import aioredis
import discord
@@ -21,94 +23,22 @@ from discord.ext.commands.errors import ExtensionNotFound
from discord.ext.commands.errors import NoEntryPointError
from context import CustomContext
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
class TuneBot(commands.Bot):
lavalink: lavalink.Client
invite_link: str
def __init__(self, config: Dict[Any, Any]):
intents = discord.Intents(
voice_states=True, guild_messages=True, guilds=True, messages=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.colors: Dict[str, Color] = self.process_colours(config.get("colors", []))
self._redis_client: Redis = aioredis.from_url(
self.config["redis_url"], encoding="utf-8", decode_responses=True
)
self.invite_link: str = ""
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,
owner_ids=self.config["owner_ids"],
description=self.config["info"]["description"],
case_insensitive=False,
fetch_offline_members=False,
intents=intents,
slash_commands=True,
slash_command_guilds=slash_guilds,
)
self.loop.create_task(self.async_init())
async def async_init(self):
await self.load_cogs(self.initial_cog_names)
async def prefix_callable(self, _, msg: Message) -> List[str]:
return commands.when_mentioned_or(*self.config["prefixes"])(self, msg)
async def load_cogs(self, cog_names: Sequence[str]):
for cog in cog_names:
try:
self.load_extension(cog)
print(f"Succesfully loaded extension {cog}.")
except (
ExtensionNotFound,
ExtensionAlreadyLoaded,
NoEntryPointError,
ExtensionFailed,
) as e:
print(f"Failed to load 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"
print(f"Logged in as: {self.user}")
print(f"Version: {discord.__version__}")
print(f"Invite: {self.invite_link}")
def process_colours(self, colors: Dict[str, str]) -> Dict[str, Color]:
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)
@tasks.loop(seconds=30)
async def update_status(self):
await self.wait_until_ready()
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)
if TYPE_CHECKING:
from tunebot import AutoJoin
from tunebot import PlaylistSource
from tunebot import GlobalPlaylist
from tunebot import GlobalPlaylistSource
from tunebot import GlobalAutoJoin
from tunebot.context import ContextLike
config_path = "config.json"
@@ -117,15 +47,123 @@ if len(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"]
class TuneBot(commands.Bot):
lavalink: "lavalink.Client"
invite_link: str
status_cycle = itertools.cycle(status_messages)
def __init__(self, config: Dict[Any, Any]):
intents = discord.Intents(
voice_states=True, guild_messages=True, guilds=True, messages=True
)
self.config = config
self.initial_cog_names: List[str] = self.config.get("cogs", [])
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_playlist: GlobalPlaylist = GlobalRedisPlaylist(
self._redis_client,
self.redis_prefix,
)
self.global_playlist_source: GlobalPlaylistSource = GlobalRedisPlaylistSource(
self._redis_client,
self.redis_prefix,
)
self.invite_link: str = ""
self.colors = colors
super().__init__(
command_prefix=self.prefix_callable,
owner_ids=self.config["owner_ids"],
description=self.config["info"]["description"],
case_insensitive=False,
fetch_offline_members=False,
intents=intents,
)
async def setup_hook(self) -> None:
await self.load_cogs(self.initial_cog_names)
self.update_status.start()
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 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}.")
except (
ExtensionNotFound,
ExtensionAlreadyLoaded,
NoEntryPointError,
ExtensionFailed,
) as e:
logger.info(f"Failed to load extension {cog}.\n\t{e}")
await self.tree.sync()
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}")
self.lavalink = self.create_lavalink(self.user.id)
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
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 playlist_source_context(self, ctx: "ContextLike") -> "PlaylistSource":
return RedisPlaylistSource(ctx)
@tasks.loop(seconds=status_interval)
async def update_status(self):
await self.wait_until_ready()
name = next(self.status_cycle)
activity = discord.Activity(name=name, type=ActivityType.listening)
await self.change_presence(activity=activity)
client = TuneBot(config)
if __name__ == "__main__":
try:
import uvloop
uvloop.install()
print("Succesfully initialized uvloop")
logger.info("Succesfully initialized uvloop")
except ModuleNotFoundError:
pass
token = config.pop("token")
TuneBot(config).run(token, reconnect=True)
client.run(token, reconnect=True)
-128
View File
@@ -1,128 +0,0 @@
import datetime
import time
from typing import Optional
import discord
import humanize
import lavalink
from discord.ext import commands
from discord.ext import tasks
from discord.ext.commands import Context
from bot import TuneBot
from context import CustomContext
from utils.classes import BaseCog
from utils.database import AutoJoin
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)
AutoJoin.get_channels()
@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
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,64 @@
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)
-307
View File
@@ -1,307 +0,0 @@
import asyncio
import datetime
import re
from typing import Optional
import discord
import lavalink
from discord import Embed
from discord.channel import TextChannel
from discord.ext import commands
from discord.ext.commands.context import Context
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 utils.classes import BaseCog
from utils.database import AutoJoin
from utils.database import Playlist
from utils.EmbedGenerator import EmbedGenerator
from utils.exceptions import EmbeddedCommandException
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):
if not hasattr(
self.bot, "lavalink"
): # This ensures the client isn't overwritten during cog reloads.
self.bot.lavalink = lavalink.Client(self.bot.user.id)
ll = self.bot.config["lavalink"]
self.bot.lavalink.add_node(
ll["host"], ll["port"], ll["password"], ll["region"], ll["name"]
)
self.bot.lavalink.add_event_hook(self.track_hook)
await self.async_init()
async def async_init(self):
redis_result = await AutoJoin.get_channels(self.bot._redis_client)
while len(self.bot.lavalink.node_manager.available_nodes) == 0:
await asyncio.sleep(1)
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 Playlist.random(self.bot._redis_client, buffer)
# Get the results for the query from Lavalink.
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], self.bot.user.id, recommended=False
)
player.add(requester=self.bot.user.id, track=track)
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 hasattr(self.bot, "lavalink"):
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):
channel_id = int(event.player.fetch("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)
@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
@@ -0,0 +1,71 @@
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
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,115 @@
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
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,64 @@
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
@@ -1,213 +0,0 @@
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))
-159
View File
@@ -1,159 +0,0 @@
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.database import AutoJoin
from utils.database import Playlist
from utils.database import PlaylistSource
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 set`\n`{ctx.prefix}autojoin unset`",
)
@autojoin.command(name="enable")
@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"""
voicechannel_id = ctx.author.voice.channel.id
textchannel_id = ctx.message.channel.id
await AutoJoin.update_channel(
ctx.redis, ctx.guild.id, voicechannel_id, textchannel_id
)
await EmbedGenerator.Message(ctx, "Autojoin", "`enabled`")
@autojoin.command(name="disable")
@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 AutoJoin.del_channel(ctx.redis, ctx.guild.id)
await EmbedGenerator.Message(ctx, "Autojoin", "`disabled`")
@commands.is_owner()
@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)
@commands.is_owner()
@source.command(name="remove")
async def source_remove(self, ctx: CustomContext, source_url: str):
"""Removes a source from the bot"""
if await PlaylistSource.remove(ctx.redis, 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)
@commands.is_owner()
@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 PlaylistSource.add(ctx.redis, source_url)
track_urls = [str(track["info"]["uri"]) for track in query_result["tracks"]]
await Playlist.add_bulk(ctx.redis, 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)
@commands.is_owner()
@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 PlaylistSource.get_all(ctx.redis)
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)
@commands.is_owner()
@source.command(name="sync")
async def source_sync(self, ctx: CustomContext):
"""Forcefully resyncs all sources"""
failed_sources: list[str] = []
await Playlist.clear(ctx.redis)
sources = await PlaylistSource.get_all(ctx.redis)
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 Playlist.add_bulk(ctx.redis, 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
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,145 @@
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)
+24 -6
View File
@@ -1,25 +1,43 @@
{
"token": "",
"owner_ids": [194545408960102400, 190875175460405249],
"prefixes": ["ck!"],
"owner_ids": [
194545408960102400,
190875175460405250
],
"manager_ids": [],
"prefix": "ck!",
"redis_url": "",
"redis_prefix": "",
"lavalink": {
"nodes": {
"example": {
"host": "",
"port": 2333,
"password": "",
"region": "",
"name": ""
}
},
"colors": {
"embed": "7289da"
},
"info": {
"name": "CloudKid Radio",
"description": "A sample bot description"
"description": "A sample bot description",
"status": {
"messages": [
"bangers",
"noises 🤖"
],
"interval": 12
}
},
"cogs": ["cogs.owner", "cogs.settings", "cogs.information", "cogs.music"],
"slash_command_guilds": [],
"cogs": [
"jishaku",
"cogs.settings",
"cogs.information",
"cogs.music"
],
"queue_buffer_size": 5,
"history_size": 2,
"slash_descriptions": {}
}
+24 -3
View File
@@ -6,22 +6,29 @@ 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
if TYPE_CHECKING:
from bot import TuneBot
from tunebot import PlaylistSource
from tunebot import AutoJoin
from utils.classes import BaseCog
class CustomContext(commands.Context):
bot: "TuneBot"
cog: "BaseCog"
@property
def redis(self) -> Redis:
return self.bot._redis_client
@property
def player(self) -> DefaultPlayer:
if hasattr(self.bot, "lavalink"):
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
color = bot.colors["embed"]
@@ -33,3 +40,17 @@ class CustomContext(commands.Context):
embed = Embed(color=color)
embed.set_footer(text=f"Requested by: {self.author}", icon_url=avatar)
return embed
@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)
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)
return self._autojoin
+13
View File
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,21 @@
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)
Generated
+173 -415
View File
@@ -1,17 +1,18 @@
[[package]]
name = "aiohttp"
version = "3.6.3"
version = "3.7.4.post0"
description = "Async http client/server framework (asyncio)"
category = "main"
optional = false
python-versions = ">=3.5.3"
python-versions = ">=3.6"
[package.dependencies]
async-timeout = ">=3.0,<4.0"
attrs = ">=17.3.0"
chardet = ">=2.0,<4.0"
multidict = ">=4.5,<5.0"
yarl = ">=1.0,<1.6.0"
chardet = ">=2.0,<5.0"
multidict = ">=4.5,<7.0"
typing-extensions = ">=3.6.5"
yarl = ">=1.0,<2.0"
[package.extras]
speedups = ["aiodns", "brotlipy", "cchardet"]
@@ -43,6 +44,17 @@ 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"
@@ -104,6 +116,14 @@ 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"
@@ -114,7 +134,7 @@ python-versions = "*"
[[package]]
name = "cffi"
version = "1.15.0"
version = "1.15.1"
description = "Foreign Function Interface for Python calling C code."
category = "main"
optional = false
@@ -154,7 +174,7 @@ unicode_backport = ["unicodedata2"]
name = "click"
version = "8.0.3"
description = "Composable command line interface toolkit"
category = "dev"
category = "main"
optional = false
python-versions = ">=3.6"
@@ -165,34 +185,27 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
name = "colorama"
version = "0.4.4"
description = "Cross-platform colored terminal text."
category = "dev"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "discord.py"
version = "2.0.0a3662+gd2adc6c0"
version = "2.0.1"
description = "A Python wrapper for the Discord API"
category = "main"
optional = false
python-versions = ">=3.8.0"
develop = false
[package.dependencies]
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\""}
aiohttp = ">=3.7.4,<4"
PyNaCl = {version = ">=1.3.0,<1.6", optional = true, markers = "extra == \"voice\""}
[package.extras]
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"
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)"]
[[package]]
name = "distlib"
@@ -245,19 +258,87 @@ optional = false
python-versions = ">=3.5"
[[package]]
name = "lavalink"
version = "3.1.4"
description = "A lavalink interface built for discord.py"
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]
aiohttp = ">=3.6.0,<3.7.0"
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."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
aiohttp = ">=3.7.4,<3.9.0"
[package.extras]
development = ["pylint", "flake8"]
docs = ["sphinx", "pygments", "guzzle-sphinx-theme"]
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)"]
[[package]]
name = "multidict"
@@ -291,14 +372,6 @@ 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"
@@ -353,15 +426,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pynacl"
version = "1.4.0"
version = "1.5.0"
description = "Python binding to the Networking and Cryptography (NaCl) library"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
python-versions = ">=3.6"
[package.dependencies]
cffi = ">=1.4.1"
six = "*"
[package.extras]
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
@@ -478,14 +550,6 @@ 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"
@@ -505,6 +569,14 @@ 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"
@@ -530,119 +602,41 @@ 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 = "87b61ad577bb9413177b2749b0c4b30026aa512bf3a6eb62e74c0ef7e2470ed4"
content-hash = "7518d3e2ccfffad1b05bd93f5178cb700c0bbe71e177707b56f60bb2dc46c70d"
[metadata.files]
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"},
]
aiohttp = []
aioredis = []
aiotube = []
astunparse = []
async-timeout = []
attrs = []
"backports.entry-points-selectable" = []
black = []
braceexpand = []
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 = [
{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"},
]
cffi = []
cfgv = []
chardet = []
charset-normalizer = []
click = [
{file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"},
{file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"},
@@ -652,157 +646,39 @@ colorama = [
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
]
"discord.py" = []
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"},
]
distlib = []
filelock = []
humanize = []
identify = []
idna = [
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
]
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"},
]
import-expression = []
importlib-metadata = []
jishaku = []
lavalink = []
line-profiler = []
multidict = []
mutagen = []
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 = [
{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"},
]
nodeenv = []
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 = [
{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"},
]
platformdirs = []
pre-commit = []
pycparser = [
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
]
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"},
]
pycryptodomex = []
pynacl = []
pytube = []
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"},
@@ -838,86 +714,17 @@ pyyaml = [
{file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
]
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"},
]
redis = []
regex = []
requests = []
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 = [
{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"},
]
toml = []
tomli = []
typing-extensions = []
urllib3 = []
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"},
@@ -936,57 +743,8 @@ 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"},
]
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"},
]
virtualenv = []
websockets = []
yarl = []
yt-dlp = []
zipp = []
+3 -2
View File
@@ -7,11 +7,12 @@ license = "GPL-v3.0"
[tool.poetry.dependencies]
python = "^3.8"
lavalink = "^3.1.4"
lavalink = "4.0.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}
+1
View File
@@ -0,0 +1 @@
from tunebot.abc import *
+58
View File
@@ -0,0 +1,58 @@
from abc import ABC
from abc import abstractmethod
from typing import Optional
class GlobalPlaylistSource(ABC):
@abstractmethod
async def fetch_sources(self) -> set[str]:
pass
class PlaylistSource(ABC):
@abstractmethod
async def add(self, source_url: str):
pass
@abstractmethod
async def remove(self, source_url: str) -> bool:
pass
class GlobalPlaylist(ABC):
@abstractmethod
async def pick_random(self, amount: Optional[int] = 1) -> set[str]:
pass
@abstractmethod
async def add_tracks(self, urls: list[str]):
pass
@abstractmethod
async def clear(self):
pass
class GlobalAutoJoin(ABC):
@abstractmethod
async def fetch_channels(self) -> dict[str, list[str]]:
pass
class AutoJoin(ABC):
@abstractmethod
async def update(self, voice_channel_id: int, text_channel_id: int):
pass
@abstractmethod
async def disable(self):
pass
__all__ = (
"GlobalPlaylistSource",
"PlaylistSource",
"GlobalPlaylist",
"GlobalAutoJoin",
"AutoJoin",
)
+28
View File
@@ -0,0 +1,28 @@
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",)
+4
View File
@@ -0,0 +1,4 @@
from tunebot.redis.entity import * # noreorder
from tunebot.redis.autojoin import *
from tunebot.redis.playlist import *
from tunebot.redis.playlist_source import *
+41
View File
@@ -0,0 +1,41 @@
from tunebot import AutoJoin
from tunebot import GlobalAutoJoin
from tunebot.redis import RedisBotEntity
from tunebot.redis import RedisContextEntity
class GlobalRedisAutoJoin(RedisBotEntity, GlobalAutoJoin):
async def fetch_channels(self) -> dict[str, list[str]]:
"""
Retrieves all guilds (with their configurations) where AutoJoin is enabled
"""
channels: dict[str, str] = await self.redis.hgetall(self.key("autojoin"))
return {key: value.split(":") for key, value in channels.items()}
class RedisAutoJoin(RedisContextEntity, AutoJoin):
async def update(self, voice_channel_id: int, text_channel_id: int):
"""
Upserts the configuration of an AutoJoin guild.
Args:
voice_channel_id (int): [description]
text_channel_id (int): [description]
"""
if not self.ctx.guild_id:
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)
async def disable(self):
"""
Removes the AutoJoin configuration of a guild.
"""
if not self.ctx.guild_id:
raise Exception("This method can only be invoked inside of a guild.")
await self.redis.hdel(self.key("autojoin"), self.ctx.guild_id)
__all__ = ("GlobalRedisAutoJoin", "RedisAutoJoin")
+49
View File
@@ -0,0 +1,49 @@
from abc import ABC
from abc import abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from tunebot.context import ContextLike
from aioredis.client import Redis
class RedisEntity(ABC):
@property
@abstractmethod
def redis() -> Redis:
pass
@abstractmethod
def key(self, name: str) -> str:
pass
class RedisBotEntity(RedisEntity):
def __init__(self, redis: Redis, prefix: str) -> None:
self._redis = redis
self.prefix = prefix
super().__init__()
@property
def redis(self) -> Redis:
return self._redis
def key(self, name: str) -> str:
return ":".join([self.prefix, name])
class RedisContextEntity(RedisEntity):
def __init__(self, ctx: "ContextLike") -> None:
self.ctx = ctx
super().__init__()
@property
def redis(self) -> Redis:
return self.ctx.redis
def key(self, name: str) -> str:
return ":".join([self.ctx.bot.redis_prefix, name])
__all__ = ("RedisEntity", "RedisBotEntity", "RedisContextEntity")
+33
View File
@@ -0,0 +1,33 @@
from typing import Optional
from tunebot.abc import GlobalPlaylist
from tunebot.redis import RedisBotEntity
class GlobalRedisPlaylist(RedisBotEntity, GlobalPlaylist):
async def pick_random(self, amount: Optional[int] = 1) -> set[str]:
"""
Picks an amount of random tracks from the playlist in Redis
Args:
amount (Optional[int], optional): [description]. Defaults to 1.
"""
return await self.redis.srandmember(self.key("playlist"), amount)
async def add_tracks(self, urls: list[str]):
"""
Adds one or more urls to the playlist in Redis
Args:
urls (list[str]): [description]
"""
await self.redis.sadd(self.key("playlist"), *urls)
async def clear(self):
"""
Clears the entire playlist in Redis
"""
await self.redis.delete(self.key("playlist"))
__all__ = ("GlobalRedisPlaylist",)
+35
View File
@@ -0,0 +1,35 @@
from tunebot import GlobalPlaylistSource
from tunebot import PlaylistSource
from tunebot.redis import RedisBotEntity
from tunebot.redis import RedisContextEntity
class GlobalRedisPlaylistSource(RedisBotEntity, GlobalPlaylistSource):
async def fetch_sources(self) -> set[str]:
"""
Fetches all sources
"""
return await self.redis.smembers(self.key("sources"))
class RedisPlaylistSource(RedisContextEntity, PlaylistSource):
async def add(self, source_url: str):
"""
Adds a playlist source to Redis
Args:
source_url (str): [description]
"""
await self.redis.sadd(self.key("sources"), source_url)
async def remove(self, source_url: str) -> bool:
"""
Removes a playlist source from Redis
Args:
source_url (str): [description]
"""
return await self.redis.srem(self.key("sources"), source_url)
__all__ = ("GlobalRedisPlaylistSource", "RedisPlaylistSource")
-47
View File
@@ -1,47 +0,0 @@
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
@@ -0,0 +1,10 @@
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
+7
View File
@@ -1,3 +1,4 @@
import asyncio
from typing import Dict
from discord.ext.commands import Cog
@@ -13,3 +14,9 @@ class BaseCog(Cog):
for command in self.walk_commands():
if brief := slash_descriptions.get(command.qualified_name):
command.brief = brief
def is_lavalink_ready(self) -> bool:
return (
hasattr(self.bot, "lavalink")
and len(self.bot.lavalink.node_manager.available_nodes) > 0
)
-57
View File
@@ -1,57 +0,0 @@
from typing import Dict
from typing import List
from typing import Optional
from aioredis import Redis
from bot import redis_prefix
class AutoJoin:
@staticmethod
async def get_channels(redis: Redis) -> Dict[str, str]:
# return all channels
channels = await redis.hgetall(f"{redis_prefix}:autojoin")
return {key: value.split("-") for key, value in channels.items()}
@staticmethod
async def update_channel(
redis: Redis, guild_id: int, voice_channel_id: int, text_channel_id: int
):
await redis.hset(
f"{redis_prefix}:autojoin",
guild_id,
f"{voice_channel_id}-{text_channel_id}",
)
@staticmethod
async def del_channel(redis: Redis, guild_id: int):
await redis.hdel(f"{redis_prefix}:autojoin", guild_id)
class Playlist:
@staticmethod
async def random(redis: Redis, amount: Optional[int] = 1) -> List[str]:
return await redis.srandmember(f"{redis_prefix}:playlist", amount)
@staticmethod
async def add_bulk(redis: Redis, urls: list[str]):
await redis.sadd(f"{redis_prefix}:playlist", *urls)
@staticmethod
async def clear(redis: Redis):
await redis.delete(f"{redis_prefix}:playlist")
class PlaylistSource:
@staticmethod
async def get_all(redis: Redis) -> list[str]:
return await redis.smembers(f"{redis_prefix}:sources")
@staticmethod
async def add(redis: Redis, source_url: str):
await redis.sadd(f"{redis_prefix}:sources", source_url)
@staticmethod
async def remove(redis: Redis, source_url: str) -> bool:
return await redis.srem(f"{redis_prefix}:sources", source_url)
+13
View File
@@ -0,0 +1,13 @@
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
@@ -1,12 +0,0 @@
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
@@ -0,0 +1,5 @@
import logging
logger = logging.getLogger("discord")
__all__ = ("logger",)
-843
View File
@@ -1,843 +0,0 @@
# 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())