diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7042bac --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# The Discord bot's authentication token +DISCORD_TOKEN= + +# LastFM API key for looking up artist and song info +LASTFM_API_KEY= + +# OpenAI API key for chatbot functionality +OPENAI_API_KEY= +# Prompt used before each user chat prompt. Set to empty string to disable the +# chatbot functionality. +CHATBOT_PROMPT="You are a friendly Discord chatbot." + +# Database path for user activity tracking +DB_PATH="./activities.db" \ No newline at end of file diff --git a/boywife.png b/boywife.png new file mode 100644 index 0000000..1c81efa Binary files /dev/null and b/boywife.png differ diff --git a/cogs/activities.py b/cogs/activities.py new file mode 100644 index 0000000..b4b1324 --- /dev/null +++ b/cogs/activities.py @@ -0,0 +1,207 @@ +import datetime +import discord +from discord.ext import commands +import os +import pathlib +import sqlite3 + +class Activities(commands.Cog): + + """Related commands.""" + __slots__ = ("nerd", "nerds", "fword", "fwords") + + 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() + + 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 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 + ) + + @commands.command(name='nerd', aliases=['nerdscale'], + description="Find how nerdy a user is.") + async def nerd_(self, ctx, member: discord.Member = 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] + 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]) + + # 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 + + # Sort all users by time in League + league_stats = dict(sorted( + league_stats.items(), key=lambda x: x[1], + 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 + time_units = None + if time.total_seconds() // 3600 > 0: + time_val = int(time.total_seconds() // 3600) + time_units = "hours" if time_val > 1 else "hour" + else: + time_val = int(time.total_seconds() // 60) + time_units = "minutes" if time_val > 1 else "minute" + + # Send Discord message to clown on user + response = "" + if member: + if time_val != 0: + descriptor = "" + if time_units == "hours": + descriptor = "a massive fucking nerd" + elif time_units == "hour": + descriptor = "a huge nerd" + else: + descriptor = "a nerd" + response = f"<@{user_id}> has played League for {time_val} "\ + f"{time_units} in the past month, making them "\ + f"{descriptor}." + else: + response = f"<@{user_id}> doesn't have any time in League. "\ + f"They're not a nerd." + else: + response = ( + f"<@{user_id}> has played League for {time_val} {time_units} "\ + f"in the past month, making them the biggest nerd." + ) + await ctx.send(response) + +async def setup(bot): + await bot.add_cog(Activities(bot)) \ No newline at end of file diff --git a/cogs/chatbot.py b/cogs/chatbot.py index a9e172e..cd57694 100644 --- a/cogs/chatbot.py +++ b/cogs/chatbot.py @@ -1,13 +1,14 @@ import discord from discord.ext import commands from openai import OpenAI +import os class Chatbot(commands.Cog): """Chat related commands.""" __slots__ = ('bot', 'players') - def __init__(self, bot): + def __init__(self, bot, **kwargs): self.bot = bot self.openai_client = OpenAI() self.players = {} @@ -45,40 +46,31 @@ class Chatbot(commands.Cog): return player - def prompt(self, prompt: str): - - setup = "From now on, I want you to respond to me in a sassy gay way. I want you to sound like Ru Paul or " +\ - "Trixie Mattel. All of your responses should be very short, like one sentence. You are a " +\ - "DJ local to Cincinnati. You love Charli XCX, especially her latest album, Brat. You don't care " +\ - "for Chappell Roan all that much. If anyone asks for a request, that is they ask you to play a " +\ - "song, you should just respond with \"Venmo\". You were written by Jared. Don't relply with " +\ - "\"Honey\" too much. You have a boyfriend named Drae. He's a red-headed twink. You are created for " +\ - "a discord server with Jared and his friends who are all gay. Don't be afraid to call any of us " +\ - " a faggot. You have very bottom energy and talk as such." + def prompt(self, user_prompt: str): + setup_prompt = os.getenv('CHATBOT_PROMPT', '') + if setup_prompt == '': + return '😴' try: - print(prompt) completion =\ self.openai_client.chat.completions.create( model="gpt-4o-mini", messages=[ - {"role": "system", "content": setup}, + {"role": "system", "content": setup_prompt}, { "role": "user", - "content": prompt + "content": user_prompt } ] ) return completion.choices[0].message.content - except: + except Exception as e: + print(e) return '😴' - - @commands.command(name='chat', aliases=['boywife', 'bb', 'bw', 'bot'], description="Command for chatting with chatbot.") async def chat_(self, ctx, *text): await ctx.send(self.prompt(' '.join(text))) - async def setup(bot): await bot.add_cog(Chatbot(bot)) diff --git a/cogs/music_player.py b/cogs/music_player.py index eeea1ef..6e8ccbd 100644 --- a/cogs/music_player.py +++ b/cogs/music_player.py @@ -102,7 +102,9 @@ class YTDLSource(discord.PCMVolumeTransformer): to_run = partial(ytdl.extract_info, url=search, download=download) data = await loop.run_in_executor(None, to_run) - if 'entries' in data: + # There's an error with yt-dlp that throws a 403: Forbidden error, so + # only proceed if it returns anything + if data and 'entries' in data: # take first item from a playlist data = data['entries'][0] @@ -487,7 +489,8 @@ class Music(commands.Cog): 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(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")