mirror of
https://github.com/Matthww/TuneBot.git
synced 2026-09-22 02:27:43 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd0b04bf28 | ||
|
|
935383e7c5 | ||
|
|
fdc9dc16ed | ||
|
|
0dc2e0a7ca | ||
|
|
2a24a4b5f0 | ||
|
|
0a14d9a93c | ||
|
|
76bfd719c3 | ||
|
|
c45bd8ced3 | ||
|
|
b37c3f533a | ||
|
|
9c55bcdc36 | ||
|
|
1495d214a1 | ||
|
|
4f65522bd8 | ||
|
|
3e6ee9ecc9 | ||
|
|
8504d6abfd | ||
|
|
d2ecbbf6ae | ||
|
|
adb3fe11a6 | ||
|
|
ebb07960bf |
@@ -4,6 +4,7 @@ from typing import Any
|
|||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import aioredis
|
import aioredis
|
||||||
import discord
|
import discord
|
||||||
@@ -21,28 +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 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):
|
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_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.invite_link: str = ""
|
|
||||||
|
self.global_autojoin = GlobalRedisAutoJoin(
|
||||||
|
self._redis_client, self.redis_prefix
|
||||||
|
)
|
||||||
|
self.global_playlist = GlobalRedisPlaylist(
|
||||||
|
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.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:
|
||||||
@@ -62,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"
|
||||||
@@ -87,7 +135,13 @@ class TuneBot(commands.Bot):
|
|||||||
print(f"Version: {discord.__version__}")
|
print(f"Version: {discord.__version__}")
|
||||||
print(f"Invite: {self.invite_link}")
|
print(f"Invite: {self.invite_link}")
|
||||||
|
|
||||||
def process_colours(self, colors: Dict[str, str]) -> Dict[str, Color]:
|
ll = self.config["lavalink"]
|
||||||
|
self.lavalink = lavalink.Client(self.user.id)
|
||||||
|
self.lavalink.add_node(
|
||||||
|
ll["host"], ll["port"], ll["password"], ll["region"], ll["name"]
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
@@ -96,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()
|
||||||
@@ -111,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
|
||||||
@@ -127,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)
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import discord
|
|||||||
import humanize
|
import humanize
|
||||||
import lavalink
|
import lavalink
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from discord.ext import tasks
|
|
||||||
from discord.ext.commands import Context
|
from discord.ext.commands import Context
|
||||||
|
|
||||||
from bot import TuneBot
|
from bot import TuneBot
|
||||||
from context import CustomContext
|
from context import CustomContext
|
||||||
from utils.classes import BaseCog
|
from utils.classes import BaseCog
|
||||||
from utils.database import AutoJoin
|
|
||||||
from utils.EmbedGenerator import EmbedGenerator
|
from utils.EmbedGenerator import EmbedGenerator
|
||||||
from utils.paginator import HelpPaginator
|
from utils.paginator import HelpPaginator
|
||||||
|
|
||||||
@@ -72,7 +70,6 @@ class InformationCog(BaseCog, name="Information"):
|
|||||||
# f"Server Uptime: `{datetime.timedelta(milliseconds=node.stats.uptime)}`"
|
# f"Server Uptime: `{datetime.timedelta(milliseconds=node.stats.uptime)}`"
|
||||||
)
|
)
|
||||||
await ctx.send(fmt)
|
await ctx.send(fmt)
|
||||||
AutoJoin.get_channels()
|
|
||||||
|
|
||||||
@commands.command(name="help", aliases=["about", "info"])
|
@commands.command(name="help", aliases=["about", "info"])
|
||||||
@commands.cooldown(1, 1, commands.BucketType.user)
|
@commands.cooldown(1, 1, commands.BucketType.user)
|
||||||
|
|||||||
+41
-24
@@ -1,26 +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.database import AutoJoin
|
|
||||||
from utils.database import Playlist
|
|
||||||
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\.)?.+")
|
||||||
|
|
||||||
|
|
||||||
@@ -76,25 +79,11 @@ class LavalinkVoiceClient(discord.VoiceClient):
|
|||||||
class Music(BaseCog):
|
class Music(BaseCog):
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_ready(self):
|
async def on_ready(self):
|
||||||
if not hasattr(
|
while not self.is_lavalink_ready():
|
||||||
self.bot, "lavalink"
|
|
||||||
): # This ensures the client isn't overwritten during cog reloads.
|
|
||||||
self.bot.lavalink = lavalink.Client(self.bot.user.id)
|
|
||||||
|
|
||||||
ll = self.bot.config["lavalink"]
|
|
||||||
self.bot.lavalink.add_node(
|
|
||||||
ll["host"], ll["port"], ll["password"], ll["region"], ll["name"]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.bot.lavalink.add_event_hook(self.track_hook)
|
|
||||||
await self.async_init()
|
|
||||||
|
|
||||||
async def async_init(self):
|
|
||||||
redis_result = await AutoJoin.get_channels(self.bot._redis_client)
|
|
||||||
|
|
||||||
while len(self.bot.lavalink.node_manager.available_nodes) == 0:
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
self.bot.lavalink.add_event_hook(self.track_hook)
|
||||||
|
redis_result = await self.bot.global_autojoin.fetch_channels()
|
||||||
for guild_id, (voicechannel_id, textchannel_id) in redis_result.items():
|
for guild_id, (voicechannel_id, textchannel_id) in redis_result.items():
|
||||||
player = self.bot.lavalink.player_manager.create(guild_id)
|
player = self.bot.lavalink.player_manager.create(guild_id)
|
||||||
player.store("channel", textchannel_id)
|
player.store("channel", textchannel_id)
|
||||||
@@ -110,11 +99,12 @@ class Music(BaseCog):
|
|||||||
await textchannel.send("Automatically joined the voice channel")
|
await textchannel.send("Automatically joined the voice channel")
|
||||||
|
|
||||||
async def fill_player_queue(self, player: DefaultPlayer, buffer: Optional[int] = 1):
|
async def fill_player_queue(self, player: DefaultPlayer, buffer: Optional[int] = 1):
|
||||||
queries = await Playlist.random(self.bot._redis_client, buffer)
|
queries = await self.bot.global_playlist.pick_random(buffer)
|
||||||
# Get the results for the query from Lavalink.
|
failed_queries: list[str] = []
|
||||||
for query in queries:
|
for query in queries:
|
||||||
result = await player.node.get_tracks(query)
|
result = await player.node.get_tracks(query)
|
||||||
if not result or not result["tracks"]:
|
if not result or not result["tracks"]:
|
||||||
|
failed_queries.append(query)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
track = lavalink.models.AudioTrack(
|
track = lavalink.models.AudioTrack(
|
||||||
@@ -122,6 +112,10 @@ class Music(BaseCog):
|
|||||||
)
|
)
|
||||||
player.add(requester=self.bot.user.id, track=track)
|
player.add(requester=self.bot.user.id, track=track)
|
||||||
|
|
||||||
|
if len(failed_queries) > 0:
|
||||||
|
await self.bot.global_playlist.remove_tracks(failed_queries)
|
||||||
|
await self.fill_player_queue(player, len(failed_queries))
|
||||||
|
|
||||||
async def create_track_embed(self, track: AudioTrack) -> Embed:
|
async def create_track_embed(self, track: AudioTrack) -> Embed:
|
||||||
embed_color = self.bot.colors["embed"]
|
embed_color = self.bot.colors["embed"]
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
@@ -153,7 +147,7 @@ class Music(BaseCog):
|
|||||||
# This is essentially the same as `@commands.guild_only()`
|
# This is essentially the same as `@commands.guild_only()`
|
||||||
# except it saves us repeating ourselves (and also a few lines).
|
# except it saves us repeating ourselves (and also a few lines).
|
||||||
|
|
||||||
if not hasattr(self.bot, "lavalink"):
|
if not self.is_lavalink_ready():
|
||||||
await ctx.send("Still starting please wait a moment.")
|
await ctx.send("Still starting please wait a moment.")
|
||||||
|
|
||||||
if guild_check:
|
if guild_check:
|
||||||
@@ -228,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"""
|
||||||
|
|||||||
+31
-26
@@ -6,9 +6,7 @@ from discord.message import Message
|
|||||||
from bot import TuneBot
|
from bot import TuneBot
|
||||||
from context import CustomContext
|
from context import CustomContext
|
||||||
from utils.classes import BaseCog
|
from utils.classes import BaseCog
|
||||||
from utils.database import AutoJoin
|
from utils.decorators import source_manager_only
|
||||||
from utils.database import Playlist
|
|
||||||
from utils.database import PlaylistSource
|
|
||||||
from utils.EmbedGenerator import EmbedGenerator
|
from utils.EmbedGenerator import EmbedGenerator
|
||||||
|
|
||||||
|
|
||||||
@@ -20,30 +18,37 @@ class SettingsCog(BaseCog, name="Settings"):
|
|||||||
await EmbedGenerator.Message(
|
await EmbedGenerator.Message(
|
||||||
ctx,
|
ctx,
|
||||||
"Autojoin",
|
"Autojoin",
|
||||||
f"Usage:\n\n`{ctx.prefix}autojoin set`\n`{ctx.prefix}autojoin unset`",
|
f"Usage:\n\n`{ctx.prefix}autojoin enable`\n`{ctx.prefix}autojoin disable`",
|
||||||
)
|
)
|
||||||
|
|
||||||
@autojoin.command(name="enable")
|
@autojoin.command(name="enable", aliases=["set"])
|
||||||
@commands.has_permissions(manage_channels=True)
|
@commands.has_permissions(manage_channels=True)
|
||||||
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
|
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
|
||||||
async def autojoin_set(self, ctx: CustomContext):
|
async def autojoin_set(self, ctx: CustomContext):
|
||||||
"""Enable the bot automatically joining"""
|
"""Enable the bot automatically joining"""
|
||||||
voicechannel_id = ctx.author.voice.channel.id
|
voice_state = ctx.author.voice
|
||||||
textchannel_id = ctx.message.channel.id
|
if not voice_state:
|
||||||
await AutoJoin.update_channel(
|
embed = ctx.create_embed()
|
||||||
ctx.redis, ctx.guild.id, voicechannel_id, textchannel_id
|
embed.title = "Please join a voice channel before running this command."
|
||||||
)
|
await ctx.send(embed=embed)
|
||||||
await EmbedGenerator.Message(ctx, "Autojoin", "`enabled`")
|
return
|
||||||
|
|
||||||
@autojoin.command(name="disable")
|
await ctx.autojoin.update(voice_state.channel.id, ctx.message.channel.id)
|
||||||
|
embed = ctx.create_embed()
|
||||||
|
embed.title = f"AutoJoin enabled for #{voice_state.channel.name}"
|
||||||
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
|
@autojoin.command(name="disable", aliases=["unset"])
|
||||||
@commands.has_permissions(manage_channels=True)
|
@commands.has_permissions(manage_channels=True)
|
||||||
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
|
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
|
||||||
async def autojoin_del(self, ctx: CustomContext):
|
async def autojoin_del(self, ctx: CustomContext):
|
||||||
"""Disable the bot automatically joining"""
|
"""Disable the bot automatically joining"""
|
||||||
await AutoJoin.del_channel(ctx.redis, ctx.guild.id)
|
await ctx.autojoin.disable()
|
||||||
await EmbedGenerator.Message(ctx, "Autojoin", "`disabled`")
|
embed = ctx.create_embed()
|
||||||
|
embed.title = f"AutoJoin disabled"
|
||||||
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
@commands.is_owner()
|
@source_manager_only()
|
||||||
@commands.group(
|
@commands.group(
|
||||||
name="source",
|
name="source",
|
||||||
aliases=["src"],
|
aliases=["src"],
|
||||||
@@ -59,11 +64,11 @@ class SettingsCog(BaseCog, name="Settings"):
|
|||||||
embed.description = f"```{prefix}source list\n{prefix}source add <url>\n{prefix}source remove <url>\n{prefix}source sync```"
|
embed.description = f"```{prefix}source list\n{prefix}source add <url>\n{prefix}source remove <url>\n{prefix}source sync```"
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
@commands.is_owner()
|
@source_manager_only()
|
||||||
@source.command(name="remove")
|
@source.command(name="remove")
|
||||||
async def source_remove(self, ctx: CustomContext, source_url: str):
|
async def source_remove(self, ctx: CustomContext, source_url: str):
|
||||||
"""Removes a source from the bot"""
|
"""Removes a source from the bot"""
|
||||||
if await PlaylistSource.remove(ctx.redis, source_url):
|
if await ctx.playlist_source.remove(source_url):
|
||||||
prefix = self.bot.config["prefixes"][0]
|
prefix = self.bot.config["prefixes"][0]
|
||||||
embed = ctx.create_embed()
|
embed = ctx.create_embed()
|
||||||
embed.title = "Removed source succesfully"
|
embed.title = "Removed source succesfully"
|
||||||
@@ -77,7 +82,7 @@ class SettingsCog(BaseCog, name="Settings"):
|
|||||||
embed.title = "Could not remove source, the specified source might not exist"
|
embed.title = "Could not remove source, the specified source might not exist"
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
@commands.is_owner()
|
@source_manager_only()
|
||||||
@source.command(name="add")
|
@source.command(name="add")
|
||||||
async def source_add(self, ctx: CustomContext, source_url: str):
|
async def source_add(self, ctx: CustomContext, source_url: str):
|
||||||
"""
|
"""
|
||||||
@@ -101,9 +106,9 @@ class SettingsCog(BaseCog, name="Settings"):
|
|||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
return
|
return
|
||||||
|
|
||||||
await PlaylistSource.add(ctx.redis, source_url)
|
await ctx.playlist_source.add(source_url)
|
||||||
track_urls = [str(track["info"]["uri"]) for track in query_result["tracks"]]
|
track_urls = [str(track["info"]["uri"]) for track in query_result["tracks"]]
|
||||||
await Playlist.add_bulk(ctx.redis, track_urls)
|
await self.bot.global_playlist.add_tracks(track_urls)
|
||||||
|
|
||||||
embed = ctx.create_embed()
|
embed = ctx.create_embed()
|
||||||
embed.title = "Finished processing source"
|
embed.title = "Finished processing source"
|
||||||
@@ -113,12 +118,12 @@ class SettingsCog(BaseCog, name="Settings"):
|
|||||||
else:
|
else:
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
@commands.is_owner()
|
@source_manager_only()
|
||||||
@source.command(name="list", aliases=["ls"])
|
@source.command(name="list", aliases=["ls"])
|
||||||
async def source_list(self, ctx: CustomContext):
|
async def source_list(self, ctx: CustomContext):
|
||||||
"""Display a list of sources"""
|
"""Display a list of sources"""
|
||||||
# TODO: Implement pagination for sources
|
# TODO: Implement pagination for sources
|
||||||
sources = await PlaylistSource.get_all(ctx.redis)
|
sources = await self.bot.global_playlist_source.fetch_sources()
|
||||||
if len(sources) > 0:
|
if len(sources) > 0:
|
||||||
description = "\n".join([f"[{source}]({source})" for source in sources])
|
description = "\n".join([f"[{source}]({source})" for source in sources])
|
||||||
else:
|
else:
|
||||||
@@ -129,13 +134,13 @@ class SettingsCog(BaseCog, name="Settings"):
|
|||||||
embed.description = description
|
embed.description = description
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
@commands.is_owner()
|
@source_manager_only()
|
||||||
@source.command(name="sync")
|
@source.command(name="sync")
|
||||||
async def source_sync(self, ctx: CustomContext):
|
async def source_sync(self, ctx: CustomContext):
|
||||||
"""Forcefully resyncs all sources"""
|
"""Forcefully resyncs all sources"""
|
||||||
failed_sources: list[str] = []
|
failed_sources: list[str] = []
|
||||||
await Playlist.clear(ctx.redis)
|
await self.bot.global_playlist.clear()
|
||||||
sources = await PlaylistSource.get_all(ctx.redis)
|
sources = await self.bot.global_playlist_source.fetch_sources()
|
||||||
for source_url in sources:
|
for source_url in sources:
|
||||||
query_result: Any = await self.bot.lavalink.get_tracks(source_url)
|
query_result: Any = await self.bot.lavalink.get_tracks(source_url)
|
||||||
if query_result["loadType"] == "LOAD_FAILED":
|
if query_result["loadType"] == "LOAD_FAILED":
|
||||||
@@ -143,7 +148,7 @@ class SettingsCog(BaseCog, name="Settings"):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
track_urls = [str(track["info"]["uri"]) for track in query_result["tracks"]]
|
track_urls = [str(track["info"]["uri"]) for track in query_result["tracks"]]
|
||||||
await Playlist.add_bulk(ctx.redis, track_urls)
|
await self.bot.global_playlist.add_tracks(track_urls)
|
||||||
|
|
||||||
embed = ctx.create_embed()
|
embed = ctx.create_embed()
|
||||||
embed.title = f"Finished sync ({len(failed_sources)} issues)"
|
embed.title = f"Finished sync ({len(failed_sources)} issues)"
|
||||||
|
|||||||
+15
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"token": "",
|
"token": "",
|
||||||
"owner_ids": [194545408960102400, 190875175460405249],
|
"owner_ids": [194545408960102400, 190875175460405249],
|
||||||
|
"manager_ids": [],
|
||||||
"prefixes": ["ck!"],
|
"prefixes": ["ck!"],
|
||||||
"redis_url": "",
|
"redis_url": "",
|
||||||
"redis_prefix": "",
|
"redis_prefix": "",
|
||||||
@@ -21,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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-1
@@ -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
|
||||||
@@ -6,18 +7,33 @@ from discord.ext import commands
|
|||||||
from discord.ext.commands.errors import CommandInvokeError
|
from discord.ext.commands.errors import CommandInvokeError
|
||||||
from lavalink.models import DefaultPlayer
|
from lavalink.models import DefaultPlayer
|
||||||
|
|
||||||
|
from tunebot.redis import RedisAutoJoin
|
||||||
|
from tunebot.redis import RedisPlaylistSource
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bot import TuneBot
|
from bot import TuneBot
|
||||||
|
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):
|
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
|
@property
|
||||||
def redis(self) -> Redis:
|
def redis(self) -> Redis:
|
||||||
return self.bot._redis_client
|
return self.bot._redis_client
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def player(self) -> DefaultPlayer:
|
def player(self) -> DefaultPlayer:
|
||||||
if hasattr(self.bot, "lavalink"):
|
if self.cog.is_lavalink_ready():
|
||||||
return self.bot.lavalink.player_manager.get(self.guild.id)
|
return self.bot.lavalink.player_manager.get(self.guild.id)
|
||||||
|
|
||||||
raise CommandInvokeError("Lavalink is still starting up.")
|
raise CommandInvokeError("Lavalink is still starting up.")
|
||||||
@@ -33,3 +49,15 @@ class CustomContext(commands.Context):
|
|||||||
embed = Embed(color=color)
|
embed = Embed(color=color)
|
||||||
embed.set_footer(text=f"Requested by: {self.author}", icon_url=avatar)
|
embed.set_footer(text=f"Requested by: {self.author}", icon_url=avatar)
|
||||||
return embed
|
return embed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playlist_source(self) -> "PlaylistSource":
|
||||||
|
if not hasattr(self, "_playlist_source"):
|
||||||
|
self._playlist_source = RedisPlaylistSource(self)
|
||||||
|
return self._playlist_source
|
||||||
|
|
||||||
|
@property
|
||||||
|
def autojoin(self) -> "AutoJoin":
|
||||||
|
if not hasattr(self, "_autojoin"):
|
||||||
|
self._autojoin = RedisAutoJoin(self)
|
||||||
|
return self._autojoin
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from tunebot.abc import *
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
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):
|
||||||
|
@abstractmethod
|
||||||
|
async def fetch_sources(self) -> set[str]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistSource(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def add(self, source_url: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def remove(self, source_url: str) -> bool:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalPlaylist(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def pick_random(self, amount: Optional[int] = 1) -> set[str]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def add_tracks(self, urls: list[str]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def clear(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def remove_tracks(self, track_urls: list[str]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalAutoJoin(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def fetch_channels(self) -> dict[str, list[str]]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AutoJoin(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def update(self, voice_channel_id: int, text_channel_id: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def disable(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
@@ -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 *
|
||||||
@@ -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",)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class PluginInitFailed(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("PluginInitFailed",)
|
||||||
@@ -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",)
|
||||||
@@ -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",)
|
||||||
@@ -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",)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 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 *
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from tunebot import AutoJoin
|
||||||
|
from tunebot import GlobalAutoJoin
|
||||||
|
from tunebot.redis import RedisBotEntity
|
||||||
|
from tunebot.redis import RedisContextEntity
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalRedisAutoJoin(RedisBotEntity, GlobalAutoJoin):
|
||||||
|
async def fetch_channels(self) -> dict[str, list[str]]:
|
||||||
|
"""
|
||||||
|
Retrieves all guilds (with their configurations) where AutoJoin is enabled
|
||||||
|
"""
|
||||||
|
channels: dict[str, str] = await self.redis.hgetall(self.key("autojoin"))
|
||||||
|
return {key: value.split(":") for key, value in channels.items()}
|
||||||
|
|
||||||
|
|
||||||
|
class RedisAutoJoin(RedisContextEntity, AutoJoin):
|
||||||
|
async def update(self, voice_channel_id: int, text_channel_id: int):
|
||||||
|
"""
|
||||||
|
Upserts the configuration of an AutoJoin guild.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
voice_channel_id (int): [description]
|
||||||
|
text_channel_id (int): [description]
|
||||||
|
"""
|
||||||
|
if not self.ctx.guild:
|
||||||
|
raise Exception("This method can only be invoked inside of a guild.")
|
||||||
|
|
||||||
|
value = f"{voice_channel_id}:{text_channel_id}"
|
||||||
|
await self.redis.hset(self.key("autojoin"), self.ctx.guild.id, value)
|
||||||
|
|
||||||
|
async def disable(self):
|
||||||
|
"""
|
||||||
|
Removes the AutoJoin configuration of a guild.
|
||||||
|
"""
|
||||||
|
if not self.ctx.guild:
|
||||||
|
raise Exception("This method can only be invoked inside of a guild.")
|
||||||
|
|
||||||
|
await self.redis.hdel(self.key("autojoin"), self.ctx.guild.id)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("GlobalRedisAutoJoin", "RedisAutoJoin")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from abc import ABC
|
||||||
|
from abc import abstractmethod
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from context import CustomContext
|
||||||
|
|
||||||
|
from aioredis.client import Redis
|
||||||
|
|
||||||
|
|
||||||
|
class RedisEntity(ABC):
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def redis() -> Redis:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def key(self, name: str) -> str:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RedisBotEntity(RedisEntity):
|
||||||
|
def __init__(self, redis: Redis, prefix: str) -> None:
|
||||||
|
self._redis = redis
|
||||||
|
self.prefix = prefix
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redis(self) -> Redis:
|
||||||
|
return self._redis
|
||||||
|
|
||||||
|
def key(self, name: str) -> str:
|
||||||
|
return ":".join([self.prefix, name])
|
||||||
|
|
||||||
|
|
||||||
|
class RedisContextEntity(RedisEntity):
|
||||||
|
def __init__(self, ctx: "CustomContext") -> None:
|
||||||
|
self.ctx = ctx
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redis(self) -> Redis:
|
||||||
|
return self.ctx.redis
|
||||||
|
|
||||||
|
def key(self, name: str) -> str:
|
||||||
|
return ":".join([self.ctx.bot.redis_prefix, name])
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("RedisEntity", "RedisBotEntity", "RedisContextEntity")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from tunebot.abc import GlobalPlaylist
|
||||||
|
from tunebot.redis import RedisBotEntity
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalRedisPlaylist(RedisBotEntity, GlobalPlaylist):
|
||||||
|
async def pick_random(self, amount: Optional[int] = 1) -> set[str]:
|
||||||
|
"""
|
||||||
|
Picks an amount of random tracks from the playlist in Redis
|
||||||
|
|
||||||
|
Args:
|
||||||
|
amount (Optional[int], optional): [description]. Defaults to 1.
|
||||||
|
"""
|
||||||
|
return await self.redis.srandmember(self.key("playlist"), amount)
|
||||||
|
|
||||||
|
async def add_tracks(self, urls: list[str]):
|
||||||
|
"""
|
||||||
|
Adds one or more urls to the playlist in Redis
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urls (list[str]): [description]
|
||||||
|
"""
|
||||||
|
await self.redis.sadd(self.key("playlist"), *urls)
|
||||||
|
|
||||||
|
async def clear(self):
|
||||||
|
"""
|
||||||
|
Clears the entire playlist in Redis
|
||||||
|
"""
|
||||||
|
await self.redis.delete(self.key("playlist"))
|
||||||
|
|
||||||
|
async def remove_tracks(self, track_urls: list[str]):
|
||||||
|
"""
|
||||||
|
Removes a single track from the playlist
|
||||||
|
|
||||||
|
Args:
|
||||||
|
track_url (str): [description]
|
||||||
|
"""
|
||||||
|
await self.redis.srem(self.key("playlist"), *track_urls)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("GlobalRedisPlaylist",)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from tunebot import GlobalPlaylistSource
|
||||||
|
from tunebot import PlaylistSource
|
||||||
|
from tunebot.redis import RedisBotEntity
|
||||||
|
from tunebot.redis import RedisContextEntity
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalRedisPlaylistSource(RedisBotEntity, GlobalPlaylistSource):
|
||||||
|
async def fetch_sources(self) -> set[str]:
|
||||||
|
"""
|
||||||
|
Fetches all sources
|
||||||
|
"""
|
||||||
|
return await self.redis.smembers(self.key("sources"))
|
||||||
|
|
||||||
|
|
||||||
|
class RedisPlaylistSource(RedisContextEntity, PlaylistSource):
|
||||||
|
async def add(self, source_url: str):
|
||||||
|
"""
|
||||||
|
Adds a playlist source to Redis
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_url (str): [description]
|
||||||
|
"""
|
||||||
|
await self.redis.sadd(self.key("sources"), source_url)
|
||||||
|
|
||||||
|
async def remove(self, source_url: str) -> bool:
|
||||||
|
"""
|
||||||
|
Removes a playlist source from Redis
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_url (str): [description]
|
||||||
|
"""
|
||||||
|
return await self.redis.srem(self.key("sources"), source_url)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("GlobalRedisPlaylistSource", "RedisPlaylistSource")
|
||||||
@@ -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)
|
||||||
+18
-1
@@ -1,15 +1,32 @@
|
|||||||
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"]
|
||||||
for command in self.walk_commands():
|
for command in self.walk_commands():
|
||||||
if brief := slash_descriptions.get(command.qualified_name):
|
if brief := slash_descriptions.get(command.qualified_name):
|
||||||
command.brief = brief
|
command.brief = brief
|
||||||
|
|
||||||
|
def is_lavalink_ready(self) -> bool:
|
||||||
|
return (
|
||||||
|
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}")
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
from typing import Dict
|
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from aioredis import Redis
|
|
||||||
|
|
||||||
from bot import redis_prefix
|
|
||||||
|
|
||||||
|
|
||||||
class AutoJoin:
|
|
||||||
@staticmethod
|
|
||||||
async def get_channels(redis: Redis) -> Dict[str, str]:
|
|
||||||
# return all channels
|
|
||||||
channels = await redis.hgetall(f"{redis_prefix}:autojoin")
|
|
||||||
return {key: value.split("-") for key, value in channels.items()}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def update_channel(
|
|
||||||
redis: Redis, guild_id: int, voice_channel_id: int, text_channel_id: int
|
|
||||||
):
|
|
||||||
await redis.hset(
|
|
||||||
f"{redis_prefix}:autojoin",
|
|
||||||
guild_id,
|
|
||||||
f"{voice_channel_id}-{text_channel_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def del_channel(redis: Redis, guild_id: int):
|
|
||||||
await redis.hdel(f"{redis_prefix}:autojoin", guild_id)
|
|
||||||
|
|
||||||
|
|
||||||
class Playlist:
|
|
||||||
@staticmethod
|
|
||||||
async def random(redis: Redis, amount: Optional[int] = 1) -> List[str]:
|
|
||||||
return await redis.srandmember(f"{redis_prefix}:playlist", amount)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def add_bulk(redis: Redis, urls: list[str]):
|
|
||||||
await redis.sadd(f"{redis_prefix}:playlist", *urls)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def clear(redis: Redis):
|
|
||||||
await redis.delete(f"{redis_prefix}:playlist")
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistSource:
|
|
||||||
@staticmethod
|
|
||||||
async def get_all(redis: Redis) -> list[str]:
|
|
||||||
return await redis.smembers(f"{redis_prefix}:sources")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def add(redis: Redis, source_url: str):
|
|
||||||
await redis.sadd(f"{redis_prefix}:sources", source_url)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def remove(redis: Redis, source_url: str) -> bool:
|
|
||||||
return await redis.srem(f"{redis_prefix}:sources", source_url)
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from typing import Callable
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from discord.ext.commands import check
|
||||||
|
from discord.ext.commands.errors import NotOwner
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from context import CustomContext
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def source_manager_only() -> Callable[[T], T]:
|
||||||
|
"""
|
||||||
|
A :func:`.check` that checks if the person invoking this command is allowed to modify the radio sources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def predicate(ctx: "CustomContext") -> bool:
|
||||||
|
is_manager = ctx.author.id in ctx.bot.config["manager_ids"]
|
||||||
|
is_owner = await ctx.bot.is_owner(ctx.author)
|
||||||
|
if not is_manager and not is_owner:
|
||||||
|
raise NotOwner("You are not allowed to modify the sources.")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
return check(predicate)
|
||||||
Reference in New Issue
Block a user