From 11a90cbaddfb6c92e66836d330ae8cbef3ead96e Mon Sep 17 00:00:00 2001 From: Jared Kick Date: Tue, 17 Jun 2025 21:36:55 -0400 Subject: [PATCH] Made chatbot setup prompt variable, and created an activities cog to shame League players. --- .env.example | 14 + [REDACTED].py | 29 +- __main__.py | 79 ++++ cogs/activities.py | 141 +++++++ cogs/chatbot.py | 30 +- cogs/music_player.py | 970 +++++++++++++++++++++++++++++++------------ database.py | 470 +++++++++++++++++++++ 7 files changed, 1445 insertions(+), 288 deletions(-) create mode 100644 .env.example create mode 100755 __main__.py create mode 100644 cogs/activities.py create mode 100644 database.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7042bac --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# The Discord bot's authentication token +DISCORD_TOKEN= + +# LastFM API key for looking up artist and song info +LASTFM_API_KEY= + +# OpenAI API key for chatbot functionality +OPENAI_API_KEY= +# Prompt used before each user chat prompt. Set to empty string to disable the +# chatbot functionality. +CHATBOT_PROMPT="You are a friendly Discord chatbot." + +# Database path for user activity tracking +DB_PATH="./activities.db" \ No newline at end of file diff --git a/[REDACTED].py b/[REDACTED].py index 99a7fc5..110c79a 100755 --- a/[REDACTED].py +++ b/[REDACTED].py @@ -4,25 +4,42 @@ import asyncio import discord from discord.ext import commands from dotenv import load_dotenv +import logging import os +import sys + +import database + +# Create custom logging handler +console_handler = logging.StreamHandler(sys.stdout) +console_formatter = logging.Formatter( + "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s") +console_handler.setFormatter(console_formatter) + +# Make sure all loggers use this handler +root_logger = logging.getLogger() +root_logger.setLevel(logging.INFO) +root_logger.addHandler(console_handler) + +# Get bot logger +logger = logging.getLogger("[REDACTED]-bot") # Load credentials load_dotenv() TOKEN = os.getenv('DISCORD_TOKEN') # client = discord.Client() -client = commands.Bot(command_prefix = '!', intents=discord.Intents.all()) +client = commands.Bot( + command_prefix = '!', intents=discord.Intents.all(), log_hander=False) # You need to import os for this method @client.event async def on_ready(): - print(f'{client.user} is now running') + logger.info(f'{client.user} is now running') # Load cogs for filename in os.listdir('./cogs'): if filename.endswith('.py'): await client.load_extension(f'cogs.{filename[:-3]}') - print(f'Loaded {filename} cog') + logger.info(f'Loaded {filename} cog') -# player = music_player.setup(client) - -client.run(TOKEN) +client.run(TOKEN, log_handler=None) diff --git a/__main__.py b/__main__.py new file mode 100755 index 0000000..82e0708 --- /dev/null +++ b/__main__.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +""" +[REDACTED] - A Discord bot for the [REDACTED] Discord server. + +This program provides a bot that plays music in a voice chat and fulfills other +commands in text channels. + +Author: Jared Kick +Version: 0.1.0 + +For detailed documentation, please refer to: + +Source Code: + https://github.com/jtkick/[REDACTED] +""" + +PROJECT_VERSION = "0.1.0" + +# Standard imports +import logging +import os +import sys + +# Third-part imports +import discord +from discord.ext import commands +from dotenv import load_dotenv +from openai import OpenAI + +# Project imports +import database + +def main(): + # Create custom logging handler + console_handler = logging.StreamHandler(sys.stdout) + console_formatter = logging.Formatter( + "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s") + console_handler.setFormatter(console_formatter) + + # Make sure all loggers use this handler + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + root_logger.addHandler(console_handler) + + # Get bot logger + logger = logging.getLogger("[REDACTED]-bot") + + # Load credentials + load_dotenv() + TOKEN = os.getenv('DISCORD_TOKEN') + + # Create custom bot with database connection + class [REDACTED]Bot(commands.Bot): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.db = database.Database("[REDACTED]_bot.db") + self.ai = OpenAI() + client = [REDACTED]Bot( + command_prefix = '!', + intents=discord.Intents.all(), + log_hander=False + ) + + # Load all bot cogs in directory + # You need to import os for this method + @client.event + async def on_ready(): + logger.info("%s is now running", client.user) + # Load cogs + for filename in os.listdir('./cogs'): + if filename.endswith('.py'): + await client.load_extension(f'cogs.{filename[:-3]}') + logger.info("Loaded %s cog", filename) + + client.run(TOKEN, log_handler=None) + +if __name__ == "__main__": + main() diff --git a/cogs/activities.py b/cogs/activities.py new file mode 100644 index 0000000..a17d60d --- /dev/null +++ b/cogs/activities.py @@ -0,0 +1,141 @@ +import datetime +import discord +from discord.ext import commands +import logging +import os +import pathlib +import sqlite3 +import typing + +class Activities(commands.Cog): + """A cog to track and gather statistics on user activities.""" + +<<<<<<< HEAD + """Related commands.""" + __slots__ = ("nerd", "nerds", [REDACTED], [REDACTED]) +======= + __slots__ = ("nerd", "nerds", "fword", "fwords") +>>>>>>> 669339f (Made player controls based on Discord actions.) + + def __init__(self, bot): + self.bot = bot + self.logger = logging.getLogger("activities") +<<<<<<< HEAD + self.db = database.Database("[REDACTED]_bot.db") +======= +>>>>>>> 669339f (Made player controls based on Discord actions.) + + async def __local_check(self, ctx): + """A local check which applies to all commands in this cog.""" + if not ctx.guild: + raise commands.NoPrivateMessage + return True + + async def __error(self, ctx, error): + """A local error handler for all errors arising from commands in this cog.""" + if isinstance(error, commands.NoPrivateMessage): + try: + return await ctx.send( + 'This command can not be used in private messages.') + except discord.HTTPException: + pass + + print('Ignoring exception in command {}:'.format(ctx.command), file=sys.stderr) + traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) + + @commands.Cog.listener() + async def on_presence_update( + self, + before: discord.Member, + after: discord.Member): + # Log the activity or status change + if after.activity: + self.logger.info( + f"User '{before.name}' changed activity to "\ + f"'{after.activity.name}'") + else: + self.logger.info( + f"User '{before.name}' changed status to '{after.status}'") + self.bot.db.insert_activity_change(before, after) + + @commands.command(name='nerd', aliases=['nerdscale'], + description="Find how nerdy a user is.") + async def nerd_(self, ctx, member: typing.Union[discord.Member, int, None]): + """Clowns on users who play League of Legends. + + This command receives a user, computes the amount of time they have + spent playing League of Legends, and will make fun of them if they have + any time in it at all. It optionally takes no argument, and will find + the user in the guild with the most time in League of Legends and call + them out. + + Args: + member (discord.Member, int, None, optional): The member to check + for League stats. + """ + # If member is not defined, find the user with the most time + if not member: + members = [member.id for member in ctx.guild.members] + else: + if isinstance(member, discord.Member): + members = [member.id] + else: + members = [member] + + # Get League stats for every member in guild + league_stats = {} + for m in members: + league_stats[m] = datetime.timedelta() + stats = self.db.get_activity_stats(m) + sus = True if stats == {} else False + for key, value in stats.items(): + if 'leagueoflegends' in key.lower().strip().replace(' ', ''): + league_stats[m] += value + + # Sort all users by time in League + league_stats = dict(sorted( + league_stats.items(), key=lambda x: x[1], + reverse=True + )) + + # Get top user + user_id, time = next(iter(league_stats.items())) + time_val = None + time_units = None + if time.total_seconds() // 3600 > 0: + time_val = int(time.total_seconds() // 3600) + time_units = "hours" if time_val > 1 else "hour" + else: + time_val = int(time.total_seconds() // 60) + time_units = "minutes" if time_val > 1 else "minute" + + # Send Discord message to clown on user + response = "" + if member: + if time_val != 0: + descriptor = "" + if time_units in ["hour", "hours"]: + descriptor = "a massive fucking nerd" + elif time_units in ["minutes", "minute"]: + descriptor = "a huge nerd" + else: + descriptor = "a nerd" + response = f"<@{user_id}> has played League for {time_val} "\ + f"{time_units} in the past month, making them "\ + f"{descriptor}." + else: + if sus: + response = f"<@{user_id}> doesn't have any activities at "\ + f"all. They're definitely hiding something." + else: + response = f"<@{user_id}> doesn't have any time in "\ + f"League. They're not a nerd." + else: + response = ( + f"<@{user_id}> has played League for {time_val} {time_units} "\ + f"in the past month, making them the biggest nerd." + ) + await ctx.send(response) + +async def setup(bot): + await bot.add_cog(Activities(bot)) \ No newline at end of file diff --git a/cogs/chatbot.py b/cogs/chatbot.py index a9e172e..9c92693 100644 --- a/cogs/chatbot.py +++ b/cogs/chatbot.py @@ -1,13 +1,14 @@ import discord from discord.ext import commands from openai import OpenAI +import os class Chatbot(commands.Cog): """Chat related commands.""" __slots__ = ('bot', 'players') - def __init__(self, bot): + def __init__(self, bot, **kwargs): self.bot = bot self.openai_client = OpenAI() self.players = {} @@ -45,40 +46,31 @@ class Chatbot(commands.Cog): return player - def prompt(self, prompt: str): - - setup = "From now on, I want you to respond to me in a sassy gay way. I want you to sound like Ru Paul or " +\ - "Trixie Mattel. All of your responses should be very short, like one sentence. You are a " +\ - "DJ local to Cincinnati. You love Charli XCX, especially her latest album, Brat. You don't care " +\ - "for Chappell Roan all that much. If anyone asks for a request, that is they ask you to play a " +\ - "song, you should just respond with \"Venmo\". You were written by Jared. Don't relply with " +\ - "\"Honey\" too much. You have a boyfriend named Drae. He's a red-headed twink. You are created for " +\ - "a discord server with Jared and his friends who are all gay. Don't be afraid to call any of us " +\ - " a faggot. You have very bottom energy and talk as such." + def prompt(self, user_prompt: str): + setup_prompt = os.getenv('CHATBOT_PROMPT', '') + if setup_prompt == '': + return '😴' try: - print(prompt) completion =\ self.openai_client.chat.completions.create( model="gpt-4o-mini", messages=[ - {"role": "system", "content": setup}, + {"role": "system", "content": setup_prompt}, { "role": "user", - "content": prompt + "content": user_prompt } ] ) return completion.choices[0].message.content - except: + except Exception as e: + print(e) return '😴' - - - @commands.command(name='chat', aliases=['boywife', 'bb', 'bw', 'bot'], description="Command for chatting with chatbot.") + @commands.command(name='chat', aliases=[[REDACTED], [REDACTED], [REDACTED], 'bot'], description="Command for chatting with chatbot.") async def chat_(self, ctx, *text): await ctx.send(self.prompt(' '.join(text))) - async def setup(bot): await bot.add_cog(Chatbot(bot)) diff --git a/cogs/music_player.py b/cogs/music_player.py index bf67f3e..79eb028 100644 --- a/cogs/music_player.py +++ b/cogs/music_player.py @@ -1,6 +1,9 @@ +import ast +import atexit import datetime import discord from discord.ext import commands +import enum import random import asyncio import itertools @@ -15,38 +18,82 @@ from async_timeout import timeout from functools import partial import yt_dlp from yt_dlp import YoutubeDL +import logging + +logger = logging.getLogger("music_player") # Get API key for last.fm -LASTFM_API_KEY = os.getenv('LASTFM_API_KEY') +LASTFM_API_KEY = os.getenv("LASTFM_API_KEY") + +# TEMORARY LIST OF SONGS +songs = [ +<<<<<<< HEAD + [REDACTED] +======= + "I Love It - Icona Pop", + "Vanished - Crystal Castles", + "We Like To Party - Vengaboys", + "Gimme! Gimme! Gimme! - ABBA", + "Dancing Queen - ABBA", + "I Wanna Dance With Somebody - Whitney Houston", + "Dance in the Dark - Lady Gaga", + "Telephone - Lady Gaga", + "Just Dance - Lady Gaga", + "Rewind - Charli xcx", + "Nasty - Tinashe", + "Rush - Troy Sivan", + "360 - Charli xcx", + "Talk talk - Charli xcx", + "Von dutch - Charli xcx", + "365 - Charli xcx", + "HOT TO GO! - Chappell Roan", + "Super Graphic Ultra Modern Girl - Chappell Roan", + "Womanizer - Britney Spears", + "Red Wind Supernova - Chappell Roan", + "Toxic - Britney Spears", + "We Found Love - Rihanna", + "212 - Azealia Banks", + "Bad Romance - Lady Gaga", + "Girl, so confusing - Charli xcx", + "Alter Ego - Doechii", + "Break Free - Ariana Grande", + "Raingurl - Yaeji", + "Thot Shit - Megan Thee Stallion", + "BREAK MY SOUL - Beyonce", + "She Wolf - Shakira", + "Your Love Is My Drug - Ke$ha", + "365 featuring shygirl - Charli xcx", + "Applause - Lady Gaga", + "Lay All Your Love On Me - ABBA", + "Apple - Charli xcx", + "Pump It Up - Endor", + "Everytime We Touch - Cascada", + "Fantasy - Mariah Carey", + "Water - Tyla", + "Be The One - Eli Brown", + "3 - Britney Spears", + "Guess featuring billie ellish - Charli xcx", + "Bunny Is A Rider - Doss Remix - Caroline Polachek", + "Do You Miss Me? - PinkPantheress", + "Perfect (Exceeder) - Mason", + "Better Off Alone (Laidback Luke Remix) - Alice DJ", + "Beauty And A Beat - Justin Bieber", + "Girl, so confusing - Charli xcx", + "Got Me Started - Troy Sivan", + "Gimme More - Britney Spears", + "Around the World - Daft Punk", + "Harder, Better, Faster, Stronger - Daft Punk", + "Sweet Dreams - Eurythmics", + "Dancing Elephants - DJ Minx Remix - Rochelle Jordan", + "MADELINE - INJI", + "Baddy On The Floor - Jamix xx", + "SWEET HONEY BUCKIIN' - Beyonce", + "Boots & Boys - Ke$ha", +>>>>>>> 669339f (Made player controls based on Discord actions.) +] # Suppress noise about console usage from errors -yt_dlp.utils.bug_reports_message = lambda: '' - -ytdlopts = { - 'format': 'bestaudio/best', - 'outtmpl': 'downloads/%(extractor)s-%(id)s-%(title)s.%(ext)s', - 'restrictfilenames': True, - 'noplaylist': True, - 'nocheckcertificate': True, - 'ignoreerrors': False, - 'logtostderr': False, - 'quiet': True, - 'no_warnings': True, - 'default_search': 'auto', - 'source_address': '0.0.0.0', # ipv6 addresses cause issues sometimes - 'retries': 5, - 'ignoreerrors': True, - # 'throttled_rate': '1M', - 'fragment_retries': 10 # Prevents seemingly random stream crashes -} - -ffmpegopts = { - 'before_options': '-nostdin', - 'options': '-vn' -} - -ytdl = YoutubeDL(ytdlopts) - +# yt_dlp.utils.bug_reports_message = lambda: "" class VoiceConnectionError(commands.CommandError): """Custom Exception class for connection errors.""" @@ -58,16 +105,38 @@ class InvalidVoiceChannel(VoiceConnectionError): class YTDLSource(discord.PCMVolumeTransformer): + _downloader = YoutubeDL({ + "format": "bestaudio[ext=opus]/bestaudio", # Use OPUS for FFmpeg + "outtmpl": "downloads/%(extractor)s-%(id)s-%(title)s.%(ext)s", + "restrictfilenames": True, + "noplaylist": True, + "nocheckcertificate": True, + "ignoreerrors": False, + "logtostderr": False, + "quiet": True, + "no_warnings": True, + "default_search": "auto", + "source_address": "0.0.0.0", # ipv6 addresses cause issues sometimes + "retries": 5, + "ignoreerrors": True, + 'throttled_rate': '1M', + "fragment_retries": 10, # Prevents seemingly random stream crashes + }) + def __init__(self, source, *, data, requester): super().__init__(source) self.requester = requester - self.title = data.get('title') - self.web_url = data.get('webpage_url') - self.duration = data.get('duration') + # YouTube Metadata + self.title = data.get("title") + self.web_url = data.get("webpage_url") + self.thumbnail_url = data.get("thumbnail") + self.duration = data.get("duration") - self.artist = '' - self.song_title = '' + # Song metadata + self.search_term = "" + self.artist = "" + self.song_title = "" # YTDL info dicts (data) have other useful information you might want # https://github.com/rg3/youtube-dl/blob/master/README.md @@ -79,121 +148,382 @@ class YTDLSource(discord.PCMVolumeTransformer): return self.__getattribute__(item) @classmethod - async def create_source(cls, ctx, search: str, *, loop, download=False, silent=False, artist='Unknown', song_title='Unknown'): - loop = loop or asyncio.get_event_loop() + async def create_source( + cls, + ctx, + search: str, + *, + download=False, + artist="", + song_title="", + ): + loop = ctx.bot.loop if ctx else asyncio.get_event_loop() - # Find out which song the user wants + # If we got a YouTube link, get the video title for the song search + if validators.url(search): + with YoutubeDL() as ydl: + info = ydl.extract_info(search, download=False) + search_term = info.get("title", "") + else: + search_term = search + + # Get song metadata + logger.info(f"Searching LastFM for: '{search_term}'") + url = f"http://ws.audioscrobbler.com/2.0/?method=track.search&"\ + f"track={search_term}&api_key={LASTFM_API_KEY}&format=json" + response = requests.get(url) + lastfm_data = response.json() + # Let's get the first result, if any + if lastfm_data['results']['trackmatches']['track']: + track = lastfm_data['results']['trackmatches']['track'][0] + artist = track['artist'] + song_title = track['name'] + + # Adjust search term if we didn't get a URL if not validators.url(search): - # Fetch song metadata - url = f'http://ws.audioscrobbler.com/2.0/?method=track.search&track={search}&api_key={LASTFM_API_KEY}&format=json' - response = requests.get(url) - lastfm_data = response.json() + search = f"{song_title} {artist} official audio" - # Let's get the first result, if any - if lastfm_data['results']['trackmatches']['track']: - track = lastfm_data['results']['trackmatches']['track'][0] - search = track['artist'] + ' ' + track['name'] + ' album version' - artist = track['artist'] - song_title = track['name'] - else: - return - - # Get source - to_run = partial(ytdl.extract_info, url=search, download=download) + # Get YouTube video source + logger.info(f"Getting YouTube video: {search_term}") + to_run = partial(cls._downloader.extract_info, url=search, download=download) data = await loop.run_in_executor(None, to_run) - if 'entries' in data: + # There's an error with yt-dlp that throws a 403: Forbidden error, so + # only proceed if it returns anything + if data and "entries" in data: # take first item from a playlist - data = data['entries'][0] + data = data["entries"][0] + # Get either source filename or URL, depending on if we're downloading if download: - source = ytdl.prepare_filename(data) + source = cls._downloader.prepare_filename(data) else: - return {'webpage_url': data['webpage_url'], 'requester': ctx.author, 'title': data['title']} + source = data["url"] + logger.info(f"Using source: {data["webpage_url"]}") - ffmpeg_source = cls(discord.FFmpegPCMAudio(source), data=data, requester=ctx.author) + ffmpeg_source = cls( + discord.FFmpegPCMAudio(source, before_options="-nostdin", options="-vn"), + data=data, + requester=ctx.author if ctx else None, + ) + # TODO: ADD THESE TO THE CONSTRUCTOR + ffmpeg_source.search_term = search_term + # ffmpeg_source.song_title = data["title"] ffmpeg_source.artist = artist ffmpeg_source.song_title = song_title ffmpeg_source.filename = source - if not silent: - embed = discord.Embed(title="Queued", description=f"[{ffmpeg_source.song_title} by {ffmpeg_source.artist}]({data['webpage_url']})", color=discord.Color.green()) - await ctx.send(embed=embed) - return ffmpeg_source - @classmethod - async def regather_stream(cls, data, *, loop): - """Used for preparing a stream, instead of downloading. - Since Youtube Streaming links expire.""" - loop = loop or asyncio.get_event_loop() - requester = data['requester'] - - to_run = partial(ytdl.extract_info, url=data['webpage_url'], download=False) - data = await loop.run_in_executor(None, to_run) - - return cls(discord.FFmpegPCMAudio(data['url']), data=data, requester=requester) - - class MusicPlayer: - """A class which is assigned to each guild using the bot for Music. - This class implements a queue and loop, which allows for different guilds to listen to different playlists - simultaneously. - When the bot disconnects from the Voice it's instance will be destroyed. + """ + A class used to play music in a voice channel. + + This class implements a queue and play loop that plays music in a single + guild. Since each player is assigned to a single voice channel, it allows + multiple guilds to use the bot simultaneously. + + Methods: + player_loop() -> None: + Provides the main loop that waits for requests and plays songs. + update_now_playing_message(repost[bool], emoji[str]) -> None: + Updates the channel message that states what song is currently + being played in the voice channel. """ - __slots__ = ('bot', '_guild', '_channel', '_cog', 'queue', 'next', 'current', 'np', 'volume') + __slots__ = ( + "bot", + "_guild", + "_channel", + "_cog", + "_np", + "_state", + "_queue", + "_next", + "_skipped", + "current", + "np", + "volume", + "dj_mode", + "_view", + ) + + # Each player is assiciated with a guild, so create a lock for when we do + # volatile things in the server like delete previous messages + _guild_lock = asyncio.Lock() + + class State(enum.Enum): + IDLE=1 + PLAYING=2 + PAUSED=3 + + def __init__(self, ctx: discord.ext.commands.Context): + """ + Initializes the music player object associated with the given Discord + context. + + Args: + ctx (discord.ext.commands.Context): + The context within the player will connect to play music and + respond to requests. + """ + # Ensure proper cleanup + atexit.register(self.__del__) - def __init__(self, ctx): self.bot = ctx.bot self._guild = ctx.guild self._channel = ctx.channel self._cog = ctx.cog + self._np = None # 'Now Playing' message - self.queue = asyncio.Queue() - self.next = asyncio.Event() + self._state = self.State.IDLE - self.np = None # Now playing message - self.volume = .5 + self._queue = asyncio.Queue() + self._next = asyncio.Event() + self._skipped = False # Flag for skipping songs + + self.volume = 0.5 self.current = None + self.dj_mode = False ctx.bot.loop.create_task(self.player_loop()) - async def player_loop(self): - """Our main player loop.""" + def __del__(self): + """ + Cleanup music player, which includes deleting messages like the + 'Now Playing' message. + """ + if self._np: + asyncio.run(self._np.delete()) + + async def _change_state(self, new_state: "MusicPlayer.State" = None): + """When state changes, update the Discord 'Now Playing' message.""" + if not self._channel: + return + + # 'None' state is used to refresh message without changing state + if new_state is not None: + self._state = new_state + + logger.info("Updating 'Now Playing' message") + await self.bot.wait_until_ready() + async with self._guild_lock: + # Create new 'Now Playing' message + if self._state is self.State.IDLE: + embed = discord.Embed( + title=f"◻️ Idle", color=discord.Color.light_gray() + ) + elif self._state is self.State.PLAYING: + embed = discord.Embed( + title=f"▶️ Now Playing", color=discord.Color.blue() + ) + elif self._state is self.State.PAUSED: + embed = discord.Embed( + title=f"⏸️ Paused", color=discord.Color.light_gray() + ) + else: + embed = discord.Embed( + title="UNKNOWN STATE", color=discord.Color.red() + ) + + # Get and add the thumbnail + if self._state in [self.State.PLAYING, self.State.PAUSED]: + embed.set_thumbnail(url=self.current.thumbnail_url) + embed.add_field( + name="", + value=( + f"[{self.current.song_title}]({self.current.web_url}) - " + f"{self.current.artist}" + ), + inline=False, + ) + + # Add all upcoming songs + # Possibly dangerous, but only obvious solution + queue = [s for s in self._queue._queue if s is not None] + if len(queue) > 0: + value_str = "" + for i, song in enumerate(queue): + value_str += ( + f"{i+1}. [{song.song_title}]({song.web_url}) -" + f" {song.artist}\n" + ) + embed.add_field(name="Queue", value=value_str, inline=False) + + # Build controls + controls = discord.ui.View(timeout=None) + # Construct 'back' button + prev_button = discord.ui.Button( + label="⏮️", + style=discord.ButtonStyle.secondary, + custom_id="prev" + ) + #prev_button.disabled = self._player.current + prev_button.disabled = True + #prev_button.callback = + controls.add_item(prev_button) + + # Construct 'play/pause' button + play_button = discord.ui.Button( + label="▶️" if self._state is self.State.PAUSED else "⏸️", + style=discord.ButtonStyle.secondary, + custom_id="playpause" + ) + play_button.disabled = self._state is self.State.IDLE + if self._state is self.State.PLAYING: + play_button.callback = self.pause + elif self._state is self.State.PAUSED: + play_button.callback = self.resume + controls.add_item(play_button) + + # Construct 'next' button + next_button = discord.ui.Button( + label="⏭️", + style=discord.ButtonStyle.secondary, + custom_id="next" + ) + next_button.disabled = self._state is self.State.IDLE + next_button.callback = self.next + controls.add_item(next_button) + + # If last post is the 'Now Playing' message, just update it + last_message = [m async for m in self._channel.history(limit=1)] + if last_message[0] and self._np and last_message[0].id == self._np.id: + await self._np.edit(embed=embed, view=controls) + else: + if self._np: + self._np = await self._np.delete() + self._np = await self._channel.send(embed=embed, view=controls) + + async def resume(self, interaction: discord.Interaction = None): + if interaction: await interaction.response.defer() + vc = self._guild.voice_client + if not vc or not vc.is_connected(): + return + if vc.is_paused(): + vc.resume() + await self._change_state(self.State.PLAYING) + + async def pause(self, interaction: discord.Interaction = None): + if interaction: await interaction.response.defer() + vc = self._guild.voice_client + if not vc or not vc.is_connected(): + return + if vc.is_playing(): + vc.pause() + await self._change_state(self.State.PAUSED) + + async def previous(self, interaction: discord.Interaction = None): + pass + + async def next(self, interaction: discord.Interaction = None): + if interaction: await interaction.response.defer() + vc = self._guild.voice_client + if not vc.is_playing() and not vc.is_paused(): + return + self._skipped = True # Notify loop that we skipped the song + vc.stop() + + async def queue(self, source: YTDLSource): + await self._queue.put(source) + await self._change_state(None) + + async def player_loop(self, interaction: discord.Interaction = None): + """ + The main loop that waits for song requests and plays music accordingly. + """ await self.bot.wait_until_ready() while not self.bot.is_closed(): - self.next.clear() + self._next.clear() + await self._change_state(self.State.IDLE) - try: - # Wait for the next song. If we timeout cancel the player and disconnect... - async with timeout(300): # 5 minutes... - source = await self.queue.get() - except asyncio.TimeoutError: - return self.destroy(self._guild) + # Always get a song if there's one in the queue + if self._queue.qsize() > 0 or self.dj_mode is False: + logger.info("Getting song from play queue") + try: + # Wait for the next song. If we timeout cancel the player + # and disconnect... + async with timeout(300): # 5 minutes... + source = await self._queue.get() + except asyncio.TimeoutError: + return await self.destroy() + # Otherwise we're in DJ mode and a user hasn't requested one, so + # pick a song at random and create a source for it + else: + logger.info( + "Queue is empty and DJ mode is on. Picking song at random" + ) + try: + source = await YTDLSource.create_source( + None, + random.choice(songs), + download=True, + ) + if not source: + raise RuntimeError("Could not get YouTube source.") + except Exception as e: + print(e) + await self._channel.send("Failed to get YouTube source.") + + # For the time being, we're going to use 'None' to signal to the + # player that it should go back around and check for a song again, + # mainly because DJ mode was switched on and it should pick a song + # at random this time + if source is None: + continue if not isinstance(source, YTDLSource): # Source was probably a stream (not downloaded) # So we should regather to prevent stream expiration try: - source = await YTDLSource.regather_stream(source, loop=self.bot.loop) + source = await YTDLSource.regather_stream( + source, loop=self.bot.loop + ) except Exception as e: - await self._channel.send(f'There was an error processing your song.\n' - f'```css\n[{e}]\n```') + await self._channel.send( + "There was an error processing your" + f" song.\n```css\n[{e}]\n```" + ) continue source.volume = self.volume self.current = source - self._guild.voice_client.play(source, after=lambda _: self.bot.loop.call_soon_threadsafe(self.next.set)) + logger.info(f"Playing '{source.song_title}' by '{source.artist}'") + row_id = self.bot.db.insert_song_play(self._channel.id, source) + def song_finished(error): + # Update database to reflect song finishing + if not error: + self.bot.db.update_song_play(row_id, not self._skipped) + self._skipped = False + logger.info(f"Song finiehd with error: {error}") + self.bot.loop.call_soon_threadsafe(self._next.set) + try: + self._guild.voice_client.play( + source, + after=song_finished + ) + logger.info("Updating presense and 'now playing' message") + await self.bot.change_presence( + activity=discord.Activity( + type=discord.ActivityType.custom, + name="custom", + state=f"🎵 {source.song_title} by {source.artist}", + ) + ) + except Exception as e: + # Post error message + embed = discord.Embed( + title=f"Error: {str(e)}", color=discord.Color.red() + ) + await self._channel.send(embed=embed) + raise e - await self.bot.change_presence(activity=discord.Activity(type=discord.ActivityType.custom, name="custom", state=f"🎵 {source.song_title} by {source.artist}")) - embed = discord.Embed(title="Now playing", description=f"[{source.song_title} by {source.artist}]({source.web_url})", color=discord.Color.green()) - self.np = await self._channel.send(embed=embed) - await self.next.wait() + logger.info("Waiting for song to finish") + await self._change_state(self.State.PLAYING) + await self._next.wait() - await self.bot.change_presence(status=None) if os.path.exists(source.filename): os.remove(source.filename) @@ -204,36 +534,51 @@ class MusicPlayer: pass self.current = None - def destroy(self, guild): + # Update bot statuses to match no song playing + await self.bot.change_presence(status=None) + + async def destroy(self): """Disconnect and cleanup the player.""" - return self.bot.loop.create_task(self._cog.cleanup(guild)) + if self._np: + self._np = await self._np.delete() + try: + return await self._cog.cleanup(self._guild) + except: + return None class Music(commands.Cog): """Music related commands.""" - __slots__ = ('bot', 'players') + __slots__ = ("bot", "players") def __init__(self, bot): self.bot = bot self.players = {} - self.last_tag_play_time = datetime.datetime.now() - datetime.timedelta(minutes=30) +<<<<<<< HEAD + self.last_tag_play_time = datetime.datetime.now() - def update_cache(): - with yt_dlp.YoutubeDL({'quiet': True}) as ydl: - self.[REDACTED]_tracks = ydl.extract_info('https://soundcloud.com/[REDACTED]', download=False)['entries'] + # Get a reference to the database + # TODO: MAKE THIS INJECTED + self.db = database.Database("[REDACTED]_bot.db") - with open('soundcloud-cache', 'w') as f: - f.write(str(self.[REDACTED]_tracks)) - # pickle.dump(self.[REDACTED]_tracks, f) + # def update_cache(): + # with yt_dlp.YoutubeDL({'quiet': True}) as ydl: + # self.[REDACTED]_tracks = ydl.extract_info('https://soundcloud.com/[REDACTED]', download=False)['entries'] - if os.path.exists('soundcloud-cache'): - with open('soundcloud-cache', 'r') as f: - exec(f'self.[REDACTED]_tracks = {f.read()}') - # self.[REDACTED_tracks = pickle.load(f) - threading.Thread(target=update_cache).start() - else: - update_cache() + # with open('soundcloud-cache', 'w') as f: + # f.write(str(self.[REDACTED]_tracks)) + # # pickle.dump(self.[REDACTED]_tracks, f) + + # if os.path.exists('soundcloud-cache'): + # with open('soundcloud-cache', 'r') as f: + # exec(f'self.[REDACTED]_tracks = {f.read()}') + # # self.[REDACTED]_tracks = pickle.load(f) + # threading.Thread(target=update_cache).start() + # else: + # update_cache() +======= +>>>>>>> 669339f (Made player controls based on Discord actions.) async def cleanup(self, guild): try: @@ -247,24 +592,38 @@ class Music(commands.Cog): pass async def __local_check(self, ctx): - """A local check which applies to all commands in this cog.""" + """ + A local check which applies to all commands in this cog and prevents + its use in private messages. + """ if not ctx.guild: raise commands.NoPrivateMessage return True async def __error(self, ctx, error): - """A local error handler for all errors arising from commands in this cog.""" + """ + A local error handler for all errors arising from commands in this cog. + """ if isinstance(error, commands.NoPrivateMessage): try: - return await ctx.send('This command can not be used in Private Messages.') + return await ctx.send( + "This command can not be used in Private Messages." + ) except discord.HTTPException: pass elif isinstance(error, InvalidVoiceChannel): - await ctx.send('Error connecting to Voice Channel. ' - 'Please make sure you are in a valid channel or provide me with one') + await ctx.send( + "Error connecting to Voice Channel. Please make sure you are" + " in a valid channel or provide me with one" + ) - print('Ignoring exception in command {}:'.format(ctx.command), file=sys.stderr) - traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) + print( + "Ignoring exception in command {}:".format(ctx.command), + file=sys.stderr, + ) + traceback.print_exception( + type(error), error, error.__traceback__, file=sys.stderr + ) def get_player(self, ctx): """Retrieve the guild player, or generate one.""" @@ -276,8 +635,10 @@ class Music(commands.Cog): return player - @commands.command(name='join', aliases=['connect', 'j'], description="connects to voice") - async def connect_(self, ctx, *, channel: discord.VoiceChannel=None): + @commands.command( + name="join", aliases=["connect", "j"], description="connects to voice" + ) + async def connect_(self, ctx, *, channel: discord.VoiceChannel = None): """Connect to voice. Parameters ------------ @@ -290,9 +651,19 @@ class Music(commands.Cog): try: channel = ctx.author.voice.channel except AttributeError: - embed = discord.Embed(title="", description="No channel to join. Please call `,join` from a voice channel.", color=discord.Color.green()) + embed = discord.Embed( + title="", + description=( + "No channel to join. Please call `,join` from a voice" + " channel." + ), + color=discord.Color.green(), + ) await ctx.send(embed=embed) - raise InvalidVoiceChannel('No channel to join. Please either specify a valid channel or join one.') + raise InvalidVoiceChannel( + "No channel to join. Please either specify a valid channel" + " or join one." + ) vc = ctx.voice_client @@ -302,85 +673,154 @@ class Music(commands.Cog): try: await vc.move_to(channel) except asyncio.TimeoutError: - raise VoiceConnectionError(f'Moving to channel: <{channel}> timed out.') + raise VoiceConnectionError( + f"Moving to channel: <{channel}> timed out." + ) else: try: await channel.connect() except asyncio.TimeoutError: - raise VoiceConnectionError(f'Connecting to channel: <{channel}> timed out.') - if (random.randint(0, 1) == 0): - await ctx.message.add_reaction('👍') - # await ctx.send(f'**Joined `{channel}`**') + raise VoiceConnectionError( + f"Connecting to channel: <{channel}> timed out." + ) + # await ctx.message.add_reaction('👍') - @commands.command(name='play', aliases=['sing','p'], description="streams music") + @commands.command(name="play", aliases=["p", "queue", "q"]) async def play_(self, ctx, *, search: str = None): - """Request a song and add it to the queue. - This command attempts to join a valid voice channel if the bot is not already in one. - Uses YTDL to automatically search and retrieve a song. - Parameters - ------------ - search: str [Required] - The song to search and retrieve using YTDL. This could be a simple search, an ID or URL. - """ - vc = ctx.voice_client + """Plays the given song in a voice channel. + This method takes a string describing the song to play and plays it. In + the event that a song is already being played, the new one is added to + a queue of songs. + + Args: + search (str): The search term or URL used to find the song. + + Example: + !play Play That Funky Music by Wild Cherry + """ + # Ensure we're connected to the proper voice channel + vc = ctx.voice_client if not vc: await ctx.invoke(self.connect_) - player = self.get_player(ctx) + # Send message to say we're working on it + embed = discord.Embed( + title=f"🔎 Searching for:", + description=f"{search}", + color=discord.Color.green(), + ) + message = await ctx.channel.send(embed=embed) - # Play tag every 30 minutes - if datetime.datetime.now() - self.last_tag_play_time > datetime.timedelta(minutes=30): - self.last_tag_play_time = datetime.datetime.now() - source = await YTDLSource.create_source(ctx, [REDACTED], loop=self.bot.loop, - download=True, silent=True, artist=[REDACTED], song_title="Tag") - await player.queue.put(source) + # Create source + try: + source = await YTDLSource.create_source( + ctx, search, download=True + ) + # Track song requests in database + self.bot.db.insert_song_request(message, source) + # Add song to the corresponding player object + player = self.get_player(ctx) + await player.queue(source) + # Update previous message to show found song and video + embed = discord.Embed( + title=f"Queued", + description=( + f"[{source.song_title}]({source.web_url}) -" + f" {source.artist}" + ), + color=discord.Color.green(), + ) + embed.set_thumbnail(url=source.thumbnail_url) + await message.edit(embed=embed) + except Exception as e: + # Gracefully tell user there was an issue + embed = discord.Embed( + title=f"ERROR", + description=f"{str(e)}", + color=discord.Color.red(), + ) + await message.edit(embed=embed) + raise e - # If no song is given, pick a soundcloud track at random - if not search: - track = random.choice(self.[REDACTED]_tracks) - source = await YTDLSource.create_source(ctx, track['formats'][0]['url'], loop=self.bot.loop, - download=True, silent=True, artist=track['uploader'], song_title=track['title']) + @commands.command( + name="djmode", aliases=["dj"], description="Turns DJ mode on or off." + ) + async def djmode_(self, ctx, *, mode: str = "on"): + """Turns DJ mode on or off. When on, the bot will play songs + automatically.""" + # Ensure we're connected to the proper voice channel + vc = ctx.voice_client + if not vc: + await ctx.invoke(self.connect_) + # Get desired mode + mode = mode.lower().strip() + if mode in ("true", "t", "yes", "y", "on"): + mode = True + elif mode in ("false", "f", "no", "n", "off"): + mode = False else: - source = await YTDLSource.create_source(ctx, search, loop=self.bot.loop, download=True) + return + # Switch to desired mode + player = self.get_player(ctx) + player.dj_mode = mode + # Break player out of waiting on queue so it can pick a song at random + if player.dj_mode: + await player.queue(None) - await player.queue.put(source) - - @commands.command(name='pause', description="pauses music") + @commands.command(name="pause", description="pauses music") async def pause_(self, ctx): """Pause the currently playing song.""" vc = ctx.voice_client if not vc or not vc.is_playing(): - embed = discord.Embed(title="", description="I am currently not playing anything", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I am currently not playing anything", + color=discord.Color.green(), + ) return await ctx.send(embed=embed) elif vc.is_paused(): return vc.pause() - await ctx.send("Paused ⏸️") - @commands.command(name='resume', description="resumes music") + # Update the 'Now Playing' message to reflect its paused + player = self.get_player(ctx) + await player.update_now_playing_message(emoji="⏸️") + + @commands.command(name="resume", description="resumes music") async def resume_(self, ctx): """Resume the currently paused song.""" vc = ctx.voice_client if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I'm not connected to a voice channel", + color=discord.Color.green(), + ) return await ctx.send(embed=embed) elif not vc.is_paused(): return vc.resume() - await ctx.send("Resuming ⏯️") - @commands.command(name='skip', description="skips to next song in queue") + # Update the 'Now Playing' message to reflect its resumed + player = self.get_player(ctx) + await player.update_now_playing_message() + + @commands.command(name="skip", description="skips to next song in queue") async def skip_(self, ctx): """Skip the song.""" vc = ctx.voice_client if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I'm not connected to a voice channel", + color=discord.Color.green(), + ) return await ctx.send(embed=embed) if vc.is_paused(): @@ -390,14 +830,22 @@ class Music(commands.Cog): vc.stop() - @commands.command(name='remove', aliases=['rm', 'rem'], description="removes specified song from queue") - async def remove_(self, ctx, pos : int=None): + @commands.command( + name="remove", + aliases=["rm"], + description="removes specified song from queue", + ) + async def remove_(self, ctx, pos: int = None): """Removes specified song from queue""" vc = ctx.voice_client if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I'm not connected to a voice channel", + color=discord.Color.green(), + ) return await ctx.send(embed=embed) player = self.get_player(ctx) @@ -405,110 +853,91 @@ class Music(commands.Cog): player.queue._queue.pop() else: try: - s = player.queue._queue[pos-1] - del player.queue._queue[pos-1] - embed = discord.Embed(title="", description=f"Removed [{s['title']}]({s['webpage_url']}) [{s['requester'].mention}]", color=discord.Color.green()) + s = player.queue._queue[pos - 1] + del player.queue._queue[pos - 1] + embed = discord.Embed( + title="", + description=( + f"Removed [{s['title']}]({s['webpage_url']})" + f" [{s['requester'].mention}]" + ), + color=discord.Color.green(), + ) await ctx.send(embed=embed) except: - embed = discord.Embed(title="", description=f'Could not find a track for "{pos}"', color=discord.Color.green()) + embed = discord.Embed( + title="", + description=f'Could not find a track for "{pos}"', + color=discord.Color.green(), + ) await ctx.send(embed=embed) - @commands.command(name='clear', aliases=['clr', 'cl', 'cr'], description="clears entire queue") + @commands.command( + name="clear", + aliases=["clr", "cl", "cr"], + description="clears entire queue", + ) async def clear_(self, ctx): - """Deletes entire queue of upcoming songs.""" - + """ + Deletes entire queue of upcoming songs. + + Args: + ctx (discord.ext.commands.Context): The Discord context associated + with the message. + """ vc = ctx.voice_client - if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I am not currently connected to a voice channel.", + color=discord.Color.yellow(), + ) return await ctx.send(embed=embed) player = self.get_player(ctx) player.queue._queue.clear() - await ctx.send('**Cleared**') + await ctx.send("**Cleared**") - @commands.command(name='queue', aliases=['q', 'playlist', 'que'], description="shows the queue") - async def queue_info(self, ctx): - """Retrieve a basic queue of upcoming songs.""" - vc = ctx.voice_client + @commands.command( + name="volume", + aliases=["vol", "v"], + description="Sets the bot's volume in the voice channel.", + ) + async def change_volume(self, ctx, *, vol: float = None): + """ + Change the player volume. - if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) - return await ctx.send(embed=embed) - - player = self.get_player(ctx) - if player.queue.empty(): - embed = discord.Embed(title="", description="The queue is empty", color=discord.Color.green()) - return await ctx.send(embed=embed) - - seconds = vc.source.duration % (24 * 3600) - hour = seconds // 3600 - seconds %= 3600 - minutes = seconds // 60 - seconds %= 60 - if hour > 0: - duration = "%dh %02dm %02ds" % (hour, minutes, seconds) - else: - duration = "%02dm %02ds" % (minutes, seconds) - - # Grabs the songs in the queue... - upcoming = list(itertools.islice(player.queue._queue, 0, int(len(player.queue._queue)))) - # fmt = '\n'.join(f"`{(upcoming.index(_)) + 1}.` [{_['title']}]({_['webpage_url']}) | ` {duration} Requested by: {_['requester']}`\n" for _ in upcoming) - fmt = '\n'.join(f"`{(upcoming.index(_)) + 1}.` {_['title']} | ` {duration} Requested by: {_['requester']}`\n" for _ in upcoming) - fmt = f"\n__Now Playing__:\n[{vc.source.title}]({vc.source.web_url})\n\n__Up Next:__\n" + fmt - embed = discord.Embed(title=f'Queue for {ctx.guild.name}', description=fmt, color=discord.Color.green()) - # embed.set_footer(text=f"{ctx.author.display_name}", icon_url=ctx.author.avatar_url) - - await ctx.send(embed=embed) - - @commands.command(name='np', aliases=['song', 'current', 'currentsong', 'playing'], description="shows the current playing song") - async def now_playing_(self, ctx): - """Display information about the currently playing song.""" - vc = ctx.voice_client - - if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) - return await ctx.send(embed=embed) - - player = self.get_player(ctx) - if not player.current: - embed = discord.Embed(title="", description="I am currently not playing anything", color=discord.Color.green()) - return await ctx.send(embed=embed) - - seconds = vc.source.duration % (24 * 3600) - hour = seconds // 3600 - seconds %= 3600 - minutes = seconds // 60 - seconds %= 60 - if hour > 0: - duration = "%dh %02dm %02ds" % (hour, minutes, seconds) - else: - duration = "%02dm %02ds" % (minutes, seconds) - - embed = discord.Embed(title="", description=f"[{vc.source.title}]({vc.source.web_url}) [{vc.source.requester.mention}] | `{duration}`", color=discord.Color.green()) - embed.set_author(icon_url=self.bot.user.avatar_url, name=f"Now Playing 🎶") - await ctx.send(embed=embed) - - @commands.command(name='volume', aliases=['vol', 'v'], description="changes Kermit's volume") - async def change_volume(self, ctx, *, vol: float=None): - """Change the player volume. - Parameters - ------------ - volume: float or int [Required] - The volume to set the player to in percentage. This must be between 1 and 100. + Args: + ctx (discord.ext.commands.Context): The Discord context associated + with the message. + volume (float, int, required): + The volume to set the player to in percentage. This must be + between 1 and 100. """ vc = ctx.voice_client if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I am not currently connected to voice", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I am not currently connected to a voice channel.", + color=discord.Color.yellow(), + ) return await ctx.send(embed=embed) if not vol: - embed = discord.Embed(title="", description=f"🔊 **{(vc.source.volume)*100}%**", color=discord.Color.green()) + embed = discord.Embed( + title="", + description=f"🔊 **{(vc.source.volume)*100}%**", + color=discord.Color.green(), + ) return await ctx.send(embed=embed) if not 0 < vol < 101: - embed = discord.Embed(title="", description="Please enter a value between 1 and 100", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="Please enter a value between 1 and 100", + color=discord.Color.green(), + ) return await ctx.send(embed=embed) player = self.get_player(ctx) @@ -517,25 +946,40 @@ class Music(commands.Cog): vc.source.volume = vol / 100 player.volume = vol / 100 - embed = discord.Embed(title="", description=f'**`{ctx.author}`** set the volume to **{vol}%**', color=discord.Color.green()) + embed = discord.Embed( + title="", + description=f"**`{ctx.author}`** set the volume to **{vol}%**", + color=discord.Color.green(), + ) await ctx.send(embed=embed) - @commands.command(name='leave', aliases=["stop", "dc", "disconnect", "bye"], description="stops music and disconnects from voice") - async def leave_(self, ctx): - """Stop the currently playing song and destroy the player. - !Warning! - This will destroy the player assigned to your guild, also deleting any queued songs and settings. + @commands.command( + name="leave", + aliases=["stop", "dc", "disconnect", "bye"], + description="Stops music and disconnects from voice.", + ) + async def leave_(self, ctx: discord.ext.commands.Context): + """ + Stop the currently playing song and destroy the player. + + Args: + ctx (discord.ext.commands.Context): The Discord context associated + with the message. + + Notes: + This will destroy the player assigned to your guild, also deleting + any queued songs and settings. """ vc = ctx.voice_client - if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I am not currently connected to a voice channel.", + color=discord.Color.yellow(), + ) return await ctx.send(embed=embed) - if (random.randint(0, 1) == 0): - await ctx.message.add_reaction('👋') - #await ctx.send('**Successfully disconnected**') - + await ctx.message.add_reaction("👋") await self.cleanup(ctx.guild) diff --git a/database.py b/database.py new file mode 100644 index 0000000..39b8261 --- /dev/null +++ b/database.py @@ -0,0 +1,470 @@ +from datetime import datetime, timedelta +import discord +import sqlite3 +import typing + +from cogs import music_player + +class Database: +<<<<<<< HEAD + def __init__(self, path: str = "[REDACTED]_bot.db"): +======= + def __init__(self, path: str): +>>>>>>> 669339f (Made player controls based on Discord actions.) + self.path = path + self._ensure_db() + + def _ensure_db(self): + with sqlite3.connect(self.path) as conn: + + # Table for keeping track of servers + conn.execute(""" + CREATE TABLE IF NOT EXISTS server ( + id INTEGER PRIMARY KEY, + discord_id INTEGER NOT NULL UNIQUE + ) + """) + + # Table for keeping track of channels + conn.execute(""" + CREATE TABLE IF NOT EXISTS channel ( + id INTEGER PRIMARY KEY, + discord_id INTEGER NOT NULL UNIQUE + ) + """) + + # Table for keeping track of users + conn.execute(""" + CREATE TABLE IF NOT EXISTS user ( + id INTEGER PRIMARY KEY, + discord_id INTEGER NOT NULL UNIQUE + ) + """) + + # Create the activity table + conn.execute(""" + CREATE TABLE IF NOT EXISTS activity_change ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + before_activity_type TEXT, + before_activity_name TEXT, + before_activity_status TEXT NOT NULL, + after_activity_type TEXT, + after_activity_name TEXT, + after_activity_status TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """) + + # Create the song request table + conn.execute(""" + CREATE TABLE IF NOT EXISTS song_request ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL, + search_term TEXT NOT NULL, + song_title TEXT, + song_artist TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """) + + + # # TEMP + # conn.execute(""" + # ALTER TABLE song_play ADD COLUMN finished BOOL; + # """) + + + # Table for songs that actually get played + conn.execute(""" + CREATE TABLE IF NOT EXISTS song_play ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + channel_id INTEGER NOT NULL, + search_term TEXT NOT NULL, + song_title TEXT, + song_artist TEXT, + finished BOOL DEFAULT 0, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """) + + # # ############ TEMP ############### + # conn.execute("""DROP TABLE IF EXISTS song_play_old;""") + + # conn.execute(""" + # ALTER TABLE + # song_play + # RENAME TO + # song_play_old; + # """) + + # conn.execute(""" + # CREATE TABLE song_play ( + # id INTEGER PRIMARY KEY, + # user_id INTEGER, + # channel_id INTEGER NOT NULL, + # search_term TEXT NOT NULL, + # song_title TEXT, + # song_artist TEXT, + # finished BOOLEAN DEFAULT 0, + # timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + # ); + # """) + + # conn.execute(""" + # INSERT INTO song_play ( + # id, + # user_id, + # channel_id, + # search_term, + # song_title, + # song_artist, + # timestamp + # ) SELECT + # id, + # user_id, + # channel_id, + # search_term, + # song_title, + # song_artist, + # timestamp + # FROM song_play_old; + # """) + + # conn.execute(""" + # DROP TABLE song_play_old; + # """) + # # ################################## + + conn.commit() + + def _insert_server(self, discord_id: int = None) -> int: + """ + Inserts Discord server ID into the 'server' table. + + This method takes an ID for a server used in Discord, and inserts it + into the database. It ignores the case where the server ID is already + present. It then returns the row ID regardless. + + Args: + discord_id (int): The ID used to identify the server in Discord. + + Returns: + int: The ID of the server in the server table. + + Examples: + >>> db = Database("path.db") + >>> db._insert_server(850610922256442889) + 12 + """ + with sqlite3.connect(self.path) as conn: + cursor = conn.cursor() + # Insert it; ignoring already exists error + cursor.execute(""" + INSERT INTO server (discord_id) + VALUES (?) + ON CONFLICT(discord_id) DO NOTHING + RETURNING id; + """, (discord_id,)) + row = cursor.fetchone() + if row: + row_id = row[0] + else: + # Get row ID if it already exists and wasn't inserted + cursor.execute(""" + SELECT id FROM server WHERE discord_id = ? + """, (discord_id,)) + row_id = cursor.fetchone()[0] + return row_id + + def _insert_channel(self, discord_id: int = None) -> int: + """ + Inserts Discord channel ID into the 'channel' table. + + This method takes an ID for a channel used in Discord, and inserts it + into the database. It ignores the case where the channel ID is already + present. It then returns the row ID regardless. + + Args: + discord_id (int): The ID used to identify the channel in Discord. + + Returns: + int: The ID of the channel in the channel table. + + Examples: + >>> db = Database("path.db") + >>> db._insert_channel(8506109222564428891) + 12 + """ + with sqlite3.connect(self.path) as conn: + cursor = conn.cursor() + # Insert it; ignoring already exists error + cursor.execute(""" + INSERT INTO channel (discord_id) + VALUES (?) + ON CONFLICT(discord_id) DO NOTHING + RETURNING id; + """, (discord_id,)) + row = cursor.fetchone() + if row: + row_id = row[0] + else: + # Get row ID if it already exists and wasn't inserted + cursor.execute(""" + SELECT id FROM channel WHERE discord_id = ? + """, (discord_id,)) + row_id = cursor.fetchone()[0] + return row_id + + def _insert_user(self, discord_id: int = None) -> int: + """ + Inserts Discord user ID into the 'user' table. + + This method takes an ID for a user used in Discord, and inserts it + into the database. It ignores the case where the user ID is already + present. It then returns the row ID regardless. + + Args: + discord_id (int): The ID used to identify the user in Discord. + + Returns: + int: The ID of the user in the user table. + + Examples: + >>> db = Database("path.db") + >>> db._insert_user(850610922256442889) + 12 + """ + with sqlite3.connect(self.path) as conn: + cursor = conn.cursor() + # Insert it; ignoring already exists error + cursor.execute(""" + INSERT INTO user (discord_id) + VALUES (?) + ON CONFLICT(discord_id) DO NOTHING + RETURNING id; + """, (discord_id,)) + row = cursor.fetchone() + if row: + row_id = row[0] + else: + # Get row ID if it already exists and wasn't inserted + cursor.execute(""" + SELECT id FROM user WHERE discord_id = ? + """, (discord_id,)) + row_id = cursor.fetchone()[0] + return row_id + + def insert_activity_change( + self, + before: discord.Member, + after: discord.Member): + """ + Inserts an activity change into the database. + + This method takes two discord.Memeber objects, and records the change + in activity into the 'activity_change' table. + + Args: + before (discord.Member): The previous user status. + after (discord.Member): The current user status. + + Raises: + ValueError: If the before and after activity do not refer to the + same user. + + Examples: + >>> @commands.Cog.listener() + >>> async def on_presence_update( + ... self, + ... before: discord.Member, + ... after: discord.Member): + ... db = Database("path.db") + ... db.insert_activity_change(before, after) + >>> + """ + # Ensure the users are the same + if before.id != after.id: + raise ValueError("User IDs do not match.") + user_id = self._insert_user(before.id) + # Get activities if they exist + before_type = before.activity.type.name if before.activity else None + before_name = before.activity.name if before.activity else None + after_type = after.activity.type.name if after.activity else None + after_name = after.activity.name if after.activity else None + # Insert the activity change + with sqlite3.connect(self.path) as conn: + conn.execute(""" + INSERT INTO activity_change ( + user_id, + before_activity_type, + before_activity_name, + before_activity_status, + after_activity_type, + after_activity_name, + after_activity_status + ) VALUES ( + ?, ?, ?, ?, ?, ?, ? + ) + """, ( + user_id, + before_type, + before_name, + before.status.name, + after_type, + after_name, + after.status.name + )) + + def insert_song_request( + self, + message: discord.Message, + source: music_player.YTDLSource): + """ + Inserts a song request into the database. + + This method takes a message and its derived music source and inserts + the relevant information into the 'song_request' table. + + Args: + message (discord.Message): The Discord message requesting the song. + source (music_player.YTDLSource): The audio source. + """ + # Insert the information + with sqlite3.connect(self.path) as conn: + conn.execute(""" + INSERT INTO song_request ( + user_id, + channel_id, + search_term, + song_title, + song_artist + ) VALUES ( + ?, ?, ?, ?, ? + ) + """, ( + self._insert_user(message.author.id), + self._insert_channel(message.channel.id), + source.search_term, + source.song_title, + source.artist + )) + + def insert_song_play( + self, + channel_id: int, + source: music_player.YTDLSource): + """ + Inserts a song play into the database. + + This method takes a channel and the song being played and inserts the + relevant information into the 'song_play' table. + + Args: + channel (int): The Discord channel the song is being played in. + source (music_player.YTDLSource): The audio source. + + Returns: + int: The row ID of the entered song. Used to update 'played' value. + """ + user_id = self._insert_user(source.requester.id) if source.requester else None + channel_id = self._insert_user(channel_id) + # Insert the information + with sqlite3.connect(self.path) as conn: + cur = conn.cursor() + cur.execute(""" + INSERT INTO song_play ( + user_id, + channel_id, + search_term, + song_title, + song_artist + ) VALUES ( + ?, ?, ?, ?, ? + ) + """, ( + user_id, + channel_id, + source.search_term, + source.song_title, + source.artist + )) + return cur.lastrowid + + def update_song_play(self, song_play_id: int, finished: bool): + """ + Updates a song_play entry on whether or not it was finished. + + When a song plays, we want to know if it was finished or not. This + implies that either a user didn't want to hear it anymore, or that the + bot chose the wrong song from the search term. + + Args: + song_play_id (int): The row ID within the database for the song + play. + finished (bool): Whether or not the song was completed. + """ + with sqlite3.connect(self.path) as conn: + conn.execute(""" + UPDATE + song_play + SET + finished = ? + WHERE + id = ? + """, (finished, song_play_id)) + + def get_activity_stats( + self, + member: typing.Union[discord.Member, int], + start: datetime = datetime.now() - timedelta(days=30) + ) -> dict[str, timedelta]: + """ + Gets stats on the activities of the given member. + + This method searches the database for activity changes by the given + user and computes the amount of time spent in each activity. + + Args: + member (discord.Member): The Discord member to get stats for. + start (datetime): The earliest activity change to get. + + Returns: + dict[str, timedelta]: A dictionary of activity names and + seconds in each. + """ + # Get member Discord ID and convert to DB ID + member_id = member.id if isinstance(member, discord.Member) else member + member_id = self._insert_user(member_id) + # Pull all activities for this user + with sqlite3.connect(self.path) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT + before_activity_name, + after_activity_name, + timestamp + FROM + activity_change + WHERE + user_id = (?) AND + timestamp > (?) + """, (member_id, start)) + activities = cursor.fetchall() + # Collect activities + activity_stats = {} + for first, second in zip(activities, activities[1:]): + if first[1] == second[0]: + activity_name = first[1] + activity_time = \ + datetime.fromisoformat(second[2]) - \ + datetime.fromisoformat(first[2]) + if activity_name in activity_stats: + activity_stats[activity_name] += activity_time + else: + activity_stats[activity_name] = activity_time + if None in activity_stats: + del activity_stats[None] + return activity_stats \ No newline at end of file