This commit is contained in:
Mylloon 2021-08-10 10:25:58 +02:00
parent 7d040a594c
commit 7d25374fd9

View file

@ -16,62 +16,737 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
import asyncio
import async_timeout
import copy
import datetime
import discord
from wavelink import Client as wClient
from discord.ext import commands
import math
import random
import re
import typing
import wavelink
from discord.ext import commands, menus
from utils.core import load
def setup(client):
client.add_cog(Music(client))
class Music(commands.Cog):
"""Commandes relatives à la musique."""
def __init__(self, client):
self.client = client
# URL matching REGEX...
URL_REG = re.compile(r'https?://(?:www\.)?.+')
class NoChannelProvided(commands.CommandError):
"""Error raised when no suitable voice channel was supplied."""
pass
class IncorrectChannelError(commands.CommandError):
"""Error raised when commands are issued outside of the players session channel."""
pass
class Track(wavelink.Track):
"""Wavelink Track object with a requester attribute."""
__slots__ = ('requester', )
def __init__(self, *args, **kwargs):
super().__init__(*args)
self.requester = kwargs.get('requester')
class Player(wavelink.Player):
"""Custom wavelink Player class."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.context: commands.Context = kwargs.get('context', None)
if self.context:
self.dj: discord.Member = self.context.author
self.queue = asyncio.Queue()
self.controller = None
self.waiting = False
self.updating = False
self.pause_votes = set()
self.resume_votes = set()
self.skip_votes = set()
self.shuffle_votes = set()
self.stop_votes = set()
async def do_next(self) -> None:
if self.is_playing or self.waiting:
return
# Clear the votes for a new song...
self.pause_votes.clear()
self.resume_votes.clear()
self.skip_votes.clear()
self.shuffle_votes.clear()
self.stop_votes.clear()
try:
self.waiting = True
with async_timeout.timeout(300):
track = await self.queue.get()
except asyncio.TimeoutError:
# No music has been played for 5 minutes, cleanup and disconnect...
return await self.teardown()
await self.play(track)
self.waiting = False
# Invoke our players controller...
await self.invoke_controller()
async def invoke_controller(self) -> None:
"""Method which updates or sends a new player controller."""
if self.updating:
return
self.updating = True
if not self.controller:
self.controller = InteractiveController(embed=self.build_embed(), player=self)
await self.controller.start(self.context)
elif not await self.is_position_fresh():
try:
await self.controller.message.delete()
except discord.HTTPException:
pass
self.controller.stop()
self.controller = InteractiveController(embed=self.build_embed(), player=self)
await self.controller.start(self.context)
else:
embed = self.build_embed()
await self.controller.message.edit(content=None, embed=embed)
self.updating = False
def build_embed(self) -> typing.Optional[discord.Embed]:
"""Method which builds our players controller embed."""
track = self.current
if not track:
return
channel = self.bot.get_channel(int(self.channel_id))
qsize = self.queue.qsize()
embed = discord.Embed(title=f'Music Controller | {channel.name}', colour=0xebb145)
embed.description = f'Now Playing:\n**`{track.title}`**\n\n'
embed.set_thumbnail(url=track.thumb)
embed.add_field(name='Duration', value=str(datetime.timedelta(milliseconds=int(track.length))))
embed.add_field(name='Queue Length', value=str(qsize))
embed.add_field(name='Volume', value=f'**`{self.volume}%`**')
embed.add_field(name='Requested By', value=track.requester.mention)
embed.add_field(name='DJ', value=self.dj.mention)
embed.add_field(name='Video URL', value=f'[Click Here!]({track.uri})')
return embed
async def is_position_fresh(self) -> bool:
"""Method which checks whether the player controller should be remade or updated."""
try:
async for message in self.context.channel.history(limit=5):
if message.id == self.controller.message.id:
return True
except (discord.HTTPException, AttributeError):
return False
return False
async def teardown(self):
"""Clear internal states, remove player controller and disconnect."""
try:
await self.controller.message.delete()
except discord.HTTPException:
pass
self.controller.stop()
try:
await self.destroy()
except KeyError:
pass
class InteractiveController(menus.Menu):
"""The Players interactive controller menu class."""
def __init__(self, *, embed: discord.Embed, player: Player):
super().__init__(timeout=None)
self.embed = embed
self.player = player
def update_context(self, payload: discord.RawReactionActionEvent):
"""Update our context with the user who reacted."""
ctx = copy.copy(self.ctx)
ctx.author = payload.member
return ctx
def reaction_check(self, payload: discord.RawReactionActionEvent):
if payload.event_type == 'REACTION_REMOVE':
return False
if not payload.member:
return False
if payload.member.bot:
return False
if payload.message_id != self.message.id:
return False
if payload.member not in self.bot.get_channel(int(self.player.channel_id)).members:
return False
return payload.emoji in self.buttons
async def send_initial_message(self, ctx: commands.Context, channel: discord.TextChannel) -> discord.Message:
return await channel.send(embed=self.embed)
@menus.button(emoji='\u25B6')
async def resume_command(self, payload: discord.RawReactionActionEvent):
"""Resume button."""
ctx = self.update_context(payload)
command = self.bot.get_command('resume')
ctx.command = command
await self.bot.invoke(ctx)
@menus.button(emoji='\u23F8')
async def pause_command(self, payload: discord.RawReactionActionEvent):
"""Pause button"""
ctx = self.update_context(payload)
command = self.bot.get_command('pause')
ctx.command = command
await self.bot.invoke(ctx)
@menus.button(emoji='\u23F9')
async def stop_command(self, payload: discord.RawReactionActionEvent):
"""Stop button."""
ctx = self.update_context(payload)
command = self.bot.get_command('stop')
ctx.command = command
await self.bot.invoke(ctx)
@menus.button(emoji='\u23ED')
async def skip_command(self, payload: discord.RawReactionActionEvent):
"""Skip button."""
ctx = self.update_context(payload)
command = self.bot.get_command('skip')
ctx.command = command
await self.bot.invoke(ctx)
@menus.button(emoji='\U0001F500')
async def shuffle_command(self, payload: discord.RawReactionActionEvent):
"""Shuffle button."""
ctx = self.update_context(payload)
command = self.bot.get_command('shuffle')
ctx.command = command
await self.bot.invoke(ctx)
@menus.button(emoji='\u2795')
async def volup_command(self, payload: discord.RawReactionActionEvent):
"""Volume up button"""
ctx = self.update_context(payload)
command = self.bot.get_command('vol_up')
ctx.command = command
await self.bot.invoke(ctx)
@menus.button(emoji='\u2796')
async def voldown_command(self, payload: discord.RawReactionActionEvent):
"""Volume down button."""
ctx = self.update_context(payload)
command = self.bot.get_command('vol_down')
ctx.command = command
await self.bot.invoke(ctx)
@menus.button(emoji='\U0001F1F6')
async def queue_command(self, payload: discord.RawReactionActionEvent):
"""Player queue button."""
ctx = self.update_context(payload)
command = self.bot.get_command('queue')
ctx.command = command
await self.bot.invoke(ctx)
class PaginatorSource(menus.ListPageSource):
"""Player queue paginator class."""
def __init__(self, entries, *, per_page=8):
super().__init__(entries, per_page=per_page)
async def format_page(self, menu: menus.Menu, page):
embed = discord.Embed(title='Coming Up...', colour=0x4f0321)
embed.description = '\n'.join(f'`{index}. {title}`' for index, title in enumerate(page, 1))
return embed
def is_paginating(self):
# We always want to embed even on 1 page of results...
return True
class Music(commands.Cog, wavelink.WavelinkMixin):
"""Music Cog."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.keys = load(["REGION_DISCORD", "DOCKER_KASSOUBOT"])
if not hasattr(client, 'wavelink'):
self.client.wavelink = wClient(bot = self.client)
if not hasattr(bot, 'wavelink'):
self.bot.wavelink = wavelink.Client(bot = bot)
if self.keys["DOCKER_KASSOUBOT"] == True:
self.url = "lavalink" # name of the service, working fine with the DNS in docker
else:
self.url = "localhost" # localhost
self.client.loop.create_task(self.start_nodes())
self.bot.loop.create_task(self.start_nodes())
async def start_nodes(self):
await self.client.wait_until_ready()
async def start_nodes(self) -> None:
"""Connect and intiate nodes."""
await self.bot.wait_until_ready()
await self.client.wavelink.initiate_node(host = f"{self.url}",
port = 2333,
rest_uri = f"http://{self.url}:2333",
password = "youshallnotpass",
identifier = "PROD",
region = self.keys["REGION_DISCORD"])
if self.bot.wavelink.nodes:
previous = self.bot.wavelink.nodes.copy()
@commands.command(name='connect')
async def connect_(self, ctx, *, channel: discord.VoiceChannel = None):
for node in previous.values():
await node.destroy()
nodes = {'MAIN': {'host': f'{self.url}',
'port': 2333,
'rest_uri': f'http://{self.url}:2333',
'password': 'youshallnotpass',
'identifier': 'MAIN',
'region': self.keys["REGION_DISCORD"]
}}
for n in nodes.values():
await self.bot.wavelink.initiate_node(**n)
@wavelink.WavelinkMixin.listener()
async def on_node_ready(self, node: wavelink.Node):
print(f'Node {node.identifier} is ready!')
@wavelink.WavelinkMixin.listener('on_track_stuck')
@wavelink.WavelinkMixin.listener('on_track_end')
@wavelink.WavelinkMixin.listener('on_track_exception')
async def on_player_stop(self, node: wavelink.Node, payload):
await payload.player.do_next()
@commands.Cog.listener()
async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
if member.bot:
return
player: Player = self.bot.wavelink.get_player(member.guild.id, cls=Player)
if not player.channel_id or not player.context:
player.node.players.pop(member.guild.id)
return
channel = self.bot.get_channel(int(player.channel_id))
if member == player.dj and after.channel is None:
for m in channel.members:
if m.bot:
continue
else:
player.dj = m
return
elif after.channel == channel and player.dj not in channel.members:
player.dj = member
async def cog_command_error(self, ctx: commands.Context, error: Exception):
"""Cog wide error handler."""
if isinstance(error, IncorrectChannelError):
return
if isinstance(error, NoChannelProvided):
return await ctx.send('You must be in a voice channel or provide one to connect to.')
async def cog_check(self, ctx: commands.Context):
"""Cog wide check, which disallows commands in DMs."""
if not ctx.guild:
await ctx.send('Music commands are not available in Private Messages.')
return False
return True
async def cog_before_invoke(self, ctx: commands.Context):
"""Coroutine called before command invocation.
We mainly just want to check whether the user is in the players controller channel.
"""
player: Player = self.bot.wavelink.get_player(ctx.guild.id, cls=Player, context=ctx)
if player.context:
if player.context.channel != ctx.channel:
await ctx.send(f'{ctx.author.mention}, you must be in {player.context.channel.mention} for this session.')
raise IncorrectChannelError
if ctx.command.name == 'connect' and not player.context:
return
elif self.is_privileged(ctx):
return
if not player.channel_id:
return
channel = self.bot.get_channel(int(player.channel_id))
if not channel:
try:
channel = ctx.author.voice.channel
except AttributeError:
raise discord.DiscordException("Aucun channel à rejoindre. Veuillez soit spécifier un canal valide, soit en rejoindre un.")
return
if player.is_connected:
if ctx.author not in channel.members:
await ctx.send(f'{ctx.author.mention}, you must be in `{channel.name}` to use voice commands.')
raise IncorrectChannelError
def required(self, ctx: commands.Context):
"""Method which returns required votes based on amount of members in a channel."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
channel = self.bot.get_channel(int(player.channel_id))
required = math.ceil((len(channel.members) - 1) / 2.5)
if ctx.command.name == 'stop':
if len(channel.members) == 3:
required = 2
return required
def is_privileged(self, ctx: commands.Context):
"""Check whether the user is an Admin or DJ."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
return player.dj == ctx.author or ctx.author.guild_permissions.kick_members
@commands.command()
async def connect(self, ctx: commands.Context, *, channel: typing.Union[discord.VoiceChannel, discord.StageChannel] = None):
"""Connect to a voice channel."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if player.is_connected:
return
channel = getattr(ctx.author.voice, 'channel', channel)
if channel is None:
raise NoChannelProvided
player = self.client.wavelink.get_player(ctx.guild.id)
await ctx.send(f"Connexion à **`{channel.name}`**")
await player.connect(channel.id)
@commands.command()
async def play(self, ctx, *, query: str):
tracks = await self.client.wavelink.get_tracks(f"ytsearch:{query}")
async def play(self, ctx: commands.Context, *, query: str):
"""Play or queue a song with the given query."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if not tracks:
return await ctx.send("Je n'ai pas trouvé la musique demandé.")
player = self.client.wavelink.get_player(ctx.guild.id)
if not player.is_connected:
await ctx.invoke(self.connect_)
await ctx.invoke(self.connect)
await ctx.send(f"Ajout de {str(tracks[0])} à la file d'attente.")
await player.play(tracks[0])
query = query.strip('<>')
if not URL_REG.match(query):
query = f'ytsearch:{query}'
tracks = await self.bot.wavelink.get_tracks(query)
if not tracks:
return await ctx.send('No songs were found with that query. Please try again.', delete_after=15)
if isinstance(tracks, wavelink.TrackPlaylist):
for track in tracks.tracks:
track = Track(track.id, track.info, requester=ctx.author)
await player.queue.put(track)
await ctx.send(f'```ini\nAdded the playlist {tracks.data["playlistInfo"]["name"]}'
f' with {len(tracks.tracks)} songs to the queue.\n```', delete_after=15)
else:
track = Track(tracks[0].id, tracks[0].info, requester=ctx.author)
await ctx.send(f'```ini\nAdded {track.title} to the Queue\n```', delete_after=15)
await player.queue.put(track)
if not player.is_playing:
await player.do_next()
@commands.command()
async def pause(self, ctx: commands.Context):
"""Pause the currently playing song."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if player.is_paused or not player.is_connected:
return
if self.is_privileged(ctx):
await ctx.send('An admin or DJ has paused the player.', delete_after=10)
player.pause_votes.clear()
return await player.set_pause(True)
required = self.required(ctx)
player.pause_votes.add(ctx.author)
if len(player.pause_votes) >= required:
await ctx.send('Vote to pause passed. Pausing player.', delete_after=10)
player.pause_votes.clear()
await player.set_pause(True)
else:
await ctx.send(f'{ctx.author.mention} has voted to pause the player.', delete_after=15)
@commands.command()
async def resume(self, ctx: commands.Context):
"""Resume a currently paused player."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if not player.is_paused or not player.is_connected:
return
if self.is_privileged(ctx):
await ctx.send('An admin or DJ has resumed the player.', delete_after=10)
player.resume_votes.clear()
return await player.set_pause(False)
required = self.required(ctx)
player.resume_votes.add(ctx.author)
if len(player.resume_votes) >= required:
await ctx.send('Vote to resume passed. Resuming player.', delete_after=10)
player.resume_votes.clear()
await player.set_pause(False)
else:
await ctx.send(f'{ctx.author.mention} has voted to resume the player.', delete_after=15)
@commands.command()
async def skip(self, ctx: commands.Context):
"""Skip the currently playing song."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if not player.is_connected:
return
if self.is_privileged(ctx):
await ctx.send('An admin or DJ has skipped the song.', delete_after=10)
player.skip_votes.clear()
return await player.stop()
if ctx.author == player.current.requester:
await ctx.send('The song requester has skipped the song.', delete_after=10)
player.skip_votes.clear()
return await player.stop()
required = self.required(ctx)
player.skip_votes.add(ctx.author)
if len(player.skip_votes) >= required:
await ctx.send('Vote to skip passed. Skipping song.', delete_after=10)
player.skip_votes.clear()
await player.stop()
else:
await ctx.send(f'{ctx.author.mention} has voted to skip the song.', delete_after=15)
@commands.command()
async def stop(self, ctx: commands.Context):
"""Stop the player and clear all internal states."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if not player.is_connected:
return
if self.is_privileged(ctx):
await ctx.send('An admin or DJ has stopped the player.', delete_after=10)
return await player.teardown()
required = self.required(ctx)
player.stop_votes.add(ctx.author)
if len(player.stop_votes) >= required:
await ctx.send('Vote to stop passed. Stopping the player.', delete_after=10)
await player.teardown()
else:
await ctx.send(f'{ctx.author.mention} has voted to stop the player.', delete_after=15)
@commands.command(aliases=['v', 'vol'])
async def volume(self, ctx: commands.Context, *, vol: int):
"""Change the players volume, between 1 and 100."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if not player.is_connected:
return
if not self.is_privileged(ctx):
return await ctx.send('Only the DJ or admins may change the volume.')
if not 0 < vol < 101:
return await ctx.send('Please enter a value between 1 and 100.')
await player.set_volume(vol)
await ctx.send(f'Set the volume to **{vol}**%', delete_after=7)
@commands.command(aliases=['mix'])
async def shuffle(self, ctx: commands.Context):
"""Shuffle the players queue."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if not player.is_connected:
return
if player.queue.qsize() < 3:
return await ctx.send('Add more songs to the queue before shuffling.', delete_after=15)
if self.is_privileged(ctx):
await ctx.send('An admin or DJ has shuffled the playlist.', delete_after=10)
player.shuffle_votes.clear()
return random.shuffle(player.queue._queue)
required = self.required(ctx)
player.shuffle_votes.add(ctx.author)
if len(player.shuffle_votes) >= required:
await ctx.send('Vote to shuffle passed. Shuffling the playlist.', delete_after=10)
player.shuffle_votes.clear()
random.shuffle(player.queue._queue)
else:
await ctx.send(f'{ctx.author.mention} has voted to shuffle the playlist.', delete_after=15)
@commands.command(hidden=True)
async def vol_up(self, ctx: commands.Context):
"""Command used for volume up button."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if not player.is_connected or not self.is_privileged(ctx):
return
vol = int(math.ceil((player.volume + 10) / 10)) * 10
if vol > 100:
vol = 100
await ctx.send('Maximum volume reached', delete_after=7)
await player.set_volume(vol)
@commands.command(hidden=True)
async def vol_down(self, ctx: commands.Context):
"""Command used for volume down button."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if not player.is_connected or not self.is_privileged(ctx):
return
vol = int(math.ceil((player.volume - 10) / 10)) * 10
if vol < 0:
vol = 0
await ctx.send('Player is currently muted', delete_after=10)
await player.set_volume(vol)
@commands.command(aliases=['eq'])
async def equalizer(self, ctx: commands.Context, *, equalizer: str):
"""Change the players equalizer."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if not player.is_connected:
return
if not self.is_privileged(ctx):
return await ctx.send('Only the DJ or admins may change the equalizer.')
eqs = {'flat': wavelink.Equalizer.flat(),
'boost': wavelink.Equalizer.boost(),
'metal': wavelink.Equalizer.metal(),
'piano': wavelink.Equalizer.piano()}
eq = eqs.get(equalizer.lower(), None)
if not eq:
joined = "\n".join(eqs.keys())
return await ctx.send(f'Invalid EQ provided. Valid EQs:\n\n{joined}')
await ctx.send(f'Successfully changed equalizer to {equalizer}', delete_after=15)
await player.set_eq(eq)
@commands.command(aliases=['q', 'que'])
async def queue(self, ctx: commands.Context):
"""Display the players queued songs."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if not player.is_connected:
return
if player.queue.qsize() == 0:
return await ctx.send('There are no more songs in the queue.', delete_after=15)
entries = [track.title for track in player.queue._queue]
pages = PaginatorSource(entries=entries)
paginator = menus.MenuPages(source=pages, timeout=None, delete_message_after=True)
await paginator.start(ctx)
@commands.command(aliases=['np', 'now_playing', 'current'])
async def nowplaying(self, ctx: commands.Context):
"""Update the player controller."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if not player.is_connected:
return
await player.invoke_controller()
@commands.command(aliases=['swap'])
async def swap_dj(self, ctx: commands.Context, *, member: discord.Member = None):
"""Swap the current DJ to another member in the voice channel."""
player: Player = self.bot.wavelink.get_player(guild_id=ctx.guild.id, cls=Player, context=ctx)
if not player.is_connected:
return
if not self.is_privileged(ctx):
return await ctx.send('Only admins and the DJ may use this command.', delete_after=15)
members = self.bot.get_channel(int(player.channel_id)).members
if member and member not in members:
return await ctx.send(f'{member} is not currently in voice, so can not be a DJ.', delete_after=15)
if member and member == player.dj:
return await ctx.send('Cannot swap DJ to the current DJ... :)', delete_after=15)
if len(members) <= 2:
return await ctx.send('No more members to swap to.', delete_after=15)
if member:
player.dj = member
return await ctx.send(f'{member.mention} is now the DJ.')
for m in members:
if m == player.dj or m.bot:
continue
else:
player.dj = m
return await ctx.send(f'{member.mention} is now the DJ.')