countdown-bot

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

analyticsCog.py (21990B)


      1 # Import dependencies
      2 from datetime import datetime, timedelta
      3 import discord
      4 from discord.ext import commands
      5 from matplotlib import pyplot as plt
      6 from matplotlib.ticker import PercentFormatter
      7 import numpy as np
      8 import os
      9 import re
     10 import tempfile
     11 
     12 # Import modules
     13 from .botUtilities import COLORS, POINT_RULES, CommandError, CountdownNotFound, getUsername, getContributor, getContextCountdown
     14 
     15 
     16 
     17 class Analytics(commands.Cog):
     18     def __init__(self, bot, db_connection):
     19         self.bot = bot
     20         self.db_connection = db_connection
     21 
     22 
     23 
     24     @commands.command(aliases=["a"])
     25     async def analytics(self, ctx):
     26         """
     27         Shows all countdown analytics
     28         """
     29 
     30         # Run analytics commands
     31         await self.contributors(ctx, "")
     32         await self.contributors(ctx, "history")
     33         await self.eta(ctx)
     34         await self.heatmap(ctx)
     35         await self.leaderboard(ctx)
     36         await self.progress(ctx)
     37         await self.speed(ctx)
     38 
     39 
     40 
     41     @commands.command(aliases=["c"])
     42     async def contributors(self, ctx, option=""):
     43         """
     44         Shows information about countdown contributors
     45         """
     46 
     47         with self.db_connection.cursor() as cur:
     48             # Get countdown channel
     49             countdown = getContextCountdown(cur, ctx)
     50             if not countdown:
     51                 raise CountdownNotFound()
     52 
     53             # Create temp file
     54             tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
     55             tmp.close()
     56 
     57             # Create embed
     58             embed=discord.Embed(title=":busts_in_silhouette: Countdown Contributors", color=COLORS["embed"])
     59 
     60             # Make sure the countdown has started
     61             if (option.lower() in ["h", "history"]):
     62                 # Create figure
     63                 fig, ax = plt.subplots()
     64                 ax.set_xlabel("Progress")
     65                 ax.set_ylabel("Percentage of Contributions")
     66                 ax.yaxis.set_major_formatter(PercentFormatter())
     67 
     68                 # Get stats
     69                 cur.execute("SELECT * FROM contributorData(%s);", (countdown,))
     70                 contributors = [x["userid"] for x in cur.fetchall()]
     71                 cur.execute("SELECT * FROM historicalContributorData(%s);", (countdown,))
     72                 data = cur.fetchall()
     73 
     74                 if not data:
     75                     raise CommandError("The countdown doesn't have enough messages yet")
     76 
     77                 # Plot data and add legend
     78                 for author in contributors[:15]:
     79                     # Top 15 contributors get included in the legend
     80                     ax.plot([x["progress"] for x in data if x["userid"] == author], [x["percentage"] for x in data if x["userid"] == author], label=await getUsername(self.bot, author))
     81                 for author in contributors[15:]:
     82                     ax.plot([x["progress"] for x in data if x["userid"] == author], [x["percentage"] for x in data if x["userid"] == author])
     83                 ax.legend(bbox_to_anchor=(1,1.025), loc="upper left")
     84 
     85                 # Save graph
     86                 fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2)
     87                 file = discord.File(tmp.name, filename="image.png")
     88 
     89                 # Add content to embed
     90                 embed.description = f"**Countdown Channel:** <#{countdown}>"
     91                 embed.set_image(url="attachment://image.png")
     92             elif (option == ""):
     93                 # Create figure
     94                 fig, ax = plt.subplots()
     95 
     96                 # Get stats
     97                 cur.execute("SELECT * FROM contributorData(%s);", (countdown,))
     98                 data = cur.fetchall()
     99 
    100                 if not data:
    101                     raise CommandError("The countdown doesn't have enough messages yet")
    102 
    103                 # Add data to graph
    104                 pieData = ax.pie([x["contributions"] for x in data], autopct="%1.1f%%", startangle=90)
    105 
    106                 # Add legend
    107                 ax.legend(pieData[0], [await getUsername(self.bot, x["userid"]) for x in
    108                     data[:15]], bbox_to_anchor=(1,1.025), loc="upper left")
    109 
    110                 # Save graph
    111                 fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2)
    112                 file = discord.File(tmp.name, filename="image.png")
    113 
    114                 # Add content to embed
    115                 embed.description = f"**Countdown Channel:** <#{countdown}>"
    116                 ranksColumn = ""
    117                 usersColumn = ""
    118                 contributionsColumn = ""
    119                 for i in range(0, min(len(data), 20)):
    120                     ranksColumn += f"{i+1:,}\n"
    121                     contributionsColumn += f"{data[i]['contributions']:,} *({data[i]['percentage']:.1f}%)*\n"
    122                     usersColumn += f"<@{data[i]['userid']}>\n"
    123                 embed.add_field(name="Rank", value=ranksColumn, inline=True)
    124                 embed.add_field(name="User", value=usersColumn, inline=True)
    125                 embed.add_field(name="Contributions", value=contributionsColumn, inline=True)
    126                 embed.set_image(url="attachment://image.png")
    127             else:
    128                 raise CommandError(f"Unrecognized option: `{option}`")
    129 
    130         # Send embed
    131         try:
    132             await ctx.send(file=file, embed=embed)
    133         except:
    134             await ctx.send(embed=embed)
    135 
    136         # Remove temp file
    137         try:
    138             os.remove(tmp.name)
    139         except Exception as e:
    140             self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e)
    141 
    142 
    143 
    144     @commands.command(aliases=["e"])
    145     async def eta(self, ctx):
    146         """
    147         Shows information about the estimated completion date
    148         """
    149 
    150         with self.db_connection.cursor() as cur:
    151             # Get countdown channel
    152             countdown = getContextCountdown(cur, ctx)
    153             if not countdown:
    154                 raise CountdownNotFound()
    155 
    156             # Create temp file
    157             tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
    158             tmp.close()
    159 
    160             # Create embed
    161             embed=discord.Embed(title=":calendar: Countdown Estimated Completion Date", color=COLORS["embed"])
    162 
    163             # Get stats
    164             cur.execute("CALL progressStats(%s,null,null,null,null,null,null,null,null,null,null,null,null);", (countdown,))
    165             stats = cur.fetchone()
    166             cur.execute("SELECT * FROM etaData(%s);", (countdown,))
    167             data = cur.fetchall()
    168 
    169             if not data:
    170                 raise CommandError("The countdown doesn't have enough messages yet")
    171 
    172             # Create figure
    173             fig, ax = plt.subplots()
    174             ax.set_xlabel("Time")
    175             fig.autofmt_xdate()
    176 
    177             # Add ETA data to graph
    178             ax.plot([x["_timestamp"] for x in data], [x["eta"] for x in data], "C0", label="Estimated Completion Date")
    179 
    180             # Add reference line graph
    181             ax.plot([data[0]["_timestamp"], data[-1]["_timestamp"]], [data[0]["_timestamp"], data[-1]["_timestamp"]], "--C1", label="Current Date")
    182 
    183             # Add legend
    184             ax.legend()
    185 
    186             # Save graph
    187             fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2)
    188             file = discord.File(tmp.name, filename="image.png")
    189 
    190             # Calculate embed data
    191             maxEta = max([x["eta"] for x in data])
    192             maxDate = [x["_timestamp"] for x in data if x["eta"] == maxEta][0]
    193             minEta = min([x["eta"] for x in data])
    194             minDate = [x["_timestamp"] for x in data if x["eta"] == minEta][0]
    195 
    196             # Add content to embed
    197             embed.description = f"**Countdown Channel:** <#{countdown}>\n\n"
    198             embed.description += f"**Maximum Estimate:** {maxEta.date()} (on {maxDate.date()})\n"
    199             embed.description += f"**Minimum Estimate:** {minEta.date()} (on {minDate.date()})\n"
    200             if stats['endage'] > timedelta(seconds=0):
    201                 embed.description += f"**Actual Completion Date:** {stats['endtime'].date()} ({stats['endage'].days:,} days ago)\n"
    202             else:
    203                 embed.description += f"**Current Estimate:** {stats['endtime'].date()} ({(-1 * stats['endage']).days:,} days from now)\n"
    204             embed.set_image(url="attachment://image.png")
    205 
    206         # Send embed
    207         try:
    208             await ctx.send(file=file, embed=embed)
    209         except:
    210             await ctx.send(embed=embed)
    211 
    212         # Remove temp file
    213         try:
    214             os.remove(tmp.name)
    215         except Exception as e:
    216             self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e)
    217 
    218 
    219 
    220     @commands.command()
    221     async def heatmap(self, ctx, user=None):
    222         """
    223         Shows a heatmap of when countdown messages are sent
    224         """
    225 
    226         with self.db_connection.cursor() as cur:
    227             # Get countdown channel
    228             countdown = getContextCountdown(cur, ctx)
    229             if not countdown:
    230                 raise CountdownNotFound()
    231 
    232             # Create temp file
    233             tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
    234             tmp.close()
    235 
    236             # Create embed
    237             embed=discord.Embed(title=":calendar_spiral: Countdown Heatmap", color=COLORS["embed"])
    238 
    239             # Get user
    240             if (user == None):
    241                 userID = None
    242             else:
    243                 userID = await getContributor(self.bot, countdown, user)
    244 
    245             # Get heatmap data
    246             cur.execute("CALL heatmapStats(%s, null, null);",
    247                 (countdown,))
    248             stats = cur.fetchone()
    249             cur.execute("SELECT * FROM heatmapData(%s, %s);",
    250                 (countdown, userID))
    251             data = cur.fetchall()
    252 
    253             if not data:
    254                 raise CommandError("The countdown doesn't have enough messages yet")
    255 
    256             # Create heatmap matrix
    257             matrix = [[0 for i in range(24)] for j in range(7)]
    258             for row in data:
    259                 matrix[int(row["dow"])][int(row["hour"])] = row["messages"]
    260 
    261             # Define hour and weekday names
    262             hours = ["12 AM", "1 AM", "2 AM", "3 AM", "4 AM", "5 AM", "6 AM", "7 AM", "8 AM", "9 AM", "10 AM", "11 AM", "12 PM", "1 PM", "2 PM", "3 PM", "4 PM", "5 PM", "6 PM", "7 PM", "8 PM", "9 PM", "10 PM", "11 PM"]
    263             weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
    264 
    265             # Create figure
    266             fig, ax = plt.subplots()
    267             ax.set_xlabel("Hour of Day")
    268             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])
    269             ax.set_xticklabels(hours)
    270             ax.set_ylabel("Day of Week")
    271             ax.set_yticks([0, 1, 2, 3, 4, 5, 6])
    272             ax.set_yticklabels(weekdays)
    273 
    274             # Add data to graph
    275             cmap = plt.get_cmap("jet").copy()
    276             cmap.set_bad("gray")
    277             cax = ax.matshow(np.ma.masked_equal(np.array(matrix), 0), cmap=cmap, aspect="auto")
    278             fig.colorbar(cax)
    279 
    280             # Save graph
    281             fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2)
    282             file = discord.File(tmp.name, filename="image.png")
    283 
    284             # Get embed data
    285             total = np.sum(matrix)
    286             averageValue = total / (24*7)
    287             maxValue = np.max(matrix)
    288             maxWeekday = np.where(matrix == maxValue)[0][0]
    289             maxHour = np.where(matrix == maxValue)[1][0]
    290             currentWeekday = int(stats['curdow'])
    291             currentHour = int(stats['curhour'])
    292             currentValue = matrix[currentWeekday][currentHour]
    293 
    294             # Add content to embed
    295             embed.description = f"**Countdown Channel:** <#{countdown}>\n\n"
    296             if (userID): embed.description += f"**User:** <@{userID}>\n"
    297             embed.description += f"**Total Contributions:** {total:,}\n"
    298             embed.description += f"**Average Contributions per Zone:** {round(averageValue):,}\n"
    299             embed.description += f"**Best Zone:** {hours[maxHour]} to {hours[(maxHour + 1) % 24]} on {weekdays[maxWeekday]}s - {maxValue:,} contributions\n"
    300             embed.description += f"**Current Zone:** {hours[currentHour]} to {hours[(currentHour + 1) % 24]} on {weekdays[currentWeekday]}s - {currentValue:,} contributions\n"
    301             embed.set_image(url="attachment://image.png")
    302 
    303         # Send embed
    304         try:
    305             await ctx.send(file=file, embed=embed)
    306         except:
    307             await ctx.send(embed=embed)
    308 
    309         # Remove temp file
    310         try:
    311             os.remove(tmp.name)
    312         except Exception as e:
    313             self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e)
    314 
    315 
    316 
    317     @commands.command(aliases=["l"])
    318     async def leaderboard(self, ctx, user=None):
    319         """
    320         Shows the countdown leaderboard
    321         """
    322 
    323         with self.db_connection.cursor() as cur:
    324             # Get countdown channel
    325             countdown = getContextCountdown(cur, ctx)
    326             if not countdown:
    327                 raise CountdownNotFound()
    328 
    329             # Create embed
    330             embed=discord.Embed(title=":trophy: Countdown Leaderboard", color=COLORS["embed"])
    331 
    332             # Get user
    333             if (user == None):
    334                 userID = None
    335             else:
    336                 userID = await getContributor(self.bot, countdown, user)
    337 
    338             # Get leaderboard
    339             cur.execute("SELECT * FROM leaderboardData(%s, %s);",
    340                 (countdown, userID))
    341             data = cur.fetchall()
    342 
    343             if not data:
    344                 raise CommandError("The countdown doesn't have enough messages yet")
    345 
    346             if (user is None):
    347                 # Add description
    348                 embed.description = f"**Countdown Channel:** <#{countdown}>"
    349 
    350                 # Add leaderboard
    351                 ranks = ""
    352                 points = ""
    353                 users = ""
    354                 for row in data[:20]:
    355                     ranks += f"{row['ranking']:,}\n"
    356                     points += f"{row['total']:,}\n"
    357                     users += f"<@{row['userid']}>\n"
    358                 embed.add_field(name="Rank",value=ranks, inline=True)
    359                 embed.add_field(name="Points",value=points, inline=True)
    360                 embed.add_field(name="User",value=users, inline=True)
    361 
    362                 # Add leaderboard rules
    363                 rules = ""
    364                 values = ""
    365                 for rule in POINT_RULES:
    366                     rules += f"{POINT_RULES[rule][0]}\n"
    367                     values += f"{POINT_RULES[rule][1]} points\n"
    368                 embed.add_field(name="Rules", value="Only 1 rule is applied towards each number", inline=False)
    369                 embed.add_field(name="Numbers", value=rules, inline=True)
    370                 embed.add_field(name="Points", value=values, inline=True)
    371             else:
    372                 # Add description
    373                 embed.description = f"**Countdown Channel:** <#{countdown}>\n\n"
    374                 embed.description += f"**User:** <@{data[0]['userid']}>\n"
    375                 embed.description += f"**Rank:** #{data[0]['ranking']:,}\n"
    376                 embed.description += f"**Total Points:** {data[0]['total']:,}\n"
    377                 embed.description += f"**Total Contributions:** {data[0]['contributions']:,} *({round(data[0]['percentage'])}%)*\n"
    378 
    379                 # Add points breakdown
    380                 rules = ""
    381                 points = ""
    382                 percentage = ""
    383                 for rule in POINT_RULES:
    384                     rules += f"{POINT_RULES[rule][0]}\n"
    385                     points += f"{data[0][rule] * POINT_RULES[rule][1]:,} *({data[0][rule]:,})*\n"
    386                     if (data[0]['total'] > 0):
    387                         percentage += f"{round(data[0][rule] * POINT_RULES[rule][1] / data[0]['total'] * 100, 1)}%\n"
    388                     else:
    389                         percentage += "0%\n"
    390                 embed.add_field(name="Category", value=rules, inline=True)
    391                 embed.add_field(name="Points", value=points, inline=True)
    392                 embed.add_field(name="Percentage", value=percentage, inline=True)
    393 
    394         # Send embed
    395         await ctx.send(embed=embed)
    396 
    397 
    398 
    399     @commands.command(aliases=["p"])
    400     async def progress(self, ctx):
    401         """
    402         Shows information about countdown progress
    403         """
    404 
    405         with self.db_connection.cursor() as cur:
    406             # Get countdown channel
    407             countdown = getContextCountdown(cur, ctx)
    408             if not countdown:
    409                 raise CountdownNotFound()
    410 
    411             # Create temp file
    412             tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
    413             tmp.close()
    414 
    415             # Create embed
    416             embed=discord.Embed(title=":chart_with_downwards_trend: Countdown Progress", color=COLORS["embed"])
    417 
    418             # Get progress stats
    419             cur.execute("SELECT * FROM progressData(%s);", (countdown,))
    420             data = cur.fetchall()
    421             cur.execute("CALL progressStats(%s,null,null,null,null,null,null,null,null,null,null,null,null);", (countdown,))
    422             stats = cur.fetchone()
    423 
    424             if not data:
    425                 raise CommandError("The countdown doesn't have enough messages yet")
    426 
    427             # Create figure
    428             fig, ax = plt.subplots()
    429             ax.set_xlabel("Time")
    430             ax.set_ylabel("Progress")
    431             fig.autofmt_xdate()
    432 
    433             # Add data to graph
    434             x = [data[0]["_timestamp"]] + [x["_timestamp"] for x in data]
    435             y = [0] + [x["progress"] for x in data]
    436             ax.plot(x, y)
    437 
    438             # Save graph
    439             fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2)
    440             file = discord.File(tmp.name, filename="image.png")
    441 
    442             # Calculate embed data
    443             longestBreakDuration = timedelta(days=stats["longestbreak"].days, seconds=stats["longestbreak"].seconds)
    444             longestBreakStart = stats["longestbreakstart"].date()
    445             longestBreakEnd = stats["longestbreakend"].date()
    446 
    447             # Add content to embed
    448             embed.description = f"**Countdown Channel:** <#{countdown}>\n\n"
    449             embed.description += f"**Progress:** {stats['progress']:,} / {stats['total']:,} ({stats['percentage']:.1f}%)\n"
    450             embed.description += f"**Average Progress per Day:** {stats['rate']:,.0f}\n"
    451             embed.description += f"**Longest Break:** {longestBreakDuration} ({stats['longestbreakstart'].date()} to {stats['longestbreakend'].date()})\n"
    452             embed.description += f"**Start Date:** {stats['starttime'].date()} ({stats['startage'].days:,} days ago)\n"
    453             if stats['endage'] > timedelta(seconds=0):
    454                 embed.description += f"**End Date:** {stats['endtime'].date()} ({stats['endage'].days:,} days ago)\n"
    455             else:
    456                 embed.description += f"**Estimated End Date:** {stats['endtime'].date()} ({(-1 * stats['endage']).days:,} days from now)\n"
    457             embed.set_image(url="attachment://image.png")
    458 
    459         # Send embed
    460         try:
    461             await ctx.send(file=file, embed=embed)
    462         except:
    463             await ctx.send(embed=embed)
    464 
    465         # Remove temp file
    466         try:
    467             os.remove(tmp.name)
    468         except Exception as e:
    469             self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e)
    470 
    471 
    472 
    473     @commands.command(aliases=["s"])
    474     async def speed(self, ctx, period="24"):
    475         """
    476         Shows information about countdown speed
    477         """
    478 
    479         with self.db_connection.cursor() as cur:
    480             # Get countdown channel
    481             countdown = getContextCountdown(cur, ctx)
    482             if not countdown:
    483                 raise CountdownNotFound()
    484 
    485             # Create temp file
    486             tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
    487             tmp.close()
    488 
    489             # Create embed
    490             embed=discord.Embed(title=":stopwatch: Countdown Speed", color=COLORS["embed"])
    491 
    492             # Parse period
    493             try:
    494                 period = int(period)
    495             except ValueError:
    496                 raise CommandError(f"Invalid number: `{period}`")
    497 
    498             # Get data
    499             cur.execute("SELECT * FROM speedData(%s, %s);", (countdown, period))
    500             data = cur.fetchall()
    501 
    502             if not data:
    503                 raise CommandError("The countdown doesn't have enough messages yet")
    504 
    505             # Create figure
    506             fig, ax = plt.subplots()
    507             ax.set_xlabel("Time")
    508             ax.set_ylabel("Progress per Period")
    509             fig.autofmt_xdate()
    510 
    511             # Add data to graph
    512             period = timedelta(hours=period)
    513             for row in data:
    514                 ax.bar(row["periodstart"], row["messages"], width=period, align="edge", color="#1f77b4")
    515 
    516             # Save graph
    517             fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2)
    518             file = discord.File(tmp.name, filename="image.png")
    519 
    520             # Calculate embed data
    521             maxSpeed = max([x["messages"] for x in data])
    522             avgSpeed = round(sum([x["messages"] for x in data]) / len(data))
    523             curSpeed = data[-1]["messages"]
    524             curPeriod = data[-1]["periodstart"]
    525 
    526             # Add content to embed
    527             embed.description = f"**Countdown Channel:** <#{countdown}>\n\n"
    528             embed.description += f"**Period Size:** {period}\n"
    529             embed.description += f"**Average Progress per Period:** {avgSpeed:,}\n"
    530             embed.description += f"**Record Progress per Period:** {maxSpeed:,}\n"
    531             embed.description += f"**Last Period Start:** {curPeriod}\n"
    532             embed.description += f"**Progress during Last Period:** {curSpeed:,}\n"
    533             embed.set_image(url="attachment://image.png")
    534 
    535         # Send embed
    536         try:
    537             await ctx.send(file=file, embed=embed)
    538         except:
    539             await ctx.send(embed=embed)
    540 
    541         # Remove temp file
    542         try:
    543             os.remove(tmp.name)
    544         except Exception as e:
    545             self.bot.logger.error(f"Unable to delete temp file {tmp.name}", exc_info=e)