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)