diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7487366 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.8-slim-buster + +COPY . /app +WORKDIR /app + +RUN pip3 install -r requirements.txt +RUN python3 -m pip install -U discord.py[voice] + +RUN apt -y update +RUN apt-get -y upgrade +RUN apt-get install -y ffmpeg + +CMD python3 __main__.py diff --git a/README.md b/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/__main__.py b/__main__.py new file mode 100755 index 0000000..edf8905 --- /dev/null +++ b/__main__.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +""" +BaseDiscordBot - A Discord bot for basing my more particular bots on. + +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/base-discord-bot +""" + +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("basediscordbot") + + # Load credentials + load_dotenv() + TOKEN = os.getenv('DISCORD_TOKEN') + + # Create custom bot with database connection + class BaseDiscordBot(commands.Bot): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.db = database.Database("basediscordbot.db") + self.ai = OpenAI() + client = BaseDiscordBot( + 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/assets/dj.png b/assets/dj.png new file mode 100644 index 0000000..dd2f471 Binary files /dev/null and b/assets/dj.png differ diff --git a/assets/pause.png b/assets/pause.png new file mode 100644 index 0000000..9261df0 Binary files /dev/null and b/assets/pause.png differ diff --git a/assets/play.png b/assets/play.png new file mode 100644 index 0000000..fee5631 Binary files /dev/null and b/assets/play.png differ diff --git a/assets/skip.png b/assets/skip.png new file mode 100644 index 0000000..9e42ca9 Binary files /dev/null and b/assets/skip.png differ diff --git a/cogs/activities.py b/cogs/activities.py new file mode 100644 index 0000000..4e631aa --- /dev/null +++ b/cogs/activities.py @@ -0,0 +1,51 @@ +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.""" + + def __init__(self, bot): + self.bot = bot + self.logger = logging.getLogger("activities") + + 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) + +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 new file mode 100644 index 0000000..aabd79c --- /dev/null +++ b/cogs/chatbot.py @@ -0,0 +1,76 @@ +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, **kwargs): + self.bot = bot + self.openai_client = OpenAI() + self.players = {} + + async def cleanup(self, guild): + try: + del self.players[guild.id] + except KeyError: + pass + + 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) + + def get_player(self, ctx): + """Retrieve the guild player, or generate one.""" + try: + player = self.players[ctx.guild.id] + except KeyError: + player = MusicPlayer(ctx) + self.players[ctx.guild.id] = player + + return player + + def prompt(self, user_prompt: str): + + setup_prompt = os.getenv('CHATBOT_PROMPT', '') + if setup_prompt == '': + return '😴' + try: + completion =\ + self.openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": setup_prompt}, + { + "role": "user", + "content": user_prompt + } + ] + ) + return completion.choices[0].message.content + except Exception as e: + print(e) + return '😴' + + @commands.command(name='chat', aliases=[], 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 new file mode 100644 index 0000000..b351f3e --- /dev/null +++ b/cogs/music_player.py @@ -0,0 +1,901 @@ +import ast +import atexit +import datetime +import discord +from discord.ext import commands +import enum +import random +import asyncio +import itertools +import sys +import traceback +import requests +import os +import validators +import threading +import pickle +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") + +# TEMORARY LIST OF SONGS +songs = [ + [REDACTED] +] + +# Suppress noise about console usage from errors +# yt_dlp.utils.bug_reports_message = lambda: "" + +class VoiceConnectionError(commands.CommandError): + """Custom Exception class for connection errors.""" + + +class InvalidVoiceChannel(VoiceConnectionError): + """Exception for cases of invalid Voice Channels.""" + + +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 + + # 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") + + # 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 + + def __getitem__(self, item: str): + """Allows us to access attributes similar to a dict. + This is only useful when you are NOT downloading. + """ + return self.__getattribute__(item) + + @classmethod + async def create_source( + cls, + ctx, + search: str, + *, + download=False, + artist="", + song_title="", + ): + loop = ctx.bot.loop if ctx else asyncio.get_event_loop() + + # 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): + search = f"{song_title} {artist} official audio" + + # 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) + + # 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] + + # Get either source filename or URL, depending on if we're downloading + if download: + source = cls._downloader.prepare_filename(data) + else: + source = data["url"] + logger.info(f"Using source: {data["webpage_url"]}") + + 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 + + return ffmpeg_source + +class MusicPlayer: + """ + 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", + "_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__) + + self.bot = ctx.bot + self._guild = ctx.guild + self._channel = ctx.channel + self._cog = ctx.cog + self._np = None # 'Now Playing' message + + self._state = self.State.IDLE + + 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()) + + 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() + await self._change_state(self.State.IDLE) + + # 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 + ) + except Exception as e: + 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 + + 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 + + logger.info("Waiting for song to finish") + await self._change_state(self.State.PLAYING) + await self._next.wait() + + if os.path.exists(source.filename): + os.remove(source.filename) + + # Make sure the FFmpeg process is cleaned up. + try: + source.cleanup() + except: + pass + self.current = None + + # Update bot statuses to match no song playing + await self.bot.change_presence(status=None) + + async def destroy(self): + """Disconnect and cleanup the player.""" + 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") + + def __init__(self, bot): + self.bot = bot + self.players = {} + + async def cleanup(self, guild): + try: + await guild.voice_client.disconnect() + except AttributeError: + pass + + try: + del self.players[guild.id] + except KeyError: + pass + + async def __local_check(self, ctx): + """ + 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. + """ + if isinstance(error, commands.NoPrivateMessage): + try: + 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" + ) + + 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.""" + try: + player = self.players[ctx.guild.id] + except KeyError: + player = MusicPlayer(ctx) + self.players[ctx.guild.id] = player + + return player + + @commands.command( + name="join", aliases=["connect", "j"], description="connects to voice" + ) + async def connect_(self, ctx, *, channel: discord.VoiceChannel = None): + """Connect to voice. + Parameters + ------------ + channel: discord.VoiceChannel [Optional] + The channel to connect to. If a channel is not specified, an attempt to join the voice channel you are in + will be made. + This command also handles moving the bot to different channels. + """ + if not channel: + 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(), + ) + await ctx.send(embed=embed) + raise InvalidVoiceChannel( + "No channel to join. Please either specify a valid channel" + " or join one." + ) + + vc = ctx.voice_client + + if vc: + if vc.channel.id == channel.id: + return + try: + await vc.move_to(channel) + except asyncio.TimeoutError: + 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." + ) + # await ctx.message.add_reaction('👍') + + @commands.command(name="play", aliases=["p", "queue", "q"]) + async def play_(self, ctx, *, search: str = None): + """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_) + + # 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) + + # 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 + + @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: + 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) + + @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(), + ) + return await ctx.send(embed=embed) + elif vc.is_paused(): + return + + vc.pause() + + # 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(), + ) + return await ctx.send(embed=embed) + elif not vc.is_paused(): + return + + vc.resume() + + # 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(), + ) + return await ctx.send(embed=embed) + + if vc.is_paused(): + pass + elif not vc.is_playing(): + return + + vc.stop() + + @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(), + ) + return await ctx.send(embed=embed) + + player = self.get_player(ctx) + if pos == None: + 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']})" + 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(), + ) + await ctx.send(embed=embed) + + @commands.command( + name="clear", + aliases=["clr", "cl", "cr"], + description="clears entire queue", + ) + async def clear_(self, ctx): + """ + 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 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**") + + @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. + + 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 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(), + ) + 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(), + ) + return await ctx.send(embed=embed) + + player = self.get_player(ctx) + + if vc.source: + 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(), + ) + 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: 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 am not currently connected to a voice channel.", + color=discord.Color.yellow(), + ) + return await ctx.send(embed=embed) + + await ctx.message.add_reaction("👋") + await self.cleanup(ctx.guild) + + +async def setup(bot): + await bot.add_cog(Music(bot)) diff --git a/database.py b/database.py new file mode 100644 index 0000000..4f1d794 --- /dev/null +++ b/database.py @@ -0,0 +1,411 @@ +from datetime import datetime, timedelta +import discord +import sqlite3 +import typing + +from cogs import music_player + +class Database: + def __init__(self, path: str): + 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 + ) + """) + + # 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 + ) + """) + + 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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..30a6043 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +discord +discord[voice] +ffmpeg +python-dotenv +yt-dlp +async_timeout +validators +openai