diff --git a/boywife_bot.py b/boywife_bot.py index 99a7fc5..eb49541 100755 --- a/boywife_bot.py +++ b/boywife_bot.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("boywife-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/cogs/activities.py b/cogs/activities.py index b4b1324..672d3bc 100644 --- a/cogs/activities.py +++ b/cogs/activities.py @@ -1,9 +1,13 @@ import datetime import discord from discord.ext import commands +import logging import os import pathlib import sqlite3 +import typing + +import database class Activities(commands.Cog): @@ -12,24 +16,8 @@ class Activities(commands.Cog): def __init__(self, bot): self.bot = bot - - # Initialize the databse - self.db_path = pathlib.Path(os.getenv('DB_PATH', 'activities.db')) - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute(''' - CREATE TABLE IF NOT EXISTS user_activity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - before_activity_type TEXT, - before_activity_name TEXT, - after_activity_type TEXT, - after_activity_name TEXT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP - ) - ''') - conn.commit() - conn.close() + self.logger = logging.getLogger("activities") + self.db = database.Database("boywife_bot.db") async def __local_check(self, ctx): """A local check which applies to all commands in this cog.""" @@ -48,114 +36,39 @@ class Activities(commands.Cog): print('Ignoring exception in command {}:'.format(ctx.command), file=sys.stderr) traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) - def log_activity(self, - user_id: int, - before_activity_type: str = None, - before_activity_name: str = None, - after_activity_type: str = None, - after_activity_name: str = None): - """Log an activity into the activities database for stats.""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - try: - cursor.execute(""" - INSERT INTO user_activity (user_id, before_activity_type, - before_activity_name, after_activity_type, after_activity_name) - VALUES (?, ?, ?, ?, ?) - """, (user_id, before_activity_type, before_activity_name, - after_activity_type, after_activity_name)) - conn.commit() - except sqlite3.Error as e: - print(f"Database error: {e}") - finally: - conn.close() - @commands.Cog.listener() - async def on_presence_update(self, before: discord.Member, after: discord.Member): - # Ignore changes to any bots - if after.bot: - return - if isinstance(before.activity, discord.Game): - before_type = "game" - before_name = before.activity.name - elif isinstance(before.activity, discord.Spotify): - before_type = "spotify" - before_name = before.activity.artist - elif isinstance(before.activity, discord.Activity): - before_type = "activity" - before_name = before.activity.name - else: - before_type = "other" - before_name = "other" - - if isinstance(after.activity, discord.Game): - after_type = "game" - after_name = after.activity.name - elif isinstance(after.activity, discord.Spotify): - after_type = "spotify" - after_name = after.activity.artist - elif isinstance(after.activity, discord.Activity): - after_type = "activity" - after_name = after.activity.name - else: - after_type = "other" - after_name = "other" - self.log_activity( - user_id=before.id, - before_activity_type=before_type, - before_activity_name=before_name, - after_activity_type=after_type, - after_activity_name=after_name - ) + async def on_presence_update( + self, + before: discord.Member, + after: discord.Member): + # Log the activity or status change + self.logger.info("Received activity change; logging") + self.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: discord.Member = None): + async def nerd_(self, ctx, member: typing.Union[discord.Member, int, None]): """Checks the play history of all users in the Discord server, and based on how many hours they have in League of Legends compared to other users, declare how nerdy they are.""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - # Start by getting all unique user IDs - if member: - user_ids = [member.id] + # 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: - user_ids = [] - cursor.execute("SELECT DISTINCT user_id FROM user_activity") - user_ids = [int(r[0]) for r in cursor.fetchall()] - - # For each user, compute timedelta for time playing League - league_stats = {} - for user_id in user_ids: - # Get all entries that mention league - cursor.execute(""" - SELECT - before_activity_name, - after_activity_name, - timestamp - FROM - user_activity - WHERE - user_id = (?) AND - timestamp > datetime('now', '-1 month') - """, (user_id,)) - league_activities = cursor.fetchall() - - # Sort by timestamp - league_activities = sorted(league_activities, key=lambda x: x[2]) + if isinstance(member, discord.Member): + members = [member.id] + else: + members = [member] - # Compare each status change to find each time playing League - total_time = datetime.timedelta() - for first, second in zip(league_activities, league_activities[1:]): - if "league of legends" in first[1].lower().strip() and \ - "league of legends" in second[0].lower().strip(): - total_time += \ - datetime.datetime.fromisoformat(second[2]) - \ - datetime.datetime.fromisoformat(first[2]) - - # Save total time in League - league_stats[user_id] = total_time + # 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( @@ -163,11 +76,6 @@ class Activities(commands.Cog): reverse=True )) - # Print all stats for testing - print("League Stats:") - for user_id, time in league_stats.items(): - print(user_id, ":", str(time)) - # Get top user user_id, time = next(iter(league_stats.items())) time_val = None @@ -184,9 +92,9 @@ class Activities(commands.Cog): if member: if time_val != 0: descriptor = "" - if time_units == "hours": + if time_units in ["hour", "hours"]: descriptor = "a massive fucking nerd" - elif time_units == "hour": + elif time_units in ["minutes", "minute"]: descriptor = "a huge nerd" else: descriptor = "a nerd" @@ -194,8 +102,12 @@ class Activities(commands.Cog): f"{time_units} in the past month, making them "\ f"{descriptor}." else: - response = f"<@{user_id}> doesn't have any time in League. "\ - f"They're not a nerd." + 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} "\ diff --git a/cogs/music_player.py b/cogs/music_player.py index 6e8ccbd..c058594 100644 --- a/cogs/music_player.py +++ b/cogs/music_player.py @@ -15,10 +15,78 @@ from async_timeout import timeout from functools import partial import yt_dlp from yt_dlp import YoutubeDL +import logging + +import database + +logger = logging.getLogger("music_player") # Get API key for last.fm LASTFM_API_KEY = os.getenv('LASTFM_API_KEY') +# TEMORARY LIST OF SONGS +songs = [ + "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" +] + # Suppress noise about console usage from errors yt_dlp.utils.bug_reports_message = lambda: '' @@ -62,12 +130,16 @@ class YTDLSource(discord.PCMVolumeTransformer): super().__init__(source) self.requester = requester + # 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 @@ -82,23 +154,33 @@ class YTDLSource(discord.PCMVolumeTransformer): 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() - # 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 + # Get YouTube video source + logger.info(f"Getting YouTube video: {search}") to_run = partial(ytdl.extract_info, url=search, download=download) data = await loop.run_in_executor(None, to_run) @@ -114,14 +196,12 @@ class YTDLSource(discord.PCMVolumeTransformer): return {'webpage_url': data['webpage_url'], 'requester': ctx.author, 'title': data['title']} ffmpeg_source = cls(discord.FFmpegPCMAudio(source), data=data, requester=ctx.author) + # TODO: ADD THESE TO THE CONSTRUCTOR + ffmpeg_source.search_term = search_term 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 @@ -144,7 +224,11 @@ class MusicPlayer: When the bot disconnects from the Voice it's instance will be destroyed. """ - __slots__ = ('bot', '_guild', '_channel', '_cog', 'queue', 'next', 'current', 'np', 'volume') + __slots__ = ('bot', '_guild', '_channel', '_cog', 'queue', 'next', 'current', 'np', 'volume', 'dj_mode') + + # 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() def __init__(self, ctx): self.bot = ctx.bot @@ -158,6 +242,7 @@ class MusicPlayer: self.np = None # Now playing message self.volume = .5 self.current = None + self.dj_mode = False ctx.bot.loop.create_task(self.player_loop()) @@ -168,12 +253,33 @@ class MusicPlayer: while not self.bot.is_closed(): self.next.clear() - 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 self.destroy(self._guild) + # 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") + source = await YTDLSource.create_source( + None, random.choice(songs), + loop=self.bot.loop, + download=True + ) + + # For the time being, we're going to use 'None' to signal to the + # player that is 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) @@ -188,14 +294,16 @@ class MusicPlayer: source.volume = self.volume self.current = source + logger.info(f"Playing '{source.song_title}' by '{source.artist}'") self._guild.voice_client.play(source, after=lambda _: self.bot.loop.call_soon_threadsafe(self.next.set)) + 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}")) - 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.update_now_playing_message(repost=True) + + logger.info("Waiting for song to finish") await self.next.wait() - await self.bot.change_presence(status=None) if os.path.exists(source.filename): os.remove(source.filename) @@ -206,6 +314,52 @@ class MusicPlayer: pass self.current = None + # Update bot statuses to match no song playing + await self.bot.change_presence(status=None) + await self.update_now_playing_message(repost=False) + + async def update_now_playing_message(self, repost=False, emoji='▢️'): + await self.bot.wait_until_ready() + + # Create and post new 'Now Playing' message + embed = discord.Embed( + title=f"{emoji} Now Playing", color=discord.Color.green()) + + # Get and add the thumbnail + if self.current: + 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 self.queue.qsize() > 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) + + # There's a chance we'll delete a message another coroutine is using, so + # lock the messages + async with self. guild_lock_: + async for message in self._channel.history(limit=50): + if len(message.embeds) == 1 and message.embeds[0].title and "Now Playing" in message.embeds[0].title: + if repost: + await message.delete() + else: + await message.edit(embed=embed) + break + + if repost: + self.np = await self._channel.send(embed=embed) + + def destroy(self, guild): """Disconnect and cleanup the player.""" return self.bot.loop.create_task(self._cog.cleanup(guild)) @@ -219,23 +373,27 @@ class Music(commands.Cog): def __init__(self, bot): self.bot = bot self.players = {} - self.last_tag_play_time = datetime.datetime.now() - datetime.timedelta(minutes=30) + self.last_tag_play_time = datetime.datetime.now() - def update_cache(): - with yt_dlp.YoutubeDL({'quiet': True}) as ydl: - self.boywife_tracks = ydl.extract_info('https://soundcloud.com/djboywife', download=False)['entries'] + # Get a reference to the database + # TODO: MAKE THIS INJECTED + self.db = database.Database("boywife_bot.db") - with open('soundcloud-cache', 'w') as f: - f.write(str(self.boywife_tracks)) - # pickle.dump(self.boywife_tracks, f) + # def update_cache(): + # with yt_dlp.YoutubeDL({'quiet': True}) as ydl: + # self.boywife_tracks = ydl.extract_info('https://soundcloud.com/djboywife', download=False)['entries'] - if os.path.exists('soundcloud-cache'): - with open('soundcloud-cache', 'r') as f: - exec(f'self.boywife_tracks = {f.read()}') - # self.boywife_tracks = pickle.load(f) - threading.Thread(target=update_cache).start() - else: - update_cache() + # with open('soundcloud-cache', 'w') as f: + # f.write(str(self.boywife_tracks)) + # # pickle.dump(self.boywife_tracks, f) + + # if os.path.exists('soundcloud-cache'): + # with open('soundcloud-cache', 'r') as f: + # exec(f'self.boywife_tracks = {f.read()}') + # # self.boywife_tracks = pickle.load(f) + # threading.Thread(target=update_cache).start() + # else: + # update_cache() async def cleanup(self, guild): @@ -311,9 +469,7 @@ class Music(commands.Cog): 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}`**') + # await ctx.message.add_reaction('πŸ‘') @commands.command(name='play', aliases=['sing','p'], description="streams music") async def play_(self, ctx, *, search: str = None): @@ -325,29 +481,59 @@ class Music(commands.Cog): search: str [Required] The song to search and retrieve using YTDL. This could be a simple search, an ID or URL. """ + # 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, 'https://soundcloud.com/djboywife/youre-listening-to-dj-boywife', loop=self.bot.loop, - download=True, silent=True, artist='DJ Boywife', song_title="Tag") - await player.queue.put(source) - - # If no song is given, pick a soundcloud track at random - if not search: - track = random.choice(self.boywife_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']) - else: + # Create source + try: source = await YTDLSource.create_source(ctx, search, loop=self.bot.loop, download=True) + except: + pass + # Update previous message to show found song and video + embed = discord.Embed( + title=f"Queued", + description=f"[{source.song_title}]({source.web_url}) - {source.artist}", + color=discord.Color.green() + ) + embed.set_thumbnail(url=source.thumbnail_url) + await message.edit(embed=embed) + + # Add song to the corresponding player object + player = self.get_player(ctx) await player.queue.put(source) + await player.update_now_playing_message() + + @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.""" + # 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: + 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.put(None) @commands.command(name='pause', description="pauses music") async def pause_(self, ctx): @@ -361,7 +547,10 @@ class Music(commands.Cog): return vc.pause() - await ctx.send("Paused ⏸️") + + # 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): @@ -375,7 +564,10 @@ class Music(commands.Cog): return vc.resume() - await ctx.send("Resuming ⏯️") + + # 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): @@ -393,7 +585,7 @@ class Music(commands.Cog): vc.stop() - @commands.command(name='remove', aliases=['rm', 'rem'], description="removes specified song from queue") + @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""" @@ -430,69 +622,6 @@ class Music(commands.Cog): player.queue._queue.clear() 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 - - 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 🎢") - embed.set_author(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. @@ -536,9 +665,7 @@ class Music(commands.Cog): embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) 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..b96d348 --- /dev/null +++ b/database.py @@ -0,0 +1,443 @@ +from datetime import datetime, timedelta +import discord +import sqlite3 +import typing + +from tqdm import tqdm + +from cogs import music_player + +class Database: + def __init__(self, path: str = "boywife_bot.db"): + self.path = path + self._ensure_db() + + # # TEMP: THIS IS FOR MIGRATING THE PREVIOUS DATABASE SCHEMA + # with sqlite3.connect("ingest.db") as conn: + # cursor = conn.cursor() + # cursor.execute(""" + # SELECT + # user_id, + # before_activity_type, + # before_activity_name, + # after_activity_type, + # after_activity_name, + # timestamp + # FROM + # user_activity + # """) + # activities = cursor.fetchall() + + # for activity in tqdm(activities): + + # # Convert activity type + # if activity[1] == "activity": + # new_before_type = "playing" + # elif activity[1] == "spotify": + # new_before_type = "listening" + # elif activity[1] == "game": + # new_before_type = "playing" + # else: + # new_before_type = None + # if activity[3] == "activity": + # new_after_type = "playing" + # elif activity[3] == "spotify": + # new_after_type = "listening" + # elif activity[3] == "game": + # new_after_type = "playing" + # else: + # new_after_type = None + + # new_before_name = None if activity[2] == "other" else activity[2] + # new_after_name = None if activity[4] == "other" else activity[4] + + # user_id = self._insert_user(activity[0]) + + # with sqlite3.connect(self.path) as conn: + # cursor = conn.cursor() + # cursor.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, + # timestamp + # ) VALUES ( + # ?, ?, ?, ?, ?, ?, ?, ? + # ) + # """, ( + # user_id, + # new_before_type, + # new_before_name, + # "unknown", + # new_after_type, + # new_after_name, + # "unknown", + # datetime.datetime.fromisoformat(activity[5]) + # )) + + 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 + ) + """) + + # Table for songs that actually get played + conn.execute(""" + CREATE TABLE IF NOT EXISTS song_play ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + 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 + ) + """) + 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. + """ + user_id = self._insert_user(source.requester.id) + channel_id = self._insert_user(channel.id) + # Insert the information + with sqlite3.connect(self.path) as conn: + conn.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 + )) + + 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