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 73eefeafdc839fb654e8deebcdd988390ff48d6f
parent 97c3b9887c7a71bfdfa9bf1c429ec2382dcb1d85
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sat, 27 Apr 2024 15:37:48 -0700

Merge pull request #3 from ashermorgan/postgresql

Migrate to PostgreSQL
Diffstat:
MREADME.md | 59++++++++++++++++++++++++++++++++---------------------------
Mcountdown_bot/__main__.py | 6+++---
Mcountdown_bot/analyticsCog.py | 312++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mcountdown_bot/bot.py | 26++++++++++++--------------
Mcountdown_bot/botUtilities.py | 266+++++++++++++++++++++++++++++--------------------------------------------------
Dcountdown_bot/models.py | 570-------------------------------------------------------------------------------
Mcountdown_bot/utilitiesCog.py | 142+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Amodels/analytics.sql | 402+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodels/tables.sql | 46++++++++++++++++++++++++++++++++++++++++++++++
Amodels/utilities.sql | 317+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mrequirements.txt | 2+-
11 files changed, 1158 insertions(+), 990 deletions(-)

diff --git a/README.md b/README.md @@ -4,30 +4,35 @@ A Discord bot that facilitates countdowns and generates detailed countdown analy ## Setup -1. Install the Python dependencies - ``` - pip install -r requirements.txt - ``` - -2. Go to the [Discord Developer Portal](https://discord.com/developers/) and create an application and a bot - -3. Create `.env` file and add settings: - ``` - TOKEN=... - PREFIX=! - DATABASE=sqlite:///data.sqlite3 - LOG_FILE=log.txt - LOG_LEVEL=INFO - ``` - -4. Run the bot - ``` - python -m countdown_bot - ``` - -5. Add the bot to your server - ``` - https://discordapp.com/oauth2/authorize?client_id=BOT_ID_HERE&scope=bot&permissions=101440 - ``` - -6. Send `!help` to the bot get a list of commands and a description of the bot's behavior +Install the Python dependencies +``` +pip install -r requirements.txt +``` + +Go to the [Discord Developer Portal](https://discord.com/developers/) and create an application and a bot + +Create `.env` file and add settings: +``` +TOKEN=... +PREFIX=! +DATABASE=postgresql://... +LOG_FILE=log.txt +LOG_LEVEL=INFO +``` + +Initialize the PostgreSQL database +``` +psql 'postgresql://...' -f models/tables.sql -f models/utilities.sql -f models/analytics.sql +``` + +Run the bot +``` +python -m countdown_bot +``` + +Add the bot to your server +``` +https://discordapp.com/oauth2/authorize?client_id=BOT_ID_HERE&scope=bot&permissions=101440 +``` + +Send `!help` to the bot get a list of commands and a description of the bot's behavior diff --git a/countdown_bot/__main__.py b/countdown_bot/__main__.py @@ -2,10 +2,10 @@ from dotenv import load_dotenv import logging import os +import psycopg # Import modules from .bot import CountdownBot -from .models import getSessionMaker # Load settings load_dotenv() @@ -20,8 +20,8 @@ logging.basicConfig( ) # Connect to database -databaseSessionMaker = getSessionMaker(os.environ.get("DATABASE")) +db_connection = psycopg.connect(os.environ.get("DATABASE"), row_factory=psycopg.rows.dict_row) # Run bot -bot = CountdownBot(databaseSessionMaker, [os.environ.get("PREFIX", "!")]) +bot = CountdownBot(db_connection, [os.environ.get("PREFIX", "!")]) bot.run(os.environ.get("TOKEN")) diff --git a/countdown_bot/analyticsCog.py b/countdown_bot/analyticsCog.py @@ -10,15 +10,14 @@ import re import tempfile # Import modules -from .botUtilities import COLORS, getContextCountdown, getUsername, getContributor, CommandError -from .models import POINT_RULES +from .botUtilities import COLORS, POINT_RULES, CommandError, CountdownNotFound, getUsername, getContributor, getContextCountdown class Analytics(commands.Cog): - def __init__(self, bot, databaseSessionMaker): + def __init__(self, bot, db_connection): self.bot = bot - self.databaseSessionMaker = databaseSessionMaker + self.db_connection = db_connection @@ -45,9 +44,11 @@ class Analytics(commands.Cog): Shows information about countdown contributors """ - with self.databaseSessionMaker() as session: + with self.db_connection.cursor() as cur: # Get countdown channel - countdown = getContextCountdown(session, ctx) + countdown = getContextCountdown(cur, ctx) + if not countdown: + raise CountdownNotFound() # Create temp file tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") @@ -65,14 +66,20 @@ class Analytics(commands.Cog): ax.yaxis.set_major_formatter(PercentFormatter()) # Get stats - contributors = countdown.historicalContributors() + cur.execute("SELECT * FROM contributorData(%s);", (countdown,)) + contributors = [x["userid"] for x in cur.fetchall()] + cur.execute("SELECT * FROM historicalContributorData(%s);", (countdown,)) + data = cur.fetchall() + + if not data: + raise CommandError("The countdown doesn't have enough messages yet") # Plot data and add legend - for author in list(contributors.keys())[:min(len(contributors), 15)]: + for author in contributors[:15]: # Top 15 contributors get included in the legend - ax.plot([x["progress"] for x in contributors[author]], [x["percentage"] * 100 for x in contributors[author]], label=await getUsername(self.bot, author)) - for author in list(contributors.keys())[15:max(len(contributors), 15)]: - ax.plot([x["progress"] for x in contributors[author]], [x["percentage"] * 100 for x in contributors[author]]) + ax.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)) + for author in contributors[15:]: + ax.plot([x["progress"] for x in data if x["userid"] == author], [x["percentage"] for x in data if x["userid"] == author]) ax.legend(bbox_to_anchor=(1,1.025), loc="upper left") # Save graph @@ -80,39 +87,42 @@ class Analytics(commands.Cog): file = discord.File(tmp.name, filename="image.png") # Add content to embed - embed.description = f"**Countdown Channel:** <#{countdown.id}>" + embed.description = f"**Countdown Channel:** <#{countdown}>" embed.set_image(url="attachment://image.png") elif (option == ""): # Create figure fig, ax = plt.subplots() # Get stats - contributors = countdown.contributors() + cur.execute("SELECT * FROM contributorData(%s);", (countdown,)) + data = cur.fetchall() + + if not data: + raise CommandError("The countdown doesn't have enough messages yet") # 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) + pieData = ax.pie([x["contributions"] for x in data], 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") + ax.legend(pieData[0], [await getUsername(self.bot, x["userid"]) for x in + data[: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.description = f"**Countdown Channel:** <#{countdown}>" + ranksColumn = "" + usersColumn = "" + contributionsColumn = "" + for i in range(0, min(len(data), 20)): + ranksColumn += f"{i+1:,}\n" + contributionsColumn += f"{data[i]['contributions']:,} *({data[i]['percentage']:.1f}%)*\n" + usersColumn += f"<@{data[i]['userid']}>\n" + embed.add_field(name="Rank", value=ranksColumn, inline=True) + embed.add_field(name="User", value=usersColumn, inline=True) + embed.add_field(name="Contributions", value=contributionsColumn, inline=True) embed.set_image(url="attachment://image.png") else: raise CommandError(f"Unrecognized option: `{option}`") @@ -132,14 +142,16 @@ class Analytics(commands.Cog): @commands.command(aliases=["e"]) - async def eta(self, ctx, period="24.0"): + async def eta(self, ctx): """ Shows information about the estimated completion date """ - with self.databaseSessionMaker() as session: + with self.db_connection.cursor() as cur: # Get countdown channel - countdown = getContextCountdown(session, ctx) + countdown = getContextCountdown(cur, ctx) + if not countdown: + raise CountdownNotFound() # Create temp file tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") @@ -148,18 +160,14 @@ class Analytics(commands.Cog): # Create embed embed=discord.Embed(title=":calendar: Countdown Estimated Completion Date", color=COLORS["embed"]) - # Parse period - try: - period = float(period) - except ValueError: - raise CommandError(f"Invalid number: `{period}`") - - # Make sure period is valid - if (period < 0.01): - raise CommandError("The period cannot be less than 0.01 hours") - # Get stats - eta = countdown.eta(timedelta(hours=period)) + cur.execute("CALL progressStats(%s,null,null,null,null,null,null,null,null,null,null,null,null);", (countdown,)) + stats = cur.fetchone() + cur.execute("SELECT * FROM etaData(%s);", (countdown,)) + data = cur.fetchall() + + if not data: + raise CommandError("The countdown doesn't have enough messages yet") # Create figure fig, ax = plt.subplots() @@ -167,10 +175,10 @@ class Analytics(commands.Cog): fig.autofmt_xdate() # Add ETA data to graph - ax.plot(eta[0], eta[1], "C0", label="Estimated Completion Date") + ax.plot([x["_timestamp"] for x in data], [x["eta"] for x in data], "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") + ax.plot([data[0]["_timestamp"], data[-1]["_timestamp"]], [data[0]["_timestamp"], data[-1]["_timestamp"]], "--C1", label="Current Date") # Add legend ax.legend() @@ -180,21 +188,19 @@ class Analytics(commands.Cog): 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() + maxEta = max([x["eta"] for x in data]) + maxDate = [x["_timestamp"] for x in data if x["eta"] == maxEta][0] + minEta = min([x["eta"] for x in data]) + minDate = [x["_timestamp"] for x in data if x["eta"] == minEta][0] # Add content to embed - embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" + embed.description = f"**Countdown Channel:** <#{countdown}>\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" + if stats['endage'] > timedelta(seconds=0): + embed.description += f"**Actual Completion Date:** {stats['endtime'].date()} ({stats['endage'].days:,} days ago)\n" else: - embed.description += f"**Current Estimate:** {end.date()} ({endDiff.days:,} days from now)\n" + embed.description += f"**Current Estimate:** {stats['endtime'].date()} ({(-1 * stats['endage']).days:,} days from now)\n" embed.set_image(url="attachment://image.png") # Send embed @@ -217,9 +223,11 @@ class Analytics(commands.Cog): Shows a heatmap of when countdown messages are sent """ - with self.databaseSessionMaker() as session: + with self.db_connection.cursor() as cur: # Get countdown channel - countdown = getContextCountdown(session, ctx) + countdown = getContextCountdown(cur, ctx) + if not countdown: + raise CountdownNotFound() # Create temp file tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") @@ -234,8 +242,22 @@ class Analytics(commands.Cog): else: userID = await getContributor(self.bot, countdown, user) - # Get heatmap matrix - heatmapMatrix = countdown.heatmap(userID) + # Get heatmap data + cur.execute("CALL heatmapStats(%s, null, null);", + (countdown,)) + stats = cur.fetchone() + cur.execute("SELECT * FROM heatmapData(%s, %s);", + (countdown, userID)) + data = cur.fetchall() + + if not data: + print(countdown, userID, data) + raise CommandError("The countdown doesn't have enough messages yet") + + # Create heatmap matrix + matrix = [[0 for i in range(24)] for j in range(7)] + for row in data: + matrix[int(row["dow"])][int(row["hour"])] = row["messages"] # Define hour and weekday names hours = ["12 AM", "1 AM", "2 AM", "3 AM", "4 AM", "5 AM", "6 AM", "7 AM", "8 AM", "9 AM", "10 AM", "11 AM", "12 PM", "1 PM", "2 PM", "3 PM", "4 PM", "5 PM", "6 PM", "7 PM", "8 PM", "9 PM", "10 PM", "11 PM"] @@ -253,7 +275,7 @@ class Analytics(commands.Cog): # Add data to graph cmap = plt.get_cmap("jet").copy() cmap.set_bad("gray") - cax = ax.matshow(np.ma.masked_equal(np.array(heatmapMatrix), 0), cmap=cmap, aspect="auto") + cax = ax.matshow(np.ma.masked_equal(np.array(matrix), 0), cmap=cmap, aspect="auto") fig.colorbar(cax) # Save graph @@ -261,17 +283,17 @@ class Analytics(commands.Cog): file = discord.File(tmp.name, filename="image.png") # Get embed data - total = np.sum(heatmapMatrix) + total = np.sum(matrix) averageValue = total / (24*7) - maxValue = np.max(heatmapMatrix) - maxWeekday = np.where(heatmapMatrix == maxValue)[0][0] - maxHour = np.where(heatmapMatrix == maxValue)[1][0] - currentWeekday = ((datetime.utcnow() + timedelta(hours=countdown.timezone)).weekday() + 1) % 7 - currentHour = (datetime.utcnow() + timedelta(hours=countdown.timezone)).hour - currentValue = heatmapMatrix[currentWeekday][currentHour] + maxValue = np.max(matrix) + maxWeekday = np.where(matrix == maxValue)[0][0] + maxHour = np.where(matrix == maxValue)[1][0] + currentWeekday = int(stats['curdow']) + currentHour = int(stats['curhour']) + currentValue = matrix[currentWeekday][currentHour] # Add content to embed - embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" + embed.description = f"**Countdown Channel:** <#{countdown}>\n\n" if (userID): embed.description += f"**User:** <@{userID}>\n" embed.description += f"**Total Contributions:** {total:,}\n" embed.description += f"**Average Contributions per Zone:** {round(averageValue):,}\n" @@ -299,29 +321,41 @@ class Analytics(commands.Cog): Shows the countdown leaderboard """ - with self.databaseSessionMaker() as session: + with self.db_connection.cursor() as cur: # Get countdown channel - countdown = getContextCountdown(session, ctx) - - # Get leaderboard - leaderboard = countdown.leaderboard() + countdown = getContextCountdown(cur, ctx) + if not countdown: + raise CountdownNotFound() # Create embed embed=discord.Embed(title=":trophy: Countdown Leaderboard", color=COLORS["embed"]) - # Make sure the countdown has started + # Get user + if (user == None): + userID = None + else: + userID = await getContributor(self.bot, countdown, user) + + # Get leaderboard + cur.execute("SELECT * FROM leaderboardData(%s, %s);", + (countdown, userID)) + data = cur.fetchall() + + if not data: + raise CommandError("The countdown doesn't have enough messages yet") + if (user is None): # Add description - embed.description = f"**Countdown Channel:** <#{countdown.id}>" + embed.description = f"**Countdown Channel:** <#{countdown}>" # 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" + for row in data[:20]: + ranks += f"{row['ranking']:,}\n" + points += f"{row['total']:,}\n" + users += f"<@{row['userid']}>\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) @@ -330,34 +364,28 @@ class Analytics(commands.Cog): rules = "" values = "" for rule in POINT_RULES: - rules += f"{rule}\n" - values += f"{POINT_RULES[rule]} points\n" + rules += f"{POINT_RULES[rule][0]}\n" + values += f"{POINT_RULES[rule][1]} points\n" embed.add_field(name="Rules", value="Only 1 rule is applied towards each number", inline=False) embed.add_field(name="Numbers", value=rules, inline=True) embed.add_field(name="Points", value=values, inline=True) else: - # Get user rank - if (re.match("^\d+$", user) and int(user) > 0 and int(user) <= len(leaderboard)): - rank = int(user) - 1 - else: - rank = [x["author"] for x in leaderboard].index(await getContributor(self.bot, countdown, user)) - # Add description - embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" - embed.description += f"**User:** <@{leaderboard[rank]['author']}>\n" - embed.description += f"**Rank:** #{rank + 1:,}\n" - embed.description += f"**Total Points:** {leaderboard[rank]['points']:,}\n" - embed.description += f"**Total Contributions:** {leaderboard[rank]['contributions']:,} *({round(leaderboard[rank]['contributions'] / len(countdown.messages) * 100, 1)}%)*\n" + embed.description = f"**Countdown Channel:** <#{countdown}>\n\n" + embed.description += f"**User:** <@{data[0]['userid']}>\n" + embed.description += f"**Rank:** #{data[0]['ranking']:,}\n" + embed.description += f"**Total Points:** {data[0]['total']:,}\n" + embed.description += f"**Total Contributions:** {data[0]['contributions']:,} *({round(data[0]['percentage'])}%)*\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" + for rule in POINT_RULES: + rules += f"{POINT_RULES[rule][0]}\n" + points += f"{data[0][rule] * POINT_RULES[rule][1]:,} *({data[0][rule]:,})*\n" + if (data[0]['total'] > 0): + percentage += f"{round(data[0][rule] * POINT_RULES[rule][1] / data[0]['total'] * 100, 1)}%\n" else: percentage += "0%\n" embed.add_field(name="Category", value=rules, inline=True) @@ -375,9 +403,11 @@ class Analytics(commands.Cog): Shows information about countdown progress """ - with self.databaseSessionMaker() as session: + with self.db_connection.cursor() as cur: # Get countdown channel - countdown = getContextCountdown(session, ctx) + countdown = getContextCountdown(cur, ctx) + if not countdown: + raise CountdownNotFound() # Create temp file tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") @@ -387,8 +417,13 @@ class Analytics(commands.Cog): embed=discord.Embed(title=":chart_with_downwards_trend: Countdown Progress", color=COLORS["embed"]) # Get progress stats - stats = countdown.progress() - breakStats = countdown.longestBreak() + cur.execute("SELECT * FROM progressData(%s);", (countdown,)) + data = cur.fetchall() + cur.execute("CALL progressStats(%s,null,null,null,null,null,null,null,null,null,null,null,null);", (countdown,)) + stats = cur.fetchone() + + if not data: + raise CommandError("The countdown doesn't have enough messages yet") # Create figure fig, ax = plt.subplots() @@ -397,8 +432,8 @@ class Analytics(commands.Cog): 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"]] + x = [data[0]["_timestamp"]] + [x["_timestamp"] for x in data] + y = [0] + [x["progress"] for x in data] ax.plot(x, y) # Save graph @@ -406,24 +441,20 @@ class Analytics(commands.Cog): file = discord.File(tmp.name, filename="image.png") # Calculate embed data - longestBreakDuration = timedelta(days=breakStats['duration'].days, seconds=breakStats['duration'].seconds) - longestBreakStart = breakStats['start'].date() - longestBreakEnd = breakStats['end'].date() - start = (stats["start"] + timedelta(hours=countdown.timezone)).date() - startDiff = (datetime.utcnow() - stats["start"]).days - end = (stats["eta"] + timedelta(hours=countdown.timezone)).date() - endDiff = stats["eta"] - datetime.utcnow() + longestBreakDuration = timedelta(days=stats["longestbreak"].days, seconds=stats["longestbreak"].seconds) + longestBreakStart = stats["longestbreakstart"].date() + longestBreakEnd = stats["longestbreakend"].date() # Add content to embed - embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" - embed.description += f"**Progress:** {stats['total'] - stats['current']:,} / {stats['total']:,} ({round(stats['percentage'], 1)}%)\n" - embed.description += f"**Average Progress per Day:** {round(stats['rate']):,}\n" - embed.description += f"**Longest Break:** {longestBreakDuration} ({longestBreakStart} to {longestBreakEnd})\n" - embed.description += f"**Start Date:** {start} ({startDiff:,} days ago)\n" - if endDiff < timedelta(seconds=0): - embed.description += f"**End Date:** {end} ({(-1 * endDiff).days:,} days ago)\n" + embed.description = f"**Countdown Channel:** <#{countdown}>\n\n" + embed.description += f"**Progress:** {stats['progress']:,} / {stats['total']:,} ({stats['percentage']:.1f}%)\n" + embed.description += f"**Average Progress per Day:** {stats['rate']:,.0f}\n" + embed.description += f"**Longest Break:** {longestBreakDuration} ({stats['longestbreakstart'].date()} to {stats['longestbreakend'].date()})\n" + embed.description += f"**Start Date:** {stats['starttime'].date()} ({stats['startage'].days:,} days ago)\n" + if stats['endage'] > timedelta(seconds=0): + embed.description += f"**End Date:** {stats['endtime'].date()} ({stats['endage'].days:,} days ago)\n" else: - embed.description += f"**Estimated End Date:** {end} ({endDiff.days:,} days from now)\n" + embed.description += f"**Estimated End Date:** {stats['endtime'].date()} ({(-1 * stats['endage']).days:,} days from now)\n" embed.set_image(url="attachment://image.png") # Send embed @@ -441,14 +472,16 @@ class Analytics(commands.Cog): @commands.command(aliases=["s"]) - async def speed(self, ctx, period="24.0"): + async def speed(self, ctx, period="24"): """ Shows information about countdown speed """ - with self.databaseSessionMaker() as session: + with self.db_connection.cursor() as cur: # Get countdown channel - countdown = getContextCountdown(session, ctx) + countdown = getContextCountdown(cur, ctx) + if not countdown: + raise CountdownNotFound() # Create temp file tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") @@ -459,18 +492,16 @@ class Analytics(commands.Cog): # Parse period try: - period = float(period) + period = int(period) except ValueError: raise CommandError(f"Invalid number: `{period}`") - # Make sure period is valid - if (period < 0.01): - raise CommandError("The period cannot be less than 0.01 hours") + # Get data + cur.execute("SELECT * FROM speedData(%s, %s);", (countdown, period)) + data = cur.fetchall() - # Get stats - stats = countdown.progress() - period = timedelta(hours=period) - speed = countdown.speed(period) + if not data: + raise CommandError("The countdown doesn't have enough messages yet") # Create figure fig, ax = plt.subplots() @@ -479,24 +510,27 @@ class Analytics(commands.Cog): 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") + period = timedelta(hours=period) + for row in data: + ax.bar(row["periodstart"], row["messages"], 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") + # Calculate embed data + maxSpeed = max([x["messages"] for x in data]) + avgSpeed = round(sum([x["messages"] for x in data]) / len(data)) + curSpeed = data[-1]["messages"] + curPeriod = data[-1]["periodstart"] + # Add content to embed - embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" + embed.description = f"**Countdown Channel:** <#{countdown}>\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.description += f"**Average Progress per Period:** {avgSpeed:,}\n" + embed.description += f"**Record Progress per Period:** {maxSpeed:,}\n" + embed.description += f"**Last Period Start:** {curPeriod}\n" + embed.description += f"**Progress during Last Period:** {curSpeed:,}\n" embed.set_image(url="attachment://image.png") # Send embed diff --git a/countdown_bot/bot.py b/countdown_bot/bot.py @@ -6,15 +6,14 @@ import logging # Import modules from . import analyticsCog, utilitiesCog -from .botUtilities import addMessage, COLORS, CountdownNotFound, ContributorNotFound, CommandError, getCountdown, getPrefix -from .models import EmptyCountdownError +from .botUtilities import addMessage, COLORS, CountdownNotFound, ContributorNotFound, CommandError, getPrefix class CountdownBot(commands.Bot): - def __init__(self, databaseSessionMaker, prefixes): + def __init__(self, db_connection, prefixes): # Set properties - self.databaseSessionMaker = databaseSessionMaker + self.db_connection = db_connection self.prefixes = prefixes self.logger = logging.getLogger(__name__) @@ -23,13 +22,13 @@ class CountdownBot(commands.Bot): intents.message_content = True # Initialize bot - super().__init__(command_prefix=lambda bot, ctx: getPrefix(self.databaseSessionMaker, ctx, self.prefixes), intents=intents) + super().__init__(command_prefix=lambda bot, ctx: getPrefix(self.db_connection, ctx, self.prefixes), intents=intents) async def setup_hook(self): - await self.add_cog(analyticsCog.Analytics(self, self.databaseSessionMaker)) - await self.add_cog(utilitiesCog.Utilities(self, self.databaseSessionMaker)) + await self.add_cog(utilitiesCog.Utilities(self, self.db_connection)) + await self.add_cog(analyticsCog.Analytics(self, self.db_connection)) @@ -62,11 +61,9 @@ class CountdownBot(commands.Bot): await obj.channel.send(embed=embed) # Parse countdown message - with self.databaseSessionMaker() as session: - countdown = getCountdown(session, obj.channel.id) - if (countdown): - # Add message to countdown and commit changes - if (await addMessage(countdown, obj)): session.commit() + with self.db_connection.cursor() as cur: + if (await addMessage(cur, obj)): + self.db_connection.commit() # Run commands try: @@ -81,6 +78,9 @@ class CountdownBot(commands.Bot): async def on_command_error(self, ctx, error): + # Rollback database transaction + self.db_connection.rollback() + # Send error embed embed=discord.Embed(title=":warning: Error", description=str(error), color=COLORS["error"]) if (isinstance(error, commands.CommandNotFound)): @@ -89,8 +89,6 @@ class CountdownBot(commands.Bot): embed.description = f"Countdown not found" elif (isinstance(error.original, ContributorNotFound)): embed.description = f"Contributor not found: `{error.original.args[0]}`" - elif (isinstance(error.original, EmptyCountdownError)): - embed.description = f"The countdown is empty" elif (isinstance(error.original, CommandError)): embed.description = error.original.args[0] else: diff --git a/countdown_bot/botUtilities.py b/countdown_bot/botUtilities.py @@ -2,10 +2,6 @@ import discord import re -# Import modules -from .models import Countdown, Message, MessageIncorrectError, MessageNotAllowedError - - COLORS = { "error": 0xD52C42, @@ -27,6 +23,22 @@ class CountdownNotFound(Exception): +# The rules for awarding leaderboard points +POINT_RULES = { + "r1": ("First Number", 0), + "r2": ("1000s", 1000), + "r3": ("1001s", 500), + "r4": ("200s", 200), + "r5": ("201s", 100), + "r6": ("100s", 100), + "r7": ("101s", 50), + # "r8": ("Prime Numbers", 15), + "r8": ("Odd Numbers", 12), + "r9": ("Even Numbers", 10), +} + + + async def getUsername(bot, id): """ Get a username from a user ID @@ -49,29 +61,6 @@ async def getUsername(bot, id): -async def getNickname(bot, server, id): - """ - Get a user's nickname in a server - - Parameters - ---------- - bot : commands.Bot - The bot - server : int - The server ID - id : int - The user ID - - Returns - ------- - str - The nickname - """ - - return (await (bot.get_guild(server)).fetch_member(id)).nick or await getUsername(bot, id) - - - async def getContributor(bot, countdown, text): """ Get the ID of the countdown contributor refered to by a string @@ -96,170 +85,104 @@ async def getContributor(bot, countdown, text): 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 + if (re.match("^<@\d+>$", text)): + return int(text[2:-1]) raise ContributorNotFound(text) -def getCountdown(session, id): +def isCountdown(cur, id): """ - Get a countdown object + Determine whether a channel is a countdown Parameters ---------- - session : sqlalchemy.orm.Session - The database session to use + cur : psycopg.cursor + The database cursor id : int - The countdown id + The countdown ID Returns ------- - Countdown - The Countdown + bool + A boolean indicating whether the channel is a countdown """ - return session.query(Countdown).filter(Countdown.id == id).first() + cur.execute("CALL isCountdown(%s, null);", + (id,)) + return cur.fetchone()["result"] -def getContextCountdown(session, ctx): +def getContextCountdown(cur, ctx): """ Get the most relevant countdown to a certain context Parameters ---------- - session : sqlalchemy.orm.Session - The database session to use + cur : psycopg.cursor + The database cursor ctx : discord.ext.commands.Context The context Returns ------- - Countdown - The countdown - - Raises - ------ - CountdownNotFound - If a matching countdown cannot be found + countdownID + The countdown ID """ if (isinstance(ctx.channel, discord.channel.TextChannel)): - # Countdown channel - countdown = getCountdown(session, ctx.channel.id) - if (countdown): return countdown - - # Server with countdown channel: get first countdown in this server that use the current prefix - countdown = session.query(Countdown).filter(Countdown.server_id == ctx.channel.guild.id and ctx.prefix in [x.value for x in Countdown.prefixes]).first() - if (countdown): return countdown + # Channel inside a server + cur.execute("CALL getServerContextCountdown(%s, %s, %s, null);", + (ctx.channel.guild.id, ctx.channel.id, ctx.prefix)) + return cur.fetchone()["countdownid"] if (isinstance(ctx.channel, discord.channel.DMChannel)): - # DM with user who has contributed to a countdown: get the first countdown they ever contributed to - firstMessage = session.query(Message).filter(Message.author_id == ctx.author.id).order_by(Message.timestamp).first() - if (firstMessage): return firstMessage.countdown + # DM with a user + cur.execute("CALL getUserContextCountdown(%s, null);", + (ctx.author.id,)) + return cur.fetchone()["countdownid"] - raise CountdownNotFound() + return None -def getPrefix(databaseSessionMaker, ctx, default): +def getPrefix(conn, ctx, default): """ Get the bot prefix for a certain context Parameters ---------- - databaseSessionMaker : sqlalchemy.orm.sessionmaker - The database session maker + conn : psycopg.Connection + The database connection ctx : discord.ext.commands.Context The context default : list The default prefixes """ - with databaseSessionMaker() as session: - # Countdown channel - countdown = getCountdown(session, ctx.channel.id) - if (countdown and len(countdown.prefixes) > 0): - return [x.value.lower() for x in countdown.prefixes] - - # Server with countdown channels - if (isinstance(ctx.channel, discord.channel.TextChannel)): - serverCountdowns = session.query(Countdown).filter(Countdown.server_id == ctx.channel.guild.id).all() - # Get list of prefixes - prefixes = [] - for countdown in serverCountdowns: - prefixes += [x.value.lower() for x in countdown.prefixes] - if (len(prefixes) > 0): - return list(dict.fromkeys(prefixes)) - - # Return default prefixes - return [x.lower() for x in default] - - - -def parseMessage(message): - """ - Parses a countdown message from a Discord message - - Parameters - ---------- - message : discord.Message - The Discord message - - Returns - ------- - Message - """ - - return Message( - id = message.id, - countdown_id = message.channel.id, - author_id = message.author.id, - timestamp = message.created_at, - number = int(re.findall("^[0-9,]+", message.content)[0].replace(",","")), - ) + with conn.cursor() as cur: + cur.execute("SELECT * FROM getPrefixes(%s, %s);", + (ctx.channel.guild.id if ctx.channel.guild else None, ctx.channel.id)) + prefixes = cur.fetchall() + return [x["prefix"] for x in prefixes] if prefixes else default -async def addMessage(countdown, rawMessage): +async def addMessage(cur, message): """ Parse a message and add it to a countdown Notes ----- - If the message is invalid or incorrect, a reacted will be added accordingly + If the message is invalid or incorrect, a reaction will be added accordingly Parameters ---------- - countdown : Countdown - The countdown - rawMessage : discord.Message + cur : psycopg.cursor + The database cursor + message : discord.Message The Discord message object Returns @@ -268,32 +191,32 @@ async def addMessage(countdown, rawMessage): 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 + # Parse message number + match = re.search("^[0-9,]+", message.content) + if not match: return False + number = int(match[0].replace(",", "")) + + # Attempt to add result + cur.execute("CALL addMessage(%s,%s,%s,%s,%s,null,null,null);", ( + message.id, message.channel.id, message.author.id, number, + message.created_at + )) + result = cur.fetchone() + + # Process result + if result["result"] == 'badNumber': + await message.add_reaction("❌") + if result["result"] == 'badUser': + await message.add_reaction("⛔") + if result["pin"]: + await message.pin() + if result["reactions"]: + cur.execute("SELECT * FROM getReactions(%s, %s);", + (message.channel.id, number)) + for reaction in cur.fetchall(): + await message.add_reaction(reaction["value"]) + + return result["result"] == 'good' @@ -305,17 +228,24 @@ async def loadCountdown(bot, countdown): ---------- bot : commands.Bot The bot to load messages with - countdown : Countdown - The countdown to load messages for + cur : psycopg.cursor + The database cursor + countdown : int + The ID of the countdown to load messages for """ - # Clear countdown - countdown.messages = [] + with bot.db_connection.cursor() as cur: + # Clear countdown + cur.execute("CALL clearCountdown(%s);", (countdown,)) + + # Get Discord messages + messages = [message async for message in + bot.get_channel(countdown).history(limit=10100)] + messages.reverse() - # Get Discord messages - rawMessages = [message async for message in bot.get_channel(countdown.id).history(limit=10100)] - rawMessages.reverse() + # Add messages to countdown + for message in messages: + await addMessage(cur, message) - # Add messages to countdown - for rawMessage in rawMessages: - await addMessage(countdown, rawMessage) + # Commit changes + bot.db_connection.commit() diff --git a/countdown_bot/models.py b/countdown_bot/models.py @@ -1,570 +0,0 @@ -# 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 EmptyCountdownError(Exception): - """Raised when an action cannot be completed because the countdown is empty""" - pass - -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 - """ - - # Make sure countdown has started - if (len(self.messages) == 0): - raise EmptyCountdownError() - - # 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 historicalContributors(self): - """ - Get countdown contributor statistics over time - - Returns - ------- - dict - A dictionary of historical contributor statistics - """ - - # Make sure countdown has at least two messages - if (len(self.messages) == 0): - raise EmptyCountdownError() - - # Get contributors - contributors = self.contributors() - - # Get countdown total - total = self.messages[0].number - - # Initialize result dictionary - result = {} - for contributor in contributors: - result[contributor["author"]] = [{"progress":0, "percentage":0, "total":0}] - - # Populate result dictionary - for message in self.messages: - for author in result: - if (author == message.author_id): - result[author] += [{"progress":(total - message.number), "percentage":(result[author][-1]["total"] + 1)/(total - message.number + 1), "total":result[author][-1]["total"] + 1}] - else: - result[author] += [{"progress":(total - message.number), "percentage":(result[author][-1]["total"] + 0)/(total - message.number + 1), "total":result[author][-1]["total"] + 0}] - - # Return result - return result - - 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) == 0): - raise EmptyCountdownError() - - # 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 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 - """ - - # Make sure countdown has started - if (len(self.messages) == 0): - raise EmptyCountdownError() - - # 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 - - Returns - ------- - list - The leaderboard - """ - - # Make sure countdown has started - if (len(self.messages) == 0): - raise EmptyCountdownError() - - # 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 longestBreak(self): - """ - Get the longest countdown break - - Returns - ------- - dict - A dictionary containing information about the longest countdown break - """ - - # Make sure countdown has started - if (len(self.messages) == 0): - raise EmptyCountdownError() - - # Calculate longest break - breaks = [] - for i in range(0, len(self.messages) - 1): - breaks += [self.messages[i+1].timestamp - self.messages[i].timestamp] - if (self.messages[-1].number == 0): - breaks += [timedelta(seconds=0)] - else: - breaks += [datetime.utcnow() - self.messages[-1].timestamp] - longestBreak = max(breaks) - index = breaks.index(longestBreak) - start = self.messages[index].timestamp + timedelta(hours=self.timezone) - if (index == len(self.messages) - 1): - end = datetime.utcnow() + timedelta(hours=self.timezone) - else: - end = self.messages[index + 1].timestamp + timedelta(hours=self.timezone) - - # Return statistics - return { - 'duration': longestBreak, - 'start': start, - 'end': end, - } - - def progress(self): - """ - Get countdown progress statistics - - Returns - ------- - dict - A dictionary containing countdown progress statistics - """ - - # Make sure countdown has started - if (len(self.messages) == 0): - raise EmptyCountdownError() - - # Get basic statistics - total = self.messages[0].number - current = self.messages[-1].number - percentage = (total - current) / total * 100 - start = self.messages[0].timestamp - - # 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: - # Only 1 message in the countdown, can't compute real rate or eta - rate = 0 - eta = datetime.utcnow() + timedelta(days=1) - - # 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 - """ - - # Make sure countdown has started - if (len(self.messages) == 0): - raise EmptyCountdownError() - - # 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/countdown_bot/utilitiesCog.py b/countdown_bot/utilitiesCog.py @@ -3,15 +3,14 @@ import discord from discord.ext import commands # Import modules -from .botUtilities import COLORS, CommandError, getContextCountdown, getCountdown, loadCountdown -from .models import Countdown, Prefix, Reaction +from .botUtilities import COLORS, CommandError, CountdownNotFound, isCountdown, loadCountdown, getContextCountdown class Utilities(commands.Cog): - def __init__(self, bot, databaseSessionMaker): + def __init__(self, bot, db_connection): self.bot = bot - self.databaseSessionMaker = databaseSessionMaker + self.db_connection = db_connection self.bot.remove_command("help") @@ -22,9 +21,9 @@ class Utilities(commands.Cog): Turns a channel into a countdown """ - with self.databaseSessionMaker() as session: + with self.db_connection.cursor() as cur: # Check if channel is already a countdown - if (getCountdown(session, ctx.channel.id)): + if (isCountdown(cur, ctx.channel.id)): raise CommandError("This channel is already a countdown") # Check if channel is a DM @@ -36,14 +35,8 @@ class Utilities(commands.Cog): raise CommandError("You must be an administrator to turn a channel into a countdown") # Create countdown - countdown = Countdown( - id = ctx.channel.id, - server_id = ctx.channel.guild.id, - timezone = 0, - prefixes = [Prefix(countdown_id=ctx.channel.id, value=x) for x in self.bot.prefixes], - reactions = [], - messages = [], - ) + cur.execute("CALL createCountdown(%s, %s, %s);", + (ctx.channel.id, ctx.channel.guild.id, self.bot.prefixes[0])) # Send initial response self.bot.logger.info(f"Activated {self.bot.get_channel(ctx.channel.id)} (ID {ctx.channel.id}) as a countdown") @@ -51,9 +44,8 @@ class Utilities(commands.Cog): msg = await ctx.send(embed=embed) # Load countdown - await loadCountdown(self.bot, countdown) - session.add(countdown) - session.commit() + await loadCountdown(self.bot, ctx.channel.id) + self.db_connection.commit() # Send final response embed = discord.Embed(title=":white_check_mark: Countdown Activated", description="This channel is now a countdown\nYou may start counting!", color=COLORS["embed"]) @@ -74,21 +66,37 @@ class Utilities(commands.Cog): if (not isinstance(ctx.channel, discord.channel.TextChannel)): raise CommandError("This command must be run in a countdown channel or a server with a countdown channel") - with self.databaseSessionMaker() as session: + with self.db_connection.cursor() as cur: # Get countdown channel - countdown = getContextCountdown(session, ctx) + countdown = getContextCountdown(cur, ctx) + + if not countdown: + raise CountdownNotFound() # 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"**Countdown Channel:** <#{countdown}>\n" + + cur.execute("SELECT * from getprefixes(null, %s);", (countdown,)) + prefixes = [x["prefix"] for x in cur.fetchall()] + embed.description += f"**Command Prefixes:** `{'`, `'.join(prefixes)}`\n" + + cur.execute("CALL getTimezone(%s, null);", (countdown,)) + timezone = cur.fetchone()["_timezone"] + if (timezone >= 0): + embed.description += f"**Countdown Timezone:** UTC+{timezone:.2f}\n" + else: + embed.description += f"**Countdown Timezone:** UTC-{abs(timezone):.2f}\n" + + cur.execute("SELECT * FROM getReactions(%s, NULL);", (countdown,)) + reactions = cur.fetchall() + if (len(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" + for number in reversed(list(set([x["number"] for x in reactions]))): + embed.description += f"**-** #{number}: {', '.join([x["value"] for x in reactions if x["number"] == number])}\n" + embed.description += f"\nUse `{ctx.prefix}help config` to view more information about settings\n" embed.description += f"Use `{ctx.prefix}config <key> <value>` to modify settings\n" elif (not ctx.message.author.guild_permissions.administrator): @@ -97,33 +105,36 @@ class Utilities(commands.Cog): raise CommandError("Please provide a value for the setting") elif (key in ["tz", "timezone"]): try: - countdown.timezone = float(args[0]) + timezone = float(args[0]) except: raise CommandError(f"Invalid timezone: `{args[0]}`") else: - embed.description = f"Timezone set to {countdown.getTimezone()}" + cur.execute("CALL setTimezone(%s, %s);", (countdown, timezone)) + if (timezone >= 0): + embed.description = f"Timezone set to UTC+{timezone:.2f}\n" + else: + embed.description = f"Timezone set to UTC-{abs(timezone):.2f}\n" elif (key in ["prefix", "prefixes"]): - countdown.prefixes = [Prefix(countdown_id=ctx.channel.id, value=x) for x in args] + cur.execute("CALL setPrefixes(%s, %s);", (countdown, list(args))) embed.description = f"Prefixes updated" elif (key in ["react"]): try: number = int(args[0]) - if (number < 0): - raise CommandError("Number must be greater than zero") - elif (len(args) == 1): - countdown.reactions = [x for x in countdown.reactions if x.number != number] - embed.description = f"Removed reactions for #{number}" - else: - countdown.reactions = [x for x in countdown.reactions if x.number != number] - countdown.reactions += [Reaction(countdown_id=countdown.id, number=number, value=x) for x in args[1:]] - embed.description = f"Updated reactions for #{number}" except: raise CommandError(f"Invalid number: `{args[0]}`") + if (number < 0): + raise CommandError("Number must be greater than zero") + cur.execute("CALL setReactions(%s, %s, %s);", + (countdown, number, list(args[1:]))) + if (len(args) == 1): + embed.description = f"Removed reactions for #{number}" + else: + embed.description = f"Updated reactions for #{number}" else: raise CommandError(f"Setting not found: `{key}`") # Save changes - session.commit() + self.db_connection.commit() # Send embed await ctx.send(embed=embed) @@ -136,10 +147,9 @@ class Utilities(commands.Cog): Deactivates a countdown channel """ - with self.databaseSessionMaker() as session: + with self.db_connection.cursor() as cur: # Check if channel isn't a countdown - countdown = getCountdown(session, ctx.channel.id) - if (not countdown): + if (not isCountdown(cur, ctx.channel.id)): raise CommandError("This channel isn't a countdown") # Check if user isn't authorized @@ -147,8 +157,9 @@ class Utilities(commands.Cog): raise CommandError("You must be an administrator to deactivate a countdown channel") # Delete countdown - session.delete(countdown) - session.commit() + cur.execute("CALL deleteCountdown(%s);", + (ctx.channel.id,)) + self.db_connection.commit() # Send response self.bot.logger.info(f"Deactivated {self.bot.get_channel(ctx.channel.id)} (ID {ctx.channel.id}) as a countdown") @@ -256,13 +267,11 @@ class Utilities(commands.Cog): "eta": "**Name:** eta\n" \ "**Description:** Shows information about the estimated completion date\n" \ - f"**Usage:** `{prefixes[0]}eta|e [<period>]`\n" \ + f"**Usage:** `{prefixes[0]}eta|e`\n" \ "**Aliases:** `e`\n" \ - "**Arguments:**\n" \ - "**-** `<period>`: The size of the period in hours (the default is 24 hours)\n" \ + "**Arguments:** none\n" \ "**Examples:**\n" \ f"**-** `{prefixes[0]}eta`\n" \ - f"**-** `{prefixes[0]}eta 48`\n" \ "**Notes:** none\n", "heatmap": "**Name:** heatmap\n" \ @@ -270,11 +279,10 @@ class Utilities(commands.Cog): 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" \ + "**-** `<user>`: The user to view heatmap information about. If no value is supplied, the general heatmap will be shown\n" \ "**Examples:**\n" \ f"**-** `{prefixes[0]}heatmap`\n" \ f"**-** `{prefixes[0]}heatmap @Alice`\n" \ - f"**-** `{prefixes[0]}heatmap Bob`\n" \ "**Notes:** none\n", "help": "**Name:** help\n" \ @@ -293,12 +301,10 @@ 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 view leaderboard information about. If no value is supplied, the whole leaderboard will be shown\n" \ + "**-** `<user>`: The user to view leaderboard information about. If no value is supplied, the whole leaderboard will be shown\n" \ "**Examples:**\n" \ f"**-** `{prefixes[0]}leaderboard`\n" \ - f"**-** `{prefixes[0]}leaderboard 1`\n" \ f"**-** `{prefixes[0]}leaderboard @Alice`\n" \ - f"**-** `{prefixes[0]}leaderboard Bob`\n" \ "**Notes:** The leaderboard embed will only show the top 20 contributors\n", "ping": "**Name:** ping\n" \ @@ -402,20 +408,20 @@ class Utilities(commands.Cog): Reloads the countdown cache """ - with self.databaseSessionMaker() as session: - countdown = getCountdown(session, ctx.channel.id) - if (countdown): - # Send initial response - embed = discord.Embed(title=":clock3: Reloading Countdown Cache", description="Please wait to continue counting", color=COLORS["embed"]) - msg = await ctx.channel.send(embed=embed) - - # Reload messages - await loadCountdown(self.bot, countdown) - session.commit() - - # Send final response - self.bot.logger.info(f"Reloaded messages from {self.bot.get_channel(ctx.channel.id)} (ID {ctx.channel.id})") - embed = discord.Embed(title=":white_check_mark: Countdown Cache Reloaded", description="Done! You may continue counting!", color=COLORS["embed"]) - await msg.edit(embed=embed) - else: + with self.db_connection.cursor() as cur: + # Check if channel isn't a countdown + if (not isCountdown(cur, ctx.channel.id)): raise CommandError("Countdown not found\nThis command must be used in a countdown channel") + + # Send initial response + embed = discord.Embed(title=":clock3: Reloading Countdown Cache", description="Please wait to continue counting", color=COLORS["embed"]) + msg = await ctx.channel.send(embed=embed) + + # Reload messages + await loadCountdown(self.bot, ctx.channel.id) + self.db_connection.commit() + + # Send final response + self.bot.logger.info(f"Reloaded messages from {self.bot.get_channel(ctx.channel.id)} (ID {ctx.channel.id})") + embed = discord.Embed(title=":white_check_mark: Countdown Cache Reloaded", description="Done! You may continue counting!", color=COLORS["embed"]) + await msg.edit(embed=embed) diff --git a/models/analytics.sql b/models/analytics.sql @@ -0,0 +1,402 @@ +-- countdown-bot analytic functions and procedures + +DROP FUNCTION IF EXISTS speedData; +DROP PROCEDURE IF EXISTS progressStats; +DROP FUNCTION IF EXISTS progressData; +DROP FUNCTION IF EXISTS leaderboardData; +DROP FUNCTION IF EXISTS historicalContributorData; +DROP PROCEDURE IF EXISTS heatmapStats; +DROP FUNCTION IF EXISTS heatmapData; +DROP FUNCTION IF EXISTS etaData; +DROP FUNCTION IF EXISTS contributorData; + +-- Get overall contributor data for a countdown +CREATE FUNCTION contributorData ( + _countdownID BIGINT -- The countdown channel ID +) +RETURNS TABLE ( + ranking BIGINT, -- The user's (1-based) contribution ranking + userID BIGINT, -- The user ID + contributions BIGINT, -- The user's number of contributions + percentage FLOAT -- The user's percentage of all contributions +) +LANGUAGE plpgsql AS $$ +DECLARE + progress INT; +BEGIN + -- Get total from first message + SELECT count(messageID) + INTO progress + FROM messages + WHERE countdownID = _countdownID; + + RETURN QUERY + SELECT + rank() OVER (ORDER BY count(messageID) DESC) AS ranking, + messages.userID, + count(messageID) AS contributions, + (100.0 * count(messageID) / progress)::float AS percentage + FROM messages + WHERE countdownID = _countdownID + GROUP BY messages.userID; +END +$$; + +-- Calculate the current ETA for each message in a countdown +CREATE FUNCTION etaData ( + _countdownID BIGINT -- The countdown channel ID +) +RETURNS TABLE ( + _timestamp TIMESTAMP, -- The timestamp of the message + eta TIMESTAMP -- The timestamp of the current ETA +) +LANGUAGE plpgsql AS $$ +DECLARE + total INT; + startTime INT; + _timezone INTERVAL; +BEGIN + -- Get total and startTime from first message + SELECT value, extract(epoch FROM timestamp) + INTO total, startTime + FROM messages + WHERE countdownID = _countdownID + ORDER BY timestamp ASC + LIMIT 1; + + -- Get timezone + SELECT timezone + INTO _timezone + FROM countdowns + WHERE countdownID = _countdownID; + + -- Calculate eta for each message + RETURN QUERY + SELECT + timestamp AT TIME ZONE _timezone, + to_timestamp(startTime + total * + (extract(epoch FROM timestamp) - startTime) / (total - value) + ) AT TIME ZONE _timezone AS eta + FROM messages + WHERE countdownID = _countdownID + AND value != total + ORDER BY messageID; +END +$$; + +-- Count the number of contributions in a countdown for each day/hour zone +CREATE FUNCTION heatmapData ( + _countdownID BIGINT, -- The countdown channel ID + _userID BIGINT -- The user ID to filter by (or NULL for all users) +) +RETURNS TABLE ( + dow NUMERIC, -- The day of the week (0-6 for Sun-Sat) + hour NUMERIC, -- The hour of the day (0-23) + messages BIGINT -- The number of contributions in the zone +) +LANGUAGE plpgsql AS $$ +DECLARE + _timezone INTERVAL; +BEGIN + -- Get timezone + SELECT timezone + INTO _timezone + FROM countdowns + WHERE countdownID = _countdownID; + + RETURN QUERY + SELECT + extract(dow FROM timestamp AT TIME ZONE _timezone) AS dow, + extract(hour FROM timestamp AT TIME ZONE _timezone) AS hour, + count(messageID) as messages + FROM messages + WHERE countdownID = _countdownID AND (userID = _userID OR _userID IS NULL) + GROUP BY dow, hour; +END +$$; + +CREATE PROCEDURE heatmapStats ( + _countdownID IN BIGINT, -- The countdown channel ID + curDow OUT NUMERIC, -- The current day of the week (0-6 for Sun-Sat) + curHour OUT NUMERIC -- The current hour of the day (0-23) +) +LANGUAGE plpgsql AS $$ +BEGIN + SELECT + extract(dow FROM NOW() AT TIME ZONE timezone) AS dow, + extract(hour FROM NOW() AT TIME ZONE timezone) AS hour + INTO curDow, curHour + FROM countdowns + WHERE countdownID = _countdownID; +END +$$; + +-- Calculate each user's contribution percentage at each message in a countdown +CREATE FUNCTION historicalContributorData ( + _countdownID BIGINT -- The countdown channel ID +) +RETURNS TABLE ( + progress INT, -- The current countdown progress (0-total) + userID BIGINT, -- The user ID + percentage FLOAT -- The user's percentage of all contributions so far +) +LANGUAGE plpgsql AS $$ +DECLARE + total INT; +BEGIN + -- Get total from first message + SELECT value + INTO total + FROM messages + WHERE countdownID = _countdownID + ORDER BY timestamp ASC + LIMIT 1; + + -- Calculator percentage for each user for each message + RETURN QUERY + SELECT + (total - value) AS progress, + users.userID, + ( + sum(CASE messages.userID WHEN users.userID THEN 1 ELSE 0 END) + OVER (PARTITION BY users.userID ORDER BY timestamp) + )::float / (total - value + 1) + FROM messages, ( + SELECT DISTINCT messages.userID + FROM messages + WHERE countdownID = _countdownID + ) users + WHERE countdownID = _countdownID; +END +$$; + +-- Get the current leaderboard data for a countdown +CREATE FUNCTION leaderboardData ( + _countdownID BIGINT, -- The countdown channel ID + _userID BIGINT -- The user ID to filter by (or NULL for all users) +) +RETURNS TABLE ( + ranking BIGINT, -- The user's (1-based) leaderboard ranking + userID BIGINT, -- The user ID + total BIGINT, -- The user's total leaderboard points + contributions BIGINT, -- The user's number of contributions + percentage FLOAT, -- The user's percentage of all contributions + r1 BIGINT, r2 BIGINT, r3 BIGINT, r4 BIGINT, r5 BIGINT, r6 BIGINT, + r7 BIGINT, r8 BIGINT, r9 BIGINT -- The number of each point rule applied +) +LANGUAGE plpgsql AS $$ +DECLARE + total INT; + progress INT; +BEGIN + -- Get total from first message + SELECT value + INTO total + FROM messages + WHERE countdownID = _countdownID + ORDER BY timestamp ASC + LIMIT 1; + + -- Get progress from last message + SELECT total - value + 1 + INTO progress + FROM messages + WHERE countdownID = _countdownID + ORDER BY timestamp DESC + LIMIT 1; + + RETURN QUERY + SELECT * FROM ( + -- Assign rankings based on total points + SELECT row_number() OVER (ORDER BY points.total DESC), * + FROM ( + -- Count points and rule breakdowns for each user + SELECT categorizedMessages.userID, + sum(CASE rule + WHEN 1 THEN 0 -- First + WHEN 2 THEN 1000 -- 1000s + WHEN 3 THEN 500 -- 1001s + WHEN 4 THEN 200 -- 200s + WHEN 5 THEN 100 -- 201s + WHEN 6 THEN 100 -- 100s + WHEN 7 THEN 50 -- 101s + WHEN 8 THEN 12 -- Odds + ELSE 10 -- Evens + END) AS total, + count(rule) AS contributions, + (100.0 * count(rule) / progress)::float AS percentage, + sum(CASE rule WHEN 1 THEN 1 ELSE 0 END) AS r1, + sum(CASE rule WHEN 2 THEN 1 ELSE 0 END) AS r2, + sum(CASE rule WHEN 3 THEN 1 ELSE 0 END) AS r3, + sum(CASE rule WHEN 4 THEN 1 ELSE 0 END) AS r4, + sum(CASE rule WHEN 5 THEN 1 ELSE 0 END) AS r5, + sum(CASE rule WHEN 6 THEN 1 ELSE 0 END) AS r6, + sum(CASE rule WHEN 7 THEN 1 ELSE 0 END) AS r7, + sum(CASE rule WHEN 8 THEN 1 ELSE 0 END) AS r8, + sum(CASE rule WHEN 9 THEN 1 ELSE 0 END) AS r9 + FROM ( + -- Get qualifying rule for each message + SELECT + messages.userID, + CASE TRUE + WHEN value=total THEN 1 -- First + WHEN value%1000=0 THEN 2 -- 1000s + WHEN value%1000=1 THEN 3 -- 1001s + WHEN value%200=0 THEN 4 -- 200s + WHEN value%200=1 THEN 5 -- 201s + WHEN value%100=0 THEN 6 -- 100s + WHEN value%100=1 THEN 7 -- 101s + WHEN value%2=1 THEN 8 -- Odds + ELSE 9 -- Evens + END AS rule + FROM messages + WHERE countdownID = _countdownID + ) categorizedMessages + GROUP BY categorizedMessages.userID + ) points + ) rankings + WHERE rankings.userID = _userID OR _userID IS NULL; +END +$$; + +-- Get the current progress for each message in a countdown +CREATE FUNCTION progressData ( + _countdownID BIGINT -- The countdown channel ID +) +RETURNS TABLE ( + _timestamp TIMESTAMP, -- The timestamp of the message + progress INT -- The current countdown progress (0-total) +) +LANGUAGE plpgsql AS $$ +DECLARE + _timezone INTERVAL; +BEGIN + -- Get timezone + SELECT timezone + INTO _timezone + FROM countdowns + WHERE countdownID = _countdownID; + + RETURN QUERY + SELECT timestamp AT TIME ZONE _timezone, value + FROM messages + WHERE countdownID = _countdownID + ORDER BY messageID; +END +$$; + +-- Get general progress-related statistics for a countdown +CREATE PROCEDURE progressStats ( + _countdownID IN BIGINT, -- The countdown channel ID + total OUT INT, -- The starting value + current OUT INT, -- The current value + progress OUT INT, -- The countdown progress (0-total) + percentage OUT DECIMAL, -- The percentage completion + startTime OUT TIMESTAMP, -- The start timestamp + startAge OUT INTERVAL, -- The time since the start + endTime OUT TIMESTAMP, -- The real/predicted finish timestamp + endAge OUT INTERVAL, -- The time since/until the finish + rate OUT DECIMAL, -- The rate of contributions per day + longestBreak OUT INTERVAL, -- The longest break in contributions + longestBreakStart OUT TIMESTAMP, -- The start of the longest break + longestBreakEnd OUT TIMESTAMP -- The end of the longest break +) +LANGUAGE plpgsql AS $$ +DECLARE + _timezone INTERVAL; + _now TIMESTAMP; +BEGIN + -- Get timezone + SELECT timezone + INTO _timezone + FROM countdowns + WHERE countdownID = _countdownID; + + SELECT NOW() AT TIME ZONE _timezone INTO _now; + + -- Get total and startTime from first message + SELECT messages.value, messages.timestamp + INTO total, startTime AT TIME ZONE _timezone + FROM messages + WHERE messages.countdownID = _countdownID + ORDER BY messages.timestamp ASC + LIMIT 1; + + -- Get current and endTime from last message + SELECT messages.value, messages.timestamp + INTO current, endTime AT TIME ZONE _timezone + FROM messages + WHERE messages.countdownID = _countdownID + ORDER BY messages.timestamp DESC + LIMIT 1; + + -- Calculate progress and percent + progress := total - current; + percentage := 100.0 * progress / total; + + -- Calculate rate and update endTime + IF current = 0 THEN + -- Countdown has ended, so endTime is already correct + rate := (total - current) / extract(epoch FROM (endTime - startTime)); + ELSEIF progress = 0 THEN + -- Countdown only has 1 message + rate := 0; + endTime = NOW(); + ELSE + rate := progress / extract(epoch FROM (_now - startTime)); + endTime := to_timestamp(extract(epoch FROM _now) + (current / rate)) + AT TIME ZONE _timezone; + END IF; + rate := rate * 60 * 60 * 24; -- Adjust rate from per sec to per day + + -- Calculate startAge and endAge + startAge := _now - startTime; + endAge := _now - endTime; + + -- Calculate longestBreak, longestBreakStart, and longestBreakEnd + SELECT + timestamp AT TIME ZONE _timezone, + CASE + WHEN value = 0 THEN '0' + ELSE LEAD(timestamp, 1, NOW()) OVER (ORDER BY timestamp) - timestamp + END AS delta + INTO longestBreakStart, longestBreak + FROM messages + WHERE messages.countdownID = _countdownID + ORDER BY delta DESC + LIMIT 1; + longestBreakEnd := longestBreakStart + longestBreak; +END +$$; + +-- Calculate the number of contributions per period in a countdown +CREATE FUNCTION speedData ( + _countdownID BIGINT, -- The countdown channel ID + hours INT -- The period size, in hours +) +RETURNS TABLE ( + periodStart TIMESTAMP, -- The start of the period + messages BIGINT -- The number of contributions in the period +) +LANGUAGE plpgsql AS $$ +DECLARE + _timezone INTERVAL; +BEGIN + -- Get timezone + SELECT timezone + INTO _timezone + FROM countdowns + WHERE countdownID = _countdownID; + + RETURN QUERY + SELECT + to_timestamp( + floor(extract(epoch FROM timestamp AT TIME ZONE _timezone) / hours + / 3600)::int * hours * 3600 + ) AT TIME ZONE '0:00' AS periodStart, + count(messageID) as messages + FROM messages + WHERE countdownID = _countdownID + GROUP BY periodStart; +END +$$; diff --git a/models/tables.sql b/models/tables.sql @@ -0,0 +1,46 @@ +-- countdown-bot table definitions + +DROP TABLE IF EXISTS reactions; +DROP TABLE IF EXISTS prefixes; +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS countdowns; + +-- Records countdown channels +CREATE TABLE countdowns ( + countdownID BIGINT PRIMARY KEY, -- The Discord channel ID + serverID BIGINT NOT NULL, -- The channel's Discord server ID + timezone INTERVAL NOT NULL DEFAULT '0' -- The preferred UTC offset +); + +-- Records contributions to countdowns +CREATE TABLE messages ( + messageID BIGINT PRIMARY KEY, -- The Discord message ID + countdownID BIGINT NOT NULL, -- The countdown ID + userID BIGINT NOT NULL, -- The author's Discord user ID + value INT NOT NULL, -- The message's numeric value + timestamp TIMESTAMPTZ NOT NULL, -- The message timestamp + FOREIGN KEY (countdownID) + REFERENCES countdowns(countdownID) + ON DELETE CASCADE +); + +-- Records bot command prefixes +CREATE table prefixes ( + prefixID SERIAL PRIMARY KEY, -- The prefix ID + countdownID BIGINT NOT NULL, -- The countdown ID + value VARCHAR(8) NOT NULL, -- The prefix + FOREIGN KEY (countdownID) + REFERENCES countdowns(countdownID) + ON DELETE CASCADE +); + +-- Records custom countdown reactions +CREATE table reactions ( + prefixID SERIAL PRIMARY KEY, -- The reaction ID + countdownID BIGINT NOT NULL, -- The countdown ID + number INT NOT NULL, -- The prefix + value VARCHAR(8) NOT NULL, -- The reaction + FOREIGN KEY (countdownID) + REFERENCES countdowns(countdownID) + ON DELETE CASCADE +); diff --git a/models/utilities.sql b/models/utilities.sql @@ -0,0 +1,317 @@ +-- countdown-bot utility procedures + +DROP PROCEDURE IF EXISTS setTimezone; +DROP PROCEDURE IF EXISTS getTimezone; +DROP PROCEDURE IF EXISTS setReactions; +DROP FUNCTION IF EXISTS getReactions; +DROP PROCEDURE IF EXISTS addMessage; +DROP TYPE IF EXISTS addMessageResults; +DROP PROCEDURE IF EXISTS deleteCountdown; +DROP PROCEDURE IF EXISTS createCountdown; +DROP PROCEDURE IF EXISTS clearCountdown; +DROP PROCEDURE IF EXISTS isCountdown; +DROP PROCEDURE IF EXISTS getUserContextCountdown; +DROP PROCEDURE IF EXISTS getServerContextCountdown; +DROP PROCEDURE IF EXISTS setPrefixes; +DROP FUNCTION IF EXISTS getPrefixes; + +-- Get the active prefixes for a server +CREATE FUNCTION getPrefixes ( + _serverID BIGINT, -- The server ID + channelID BIGINT -- The channel ID +) +RETURNS TABLE ( + prefix VARCHAR(8) -- An active prefix +) +LANGUAGE plpgsql AS $$ +BEGIN + IF EXISTS( + SELECT 1 + FROM countdowns + WHERE countdownID = channelID + ) THEN + -- Filter prefixes if channel is a countdown + RETURN QUERY + SELECT value + FROM prefixes + WHERE prefixes.countdownID = channelID; + ELSE + -- Return all server prefixes if channel is not a countdown + RETURN QUERY + SELECT DISTINCT value + FROM prefixes + JOIN countdowns ON countdowns.countdownID = prefixes.countdownID + WHERE countdowns.serverID = _serverID; + END IF; +END +$$; + +-- Set the command prefixes used by a countdown channel +CREATE PROCEDURE setPrefixes ( + _countdownID BIGINT, -- The countdown channel ID + _prefixes VARCHAR(8)[] -- The prefix values +) +LANGUAGE plpgsql AS $$ +BEGIN + DELETE FROM prefixes + WHERE countdownID = _countdownID; + + INSERT INTO prefixes (countdownID, value) + SELECT _countdownID, * + FROM unnest(_prefixes); +END +$$; + +-- Get the most relevant countdown to a server channel +CREATE PROCEDURE getServerContextCountdown ( + _serverID IN BIGINT, -- The server ID + channelID IN BIGINT, -- The channel ID + prefix IN VARCHAR(8), -- The prefix used to invoke the bot + countdownID OUT BIGINT -- The ID of the most relevant countdown +) +LANGUAGE plpgsql AS $$ +BEGIN + -- Check if the channel is a countdown + SELECT countdowns.countdownID + INTO countdownID + FROM countdowns + WHERE countdowns.countdownID = channelID + + UNION ALL + ( + -- Get server countdowns by prefix sorted by most recent activity + SELECT countdowns.countdownID + FROM countdowns + LEFT OUTER JOIN messages + ON messages.countdownID = countdowns.countdownID + JOIN prefixes + ON prefixes.countdownID = countdowns.countdownID + WHERE serverID = _serverID AND prefixes.value = prefix + GROUP BY countdowns.countdownID + ORDER BY max(messages.timestamp) DESC NULLS LAST + ) + LIMIT 1; +END +$$; + +-- Get the most relevant countdown to a user +CREATE PROCEDURE getUserContextCountdown ( + _userID IN BIGINT, -- The user ID + countdownID OUT BIGINT -- The ID of the most relevant countdown +) +LANGUAGE plpgsql AS $$ +BEGIN + -- Get user countdowns sorted by most recent activity + SELECT countdowns.countdownID + INTO countdownID + FROM countdowns + LEFT OUTER JOIN messages ON messages.countdownID = countdowns.countdownID + WHERE userID = _userID + GROUP BY countdowns.countdownID + ORDER BY max(messages.timestamp) DESC NULLS LAST + LIMIT 1; +END +$$; + +-- Determine if a channel is a countdown +CREATE PROCEDURE isCountdown ( + channelID IN BIGINT, -- The channel ID + result OUT BOOLEAN -- Whether the channel is a countdown +) +LANGUAGE plpgsql AS $$ +BEGIN + SELECT EXISTS( + SELECT 1 + FROM countdowns + WHERE countdownID = channelID + ) INTO result; +END +$$; + +-- Delete all messages in a countdown +CREATE PROCEDURE clearCountdown ( + _countdownID IN BIGINT -- The countdown channel ID +) +LANGUAGE plpgsql AS $$ +BEGIN + DELETE + FROM messages + WHERE countdownID = _countdownID; +END +$$; + +-- Create a new countdown +CREATE PROCEDURE createCountdown ( + _countdownID IN BIGINT, -- The countdown channel ID + _serverID IN BIGINT, -- The server ID + prefix IN VARCHAR(8) -- The initial prefix +) +LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO countdowns (countdownID, serverID) + VALUES (_countdownID, _serverID); + INSERT INTO prefixes (countdownID, value) + VALUES (_countdownID, prefix); +END +$$; + +-- Delete a countdown +CREATE PROCEDURE deleteCountdown ( + _countdownID IN BIGINT -- The countdown channel ID +) +LANGUAGE plpgsql AS $$ +BEGIN + DELETE + FROM countdowns + WHERE countdownID = _countdownID; +END +$$; + +-- Possible results of the addMessage procedure +CREATE TYPE addMessageResults AS ENUM ( + 'badCountdown', -- Countdown doesn't exist or has ended + 'badNumber', -- Message number is incorrect + 'badUser', -- User sent consecutive messages + 'good' -- Message was successfully added +); + +-- Validate and add a new countdown message +CREATE PROCEDURE addMessage ( + _messageID IN BIGINT, -- The message ID + _countdownID IN BIGINT, -- The message countdown ID + _userID IN BIGINT, -- The message user ID + _value IN INT, -- The message value + _timestamp IN TIMESTAMPTZ, -- The message timestamp + result OUT addMessageResults, -- The operation result + pin OUT BOOLEAN, -- Whether the message should be pinned + reactions OUT BOOLEAN -- Whether the message has custom reactions +) +LANGUAGE plpgsql AS $$ +DECLARE + lastMessage record; + total INT; +BEGIN + -- Get last countdown message + SELECT countdowns.countdownID, messageID, userID, value, timestamp + INTO lastMessage + FROM countdowns + + -- Still return a row if the countdown is empty + LEFT OUTER JOIN messages + ON messages.countdownID = countdowns.countdownID + + WHERE countdowns.countdownID = _countdownID + ORDER BY messages.value ASC + LIMIT 1; + + -- Initialize pin and reactions + pin := FALSE; + reactions := FALSE; + + -- Validate message + IF lastMessage.countdownID IS NULL OR lastMessage.value = 0 THEN + -- Countdown doesn't exist or has ended + result := 'badCountdown'; + + ELSEIF lastMessage.value IS NOT NULL AND + lastMessage.value != _value + 1 THEN + -- Message contains the wrong number + result := 'badNumber'; + + ELSEIF lastMessage.userID = _userID THEN + -- User sent consecutive messages + result := 'badUser'; + + ELSE + -- Message is valid, insert it into messages + INSERT INTO messages (messageID, userID, countdownID, value, timestamp) + VALUES (_messageID, _userID, _countdownID, _value, _timestamp); + result := 'good'; + + -- Get total from first message + SELECT value + INTO total + FROM messages + WHERE countdownID = _countdownID + ORDER BY timestamp ASC + LIMIT 1; + + -- Check if message should be pinned + IF total >= 500 AND _value % (total / 50) = 0 AND _value != 0 THEN + pin := TRUE; + END IF; + + -- Check if message has custom reactions + IF EXISTS(SELECT 1 FROM reactions + WHERE countdownID = _countdownID AND number = _value + ) THEN + reactions := TRUE; + END IF; + END IF; +END +$$; + +-- Get the custom reactions for a number in a countdown +CREATE FUNCTION getReactions ( + _countdownID BIGINT, -- The countdown channel ID + _number INT -- The number (or NULL for all numbers) +) +RETURNS TABLE ( + value VARCHAR(8), -- A custom reaction + number INT -- The number +) +LANGUAGE plpgsql AS $$ +BEGIN + RETURN QUERY + SELECT reactions.value, reactions.number + FROM reactions + WHERE countdownID = _countdownID + AND (reactions.number = _number OR _number IS NULL) + ORDER BY reactions.number DESC; +END +$$; + +-- Set the custom reactions for a number in a countdown +CREATE PROCEDURE setReactions ( + _countdownID BIGINT, -- The countdown channel ID + _number INT, -- The number + _reactions VARCHAR(8)[] -- The custom reactions +) +LANGUAGE plpgsql AS $$ +BEGIN + DELETE FROM reactions + WHERE countdownID = _countdownID AND number = _number; + + INSERT INTO reactions (countdownID, number, value) + SELECT _countdownID, _number, * + FROM unnest(_reactions); +END +$$; + +-- Get the timezone of a countdown +CREATE PROCEDURE getTimezone ( + _countdownID IN BIGINT, -- The countdown channel ID + _timezone OUT DECIMAL -- The timezone as a UTC offest +) +LANGUAGE plpgsql AS $$ +BEGIN + SELECT extract(minute FROM timezone) / 60 + INTO _timezone + FROM countdowns + WHERE countdownID = _countdownID; +END +$$; + +-- Set the timezone of a countdown +CREATE PROCEDURE setTimezone ( + _countdownID IN BIGINT, -- The countdown channel ID + _timezone IN FLOAT -- The timezone as a UTC offest +) +LANGUAGE plpgsql AS $$ +BEGIN + UPDATE countdowns + SET timezone = make_interval(0,0,0,0, floor(_timezone)::integer, + (_timezone * 60)::integer % 60) + WHERE countdownID = _countdownID; +END +$$; diff --git a/requirements.txt b/requirements.txt @@ -1,4 +1,4 @@ discord matplotlib +psycopg2 python-dotenv -sqlalchemy