commit 497cb4e3048d2d8d80521fa65f8076d5c865708c
parent 76041ecb441e813427f046f53b187dd536b9c2b2
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date: Sun, 27 Jun 2021 11:15:17 -0700
Merge pull request #2 from ashermorgan/refactor
Refactor bot
Diffstat:
12 files changed, 1658 insertions(+), 1451 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,2 +1,8 @@
.venv
-data.json
+
+__pycache__
+*.pyc
+
+settings.json
+*.db
+*.sqlite3
diff --git a/README.md b/README.md
@@ -1,5 +1,5 @@
# countdown-bot
-A Discord bot to facilitate countdowns.
+A Discord bot to facilitate countdowns
@@ -9,9 +9,9 @@ A Discord bot to facilitate countdowns.
pip install -r requirements.txt
```
-2. Go to the [Discord Developer Portal](https://discord.com/developers/) and create an application and a bot.
+2. Go to the [Discord Developer Portal](https://discord.com/developers/) and create an application and a bot
-3. Add the bot to your server.
+3. Add the bot to your server
```
https://discordapp.com/oauth2/authorize?client_id=BOT_ID_HERE&scope=bot&permissions=232512
```
@@ -21,14 +21,14 @@ A Discord bot to facilitate countdowns.
python setup.py
```
-5. Open `data.json` (which was generated by `setup.py`) and add your bot's token.
+5. Open `settings.json` (which was generated by `setup.py`) and add your bot's token
```json
- {"token": "YOUR_TOKEN_HERE", "countdowns": {}}
+ {"token": "YOUR_TOKEN_HERE", "prefixes": ["c."], "database": "sqlite:///data.sqlite3"}
```
6. Run the bot
```
- python bot.py
+ python run.py
```
-7. Run `c.help` to get a list of commands and a description of the bot's behavior.
+7. Run `c.help` to get a list of commands and a description of the bot's behavior
diff --git a/bot.py b/bot.py
@@ -1,1440 +0,0 @@
-# Import dependencies
-import copy
-from datetime import datetime, timedelta
-import discord
-from discord.ext import commands
-import json
-import math
-from matplotlib import pyplot as plt
-from matplotlib.ticker import PercentFormatter
-import os
-import re
-import tempfile
-
-
-
-# Global variables
-data = {}
-loaded = 0 # percentage of countdowns fully loaded
-POINT_RULES = {
- "1000s": 1000,
- "1001s": 500,
- "200s": 200,
- "201s": 100,
- "100s": 100,
- "101s": 50,
- "Prime Numbers": 15,
- "Odd Numbers": 12,
- "Even Numbers": 10,
- "First Number": 0,
-}
-COLORS = {
- "error": 0xD52C42,
- "embed": 0x248AD1,
-}
-
-
-
-# Error classes
-class MessageNotAllowedError(Exception):
- """Raised when someone posts twice in a row."""
- pass
-
-class MessageIncorrectError(Exception):
- """Raised when someone posts an incorrect number."""
- pass
-
-
-
-# Static methods
-async def getUsername(id):
- """
- Get a username from a user ID.
-
- Parameters
- ----------
- 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(server, id):
- """
- Get a user's nickname in a server
-
- Parameters
- ----------
- 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(id)
-
-def saveData(data):
- """
- Save countdown data to the data.json file.
-
- Parameters
- ----------
- data : dict
- The countdown data
- """
-
- # Copy data
- obj = copy.deepcopy(data)
-
- # Remove countdown objects
- for countdown in obj["countdowns"]:
- del obj["countdowns"][countdown]["countdown"]
-
- # Save data
- with open(os.path.join(os.path.dirname(__file__), "data.json"), "w") as f:
- return json.dump(obj, f)
-
-def getCountdownChannel(ctx, resortToFirst=True):
- """
- Get the most relevant countdown channel to a certain context.
-
- Parameters
- ----------
- ctx
- The context
- resortToFirst : bool
- Whether to return the 1st countdown channel if no relevant countdown channels are found
-
- Returns
- -------
- dict
- The countdown channel
- str
- The channel ID
- """
-
- # Countdown channel
- global data
- if (str(ctx.channel.id) in data["countdowns"]):
- return data["countdowns"][str(ctx.channel.id)], str(ctx.channel.id)
-
- # Server with countdown channel
- if (isinstance(ctx.channel, discord.channel.TextChannel)):
- # Get first countdown in this server that use the current prefix
- serverChannels = [x for x in data["countdowns"] if data["countdowns"][x]["server"] == ctx.channel.guild.id and ctx.prefix in data["countdowns"][x]["prefixes"]]
- if (len(serverChannels) > 0):
- return data["countdowns"][serverChannels[0]], serverChannels[0]
-
- # No countdown channels
- if (len(data["countdowns"]) == 0):
- raise Exception("Countdown channel not found.")
-
- # Return default countdown channel
- if resortToFirst:
- return list(data["countdowns"].values())[0], list(data["countdowns"].keys())[0]
- else:
- raise Exception("Countdown channel not found.")
-
-def getPrefix(bot, ctx):
- """
- Get the bot prefix for a certain context.
-
- Parameters
- ----------
- bot
- The bot
- ctx
- The context
- """
-
- # Countdown channel
- global data
- if (str(ctx.channel.id) in data["countdowns"] and len(data["countdowns"][str(ctx.channel.id)]["prefixes"]) > 0):
- return data["countdowns"][str(ctx.channel.id)]["prefixes"]
-
- # Server with countdown channels
- if (isinstance(ctx.channel, discord.channel.TextChannel)):
- serverChannels = [x for x in data["countdowns"] if data["countdowns"][x]["server"] == ctx.channel.guild.id]
- # Get list of prefixes
- prefixes = []
- for channel in serverChannels:
- prefixes += data["countdowns"][channel]["prefixes"]
- if (len(prefixes) > 0):
- return list(dict.fromkeys(prefixes))
-
- # Return default prefixes
- return data["prefixes"]
-
-
-
-# Message class
-class Message:
- """
- Represents a single, valid, countdown message.
-
- Attributes
- ----------
- id : int
- The message ID.
- channel : int
- The channel ID.
- author : int
- The message author ID.
- number : int
- The message content.
- """
-
- def __init__(self, obj):
- self.channel = obj.channel.id
- self.id = obj.id
- self.timestamp = obj.created_at
- self.author = obj.author.id
- self.number = int(re.findall("^[0-9,]+", obj.content)[0].replace(",",""))
-
- def __eq__(self, o: object) -> bool:
- if not isinstance(o, Message): return False
- else: return self.id == o.id
-
-
-
-# Countdown class
-class Countdown:
- """
- Represents a countdown.
-
- Attributes
- ----------
- messages : list
- The (valid) messages belonging to the countdown.
-
- Methods
- -------
- addMessage
- Add a message to the list of messages.
- parseMessage
- Parse a message and adds it to the list of messages.
- """
-
- def __init__(self, messages, reactions):
- self.messages = messages
- self.reactions = reactions
-
- def addMessage(self, message):
- """
- Add a message to the list of messages.
-
- Parameters
- ----------
- message : Message
- The message object.
-
- Raises
- ------
- MessageNotAllowedError
- If the author posted the last message.
- MessageIncorrectError
- If the message content is incorrect.
- """
-
- if (len(self.messages) != 0 and message.author == self.messages[-1].author):
- raise MessageNotAllowedError()
- elif (len(self.messages) != 0 and message.number + 1 != self.messages[-1].number):
- raise MessageIncorrectError()
- else:
- self.messages += [message]
-
- async def parseMessage(self, rawMessage):
- """
- Parse a message and add it to the list of messages.
-
- Notes
- -----
- If the message is invalid or incorrect, a reacted will be added accordingly.
-
- Parameters
- ----------
- rawMessage : obj
- The raw Discord message object.
- """
-
- try:
- # Parse message
- message = Message(rawMessage)
-
- # Add message
- self.addMessage(message)
-
- # Mark important messages
- if (str(message.number) in self.reactions):
- for reaction in self.reactions[str(message.number)]:
- try:
- await rawMessage.add_reaction(reaction)
- except:
- pass
- if (self.messages[0].number >= 500 and message.number % (self.messages[0].number // 50) == 0):
- await rawMessage.pin()
- except MessageNotAllowedError:
- await rawMessage.add_reaction("⛔")
- except MessageIncorrectError:
- await rawMessage.add_reaction("❌")
- except:
- pass
-
- def contributors(self):
- """
- Get countdown contributor statistics.
-
- Returns
- -------
- list
- A list of contributor statistics.
- """
-
- # Get contributors
- authors = list(set([x.author for x in self.messages]))
-
- # Get contributions
- contributors = []
- for author in authors:
- contributors += [{
- "author":author,
- "contributions":len([x for x in self.messages if x.author == author]),
- }]
-
- # Sort contributors by contributions
- contributors = sorted(contributors, key=lambda x: x["contributions"], reverse=True)
-
- # Return contributors
- return contributors
-
- def eta(self, period=timedelta(days=1), tz=timedelta(hours=0)):
- """
- Get countdown eta statistics.
-
- Parameters
- ----------
- period : timedelta
- The period size. The default is 1 day.
- tz : timedelta
- The timezone. The default is +0 (UTC)
-
- Returns
- -------
- list
- The countdown eta statistics.
- """
-
- # Make sure countdown has at least two messages
- if (len(self.messages) < 2):
- return [[], []]
-
- # Initialize period data
- periodEnd = self.messages[0].timestamp + tz + period
- lastMessage = 0
-
- # Initialize result and add first data point
- data = [[self.messages[0].timestamp + tz], [self.messages[0].timestamp + tz]]
-
- # Calculate timestamp for last data point
- if (self.messages[-1].number == 0):
- end = self.messages[-1].timestamp + tz
- else:
- end = datetime.utcnow() + tz
-
- # Add data points
- while (periodEnd < end):
- # Advance to last message in period
- while (lastMessage+1 < len(self.messages) and self.messages[lastMessage+1].timestamp + tz < periodEnd):
- lastMessage += 1
-
- # Calculate data
- rate = (self.messages[0].number - self.messages[lastMessage].number) / ((periodEnd - (self.messages[0].timestamp + tz)) / timedelta(days=1))
- eta = periodEnd + timedelta(days=self.messages[lastMessage].number/rate)
- data[0] += [periodEnd]
- data[1] += [eta]
-
- # Advance to next period
- periodEnd += period
-
- # Add last data point
- data[0] += [end]
- data[1] += [self.progress()["eta"]]
-
- # Return eta data
- return data
-
- def leaderboard(self):
- """
- Get countdown leaderboard.
-
- Returns
- -------
- list
- The leaderboard.
- """
-
- if (len(self.messages) == 0):
- return []
-
- # Get list of prime numbers
- curTest = 5
- search = 1
- primes = [2, 3]
- while curTest < self.messages[0].number:
- if curTest%(primes[search]) == 0:
- curTest = curTest + 2
- search = 1
- else:
- if primes[search] > math.sqrt(curTest):
- primes.append(curTest)
- curTest = curTest + 2
- search = 1
- else:
- search = search + 1
-
- # Calculate contributor points
- points = {}
- for message in self.messages:
- if (message.author not in points):
- points[message.author] = {
- "author": message.author,
- "breakdown": {
- "1000s": 0,
- "1001s": 0,
- "200s": 0,
- "201s": 0,
- "100s": 0,
- "101s": 0,
- "Prime Numbers": 0,
- "Odd Numbers": 0,
- "Even Numbers": 0,
- "First Number": 0,
- },
- }
- if (message.number == self.messages[0].number): points[message.author]["breakdown"]["First Number"] += 1
- elif (message.number % 1000 == 0): points[message.author]["breakdown"]["1000s"] += 1
- elif (message.number % 1000 == 1): points[message.author]["breakdown"]["1001s"] += 1
- elif (message.number % 200 == 0): points[message.author]["breakdown"]["200s"] += 1
- elif (message.number % 200 == 1): points[message.author]["breakdown"]["201s"] += 1
- elif (message.number % 100 == 0): points[message.author]["breakdown"]["100s"] += 1
- elif (message.number % 100 == 1): points[message.author]["breakdown"]["101s"] += 1
- elif (message.number in primes): points[message.author]["breakdown"]["Prime Numbers"] += 1
- elif (message.number % 2 == 1): points[message.author]["breakdown"]["Odd Numbers"] += 1
- else: points[message.author]["breakdown"]["Even Numbers"] += 1
-
- # Create ranked leaderboard
- leaderboard = []
- for contributor in points.values():
- contributor["contributions"] = sum(contributor["breakdown"].values())
- contributor["points"] = sum([contributor["breakdown"][x] * POINT_RULES[x] for x in contributor["breakdown"]])
- leaderboard += [contributor]
- leaderboard = sorted(leaderboard, key=lambda x: x["points"], reverse=True)
- return leaderboard
-
- def progress(self):
- """
- Get countdown progress statistics.
-
- Returns
- -------
- dict
- A dictionary containing countdown progress statistics.
- """
-
- # Get basic statistics
- if (len(self.messages) > 0):
- total = self.messages[0].number
- current = self.messages[-1].number
- percentage = (total - current) / total * 100
- start = self.messages[0].timestamp
- else:
- total = 0
- current = 0
- percentage = 0
- start = datetime.utcnow()
-
- # Get rate statistics
- if (len(self.messages) > 1 and self.messages[-1].number == 0):
- # The countdown has already finished
- rate = (total - current)/((self.messages[-1].timestamp - self.messages[0].timestamp) / timedelta(days=1))
- eta = self.messages[-1].timestamp
- elif (len(self.messages) > 1):
- # The countdown is still going
- rate = (total - current)/((datetime.utcnow() - self.messages[0].timestamp) / timedelta(days=1))
- eta = datetime.utcnow() + timedelta(days=current/rate)
- else:
- rate = 0
- eta = datetime.utcnow()
-
- # Get list of progress
- progress = [{"time":x.timestamp, "progress":x.number} for x in self.messages]
-
- # Return stats
- return {
- "total": total,
- "current": current,
- "percentage": percentage,
- "progress": progress,
- "start": start,
- "rate": rate,
- "eta": eta,
- }
-
- def speed(self, period=timedelta(days=1), tz=timedelta(hours=0)):
- """
- Get countdown speed statistics.
-
- Parameters
- ----------
- periodLength : timedelta
- The period size. The default is 1 day.
- tz : timedelta
- The timezone. The default is +0 (UTC)
-
- Returns
- -------
- list
- The countdown speed statistics.
- """
-
- # Calculate speed statistics
- data = [[], []]
- periodStart = datetime(2018, 1, 1) # Starts on Monday, Jan 1st
- for message in self.messages:
- # If data point isn't in the current period
- while (message.timestamp + tz - period >= periodStart):
- periodStart += period
-
- # Add new period if needed
- if (len(data[0]) == 0 or data[0][-1] != periodStart):
- data[0] += [periodStart]
- data[1] += [0]
-
- # Otherwise add the latest diff to the current period
- data[1][-1] += 1
-
- # Return speed statistics
- return data
-
-
-
-# Load countdown data
-with open(os.path.join(os.path.dirname(__file__), "data.json"), "a+") as f:
- f.seek(0)
- data = json.load(f)
-
-
-
-# Create Discord bot
-bot = commands.Bot(command_prefix=getPrefix, case_insensitive=True)
-bot.remove_command("help")
-
-
-
-@bot.event
-async def on_ready():
- # Print status
- print(f"Connected to Discord as {bot.user}")
-
- # Load messages
- global data
- global loaded
- for channel in data["countdowns"]:
- # Get messages
- rawMessages = await bot.get_channel(int(channel)).history(limit=10100).flatten()
- rawMessages.reverse()
-
- # Create countdown
- data["countdowns"][channel]["countdown"] = Countdown([], data["countdowns"][channel]["reactions"])
-
- # Load messages
- for rawMessage in rawMessages:
- await data["countdowns"][channel]["countdown"].parseMessage(rawMessage)
-
- # Print status
- print(f"Loaded messages from {bot.get_channel(int(channel))}")
- loaded += (1 / len(data["countdowns"]))
- loaded = 1
-
-
-
-@bot.event
-async def on_message(obj):
- if bot.user in obj.mentions:
- embed=discord.Embed(title="countdown-bot", description=f"Use `{(await bot.get_prefix(obj))[0]}help` to view help information", color=COLORS["embed"])
- await obj.channel.send(embed=embed)
- if (str(obj.channel.id) in data["countdowns"] and obj.author.name != "countdown-bot"):
- await data["countdowns"][str(obj.channel.id)]["countdown"].parseMessage(obj)
- try:
- await bot.process_commands(obj)
- except:
- pass
-
-
-
-@bot.event
-async def on_command_error(ctx, error):
- embed=discord.Embed(title="Error", description=str(error), color=COLORS["error"])
- if (isinstance(error, commands.CommandNotFound)):
- embed.description = f"Command not found: `{str(error)[9:-14]}`"
- else:
- embed.description = str(error)
- embed.description += f"\nUse `{(await bot.get_prefix(ctx))[0]}help` to view help information\n"
- await ctx.send(embed=embed)
-
-
-
-@bot.command()
-async def activate(ctx):
- """
- Turns a channel into a countdown
- """
-
- # Channel is already a coutndown
- if (str(ctx.channel.id) in data["countdowns"]):
- embed = discord.Embed(title="Error", description="This channel is already a countdown", color=COLORS["error"])
- await ctx.send(embed=embed)
-
- # Channel is a DM
- elif (not isinstance(ctx.channel, discord.channel.TextChannel)):
- embed = discord.Embed(title="Error", description="This command must be run inside a server", color=COLORS["error"])
- await ctx.send(embed=embed)
-
- # User isn't authorized
- elif (not ctx.message.author.guild_permissions.administrator):
- embed = discord.Embed(title="Error", description="You must be an administrator to turn a channel into a countdown", color=COLORS["error"])
- await ctx.send(embed=embed)
-
- # Channel is valid
- else:
- # Create countdown channel
- data["countdowns"][str(ctx.channel.id)] = {
- "server": ctx.channel.guild.id,
- "timezone": 0,
- "prefixes": data["prefixes"],
- "reactions": {},
- "countdown": Countdown([], {})
- }
- saveData(data)
-
- # Send initial responce
- print(f"Activated {bot.get_channel(ctx.channel.id)} as a countdown")
- embed = discord.Embed(title=":clock3: Loading Countdown", description="@here This channel is now a countdown\nPlease wait to start counting", color=COLORS["embed"])
- msg = await ctx.send(embed=embed)
-
- # Get messages
- rawMessages = await bot.get_channel(ctx.channel.id).history(limit=10100).flatten()
- rawMessages.reverse()
-
- # Create countdown
- data["countdowns"][str(ctx.channel.id)]["countdown"] = Countdown([], {})
-
- # Load messages
- for rawMessage in rawMessages:
- await data["countdowns"][str(ctx.channel.id)]["countdown"].parseMessage(rawMessage)
-
- # Send final responce
- print(f"Loaded messages from {bot.get_channel(ctx.channel.id)}")
- embed = discord.Embed(title=":white_check_mark: Countdown Activated", description="@here This channel is now a countdown\nYou may start counting!", color=COLORS["embed"])
- await msg.edit(embed=embed)
-
-
-
-@bot.command(aliases=["a"])
-async def analytics(ctx):
- """
- Shows all countdown analytics
- """
-
- # Get countdown channel
- channel, id = getCountdownChannel(ctx)
-
- # Check if countdown is empty
- if (len(channel["countdown"].messages) == 0):
- embed=discord.Embed(title=":bar_chart: Countdown Analytics", color=COLORS["error"])
- embed.description = "The countdown is empty"
- await ctx.send(embed=embed)
-
- # Run analytics commands
- else:
- await contributors(ctx, "")
- await contributors(ctx, "history")
- if (len(channel["countdown"].messages) >= 2): await eta(ctx) # Countdown must have 2 messages to run eta command
- await leaderboard(ctx)
- await progress(ctx)
- await speed(ctx)
-
-
-
-@bot.command()
-async def config(ctx, key=None, *args):
- """
- Shows and modifies countdown settings
- """
-
- # Create embed
- embed = discord.Embed(title=":gear: Countdown Settings", color=COLORS["embed"])
-
- # Get countdown channel
- try:
- channel, id = getCountdownChannel(ctx, resortToFirst=False)
- except:
- embed.color = COLORS["error"]
- embed.description = "This command must be run in a countdown channel or a server with a countdown channel"
- else:
- # Get / set settings
- if (key is None):
- embed.description = f"**Countdown Channel:** <#{id}>\n"
- embed.description += f"**Command Prefixes:** `{'`, `'.join(channel['prefixes'])}`\n"
- if (channel["timezone"] < 0):
- embed.description += f"**Countdown Timezone:** UTC-{-1 * channel['timezone']}\n"
- else:
- embed.description += f"**Countdown Timezone:** UTC+{channel['timezone']}\n"
- if (len(channel["reactions"]) == 0):
- embed.description += f"**Reactions:** none\n"
- else:
- embed.description += f"**Reactions:**\n"
- for reaction in sorted(channel["reactions"].keys(), reverse=True):
- embed.description += f"**-** #{reaction}: {', '.join(channel['reactions'][reaction])}\n"
- elif (not ctx.message.author.guild_permissions.administrator):
- embed.color = COLORS["error"]
- embed.description = f"You must be an administrator to modify settings"
- elif (len(args) == 0):
- embed.color = COLORS["error"]
- embed.description = f"Please provide a value for the setting"
- elif (key in ["tz", "timezone"]):
- embed.description = f"Done"
- try:
- channel["timezone"] = int(args[0])
- except:
- try:
- channel["timezone"] = float(args[0])
- except:
- embed.color = COLORS["error"]
- embed.description = f"Invalid timezone: {args[0]}"
- elif (key in ["prefix", "prefixes"]):
- channel["prefixes"] = args
- embed.description = f"Done"
- elif (key in ["react"]):
- try:
- number = int(args[0])
- if (number < 0):
- embed.color = COLORS["error"]
- embed.description = f"Number must be greater than zero"
- elif (len(args) == 1):
- if (str(number) in channel["reactions"]):
- del channel["reactions"][str(number)]
- embed.description = f"Removed reactions for #{number}"
- else:
- channel["reactions"][str(number)] = args[1:]
- embed.description = f"Updated reactions for #{number}"
- except:
- embed.color = COLORS["error"]
- embed.description = f"Invalid number: {args[0]}"
- else:
- embed.color = COLORS["error"]
- embed.description = f"Setting not found: `{key}`\n"
- embed.description += f"Use `{(await bot.get_prefix(ctx))[0]}help config` to view the list of settings"
-
- # Save changes
- saveData(data)
-
- # Send embed
- await ctx.send(embed=embed)
-
-
-
-@bot.command(aliases=["c"])
-async def contributors(ctx, option=""):
- """
- Shows information about countdown contributors
- """
-
- # Get countdown channel
- channel, id = getCountdownChannel(ctx)
-
- # Create temp file
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
- tmp.close()
-
- # Get stats
- stats = channel["countdown"].progress()
- contributors = channel["countdown"].contributors()
-
- # Create embed
- embed=discord.Embed(title=":busts_in_silhouette: Countdown Contributors", color=COLORS["embed"])
-
- # Make sure the countdown has started
- if (len(channel["countdown"].messages) == 0):
- embed.color = COLORS["error"]
- embed.description = "The countdown is empty."
- elif (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 historical contributor data
- authors = {}
- for author in contributors:
- authors[author["author"]] = [{"progress":0, "percentage":0, "total":0}]
- for message in channel["countdown"].messages:
- for author in authors:
- if (author == message.author):
- authors[author] += [{"progress":(stats["total"] - message.number), "percentage":(authors[author][-1]["total"] + 1)/(stats["total"] - message.number + 1) * 100, "total":authors[author][-1]["total"] + 1}]
- else:
- authors[author] += [{"progress":(stats["total"] - message.number), "percentage":(authors[author][-1]["total"] + 0)/(stats["total"] - message.number + 1) * 100, "total":authors[author][-1]["total"] + 0}]
-
- # Plot data and add legend
- for author in list(authors.keys())[:min(len(authors), 15)]:
- # Top 15 contributors get included in the legend
- ax.plot([x["progress"] for x in authors[author]], [x["percentage"] for x in authors[author]], label=await getUsername(author))
- for author in list(authors.keys())[15:max(len(authors), 15)]:
- ax.plot([x["progress"] for x in authors[author]], [x["percentage"] for x in authors[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:** <#{id}>"
- embed.set_image(url="attachment://image.png")
- elif (option == ""):
- # Create figure
- fig, ax = plt.subplots()
-
- # 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(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:** <#{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(channel['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:
- embed.color = COLORS["error"]
- embed.description = f"Unrecognized option: `{option}`\n"
- embed.description += f"Use `{(await bot.get_prefix(ctx))[0]}help contributors` to view help information"
-
- # 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:
- print(f"Unable to delete temp file: {tmp.name}.")
-
-
-
-@bot.command()
-async def deactivate(ctx):
- """
- Deactivates a countdown channel
- """
-
- # Channel isn't a countdown
- if (str(ctx.channel.id) not in data["countdowns"]):
- embed = discord.Embed(title="Error", description="This channel isn't a countdown", color=COLORS["error"])
- await ctx.send(embed=embed)
-
- # User isn't authorized
- elif (not ctx.message.author.guild_permissions.administrator):
- embed = discord.Embed(title="Error", description="You must be an administrator to deactivate a countdown channel", color=COLORS["error"])
- await ctx.send(embed=embed)
-
- # Channel is valid
- else:
- # Add channel data
- del data["countdowns"][str(ctx.channel.id)]
- saveData(data)
-
- # Send initial responce
- print(f"Deactivated {bot.get_channel(ctx.channel.id)} as a countdown")
- embed = discord.Embed(title=":octagonal_sign: Countdown Deactivated", description="@here This channel is no longer a countdown", color=COLORS["embed"])
- await ctx.send(embed=embed)
-
-
-
-@bot.command(aliases=["e"])
-async def eta(ctx, period="24.0"):
- """
- Shows information about the estimated completion date
- """
-
- # Get countdown channel
- channel, id = getCountdownChannel(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:
- embed.color = COLORS["error"]
- embed.description = "The period must be a number"
- else:
- if (len(channel["countdown"].messages) < 2):
- embed.color = COLORS["embed"]
- embed.description = "The countdown must have at least two messages"
- elif (period < 0.01):
- embed.color = COLORS["error"]
- embed.description = "The period cannot be less than 0.01 hours"
- else:
- # Get stats
- eta = channel["countdown"].eta(timedelta(hours=period), tz=timedelta(hours=channel["timezone"]))
-
- # 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=channel["timezone"])
- endDiff = eta[1][-1] - datetime.utcnow()
-
- # Add content to embed
- embed.description = f"**Countdown Channel:** <#{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:
- print(f"Unable to delete temp file: {tmp.name}.")
-
-
-
-@bot.command(aliases=["h", ""])
-async def help(ctx, command=None):
- """
- Shows help information
- """
-
- # Initialize help information
- prefixes = await 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" \
- "**-** `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" \
- "**-** Pins numbers every 2% if the countdown started at 500 or higher\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" \
- "**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:**\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 (see below).\n" \
- "**-** `<value>`: The new value(s) for the setting. If no key-value pair is supplied, all settings will be shown.\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. Ex: `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" \
- "**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" \
- "**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" \
- "**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" \
- "**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 viewleaderboard information about. If no value is supplied, the whole leaderboard will be shown.\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" \
- "**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" \
- "**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" \
- "**Notes:** none\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" \
- "**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.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 ["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:
- embed.color = COLORS["error"]
- embed.description = f"Command not found: `{command}`\n"
- embed.description += f"Use `{prefixes[0]}help` to view the list of commands"
-
- # Send embed
- await ctx.send(embed=embed)
-
-
-
-@bot.command(aliases=["l"])
-async def leaderboard(ctx, user=None):
- """
- Shows the countdown leaderboard
- """
-
- # Get countdown channel
- channel, id = getCountdownChannel(ctx)
-
- # Get leaderboard
- leaderboard = channel["countdown"].leaderboard()
-
- # Create embed
- embed=discord.Embed(title=":trophy: Countdown Leaderboard", color=COLORS["embed"])
-
- # Make sure the countdown has started
- if (len(channel["countdown"].messages) == 0):
- embed.color = COLORS["error"]
- embed.description = "The countdown is empty."
- elif (user is None):
- # Add description
- embed.description = f"**Countdown Channel:** <#{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:
- rank = None
- if (re.match("^\d+$", user) and int(user) > 0 and int(user) <= len(leaderboard)):
- # Get user from rank
- rank = int(user) - 1
- elif (re.match("^<@!\d+>$", user) and int(user[3:-1]) in [x["author"] for x in leaderboard]):
- # Get user from mention
- rank = [x["author"] for x in leaderboard].index(int(user[3:-1]))
- else:
- # Get user from username
- for contributor in leaderboard:
- username = await getUsername(contributor["author"])
- if (username.lower().startswith(user.lower())):
- rank = leaderboard.index(contributor)
-
- if (rank == None):
- # Get user from nickname
- for contributor in leaderboard:
- nickname = await getNickname(channel["server"], contributor["author"])
- if (nickname.lower().startswith(user.lower())):
- rank = leaderboard.index(contributor)
-
- if (rank == None):
- # User not found
- embed.color = COLORS["error"]
- embed.description = f"User not found: `{user}`"
- await ctx.send(embed=embed)
- return
-
- # Add description
- embed.description = f"**Countdown Channel:** <#{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(channel['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)
-
-
-
-@bot.command()
-async def ping(ctx):
- """
- Pings the bot
- """
-
- embed=discord.Embed(title=":ping_pong: Pong!", color=COLORS["embed"])
- embed.description = f"**Latency:** {round(bot.latency * 1000)} ms\n"
- if (loaded == 1):
- embed.description += "**Status:** Ready :white_check_mark:"
- else:
- embed.description += f"**Status:** Loading ({round(loaded * 100)}%) :clock3:"
- await ctx.send(embed=embed)
-
-
-
-@bot.command(aliases=["p"])
-async def progress(ctx):
- """
- Shows information about countdown progress
- """
-
- # Get countdown channel
- channel, id = getCountdownChannel(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"])
-
- # Make sure the countdown has started
- if (len(channel["countdown"].messages) == 0):
- embed.color = COLORS["error"]
- embed.description = "The countdown is empty."
- else:
- # Get progress stats
- stats = channel["countdown"].progress()
-
- # 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=channel["timezone"])] + [x["time"] + timedelta(hours=channel["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
- start = (stats["start"] + timedelta(hours=channel["timezone"])).date()
- startDiff = (datetime.utcnow() - stats["start"]).days
- end = (stats["eta"] + timedelta(hours=channel["timezone"])).date()
- endDiff = stats["eta"] - datetime.utcnow()
-
- # Add content to embed
- embed.description = f"**Countdown Channel:** <#{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"**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:
- print(f"Unable to delete temp file: {tmp.name}.")
-
-
-
-@bot.command()
-async def reload(ctx):
- """
- Reloads the countdown cache
- """
-
- if (str(ctx.channel.id) in data["countdowns"]):
- # Send inital responce
- print(f"Reloading messages from {bot.get_channel(ctx.channel.id)}")
- embed = discord.Embed(title=":clock3: Reloading Countdown Cache", description="Please wait to continue counting.", color=COLORS["embed"])
- msg = await ctx.channel.send(embed=embed)
-
- # Get messages
- rawMessages = await bot.get_channel(ctx.channel.id).history(limit=10100).flatten()
- rawMessages.reverse()
-
- # Create countdown
- data["countdowns"][str(ctx.channel.id)]["countdown"] = Countdown([], data["countdowns"][str(ctx.channel.id)]["reactions"])
-
- # Load messages
- for rawMessage in rawMessages:
- await data["countdowns"][str(ctx.channel.id)]["countdown"].parseMessage(rawMessage)
-
- # Send final responce
- print(f"Reloaded messages from {bot.get_channel(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:
- embed = discord.Embed(title="Error", description="This command must be used in a countdown channel", color = COLORS["error"])
- await ctx.channel.send(embed=embed)
-
-
-
-@bot.command(aliases=["s"])
-async def speed(ctx, period="24.0"):
- """
- Shows information about countdown speed
- """
-
- # Get countdown channel
- channel, id = getCountdownChannel(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:
- embed.color = COLORS["error"]
- embed.description = "The period must be a number"
- else:
- if (len(channel["countdown"].messages) == 0):
- embed.color = COLORS["error"]
- embed.description = "The countdown is empty."
- elif (period < 0.01):
- embed.color = COLORS["error"]
- embed.description = "The period cannot be less than 0.01 hours"
- else:
- # Get stats
- stats = channel["countdown"].progress()
- period = timedelta(hours=period)
- speed = channel["countdown"].speed(period, tz=timedelta(hours=channel["timezone"]))
-
- # 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:** <#{id}>\n\n"
- embed.description += f"**Period Size:** {period}\n"
- if (len(channel["countdown"].messages) > 1):
- rate = (stats['total'] - stats['current'])/((channel["countdown"].messages[-1].timestamp - channel["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:
- print(f"Unable to delete temp file: {tmp.name}.")
-
-
-
-# Run bot
-if (__name__ == "__main__"):
- bot.run(data["token"])
diff --git a/requirements.txt b/requirements.txt
@@ -1,2 +1,3 @@
discord
matplotlib
+sqlalchemy
diff --git a/run.py b/run.py
@@ -0,0 +1,19 @@
+# Import dependencies
+import json
+import os
+
+# Import modules
+from src import CountdownBot
+
+
+
+# Load settings
+settings = {}
+with open(os.path.join(os.path.dirname(__file__), "settings.json"), "a+") as f:
+ f.seek(0)
+ settings = json.load(f)
+
+
+
+# Run countdown-bot
+CountdownBot(settings["database"], settings["prefixes"]).run(settings["token"])
diff --git a/setup.py b/setup.py
@@ -2,11 +2,11 @@
import json
import os
-# Write to data.json
-with open(os.path.join(os.path.dirname(__file__), "data.json"), "w") as f:
+# Write to settings.json
+with open(os.path.join(os.path.dirname(__file__), "settings.json"), "w") as f:
data = {
"token": "YOUR_TOKEN_HERE",
"prefixes": ["c."],
- "countdowns": {}
+ "database": "sqlite:///data.sqlite3"
}
json.dump(data, f)
diff --git a/src/__init__.py b/src/__init__.py
@@ -0,0 +1,2 @@
+# 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
@@ -0,0 +1,488 @@
+# 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 os
+import re
+import tempfile
+
+# Import modules
+from src.botUtilities import COLORS, getContextCountdown, getNickname, getUsername
+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
+ """
+
+ with self.databaseSessionMaker() as session:
+ # Get countdown channel
+ countdown = getContextCountdown(session, ctx)
+
+ # Check if countdown is empty
+ if (len(countdown.messages) == 0):
+ embed=discord.Embed(title=":bar_chart: Countdown Analytics", color=COLORS["error"])
+ embed.description = "The countdown is empty"
+ await ctx.send(embed=embed)
+
+ # Run analytics commands
+ else:
+ await self.contributors(ctx, "")
+ await self.contributors(ctx, "history")
+ if (len(countdown.messages) >= 2): await self.eta(ctx) # Countdown must have 2 messages to run eta command
+ 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()
+
+ # Get stats
+ stats = countdown.progress()
+ contributors = countdown.contributors()
+
+ # Create embed
+ embed=discord.Embed(title=":busts_in_silhouette: Countdown Contributors", color=COLORS["embed"])
+
+ # Make sure the countdown has started
+ if (len(countdown.messages) == 0):
+ embed.color = COLORS["error"]
+ embed.description = "The countdown is empty."
+ elif (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 historical contributor data
+ authors = {}
+ for author in contributors:
+ authors[author["author"]] = [{"progress":0, "percentage":0, "total":0}]
+ for message in countdown.messages:
+ for author in authors:
+ if (author == message.author_id):
+ authors[author] += [{"progress":(stats["total"] - message.number), "percentage":(authors[author][-1]["total"] + 1)/(stats["total"] - message.number + 1) * 100, "total":authors[author][-1]["total"] + 1}]
+ else:
+ authors[author] += [{"progress":(stats["total"] - message.number), "percentage":(authors[author][-1]["total"] + 0)/(stats["total"] - message.number + 1) * 100, "total":authors[author][-1]["total"] + 0}]
+
+ # Plot data and add legend
+ for author in list(authors.keys())[:min(len(authors), 15)]:
+ # Top 15 contributors get included in the legend
+ ax.plot([x["progress"] for x in authors[author]], [x["percentage"] for x in authors[author]], label=await getUsername(self.bot, author))
+ for author in list(authors.keys())[15:max(len(authors), 15)]:
+ ax.plot([x["progress"] for x in authors[author]], [x["percentage"] for x in authors[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()
+
+ # 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:
+ embed.color = COLORS["error"]
+ embed.description = f"Unrecognized option: `{option}`\n"
+ embed.description += f"Use `{(await self.bot.get_prefix(ctx))[0]}help contributors` to view help information"
+
+ # 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:
+ print(f"Unable to delete temp file: {tmp.name}.")
+
+
+
+ @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:
+ embed.color = COLORS["error"]
+ embed.description = "The period must be a number"
+ else:
+ if (len(countdown.messages) < 2):
+ embed.color = COLORS["embed"]
+ embed.description = "The countdown must have at least two messages"
+ elif (period < 0.01):
+ embed.color = COLORS["error"]
+ embed.description = "The period cannot be less than 0.01 hours"
+ else:
+ # 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:
+ print(f"Unable to delete temp file: {tmp.name}.")
+
+
+
+ @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 (len(countdown.messages) == 0):
+ embed.color = COLORS["error"]
+ embed.description = "The countdown is empty."
+ elif (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:
+ rank = None
+ if (re.match("^\d+$", user) and int(user) > 0 and int(user) <= len(leaderboard)):
+ # Get user from rank
+ rank = int(user) - 1
+ elif (re.match("^<@!\d+>$", user) and int(user[3:-1]) in [x["author"] for x in leaderboard]):
+ # Get user from mention
+ rank = [x["author"] for x in leaderboard].index(int(user[3:-1]))
+ else:
+ # Get user from username
+ for contributor in leaderboard:
+ try: username = await getUsername(self.bot, contributor["author"])
+ except: pass
+ if (username.lower().startswith(user.lower())):
+ rank = leaderboard.index(contributor)
+
+ if (rank == None):
+ # Get user from nickname
+ for contributor in leaderboard:
+ try: nickname = await getNickname(self.bot, countdown.server_id, contributor["author"])
+ except: pass
+ if (nickname.lower().startswith(user.lower())):
+ rank = leaderboard.index(contributor)
+
+ if (rank == None):
+ # User not found
+ embed.color = COLORS["error"]
+ embed.description = f"User not found: `{user}`"
+ await ctx.send(embed=embed)
+ return
+
+ # 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"])
+
+ # Make sure the countdown has started
+ if (len(countdown.messages) == 0):
+ embed.color = COLORS["error"]
+ embed.description = "The countdown is empty."
+ else:
+ # Get progress stats
+ stats = countdown.progress()
+
+ # 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
+ 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"**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:
+ print(f"Unable to delete temp file: {tmp.name}.")
+
+
+
+ @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:
+ embed.color = COLORS["error"]
+ embed.description = "The period must be a number"
+ else:
+ if (len(countdown.messages) == 0):
+ embed.color = COLORS["error"]
+ embed.description = "The countdown is empty."
+ elif (period < 0.01):
+ embed.color = COLORS["error"]
+ embed.description = "The period cannot be less than 0.01 hours"
+ else:
+ # 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:
+ print(f"Unable to delete temp file: {tmp.name}.")
diff --git a/src/bot.py b/src/bot.py
@@ -0,0 +1,62 @@
+# Import dependencies
+import discord
+from discord.ext import commands
+
+
+# Import modules
+from src import analyticsCog, utilitiesCog
+from src.botUtilities import addMessage, COLORS, getCountdown, getPrefix
+from src.models import getSessionMaker
+
+
+
+class CountdownBot(commands.Bot):
+ def __init__(self, databaseLocation, prefixes=["c."]):
+ # Initialize bot
+ commands.Bot.__init__(self, command_prefix=lambda bot, ctx: getPrefix(self.databaseSessionMaker, ctx, self.prefixes), case_insensitive=True)
+
+ # Set properties
+ self.databaseSessionMaker = getSessionMaker(databaseLocation)
+ self.prefixes = prefixes
+
+ # Add cogs
+ self.add_cog(analyticsCog.Analytics(self, self.databaseSessionMaker))
+ self.add_cog(utilitiesCog.Utilities(self, self.databaseSessionMaker))
+
+
+
+ async def on_ready(self):
+ print(f"Connected to Discord as {self.user}")
+
+
+
+ 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 and obj.author.name != "countdown-bot"):
+ # Add message to countdown and commit changes
+ if (await addMessage(countdown, obj)): session.commit()
+
+ # Run commands
+ try:
+ await self.process_commands(obj)
+ except:
+ pass
+
+
+
+ async def on_command_error(self, ctx, error):
+ # Send error embed
+ embed=discord.Embed(title="Error", description=str(error), color=COLORS["error"])
+ if (isinstance(error, commands.CommandNotFound)):
+ embed.description = f"Command not found: `{str(error)[9:-14]}`"
+ else:
+ embed.description = str(error)
+ embed.description += f"\nUse `{(await self.get_prefix(ctx))[0]}help` to view help information\n"
+ await ctx.send(embed=embed)
diff --git a/src/botUtilities.py b/src/botUtilities.py
@@ -0,0 +1,248 @@
+# Import dependencies
+import discord
+import re
+
+# Import modules
+from src.models import Countdown, Message, MessageIncorrectError, MessageNotAllowedError
+
+
+
+COLORS = {
+ "error": 0xD52C42,
+ "embed": 0x248AD1,
+}
+
+
+
+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)
+
+
+
+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, resortToFirst=True):
+ """
+ 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
+ resortToFirst : bool
+ Whether to return the 1st countdown if no relevant countdowns are found
+
+ Returns
+ -------
+ Countdown
+ The countdown
+ """
+
+ if (isinstance(ctx.channel, discord.channel.TextChannel)):
+ # Countdown channel
+ countdown = getCountdown(session, ctx.channel.guild.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
+
+ # First countdown channel
+ countdown = session.query(Countdown).first()
+ if (resortToFirst and countdown): return countdown
+ else: raise Exception("Countdown channel not found")
+
+
+
+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 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 for x in countdown.prefixes]
+ if (len(prefixes) > 0):
+ return list(dict.fromkeys(prefixes))
+
+ # Return default prefixes
+ return 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 = await bot.get_channel(countdown.id).history(limit=10100).flatten()
+ rawMessages.reverse()
+
+ # Add messages to countdown
+ for rawMessage in rawMessages:
+ await addMessage(countdown, rawMessage)
diff --git a/src/models.py b/src/models.py
@@ -0,0 +1,444 @@
+# Import dependencies
+from datetime import datetime, timedelta
+import math
+from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, ForeignKey
+from sqlalchemy.orm import relationship, sessionmaker
+from sqlalchemy.ext.declarative import declarative_base
+
+
+
+Base = declarative_base()
+
+
+
+def getSessionMaker(location):
+ """
+ Create a sessionmaker from a database URI
+
+ Parameters
+ ----------
+ location : str
+ The location of the database
+ """
+
+ engine = create_engine(location)
+ Base.metadata.create_all(bind=engine)
+ return sessionmaker(bind=engine)
+
+
+
+# The rules for awarding leaderboard points
+POINT_RULES = {
+ "1000s": 1000,
+ "1001s": 500,
+ "200s": 200,
+ "201s": 100,
+ "100s": 100,
+ "101s": 50,
+ "Prime Numbers": 15,
+ "Odd Numbers": 12,
+ "Even Numbers": 10,
+ "First Number": 0,
+}
+
+
+
+# Error classes
+class MessageNotAllowedError(Exception):
+ """Raised when someone posts twice in a row."""
+ pass
+
+class MessageIncorrectError(Exception):
+ """Raised when someone posts an incorrect number."""
+ pass
+
+
+
+class Countdown(Base):
+ """
+ A Discord countdown
+
+ Attributes
+ ----------
+ id : int
+ The countdown's ID
+ server_id : int
+ The countdown's server's ID
+ timezone : float
+ The countdown's UTC offset (in hours)
+ prefixes : list
+ The countdown's command prefixes
+ reactions : list
+ The countdown's custom reactions
+ messages : list
+ The messages in the countdown
+ """
+
+ __tablename__ = "countdown"
+
+ id = Column(Integer, primary_key=True)
+ server_id = Column(Integer)
+ timezone = Column(Float)
+ prefixes = relationship("Prefix", back_populates="countdown", cascade="all, delete-orphan")
+ reactions = relationship("Reaction", back_populates="countdown", cascade="all, delete-orphan")
+ messages = relationship("Message", back_populates="countdown", cascade="all, delete-orphan")
+
+ def addMessage(self, message):
+ """
+ Add a message to the countdown
+
+ Parameters
+ ----------
+ message : Message
+ The message object
+
+ Raises
+ ------
+ MessageNotAllowedError
+ If the author posted the last message
+ MessageIncorrectError
+ If the message content is incorrect
+ """
+
+ if (len(self.messages) != 0 and message.author_id == self.messages[-1].author_id):
+ raise MessageNotAllowedError()
+ elif (len(self.messages) != 0 and message.number + 1 != self.messages[-1].number):
+ raise MessageIncorrectError()
+ else:
+ self.messages += [message]
+
+ def getTimezone(self):
+ """
+ Get the timezone as a string
+
+ Returns
+ -------
+ str
+ The timezone string
+ """
+
+ # Get tiemzone
+ if (self.timezone >= 0): result = f"UTC+{self.timezone}"
+ else: result = f"UTC-{abs(self.timezone)}"
+
+ # Remove ".0" from the end
+ if (self.timezone % 1 == 0): result = result[:-2]
+
+ # Return timezone string
+ return result
+
+ def contributors(self):
+ """
+ Get countdown contributor statistics.
+
+ Returns
+ -------
+ list
+ A list of contributor statistics.
+ """
+
+ # Get contributors
+ authors = list(set([x.author_id for x in self.messages]))
+
+ # Get contributions
+ contributors = []
+ for author in authors:
+ contributors += [{
+ "author":author,
+ "contributions":len([x for x in self.messages if x.author_id == author]),
+ }]
+
+ # Sort contributors by contributions
+ contributors = sorted(contributors, key=lambda x: x["contributions"], reverse=True)
+
+ # Return contributors
+ return contributors
+
+ def eta(self, period=timedelta(days=1)):
+ """
+ Get countdown eta statistics.
+
+ Parameters
+ ----------
+ period : timedelta
+ The period size. The default is 1 day.
+
+ Returns
+ -------
+ list
+ The countdown eta statistics.
+ """
+
+ # Make sure countdown has at least two messages
+ if (len(self.messages) < 2):
+ return [[], []]
+
+ # Initialize period data
+ periodEnd = self.messages[0].timestamp + timedelta(hours=self.timezone) + period
+ lastMessage = 0
+
+ # Initialize result and add first data point
+ data = [[self.messages[0].timestamp + timedelta(hours=self.timezone)], [self.messages[0].timestamp + timedelta(hours=self.timezone)]]
+
+ # Calculate timestamp for last data point
+ if (self.messages[-1].number == 0):
+ end = self.messages[-1].timestamp + timedelta(hours=self.timezone)
+ else:
+ end = datetime.utcnow() + timedelta(hours=self.timezone)
+
+ # Add data points
+ while (periodEnd < end):
+ # Advance to last message in period
+ while (lastMessage+1 < len(self.messages) and self.messages[lastMessage+1].timestamp + timedelta(hours=self.timezone) < periodEnd):
+ lastMessage += 1
+
+ # Calculate data
+ rate = (self.messages[0].number - self.messages[lastMessage].number) / ((periodEnd - (self.messages[0].timestamp + timedelta(hours=self.timezone))) / timedelta(days=1))
+ eta = periodEnd + timedelta(days=self.messages[lastMessage].number/rate)
+ data[0] += [periodEnd]
+ data[1] += [eta]
+
+ # Advance to next period
+ periodEnd += period
+
+ # Add last data point
+ data[0] += [end]
+ data[1] += [self.progress()["eta"]]
+
+ # Return eta data
+ return data
+
+ def leaderboard(self):
+ """
+ Get countdown leaderboard.
+
+ Returns
+ -------
+ list
+ The leaderboard.
+ """
+
+ if (len(self.messages) == 0):
+ return []
+
+ # Get list of prime numbers
+ curTest = 5
+ search = 1
+ primes = [2, 3]
+ while curTest < self.messages[0].number:
+ if curTest%(primes[search]) == 0:
+ curTest = curTest + 2
+ search = 1
+ else:
+ if primes[search] > math.sqrt(curTest):
+ primes.append(curTest)
+ curTest = curTest + 2
+ search = 1
+ else:
+ search = search + 1
+
+ # Calculate contributor points
+ points = {}
+ for message in self.messages:
+ if (message.author_id not in points):
+ points[message.author_id] = {
+ "author": message.author_id,
+ "breakdown": {
+ "1000s": 0,
+ "1001s": 0,
+ "200s": 0,
+ "201s": 0,
+ "100s": 0,
+ "101s": 0,
+ "Prime Numbers": 0,
+ "Odd Numbers": 0,
+ "Even Numbers": 0,
+ "First Number": 0,
+ },
+ }
+ if (message.number == self.messages[0].number): points[message.author_id]["breakdown"]["First Number"] += 1
+ elif (message.number % 1000 == 0): points[message.author_id]["breakdown"]["1000s"] += 1
+ elif (message.number % 1000 == 1): points[message.author_id]["breakdown"]["1001s"] += 1
+ elif (message.number % 200 == 0): points[message.author_id]["breakdown"]["200s"] += 1
+ elif (message.number % 200 == 1): points[message.author_id]["breakdown"]["201s"] += 1
+ elif (message.number % 100 == 0): points[message.author_id]["breakdown"]["100s"] += 1
+ elif (message.number % 100 == 1): points[message.author_id]["breakdown"]["101s"] += 1
+ elif (message.number in primes): points[message.author_id]["breakdown"]["Prime Numbers"] += 1
+ elif (message.number % 2 == 1): points[message.author_id]["breakdown"]["Odd Numbers"] += 1
+ else: points[message.author_id]["breakdown"]["Even Numbers"] += 1
+
+ # Create ranked leaderboard
+ leaderboard = []
+ for contributor in points.values():
+ contributor["contributions"] = sum(contributor["breakdown"].values())
+ contributor["points"] = sum([contributor["breakdown"][x] * POINT_RULES[x] for x in contributor["breakdown"]])
+ leaderboard += [contributor]
+ leaderboard = sorted(leaderboard, key=lambda x: x["points"], reverse=True)
+ return leaderboard
+
+ def progress(self):
+ """
+ Get countdown progress statistics.
+
+ Returns
+ -------
+ dict
+ A dictionary containing countdown progress statistics.
+ """
+
+ # Get basic statistics
+ if (len(self.messages) > 0):
+ total = self.messages[0].number
+ current = self.messages[-1].number
+ percentage = (total - current) / total * 100
+ start = self.messages[0].timestamp
+ else:
+ total = 0
+ current = 0
+ percentage = 0
+ start = datetime.utcnow()
+
+ # Get rate statistics
+ if (len(self.messages) > 1 and self.messages[-1].number == 0):
+ # The countdown has already finished
+ rate = (total - current)/((self.messages[-1].timestamp - self.messages[0].timestamp) / timedelta(days=1))
+ eta = self.messages[-1].timestamp
+ elif (len(self.messages) > 1):
+ # The countdown is still going
+ rate = (total - current)/((datetime.utcnow() - self.messages[0].timestamp) / timedelta(days=1))
+ eta = datetime.utcnow() + timedelta(days=current/rate)
+ else:
+ rate = 0
+ eta = datetime.utcnow()
+
+ # Get list of progress
+ progress = [{"time":x.timestamp, "progress":x.number} for x in self.messages]
+
+ # Return stats
+ return {
+ "total": total,
+ "current": current,
+ "percentage": percentage,
+ "progress": progress,
+ "start": start,
+ "rate": rate,
+ "eta": eta,
+ }
+
+ def speed(self, period=timedelta(days=1)):
+ """
+ Get countdown speed statistics.
+
+ Parameters
+ ----------
+ periodLength : timedelta
+ The period size. The default is 1 day.
+
+ Returns
+ -------
+ list
+ The countdown speed statistics.
+ """
+
+ # Calculate speed statistics
+ data = [[], []]
+ periodStart = datetime(2018, 1, 1) # Starts on Monday, Jan 1st
+ for message in self.messages:
+ # If data point isn't in the current period
+ while (message.timestamp + timedelta(hours=self.timezone) - period >= periodStart):
+ periodStart += period
+
+ # Add new period if needed
+ if (len(data[0]) == 0 or data[0][-1] != periodStart):
+ data[0] += [periodStart]
+ data[1] += [0]
+
+ # Otherwise add the latest diff to the current period
+ data[1][-1] += 1
+
+ # Return speed statistics
+ return data
+
+
+
+class Prefix(Base):
+ """
+ A command prefix for a countdown
+
+ Attributes
+ ----------
+ id : int
+ The prefix's ID
+ countdown_id : int
+ The prefix's countdown's ID
+ countdown : Countdown
+ The prefix's countdown
+ value : string
+ The command prefix
+ """
+
+ __tablename__ = "prefix"
+
+ id = Column(Integer, primary_key=True)
+ countdown_id = Column(Integer, ForeignKey("countdown.id"))
+ countdown = relationship("Countdown", back_populates="prefixes")
+ value = Column(String)
+
+
+
+class Reaction(Base):
+ """
+ A custom countdown reaction
+
+ Attributes
+ ----------
+ id : int
+ The reaction's ID
+ countdown_id : int
+ The prefix's countdown's ID
+ countdown : Countdown
+ The prefix's countdown
+ number : int
+ The number that the reaction applies to
+ value : string
+ The reaction
+ """
+
+ __tablename__ = "reaction"
+
+ id = Column(Integer, primary_key=True)
+ countdown_id = Column(Integer, ForeignKey("countdown.id"))
+ countdown = relationship("Countdown", back_populates="reactions")
+ number = Column(Integer)
+ value = Column(String)
+
+
+
+class Message(Base):
+ """
+ A countdown message
+
+ Attributes
+ ----------
+ id : int
+ The message's ID
+ countdown_id : int
+ The message's countdown's ID
+ countdown : Countdown
+ The message's countdown
+ author_id : int
+ The message's author's ID
+ timestamp : datetime.datetime
+ The message's timestamp
+ number : int
+ The message's number
+ """
+
+ __tablename__ = "message"
+
+ id = Column(Integer, primary_key=True)
+ countdown_id = Column(Integer, ForeignKey("countdown.id"))
+ countdown = relationship("Countdown", back_populates="messages")
+ author_id = Column(Integer)
+ timestamp = Column(DateTime)
+ number = Column(Integer)
diff --git a/src/utilitiesCog.py b/src/utilitiesCog.py
@@ -0,0 +1,377 @@
+# Import dependencies
+import discord
+from discord.ext import commands
+
+# Import modules
+from src.botUtilities import COLORS, 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:
+ # Channel is already a coutndown
+ if (getCountdown(session, ctx.channel.id)):
+ embed = discord.Embed(title="Error", description="This channel is already a countdown", color=COLORS["error"])
+ await ctx.send(embed=embed)
+
+ # Channel is a DM
+ elif (not isinstance(ctx.channel, discord.channel.TextChannel)):
+ embed = discord.Embed(title="Error", description="This command must be run inside a server", color=COLORS["error"])
+ await ctx.send(embed=embed)
+
+ # User isn't authorized
+ elif (not ctx.message.author.guild_permissions.administrator):
+ embed = discord.Embed(title="Error", description="You must be an administrator to turn a channel into a countdown", color=COLORS["error"])
+ await ctx.send(embed=embed)
+
+ # Channel is valid
+ else:
+ # 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 responce
+ print(f"Activated {self.bot.get_channel(ctx.channel.id)} as a countdown")
+ embed = discord.Embed(title=":clock3: Loading Countdown", description="@here 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 responce
+ embed = discord.Embed(title=":white_check_mark: Countdown Activated", description="@here 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"])
+
+ with self.databaseSessionMaker() as session:
+ # Get countdown channel
+ try:
+ countdown = getContextCountdown(session, ctx, resortToFirst=False)
+ except:
+ embed.color = COLORS["error"]
+ embed.description = "This command must be run in a countdown channel or a server with a countdown channel"
+ else:
+ # 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"
+ elif (not ctx.message.author.guild_permissions.administrator):
+ embed.color = COLORS["error"]
+ embed.description = f"You must be an administrator to modify settings"
+ elif (len(args) == 0):
+ embed.color = COLORS["error"]
+ embed.description = f"Please provide a value for the setting"
+ elif (key in ["tz", "timezone"]):
+ try:
+ countdown.timezone = float(args[0])
+ except:
+ embed.color = COLORS["error"]
+ embed.description = 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):
+ embed.color = COLORS["error"]
+ embed.description = f"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:
+ embed.color = COLORS["error"]
+ embed.description = f"Invalid number: {args[0]}"
+ else:
+ embed.color = COLORS["error"]
+ embed.description = f"Setting not found: `{key}`\n"
+ embed.description += f"Use `{(await self.bot.get_prefix(ctx))[0]}help config` to view the list of settings"
+
+ # 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:
+ # Channel isn't a countdown
+ countdown = getCountdown(session, ctx.channel.id)
+ if (not countdown):
+ embed = discord.Embed(title="Error", description="This channel isn't a countdown", color=COLORS["error"])
+ await ctx.send(embed=embed)
+
+ # User isn't authorized
+ elif (not ctx.author.guild_permissions.administrator):
+ embed = discord.Embed(title="Error", description="You must be an administrator to deactivate a countdown channel", color=COLORS["error"])
+ await ctx.send(embed=embed)
+
+ # Channel is valid
+ else:
+ # Delete countdown
+ session.delete(countdown)
+ session.commit()
+
+ # Send responce
+ print(f"Deactivated {self.bot.get_channel(ctx.channel.id)} as a countdown")
+ embed = discord.Embed(title=":octagonal_sign: Countdown Deactivated", description="@here 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" \
+ "**-** `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" \
+ "**-** Pins numbers every 2% if the countdown started at 500 or higher\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" \
+ "**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:**\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 (see below).\n" \
+ "**-** `<value>`: The new value(s) for the setting. If no key-value pair is supplied, all settings will be shown.\n" \
+ "**Available Settings:**\n" \
+ "**-** `prefix`, `prefixes`: The prefix(es) for the self.bot.\n" \
+ "**-** `tz`, `timezone`: The UTC offset, in hours.\n" \
+ "**-** `react`: The reactions for a certain number. Ex: `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" \
+ "**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" \
+ "**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" \
+ "**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" \
+ "**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 viewleaderboard information about. If no value is supplied, the whole leaderboard will be shown.\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" \
+ "**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" \
+ "**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" \
+ "**Notes:** none\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" \
+ "**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.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 ["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:
+ embed.color = COLORS["error"]
+ embed.description = f"Command not found: `{command}`\n"
+ embed.description += f"Use `{prefixes[0]}help` to view the list of commands"
+
+ # 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 inital responce
+ 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 responce
+ print(f"Reloaded messages from {self.bot.get_channel(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:
+ embed = discord.Embed(title="Error", description="This command must be used in a countdown channel", color = COLORS["error"])
+ await ctx.channel.send(embed=embed)