Merge pull request #44 from strNophix/lastfm-scrobbler

Plugins + lastfm scrobbler
This commit is contained in:
2021-12-08 19:39:01 +01:00
committed by GitHub
16 changed files with 498 additions and 36 deletions
+61 -26
View File
@@ -22,51 +22,74 @@ from discord.ext.commands.errors import ExtensionNotFound
from discord.ext.commands.errors import NoEntryPointError from discord.ext.commands.errors import NoEntryPointError
from context import CustomContext from context import CustomContext
from tunebot.plugins import FileSystemPluginLoader
from tunebot.plugins import SimplePluginManager
from tunebot.redis import GlobalRedisAutoJoin from tunebot.redis import GlobalRedisAutoJoin
from tunebot.redis import GlobalRedisPlaylist from tunebot.redis import GlobalRedisPlaylist
from tunebot.redis import GlobalRedisPlaylistSource from tunebot.redis import GlobalRedisPlaylistSource
from tunebot.redis import GlobalRedisUtils
if TYPE_CHECKING: if TYPE_CHECKING:
from tunebot import PluginManagerBase
from tunebot import GlobalPlaylist from tunebot import GlobalPlaylist
from tunebot import GlobalPlaylistSource from tunebot import GlobalPlaylistSource
from tunebot import GlobalAutoJoin from tunebot import GlobalAutoJoin
from tunebot import PluginLoaderBase
from tunebot import PluginManagerBase
from tunebot import GlobalUtils
ColorDict = dict[str, "Color"]
class TuneBot(commands.Bot): class TuneBot(commands.Bot):
lavalink: lavalink.Client 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]): def __init__(self, config: Dict[Any, Any]):
intents = discord.Intents( 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.rpc_is_help_message = True
self.update_status.start() self.update_status.start()
self.config = config 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_prefix = self.config["redis_prefix"]
self._redis_client: Redis = aioredis.from_url( self._redis_client: Redis = aioredis.from_url(
self.config["redis_url"], encoding="utf-8", decode_responses=True self.config["redis_url"], encoding="utf-8", decode_responses=True
) )
self.global_autojoin: GlobalAutoJoin = GlobalRedisAutoJoin( self.global_autojoin = GlobalRedisAutoJoin(
self._redis_client, self._redis_client, self.redis_prefix
self.redis_prefix,
) )
self.global_playlist: GlobalPlaylist = GlobalRedisPlaylist( self.global_playlist = GlobalRedisPlaylist(
self._redis_client, self._redis_client, self.redis_prefix
self.redis_prefix,
) )
self.global_playlist_source: GlobalPlaylistSource = GlobalRedisPlaylistSource( self.global_playlist_source = GlobalRedisPlaylistSource(
self._redis_client, self._redis_client, self.redis_prefix
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 slash_guilds = None
if len(self.config["slash_command_guilds"]) > 0: if len(self.config["slash_command_guilds"]) > 0:
@@ -86,23 +109,24 @@ class TuneBot(commands.Bot):
self.loop.create_task(self.async_init()) self.loop.create_task(self.async_init())
async def async_init(self): 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]: async def prefix_callable(self, _, msg: Message) -> List[str]:
return commands.when_mentioned_or(*self.config["prefixes"])(self, msg) 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: for cog in cog_names:
try: try:
self.load_extension(cog) self.load_extension(cog)
print(f"Succesfully loaded extension {cog}.") print(f"[✓] loaded extension: {cog}.")
except ( except (
ExtensionNotFound, ExtensionNotFound,
ExtensionAlreadyLoaded, ExtensionAlreadyLoaded,
NoEntryPointError, NoEntryPointError,
ExtensionFailed, ExtensionFailed,
) as e: ) 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): 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" 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"] 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] = {} colour_dict: Dict[str, Color] = {}
for name, color in colors.items(): for name, color in colors.items():
colour_dict[name] = Color(int(color, 16)) colour_dict[name] = Color(int(color, 16))
@@ -126,6 +150,19 @@ class TuneBot(commands.Bot):
async def get_context(self, message: Message, *, cls=CustomContext): async def get_context(self, message: Message, *, cls=CustomContext):
return await super().get_context(message, cls=cls) 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) @tasks.loop(seconds=30)
async def update_status(self): async def update_status(self):
await self.wait_until_ready() await self.wait_until_ready()
@@ -141,13 +178,6 @@ class TuneBot(commands.Bot):
await self.change_presence(activity=activity) 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__": if __name__ == "__main__":
try: try:
import uvloop import uvloop
@@ -157,5 +187,10 @@ if __name__ == "__main__":
except ModuleNotFoundError: except ModuleNotFoundError:
pass 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") token = config.pop("token")
TuneBot(config).run(token, reconnect=True) TuneBot(config).run(token, reconnect=True)
+30 -2
View File
@@ -1,24 +1,29 @@
import asyncio import asyncio
import datetime import datetime
import re import re
from typing import Any
from typing import Optional from typing import Optional
from typing import TYPE_CHECKING
import discord import discord
import lavalink import lavalink
from discord import Embed from discord import Embed
from discord.channel import TextChannel from discord.channel import TextChannel
from discord.ext import commands from discord.ext import commands
from discord.ext.commands.context import Context
from discord.ext.commands.errors import CommandError from discord.ext.commands.errors import CommandError
from lavalink.models import AudioTrack from lavalink.models import AudioTrack
from lavalink.models import DefaultPlayer from lavalink.models import DefaultPlayer
from bot import TuneBot from bot import TuneBot
from context import CustomContext from context import CustomContext
from tunebot.plugins import ServiceEvent
from utils.classes import BaseCog from utils.classes import BaseCog
from utils.EmbedGenerator import EmbedGenerator from utils.EmbedGenerator import EmbedGenerator
from utils.exceptions import EmbeddedCommandException from utils.exceptions import EmbeddedCommandException
if TYPE_CHECKING:
from discord import VoiceChannel
url_rx = re.compile(r"https?://(?:www\.)?.+") url_rx = re.compile(r"https?://(?:www\.)?.+")
@@ -217,13 +222,36 @@ class Music(BaseCog):
guild = self.bot.get_guild(guild_id) guild = self.bot.get_guild(guild_id)
await guild.voice_client.disconnect(force=True) await guild.voice_client.disconnect(force=True)
elif isinstance(event, lavalink.events.TrackStartEvent): elif isinstance(event, lavalink.events.TrackStartEvent):
channel_id = int(event.player.fetch("channel")) if channel := event.player.fetch("channel"):
channel_id = int(channel)
channel: TextChannel = self.bot.get_channel(channel_id) channel: TextChannel = self.bot.get_channel(channel_id)
embed = await self.create_track_embed(event.player.current) embed = await self.create_track_embed(event.player.current)
await channel.send(embed=embed) await channel.send(embed=embed)
elif isinstance(event, lavalink.events.TrackEndEvent): elif isinstance(event, lavalink.events.TrackEndEvent):
await self.fill_player_queue(event.player, 1) 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"]) @commands.command(name="connect", aliases=["p", "play", "join"])
async def play(self, ctx: CustomContext): async def play(self, ctx: CustomContext):
"""Start the radio""" """Start the radio"""
+14 -1
View File
@@ -22,5 +22,18 @@
"cogs": ["cogs.owner", "cogs.settings", "cogs.information", "cogs.music"], "cogs": ["cogs.owner", "cogs.settings", "cogs.information", "cogs.music"],
"slash_command_guilds": [], "slash_command_guilds": [],
"queue_buffer_size": 5, "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
}
}
} }
+7
View File
@@ -1,3 +1,4 @@
from typing import Any
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from aioredis.client import Redis from aioredis.client import Redis
@@ -14,12 +15,18 @@ if TYPE_CHECKING:
from tunebot import PlaylistSource from tunebot import PlaylistSource
from tunebot import AutoJoin from tunebot import AutoJoin
from utils.classes import BaseCog from utils.classes import BaseCog
from tunebot.plugins import ServiceEvent
AnyDict = dict[Any, Any]
class CustomContext(commands.Context): class CustomContext(commands.Context):
bot: "TuneBot" bot: "TuneBot"
cog: "BaseCog" cog: "BaseCog"
async def dispatch(self, event: "ServiceEvent", payload: AnyDict):
await self.bot.plugin_manager.dispatch(event, payload)
@property @property
def redis(self) -> Redis: def redis(self) -> Redis:
return self.bot._redis_client return self.bot._redis_client
+46
View File
@@ -0,0 +1,46 @@
from typing import TYPE_CHECKING
from discord.ext import commands
from context import CustomContext
from utils.classes import PluginCog
if TYPE_CHECKING:
from bot import TuneBot
class LastFMScrobblerCog(PluginCog, name="LastFMScrobbler"):
def __init__(self, bot: "TuneBot") -> None:
super().__init__(bot)
self.plugin = self.get_plugin_instance("lastfm-scrobbler")
@commands.group(name="lfm", invoke_without_command=True)
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
async def lfm(self, ctx: CustomContext):
"""Enable/Disable LastFM Scrobbling"""
embed = ctx.create_embed()
embed.title = f"LastFM scrobbler usage:"
embed.description = f"```{ctx.prefix}lfm enable\n{ctx.prefix}lfm del```"
await ctx.send(embed=embed)
@lfm.command(name="enable", aliases=["set"])
async def lfm_aut(self, ctx: CustomContext):
"""Enable LastFM Scrobbling"""
embed = ctx.create_embed()
embed.title = f"Start scrobbling"
auth_url = self.plugin.config["auth_url"] + "?user_id=" + str(ctx.author.id)
embed.description = f"[Login]({auth_url})"
await ctx.send(embed=embed)
@lfm.command(name="delete", aliases=["del"])
async def lfm_del(self, ctx: CustomContext):
"""Disable LastFM Scrobbling"""
db_key = self.plugin.config["session_key_table"]
await self.bot.global_utils.raw_table_del_entry(db_key, [ctx.author.id])
embed = ctx.create_embed()
embed.title = f"Stopped scrobbling"
await ctx.send(embed=embed)
def setup(bot: "TuneBot"):
bot.add_cog(LastFMScrobblerCog(bot))
+84
View File
@@ -0,0 +1,84 @@
import hashlib
import time
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING
import aiohttp
from lavalink.models import AudioTrack
from tunebot.plugins import ServiceEvent
if TYPE_CHECKING:
from bot import TuneBot
AnyDict = dict[Any, Any]
@dataclass
class Track:
name: str
artist: str
class LastFMScrobbler:
api_url = "http://ws.audioscrobbler.com/2.0/"
def __init__(self, bot: "TuneBot", config: AnyDict) -> None:
self.bot = bot
self.plug_conf = config
async def on_dispatch(self, event: "ServiceEvent", payload: AnyDict):
if event == ServiceEvent.TRACK_ENDED:
_track: AudioTrack = payload.get("last_track", None)
if not _track:
print("LastFMScrobbler: Could not find required field `last_track`")
return
# TODO: expand parsing options
artist, track_name = _track.title.split(" - ", 1)
track = Track(track_name, artist)
in_voice = payload.get("in_voice", [])
for session_key in await self.fetch_lastfm_sessions(in_voice):
if not session_key:
continue
await self.scrobble(session_key, track)
async def fetch_lastfm_sessions(self, user_ids: list[int]) -> list[str]:
table_name = self.plug_conf["config"]["session_key_table"]
result: list[str] = await self.bot.global_utils.raw_table_lookup(
table_name, user_ids
)
return result
async def scrobble(self, session_key: str, track: Track):
params: AnyDict = {
"method": "track.scrobble",
"timestamp": str(int(time.time() - 30)),
"track": track.name,
"artist": track.artist,
"sk": session_key,
}
resp = await self.lastfm_request(params)
if resp.status != 200:
fmt = f"Failed to scrobble for user {session_key} on track: {track.artist} - {track.name}"
print(fmt)
async def lastfm_request(self, params: AnyDict) -> aiohttp.ClientResponse:
params["api_key"] = self.plug_conf["config"]["lastfm_api_key"]
params = {key: params[key] for key in sorted(params)}
secret = self.plug_conf["config"]["lastfm_api_secret"]
sig_str = "".join(key + params[key] for key in params.keys()) + secret
params["api_sig"] = hashlib.md5(sig_str.encode("utf8")).hexdigest()
params["format"] = "json"
async with aiohttp.ClientSession() as sess:
async with sess.post(self.api_url, params=params) as resp:
return resp
def setup(bot: "TuneBot", config: AnyDict):
return LastFMScrobbler(bot, config)
+53
View File
@@ -1,6 +1,16 @@
from abc import ABC from abc import ABC
from abc import abstractmethod from abc import abstractmethod
from typing import Any
from typing import Optional 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): class GlobalPlaylistSource(ABC):
@@ -53,10 +63,53 @@ class AutoJoin(ABC):
pass 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__ = ( __all__ = (
"GlobalPlaylistSource", "GlobalPlaylistSource",
"PlaylistSource", "PlaylistSource",
"GlobalPlaylist", "GlobalPlaylist",
"GlobalAutoJoin", "GlobalAutoJoin",
"AutoJoin", "AutoJoin",
"ServiceBase",
"PluginManagerBase",
"PluginLoaderBase",
"GlobalUtils",
"BasePluginInstance",
) )
+6
View File
@@ -0,0 +1,6 @@
# noreorder
from tunebot.plugins.exceptions import *
from tunebot.plugins.events import *
from tunebot.plugins.plugin import *
from tunebot.plugins.loader import *
from tunebot.plugins.manager import *
+16
View File
@@ -0,0 +1,16 @@
from enum import auto
from enum import Enum
class ServiceEvent(Enum):
"""
This Enum contains all possible events that can be dispatched to services
Args:
Enum ([type]): [description]
"""
TRACK_ENDED = auto()
__all__ = ("ServiceEvent",)
+5
View File
@@ -0,0 +1,5 @@
class PluginInitFailed(Exception):
pass
__all__ = ("PluginInitFailed",)
+42
View File
@@ -0,0 +1,42 @@
import importlib
from typing import Any
from typing import TYPE_CHECKING
from tunebot.plugins import PluginInstance
from tunebot.plugins.exceptions import PluginInitFailed
if TYPE_CHECKING:
from tunebot import BasePluginInstance
from tunebot import ServiceBase
from bot import TuneBot
AnyDict = dict[Any, Any]
class FileSystemPluginLoader:
def __init__(self, bot: "TuneBot") -> None:
self.bot = bot
def load_plugin(self, plug_conf: AnyDict) -> "BasePluginInstance":
"""
Loads a plugin from configuration and returns a sequence of Services
Raises:
PluginInitFailed: [description]
Returns:
tuple[list["ServiceBase"], list[str]]: [description]
"""
services: list["ServiceBase"] = []
for service_location in plug_conf["services"]:
module = importlib.import_module(service_location)
if not hasattr(module, "setup"):
raise PluginInitFailed('Failed to find setup() for "{location}"')
services.append(module.setup(self.bot, plug_conf))
return PluginInstance(plug_conf["config"], services, plug_conf["cogs"])
__all__ = ("FileSystemPluginLoader",)
+79
View File
@@ -0,0 +1,79 @@
from typing import Any
from typing import TYPE_CHECKING
from typing import Union
from discord.ext.commands.errors import ExtensionAlreadyLoaded
from discord.ext.commands.errors import ExtensionFailed
from discord.ext.commands.errors import ExtensionNotFound
from discord.ext.commands.errors import NoEntryPointError
if TYPE_CHECKING:
from tunebot.abc import BasePluginInstance
from tunebot.plugins import ServiceEvent
from bot import TuneBot
AnyDict = dict[Any, Any]
class SimplePluginManager:
_plugins: dict[str, "BasePluginInstance"] = {}
def __init__(self, bot: "TuneBot") -> None:
self.bot = bot
def get_plugin(self, name: str) -> Union["BasePluginInstance", None]:
"""
Retrieves the corresponding `BasePluginInstance` if it exists
Returns:
Union["BasePluginInstance", None]: [description]
"""
return self._plugins.get(name)
def remove_plugin(self, name: str):
"""
Unloads/Removes all components related to the `BasePluginInstance`
Args:
name (str): [description]
"""
plugin = self.get_plugin(name)
if not plugin:
return
for cog in plugin.cogs:
try:
self.bot.unload_extension(cog)
except Exception:
pass
del self._plugins[name]
def enable_plugin(self, name: str, plugin: "BasePluginInstance"):
"""
Loads/Activates all cogs/services included within the plugin
Args:
plugin_name (str): [description]
services (list[): [description]
cog_names (list[str]): [description]
"""
self._plugins[name] = plugin
for cog_name in plugin.cogs:
self.bot.load_extension(cog_name)
async def dispatch(self, event: "ServiceEvent", payload: AnyDict = {}):
"""
Dispatches an event to all registered services
Args:
event (ServiceEvent): [description]
payload (AnyDict, optional): [description]. Defaults to {}.
"""
for plugin in self._plugins.values():
for service in plugin.services:
await service.on_dispatch(event, payload)
__all__ = ("SimplePluginManager",)
+18
View File
@@ -0,0 +1,18 @@
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from tunebot import ServiceBase
AnyDict = dict[Any, Any]
@dataclass
class PluginInstance:
config: AnyDict
services: list["ServiceBase"]
cogs: list[str]
__all__ = ("PluginInstance",)
+3 -1
View File
@@ -1,4 +1,6 @@
from tunebot.redis.entity import * # noreorder # noreorder
from tunebot.redis.entity import *
from tunebot.redis.utils import *
from tunebot.redis.autojoin import * from tunebot.redis.autojoin import *
from tunebot.redis.playlist import * from tunebot.redis.playlist import *
from tunebot.redis.playlist_source import * from tunebot.redis.playlist_source import *
+18
View File
@@ -0,0 +1,18 @@
from typing import Any
from tunebot.redis import RedisBotEntity
class GlobalRedisUtils(RedisBotEntity):
async def raw_table_lookup(self, table_name: str, keys: list[Any]) -> list[Any]:
if len(keys) == 0:
return []
result: list[Any] = await self.redis.hmget(table_name, keys)
return result
async def raw_table_del_entry(self, table_name: str, keys: list[Any]):
if len(keys) == 0:
return
await self.redis.hdel(table_name, *keys)
+12 -2
View File
@@ -1,13 +1,15 @@
import asyncio
from typing import Dict from typing import Dict
from typing import TYPE_CHECKING
from discord.ext.commands import Cog from discord.ext.commands import Cog
if TYPE_CHECKING:
from tunebot import BasePluginInstance
from bot import TuneBot from bot import TuneBot
class BaseCog(Cog): class BaseCog(Cog):
def __init__(self, bot: TuneBot) -> None: def __init__(self, bot: "TuneBot") -> None:
self.bot = bot self.bot = bot
slash_descriptions: Dict[str, str] = self.bot.config["slash_descriptions"] slash_descriptions: Dict[str, str] = self.bot.config["slash_descriptions"]
@@ -20,3 +22,11 @@ class BaseCog(Cog):
hasattr(self.bot, "lavalink") hasattr(self.bot, "lavalink")
and len(self.bot.lavalink.node_manager.available_nodes) > 0 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}")