diff --git a/config.py b/config.py deleted file mode 100644 index b9bf8cb..0000000 --- a/config.py +++ /dev/null @@ -1,22 +0,0 @@ -from dotenv import load_dotenv -import os - -load_dotenv() - -config = { - 'DISCORD_TOKEN': os.getenv('DISCORD_TOKEN'), - 'GUILD_ID_1': int(os.getenv('DISCORD_GUILD_ID')), - 'GUILD_ID_2': int(os.getenv('DISCORD_GUILD_ID_2')), - 'DISCORD_CHANNEL_ID': int(os.getenv('DISCORD_CHANNEL_ID')), - 'YOUTUBE_API_KEY': os.getenv('YOUTUBE_API_KEY'), - 'TWITCH_CLIENT_ID': os.getenv('TWITCH_CLIENT_ID'), - 'TWITCH_CLIENT_SECRET': os.getenv('TWITCH_CLIENT_SECRET'), - 'BUNGIE_API_KEY': os.getenv('BUNGIE_API_KEY'), - 'OAUTH_URL': os.getenv('OAUTH_URL'), - 'OAUTH_CLIENT_ID': os.getenv('OAUTH_CLIENT_ID'), - 'modules': { - 'music': {'enabled': True}, - 'terms_privacy': {'enabled': True}, - 'data_privacy': {'enabled': True} - } -} diff --git a/main.py b/main.py index 521fec5..fd3dcab 100644 --- a/main.py +++ b/main.py @@ -1,59 +1,34 @@ import discord -from config import config -import logging +from discord.ext import commands +import asyncio +import os +from dotenv import load_dotenv -logging.basicConfig(level=logging.INFO) +# Load environment variables +load_dotenv() +TOKEN = os.getenv('DISCORD_TOKEN') -TOKEN = config['DISCORD_TOKEN'] -GUILD_ID_1 = config['GUILD_ID_1'] -GUILD_ID_2 = config['GUILD_ID_2'] +# Import the Music module +from modules.music import Music intents = discord.Intents.default() -intents.message_content = True +intents.message_content = True # Required for accessing message content class Selena(discord.Client): - def __init__(self): + def __init__(self, *, intents): super().__init__(intents=intents) self.tree = discord.app_commands.CommandTree(self) - self.xp_module = None # Initialize as None - self.load_modules() + + # Initialize modules + self.music = Music(self) async def setup_hook(self): - logging.info("Setting up modules...") - await self.tree.sync(guild=discord.Object(id=GUILD_ID_1)) - logging.info(f"Modules setup and commands synchronized to {GUILD_ID_1}") - await self.tree.sync(guild=discord.Object(id=GUILD_ID_2)) - logging.info(f"Modules setup and commands synchronized to {GUILD_ID_2}") - # Call setup_hook for xp_module here - if self.xp_module: - await self.xp_module.setup_hook() - - def load_modules(self): - if config['modules']['music']['enabled']: - from modules.music.music import Music - music = Music(self) - music.setup(self.tree) - logging.info("Music module loaded") - - if config['modules']['terms_privacy']['enabled']: - from modules.admin.terms_privacy import TermsPrivacy - terms_privacy = TermsPrivacy(self) - terms_privacy.setup(self.tree) - logging.info("Terms and Privacy module loaded") - - if config['modules']['data_privacy']['enabled']: - from modules.admin.data_privacy import DataPrivacy - data_privacy = DataPrivacy(self) - data_privacy.setup(self.tree) - logging.info("Data Privacy module loaded") + # Sync the app commands with Discord + await self.tree.sync() -bot = Selena() +client = Selena(intents=intents) - -@bot.event -async def on_ready(): - logging.info(f'{bot.user.name} has connected to Discord!') - -bot.run(TOKEN) +# Run the bot +client.run(TOKEN) diff --git a/modules/admin/data_privacy.py b/modules/admin/data_privacy.py deleted file mode 100644 index e6b3013..0000000 --- a/modules/admin/data_privacy.py +++ /dev/null @@ -1,51 +0,0 @@ -import discord -from discord import app_commands -import sqlite3 - - -class DataPrivacy: - def __init__(self, bot): - self.bot = bot - self.db_path = 'data/selena.db' - - async def fetch_user_data(self, user_id): - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute("SELECT * FROM user_data WHERE user_id = ?", (user_id,)) - data = cursor.fetchall() - conn.close() - return data - - async def delete_user_data(self, user_id): - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute("DELETE FROM user_data WHERE user_id = ?", (user_id,)) - conn.commit() - conn.close() - - def setup(self, tree: app_commands.CommandTree): - @tree.command(name="request_data", description="Request your stored data") - async def request_data_command(interaction: discord.Interaction): - user_id = interaction.user.id - data = await self.fetch_user_data(user_id) - if data: - await interaction.response.send_message(f"Your data: {data}", ephemeral=True) - else: - await interaction.response.send_message("No data found for your user.", ephemeral=True) - - @tree.command(name="delete_data", description="Request deletion of your stored data") - async def delete_data_command(interaction: discord.Interaction): - user_id = interaction.user.id - await self.delete_user_data(user_id) - await interaction.response.send_message("Your data has been deleted.", ephemeral=True) - - if not tree.get_command("request_data"): - tree.add_command(request_data_command) - - if not tree.get_command("delete_data"): - tree.add_command(delete_data_command) - - -def setup(bot): - data_privacy = DataPrivacy(bot) - data_privacy.setup(bot.tree) diff --git a/modules/admin/terms_privacy.py b/modules/admin/terms_privacy.py deleted file mode 100644 index 9956464..0000000 --- a/modules/admin/terms_privacy.py +++ /dev/null @@ -1,73 +0,0 @@ -import discord -from discord import app_commands -import sqlite3 - - -class TermsPrivacy: - def __init__(self, bot): - self.bot = bot - self.db_path = 'data/selena.db' - self.privacy_policy_url = "https://advtech92.github.io/selena-website/privacy_policy.html" - self.terms_of_service_url = "https://advtech92.github.io/selena-website/terms_of_service.html" - - async def user_opt_out(self, user_id): - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute("INSERT INTO opt_out_users (user_id) VALUES (?)", (user_id,)) - conn.commit() - conn.close() - - async def user_opt_in(self, user_id): - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute("DELETE FROM opt_out_users WHERE user_id = ?", (user_id,)) - conn.commit() - conn.close() - - async def is_user_opted_out(self, user_id): - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute("SELECT 1 FROM opt_out_users WHERE user_id = ?", (user_id,)) - result = cursor.fetchone() - conn.close() - return result is not None - - def setup(self, tree: app_commands.CommandTree): - @tree.command(name="privacy_policy", description="Show the privacy policy") - async def privacy_policy_command(interaction: discord.Interaction): - embed = discord.Embed(title="Privacy Policy", url=self.privacy_policy_url, description="Read our privacy policy.", color=discord.Color.blue()) - await interaction.response.send_message(embed=embed, ephemeral=True) - - @tree.command(name="terms_of_service", description="Show the terms of service") - async def terms_of_service_command(interaction: discord.Interaction): - embed = discord.Embed(title="Terms of Service", url=self.terms_of_service_url, description="Read our terms of service.", color=discord.Color.blue()) - await interaction.response.send_message(embed=embed, ephemeral=True) - - @tree.command(name="opt_out", description="Opt out of using the bot") - async def opt_out_command(interaction: discord.Interaction): - user_id = interaction.user.id - await self.user_opt_out(user_id) - await interaction.response.send_message("You have opted out of using the bot.", ephemeral=True) - - @tree.command(name="opt_in", description="Opt back in to using the bot") - async def opt_in_command(interaction: discord.Interaction): - user_id = interaction.user.id - await self.user_opt_in(user_id) - await interaction.response.send_message("You have opted back in to using the bot.", ephemeral=True) - - if not tree.get_command("privacy_policy"): - tree.add_command(privacy_policy_command) - - if not tree.get_command("terms_of_service"): - tree.add_command(terms_of_service_command) - - if not tree.get_command("opt_out"): - tree.add_command(opt_out_command) - - if not tree.get_command("opt_in"): - tree.add_command(opt_in_command) - - -def setup(bot): - terms_privacy = TermsPrivacy(bot) - terms_privacy.setup(bot.tree) diff --git a/modules/music.py b/modules/music.py new file mode 100644 index 0000000..50d9c1e --- /dev/null +++ b/modules/music.py @@ -0,0 +1,256 @@ +import discord +from discord import app_commands +import yt_dlp +import asyncio +import json +import os + +class Music: + def __init__(self, client): + self.client = client + self.voice_clients = {} + self.music_queues = {} + self.current_tracks = {} + self.save_file = 'music_state.json' + self.volumes = {} # Store volume levels per guild + self.default_volume = 0.5 # Default volume level (50%) + + # Load saved state for auto-resume + self.load_music_state() + + # Register app commands + self.register_commands() + + def register_commands(self): + @app_commands.command(name='play', description='Play a song by title and artist') + async def play(interaction: discord.Interaction, *, query: str): + await self.play(interaction, query) + + @app_commands.command(name='pause', description='Pause the current song') + async def pause(interaction: discord.Interaction): + await self.pause(interaction) + + @app_commands.command(name='resume', description='Resume the paused song') + async def resume(interaction: discord.Interaction): + await self.resume(interaction) + + @app_commands.command(name='skip', description='Skip the current song') + async def skip(interaction: discord.Interaction): + await self.skip(interaction) + + @app_commands.command(name='stop', description='Stop playback and clear the queue') + async def stop(interaction: discord.Interaction): + await self.stop(interaction) + + @app_commands.command(name='volume', description='Set the playback volume') + @app_commands.describe(level='Volume level between 0 and 100') + async def volume(interaction: discord.Interaction, level: int): + await self.set_volume(interaction, level) + + # Add commands to the client's tree + self.client.tree.add_command(play) + self.client.tree.add_command(pause) + self.client.tree.add_command(resume) + self.client.tree.add_command(skip) + self.client.tree.add_command(stop) + self.client.tree.add_command(volume) + + async def play(self, interaction: discord.Interaction, query: str): + await interaction.response.defer() + + guild_id = interaction.guild_id + + # Check if the user is in a voice channel + if interaction.user.voice is None: + await interaction.followup.send("You must be connected to a voice channel to use this command.") + return + + # Connect to the voice channel if not already connected + if guild_id not in self.voice_clients or not self.voice_clients[guild_id].is_connected(): + channel = interaction.user.voice.channel + self.voice_clients[guild_id] = await channel.connect() + + # Ensure volume is set + if guild_id not in self.volumes: + self.volumes[guild_id] = self.default_volume + + await interaction.followup.send(f"πŸ” **Searching for:** {query}") + + # Search YouTube for the song + search_url = await self.search_youtube(query) + if not search_url: + await interaction.followup.send("❌ **Could not find the song on YouTube.**") + return + + # Add URL to the music queue + if guild_id not in self.music_queues: + self.music_queues[guild_id] = [] + self.music_queues[guild_id].append(search_url) + + await interaction.followup.send(f"βœ… **Added to queue:** {query}") + + # If nothing is playing, start playing + if not self.voice_clients[guild_id].is_playing(): + await self.play_next(guild_id) + + async def search_youtube(self, query): + """Searches YouTube for the query and returns the URL of the first result.""" + ytdl_opts = { + 'format': 'bestaudio/best', + 'noplaylist': True, + 'default_search': 'ytsearch', + 'quiet': True, + 'no_warnings': True, + 'skip_download': True, + } + loop = asyncio.get_event_loop() + with yt_dlp.YoutubeDL(ytdl_opts) as ytdl: + try: + info = await loop.run_in_executor(None, lambda: ytdl.extract_info(query, download=False)) + if 'entries' in info: + # Take the first item from the search results + video = info['entries'][0] + else: + video = info + video_url = f"https://www.youtube.com/watch?v={video['id']}" + return video_url + except Exception as e: + print(f"Error searching YouTube: {e}") + return None + + async def play_next(self, guild_id): + if guild_id not in self.music_queues or not self.music_queues[guild_id]: + await self.voice_clients[guild_id].disconnect() + return + + url = self.music_queues[guild_id].pop(0) + self.current_tracks[guild_id] = url + + try: + # Use yt_dlp to get audio source + ytdl_opts = {'format': 'bestaudio/best'} + loop = asyncio.get_event_loop() + with yt_dlp.YoutubeDL(ytdl_opts) as ytdl: + info = await loop.run_in_executor(None, lambda: ytdl.extract_info(url, download=False)) + audio_url = info['url'] + title = info.get('title', 'Unknown Title') + webpage_url = info.get('webpage_url', url) + thumbnail = info.get('thumbnail') + + # Prepare FFmpeg options + ffmpeg_opts = { + 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', + 'options': '-vn', + } + + # Create audio source using FFmpegPCMAudio + source = discord.FFmpegPCMAudio(audio_url, **ffmpeg_opts) + volume = self.volumes.get(guild_id, self.default_volume) + source = discord.PCMVolumeTransformer(source, volume=volume) + + # Play audio + self.voice_clients[guild_id].play(source, after=lambda e: self.after_song(e, guild_id)) + + # Send an embedded message indicating the song is now playing + embed = discord.Embed( + title="Now Playing 🎡", + description=f"[{title}]({webpage_url})", + color=discord.Color.blue() + ) + if thumbnail: + embed.set_thumbnail(url=thumbnail) + + channel = self.voice_clients[guild_id].channel + text_channel = channel.guild.system_channel or channel.guild.text_channels[0] + await text_channel.send(embed=embed) + + # Save state for auto-resume + self.save_music_state() + + except Exception as e: + print(f"Error during playback: {e}") + await self.voice_clients[guild_id].disconnect() + + def after_song(self, error, guild_id): + if error: + print(f"Error: {error}") + coro = self.play_next(guild_id) + asyncio.run_coroutine_threadsafe(coro, self.client.loop) + + async def pause(self, interaction: discord.Interaction): + guild_id = interaction.guild_id + if guild_id in self.voice_clients and self.voice_clients[guild_id].is_playing(): + self.voice_clients[guild_id].pause() + await interaction.response.send_message("⏸️ **Music paused.**") + else: + await interaction.response.send_message("❌ **No music is playing.**") + + async def resume(self, interaction: discord.Interaction): + guild_id = interaction.guild_id + if guild_id in self.voice_clients and self.voice_clients[guild_id].is_paused(): + self.voice_clients[guild_id].resume() + await interaction.response.send_message("▢️ **Music resumed.**") + else: + await interaction.response.send_message("❌ **No music is paused.**") + + async def skip(self, interaction: discord.Interaction): + guild_id = interaction.guild_id + if guild_id in self.voice_clients and self.voice_clients[guild_id].is_playing(): + self.voice_clients[guild_id].stop() + await interaction.response.send_message("⏭️ **Skipped current song.**") + else: + await interaction.response.send_message("❌ **No music is playing.**") + + async def stop(self, interaction: discord.Interaction): + guild_id = interaction.guild_id + if guild_id in self.voice_clients: + self.voice_clients[guild_id].stop() + self.music_queues[guild_id] = [] + await self.voice_clients[guild_id].disconnect() + await interaction.response.send_message("πŸ›‘ **Playback stopped and queue cleared.**") + else: + await interaction.response.send_message("❌ **No music is playing.**") + + async def set_volume(self, interaction: discord.Interaction, level: int): + guild_id = interaction.guild_id + + # Validate volume level + if level < 0 or level > 100: + await interaction.response.send_message("❌ **Volume must be between 0 and 100.**") + return + + # Set the volume + volume = level / 100 # Convert to a 0.0 - 1.0 scale + self.volumes[guild_id] = volume + + # Adjust volume if something is playing + if guild_id in self.voice_clients and self.voice_clients[guild_id].is_playing(): + current_source = self.voice_clients[guild_id].source + if isinstance(current_source, discord.PCMVolumeTransformer): + current_source.volume = volume + else: + # Wrap the existing source with PCMVolumeTransformer + self.voice_clients[guild_id].source = discord.PCMVolumeTransformer(current_source, volume=volume) + + await interaction.response.send_message(f"πŸ”Š **Volume set to {level}%.**") + + def save_music_state(self): + state = { + 'current_tracks': self.current_tracks, + 'music_queues': self.music_queues, + 'volumes': self.volumes + } + with open(self.save_file, 'w') as f: + json.dump(state, f) + + def load_music_state(self): + if os.path.exists(self.save_file): + with open(self.save_file, 'r') as f: + state = json.load(f) + self.current_tracks = state.get('current_tracks', {}) + self.music_queues = state.get('music_queues', {}) + self.volumes = state.get('volumes', {}) + else: + self.current_tracks = {} + self.music_queues = {} + self.volumes = {} diff --git a/modules/music/music.py b/modules/music/music.py deleted file mode 100644 index e16104d..0000000 --- a/modules/music/music.py +++ /dev/null @@ -1,183 +0,0 @@ -import discord -from discord.ext import commands -from discord import app_commands -from yt_dlp import YoutubeDL -import os -from dotenv import load_dotenv - -load_dotenv() - -YTDL_OPTIONS = { - 'format': 'bestaudio', - 'noplaylist': 'True', -} - -FFMPEG_OPTIONS = { - 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', - 'options': '-vn' -} - -ytdl = YoutubeDL(YTDL_OPTIONS) - - -class Music(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.queue = [] - self.is_playing = False - self.volume = 0.3 - self.loop = False # Initialize loop state - self.current_song = None # Track the current song - - async def join(self, interaction: discord.Interaction): - channel = interaction.user.voice.channel - if interaction.guild.voice_client is None: - await channel.connect(self_deaf=True) # Join the channel deafened - await interaction.followup.send("Joined the voice channel.") - else: - await interaction.followup.send("Already in a voice channel.") - - async def leave(self, interaction: discord.Interaction): - if interaction.guild.voice_client: - await interaction.guild.voice_client.disconnect() - await interaction.followup.send("Left the voice channel.") - else: - await interaction.followup.send("Not connected to a voice channel.") - - async def play(self, interaction: discord.Interaction, search: str): - if not interaction.guild.voice_client: - await self.join(interaction) - - info = ytdl.extract_info(f"ytsearch:{search}", download=False)['entries'][0] - url = info['url'] - - if interaction.guild.voice_client.is_playing(): - self.queue.append((url, info['title'])) - await interaction.followup.send(f'Queued: {info["title"]}') - else: - self.current_song = (url, info['title']) - source = discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(url, **FFMPEG_OPTIONS), volume=self.volume) - interaction.guild.voice_client.play(source, after=lambda e: self.bot.loop.create_task(self.play_next(interaction))) - self.is_playing = True - await interaction.followup.send(f'Playing: {info["title"]}') - - async def play_next(self, interaction: discord.Interaction): - if self.loop and self.current_song: # If loop is active, repeat the current song - url, title = self.current_song - source = discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(url, **FFMPEG_OPTIONS), volume=self.volume) - interaction.guild.voice_client.play(source, after=lambda e: self.bot.loop.create_task(self.play_next(interaction))) - await interaction.followup.send(f'Repeating: {title}') - elif self.queue: - url, title = self.queue.pop(0) - self.current_song = (url, title) - source = discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(url, **FFMPEG_OPTIONS), volume=self.volume) - interaction.guild.voice_client.play(source, after=lambda e: self.bot.loop.create_task(self.play_next(interaction))) - await interaction.followup.send(f'Playing next: {title}') - else: - self.is_playing = False - - async def pause(self, interaction: discord.Interaction): - if interaction.guild.voice_client.is_playing(): - interaction.guild.voice_client.pause() - await interaction.followup.send("Paused the song.") - else: - await interaction.followup.send("No song is currently playing.") - - async def resume(self, interaction: discord.Interaction): - if interaction.guild.voice_client.is_paused(): - interaction.guild.voice_client.resume() - await interaction.followup.send("Resumed the song.") - else: - await interaction.followup.send("The song is not paused.") - - async def stop(self, interaction: discord.Interaction): - if interaction.guild.voice_client.is_playing(): - interaction.guild.voice_client.stop() - self.queue = [] - self.current_song = None - await interaction.followup.send("Stopped the song.") - else: - await interaction.followup.send("No song is currently playing.") - - async def set_volume(self, interaction: discord.Interaction, volume: float): - self.volume = volume - if interaction.guild.voice_client and interaction.guild.voice_client.source: - interaction.guild.voice_client.source.volume = self.volume - await interaction.followup.send(f"Volume set to {volume * 100}%.") - else: - await interaction.followup.send("No audio source found.") - - async def toggle_loop(self, interaction: discord.Interaction): - self.loop = not self.loop # Toggle the loop state - state = "enabled" if self.loop else "disabled" - await interaction.followup.send(f"Loop has been {state}.") - - def setup(self, tree: discord.app_commands.CommandTree): - @tree.command(name="join", description="Join the voice channel") - async def join_command(interaction: discord.Interaction): - await interaction.response.defer() - await self.join(interaction) - - @tree.command(name="leave", description="Leave the voice channel") - async def leave_command(interaction: discord.Interaction): - await interaction.response.defer() - await self.leave(interaction) - - @tree.command(name="play", description="Play a song from YouTube") - async def play_command(interaction: discord.Interaction, search: str): - await interaction.response.defer() - await self.play(interaction, search) - - @tree.command(name="pause", description="Pause the current song") - async def pause_command(interaction: discord.Interaction): - await interaction.response.defer() - await self.pause(interaction) - - @tree.command(name="resume", description="Resume the paused song") - async def resume_command(interaction: discord.Interaction): - await interaction.response.defer() - await self.resume(interaction) - - @tree.command(name="stop", description="Stop the current song") - async def stop_command(interaction: discord.Interaction): - await interaction.response.defer() - await self.stop(interaction) - - @tree.command(name="volume", description="Set the volume (0 to 1)") - async def volume_command(interaction: discord.Interaction, volume: float): - await interaction.response.defer() - await self.set_volume(interaction, volume) - - @tree.command(name="loop", description="Toggle loop for the current song") - async def loop_command(interaction: discord.Interaction): - await interaction.response.defer() - await self.toggle_loop(interaction) - - if not tree.get_command("play"): - tree.add_command(play_command) - - if not tree.get_command("pause"): - tree.add_command(pause_command) - - if not tree.get_command("resume"): - tree.add_command(resume_command) - - if not tree.get_command("stop"): - tree.add_command(stop_command) - - if not tree.get_command("volume"): - tree.add_command(volume_command) - - if not tree.get_command("loop"): - tree.add_command(loop_command) - - if not tree.get_command("join"): - tree.add_command(join_command) - - if not tree.get_command("leave"): - tree.add_command(leave_command) - - -def setup(bot): - music = Music(bot) - music.setup(bot.tree) diff --git a/music_state.json b/music_state.json new file mode 100644 index 0000000..8f083c2 --- /dev/null +++ b/music_state.json @@ -0,0 +1 @@ +{"current_tracks": {"1161168803888107550": "https://www.youtube.com/watch?v=fnlJw9H0xAM", "1142517462529757184": "https://www.youtube.com/watch?v=fnlJw9H0xAM"}, "music_queues": {"1161168803888107550": [], "1142517462529757184": []}, "volumes": {"1161168803888107550": 0.5, "1142517462529757184": 0.5}} \ No newline at end of file