From 8ef6ccde085b7c90425745790d4170a13cb0fbcb Mon Sep 17 00:00:00 2001 From: Jared Kick Date: Fri, 25 Jul 2025 15:20:55 -0400 Subject: [PATCH] Extracted base functionality from original bot. Not guaranteed to work at all. --- Dockerfile | 13 + README.md | 0 __main__.py | 79 ++++ assets/dj.png | Bin 0 -> 7352 bytes assets/pause.png | Bin 0 -> 3725 bytes assets/play.png | Bin 0 -> 4526 bytes assets/skip.png | Bin 0 -> 6797 bytes cogs/activities.py | 51 +++ cogs/chatbot.py | 76 ++++ cogs/music_player.py | 901 +++++++++++++++++++++++++++++++++++++++++++ database.py | 411 ++++++++++++++++++++ requirements.txt | 8 + 12 files changed, 1539 insertions(+) create mode 100644 Dockerfile delete mode 100644 README.md create mode 100755 __main__.py create mode 100644 assets/dj.png create mode 100644 assets/pause.png create mode 100644 assets/play.png create mode 100644 assets/skip.png create mode 100644 cogs/activities.py create mode 100644 cogs/chatbot.py create mode 100644 cogs/music_player.py create mode 100644 database.py create mode 100644 requirements.txt 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 0000000000000000000000000000000000000000..dd2f471a1ba8c502006ffaa72f71048132e84c03 GIT binary patch literal 7352 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 O1lU+Onm3zbQ~n1nYvSMl literal 0 HcmV?d00001 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