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:
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