diff --git a/bot.py b/bot.py index 2bf0e1e..fe670b4 100644 --- a/bot.py +++ b/bot.py @@ -22,51 +22,74 @@ from discord.ext.commands.errors import ExtensionNotFound from discord.ext.commands.errors import NoEntryPointError from context import CustomContext +from tunebot.plugins import FileSystemPluginLoader +from tunebot.plugins import SimplePluginManager from tunebot.redis import GlobalRedisAutoJoin from tunebot.redis import GlobalRedisPlaylist from tunebot.redis import GlobalRedisPlaylistSource +from tunebot.redis import GlobalRedisUtils if TYPE_CHECKING: + from tunebot import PluginManagerBase from tunebot import GlobalPlaylist from tunebot import GlobalPlaylistSource from tunebot import GlobalAutoJoin + from tunebot import PluginLoaderBase + from tunebot import PluginManagerBase + from tunebot import GlobalUtils + +ColorDict = dict[str, "Color"] class TuneBot(commands.Bot): lavalink: lavalink.Client - invite_link: str + invite_link: str = "" + initial_cog_names: list[str] + colors: ColorDict + + global_autojoin: "GlobalAutoJoin" + global_playlist: "GlobalPlaylist" + global_playlist_source: "GlobalPlaylistSource" + global_utils: "GlobalUtils" + + plugin_loader: "PluginLoaderBase" + plugin_manager: "PluginManagerBase" def __init__(self, config: Dict[Any, Any]): intents = discord.Intents( - voice_states=True, guild_messages=True, guilds=True, messages=True + voice_states=True, + guild_messages=True, + guilds=True, + messages=True, + members=True, ) self.rpc_is_help_message = True self.update_status.start() self.config = config - self.initial_cog_names: List[str] = self.config.get("cogs", []) - self.colors: Dict[str, Color] = self.process_colours(config.get("colors", [])) + + self.initial_cog_names = self.config.get("cogs", []) + self.colors = self.process_colours(config.get("colors", [])) self.redis_prefix = self.config["redis_prefix"] self._redis_client: Redis = aioredis.from_url( self.config["redis_url"], encoding="utf-8", decode_responses=True ) - self.global_autojoin: GlobalAutoJoin = GlobalRedisAutoJoin( - self._redis_client, - self.redis_prefix, + self.global_autojoin = GlobalRedisAutoJoin( + self._redis_client, self.redis_prefix ) - self.global_playlist: GlobalPlaylist = GlobalRedisPlaylist( - self._redis_client, - self.redis_prefix, + self.global_playlist = GlobalRedisPlaylist( + self._redis_client, self.redis_prefix ) - self.global_playlist_source: GlobalPlaylistSource = GlobalRedisPlaylistSource( - self._redis_client, - self.redis_prefix, + self.global_playlist_source = GlobalRedisPlaylistSource( + self._redis_client, self.redis_prefix ) + self.global_utils = GlobalRedisUtils(self._redis_client, self.redis_prefix) - self.invite_link: str = "" + self.plugin_loader = FileSystemPluginLoader(self) + self.plugin_manager = SimplePluginManager(self) slash_guilds = None if len(self.config["slash_command_guilds"]) > 0: @@ -86,23 +109,24 @@ class TuneBot(commands.Bot): self.loop.create_task(self.async_init()) async def async_init(self): - await self.load_cogs(self.initial_cog_names) + self.init_plugins() + 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]): + def load_cogs(self, cog_names: Sequence[str]): for cog in cog_names: try: self.load_extension(cog) - print(f"Succesfully loaded extension {cog}.") + print(f"[✓] loaded extension: {cog}.") except ( ExtensionNotFound, ExtensionAlreadyLoaded, NoEntryPointError, ExtensionFailed, ) as e: - print(f"Failed to load extension {cog}.\n\t{e}", file=sys.stderr) + print(f"[x] failed loading extension: {cog}.\n\t{e}", file=sys.stderr) async def on_ready(self): self.invite_link = f"https://discord.com/oauth2/authorize?client_id={self.user.id}&permissions=3230720&scope=bot%20applications.commands" @@ -117,7 +141,7 @@ class TuneBot(commands.Bot): ll["host"], ll["port"], ll["password"], ll["region"], ll["name"] ) - def process_colours(self, colors: Dict[str, str]) -> Dict[str, Color]: + def process_colours(self, colors: Dict[str, str]) -> ColorDict: colour_dict: Dict[str, Color] = {} for name, color in colors.items(): colour_dict[name] = Color(int(color, 16)) @@ -126,6 +150,19 @@ class TuneBot(commands.Bot): async def get_context(self, message: Message, *, cls=CustomContext): return await super().get_context(message, cls=cls) + def init_plugins(self): + for plug_id, plug_conf in self.config["plugins"].items(): + if not plug_conf.get("enabled"): + continue + + try: + plugin = self.plugin_loader.load_plugin(plug_conf) + self.plugin_manager.enable_plugin(plug_id, plugin) + print(f"[✓] loaded plugin: {plug_id}") + except Exception as e: + self.plugin_manager.remove_plugin(plug_id) + print(f"[x] failed loading plugin: {plug_id}\n{e}") + @tasks.loop(seconds=30) async def update_status(self): await self.wait_until_ready() @@ -141,13 +178,6 @@ class TuneBot(commands.Bot): await self.change_presence(activity=activity) -config_path = "config.json" -if len(sys.argv) > 1: - config_path = sys.argv[1] - -config = json.load(open(config_path, "r", encoding="utf-8")) -redis_prefix = config["redis_prefix"] - if __name__ == "__main__": try: import uvloop @@ -157,5 +187,10 @@ if __name__ == "__main__": except ModuleNotFoundError: pass + config_path = "config.json" + if len(sys.argv) > 1: + config_path = sys.argv[1] + + config = json.load(open(config_path, "r", encoding="utf-8")) token = config.pop("token") TuneBot(config).run(token, reconnect=True) diff --git a/cogs/music.py b/cogs/music.py index 26fd5df..a5e4c95 100644 --- a/cogs/music.py +++ b/cogs/music.py @@ -1,24 +1,29 @@ import asyncio import datetime import re +from typing import Any from typing import Optional +from typing import TYPE_CHECKING import discord import lavalink from discord import Embed from discord.channel import TextChannel from discord.ext import commands -from discord.ext.commands.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 tunebot.plugins import ServiceEvent from utils.classes import BaseCog from utils.EmbedGenerator import EmbedGenerator from utils.exceptions import EmbeddedCommandException +if TYPE_CHECKING: + from discord import VoiceChannel + url_rx = re.compile(r"https?://(?:www\.)?.+") @@ -217,13 +222,36 @@ class Music(BaseCog): 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) + if channel := event.player.fetch("channel"): + channel_id = int(channel) + channel: TextChannel = self.bot.get_channel(channel_id) + embed = await self.create_track_embed(event.player.current) + await channel.send(embed=embed) elif isinstance(event, lavalink.events.TrackEndEvent): await self.fill_player_queue(event.player, 1) + if event.reason == "FINISHED": + if event.player.channel_id: + channel_id = int(event.player.channel_id) + voice_channel: "VoiceChannel" = self.bot.get_channel(channel_id) + in_voice: list[int] = [] + + for member in voice_channel.members: + if member.bot: + continue + + in_voice.append(member.id) + + payload: dict[str, Any] = { + "last_track": event.track, + "in_voice": in_voice, + } + s = ServiceEvent.TRACK_ENDED + await self.bot.plugin_manager.dispatch(s, payload) + else: + fmt = f"Failed dispatching for TrackEnd event, missing channel_id on player" + print(fmt) + @commands.command(name="connect", aliases=["p", "play", "join"]) async def play(self, ctx: CustomContext): """Start the radio""" diff --git a/config.json.sample b/config.json.sample index 874b4a2..2867d5f 100644 --- a/config.json.sample +++ b/config.json.sample @@ -22,5 +22,18 @@ "cogs": ["cogs.owner", "cogs.settings", "cogs.information", "cogs.music"], "slash_command_guilds": [], "queue_buffer_size": 5, - "slash_descriptions": {} + "slash_descriptions": {}, + "plugins": { + "lastfm-scrobbler": { + "services": ["plugins.lastfm_scrobbler.service"], + "cogs": ["plugins.lastfm_scrobbler.cog"], + "config": { + "lastfm_api_key": "", + "lastfm_api_secret": "", + "session_key_table": "", + "auth_url": "" + }, + "enabled": false + } + } } diff --git a/context.py b/context.py index 0020b0d..e126bab 100644 --- a/context.py +++ b/context.py @@ -1,3 +1,4 @@ +from typing import Any from typing import TYPE_CHECKING from aioredis.client import Redis @@ -14,12 +15,18 @@ if TYPE_CHECKING: from tunebot import PlaylistSource from tunebot import AutoJoin from utils.classes import BaseCog + from tunebot.plugins import ServiceEvent + +AnyDict = dict[Any, Any] class CustomContext(commands.Context): bot: "TuneBot" cog: "BaseCog" + async def dispatch(self, event: "ServiceEvent", payload: AnyDict): + await self.bot.plugin_manager.dispatch(event, payload) + @property def redis(self) -> Redis: return self.bot._redis_client diff --git a/plugins/lastfm_scrobbler/cog.py b/plugins/lastfm_scrobbler/cog.py new file mode 100644 index 0000000..d2ae76e --- /dev/null +++ b/plugins/lastfm_scrobbler/cog.py @@ -0,0 +1,46 @@ +from typing import TYPE_CHECKING + +from discord.ext import commands + +from context import CustomContext +from utils.classes import PluginCog + +if TYPE_CHECKING: + from bot import TuneBot + + +class LastFMScrobblerCog(PluginCog, name="LastFMScrobbler"): + def __init__(self, bot: "TuneBot") -> None: + super().__init__(bot) + self.plugin = self.get_plugin_instance("lastfm-scrobbler") + + @commands.group(name="lfm", invoke_without_command=True) + @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) + async def lfm(self, ctx: CustomContext): + """Enable/Disable LastFM Scrobbling""" + embed = ctx.create_embed() + embed.title = f"LastFM scrobbler usage:" + embed.description = f"```{ctx.prefix}lfm enable\n{ctx.prefix}lfm del```" + await ctx.send(embed=embed) + + @lfm.command(name="enable", aliases=["set"]) + async def lfm_aut(self, ctx: CustomContext): + """Enable LastFM Scrobbling""" + embed = ctx.create_embed() + embed.title = f"Start scrobbling" + auth_url = self.plugin.config["auth_url"] + "?user_id=" + str(ctx.author.id) + embed.description = f"[Login]({auth_url})" + await ctx.send(embed=embed) + + @lfm.command(name="delete", aliases=["del"]) + async def lfm_del(self, ctx: CustomContext): + """Disable LastFM Scrobbling""" + db_key = self.plugin.config["session_key_table"] + await self.bot.global_utils.raw_table_del_entry(db_key, [ctx.author.id]) + embed = ctx.create_embed() + embed.title = f"Stopped scrobbling" + await ctx.send(embed=embed) + + +def setup(bot: "TuneBot"): + bot.add_cog(LastFMScrobblerCog(bot)) diff --git a/plugins/lastfm_scrobbler/service.py b/plugins/lastfm_scrobbler/service.py new file mode 100644 index 0000000..c27c521 --- /dev/null +++ b/plugins/lastfm_scrobbler/service.py @@ -0,0 +1,84 @@ +import hashlib +import time +from dataclasses import dataclass +from typing import Any +from typing import TYPE_CHECKING + +import aiohttp +from lavalink.models import AudioTrack + +from tunebot.plugins import ServiceEvent + +if TYPE_CHECKING: + from bot import TuneBot + +AnyDict = dict[Any, Any] + + +@dataclass +class Track: + name: str + artist: str + + +class LastFMScrobbler: + api_url = "http://ws.audioscrobbler.com/2.0/" + + def __init__(self, bot: "TuneBot", config: AnyDict) -> None: + self.bot = bot + self.plug_conf = config + + async def on_dispatch(self, event: "ServiceEvent", payload: AnyDict): + if event == ServiceEvent.TRACK_ENDED: + _track: AudioTrack = payload.get("last_track", None) + if not _track: + print("LastFMScrobbler: Could not find required field `last_track`") + return + + # TODO: expand parsing options + artist, track_name = _track.title.split(" - ", 1) + track = Track(track_name, artist) + + in_voice = payload.get("in_voice", []) + for session_key in await self.fetch_lastfm_sessions(in_voice): + if not session_key: + continue + + await self.scrobble(session_key, track) + + async def fetch_lastfm_sessions(self, user_ids: list[int]) -> list[str]: + table_name = self.plug_conf["config"]["session_key_table"] + result: list[str] = await self.bot.global_utils.raw_table_lookup( + table_name, user_ids + ) + return result + + async def scrobble(self, session_key: str, track: Track): + params: AnyDict = { + "method": "track.scrobble", + "timestamp": str(int(time.time() - 30)), + "track": track.name, + "artist": track.artist, + "sk": session_key, + } + resp = await self.lastfm_request(params) + if resp.status != 200: + fmt = f"Failed to scrobble for user {session_key} on track: {track.artist} - {track.name}" + print(fmt) + + async def lastfm_request(self, params: AnyDict) -> aiohttp.ClientResponse: + params["api_key"] = self.plug_conf["config"]["lastfm_api_key"] + params = {key: params[key] for key in sorted(params)} + + secret = self.plug_conf["config"]["lastfm_api_secret"] + sig_str = "".join(key + params[key] for key in params.keys()) + secret + params["api_sig"] = hashlib.md5(sig_str.encode("utf8")).hexdigest() + + params["format"] = "json" + async with aiohttp.ClientSession() as sess: + async with sess.post(self.api_url, params=params) as resp: + return resp + + +def setup(bot: "TuneBot", config: AnyDict): + return LastFMScrobbler(bot, config) diff --git a/tunebot/abc.py b/tunebot/abc.py index b3b2d65..e603354 100644 --- a/tunebot/abc.py +++ b/tunebot/abc.py @@ -1,6 +1,16 @@ from abc import ABC from abc import abstractmethod +from typing import Any from typing import Optional +from typing import Protocol +from typing import TYPE_CHECKING +from typing import Union + +if TYPE_CHECKING: + from tunebot.plugins import ServiceEvent + from discord.ext.commands import Cog + +AnyDict = dict[Any, Any] class GlobalPlaylistSource(ABC): @@ -53,10 +63,53 @@ class AutoJoin(ABC): pass +class ServiceBase(Protocol): + async def on_dispatch(self, event: "ServiceEvent", payload: AnyDict): + ... + + +class PluginManagerBase(Protocol): + def get_plugin(self, name: str) -> Union["BasePluginInstance", None]: + ... + + def remove_plugin(self, name: str): + ... + + def enable_plugin(self, name: str, plugin: "BasePluginInstance"): + ... + + async def dispatch(self, event: "ServiceEvent", payload: AnyDict = {}): + ... + + +class PluginLoaderBase(Protocol): + def load_plugin(self, plug_conf: AnyDict) -> "BasePluginInstance": + ... + + +class GlobalUtils(Protocol): + async def raw_table_lookup(self, table_name: str, keys: list[Any]) -> list[Any]: + ... + + async def raw_table_del_entry(self, table_name: str, keys: list[Any]): + ... + + +class BasePluginInstance(Protocol): + config: AnyDict + services: list["ServiceBase"] + cogs: list[str] + + __all__ = ( "GlobalPlaylistSource", "PlaylistSource", "GlobalPlaylist", "GlobalAutoJoin", "AutoJoin", + "ServiceBase", + "PluginManagerBase", + "PluginLoaderBase", + "GlobalUtils", + "BasePluginInstance", ) diff --git a/tunebot/plugins/__init__.py b/tunebot/plugins/__init__.py new file mode 100644 index 0000000..ccaab8b --- /dev/null +++ b/tunebot/plugins/__init__.py @@ -0,0 +1,6 @@ +# noreorder +from tunebot.plugins.exceptions import * +from tunebot.plugins.events import * +from tunebot.plugins.plugin import * +from tunebot.plugins.loader import * +from tunebot.plugins.manager import * diff --git a/tunebot/plugins/events.py b/tunebot/plugins/events.py new file mode 100644 index 0000000..32e7290 --- /dev/null +++ b/tunebot/plugins/events.py @@ -0,0 +1,16 @@ +from enum import auto +from enum import Enum + + +class ServiceEvent(Enum): + """ + This Enum contains all possible events that can be dispatched to services + + Args: + Enum ([type]): [description] + """ + + TRACK_ENDED = auto() + + +__all__ = ("ServiceEvent",) diff --git a/tunebot/plugins/exceptions.py b/tunebot/plugins/exceptions.py new file mode 100644 index 0000000..f59d137 --- /dev/null +++ b/tunebot/plugins/exceptions.py @@ -0,0 +1,5 @@ +class PluginInitFailed(Exception): + pass + + +__all__ = ("PluginInitFailed",) diff --git a/tunebot/plugins/loader.py b/tunebot/plugins/loader.py new file mode 100644 index 0000000..dc6cab1 --- /dev/null +++ b/tunebot/plugins/loader.py @@ -0,0 +1,42 @@ +import importlib +from typing import Any +from typing import TYPE_CHECKING + +from tunebot.plugins import PluginInstance +from tunebot.plugins.exceptions import PluginInitFailed + +if TYPE_CHECKING: + from tunebot import BasePluginInstance + from tunebot import ServiceBase + from bot import TuneBot + +AnyDict = dict[Any, Any] + + +class FileSystemPluginLoader: + def __init__(self, bot: "TuneBot") -> None: + self.bot = bot + + def load_plugin(self, plug_conf: AnyDict) -> "BasePluginInstance": + """ + Loads a plugin from configuration and returns a sequence of Services + + Raises: + PluginInitFailed: [description] + + Returns: + tuple[list["ServiceBase"], list[str]]: [description] + """ + services: list["ServiceBase"] = [] + for service_location in plug_conf["services"]: + module = importlib.import_module(service_location) + + if not hasattr(module, "setup"): + raise PluginInitFailed('Failed to find setup() for "{location}"') + + services.append(module.setup(self.bot, plug_conf)) + + return PluginInstance(plug_conf["config"], services, plug_conf["cogs"]) + + +__all__ = ("FileSystemPluginLoader",) diff --git a/tunebot/plugins/manager.py b/tunebot/plugins/manager.py new file mode 100644 index 0000000..a22db38 --- /dev/null +++ b/tunebot/plugins/manager.py @@ -0,0 +1,79 @@ +from typing import Any +from typing import TYPE_CHECKING +from typing import Union + +from discord.ext.commands.errors import ExtensionAlreadyLoaded +from discord.ext.commands.errors import ExtensionFailed +from discord.ext.commands.errors import ExtensionNotFound +from discord.ext.commands.errors import NoEntryPointError + +if TYPE_CHECKING: + from tunebot.abc import BasePluginInstance + from tunebot.plugins import ServiceEvent + from bot import TuneBot + +AnyDict = dict[Any, Any] + + +class SimplePluginManager: + _plugins: dict[str, "BasePluginInstance"] = {} + + def __init__(self, bot: "TuneBot") -> None: + self.bot = bot + + def get_plugin(self, name: str) -> Union["BasePluginInstance", None]: + """ + Retrieves the corresponding `BasePluginInstance` if it exists + + Returns: + Union["BasePluginInstance", None]: [description] + """ + return self._plugins.get(name) + + def remove_plugin(self, name: str): + """ + Unloads/Removes all components related to the `BasePluginInstance` + + Args: + name (str): [description] + """ + plugin = self.get_plugin(name) + + if not plugin: + return + + for cog in plugin.cogs: + try: + self.bot.unload_extension(cog) + except Exception: + pass + + del self._plugins[name] + + def enable_plugin(self, name: str, plugin: "BasePluginInstance"): + """ + Loads/Activates all cogs/services included within the plugin + + Args: + plugin_name (str): [description] + services (list[): [description] + cog_names (list[str]): [description] + """ + self._plugins[name] = plugin + for cog_name in plugin.cogs: + self.bot.load_extension(cog_name) + + async def dispatch(self, event: "ServiceEvent", payload: AnyDict = {}): + """ + Dispatches an event to all registered services + + Args: + event (ServiceEvent): [description] + payload (AnyDict, optional): [description]. Defaults to {}. + """ + for plugin in self._plugins.values(): + for service in plugin.services: + await service.on_dispatch(event, payload) + + +__all__ = ("SimplePluginManager",) diff --git a/tunebot/plugins/plugin.py b/tunebot/plugins/plugin.py new file mode 100644 index 0000000..0073eb2 --- /dev/null +++ b/tunebot/plugins/plugin.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from typing import Any +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tunebot import ServiceBase + +AnyDict = dict[Any, Any] + + +@dataclass +class PluginInstance: + config: AnyDict + services: list["ServiceBase"] + cogs: list[str] + + +__all__ = ("PluginInstance",) diff --git a/tunebot/redis/__init__.py b/tunebot/redis/__init__.py index 1e4a501..753da7d 100644 --- a/tunebot/redis/__init__.py +++ b/tunebot/redis/__init__.py @@ -1,4 +1,6 @@ -from tunebot.redis.entity import * # noreorder +# noreorder +from tunebot.redis.entity import * +from tunebot.redis.utils import * from tunebot.redis.autojoin import * from tunebot.redis.playlist import * from tunebot.redis.playlist_source import * diff --git a/tunebot/redis/utils.py b/tunebot/redis/utils.py new file mode 100644 index 0000000..62e1b16 --- /dev/null +++ b/tunebot/redis/utils.py @@ -0,0 +1,18 @@ +from typing import Any + +from tunebot.redis import RedisBotEntity + + +class GlobalRedisUtils(RedisBotEntity): + async def raw_table_lookup(self, table_name: str, keys: list[Any]) -> list[Any]: + if len(keys) == 0: + return [] + + result: list[Any] = await self.redis.hmget(table_name, keys) + return result + + async def raw_table_del_entry(self, table_name: str, keys: list[Any]): + if len(keys) == 0: + return + + await self.redis.hdel(table_name, *keys) diff --git a/utils/classes.py b/utils/classes.py index 7da2ad3..bdf7435 100644 --- a/utils/classes.py +++ b/utils/classes.py @@ -1,13 +1,15 @@ -import asyncio from typing import Dict +from typing import TYPE_CHECKING from discord.ext.commands import Cog -from bot import TuneBot +if TYPE_CHECKING: + from tunebot import BasePluginInstance + from bot import TuneBot class BaseCog(Cog): - def __init__(self, bot: TuneBot) -> None: + def __init__(self, bot: "TuneBot") -> None: self.bot = bot slash_descriptions: Dict[str, str] = self.bot.config["slash_descriptions"] @@ -20,3 +22,11 @@ class BaseCog(Cog): hasattr(self.bot, "lavalink") and len(self.bot.lavalink.node_manager.available_nodes) > 0 ) + + +class PluginCog(BaseCog): + def get_plugin_instance(self, name: str) -> "BasePluginInstance": + if plugin := self.bot.plugin_manager.get_plugin(name): + return plugin + + raise KeyError(f"Failed to retrieve plugin instance with name: {name}")