countdown-bot

A Discord bot that runs countdown games and generates analytics
git clone https://git.ashermorgan.net/countdown-bot/
Log | Files | Refs | README

commit 97c3b9887c7a71bfdfa9bf1c429ec2382dcb1d85
parent fed129192bf1cb053ba0380c78abaaa5f3b40d4b
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sun, 31 Mar 2024 19:30:23 -0700

Rename package from src to countdown_bot

Diffstat:
MREADME.md | 2+-
Acountdown_bot/__init__.py | 2++
Acountdown_bot/__main__.py | 27+++++++++++++++++++++++++++
Acountdown_bot/analyticsCog.py | 512+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acountdown_bot/bot.py | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acountdown_bot/botUtilities.py | 321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/models.py -> countdown_bot/models.py | 0
Acountdown_bot/utilitiesCog.py | 421+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Drun.py | 27---------------------------
Dsrc/__init__.py | 2--
Dsrc/analyticsCog.py | 512-------------------------------------------------------------------------------
Dsrc/bot.py | 101-------------------------------------------------------------------------------
Dsrc/botUtilities.py | 321-------------------------------------------------------------------------------
Dsrc/utilitiesCog.py | 421-------------------------------------------------------------------------------
14 files changed, 1385 insertions(+), 1385 deletions(-)

diff --git a/README.md b/README.md @@ -22,7 +22,7 @@ A Discord bot that facilitates countdowns and generates detailed countdown analy 4. Run the bot ``` - python run.py + python -m countdown_bot ``` 5. Add the bot to your server diff --git a/countdown_bot/__init__.py b/countdown_bot/__init__.py @@ -0,0 +1,2 @@ +# Import CountdownBot so it can be easily imported from countdown_bot +from .bot import CountdownBot diff --git a/countdown_bot/__main__.py b/countdown_bot/__main__.py @@ -0,0 +1,27 @@ +# Import dependencies +from dotenv import load_dotenv +import logging +import os + +# Import modules +from .bot import CountdownBot +from .models import getSessionMaker + +# Load settings +load_dotenv() + +# Setup logging +logger = logging.getLogger() +logger.setLevel(getattr(logging, os.environ.get("LOG_LEVEL", "INFO"))) +logging.basicConfig( + format = "[{asctime}] [{levelname:<8}] {name}: {message}", + style="{", + filename = os.environ.get("LOG_FILE", "log.txt"), +) + +# Connect to database +databaseSessionMaker = getSessionMaker(os.environ.get("DATABASE")) + +# Run bot +bot = CountdownBot(databaseSessionMaker, [os.environ.get("PREFIX", "!")]) +bot.run(os.environ.get("TOKEN")) diff --git a/countdown_bot/analyticsCog.py b/countdown_bot/analyticsCog.py @@ -0,0 +1,512 @@ +# Import dependencies +from datetime import datetime, timedelta +import discord +from discord.ext import commands +from matplotlib import pyplot as plt +from matplotlib.ticker import PercentFormatter +import numpy as np +import os +import re +import tempfile + +# Import modules +from .botUtilities import COLORS, getContextCountdown, getUsername, getContributor, CommandError +from .models import POINT_RULES + + + +class Analytics(commands.Cog): + def __init__(self, bot, databaseSessionMaker): + self.bot = bot + self.databaseSessionMaker = databaseSessionMaker + + + + @commands.command(aliases=["a"]) + async def analytics(self, ctx): + """ + Shows all countdown analytics + """ + + # Run analytics commands + await self.contributors(ctx, "") + await self.contributors(ctx, "history") + await self.eta(ctx) + await self.heatmap(ctx) + await self.leaderboard(ctx) + await self.progress(ctx) + await self.speed(ctx) + + + + @commands.command(aliases=["c"]) + async def contributors(self, ctx, option=""): + """ + Shows information about countdown contributors + """ + + with self.databaseSessionMaker() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) + + # Create temp file + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + tmp.close() + + # Create embed + embed=discord.Embed(title=":busts_in_silhouette: Countdown Contributors", color=COLORS["embed"]) + + # Make sure the countdown has started + if (option.lower() in ["h", "history"]): + # Create figure + fig, ax = plt.subplots() + ax.set_xlabel("Progress") + ax.set_ylabel("Percentage of Contributions") + ax.yaxis.set_major_formatter(PercentFormatter()) + + # Get stats + contributors = countdown.historicalContributors() + + # Plot data and add legend + for author in list(contributors.keys())[:min(len(contributors), 15)]: + # Top 15 contributors get included in the legend + ax.plot([x["progress"] for x in contributors[author]], [x["percentage"] * 100 for x in contributors[author]], label=await getUsername(self.bot, author)) + for author in list(contributors.keys())[15:max(len(contributors), 15)]: + ax.plot([x["progress"] for x in contributors[author]], [x["percentage"] * 100 for x in contributors[author]]) + ax.legend(bbox_to_anchor=(1,1.025), loc="upper left") + + # Save graph + fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) + file = discord.File(tmp.name, filename="image.png") + + # Add content to embed + embed.description = f"**Countdown Channel:** <#{countdown.id}>" + embed.set_image(url="attachment://image.png") + elif (option == ""): + # Create figure + fig, ax = plt.subplots() + + # Get stats + contributors = countdown.contributors() + + # Add data to graph + x = [x["author"] for x in contributors] + y = [x["contributions"] for x in contributors] + pieData = ax.pie(y, autopct="%1.1f%%", startangle=90) + + # Add legend + ax.legend(pieData[0], [await getUsername(self.bot, i) for i in x[:min(len(x), 15)]], bbox_to_anchor=(1,1.025), loc="upper left") + + # Save graph + fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) + file = discord.File(tmp.name, filename="image.png") + + # Add content to embed + embed.description = f"**Countdown Channel:** <#{countdown.id}>" + ranks = "" + users = "" + contributions = "" + for i in range(0, min(len(x), 20)): + ranks += f"{i+1:,}\n" + contributions += f"{y[i]:,} *({round(y[i] / len(countdown.messages) * 100, 1)}%)*\n" + users += f"<@{x[i]}>\n" + embed.add_field(name="Rank",value=ranks, inline=True) + embed.add_field(name="User",value=users, inline=True) + embed.add_field(name="Contributions",value=contributions, inline=True) + embed.set_image(url="attachment://image.png") + else: + raise CommandError(f"Unrecognized option: `{option}`") + + # Send embed + try: + await ctx.send(file=file, embed=embed) + except: + await ctx.send(embed=embed) + + # Remove temp file + try: + os.remove(tmp.name) + except Exception as e: + self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e) + + + + @commands.command(aliases=["e"]) + async def eta(self, ctx, period="24.0"): + """ + Shows information about the estimated completion date + """ + + with self.databaseSessionMaker() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) + + # Create temp file + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + tmp.close() + + # Create embed + embed=discord.Embed(title=":calendar: Countdown Estimated Completion Date", color=COLORS["embed"]) + + # Parse period + try: + period = float(period) + except ValueError: + raise CommandError(f"Invalid number: `{period}`") + + # Make sure period is valid + if (period < 0.01): + raise CommandError("The period cannot be less than 0.01 hours") + + # Get stats + eta = countdown.eta(timedelta(hours=period)) + + # Create figure + fig, ax = plt.subplots() + ax.set_xlabel("Time") + fig.autofmt_xdate() + + # Add ETA data to graph + ax.plot(eta[0], eta[1], "C0", label="Estimated Completion Date") + + # Add reference line graph + ax.plot([eta[0][0], eta[0][-1]], [eta[0][0], eta[0][-1]], "--C1", label="Current Date") + + # Add legend + ax.legend() + + # Save graph + fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) + file = discord.File(tmp.name, filename="image.png") + + # Calculate embed data + maxEta = max(eta[1]) + maxDate = eta[0][eta[1].index(maxEta)] + minEta = min(eta[1][1:]) + minDate = eta[0][eta[1].index(minEta)] + end = eta[1][-1] + timedelta(hours=countdown.timezone) + endDiff = eta[1][-1] - datetime.utcnow() + + # Add content to embed + embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" + embed.description += f"**Maximum Estimate:** {maxEta.date()} (on {maxDate.date()})\n" + embed.description += f"**Minimum Estimate:** {minEta.date()} (on {minDate.date()})\n" + if endDiff < timedelta(seconds=0): + embed.description += f"**Actual Completion Date:** {end.date()} ({(-1 * endDiff).days:,} days ago)\n" + else: + embed.description += f"**Current Estimate:** {end.date()} ({endDiff.days:,} days from now)\n" + embed.set_image(url="attachment://image.png") + + # Send embed + try: + await ctx.send(file=file, embed=embed) + except: + await ctx.send(embed=embed) + + # Remove temp file + try: + os.remove(tmp.name) + except Exception as e: + self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e) + + + + @commands.command() + async def heatmap(self, ctx, user=None): + """ + Shows a heatmap of when countdown messages are sent + """ + + with self.databaseSessionMaker() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) + + # Create temp file + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + tmp.close() + + # Create embed + embed=discord.Embed(title=":calendar_spiral: Countdown Heatmap", color=COLORS["embed"]) + + # Get user + if (user == None): + userID = None + else: + userID = await getContributor(self.bot, countdown, user) + + # Get heatmap matrix + heatmapMatrix = countdown.heatmap(userID) + + # Define hour and weekday names + hours = ["12 AM", "1 AM", "2 AM", "3 AM", "4 AM", "5 AM", "6 AM", "7 AM", "8 AM", "9 AM", "10 AM", "11 AM", "12 PM", "1 PM", "2 PM", "3 PM", "4 PM", "5 PM", "6 PM", "7 PM", "8 PM", "9 PM", "10 PM", "11 PM"] + weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + + # Create figure + fig, ax = plt.subplots() + ax.set_xlabel("Hour of Day") + ax.set_xticks([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]) + ax.set_xticklabels(hours) + ax.set_ylabel("Day of Week") + ax.set_yticks([0, 1, 2, 3, 4, 5, 6]) + ax.set_yticklabels(weekdays) + + # Add data to graph + cmap = plt.get_cmap("jet").copy() + cmap.set_bad("gray") + cax = ax.matshow(np.ma.masked_equal(np.array(heatmapMatrix), 0), cmap=cmap, aspect="auto") + fig.colorbar(cax) + + # Save graph + fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) + file = discord.File(tmp.name, filename="image.png") + + # Get embed data + total = np.sum(heatmapMatrix) + averageValue = total / (24*7) + maxValue = np.max(heatmapMatrix) + maxWeekday = np.where(heatmapMatrix == maxValue)[0][0] + maxHour = np.where(heatmapMatrix == maxValue)[1][0] + currentWeekday = ((datetime.utcnow() + timedelta(hours=countdown.timezone)).weekday() + 1) % 7 + currentHour = (datetime.utcnow() + timedelta(hours=countdown.timezone)).hour + currentValue = heatmapMatrix[currentWeekday][currentHour] + + # Add content to embed + embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" + if (userID): embed.description += f"**User:** <@{userID}>\n" + embed.description += f"**Total Contributions:** {total:,}\n" + embed.description += f"**Average Contributions per Zone:** {round(averageValue):,}\n" + embed.description += f"**Best Zone:** {hours[maxHour]} to {hours[(maxHour + 1) % 24]} on {weekdays[maxWeekday]}s - {maxValue:,} contributions\n" + embed.description += f"**Current Zone:** {hours[currentHour]} to {hours[(currentHour + 1) % 24]} on {weekdays[currentWeekday]}s - {currentValue:,} contributions\n" + embed.set_image(url="attachment://image.png") + + # Send embed + try: + await ctx.send(file=file, embed=embed) + except: + await ctx.send(embed=embed) + + # Remove temp file + try: + os.remove(tmp.name) + except Exception as e: + self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e) + + + + @commands.command(aliases=["l"]) + async def leaderboard(self, ctx, user=None): + """ + Shows the countdown leaderboard + """ + + with self.databaseSessionMaker() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) + + # Get leaderboard + leaderboard = countdown.leaderboard() + + # Create embed + embed=discord.Embed(title=":trophy: Countdown Leaderboard", color=COLORS["embed"]) + + # Make sure the countdown has started + if (user is None): + # Add description + embed.description = f"**Countdown Channel:** <#{countdown.id}>" + + # Add leaderboard + ranks = "" + points = "" + users = "" + for i in range(0, min(len(leaderboard), 20)): + ranks += f"{i+1:,}\n" + points += f"{leaderboard[i]['points']:,}\n" + users += f"<@{leaderboard[i]['author']}>\n" + embed.add_field(name="Rank",value=ranks, inline=True) + embed.add_field(name="Points",value=points, inline=True) + embed.add_field(name="User",value=users, inline=True) + + # Add leaderboard rules + rules = "" + values = "" + for rule in POINT_RULES: + rules += f"{rule}\n" + values += f"{POINT_RULES[rule]} points\n" + embed.add_field(name="Rules", value="Only 1 rule is applied towards each number", inline=False) + embed.add_field(name="Numbers", value=rules, inline=True) + embed.add_field(name="Points", value=values, inline=True) + else: + # Get user rank + if (re.match("^\d+$", user) and int(user) > 0 and int(user) <= len(leaderboard)): + rank = int(user) - 1 + else: + rank = [x["author"] for x in leaderboard].index(await getContributor(self.bot, countdown, user)) + + # Add description + embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" + embed.description += f"**User:** <@{leaderboard[rank]['author']}>\n" + embed.description += f"**Rank:** #{rank + 1:,}\n" + embed.description += f"**Total Points:** {leaderboard[rank]['points']:,}\n" + embed.description += f"**Total Contributions:** {leaderboard[rank]['contributions']:,} *({round(leaderboard[rank]['contributions'] / len(countdown.messages) * 100, 1)}%)*\n" + + # Add points breakdown + rules = "" + points = "" + percentage = "" + for category in leaderboard[rank]["breakdown"]: + rules += f"{category}\n" + points += f"{leaderboard[rank]['breakdown'][category] * POINT_RULES[category]:,} *({leaderboard[rank]['breakdown'][category]:,})*\n" + if (leaderboard[rank]['points'] > 0): + percentage += f"{round(leaderboard[rank]['breakdown'][category] * POINT_RULES[category] / leaderboard[rank]['points'] * 100, 1)}%\n" + else: + percentage += "0%\n" + embed.add_field(name="Category", value=rules, inline=True) + embed.add_field(name="Points", value=points, inline=True) + embed.add_field(name="Percentage", value=percentage, inline=True) + + # Send embed + await ctx.send(embed=embed) + + + + @commands.command(aliases=["p"]) + async def progress(self, ctx): + """ + Shows information about countdown progress + """ + + with self.databaseSessionMaker() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) + + # Create temp file + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + tmp.close() + + # Create embed + embed=discord.Embed(title=":chart_with_downwards_trend: Countdown Progress", color=COLORS["embed"]) + + # Get progress stats + stats = countdown.progress() + breakStats = countdown.longestBreak() + + # Create figure + fig, ax = plt.subplots() + ax.set_xlabel("Time") + ax.set_ylabel("Progress") + fig.autofmt_xdate() + + # Add data to graph + x = [stats["start"] + timedelta(hours=countdown.timezone)] + [x["time"] + timedelta(hours=countdown.timezone) for x in stats["progress"]] + y = [0] + [x["progress"] for x in stats["progress"]] + ax.plot(x, y) + + # Save graph + fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) + file = discord.File(tmp.name, filename="image.png") + + # Calculate embed data + longestBreakDuration = timedelta(days=breakStats['duration'].days, seconds=breakStats['duration'].seconds) + longestBreakStart = breakStats['start'].date() + longestBreakEnd = breakStats['end'].date() + start = (stats["start"] + timedelta(hours=countdown.timezone)).date() + startDiff = (datetime.utcnow() - stats["start"]).days + end = (stats["eta"] + timedelta(hours=countdown.timezone)).date() + endDiff = stats["eta"] - datetime.utcnow() + + # Add content to embed + embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" + embed.description += f"**Progress:** {stats['total'] - stats['current']:,} / {stats['total']:,} ({round(stats['percentage'], 1)}%)\n" + embed.description += f"**Average Progress per Day:** {round(stats['rate']):,}\n" + embed.description += f"**Longest Break:** {longestBreakDuration} ({longestBreakStart} to {longestBreakEnd})\n" + embed.description += f"**Start Date:** {start} ({startDiff:,} days ago)\n" + if endDiff < timedelta(seconds=0): + embed.description += f"**End Date:** {end} ({(-1 * endDiff).days:,} days ago)\n" + else: + embed.description += f"**Estimated End Date:** {end} ({endDiff.days:,} days from now)\n" + embed.set_image(url="attachment://image.png") + + # Send embed + try: + await ctx.send(file=file, embed=embed) + except: + await ctx.send(embed=embed) + + # Remove temp file + try: + os.remove(tmp.name) + except Exception as e: + self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e) + + + + @commands.command(aliases=["s"]) + async def speed(self, ctx, period="24.0"): + """ + Shows information about countdown speed + """ + + with self.databaseSessionMaker() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) + + # Create temp file + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + tmp.close() + + # Create embed + embed=discord.Embed(title=":stopwatch: Countdown Speed", color=COLORS["embed"]) + + # Parse period + try: + period = float(period) + except ValueError: + raise CommandError(f"Invalid number: `{period}`") + + # Make sure period is valid + if (period < 0.01): + raise CommandError("The period cannot be less than 0.01 hours") + + # Get stats + stats = countdown.progress() + period = timedelta(hours=period) + speed = countdown.speed(period) + + # Create figure + fig, ax = plt.subplots() + ax.set_xlabel("Time") + ax.set_ylabel("Progress per Period") + fig.autofmt_xdate() + + # Add data to graph + for i in range(0, len(speed[0])): + ax.bar(speed[0][i], speed[1][i], width=period, align="edge", color="#1f77b4") + + # Save graph + fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) + file = discord.File(tmp.name, filename="image.png") + + # Add content to embed + embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" + embed.description += f"**Period Size:** {period}\n" + if (len(countdown.messages) > 1): + rate = (stats['total'] - stats['current'])/((countdown.messages[-1].timestamp - countdown.messages[0].timestamp) / period) + else: + rate = 0 + embed.description += f"**Average Progress per Period:** {round(rate):,}\n" + embed.description += f"**Record Progress per Period:** {max(speed[1]):,}\n" + embed.description += f"**Last Period Start:** {speed[0][-1]}\n" + embed.description += f"**Progress during Last Period:** {speed[1][-1]:,}\n" + embed.set_image(url="attachment://image.png") + + # Send embed + try: + await ctx.send(file=file, embed=embed) + except: + await ctx.send(embed=embed) + + # Remove temp file + try: + os.remove(tmp.name) + except Exception as e: + self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e) diff --git a/countdown_bot/bot.py b/countdown_bot/bot.py @@ -0,0 +1,101 @@ +# Import dependencies +import discord +from discord.ext import commands +import logging + + +# Import modules +from . import analyticsCog, utilitiesCog +from .botUtilities import addMessage, COLORS, CountdownNotFound, ContributorNotFound, CommandError, getCountdown, getPrefix +from .models import EmptyCountdownError + + + +class CountdownBot(commands.Bot): + def __init__(self, databaseSessionMaker, prefixes): + # Set properties + self.databaseSessionMaker = databaseSessionMaker + self.prefixes = prefixes + self.logger = logging.getLogger(__name__) + + # Get intents + intents = discord.Intents.default() + intents.message_content = True + + # Initialize bot + super().__init__(command_prefix=lambda bot, ctx: getPrefix(self.databaseSessionMaker, ctx, self.prefixes), intents=intents) + + + + async def setup_hook(self): + await self.add_cog(analyticsCog.Analytics(self, self.databaseSessionMaker)) + await self.add_cog(utilitiesCog.Utilities(self, self.databaseSessionMaker)) + + + + async def on_ready(self): + self.logger.info(f"Connected to Discord as {self.user} (ID {self.user.id})") + + + + async def on_guild_join(self, guild): + # Print status + self.logger.info(f"Added to {guild} (ID {guild.id})") + + # Create embed + embed=discord.Embed(title=":rocket: Getting Started with countdown-bot", color=COLORS["embed"]) + embed.description = f"Thanks for adding me to your server! Here are some steps for getting started:\n" + embed.description += f"**1.** View help information using the `{self.prefixes[0]}help` command\n" + embed.description += f"**2.** Activate a new countdown channel using the `{self.prefixes[0]}activate` command\n" + embed.description += f"**3.** Change my settings using the `{self.prefixes[0]}config` command\n" + embed.description += f"**4.** View countdown analytics using the `{self.prefixes[0]}analytics` command\n" + + # Send embed + await guild.system_channel.send(embed=embed) + + + + async def on_message(self, obj): + # Respond to @mentions + if self.user in obj.mentions: + embed=discord.Embed(title="countdown-bot", description=f"Use `{(await self.get_prefix(obj))[0]}help` to view help information", color=COLORS["embed"]) + await obj.channel.send(embed=embed) + + # Parse countdown message + with self.databaseSessionMaker() as session: + countdown = getCountdown(session, obj.channel.id) + if (countdown): + # Add message to countdown and commit changes + if (await addMessage(countdown, obj)): session.commit() + + # Run commands + try: + # Make command prefixes, names, and arguments case insensitive + obj.content = obj.content.lower() + + # Execute command + await self.process_commands(obj) + except: + pass + + + + async def on_command_error(self, ctx, error): + # Send error embed + embed=discord.Embed(title=":warning: Error", description=str(error), color=COLORS["error"]) + if (isinstance(error, commands.CommandNotFound)): + embed.description = f"Command not found: `{str(error)[9:-14]}`" + elif (isinstance(error.original, CountdownNotFound)): + embed.description = f"Countdown not found" + elif (isinstance(error.original, ContributorNotFound)): + embed.description = f"Contributor not found: `{error.original.args[0]}`" + elif (isinstance(error.original, EmptyCountdownError)): + embed.description = f"The countdown is empty" + elif (isinstance(error.original, CommandError)): + embed.description = error.original.args[0] + else: + # Unanticipated error + embed.description = str(error) + logging.error(f"Error during command {ctx.message.content}", exc_info=error) + embed.description += f"\n\nUse `{(await self.get_prefix(ctx))[0]}help` to view help information" + await ctx.send(embed=embed) diff --git a/countdown_bot/botUtilities.py b/countdown_bot/botUtilities.py @@ -0,0 +1,321 @@ +# Import dependencies +import discord +import re + +# Import modules +from .models import Countdown, Message, MessageIncorrectError, MessageNotAllowedError + + + +COLORS = { + "error": 0xD52C42, + "embed": 0x248AD1, +} + + + +# Error classes +class CommandError(Exception): + """Raised when a command encounters an anticipated error""" + pass +class ContributorNotFound(Exception): + """Raised when a matching countdown contributor cannot be found""" + pass +class CountdownNotFound(Exception): + """Raised when a matching countdown cannot be found""" + pass + + + +async def getUsername(bot, id): + """ + Get a username from a user ID + + Parameters + ---------- + bot : commands.Bot + The bot + id : int + The user ID + + Returns + ------- + str + The username (ex: "user#0000") + """ + + user = await bot.fetch_user(id) + return f"{user.name}#{user.discriminator}" + + + +async def getNickname(bot, server, id): + """ + Get a user's nickname in a server + + Parameters + ---------- + bot : commands.Bot + The bot + server : int + The server ID + id : int + The user ID + + Returns + ------- + str + The nickname + """ + + return (await (bot.get_guild(server)).fetch_member(id)).nick or await getUsername(bot, id) + + + +async def getContributor(bot, countdown, text): + """ + Get the ID of the countdown contributor refered to by a string + + Parameters + ---------- + bot : commands.Bot + The bot + countdown : Countdown + The countdown + text : str + The string + + Returns + ------- + int + The ID of the contributor + + Raises + ------ + ContributorNotFound + If a matching contributor cannot be found + """ + + # Get countdown contributors + contributors = [x["author"] for x in countdown.contributors()] + + # Get user from mention + if (re.match("^<@!\d+>$", text) and int(text[3:-1]) in contributors): + return int(text[3:-1]) + elif (re.match("^<@!\d+>$", text)): + raise ContributorNotFound(text) + + # Get user from username + for contributor in contributors: + try: + username = await getUsername(bot, contributor) + except: + continue + if (username.lower().startswith(text.lower())): + return contributor + + # Get user from nickname + for contributor in contributors: + try: + nickname = await getNickname(bot, countdown.server_id, contributor) + except: + continue + if (nickname.lower().startswith(text.lower())): + return contributor + + raise ContributorNotFound(text) + + + +def getCountdown(session, id): + """ + Get a countdown object + + Parameters + ---------- + session : sqlalchemy.orm.Session + The database session to use + id : int + The countdown id + + Returns + ------- + Countdown + The Countdown + """ + + return session.query(Countdown).filter(Countdown.id == id).first() + + + +def getContextCountdown(session, ctx): + """ + Get the most relevant countdown to a certain context + + Parameters + ---------- + session : sqlalchemy.orm.Session + The database session to use + ctx : discord.ext.commands.Context + The context + + Returns + ------- + Countdown + The countdown + + Raises + ------ + CountdownNotFound + If a matching countdown cannot be found + """ + + if (isinstance(ctx.channel, discord.channel.TextChannel)): + # Countdown channel + countdown = getCountdown(session, ctx.channel.id) + if (countdown): return countdown + + # Server with countdown channel: get first countdown in this server that use the current prefix + countdown = session.query(Countdown).filter(Countdown.server_id == ctx.channel.guild.id and ctx.prefix in [x.value for x in Countdown.prefixes]).first() + if (countdown): return countdown + + if (isinstance(ctx.channel, discord.channel.DMChannel)): + # DM with user who has contributed to a countdown: get the first countdown they ever contributed to + firstMessage = session.query(Message).filter(Message.author_id == ctx.author.id).order_by(Message.timestamp).first() + if (firstMessage): return firstMessage.countdown + + raise CountdownNotFound() + + + +def getPrefix(databaseSessionMaker, ctx, default): + """ + Get the bot prefix for a certain context + + Parameters + ---------- + databaseSessionMaker : sqlalchemy.orm.sessionmaker + The database session maker + ctx : discord.ext.commands.Context + The context + default : list + The default prefixes + """ + + with databaseSessionMaker() as session: + # Countdown channel + countdown = getCountdown(session, ctx.channel.id) + if (countdown and len(countdown.prefixes) > 0): + return [x.value.lower() for x in countdown.prefixes] + + # Server with countdown channels + if (isinstance(ctx.channel, discord.channel.TextChannel)): + serverCountdowns = session.query(Countdown).filter(Countdown.server_id == ctx.channel.guild.id).all() + # Get list of prefixes + prefixes = [] + for countdown in serverCountdowns: + prefixes += [x.value.lower() for x in countdown.prefixes] + if (len(prefixes) > 0): + return list(dict.fromkeys(prefixes)) + + # Return default prefixes + return [x.lower() for x in default] + + + +def parseMessage(message): + """ + Parses a countdown message from a Discord message + + Parameters + ---------- + message : discord.Message + The Discord message + + Returns + ------- + Message + """ + + return Message( + id = message.id, + countdown_id = message.channel.id, + author_id = message.author.id, + timestamp = message.created_at, + number = int(re.findall("^[0-9,]+", message.content)[0].replace(",","")), + ) + + + +async def addMessage(countdown, rawMessage): + """ + Parse a message and add it to a countdown + + Notes + ----- + If the message is invalid or incorrect, a reacted will be added accordingly + + Parameters + ---------- + countdown : Countdown + The countdown + rawMessage : discord.Message + The Discord message object + + Returns + ------- + bool + Whether the message was valid and added to the countdown + """ + + try: + # Parse message + message = parseMessage(rawMessage) + + # Add message + countdown.addMessage(message) + + # Mark important messages + if (message.number in [x.number for x in countdown.reactions]): + for reaction in [x for x in countdown.reactions if x.number == message.number]: + try: + await rawMessage.add_reaction(reaction.value) + except: + pass + if (countdown.messages[0].number >= 500 and message.number % (countdown.messages[0].number // 50) == 0): + await rawMessage.pin() + except MessageNotAllowedError: + await rawMessage.add_reaction("⛔") + return False + except MessageIncorrectError: + await rawMessage.add_reaction("❌") + return False + except: + return False + else: + return True + + + +async def loadCountdown(bot, countdown): + """ + Loads countdown messages from a Discord countdown + + Parameters + ---------- + bot : commands.Bot + The bot to load messages with + countdown : Countdown + The countdown to load messages for + """ + + # Clear countdown + countdown.messages = [] + + # Get Discord messages + rawMessages = [message async for message in bot.get_channel(countdown.id).history(limit=10100)] + rawMessages.reverse() + + # Add messages to countdown + for rawMessage in rawMessages: + await addMessage(countdown, rawMessage) diff --git a/src/models.py b/countdown_bot/models.py diff --git a/countdown_bot/utilitiesCog.py b/countdown_bot/utilitiesCog.py @@ -0,0 +1,421 @@ +# Import dependencies +import discord +from discord.ext import commands + +# Import modules +from .botUtilities import COLORS, CommandError, getContextCountdown, getCountdown, loadCountdown +from .models import Countdown, Prefix, Reaction + + + +class Utilities(commands.Cog): + def __init__(self, bot, databaseSessionMaker): + self.bot = bot + self.databaseSessionMaker = databaseSessionMaker + self.bot.remove_command("help") + + + + @commands.command() + async def activate(self, ctx): + """ + Turns a channel into a countdown + """ + + with self.databaseSessionMaker() as session: + # Check if channel is already a countdown + if (getCountdown(session, ctx.channel.id)): + raise CommandError("This channel is already a countdown") + + # Check if channel is a DM + if (not isinstance(ctx.channel, discord.channel.TextChannel)): + raise CommandError("This command must be run inside a server") + + # Check if user isn't authorized + if (not ctx.message.author.guild_permissions.administrator): + raise CommandError("You must be an administrator to turn a channel into a countdown") + + # Create countdown + countdown = Countdown( + id = ctx.channel.id, + server_id = ctx.channel.guild.id, + timezone = 0, + prefixes = [Prefix(countdown_id=ctx.channel.id, value=x) for x in self.bot.prefixes], + reactions = [], + messages = [], + ) + + # Send initial response + self.bot.logger.info(f"Activated {self.bot.get_channel(ctx.channel.id)} (ID {ctx.channel.id}) as a countdown") + embed = discord.Embed(title=":clock3: Loading Countdown", description="This channel is now a countdown\nPlease wait to start counting", color=COLORS["embed"]) + msg = await ctx.send(embed=embed) + + # Load countdown + await loadCountdown(self.bot, countdown) + session.add(countdown) + session.commit() + + # Send final response + embed = discord.Embed(title=":white_check_mark: Countdown Activated", description="This channel is now a countdown\nYou may start counting!", color=COLORS["embed"]) + await msg.edit(embed=embed) + + + + @commands.command() + async def config(self, ctx, key=None, *args): + """ + Shows and modifies countdown settings + """ + + # Create embed + embed = discord.Embed(title=":gear: Countdown Settings", color=COLORS["embed"]) + + # Make sure context is in a server + if (not isinstance(ctx.channel, discord.channel.TextChannel)): + raise CommandError("This command must be run in a countdown channel or a server with a countdown channel") + + with self.databaseSessionMaker() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) + + # Get / set settings + if (key is None): + embed.description = f"**Countdown Channel:** <#{countdown.id}>\n" + embed.description += f"**Command Prefixes:** `{'`, `'.join([x.value for x in countdown.prefixes])}`\n" + embed.description += f"**Countdown Timezone:** {countdown.getTimezone()}\n" + if (len(countdown.reactions) == 0): + embed.description += f"**Reactions:** none\n" + else: + embed.description += f"**Reactions:**\n" + for number in list(dict.fromkeys([x.number for x in countdown.reactions])): + embed.description += f"**-** #{number}: {', '.join([x.value for x in countdown.reactions if x.number == number])}\n" + embed.description += f"\nUse `{ctx.prefix}help config` to view more information about settings\n" + embed.description += f"Use `{ctx.prefix}config <key> <value>` to modify settings\n" + elif (not ctx.message.author.guild_permissions.administrator): + raise CommandError("You must be an administrator to modify settings") + elif (len(args) == 0): + raise CommandError("Please provide a value for the setting") + elif (key in ["tz", "timezone"]): + try: + countdown.timezone = float(args[0]) + except: + raise CommandError(f"Invalid timezone: `{args[0]}`") + else: + embed.description = f"Timezone set to {countdown.getTimezone()}" + elif (key in ["prefix", "prefixes"]): + countdown.prefixes = [Prefix(countdown_id=ctx.channel.id, value=x) for x in args] + embed.description = f"Prefixes updated" + elif (key in ["react"]): + try: + number = int(args[0]) + if (number < 0): + raise CommandError("Number must be greater than zero") + elif (len(args) == 1): + countdown.reactions = [x for x in countdown.reactions if x.number != number] + embed.description = f"Removed reactions for #{number}" + else: + countdown.reactions = [x for x in countdown.reactions if x.number != number] + countdown.reactions += [Reaction(countdown_id=countdown.id, number=number, value=x) for x in args[1:]] + embed.description = f"Updated reactions for #{number}" + except: + raise CommandError(f"Invalid number: `{args[0]}`") + else: + raise CommandError(f"Setting not found: `{key}`") + + # Save changes + session.commit() + + # Send embed + await ctx.send(embed=embed) + + + + @commands.command() + async def deactivate(self, ctx): + """ + Deactivates a countdown channel + """ + + with self.databaseSessionMaker() as session: + # Check if channel isn't a countdown + countdown = getCountdown(session, ctx.channel.id) + if (not countdown): + raise CommandError("This channel isn't a countdown") + + # Check if user isn't authorized + if (not ctx.author.guild_permissions.administrator): + raise CommandError("You must be an administrator to deactivate a countdown channel") + + # Delete countdown + session.delete(countdown) + session.commit() + + # Send response + self.bot.logger.info(f"Deactivated {self.bot.get_channel(ctx.channel.id)} (ID {ctx.channel.id}) as a countdown") + embed = discord.Embed(title=":octagonal_sign: Countdown Deactivated", description="This channel is no longer a countdown", color=COLORS["embed"]) + await ctx.send(embed=embed) + + + + @commands.command(aliases=["h", ""]) + async def help(self, ctx, command=None): + """ + Shows help information + """ + + # Initialize help information + prefixes = await self.bot.get_prefix(ctx) + help_text = { + "prefixes": + f"`{'`, `'.join(prefixes)}`", + "utility-commands": + "**-** `activate`: Turns a channel into a countdown\n" \ + "**-** `config`: Shows and modifies bot and countdown settings\n" \ + "**-** `deactivate`: Deactivates a countdown channel\n" \ + "**-** `help`, `h`: Shows help information\n" \ + "**-** `ping`: Pings the bot\n" \ + "**-** `reload`: Reloads the countdown cache\n", + "analytics-commands": + "**-** `analytics`, `a`: Shows all countdown analytics\n" \ + "**-** `contributors`, `c`: Shows information about countdown contributors\n" \ + "**-** `eta`, `e`: Shows information about the estimated completion date\n" \ + "**-** `heatmap`: Shows a heatmap of when messages are sent\n" \ + "**-** `leaderboard`, `l`: Shows the countdown leaderboard\n" \ + "**-** `progress`, `p`: Shows information about countdown progress\n" \ + "**-** `speed`, `s`: Shows information about countdown speed\n", + "behavior": + "**-** Reacts with :no_entry: when a user counts out of turn\n" \ + "**-** Reacts with :x: when a user counts incorrectly\n" \ + "**-** Ignores messages that don't start with a (positive) number\n" \ + "**-** Pins numbers every 2% if the countdown started at 500 or higher\n", + "getting-started": + f"**1.** View help information using the `{prefixes[0]}help` command\n" \ + f"**2.** Activate a new countdown channel using the `{prefixes[0]}activate` command\n" \ + f"**3.** Change my settings using the `{prefixes[0]}config` command\n" \ + f"**4.** View countdown analytics using the `{prefixes[0]}analytics` command\n", + "troubleshooting": + f"**1.** Run `{prefixes[0]}ping` to make sure that I'm online\n" \ + f"**2.** If I reacted incorrectly to a message, remove my incorrect reaction(s)\n" \ + f"**3.** Run `{prefixes[0]}reload` in the countdown channel\n", + "activate": + "**Name:** activate\n" \ + "**Description:** Turns a channel into a countdown\n" \ + f"**Usage:** `{prefixes[0]}activate`\n" \ + "**Aliases:** none\n" \ + "**Arguments:** none\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}activate`\n" \ + "**Notes:** Users must have admin permissions to turn a channel into a countdown\n", + "analytics": + "**Name:** analytics\n" \ + "**Description:** Shows all countdown analytics\n" \ + f"**Usage:** `{prefixes[0]}analytics|a`\n" \ + "**Aliases:** `a`\n" \ + "**Arguments: none**\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}analytics`\n" \ + "**Notes:** none\n", + "config": + "**Name:** config\n" \ + "**Description:** Shows and modifies countdown settings\n" \ + f"**Usage:** `{prefixes[0]}config [<key> <value>...]`\n" \ + "**Aliases:** none\n" \ + "**Arguments:**\n" \ + "**-** `<key>`: The name of the setting to modify. If no key is supplied, all settings will be shown\n" \ + "**-** `<value>`: The new value(s) for the setting\n" \ + "**Available Settings:**\n" \ + "**-** `prefix`, `prefixes`: The prefix(es) for the bot\n" \ + "**-** `tz`, `timezone`: The UTC offset in hours\n" \ + "**-** `react`: The reactions for a certain number\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}config`\n" \ + f"**-** `{prefixes[0]}config prefixes prefix1 prefix2 prefix3`\n" \ + f"**-** `{prefixes[0]}config timezone -1.5`\n" \ + f"**-** `{prefixes[0]}config react 0 :partying_face: :smile:`\n" \ + "**Notes:** Users must have admin permissions to modify settings\n", + "contributors": + "**Name:** contributors\n" \ + "**Description:** Shows information about countdown contributors\n" \ + f"**Usage:** `{prefixes[0]}contributors|c [history|h]`\n" \ + "**Aliases:** `c`\n" \ + "**Arguments:**\n" \ + "**-** `history`, `h`: Shows historical data about countdown contributors\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}contributors`\n" \ + f"**-** `{prefixes[0]}contributors history`\n" \ + "**Notes:** The contributors embed will only show the top 20 contributors\n", + "deactivate": + "**Name:** deactivate\n" \ + "**Description:** Deactivates a countdown channel\n" \ + f"**Usage:** `{prefixes[0]}deactivate`\n" \ + "**Aliases:** none\n" \ + "**Arguments:** none\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}deactivate`\n" \ + "**Notes:** Users must have admin permissions to deactivate a countdown channel\n", + "eta": + "**Name:** eta\n" \ + "**Description:** Shows information about the estimated completion date\n" \ + f"**Usage:** `{prefixes[0]}eta|e [<period>]`\n" \ + "**Aliases:** `e`\n" \ + "**Arguments:**\n" \ + "**-** `<period>`: The size of the period in hours (the default is 24 hours)\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}eta`\n" \ + f"**-** `{prefixes[0]}eta 48`\n" \ + "**Notes:** none\n", + "heatmap": + "**Name:** heatmap\n" \ + "**Description:** Shows a heatmap of when countdown messages are sent\n" \ + f"**Usage:** `{prefixes[0]}heatmap [<user>]`\n" \ + "**Aliases:** none\n" \ + "**Arguments:**\n" \ + "**-** `<user>`: The username or nickname of the user to view heatmap information about. If no value is supplied, the general heatmap will be shown\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}heatmap`\n" \ + f"**-** `{prefixes[0]}heatmap @Alice`\n" \ + f"**-** `{prefixes[0]}heatmap Bob`\n" \ + "**Notes:** none\n", + "help": + "**Name:** help\n" \ + "**Description:** Shows help information\n" \ + f"**Usage:** `{prefixes[0]}help|h [<command>]`\n" \ + "**Aliases:** `h`\n" \ + "**Arguments:**\n" \ + "**-** `<command>`: The command to view help information about. If no value is supplied, general help information will be shown\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}help`\n" \ + f"**-** `{prefixes[0]}help config`\n" \ + "**Notes:** none\n", + "leaderboard": + "**Name:** leaderboard\n" \ + "**Description:** Shows the countdown leaderboard\n" \ + f"**Usage:** `{prefixes[0]}leaderboard|l [<user>]`\n" \ + "**Aliases:** `l`\n" \ + "**Arguments:**\n" \ + "**-** `<user>`: The rank, username, or nickname of the user to view leaderboard information about. If no value is supplied, the whole leaderboard will be shown\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}leaderboard`\n" \ + f"**-** `{prefixes[0]}leaderboard 1`\n" \ + f"**-** `{prefixes[0]}leaderboard @Alice`\n" \ + f"**-** `{prefixes[0]}leaderboard Bob`\n" \ + "**Notes:** The leaderboard embed will only show the top 20 contributors\n", + "ping": + "**Name:** ping\n" \ + "**Description:** Pings the bot\n" \ + f"**Usage:** `{prefixes[0]}ping`\n" \ + "**Aliases:** none\n" \ + "**Arguments:** none\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}ping`\n" \ + "**Notes:** none\n", + "progress": + "**Name:** progress\n" \ + "**Description:** Shows information about countdown progress\n" \ + f"**Usage:** `{prefixes[0]}progress|p`\n" \ + "**Aliases:** `p`\n" \ + "**Arguments:** none\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}progress`\n" \ + "**Notes:** none\n", + "reload": + "**Name:** reload\n" \ + "**Description:** Reloads the countdown cache\n" \ + f"**Usage:** `{prefixes[0]}reload`\n" \ + "**Aliases:** none\n" \ + "**Arguments:** none\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}reload`\n" \ + "**Notes:** This command must be used in a countdown channel\n", + "speed": + "**Name:** speed\n" \ + "**Description:** Shows information about countdown speed\n" \ + f"**Usage:** `{prefixes[0]}speed|s [<period>]`\n" \ + "**Aliases:** `s`\n" \ + "**Arguments:**\n" \ + "**-** `<period>`: The size of the period in hours (the default is 24 hours)\n" \ + "**Examples:**\n" \ + f"**-** `{prefixes[0]}speed`\n" \ + f"**-** `{prefixes[0]}speed 48`\n" \ + "**Notes:** none\n", + } + + # Create embed + embed=discord.Embed(title=":grey_question: countdown-bot Help", color=COLORS["embed"]) + if (command is None): + embed.add_field(name="Command Prefixes :gear:", value=help_text["prefixes"], inline=False) + embed.add_field(name="Utility Commands :wrench:", value=help_text["utility-commands"], inline=False) + embed.add_field(name="Analytics Commands :bar_chart:", value=help_text["analytics-commands"], inline=False) + embed.add_field(name="Behavior in Countdown Channels :robot:", value=help_text["behavior"], inline=False) + embed.add_field(name="Getting Started :rocket:", value=help_text["getting-started"], inline=False) + embed.add_field(name="Troubleshooting :screwdriver:", value=help_text["troubleshooting"], inline=False) + embed.description = f"Use `{prefixes[0]}help command` to get more info on a command" + elif (command.lower() in ["activate"]): + embed.description = help_text["activate"] + elif (command.lower() in ["a", "analytics"]): + embed.description = help_text["analytics"] + elif (command.lower() in ["config"]): + embed.description = help_text["config"] + elif (command.lower() in ["c", "contributors"]): + embed.description = help_text["contributors"] + elif (command.lower() in ["deactivate"]): + embed.description = help_text["deactivate"] + elif (command.lower() in ["e", "eta"]): + embed.description = help_text["eta"] + elif (command.lower() in ["heatmap"]): + embed.description = help_text["heatmap"] + elif (command.lower() in ["h", "help"]): + embed.description = help_text["help"] + elif (command.lower() in ["l", "leaderboard"]): + embed.description = help_text["leaderboard"] + elif (command.lower() in ["ping"]): + embed.description = help_text["ping"] + elif (command.lower() in ["p", "progress"]): + embed.description = help_text["progress"] + elif (command.lower() in ["reload"]): + embed.description = help_text["reload"] + elif (command.lower() in ["s", "speed"]): + embed.description = help_text["speed"] + else: + raise CommandError(f"Command not found: `{command}`") + + # Send embed + await ctx.send(embed=embed) + + + + @commands.command() + async def ping(self, ctx): + """ + Pings the bot + """ + + embed=discord.Embed(title=":ping_pong: Pong!", color=COLORS["embed"]) + embed.description = f"**Latency:** {round(self.bot.latency * 1000)} ms\n" + await ctx.send(embed=embed) + + + + @commands.command() + async def reload(self, ctx): + """ + Reloads the countdown cache + """ + + with self.databaseSessionMaker() as session: + countdown = getCountdown(session, ctx.channel.id) + if (countdown): + # Send initial response + embed = discord.Embed(title=":clock3: Reloading Countdown Cache", description="Please wait to continue counting", color=COLORS["embed"]) + msg = await ctx.channel.send(embed=embed) + + # Reload messages + await loadCountdown(self.bot, countdown) + session.commit() + + # Send final response + self.bot.logger.info(f"Reloaded messages from {self.bot.get_channel(ctx.channel.id)} (ID {ctx.channel.id})") + embed = discord.Embed(title=":white_check_mark: Countdown Cache Reloaded", description="Done! You may continue counting!", color=COLORS["embed"]) + await msg.edit(embed=embed) + else: + raise CommandError("Countdown not found\nThis command must be used in a countdown channel") diff --git a/run.py b/run.py @@ -1,27 +0,0 @@ -# Import dependencies -from dotenv import load_dotenv -import logging -import os - -# Import modules -from src import CountdownBot -from src.models import getSessionMaker - -# Load settings -load_dotenv() - -# Setup logging -logger = logging.getLogger() -logger.setLevel(getattr(logging, os.environ.get("LOG_LEVEL", "INFO"))) -logging.basicConfig( - format = "[{asctime}] [{levelname:<8}] {name}: {message}", - style="{", - filename = os.environ.get("LOG_FILE", "log.txt"), -) - -# Connect to database -databaseSessionMaker = getSessionMaker(os.environ.get("DATABASE")) - -# Run bot -bot = CountdownBot(databaseSessionMaker, [os.environ.get("PREFIX", "!")]) -bot.run(os.environ.get("TOKEN")) diff --git a/src/__init__.py b/src/__init__.py @@ -1,2 +0,0 @@ -# Import CountdownBot so it can be easily imported from src -from src.bot import CountdownBot diff --git a/src/analyticsCog.py b/src/analyticsCog.py @@ -1,512 +0,0 @@ -# Import dependencies -from datetime import datetime, timedelta -import discord -from discord.ext import commands -from matplotlib import pyplot as plt -from matplotlib.ticker import PercentFormatter -import numpy as np -import os -import re -import tempfile - -# Import modules -from src.botUtilities import COLORS, getContextCountdown, getUsername, getContributor, CommandError -from src.models import POINT_RULES - - - -class Analytics(commands.Cog): - def __init__(self, bot, databaseSessionMaker): - self.bot = bot - self.databaseSessionMaker = databaseSessionMaker - - - - @commands.command(aliases=["a"]) - async def analytics(self, ctx): - """ - Shows all countdown analytics - """ - - # Run analytics commands - await self.contributors(ctx, "") - await self.contributors(ctx, "history") - await self.eta(ctx) - await self.heatmap(ctx) - await self.leaderboard(ctx) - await self.progress(ctx) - await self.speed(ctx) - - - - @commands.command(aliases=["c"]) - async def contributors(self, ctx, option=""): - """ - Shows information about countdown contributors - """ - - with self.databaseSessionMaker() as session: - # Get countdown channel - countdown = getContextCountdown(session, ctx) - - # Create temp file - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") - tmp.close() - - # Create embed - embed=discord.Embed(title=":busts_in_silhouette: Countdown Contributors", color=COLORS["embed"]) - - # Make sure the countdown has started - if (option.lower() in ["h", "history"]): - # Create figure - fig, ax = plt.subplots() - ax.set_xlabel("Progress") - ax.set_ylabel("Percentage of Contributions") - ax.yaxis.set_major_formatter(PercentFormatter()) - - # Get stats - contributors = countdown.historicalContributors() - - # Plot data and add legend - for author in list(contributors.keys())[:min(len(contributors), 15)]: - # Top 15 contributors get included in the legend - ax.plot([x["progress"] for x in contributors[author]], [x["percentage"] * 100 for x in contributors[author]], label=await getUsername(self.bot, author)) - for author in list(contributors.keys())[15:max(len(contributors), 15)]: - ax.plot([x["progress"] for x in contributors[author]], [x["percentage"] * 100 for x in contributors[author]]) - ax.legend(bbox_to_anchor=(1,1.025), loc="upper left") - - # Save graph - fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) - file = discord.File(tmp.name, filename="image.png") - - # Add content to embed - embed.description = f"**Countdown Channel:** <#{countdown.id}>" - embed.set_image(url="attachment://image.png") - elif (option == ""): - # Create figure - fig, ax = plt.subplots() - - # Get stats - contributors = countdown.contributors() - - # Add data to graph - x = [x["author"] for x in contributors] - y = [x["contributions"] for x in contributors] - pieData = ax.pie(y, autopct="%1.1f%%", startangle=90) - - # Add legend - ax.legend(pieData[0], [await getUsername(self.bot, i) for i in x[:min(len(x), 15)]], bbox_to_anchor=(1,1.025), loc="upper left") - - # Save graph - fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) - file = discord.File(tmp.name, filename="image.png") - - # Add content to embed - embed.description = f"**Countdown Channel:** <#{countdown.id}>" - ranks = "" - users = "" - contributions = "" - for i in range(0, min(len(x), 20)): - ranks += f"{i+1:,}\n" - contributions += f"{y[i]:,} *({round(y[i] / len(countdown.messages) * 100, 1)}%)*\n" - users += f"<@{x[i]}>\n" - embed.add_field(name="Rank",value=ranks, inline=True) - embed.add_field(name="User",value=users, inline=True) - embed.add_field(name="Contributions",value=contributions, inline=True) - embed.set_image(url="attachment://image.png") - else: - raise CommandError(f"Unrecognized option: `{option}`") - - # Send embed - try: - await ctx.send(file=file, embed=embed) - except: - await ctx.send(embed=embed) - - # Remove temp file - try: - os.remove(tmp.name) - except Exception as e: - self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e) - - - - @commands.command(aliases=["e"]) - async def eta(self, ctx, period="24.0"): - """ - Shows information about the estimated completion date - """ - - with self.databaseSessionMaker() as session: - # Get countdown channel - countdown = getContextCountdown(session, ctx) - - # Create temp file - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") - tmp.close() - - # Create embed - embed=discord.Embed(title=":calendar: Countdown Estimated Completion Date", color=COLORS["embed"]) - - # Parse period - try: - period = float(period) - except ValueError: - raise CommandError(f"Invalid number: `{period}`") - - # Make sure period is valid - if (period < 0.01): - raise CommandError("The period cannot be less than 0.01 hours") - - # Get stats - eta = countdown.eta(timedelta(hours=period)) - - # Create figure - fig, ax = plt.subplots() - ax.set_xlabel("Time") - fig.autofmt_xdate() - - # Add ETA data to graph - ax.plot(eta[0], eta[1], "C0", label="Estimated Completion Date") - - # Add reference line graph - ax.plot([eta[0][0], eta[0][-1]], [eta[0][0], eta[0][-1]], "--C1", label="Current Date") - - # Add legend - ax.legend() - - # Save graph - fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) - file = discord.File(tmp.name, filename="image.png") - - # Calculate embed data - maxEta = max(eta[1]) - maxDate = eta[0][eta[1].index(maxEta)] - minEta = min(eta[1][1:]) - minDate = eta[0][eta[1].index(minEta)] - end = eta[1][-1] + timedelta(hours=countdown.timezone) - endDiff = eta[1][-1] - datetime.utcnow() - - # Add content to embed - embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" - embed.description += f"**Maximum Estimate:** {maxEta.date()} (on {maxDate.date()})\n" - embed.description += f"**Minimum Estimate:** {minEta.date()} (on {minDate.date()})\n" - if endDiff < timedelta(seconds=0): - embed.description += f"**Actual Completion Date:** {end.date()} ({(-1 * endDiff).days:,} days ago)\n" - else: - embed.description += f"**Current Estimate:** {end.date()} ({endDiff.days:,} days from now)\n" - embed.set_image(url="attachment://image.png") - - # Send embed - try: - await ctx.send(file=file, embed=embed) - except: - await ctx.send(embed=embed) - - # Remove temp file - try: - os.remove(tmp.name) - except Exception as e: - self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e) - - - - @commands.command() - async def heatmap(self, ctx, user=None): - """ - Shows a heatmap of when countdown messages are sent - """ - - with self.databaseSessionMaker() as session: - # Get countdown channel - countdown = getContextCountdown(session, ctx) - - # Create temp file - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") - tmp.close() - - # Create embed - embed=discord.Embed(title=":calendar_spiral: Countdown Heatmap", color=COLORS["embed"]) - - # Get user - if (user == None): - userID = None - else: - userID = await getContributor(self.bot, countdown, user) - - # Get heatmap matrix - heatmapMatrix = countdown.heatmap(userID) - - # Define hour and weekday names - hours = ["12 AM", "1 AM", "2 AM", "3 AM", "4 AM", "5 AM", "6 AM", "7 AM", "8 AM", "9 AM", "10 AM", "11 AM", "12 PM", "1 PM", "2 PM", "3 PM", "4 PM", "5 PM", "6 PM", "7 PM", "8 PM", "9 PM", "10 PM", "11 PM"] - weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] - - # Create figure - fig, ax = plt.subplots() - ax.set_xlabel("Hour of Day") - ax.set_xticks([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]) - ax.set_xticklabels(hours) - ax.set_ylabel("Day of Week") - ax.set_yticks([0, 1, 2, 3, 4, 5, 6]) - ax.set_yticklabels(weekdays) - - # Add data to graph - cmap = plt.get_cmap("jet").copy() - cmap.set_bad("gray") - cax = ax.matshow(np.ma.masked_equal(np.array(heatmapMatrix), 0), cmap=cmap, aspect="auto") - fig.colorbar(cax) - - # Save graph - fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) - file = discord.File(tmp.name, filename="image.png") - - # Get embed data - total = np.sum(heatmapMatrix) - averageValue = total / (24*7) - maxValue = np.max(heatmapMatrix) - maxWeekday = np.where(heatmapMatrix == maxValue)[0][0] - maxHour = np.where(heatmapMatrix == maxValue)[1][0] - currentWeekday = ((datetime.utcnow() + timedelta(hours=countdown.timezone)).weekday() + 1) % 7 - currentHour = (datetime.utcnow() + timedelta(hours=countdown.timezone)).hour - currentValue = heatmapMatrix[currentWeekday][currentHour] - - # Add content to embed - embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" - if (userID): embed.description += f"**User:** <@{userID}>\n" - embed.description += f"**Total Contributions:** {total:,}\n" - embed.description += f"**Average Contributions per Zone:** {round(averageValue):,}\n" - embed.description += f"**Best Zone:** {hours[maxHour]} to {hours[(maxHour + 1) % 24]} on {weekdays[maxWeekday]}s - {maxValue:,} contributions\n" - embed.description += f"**Current Zone:** {hours[currentHour]} to {hours[(currentHour + 1) % 24]} on {weekdays[currentWeekday]}s - {currentValue:,} contributions\n" - embed.set_image(url="attachment://image.png") - - # Send embed - try: - await ctx.send(file=file, embed=embed) - except: - await ctx.send(embed=embed) - - # Remove temp file - try: - os.remove(tmp.name) - except Exception as e: - self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e) - - - - @commands.command(aliases=["l"]) - async def leaderboard(self, ctx, user=None): - """ - Shows the countdown leaderboard - """ - - with self.databaseSessionMaker() as session: - # Get countdown channel - countdown = getContextCountdown(session, ctx) - - # Get leaderboard - leaderboard = countdown.leaderboard() - - # Create embed - embed=discord.Embed(title=":trophy: Countdown Leaderboard", color=COLORS["embed"]) - - # Make sure the countdown has started - if (user is None): - # Add description - embed.description = f"**Countdown Channel:** <#{countdown.id}>" - - # Add leaderboard - ranks = "" - points = "" - users = "" - for i in range(0, min(len(leaderboard), 20)): - ranks += f"{i+1:,}\n" - points += f"{leaderboard[i]['points']:,}\n" - users += f"<@{leaderboard[i]['author']}>\n" - embed.add_field(name="Rank",value=ranks, inline=True) - embed.add_field(name="Points",value=points, inline=True) - embed.add_field(name="User",value=users, inline=True) - - # Add leaderboard rules - rules = "" - values = "" - for rule in POINT_RULES: - rules += f"{rule}\n" - values += f"{POINT_RULES[rule]} points\n" - embed.add_field(name="Rules", value="Only 1 rule is applied towards each number", inline=False) - embed.add_field(name="Numbers", value=rules, inline=True) - embed.add_field(name="Points", value=values, inline=True) - else: - # Get user rank - if (re.match("^\d+$", user) and int(user) > 0 and int(user) <= len(leaderboard)): - rank = int(user) - 1 - else: - rank = [x["author"] for x in leaderboard].index(await getContributor(self.bot, countdown, user)) - - # Add description - embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" - embed.description += f"**User:** <@{leaderboard[rank]['author']}>\n" - embed.description += f"**Rank:** #{rank + 1:,}\n" - embed.description += f"**Total Points:** {leaderboard[rank]['points']:,}\n" - embed.description += f"**Total Contributions:** {leaderboard[rank]['contributions']:,} *({round(leaderboard[rank]['contributions'] / len(countdown.messages) * 100, 1)}%)*\n" - - # Add points breakdown - rules = "" - points = "" - percentage = "" - for category in leaderboard[rank]["breakdown"]: - rules += f"{category}\n" - points += f"{leaderboard[rank]['breakdown'][category] * POINT_RULES[category]:,} *({leaderboard[rank]['breakdown'][category]:,})*\n" - if (leaderboard[rank]['points'] > 0): - percentage += f"{round(leaderboard[rank]['breakdown'][category] * POINT_RULES[category] / leaderboard[rank]['points'] * 100, 1)}%\n" - else: - percentage += "0%\n" - embed.add_field(name="Category", value=rules, inline=True) - embed.add_field(name="Points", value=points, inline=True) - embed.add_field(name="Percentage", value=percentage, inline=True) - - # Send embed - await ctx.send(embed=embed) - - - - @commands.command(aliases=["p"]) - async def progress(self, ctx): - """ - Shows information about countdown progress - """ - - with self.databaseSessionMaker() as session: - # Get countdown channel - countdown = getContextCountdown(session, ctx) - - # Create temp file - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") - tmp.close() - - # Create embed - embed=discord.Embed(title=":chart_with_downwards_trend: Countdown Progress", color=COLORS["embed"]) - - # Get progress stats - stats = countdown.progress() - breakStats = countdown.longestBreak() - - # Create figure - fig, ax = plt.subplots() - ax.set_xlabel("Time") - ax.set_ylabel("Progress") - fig.autofmt_xdate() - - # Add data to graph - x = [stats["start"] + timedelta(hours=countdown.timezone)] + [x["time"] + timedelta(hours=countdown.timezone) for x in stats["progress"]] - y = [0] + [x["progress"] for x in stats["progress"]] - ax.plot(x, y) - - # Save graph - fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) - file = discord.File(tmp.name, filename="image.png") - - # Calculate embed data - longestBreakDuration = timedelta(days=breakStats['duration'].days, seconds=breakStats['duration'].seconds) - longestBreakStart = breakStats['start'].date() - longestBreakEnd = breakStats['end'].date() - start = (stats["start"] + timedelta(hours=countdown.timezone)).date() - startDiff = (datetime.utcnow() - stats["start"]).days - end = (stats["eta"] + timedelta(hours=countdown.timezone)).date() - endDiff = stats["eta"] - datetime.utcnow() - - # Add content to embed - embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" - embed.description += f"**Progress:** {stats['total'] - stats['current']:,} / {stats['total']:,} ({round(stats['percentage'], 1)}%)\n" - embed.description += f"**Average Progress per Day:** {round(stats['rate']):,}\n" - embed.description += f"**Longest Break:** {longestBreakDuration} ({longestBreakStart} to {longestBreakEnd})\n" - embed.description += f"**Start Date:** {start} ({startDiff:,} days ago)\n" - if endDiff < timedelta(seconds=0): - embed.description += f"**End Date:** {end} ({(-1 * endDiff).days:,} days ago)\n" - else: - embed.description += f"**Estimated End Date:** {end} ({endDiff.days:,} days from now)\n" - embed.set_image(url="attachment://image.png") - - # Send embed - try: - await ctx.send(file=file, embed=embed) - except: - await ctx.send(embed=embed) - - # Remove temp file - try: - os.remove(tmp.name) - except Exception as e: - self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e) - - - - @commands.command(aliases=["s"]) - async def speed(self, ctx, period="24.0"): - """ - Shows information about countdown speed - """ - - with self.databaseSessionMaker() as session: - # Get countdown channel - countdown = getContextCountdown(session, ctx) - - # Create temp file - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") - tmp.close() - - # Create embed - embed=discord.Embed(title=":stopwatch: Countdown Speed", color=COLORS["embed"]) - - # Parse period - try: - period = float(period) - except ValueError: - raise CommandError(f"Invalid number: `{period}`") - - # Make sure period is valid - if (period < 0.01): - raise CommandError("The period cannot be less than 0.01 hours") - - # Get stats - stats = countdown.progress() - period = timedelta(hours=period) - speed = countdown.speed(period) - - # Create figure - fig, ax = plt.subplots() - ax.set_xlabel("Time") - ax.set_ylabel("Progress per Period") - fig.autofmt_xdate() - - # Add data to graph - for i in range(0, len(speed[0])): - ax.bar(speed[0][i], speed[1][i], width=period, align="edge", color="#1f77b4") - - # Save graph - fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) - file = discord.File(tmp.name, filename="image.png") - - # Add content to embed - embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" - embed.description += f"**Period Size:** {period}\n" - if (len(countdown.messages) > 1): - rate = (stats['total'] - stats['current'])/((countdown.messages[-1].timestamp - countdown.messages[0].timestamp) / period) - else: - rate = 0 - embed.description += f"**Average Progress per Period:** {round(rate):,}\n" - embed.description += f"**Record Progress per Period:** {max(speed[1]):,}\n" - embed.description += f"**Last Period Start:** {speed[0][-1]}\n" - embed.description += f"**Progress during Last Period:** {speed[1][-1]:,}\n" - embed.set_image(url="attachment://image.png") - - # Send embed - try: - await ctx.send(file=file, embed=embed) - except: - await ctx.send(embed=embed) - - # Remove temp file - try: - os.remove(tmp.name) - except Exception as e: - self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e) diff --git a/src/bot.py b/src/bot.py @@ -1,101 +0,0 @@ -# Import dependencies -import discord -from discord.ext import commands -import logging - - -# Import modules -from src import analyticsCog, utilitiesCog -from src.botUtilities import addMessage, COLORS, CountdownNotFound, ContributorNotFound, CommandError, getCountdown, getPrefix -from src.models import EmptyCountdownError - - - -class CountdownBot(commands.Bot): - def __init__(self, databaseSessionMaker, prefixes): - # Set properties - self.databaseSessionMaker = databaseSessionMaker - self.prefixes = prefixes - self.logger = logging.getLogger(__name__) - - # Get intents - intents = discord.Intents.default() - intents.message_content = True - - # Initialize bot - super().__init__(command_prefix=lambda bot, ctx: getPrefix(self.databaseSessionMaker, ctx, self.prefixes), intents=intents) - - - - async def setup_hook(self): - await self.add_cog(analyticsCog.Analytics(self, self.databaseSessionMaker)) - await self.add_cog(utilitiesCog.Utilities(self, self.databaseSessionMaker)) - - - - async def on_ready(self): - self.logger.info(f"Connected to Discord as {self.user} (ID {self.user.id})") - - - - async def on_guild_join(self, guild): - # Print status - self.logger.info(f"Added to {guild} (ID {guild.id})") - - # Create embed - embed=discord.Embed(title=":rocket: Getting Started with countdown-bot", color=COLORS["embed"]) - embed.description = f"Thanks for adding me to your server! Here are some steps for getting started:\n" - embed.description += f"**1.** View help information using the `{self.prefixes[0]}help` command\n" - embed.description += f"**2.** Activate a new countdown channel using the `{self.prefixes[0]}activate` command\n" - embed.description += f"**3.** Change my settings using the `{self.prefixes[0]}config` command\n" - embed.description += f"**4.** View countdown analytics using the `{self.prefixes[0]}analytics` command\n" - - # Send embed - await guild.system_channel.send(embed=embed) - - - - async def on_message(self, obj): - # Respond to @mentions - if self.user in obj.mentions: - embed=discord.Embed(title="countdown-bot", description=f"Use `{(await self.get_prefix(obj))[0]}help` to view help information", color=COLORS["embed"]) - await obj.channel.send(embed=embed) - - # Parse countdown message - with self.databaseSessionMaker() as session: - countdown = getCountdown(session, obj.channel.id) - if (countdown): - # Add message to countdown and commit changes - if (await addMessage(countdown, obj)): session.commit() - - # Run commands - try: - # Make command prefixes, names, and arguments case insensitive - obj.content = obj.content.lower() - - # Execute command - await self.process_commands(obj) - except: - pass - - - - async def on_command_error(self, ctx, error): - # Send error embed - embed=discord.Embed(title=":warning: Error", description=str(error), color=COLORS["error"]) - if (isinstance(error, commands.CommandNotFound)): - embed.description = f"Command not found: `{str(error)[9:-14]}`" - elif (isinstance(error.original, CountdownNotFound)): - embed.description = f"Countdown not found" - elif (isinstance(error.original, ContributorNotFound)): - embed.description = f"Contributor not found: `{error.original.args[0]}`" - elif (isinstance(error.original, EmptyCountdownError)): - embed.description = f"The countdown is empty" - elif (isinstance(error.original, CommandError)): - embed.description = error.original.args[0] - else: - # Unanticipated error - embed.description = str(error) - logging.error(f"Error during command {ctx.message.content}", exc_info=error) - embed.description += f"\n\nUse `{(await self.get_prefix(ctx))[0]}help` to view help information" - await ctx.send(embed=embed) diff --git a/src/botUtilities.py b/src/botUtilities.py @@ -1,321 +0,0 @@ -# Import dependencies -import discord -import re - -# Import modules -from src.models import Countdown, Message, MessageIncorrectError, MessageNotAllowedError - - - -COLORS = { - "error": 0xD52C42, - "embed": 0x248AD1, -} - - - -# Error classes -class CommandError(Exception): - """Raised when a command encounters an anticipated error""" - pass -class ContributorNotFound(Exception): - """Raised when a matching countdown contributor cannot be found""" - pass -class CountdownNotFound(Exception): - """Raised when a matching countdown cannot be found""" - pass - - - -async def getUsername(bot, id): - """ - Get a username from a user ID - - Parameters - ---------- - bot : commands.Bot - The bot - id : int - The user ID - - Returns - ------- - str - The username (ex: "user#0000") - """ - - user = await bot.fetch_user(id) - return f"{user.name}#{user.discriminator}" - - - -async def getNickname(bot, server, id): - """ - Get a user's nickname in a server - - Parameters - ---------- - bot : commands.Bot - The bot - server : int - The server ID - id : int - The user ID - - Returns - ------- - str - The nickname - """ - - return (await (bot.get_guild(server)).fetch_member(id)).nick or await getUsername(bot, id) - - - -async def getContributor(bot, countdown, text): - """ - Get the ID of the countdown contributor refered to by a string - - Parameters - ---------- - bot : commands.Bot - The bot - countdown : Countdown - The countdown - text : str - The string - - Returns - ------- - int - The ID of the contributor - - Raises - ------ - ContributorNotFound - If a matching contributor cannot be found - """ - - # Get countdown contributors - contributors = [x["author"] for x in countdown.contributors()] - - # Get user from mention - if (re.match("^<@!\d+>$", text) and int(text[3:-1]) in contributors): - return int(text[3:-1]) - elif (re.match("^<@!\d+>$", text)): - raise ContributorNotFound(text) - - # Get user from username - for contributor in contributors: - try: - username = await getUsername(bot, contributor) - except: - continue - if (username.lower().startswith(text.lower())): - return contributor - - # Get user from nickname - for contributor in contributors: - try: - nickname = await getNickname(bot, countdown.server_id, contributor) - except: - continue - if (nickname.lower().startswith(text.lower())): - return contributor - - raise ContributorNotFound(text) - - - -def getCountdown(session, id): - """ - Get a countdown object - - Parameters - ---------- - session : sqlalchemy.orm.Session - The database session to use - id : int - The countdown id - - Returns - ------- - Countdown - The Countdown - """ - - return session.query(Countdown).filter(Countdown.id == id).first() - - - -def getContextCountdown(session, ctx): - """ - Get the most relevant countdown to a certain context - - Parameters - ---------- - session : sqlalchemy.orm.Session - The database session to use - ctx : discord.ext.commands.Context - The context - - Returns - ------- - Countdown - The countdown - - Raises - ------ - CountdownNotFound - If a matching countdown cannot be found - """ - - if (isinstance(ctx.channel, discord.channel.TextChannel)): - # Countdown channel - countdown = getCountdown(session, ctx.channel.id) - if (countdown): return countdown - - # Server with countdown channel: get first countdown in this server that use the current prefix - countdown = session.query(Countdown).filter(Countdown.server_id == ctx.channel.guild.id and ctx.prefix in [x.value for x in Countdown.prefixes]).first() - if (countdown): return countdown - - if (isinstance(ctx.channel, discord.channel.DMChannel)): - # DM with user who has contributed to a countdown: get the first countdown they ever contributed to - firstMessage = session.query(Message).filter(Message.author_id == ctx.author.id).order_by(Message.timestamp).first() - if (firstMessage): return firstMessage.countdown - - raise CountdownNotFound() - - - -def getPrefix(databaseSessionMaker, ctx, default): - """ - Get the bot prefix for a certain context - - Parameters - ---------- - databaseSessionMaker : sqlalchemy.orm.sessionmaker - The database session maker - ctx : discord.ext.commands.Context - The context - default : list - The default prefixes - """ - - with databaseSessionMaker() as session: - # Countdown channel - countdown = getCountdown(session, ctx.channel.id) - if (countdown and len(countdown.prefixes) > 0): - return [x.value.lower() for x in countdown.prefixes] - - # Server with countdown channels - if (isinstance(ctx.channel, discord.channel.TextChannel)): - serverCountdowns = session.query(Countdown).filter(Countdown.server_id == ctx.channel.guild.id).all() - # Get list of prefixes - prefixes = [] - for countdown in serverCountdowns: - prefixes += [x.value.lower() for x in countdown.prefixes] - if (len(prefixes) > 0): - return list(dict.fromkeys(prefixes)) - - # Return default prefixes - return [x.lower() for x in default] - - - -def parseMessage(message): - """ - Parses a countdown message from a Discord message - - Parameters - ---------- - message : discord.Message - The Discord message - - Returns - ------- - Message - """ - - return Message( - id = message.id, - countdown_id = message.channel.id, - author_id = message.author.id, - timestamp = message.created_at, - number = int(re.findall("^[0-9,]+", message.content)[0].replace(",","")), - ) - - - -async def addMessage(countdown, rawMessage): - """ - Parse a message and add it to a countdown - - Notes - ----- - If the message is invalid or incorrect, a reacted will be added accordingly - - Parameters - ---------- - countdown : Countdown - The countdown - rawMessage : discord.Message - The Discord message object - - Returns - ------- - bool - Whether the message was valid and added to the countdown - """ - - try: - # Parse message - message = parseMessage(rawMessage) - - # Add message - countdown.addMessage(message) - - # Mark important messages - if (message.number in [x.number for x in countdown.reactions]): - for reaction in [x for x in countdown.reactions if x.number == message.number]: - try: - await rawMessage.add_reaction(reaction.value) - except: - pass - if (countdown.messages[0].number >= 500 and message.number % (countdown.messages[0].number // 50) == 0): - await rawMessage.pin() - except MessageNotAllowedError: - await rawMessage.add_reaction("⛔") - return False - except MessageIncorrectError: - await rawMessage.add_reaction("❌") - return False - except: - return False - else: - return True - - - -async def loadCountdown(bot, countdown): - """ - Loads countdown messages from a Discord countdown - - Parameters - ---------- - bot : commands.Bot - The bot to load messages with - countdown : Countdown - The countdown to load messages for - """ - - # Clear countdown - countdown.messages = [] - - # Get Discord messages - rawMessages = [message async for message in bot.get_channel(countdown.id).history(limit=10100)] - rawMessages.reverse() - - # Add messages to countdown - for rawMessage in rawMessages: - await addMessage(countdown, rawMessage) diff --git a/src/utilitiesCog.py b/src/utilitiesCog.py @@ -1,421 +0,0 @@ -# Import dependencies -import discord -from discord.ext import commands - -# Import modules -from src.botUtilities import COLORS, CommandError, getContextCountdown, getCountdown, loadCountdown -from src.models import Countdown, Prefix, Reaction - - - -class Utilities(commands.Cog): - def __init__(self, bot, databaseSessionMaker): - self.bot = bot - self.databaseSessionMaker = databaseSessionMaker - self.bot.remove_command("help") - - - - @commands.command() - async def activate(self, ctx): - """ - Turns a channel into a countdown - """ - - with self.databaseSessionMaker() as session: - # Check if channel is already a countdown - if (getCountdown(session, ctx.channel.id)): - raise CommandError("This channel is already a countdown") - - # Check if channel is a DM - if (not isinstance(ctx.channel, discord.channel.TextChannel)): - raise CommandError("This command must be run inside a server") - - # Check if user isn't authorized - if (not ctx.message.author.guild_permissions.administrator): - raise CommandError("You must be an administrator to turn a channel into a countdown") - - # Create countdown - countdown = Countdown( - id = ctx.channel.id, - server_id = ctx.channel.guild.id, - timezone = 0, - prefixes = [Prefix(countdown_id=ctx.channel.id, value=x) for x in self.bot.prefixes], - reactions = [], - messages = [], - ) - - # Send initial response - self.bot.logger.info(f"Activated {self.bot.get_channel(ctx.channel.id)} (ID {ctx.channel.id}) as a countdown") - embed = discord.Embed(title=":clock3: Loading Countdown", description="This channel is now a countdown\nPlease wait to start counting", color=COLORS["embed"]) - msg = await ctx.send(embed=embed) - - # Load countdown - await loadCountdown(self.bot, countdown) - session.add(countdown) - session.commit() - - # Send final response - embed = discord.Embed(title=":white_check_mark: Countdown Activated", description="This channel is now a countdown\nYou may start counting!", color=COLORS["embed"]) - await msg.edit(embed=embed) - - - - @commands.command() - async def config(self, ctx, key=None, *args): - """ - Shows and modifies countdown settings - """ - - # Create embed - embed = discord.Embed(title=":gear: Countdown Settings", color=COLORS["embed"]) - - # Make sure context is in a server - if (not isinstance(ctx.channel, discord.channel.TextChannel)): - raise CommandError("This command must be run in a countdown channel or a server with a countdown channel") - - with self.databaseSessionMaker() as session: - # Get countdown channel - countdown = getContextCountdown(session, ctx) - - # Get / set settings - if (key is None): - embed.description = f"**Countdown Channel:** <#{countdown.id}>\n" - embed.description += f"**Command Prefixes:** `{'`, `'.join([x.value for x in countdown.prefixes])}`\n" - embed.description += f"**Countdown Timezone:** {countdown.getTimezone()}\n" - if (len(countdown.reactions) == 0): - embed.description += f"**Reactions:** none\n" - else: - embed.description += f"**Reactions:**\n" - for number in list(dict.fromkeys([x.number for x in countdown.reactions])): - embed.description += f"**-** #{number}: {', '.join([x.value for x in countdown.reactions if x.number == number])}\n" - embed.description += f"\nUse `{ctx.prefix}help config` to view more information about settings\n" - embed.description += f"Use `{ctx.prefix}config <key> <value>` to modify settings\n" - elif (not ctx.message.author.guild_permissions.administrator): - raise CommandError("You must be an administrator to modify settings") - elif (len(args) == 0): - raise CommandError("Please provide a value for the setting") - elif (key in ["tz", "timezone"]): - try: - countdown.timezone = float(args[0]) - except: - raise CommandError(f"Invalid timezone: `{args[0]}`") - else: - embed.description = f"Timezone set to {countdown.getTimezone()}" - elif (key in ["prefix", "prefixes"]): - countdown.prefixes = [Prefix(countdown_id=ctx.channel.id, value=x) for x in args] - embed.description = f"Prefixes updated" - elif (key in ["react"]): - try: - number = int(args[0]) - if (number < 0): - raise CommandError("Number must be greater than zero") - elif (len(args) == 1): - countdown.reactions = [x for x in countdown.reactions if x.number != number] - embed.description = f"Removed reactions for #{number}" - else: - countdown.reactions = [x for x in countdown.reactions if x.number != number] - countdown.reactions += [Reaction(countdown_id=countdown.id, number=number, value=x) for x in args[1:]] - embed.description = f"Updated reactions for #{number}" - except: - raise CommandError(f"Invalid number: `{args[0]}`") - else: - raise CommandError(f"Setting not found: `{key}`") - - # Save changes - session.commit() - - # Send embed - await ctx.send(embed=embed) - - - - @commands.command() - async def deactivate(self, ctx): - """ - Deactivates a countdown channel - """ - - with self.databaseSessionMaker() as session: - # Check if channel isn't a countdown - countdown = getCountdown(session, ctx.channel.id) - if (not countdown): - raise CommandError("This channel isn't a countdown") - - # Check if user isn't authorized - if (not ctx.author.guild_permissions.administrator): - raise CommandError("You must be an administrator to deactivate a countdown channel") - - # Delete countdown - session.delete(countdown) - session.commit() - - # Send response - self.bot.logger.info(f"Deactivated {self.bot.get_channel(ctx.channel.id)} (ID {ctx.channel.id}) as a countdown") - embed = discord.Embed(title=":octagonal_sign: Countdown Deactivated", description="This channel is no longer a countdown", color=COLORS["embed"]) - await ctx.send(embed=embed) - - - - @commands.command(aliases=["h", ""]) - async def help(self, ctx, command=None): - """ - Shows help information - """ - - # Initialize help information - prefixes = await self.bot.get_prefix(ctx) - help_text = { - "prefixes": - f"`{'`, `'.join(prefixes)}`", - "utility-commands": - "**-** `activate`: Turns a channel into a countdown\n" \ - "**-** `config`: Shows and modifies bot and countdown settings\n" \ - "**-** `deactivate`: Deactivates a countdown channel\n" \ - "**-** `help`, `h`: Shows help information\n" \ - "**-** `ping`: Pings the bot\n" \ - "**-** `reload`: Reloads the countdown cache\n", - "analytics-commands": - "**-** `analytics`, `a`: Shows all countdown analytics\n" \ - "**-** `contributors`, `c`: Shows information about countdown contributors\n" \ - "**-** `eta`, `e`: Shows information about the estimated completion date\n" \ - "**-** `heatmap`: Shows a heatmap of when messages are sent\n" \ - "**-** `leaderboard`, `l`: Shows the countdown leaderboard\n" \ - "**-** `progress`, `p`: Shows information about countdown progress\n" \ - "**-** `speed`, `s`: Shows information about countdown speed\n", - "behavior": - "**-** Reacts with :no_entry: when a user counts out of turn\n" \ - "**-** Reacts with :x: when a user counts incorrectly\n" \ - "**-** Ignores messages that don't start with a (positive) number\n" \ - "**-** Pins numbers every 2% if the countdown started at 500 or higher\n", - "getting-started": - f"**1.** View help information using the `{prefixes[0]}help` command\n" \ - f"**2.** Activate a new countdown channel using the `{prefixes[0]}activate` command\n" \ - f"**3.** Change my settings using the `{prefixes[0]}config` command\n" \ - f"**4.** View countdown analytics using the `{prefixes[0]}analytics` command\n", - "troubleshooting": - f"**1.** Run `{prefixes[0]}ping` to make sure that I'm online\n" \ - f"**2.** If I reacted incorrectly to a message, remove my incorrect reaction(s)\n" \ - f"**3.** Run `{prefixes[0]}reload` in the countdown channel\n", - "activate": - "**Name:** activate\n" \ - "**Description:** Turns a channel into a countdown\n" \ - f"**Usage:** `{prefixes[0]}activate`\n" \ - "**Aliases:** none\n" \ - "**Arguments:** none\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}activate`\n" \ - "**Notes:** Users must have admin permissions to turn a channel into a countdown\n", - "analytics": - "**Name:** analytics\n" \ - "**Description:** Shows all countdown analytics\n" \ - f"**Usage:** `{prefixes[0]}analytics|a`\n" \ - "**Aliases:** `a`\n" \ - "**Arguments: none**\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}analytics`\n" \ - "**Notes:** none\n", - "config": - "**Name:** config\n" \ - "**Description:** Shows and modifies countdown settings\n" \ - f"**Usage:** `{prefixes[0]}config [<key> <value>...]`\n" \ - "**Aliases:** none\n" \ - "**Arguments:**\n" \ - "**-** `<key>`: The name of the setting to modify. If no key is supplied, all settings will be shown\n" \ - "**-** `<value>`: The new value(s) for the setting\n" \ - "**Available Settings:**\n" \ - "**-** `prefix`, `prefixes`: The prefix(es) for the bot\n" \ - "**-** `tz`, `timezone`: The UTC offset in hours\n" \ - "**-** `react`: The reactions for a certain number\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}config`\n" \ - f"**-** `{prefixes[0]}config prefixes prefix1 prefix2 prefix3`\n" \ - f"**-** `{prefixes[0]}config timezone -1.5`\n" \ - f"**-** `{prefixes[0]}config react 0 :partying_face: :smile:`\n" \ - "**Notes:** Users must have admin permissions to modify settings\n", - "contributors": - "**Name:** contributors\n" \ - "**Description:** Shows information about countdown contributors\n" \ - f"**Usage:** `{prefixes[0]}contributors|c [history|h]`\n" \ - "**Aliases:** `c`\n" \ - "**Arguments:**\n" \ - "**-** `history`, `h`: Shows historical data about countdown contributors\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}contributors`\n" \ - f"**-** `{prefixes[0]}contributors history`\n" \ - "**Notes:** The contributors embed will only show the top 20 contributors\n", - "deactivate": - "**Name:** deactivate\n" \ - "**Description:** Deactivates a countdown channel\n" \ - f"**Usage:** `{prefixes[0]}deactivate`\n" \ - "**Aliases:** none\n" \ - "**Arguments:** none\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}deactivate`\n" \ - "**Notes:** Users must have admin permissions to deactivate a countdown channel\n", - "eta": - "**Name:** eta\n" \ - "**Description:** Shows information about the estimated completion date\n" \ - f"**Usage:** `{prefixes[0]}eta|e [<period>]`\n" \ - "**Aliases:** `e`\n" \ - "**Arguments:**\n" \ - "**-** `<period>`: The size of the period in hours (the default is 24 hours)\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}eta`\n" \ - f"**-** `{prefixes[0]}eta 48`\n" \ - "**Notes:** none\n", - "heatmap": - "**Name:** heatmap\n" \ - "**Description:** Shows a heatmap of when countdown messages are sent\n" \ - f"**Usage:** `{prefixes[0]}heatmap [<user>]`\n" \ - "**Aliases:** none\n" \ - "**Arguments:**\n" \ - "**-** `<user>`: The username or nickname of the user to view heatmap information about. If no value is supplied, the general heatmap will be shown\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}heatmap`\n" \ - f"**-** `{prefixes[0]}heatmap @Alice`\n" \ - f"**-** `{prefixes[0]}heatmap Bob`\n" \ - "**Notes:** none\n", - "help": - "**Name:** help\n" \ - "**Description:** Shows help information\n" \ - f"**Usage:** `{prefixes[0]}help|h [<command>]`\n" \ - "**Aliases:** `h`\n" \ - "**Arguments:**\n" \ - "**-** `<command>`: The command to view help information about. If no value is supplied, general help information will be shown\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}help`\n" \ - f"**-** `{prefixes[0]}help config`\n" \ - "**Notes:** none\n", - "leaderboard": - "**Name:** leaderboard\n" \ - "**Description:** Shows the countdown leaderboard\n" \ - f"**Usage:** `{prefixes[0]}leaderboard|l [<user>]`\n" \ - "**Aliases:** `l`\n" \ - "**Arguments:**\n" \ - "**-** `<user>`: The rank, username, or nickname of the user to view leaderboard information about. If no value is supplied, the whole leaderboard will be shown\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}leaderboard`\n" \ - f"**-** `{prefixes[0]}leaderboard 1`\n" \ - f"**-** `{prefixes[0]}leaderboard @Alice`\n" \ - f"**-** `{prefixes[0]}leaderboard Bob`\n" \ - "**Notes:** The leaderboard embed will only show the top 20 contributors\n", - "ping": - "**Name:** ping\n" \ - "**Description:** Pings the bot\n" \ - f"**Usage:** `{prefixes[0]}ping`\n" \ - "**Aliases:** none\n" \ - "**Arguments:** none\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}ping`\n" \ - "**Notes:** none\n", - "progress": - "**Name:** progress\n" \ - "**Description:** Shows information about countdown progress\n" \ - f"**Usage:** `{prefixes[0]}progress|p`\n" \ - "**Aliases:** `p`\n" \ - "**Arguments:** none\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}progress`\n" \ - "**Notes:** none\n", - "reload": - "**Name:** reload\n" \ - "**Description:** Reloads the countdown cache\n" \ - f"**Usage:** `{prefixes[0]}reload`\n" \ - "**Aliases:** none\n" \ - "**Arguments:** none\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}reload`\n" \ - "**Notes:** This command must be used in a countdown channel\n", - "speed": - "**Name:** speed\n" \ - "**Description:** Shows information about countdown speed\n" \ - f"**Usage:** `{prefixes[0]}speed|s [<period>]`\n" \ - "**Aliases:** `s`\n" \ - "**Arguments:**\n" \ - "**-** `<period>`: The size of the period in hours (the default is 24 hours)\n" \ - "**Examples:**\n" \ - f"**-** `{prefixes[0]}speed`\n" \ - f"**-** `{prefixes[0]}speed 48`\n" \ - "**Notes:** none\n", - } - - # Create embed - embed=discord.Embed(title=":grey_question: countdown-bot Help", color=COLORS["embed"]) - if (command is None): - embed.add_field(name="Command Prefixes :gear:", value=help_text["prefixes"], inline=False) - embed.add_field(name="Utility Commands :wrench:", value=help_text["utility-commands"], inline=False) - embed.add_field(name="Analytics Commands :bar_chart:", value=help_text["analytics-commands"], inline=False) - embed.add_field(name="Behavior in Countdown Channels :robot:", value=help_text["behavior"], inline=False) - embed.add_field(name="Getting Started :rocket:", value=help_text["getting-started"], inline=False) - embed.add_field(name="Troubleshooting :screwdriver:", value=help_text["troubleshooting"], inline=False) - embed.description = f"Use `{prefixes[0]}help command` to get more info on a command" - elif (command.lower() in ["activate"]): - embed.description = help_text["activate"] - elif (command.lower() in ["a", "analytics"]): - embed.description = help_text["analytics"] - elif (command.lower() in ["config"]): - embed.description = help_text["config"] - elif (command.lower() in ["c", "contributors"]): - embed.description = help_text["contributors"] - elif (command.lower() in ["deactivate"]): - embed.description = help_text["deactivate"] - elif (command.lower() in ["e", "eta"]): - embed.description = help_text["eta"] - elif (command.lower() in ["heatmap"]): - embed.description = help_text["heatmap"] - elif (command.lower() in ["h", "help"]): - embed.description = help_text["help"] - elif (command.lower() in ["l", "leaderboard"]): - embed.description = help_text["leaderboard"] - elif (command.lower() in ["ping"]): - embed.description = help_text["ping"] - elif (command.lower() in ["p", "progress"]): - embed.description = help_text["progress"] - elif (command.lower() in ["reload"]): - embed.description = help_text["reload"] - elif (command.lower() in ["s", "speed"]): - embed.description = help_text["speed"] - else: - raise CommandError(f"Command not found: `{command}`") - - # Send embed - await ctx.send(embed=embed) - - - - @commands.command() - async def ping(self, ctx): - """ - Pings the bot - """ - - embed=discord.Embed(title=":ping_pong: Pong!", color=COLORS["embed"]) - embed.description = f"**Latency:** {round(self.bot.latency * 1000)} ms\n" - await ctx.send(embed=embed) - - - - @commands.command() - async def reload(self, ctx): - """ - Reloads the countdown cache - """ - - with self.databaseSessionMaker() as session: - countdown = getCountdown(session, ctx.channel.id) - if (countdown): - # Send initial response - embed = discord.Embed(title=":clock3: Reloading Countdown Cache", description="Please wait to continue counting", color=COLORS["embed"]) - msg = await ctx.channel.send(embed=embed) - - # Reload messages - await loadCountdown(self.bot, countdown) - session.commit() - - # Send final response - self.bot.logger.info(f"Reloaded messages from {self.bot.get_channel(ctx.channel.id)} (ID {ctx.channel.id})") - embed = discord.Embed(title=":white_check_mark: Countdown Cache Reloaded", description="Done! You may continue counting!", color=COLORS["embed"]) - await msg.edit(embed=embed) - else: - raise CommandError("Countdown not found\nThis command must be used in a countdown channel")