diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d92a696 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,73 @@ +name: Test And Build BOT +on: [push, pull_request] + +jobs: + flake8: + runs-on: ubuntu-latest + env: + CheckFiles: "bot.py cogs/" + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Lint with flake8 + run: | + pip install flake8 + flake8 ${CheckFiles} + + - name: Check with Isort + run: | + pip install isort + isort --check --sp setup.cfg ${CheckFiles} + - name: Sort with Isort + if: failure() + run: | + isort --sp setup.cfg ${CheckFiles} + - name: Create Pull Request + if: failure() + uses: peter-evans/create-pull-request@v2 + with: + token: ${{ secrets.GH_TOKEN }} + branch: actions/auto + commit-message: Changes done by tests + committer: Roxedus + title: Linter changed files + + build-and-push: + needs: flake8 + runs-on: ubuntu-latest + env: + repo: rnorge/music + ref: ${{github.ref}} + DOCKER_USERNAME: roxedus + DOCKER_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }} + + steps: + - uses: actions/checkout@v2 + - name: Build the Docker image + run: | + branch="${ref##*/}" + docker build -t $repo:$branch -t $repo:$branch-${{github.sha}} . + + - name: Master tag + if: github.ref == 'refs/heads/master' + run: | + branch="${ref##*/}" + bot_ver=$(grep -i bot_version cogs/utils/bot_version.py | cut -d" " -f3 | tr -d \") + docker tag $repo:$branch $repo:$bot_ver + + - name: Bump version and push tag + if: github.ref == 'refs/heads/master' + uses: mathieudutour/github-tag-action@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to registry + if: github.event.pull_request == false + run: echo $DOCKER_PASSWORD | docker login -u $DOCKER_USERNAME --password-stdin + + - name: Pushes the Docker image + if: github.event.pull_request == false + run: docker push $repo diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..aacd762 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "oderwat.indent-rainbow", + "ms-python.python", + "ms-python.vscode-pylance" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..958f4d6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [{ + "name": "Debug Bot", + "type": "python", + "request": "launch", + "program": "bot.py", + "console": "integratedTerminal", + "args": ["-D", "-d", "data"], + "host": "localhost" + }] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3f76c37 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "python.languageSever": "Pylance", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.linting.flake8Enabled": true, + "python.linting.pycodestyleEnabled": true, + "[python]": { + "editor.rulers": [120], + }, + "[Dockerfile]": { + "editor.formatOnSave": false + }, + "editor.tabSize": 4 + +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 112d5c0..4ab1572 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -FROM amd64/python:3.7.3-alpine +FROM amd64/python:3.8.3-alpine -LABEL maintainer="Roxedus" jekins="dorli" +LABEL maintainer="Roxedus" COPY / /app diff --git a/README.md b/README.md index a7a965f..09f97ec 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # WIP - Shite Music Bot [![Build Status](https://jenkins.roxedus.net/buildStatus/icon?job=MusicBot%2Fdev)](https://jenkins.roxedus.net/job/MusicBot/job/dev/) -[![](https://images.microbadger.com/badges/image/rnorge/music:dev-amd64.svg)](https://microbadger.com/images/rnorge/music:dev-amd64 "Get your own image badge on microbadger.com") +[![MicroBadger](https://images.microbadger.com/badges/image/rnorge/music:dev-amd64.svg)](https://microbadger.com/images/rnorge/music:dev-amd64 "Get your own image badge on microbadger.com") + +## Features -### Features: - Translations, get the bot in your preferred langauge! - Round Robin style queue, you won't have to wait forever for your songs to play. - Noice per server customization options. @@ -12,18 +13,19 @@ - Lyrics command that sometimes gives the correct lyrics - DJ roles +### Setup -#### Setup 1. Change or copy data/config.yaml.example to config.yaml 2. Create or find a [lavalink](https://github.com/Frederikam/Lavalink) server you can use. 3. Add your bot token and lavalink server. 4. Edit any other settings you want. -5. install requirements -> python -m pip install -r requirements.txt +5. install requirements `python -m pip install -r requirements.txt` 6. run the bot :) -#### Docker -#### Compose Example: +### Docker + +### Compose Example + ````yaml version: '3' networks: @@ -42,7 +44,7 @@ services: - lavalink volumes: - /compose/BottStack/lavalink/application.yml:/opt/Lavalink/application.yml - bot: + bot: container_name: ProdMaster image: rnorge/music networks: @@ -51,4 +53,4 @@ services: volumes: - /compose/BottStack/ProdBetaMaster/script:/diks - /compose/BottStack/ProdBetaMaster/data:/app/data -```` \ No newline at end of file +```` diff --git a/bot.py b/bot.py index c07e3a6..9b3d0e0 100644 --- a/bot.py +++ b/bot.py @@ -1,23 +1,25 @@ +# Discord Packages import discord -import codecs -import time -import os -import traceback -import yaml -import aiohttp - from discord.ext import commands + +import codecs +import os +import time +import traceback from argparse import ArgumentParser, RawTextHelpFormatter -from cogs.utils.settingsmanager import Settings -from cogs.utils.localizer import Localizer -from cogs.utils.localizer import LocalizerWrapper + +import aiohttp +import yaml + +# Bot Utilities from cogs.utils.alias import Aliaser from cogs.utils.context import Context +from cogs.utils.localizer import Localizer, LocalizerWrapper from cogs.utils.logger import BotLogger -from cogs.helpformatter import commandhelper -from cogs.utils.paginator import Scroller +from cogs.utils.settingsmanager import Settings initial_extensions = [ + 'cogs.errors', 'cogs.cogs', 'cogs.settings', 'cogs.misc', @@ -25,8 +27,7 @@ initial_extensions = [ ] on_ready_extensions = [ - 'cogs.music', - 'cogs.musicevents' + 'cogs.nodemanager' ] @@ -49,8 +50,6 @@ class Bot(commands.Bot): self.localizer = Localizer(conf.get('locale path', "./localization"), conf.get('locale', 'en_en')) self.aliaser = Aliaser(conf.get('locale path', "./localization"), conf.get('locale', 'en_en')) - self.session = aiohttp.ClientSession(loop=self.loop) - self.datadir = datadir self.debug = debug self.main_logger = logger @@ -63,42 +62,6 @@ class Bot(commands.Bot): except Exception: self.logger.exception("Loading of extension %s failed" % extension) - async def on_command_error(self, ctx, err): - if not self.debug: - if (isinstance(err, commands.MissingRequiredArgument) or - isinstance(err, commands.BadArgument)): - paginator = commandhelper(ctx, ctx.command, ctx.invoker, include_subcmd=False) - scroller = Scroller(ctx, paginator) - await scroller.start_scrolling() - - if isinstance(err, commands.CommandInvokeError): - self.logger.debug("Error running command: %s\n Traceback: %s" % (ctx.command, err)) - pass - - elif isinstance(err, commands.NoPrivateMessage): - self.logger.debug("Error running command: %s\n Traceback: %s" % (ctx.command, err)) - await ctx.send('That command is not available in DMs') - - elif isinstance(err, commands.CommandOnCooldown): - await ctx.send(f"{ctx.message.author.mention} Command is on cooldown. " - f"Try again in `{err.retry_after:.1f}` seconds.") - - elif isinstance(err, RuntimeError): - self.logger.debug("Error running command: %s\n Traceback: %s" % (ctx.command, err)) - pass - - elif isinstance(err, commands.CheckFailure): - self.logger.debug("Error running command: %s\n Traceback: %s" % (ctx.command, err)) - pass - - elif isinstance(err, commands.CommandNotFound): - pass - else: - tb = err.__traceback__ - traceback.print_tb(tb) - print(err) - self.logger.debug("Error running command: %s\n Traceback: %s" % (ctx.command, err)) - async def on_message(self, message): if message.author.bot: return @@ -121,6 +84,7 @@ class Bot(commands.Bot): async def on_ready(self): if not hasattr(self, 'uptime'): self.uptime = time.time() + for extension in on_ready_extensions: try: self.logger.debug("Loading extension %s" % extension) @@ -133,8 +97,9 @@ class Bot(commands.Bot): print(f'Version: {discord.__version__}\n') self.logger.debug("Bot Ready\n\n\n") + self.session = aiohttp.ClientSession(loop=self.loop) await self.change_presence(activity=discord.Game(type=0, - name=conf["bot"]["playing status"]), + name=conf["bot"]["playing status"]), status=discord.Status.online) def run(self): diff --git a/cogs/cogs.py b/cogs/cogs.py index e07b7cd..1c00b3d 100644 --- a/cogs/cogs.py +++ b/cogs/cogs.py @@ -1,6 +1,8 @@ -import traceback +# Discord Packages from discord.ext import commands +import traceback + class Cogs(commands.Cog): def __init__(self, bot): @@ -21,13 +23,15 @@ class Cogs(commands.Cog): try: self.bot.load_extension(f'cogs.{module}') await ctx.send(f'{module} loaded') - except Exception as e: - await ctx.send(f'```py\n{traceback.format_exc(e)}\n```') + except Exception: + await ctx.send(f'```py\n{traceback.format_exc()}\n```') @_cogs.command() @commands.is_owner() async def unload(self, ctx, *, module): """Unloads a module.""" + if module == "cogs": + return await ctx.send('Unloading this cog is not allowed') try: self.bot.unload_extension(f'cogs.{module}') await ctx.send(f'{module} unloaded') @@ -63,7 +67,7 @@ class Cogs(commands.Cog): @commands.is_owner() async def _shutdown(self, ctx): """Logs out and stops.""" - self.bot.lavalink.players.clear() + self.bot.lavalink.player_manager.players.clear() await self.bot.logout() diff --git a/cogs/errors.py b/cogs/errors.py new file mode 100644 index 0000000..92117d7 --- /dev/null +++ b/cogs/errors.py @@ -0,0 +1,83 @@ +# Discord Packages +import discord +from discord.ext import commands + +import traceback + +# Bot Utilities +from cogs.helpformatter import commandhelper +from cogs.utils.paginator import Scroller + + +class Errors(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.logger = self.bot.main_logger.bot_logger.getChild("Errors") + + async def base_msg(self, ctx, state: int = 0xFCBA03): + embed = discord.Embed(color=state) + embed.title = '{errors.error_occurred}' + embed.set_footer(icon_url=ctx.author.avatar_url, + text=f'{ctx.author.name}#{ctx.author.discriminator}') + return embed + + @commands.Cog.listener() + async def on_command_error(self, ctx, err): + + # Help commands + if (isinstance(err, commands.MissingRequiredArgument) or + isinstance(err, commands.BadArgument)): + paginator = commandhelper(ctx, ctx.command, ctx.invoker, include_subcmd=False) + scroller = Scroller(ctx, paginator) + await scroller.start_scrolling() + + if isinstance(err, (commands.CommandNotFound)): + return + + async def send_error_embed(description): + embed = await self.base_msg(ctx, state=0xFC0303) + embed.description = description + embed = ctx.localizer.format_embed(embed) + return await ctx.send(embed=embed) + + # Music related errors + if isinstance(err, commands.CommandInvokeError): + if (err.original == 'Join a voicechannel first.'): + return await send_error_embed('{errors.join_voice_first}') + + elif (err.original == 'Not connected.'): + return await send_error_embed('{errors.not_connected}') + + elif (err.original == 'I need the `CONNECT` and `SPEAK` permissions.'): + return await send_error_embed('{errors.need_permission}') + + elif (err.original == 'You need to be in the right voice channel'): + return await send_error_embed('{errors.right_channel}') + + elif (err.original == 'You need to be in my voicechannel.'): + return await send_error_embed('{errors.my_channel}') + + if isinstance(err, commands.CommandOnCooldown): + await ctx.send(f"{ctx.message.author.mention} Command is on cooldown. " + f"Try again in `{err.retry_after:.1f}` seconds.") + + elif isinstance(err, commands.NoPrivateMessage): + await ctx.send('That command is not available in DMs') + + else: + # Log all exceptions if the bot is in debug mode + if self.bot.debug: + tb = err.__traceback__ + traceback.print_tb(tb) + self.logger.debug("Error running command: %s\nTraceback: %s" % (ctx.command, err)) + + else: + to_log = (RuntimeError, commands.CheckFailure, commands.CommandInvokeError, + commands.NoPrivateMessage) + + if isinstance(err, to_log): + self.logger.debug("Error running command: %s\nTraceback: %s" % (ctx.command, err)) + + +def setup(bot): + bot.add_cog(Errors(bot)) diff --git a/cogs/helpformatter.py b/cogs/helpformatter.py index 8347125..2f77feb 100644 --- a/cogs/helpformatter.py +++ b/cogs/helpformatter.py @@ -1,9 +1,11 @@ +# Discord Packages from discord.ext import commands + import re +# Bot Utilities from cogs.utils.paginator import HelpPaginator, Scroller - usermention = r"<@!?\d{17,19}>" diff --git a/cogs/misc.py b/cogs/misc.py index 1d600a0..05873f8 100644 --- a/cogs/misc.py +++ b/cogs/misc.py @@ -1,11 +1,14 @@ +# Discord Packages import discord -import time -import platform - from discord.ext import commands -from cogs.utils import bot_version from lavalink import __version__ as LavalinkVersion +import platform +import time + +# Bot Utilities +from cogs.utils import bot_version + class Misc(commands.Cog): def __init__(self, bot): @@ -51,7 +54,7 @@ class Misc(commands.Cog): lavalink = self.bot.lavalink listeners = 0 - for guild, player in lavalink.player_manager.players: + for guild, player in lavalink.player_manager.players.items(): listeners += len(player.listeners) embed.add_field(name='{music.players}', value=f'{len(lavalink.player_manager.players)}') diff --git a/cogs/music.py b/cogs/music.py index 8fb9446..2bc6e77 100644 --- a/cogs/music.py +++ b/cogs/music.py @@ -1,25 +1,22 @@ """ Music commands """ +# Discord Packages +import discord +import lavalink +from discord.ext import commands + +import asyncio +import json import math import re -import asyncio -import yaml -import codecs - -import lavalink -import discord -from discord.ext import commands +import urllib from typing import Optional -import urllib -import json from bs4 import BeautifulSoup -from .utils import checks, RoxUtils, timeformatter -from .utils.mixplayer import MixPlayer -from .utils.paginator import QueuePaginator, TextPaginator, Scroller - +from .utils import checks, thumbnailer, timeformatter +from .utils.paginator import QueuePaginator, Scroller, TextPaginator from .utils.selector import Selector time_rx = re.compile('[0-9]+') @@ -30,15 +27,6 @@ class Music(commands.Cog): def __init__(self, bot): self.bot = bot self.logger = self.bot.main_logger.bot_logger.getChild("Music") - if not hasattr(bot, 'lavalink'): # This ensures the client isn't overwritten during cog reloads. - bot.lavalink = lavalink.Client(bot.user.id, player=MixPlayer) - - with codecs.open(f"{self.bot.datadir}/config.yaml", 'r', encoding='utf8') as f: - conf = yaml.load(f, Loader=yaml.SafeLoader) - - bot.lavalink.add_node(**conf['lavalink nodes']['main']) - self.logger.debug("Adding Lavalink node") - bot.add_listener(bot.lavalink.voice_update_handler, 'on_socket_response') async def cog_check(self, ctx): if not ctx.guild: @@ -81,11 +69,17 @@ class Music(commands.Cog): numtracks = 0 for track in tracks: if track['info']['length'] <= maxlength: + thumbnail_url = await thumbnailer.ThumbNailer.identify( + self, track['info']['identifier'], track['info']['uri']) + track = lavalink.models.AudioTrack(track, ctx.author.id, thumbnail_url=thumbnail_url) player.add(requester=ctx.author.id, track=track) numtracks += 1 else: numtracks = len(tracks) for track in tracks: + thumbnail_url = await thumbnailer.ThumbNailer.identify( + self, track['info']['identifier'], track['info']['uri']) + track = lavalink.models.AudioTrack(track, ctx.author.id, thumbnail_url=thumbnail_url) player.add(requester=ctx.author.id, track=track) embed.title = '{playlist_enqued}' @@ -94,6 +88,9 @@ class Music(commands.Cog): await ctx.send(embed=embed) else: track = results['tracks'][0] + thumbnail_url = await thumbnailer.ThumbNailer.identify( + self, track['info']['identifier'], track['info']['uri']) + track = lavalink.models.AudioTrack(track, ctx.author.id, thumbnail_url=thumbnail_url) await self.enqueue(ctx, track, embed) embed = ctx.localizer.format_embed(embed) await ctx.send(embed=embed) @@ -144,7 +141,7 @@ class Music(commands.Cog): if player.current: song = f'**[{player.current.title}]({player.current.uri})**' embed = discord.Embed(color=ctx.me.color, description=song, title='{now}') - thumbnail_url = await RoxUtils.ThumbNailer.identify(self, player.current.identifier, player.current.uri) + thumbnail_url = player.current.extra["thumbnail_url"] member = ctx.guild.get_member(player.current.requester) if thumbnail_url: embed.set_thumbnail(url=thumbnail_url) @@ -195,7 +192,8 @@ class Music(commands.Cog): player.queue.clear() await player.stop() - await ctx.send(ctx.localizer.format_str("{stop}")) + embed = discord.Embed(color=ctx.me.color, title='{stop}') + await ctx.send(embed=ctx.localizer.format_embed(embed)) @commands.command(name='now') async def _now(self, ctx): @@ -214,7 +212,7 @@ class Music(commands.Cog): member = ctx.guild.get_member(player.current.requester) embed = discord.Embed(color=ctx.me.color, description=song, title='{now}') - thumbnail_url = await RoxUtils.ThumbNailer.identify(self, player.current.identifier, player.current.uri) + thumbnail_url = player.current.extra["thumbnail_url"] if thumbnail_url: embed.set_thumbnail(url=thumbnail_url) @@ -334,7 +332,7 @@ class Music(commands.Cog): # Create a nice embed explaining what happened song = f'**[{moved.title}]({moved.uri})**' embed = discord.Embed(color=ctx.me.color, description=song, title='{moved.moved}') - thumbnail_url = await RoxUtils.ThumbNailer.identify(self, moved.identifier, moved.uri) + thumbnail_url = moved.extra["thumbnail_url"] member = ctx.guild.get_member(moved.requester) if thumbnail_url: embed.set_thumbnail(url=thumbnail_url) @@ -560,7 +558,7 @@ class Music(commands.Cog): description = ctx.localizer.format_str("{history.current}", _title=track.title, _uri=track.uri, _id=track.requester) + '\n\n' description += ctx.localizer.format_str("{history.previous}", _len=len(history)-1) + '\n' - thumb_url = await RoxUtils.ThumbNailer.identify(self, track.identifier, track.uri) + thumbnail_url = track.extra["thumbnail_url"] for index, track in enumerate(history[1:], start=1): description += ctx.localizer.format_str("{history.track}", _index=-index, _title=track.title, _uri=track.uri, _id=track.requester) + '\n' @@ -568,8 +566,8 @@ class Music(commands.Cog): embed = discord.Embed(title=ctx.localizer.format_str('{history.title}'), color=ctx.me.color, description=description) - if thumb_url: - embed.set_thumbnail(url=thumb_url) + if thumbnail_url: + embed.set_thumbnail(url=thumbnail_url) await ctx.send(embed=embed) @commands.cooldown(1, 5, commands.BucketType.guild) @@ -768,9 +766,11 @@ class Music(commands.Cog): raise commands.CommandInvokeError('You need to be in my voicechannel.') async def enqueue(self, ctx, track, embed): - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + if isinstance(track, dict): + track = lavalink.models.AudioTrack(track, ctx.author.id) + maxlength = self.max_track_length(ctx.guild, player) if maxlength and track['info']['length'] > maxlength: embed.description = ctx.localizer.format_str("{enqueue.toolong}", @@ -793,10 +793,10 @@ class Music(commands.Cog): embed.add_field(name="{enqueue.playing_in}", value=f"`{until_play} ({{enqueue.estimated}})`", inline=True) embed.title = '{enqueue.enqueued}' - thumb_url = await RoxUtils.ThumbNailer.identify(self, track.identifier, track.uri) + thumbnail_url = track.extra["thumbnail_url"] - if thumb_url: - embed.set_thumbnail(url=thumb_url) + if thumbnail_url: + embed.set_thumbnail(url=thumbnail_url) duration = timeformatter.format_ms(int(track.duration)) embed.description = f'[{track.title}]({track.uri})\n**{duration}**' diff --git a/cogs/musicevents.py b/cogs/musicevents.py index 5f5ecc9..04d5091 100644 --- a/cogs/musicevents.py +++ b/cogs/musicevents.py @@ -2,29 +2,18 @@ A cog to separate events from regular music commands """ -import codecs -import yaml +# Discord Packages import lavalink -from discord.ext import commands - -from .utils.mixplayer import MixPlayer import lavalink.events +from discord.ext import commands, tasks class MusicEvents(commands.Cog): def __init__(self, bot): self.bot = bot - # TODO: maybe only load when Music loads - if not hasattr(bot, 'lavalink'): # This ensures the client isn't overwritten during cog reloads. - bot.lavalink = lavalink.Client(bot.user.id, player=MixPlayer) - - with codecs.open(f"{self.bot.datadir}/config.yaml", 'r', encoding='utf8') as f: - conf = yaml.load(f, Loader=yaml.SafeLoader) - - bot.lavalink.add_node(**conf['lavalink nodes']['main']) - bot.add_listener(bot.lavalink.voice_update_handler, 'on_socket_response') - - lavalink.add_event_hook(self.track_hook) + self.leave_timer.start() + self.logger = self.bot.main_logger.bot_logger.getChild("Errors") + bot.lavalink.add_event_hook(self.track_hook) def cog_unload(self): self.bot.lavalink._event_hooks.clear() @@ -77,6 +66,17 @@ class MusicEvents(commands.Cog): await player.stop() await self.connect_to(guild.id, None) + async def leave_check(self): + for player_id in self.bot.lavalink.player_manager.players: + await self.check_leave_voice(self.bot.get_guild(player_id)) + + @tasks.loop(seconds=10.0) + async def leave_timer(self): + try: + await self.leave_check() + except Exception as err: + self.logger.debug("Error in leave_timer loop.\nTraceback: %s" % (err)) + def setup(bot): bot.add_cog(MusicEvents(bot)) diff --git a/cogs/nodemanager.py b/cogs/nodemanager.py new file mode 100644 index 0000000..ef08c9b --- /dev/null +++ b/cogs/nodemanager.py @@ -0,0 +1,157 @@ +# Discord Packages +import discord +import lavalink +from discord.ext import commands +from lavalink import Node + +import codecs + +import yaml + +# Bot Utilities +from cogs.helpformatter import commandhelper +from cogs.utils.paginator import Scroller +from .utils.mixplayer import MixPlayer + + +class NodeManager(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.settings = self.bot.settings + self.logger = self.bot.main_logger.bot_logger.getChild("NodeManager") + + music_extensions = [ + 'cogs.music', + 'cogs.musicevents' + ] + + if not hasattr(bot, 'lavalink'): + bot.lavalink = lavalink.Client(bot.user.id, player=MixPlayer) + + self.load_nodes_from_file() + + bot.add_listener(bot.lavalink.voice_update_handler, 'on_socket_response') + + for extension in music_extensions: + try: + self.logger.debug("Loading extension %s" % extension) + self.bot.load_extension(extension) + except Exception: + self.logger.exception("Loading of extension %s failed" % extension) + + def load_nodes_from_file(self): + with codecs.open(f"{self.bot.datadir}/config.yaml", 'r', encoding='utf8') as f: + conf = yaml.load(f, Loader=yaml.SafeLoader) + + name_cache = [] + new_nodes = [] + + for node in self.bot.lavalink.node_manager.nodes: + name_cache.append(node.name) + + for node in conf['lavalink nodes']: + if node['name'] in name_cache: + continue + + self.bot.lavalink.add_node(**node) + self.logger.debug("Adding Lavalink node: %s on %s with the port %s in %s" % ( + node['name'], node['host'], + node['port'], node['region'],)) + new_nodes.append({**node}) + return new_nodes + + async def _regioner(self, region): + flags = { + 'us': ':flag_us:', + 'eu': ':flag_eu:', + 'singapore': ':flag_sg:', + 'london': ':flag_gb:', + 'sydney': ':flag_au:', + 'amsterdam': ':flag_nl:', + 'frankfurt': ':flag_de:', + 'brazil': ':flag_br:', + 'japan': ':flag_jp:', + 'russia': ':flag_ru:', + 'southafrica': ':flag_za:', + 'hongkong': ':flag_hk:', + 'india': ':flag_in:' + } + try: + return flags[region] + except KeyError: + return ':question:' + + async def _node_presenter(self, ctx, node): + embed = discord.Embed(color=ctx.me.color) + embed.title = 'Added new node!' + + if isinstance(node, list): + for n in node: + embed.add_field(name=f'{await self._regioner(n.region)} **Name:** {n.name}', + value=f'**Host:** {n.host}\n **Port:** {n.port}') + + if isinstance(node, Node): + embed.add_field(name=f'{await self._regioner(node.region)} **Name:** {node.name}', + value=f'**Host:** {node.host}\n **Port:** {node.port}') + + if isinstance(node, dict): + embed.description = f'**Name:** {node.get("name")}\n **Host:** {node.get("host")}\n ' \ + f'**Port:** {node.get("port")}\n **Region:** {await self._regioner(node.get("region"))}' + + return embed + + @commands.group(name='node', hidden=True) + @commands.is_owner() + async def _node(self, ctx): + if ctx.invoked_subcommand is None: + paginator = commandhelper(ctx, ctx.command, ctx.invoker, include_subcmd=True) + scroller = Scroller(ctx, paginator) + await scroller.start_scrolling() + + @_node.command(name='reload_file') + @commands.is_owner() + async def reload_file(self, ctx): + for node in self.load_nodes_from_file(): + embed = await self._node_presenter(ctx, node) + embed.title = 'Added new node from file!' + await ctx.send(embed=embed) + + @_node.command(name='add') + @commands.is_owner() + async def _add(self, ctx, host, port, password, region, name=None): + self.bot.lavalink.add_node(host, port, password, region, name) + self.logger.debug("Adding Lavalink node: %s on %s with the port %s in %s" % (host, port, region, name,)) + embed = await self._node_presenter(ctx, {'host': host, 'port': port, 'password': password, + 'region': region, 'name': name}) + embed.title = 'Added new node!' + await ctx.send(embed=embed) + + @_node.command(name='list') + @commands.is_owner() + async def list_nodes(self, ctx): + embed = await self._node_presenter(ctx, self.bot.lavalink.node_manager.nodes) + embed.title = 'Lavalink nodes attatched to this bot:' + await ctx.send(embed=embed) + + @_node.command(name='remove') + @commands.is_owner() + async def _remove(self, ctx, node): + sent_feedback = False + for _node in self.bot.lavalink.node_manager.nodes: + if len(self.bot.lavalink.node_manager.nodes) <= 1: + await ctx.send('Cannot remove the last node') + sent_feedback = True + break + if (_node.name or _node.host) == node: + sent_feedback = True + embed = await self._node_presenter(ctx, _node) + embed.title = 'Removed node from bot' + await ctx.send(embed=embed) + self.bot.lavalink.node_manager.remove_node(_node) + self.logger.info("Removed Lavalink node: %s" % _node.host) + if not sent_feedback: + await ctx.send('No node found') + + +def setup(bot): + bot.add_cog(NodeManager(bot)) diff --git a/cogs/settings.py b/cogs/settings.py index a813e08..22c5285 100644 --- a/cogs/settings.py +++ b/cogs/settings.py @@ -1,6 +1,8 @@ +# Discord Packages import discord - from discord.ext import commands + +# Bot Utilities from cogs.utils import checks @@ -188,9 +190,9 @@ class Settings(commands.Cog): embed = discord.Embed(title='Max duration type set to', color=ctx.me.color) if is_dynamic: - embed.description = f'Dynamic' + embed.description = 'Dynamic' else: - embed.description = f'Static' + embed.description = 'Static' await ctx.send(embed=embed) @commands.guild_only() diff --git a/cogs/utils/alias.py b/cogs/utils/alias.py index 21f63eb..8a68d61 100644 --- a/cogs/utils/alias.py +++ b/cogs/utils/alias.py @@ -1,9 +1,10 @@ -import yaml +# Discord Packages +from discord.ext import commands from glob import glob from os import path -from discord.ext import commands +import yaml """ Not the prettiest this, works by replacing any found aliases in a command string with the actual command names. diff --git a/cogs/utils/checks.py b/cogs/utils/checks.py index 3925184..75e22db 100644 --- a/cogs/utils/checks.py +++ b/cogs/utils/checks.py @@ -1,3 +1,4 @@ +# Discord Packages import discord from discord.ext import commands @@ -60,7 +61,7 @@ def is_dj(ctx): def dj_or(alone: bool = False, track_requester: bool = False): async def predicate(ctx): try: - player = ctx.bot.lavalink.players.get(ctx.guild.id) + player = ctx.bot.lavalink.player_manager.get(ctx.guild.id) is_alone = (ctx.author in player.listeners and len(player.listeners) == 1) and alone requester = (player.current.requester == ctx.author.id) and track_requester diff --git a/cogs/utils/context.py b/cogs/utils/context.py index 61da689..3dbc92b 100644 --- a/cogs/utils/context.py +++ b/cogs/utils/context.py @@ -1,3 +1,4 @@ +# Discord Packages from discord.ext import commands diff --git a/cogs/utils/localizer.py b/cogs/utils/localizer.py index 8706df1..c3a444e 100644 --- a/cogs/utils/localizer.py +++ b/cogs/utils/localizer.py @@ -1,14 +1,15 @@ +# Discord Packages +from discord import Embed + +import copy +import re from glob import glob from os import path -import re - -from cogs.utils.dict_utils import flatten, SafeDict - import yaml -import copy -from discord import Embed +# Bot Utilities +from cogs.utils.dict_utils import SafeDict, flatten """ Localizer for bot @@ -95,6 +96,7 @@ class Localizer: nstr = nstr.replace(".", "/") value = value.replace(outer, nstr) return value + # parses and interpolates strings @staticmethod def _parse_localization_string(value, d, prefix=None): diff --git a/cogs/utils/logger.py b/cogs/utils/logger.py index 207f7ff..9a921b0 100644 --- a/cogs/utils/logger.py +++ b/cogs/utils/logger.py @@ -1,5 +1,5 @@ -import logging.handlers import logging +import logging.handlers import os diff --git a/cogs/utils/mixplayer.py b/cogs/utils/mixplayer.py index 9d9dae0..116f0cd 100644 --- a/cogs/utils/mixplayer.py +++ b/cogs/utils/mixplayer.py @@ -1,12 +1,13 @@ +# Discord Packages import lavalink +from lavalink import AudioTrack, DefaultPlayer, Node +from lavalink.events import QueueEndEvent, TrackEndEvent, TrackExceptionEvent, TrackStartEvent, TrackStuckEvent +import typing from collections import OrderedDict, deque -from itertools import cycle, islice, chain +from itertools import chain, cycle, islice from random import shuffle -from lavalink.events import TrackStartEvent, TrackStuckEvent, TrackExceptionEvent, TrackEndEvent, QueueEndEvent -from lavalink import Node, DefaultPlayer, AudioTrack - class MixPlayer(DefaultPlayer): def __init__(self, guild_id: int, node: Node): @@ -18,13 +19,13 @@ class MixPlayer(DefaultPlayer): self.skip_voters = set() self.boosted = False - def add(self, requester: int, track: dict, pos: int = None): + def add(self, requester: int, track: typing.Union[dict, AudioTrack], pos: int = None): """ Adds a track to the queue. """ - return self.queue.add_track(requester, AudioTrack(track, requester), pos) + return self.queue.add_track(requester, track, pos) - def add_next(self, requester: int, track: dict): + def add_next(self, requester: int, track: typing.Union[dict, AudioTrack], pos: int = None): """ Adds a track to beginning of the queue """ - self.queue.add_next_track(AudioTrack(track, requester)) + self.queue.add_next_track(track) def move_user_track(self, requester: int, initial: int, final: int): """ Moves a track in a users queue""" diff --git a/cogs/utils/paginator.py b/cogs/utils/paginator.py index 4300ff1..70a5bb3 100644 --- a/cogs/utils/paginator.py +++ b/cogs/utils/paginator.py @@ -1,4 +1,6 @@ +# Discord Packages import discord + import asyncio @@ -93,7 +95,7 @@ class FieldPaginator(BasePaginator): field = {"name": name, "value": value} fieldsize = sum([len(val) for _, val in field.items()]) if len(name) > 256 or len(value) > 1024: - raise RuntimeError(f'Maximum field size exceeded, max name length: 256. max value length: 1024.') + raise RuntimeError('Maximum field size exceeded, max name length: 256. max value length: 1024.') if self._current_page_size + fieldsize > self._max_size: self.close_page() diff --git a/cogs/utils/selector.py b/cogs/utils/selector.py index 22fc41c..3e93e60 100644 --- a/cogs/utils/selector.py +++ b/cogs/utils/selector.py @@ -1,6 +1,9 @@ +# Discord Packages import discord + import asyncio import inspect + from .paginator import TextPaginator diff --git a/cogs/utils/settingsmanager.py b/cogs/utils/settingsmanager.py index 2a25a22..f361446 100644 --- a/cogs/utils/settingsmanager.py +++ b/cogs/utils/settingsmanager.py @@ -1,6 +1,7 @@ -import os import codecs import locale as localee +import os + import yaml diff --git a/cogs/utils/RoxUtils.py b/cogs/utils/thumbnailer.py similarity index 92% rename from cogs/utils/RoxUtils.py rename to cogs/utils/thumbnailer.py index 858e748..cbc88ea 100644 --- a/cogs/utils/RoxUtils.py +++ b/cogs/utils/thumbnailer.py @@ -19,7 +19,7 @@ class ThumbNailer(object): img = soup.find("meta", property="twitter:image") return img["content"] except Exception as e: - self.logger.exception("%s".format(e)) + self.logger.exception("%s" % e) async def _bandcamp(self, url): html = await ThumbNailer.get_html(self, url) @@ -28,7 +28,7 @@ class ThumbNailer(object): img = soup.find(class_="popupImage").get("href") return img except Exception as e: - self.logger.exception("%s".format(e)) + self.logger.exception("%s" % e) async def _vimeo(self, url): html = await ThumbNailer.get_html(self, url) @@ -37,7 +37,7 @@ class ThumbNailer(object): img = soup.find("meta", property="og:image") return img["content"] except Exception as e: - self.logger.exception("%s".format(e)) + self.logger.exception("%s" % e) @staticmethod async def identify(self, identifier, uri): diff --git a/data/config.yaml.example b/data/config.yaml.example index f1f4f8b..281354f 100644 --- a/data/config.yaml.example +++ b/data/config.yaml.example @@ -19,20 +19,17 @@ default server settings: dynamic max duration: Yes lavalink nodes: - main: # The preferred lavalink node for your music bot - host: localhost + - host: localhost port: 2333 password: youshallnotpass region: eu - name: default-node + name: main - # Backup nodes for your music bot - backup: - - host: localhost - port: 2333 - password: youshallnotpass - region: eu - name: backup-1 + - host: localhost + port: 2333 + password: youshallnotpass + region: eu + name: backup-1 APIkeys: genius: diff --git a/localization/en_en/commands.yaml b/localization/en_en/commands.yaml index 14fe1e7..99a396b 100644 --- a/localization/en_en/commands.yaml +++ b/localization/en_en/commands.yaml @@ -112,6 +112,24 @@ reloadalias: aliases: [reloadalias] description: 'Reload all aliases and help information.' +# NodeManager +node: + aliases: [node] + description: 'Manage server nodes.' + sub_commands: + add: + aliases: [add] + description: 'Adds node to pool.' + list: + aliases: [list, nodes] + description: 'Lists nodes in the pool.' + remove: + aliases: [remove] + description: 'Removes nodes in the pool.' + reload_file: + aliases: [reload_file, reload] + description: 'Reads the settings and adds newly added nodes.' + # Settings settings: aliases: [settings, set] diff --git a/localization/en_en/music.yaml b/localization/en_en/music.yaml index 2afafbb..f7c84f4 100644 --- a/localization/en_en/music.yaml +++ b/localization/en_en/music.yaml @@ -15,7 +15,7 @@ skip_to: invalid_pos: Invalid specified position. exceeds_queue: The position exceeds the queue\'s length. skipped_to: skipped to `{_title}` at position `{_pos}` -stop: "⏹ | Stopped." +stop: "Stopped." live: "\U0001F534 LIVE" now: Now Playing queue: @@ -74,3 +74,10 @@ history: settings_check: textchannel: "You can't use music commands in this channel.\nTry in: " voicechannel: 'I can only play music in: ' +errors: + error_occurred: An error occurred + join_voice_first: You need to join a voicechannel first + not_connected: Not connected + need_permission: I need the `CONNECT` and `SPEAK` permissions + right_channel: You need to be in the right voice channel + my_channel: You need to be in my voicechannel diff --git a/localization/en_en/nodemanager.yaml b/localization/en_en/nodemanager.yaml new file mode 100644 index 0000000..7ec4f85 --- /dev/null +++ b/localization/en_en/nodemanager.yaml @@ -0,0 +1,2 @@ +pageindicator: Viewing page {_current}/{_total} | Type "{_prefix}help " for more information +notcommand: Looks like "{_command}" is not a command or category. \ No newline at end of file diff --git a/localization/nb_no/music.yaml b/localization/nb_no/music.yaml index 42c6c61..d91492f 100644 --- a/localization/nb_no/music.yaml +++ b/localization/nb_no/music.yaml @@ -74,3 +74,10 @@ history: settings_check: textchannel: "Du kan ikke bruke musikkommandoer i denne kanalen.\nPrøv i: " voicechannel: 'Jeg kan bare spille musikk i: ' +errors: + error_occurred: En feil oppstod + join_voice_first: Du må være i en stemmekanal + not_connected: Ikke tilkoblet + need_permission: Jeg trenger tillgang til `CONNECT` og `SPEAK` + right_channel: Du må være i riktig kanal + my_channel: Du må være i samme stemmekanal som meg diff --git a/localization/nb_no/nodemanager.yaml b/localization/nb_no/nodemanager.yaml new file mode 100644 index 0000000..ffa8294 --- /dev/null +++ b/localization/nb_no/nodemanager.yaml @@ -0,0 +1,2 @@ +pageindicator: Viser side {_current}/{_total} | Skriv "{_prefix}hjelp " for mer informasjon +notcommand: Ser ut som "{_command}" ikke er en kategori eller en kommando. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 58c9768..9f1d579 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ -discord.py >= 1.3 -git+https://github.com/Devoxin/Lavalink.py@b614a5389e3cf5ea7c444041428a05b084ca15ec +discord.py +git+https://github.com/Devoxin/Lavalink.py@dev asyncio pyyaml BeautifulSoup4 -aiohttp<3.5.0,>=3.3.0 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..92598fc --- /dev/null +++ b/setup.cfg @@ -0,0 +1,26 @@ +[flake8] +max-line-length = 120 +count = True +statistics = True + +[isort] +known_discord = discord,lavalink +known_utils = cogs.utils,cogs.helpformatter +import_heading_discord = Discord Packages +import_heading_utils = Bot Utilities +sections = DISCORD,FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,UTILS,LOCALFOLDER +no_lines_before = LOCALFOLDER +indent = " " +line_length = 120 +balanced_wrapping = True +multi_line_output = 4 + +[pycodestyle] +max_line_length = 120 + +[pylint.FORMAT] +max-line-length=120 + +[pylint.BASIC] +variable-naming-style=camelCase +constant-naming-style=camelCase