countdown-bot

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

commit 692f6a15ed2ac26513906c17135875b742b47735
parent 0636ea27d94a93636d7b824d072ceb9daae44853
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Tue, 29 Jun 2021 14:49:13 -0700

Implement heatmap command

Diffstat:
Msrc/analyticsCog.py | 112++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Msrc/botUtilities.py | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/models.py | 43++++++++++++++++++++++++++++++++++++++++---
Msrc/utilitiesCog.py | 23+++++++++++++++++------
4 files changed, 193 insertions(+), 39 deletions(-)

diff --git a/src/analyticsCog.py b/src/analyticsCog.py @@ -4,12 +4,13 @@ import discord from discord.ext import commands from matplotlib import pyplot as plt from matplotlib.ticker import PercentFormatter +import numpy as np import os import re import tempfile # Import modules -from src.botUtilities import COLORS, getContextCountdown, getNickname, getUsername +from src.botUtilities import COLORS, getContextCountdown, getUsername, getContributor, ContributorNotFound from src.models import POINT_RULES @@ -42,6 +43,7 @@ class Analytics(commands.Cog): 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.heatmap(ctx) await self.leaderboard(ctx) await self.progress(ctx) await self.speed(ctx) @@ -238,6 +240,75 @@ class Analytics(commands.Cog): + @commands.command() + async def heatmap(self, ctx, user=None): + """ + Shows a heatmap of when countdown messages are sent + """ + + with self.databaseSessionMaker() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) + + # Create temp file + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + tmp.close() + + # Create embed + embed=discord.Embed(title=":calendar_spiral: Countdown Heatmap", color=COLORS["embed"]) + + # Get user + try: + if (user == None): userID = None + else: userID = await getContributor(self.bot, countdown, user) + except ContributorNotFound: + embed.color = COLORS["error"] + embed.description = f"Contributor not found: `{user}`" + await ctx.send(embed=embed) + return + + # Get heatmap matrix + heatmapMatrix = np.ma.masked_equal(np.array(countdown.heatmap(userID)), 0) + + # Create figure + fig, ax = plt.subplots() + ax.set_xlabel("Hour of Day") + ax.set_xticks([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]) + ax.set_xticklabels(["12 AM", "1 AM", "2 AM", "3 AM", "4 AM", "5 AM", "6 AM", "7 AM", "8 AM", "9 AM", "10 AM", "11 AM", "12 PM", "1 PM", "2 PM", "3 PM", "4 PM", "5 PM", "6 PM", "7 PM", "8 PM", "9 PM", "10 PM", "11 PM"]) + ax.set_ylabel("Day of Week") + ax.set_yticks([0, 1, 2, 3, 4, 5, 6]) + ax.set_yticklabels(["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]) + + # Add data to graph + cmap = plt.get_cmap("jet").copy() + cmap.set_bad("gray") + cax = ax.matshow(heatmapMatrix, cmap=cmap, aspect="auto") + fig.colorbar(cax) + + # Save graph + fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) + file = discord.File(tmp.name, filename="image.png") + + # Add content to embed + embed.description = f"**Countdown Channel:** <#{countdown.id}>\n" + if (userID): embed.description += f"**User:** <@{userID}>\n" + embed.description += f"**Total Contributions:** {np.sum(heatmapMatrix):,}\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): """ @@ -284,35 +355,16 @@ class Analytics(commands.Cog): 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 + try: + if (re.match("^\d+$", user) and int(user) > 0 and int(user) <= len(leaderboard)): + rank = int(user) - 1 + else: + rank = [x["author"] for x in leaderboard].index(await getContributor(self.bot, countdown, user)) + except ContributorNotFound: + embed.color = COLORS["error"] + embed.description = f"Contributor not found: `{user}`" + await ctx.send(embed=embed) + return # Add description embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" diff --git a/src/botUtilities.py b/src/botUtilities.py @@ -14,6 +14,13 @@ COLORS = { +# Error classes +class ContributorNotFound(Exception): + """Raised when a matching countdown contributor cannot be found""" + pass + + + async def getUsername(bot, id): """ Get a username from a user ID. @@ -59,6 +66,53 @@ async def getNickname(bot, server, id): +async def getContributor(bot, countdown, text): + """ + Get the ID of the countdown contributor refered to by a string + + Parameters + ---------- + bot : commands.Bot + The bot + countdown : Countdown + The countdown + text : str + The string + + Returns + ------- + int + The ID of the contributor + + Raises + ------ + ContributorNotFound + If a matching contributor cannot be found + """ + + # Get countdown contributors + contributors = [x["author"] for x in countdown.contributors()] + + # Get user from mention + if (re.match("^<@!\d+>$", text) and int(text[3:-1]) in contributors): return int(text[3:-1]) + elif (re.match("^<@!\d+>$", text)): raise ContributorNotFound(text) + + # Get user from username + for contributor in contributors: + try: username = await getUsername(bot, contributor) + except: continue + if (username.lower().startswith(text.lower())): return contributor + + # Get user from nickname + for contributor in contributors: + try: nickname = await getNickname(bot, countdown.server_id, contributor) + except: continue + if (nickname.lower().startswith(text.lower())): return contributor + + raise ContributorNotFound(text) + + + def getCountdown(session, id): """ Get a countdown object diff --git a/src/models.py b/src/models.py @@ -123,7 +123,7 @@ class Countdown(Base): # Remove ".0" from the end if (self.timezone % 1 == 0): result = result[:-2] - + # Return timezone string return result @@ -208,6 +208,43 @@ class Countdown(Base): # Return eta data return data + def heatmap(self, user=None): + """ + Get a heatmap of when countdown messages are sent + + Parameters + ---------- + user : int + The ID of the specific user to generate the heatmap for (the default is None) + + Returns + ------- + list + A 7x24 2D array containing the heatmap + """ + + # Initialize result matrix + result = [[0 for i in range(24)] for j in range(7)] + + for message in self.messages: + if (user != None and message.author_id != user): continue + + # Apply timezone offset + timestamp = message.timestamp + timedelta(hours=self.timezone) + + # Get time and weekday + dayOfWeek = timestamp.weekday() # 0-6, 0=Monday + timeOfDay = timestamp.hour # 0-23 + + # Make Sunday the first day of the week + dayOfWeek = (dayOfWeek + 1) % 7 + + # Add data to result matrix + result[dayOfWeek][timeOfDay] += 1 + + # Return result matrix + return result + def leaderboard(self): """ Get countdown leaderboard. @@ -376,7 +413,7 @@ class Prefix(Base): value : string The command prefix """ - + __tablename__ = "prefix" id = Column(Integer, primary_key=True) @@ -403,7 +440,7 @@ class Reaction(Base): value : string The reaction """ - + __tablename__ = "reaction" id = Column(Integer, primary_key=True) diff --git a/src/utilitiesCog.py b/src/utilitiesCog.py @@ -50,7 +50,7 @@ class Utilities(commands.Cog): messages = [], ) - # Send initial responce + # Send initial response 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) @@ -60,7 +60,7 @@ class Utilities(commands.Cog): session.add(countdown) session.commit() - # Send final responce + # Send final response 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) @@ -164,7 +164,7 @@ class Utilities(commands.Cog): session.delete(countdown) session.commit() - # Send responce + # Send response 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) @@ -193,6 +193,7 @@ class Utilities(commands.Cog): "**-** `analytics`, `a`: Shows all countdown analytics\n" \ "**-** `contributors`, `c`: Shows information about countdown contributors\n" \ "**-** `eta`, `e`: Shows information about the estimated completion date\n" \ + "**-** `heatmap`: Shows a heatmap of when messages are sent\n" \ "**-** `leaderboard`, `l`: Shows the countdown leaderboard\n" \ "**-** `progress`, `p`: Shows information about countdown progress\n" \ "**-** `speed`, `s`: Shows information about countdown speed\n", @@ -250,6 +251,14 @@ class Utilities(commands.Cog): "**Arguments:**\n" \ "**-** `<period>`: The size of the period in hours. The default is 24 hours.\n" \ "**Notes:** none\n", + "heatmap": + "**Name:** heatmap\n" \ + "**Description:** Shows a heatmap of when countdown messages are sent\n" \ + f"**Usage:** `{prefixes[0]}heatmap [<user>]`\n" \ + "**Aliases:** none\n" \ + "**Arguments:**\n" \ + "**-** `<user>`: The username or nickname of the user to view heatmap information about. If no value is supplied, the general heatmap will be shown.\n" \ + "**Notes:** none\n", "help": "**Name:** help\n" \ "**Description:** Shows help information\n" \ @@ -264,7 +273,7 @@ class Utilities(commands.Cog): 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" \ + "**-** `<user>`: The rank, username, or nickname of the user to view leaderboard information about. If no value is supplied, the whole leaderboard will be shown.\n" \ "**Notes:** The leaderboard embed will only show the top 20 contributors\n", "ping": "**Name:** ping\n" \ @@ -317,6 +326,8 @@ class Utilities(commands.Cog): embed.description = help_text["deactivate"] elif (command.lower() in ["e", "eta"]): embed.description = help_text["eta"] + elif (command.lower() in ["heatmap"]): + embed.description = help_text["heatmap"] elif (command.lower() in ["h", "help"]): embed.description = help_text["help"] elif (command.lower() in ["l", "leaderboard"]): @@ -360,7 +371,7 @@ class Utilities(commands.Cog): with self.databaseSessionMaker() as session: countdown = getCountdown(session, ctx.channel.id) if (countdown): - # Send inital responce + # Send initial response embed = discord.Embed(title=":clock3: Reloading Countdown Cache", description="Please wait to continue counting.", color=COLORS["embed"]) msg = await ctx.channel.send(embed=embed) @@ -368,7 +379,7 @@ class Utilities(commands.Cog): await loadCountdown(self.bot, countdown) session.commit() - # Send final responce + # Send final response 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)