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:
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")