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 f05a87f3a2ed8dc6b4846396c196da7ed7436b3e
parent 76041ecb441e813427f046f53b187dd536b9c2b2
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Wed, 23 Jun 2021 21:04:05 -0700

Store messages in data.json so bot loads faster

Diffstat:
Mbot.py | 240++++++++++++++++++++++++++++++++++++++++---------------------------------------
1 file changed, 123 insertions(+), 117 deletions(-)

diff --git a/bot.py b/bot.py @@ -15,7 +15,6 @@ import tempfile # Global variables data = {} -loaded = 0 # percentage of countdowns fully loaded POINT_RULES = { "1000s": 1000, "1001s": 500, @@ -97,44 +96,54 @@ def saveData(data): # Copy data obj = copy.deepcopy(data) - # Remove countdown objects - for countdown in obj["countdowns"]: - del obj["countdowns"][countdown]["countdown"] + # Format countdown objects + for countdown in data["countdowns"]: + obj["countdowns"][countdown] = { + "messages": [], + "server": data["countdowns"][countdown].server, + "timezone": data["countdowns"][countdown].timezone, + "prefixes": data["countdowns"][countdown].prefixes, + "reactions": data["countdowns"][countdown].reactions, + } + for message in data["countdowns"][countdown].messages: + obj["countdowns"][countdown]["messages"] += [{ + "timestamp": message.timestamp.strftime("%Y-%m-%d %H:%M:%S"), + "author": message.author, + "number": message.number, + }] # Save data with open(os.path.join(os.path.dirname(__file__), "data.json"), "w") as f: return json.dump(obj, f) -def getCountdownChannel(ctx, resortToFirst=True): +def getCountdown(ctx, resortToFirst=True): """ - Get the most relevant countdown channel to a certain context. + Get the most relevant countdown to a certain context. Parameters ---------- ctx The context resortToFirst : bool - Whether to return the 1st countdown channel if no relevant countdown channels are found + Whether to return the 1st countdown if no relevant countdowns are found Returns ------- - dict - The countdown channel - str - The channel ID + Countdown + The countdown """ # Countdown channel global data if (str(ctx.channel.id) in data["countdowns"]): - return data["countdowns"][str(ctx.channel.id)], str(ctx.channel.id) + return data["countdowns"][str(ctx.channel.id)] # Server with countdown channel if (isinstance(ctx.channel, discord.channel.TextChannel)): # Get first countdown in this server that use the current prefix - serverChannels = [x for x in data["countdowns"] if data["countdowns"][x]["server"] == ctx.channel.guild.id and ctx.prefix in data["countdowns"][x]["prefixes"]] + serverChannels = [x for x in data["countdowns"] if data["countdowns"][x].server == ctx.channel.guild.id and ctx.prefix in data["countdowns"][x].prefixes] if (len(serverChannels) > 0): - return data["countdowns"][serverChannels[0]], serverChannels[0] + return data["countdowns"][serverChannels[0]] # No countdown channels if (len(data["countdowns"]) == 0): @@ -142,7 +151,7 @@ def getCountdownChannel(ctx, resortToFirst=True): # Return default countdown channel if resortToFirst: - return list(data["countdowns"].values())[0], list(data["countdowns"].keys())[0] + return list(data["countdowns"].values())[0] else: raise Exception("Countdown channel not found.") @@ -160,16 +169,16 @@ def getPrefix(bot, ctx): # Countdown channel global data - if (str(ctx.channel.id) in data["countdowns"] and len(data["countdowns"][str(ctx.channel.id)]["prefixes"]) > 0): - return data["countdowns"][str(ctx.channel.id)]["prefixes"] + if (str(ctx.channel.id) in data["countdowns"] and len(data["countdowns"][str(ctx.channel.id)].prefixes) > 0): + return data["countdowns"][str(ctx.channel.id)].prefixes # Server with countdown channels if (isinstance(ctx.channel, discord.channel.TextChannel)): - serverChannels = [x for x in data["countdowns"] if data["countdowns"][x]["server"] == ctx.channel.guild.id] + serverChannels = [x for x in data["countdowns"] if data["countdowns"][x].server == ctx.channel.guild.id] # Get list of prefixes prefixes = [] for channel in serverChannels: - prefixes += data["countdowns"][channel]["prefixes"] + prefixes += data["countdowns"][channel].prefixes if (len(prefixes) > 0): return list(dict.fromkeys(prefixes)) @@ -196,11 +205,18 @@ class Message: """ def __init__(self, obj): - self.channel = obj.channel.id - self.id = obj.id - self.timestamp = obj.created_at - self.author = obj.author.id - self.number = int(re.findall("^[0-9,]+", obj.content)[0].replace(",","")) + if (obj == None): + self.channel = None + self.id = None + self.timestamp = None + self.author = None + self.number = None + else: + self.channel = obj.channel.id + self.id = obj.id + self.timestamp = obj.created_at + self.author = obj.author.id + self.number = int(re.findall("^[0-9,]+", obj.content)[0].replace(",","")) def __eq__(self, o: object) -> bool: if not isinstance(o, Message): return False @@ -226,8 +242,12 @@ class Countdown: Parse a message and adds it to the list of messages. """ - def __init__(self, messages, reactions): + def __init__(self, id, messages, server, timezone, prefixes, reactions): + self.id = id self.messages = messages + self.server = server + self.timezone = timezone + self.prefixes = prefixes self.reactions = reactions def addMessage(self, message): @@ -532,7 +552,25 @@ class Countdown: # Load countdown data with open(os.path.join(os.path.dirname(__file__), "data.json"), "a+") as f: f.seek(0) - data = json.load(f) + rawData = json.load(f) + data = copy.deepcopy(rawData) + + # Format countdown objects + for countdown in rawData["countdowns"]: + data["countdowns"][countdown] = Countdown( + countdown, + [], + rawData["countdowns"][countdown]["server"], + rawData["countdowns"][countdown]["timezone"], + rawData["countdowns"][countdown]["prefixes"], + rawData["countdowns"][countdown]["reactions"], + ) + for message in rawData["countdowns"][countdown]["messages"]: + messageObj = Message(None) + messageObj.timestamp = datetime.strptime(message["timestamp"], "%Y-%m-%d %H:%M:%S") + messageObj.author = message["author"] + messageObj.number = message["number"] + data["countdowns"][countdown].messages += [messageObj] @@ -547,26 +585,6 @@ async def on_ready(): # Print status print(f"Connected to Discord as {bot.user}") - # Load messages - global data - global loaded - for channel in data["countdowns"]: - # Get messages - rawMessages = await bot.get_channel(int(channel)).history(limit=10100).flatten() - rawMessages.reverse() - - # Create countdown - data["countdowns"][channel]["countdown"] = Countdown([], data["countdowns"][channel]["reactions"]) - - # Load messages - for rawMessage in rawMessages: - await data["countdowns"][channel]["countdown"].parseMessage(rawMessage) - - # Print status - print(f"Loaded messages from {bot.get_channel(int(channel))}") - loaded += (1 / len(data["countdowns"])) - loaded = 1 - @bot.event @@ -575,7 +593,7 @@ async def on_message(obj): embed=discord.Embed(title="countdown-bot", description=f"Use `{(await bot.get_prefix(obj))[0]}help` to view help information", color=COLORS["embed"]) await obj.channel.send(embed=embed) if (str(obj.channel.id) in data["countdowns"] and obj.author.name != "countdown-bot"): - await data["countdowns"][str(obj.channel.id)]["countdown"].parseMessage(obj) + await data["countdowns"][str(obj.channel.id)].parseMessage(obj) try: await bot.process_commands(obj) except: @@ -619,14 +637,7 @@ async def activate(ctx): # Channel is valid else: # Create countdown channel - data["countdowns"][str(ctx.channel.id)] = { - "server": ctx.channel.guild.id, - "timezone": 0, - "prefixes": data["prefixes"], - "reactions": {}, - "countdown": Countdown([], {}) - } - saveData(data) + data["countdowns"][str(ctx.channel.id)] = Countdown(ctx.channel.id, [], ctx.channel.guild.id, 0, data["prefixes"], {}) # Send initial responce print(f"Activated {bot.get_channel(ctx.channel.id)} as a countdown") @@ -637,12 +648,10 @@ async def activate(ctx): rawMessages = await bot.get_channel(ctx.channel.id).history(limit=10100).flatten() rawMessages.reverse() - # Create countdown - data["countdowns"][str(ctx.channel.id)]["countdown"] = Countdown([], {}) - # Load messages for rawMessage in rawMessages: - await data["countdowns"][str(ctx.channel.id)]["countdown"].parseMessage(rawMessage) + await data["countdowns"][str(ctx.channel.id)].parseMessage(rawMessage) + saveData(data) # Send final responce print(f"Loaded messages from {bot.get_channel(ctx.channel.id)}") @@ -658,10 +667,10 @@ async def analytics(ctx): """ # Get countdown channel - channel, id = getCountdownChannel(ctx) + countdown = getCountdown(ctx) # Check if countdown is empty - if (len(channel["countdown"].messages) == 0): + if (len(countdown.messages) == 0): embed=discord.Embed(title=":bar_chart: Countdown Analytics", color=COLORS["error"]) embed.description = "The countdown is empty" await ctx.send(embed=embed) @@ -670,7 +679,7 @@ async def analytics(ctx): else: await contributors(ctx, "") await contributors(ctx, "history") - if (len(channel["countdown"].messages) >= 2): await eta(ctx) # Countdown must have 2 messages to run eta command + if (len(countdown.messages) >= 2): await eta(ctx) # Countdown must have 2 messages to run eta command await leaderboard(ctx) await progress(ctx) await speed(ctx) @@ -688,25 +697,25 @@ async def config(ctx, key=None, *args): # Get countdown channel try: - channel, id = getCountdownChannel(ctx, resortToFirst=False) + countdown = getCountdown(ctx, resortToFirst=False) except: embed.color = COLORS["error"] embed.description = "This command must be run in a countdown channel or a server with a countdown channel" else: # Get / set settings if (key is None): - embed.description = f"**Countdown Channel:** <#{id}>\n" - embed.description += f"**Command Prefixes:** `{'`, `'.join(channel['prefixes'])}`\n" - if (channel["timezone"] < 0): - embed.description += f"**Countdown Timezone:** UTC-{-1 * channel['timezone']}\n" + embed.description = f"**Countdown Channel:** <#{countdown.id}>\n" + embed.description += f"**Command Prefixes:** `{'`, `'.join(countdown.prefixes)}`\n" + if (countdown.timezone < 0): + embed.description += f"**Countdown Timezone:** UTC-{-1 * countdown.timezone}\n" else: - embed.description += f"**Countdown Timezone:** UTC+{channel['timezone']}\n" - if (len(channel["reactions"]) == 0): + embed.description += f"**Countdown Timezone:** UTC+{countdown.timezone}\n" + if (len(countdown.reactions) == 0): embed.description += f"**Reactions:** none\n" else: embed.description += f"**Reactions:**\n" - for reaction in sorted(channel["reactions"].keys(), reverse=True): - embed.description += f"**-** #{reaction}: {', '.join(channel['reactions'][reaction])}\n" + for reaction in sorted(countdown.reactions.keys(), reverse=True): + embed.description += f"**-** #{reaction}: {', '.join(countdown.reactions[reaction])}\n" elif (not ctx.message.author.guild_permissions.administrator): embed.color = COLORS["error"] embed.description = f"You must be an administrator to modify settings" @@ -716,15 +725,15 @@ async def config(ctx, key=None, *args): elif (key in ["tz", "timezone"]): embed.description = f"Done" try: - channel["timezone"] = int(args[0]) + countdown.timezone = int(args[0]) except: try: - channel["timezone"] = float(args[0]) + countdown.timezone = float(args[0]) except: embed.color = COLORS["error"] embed.description = f"Invalid timezone: {args[0]}" elif (key in ["prefix", "prefixes"]): - channel["prefixes"] = args + countdown.prefixes = args embed.description = f"Done" elif (key in ["react"]): try: @@ -733,11 +742,11 @@ async def config(ctx, key=None, *args): embed.color = COLORS["error"] embed.description = f"Number must be greater than zero" elif (len(args) == 1): - if (str(number) in channel["reactions"]): - del channel["reactions"][str(number)] + if (str(number) in countdown.reactions): + del countdown.reactions[str(number)] embed.description = f"Removed reactions for #{number}" else: - channel["reactions"][str(number)] = args[1:] + countdown.reactions[str(number)] = args[1:] embed.description = f"Updated reactions for #{number}" except: embed.color = COLORS["error"] @@ -762,21 +771,21 @@ async def contributors(ctx, option=""): """ # Get countdown channel - channel, id = getCountdownChannel(ctx) + countdown = getCountdown(ctx) # Create temp file tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") tmp.close() # Get stats - stats = channel["countdown"].progress() - contributors = channel["countdown"].contributors() + stats = countdown.progress() + contributors = countdown.contributors() # Create embed embed=discord.Embed(title=":busts_in_silhouette: Countdown Contributors", color=COLORS["embed"]) # Make sure the countdown has started - if (len(channel["countdown"].messages) == 0): + if (len(countdown.messages) == 0): embed.color = COLORS["error"] embed.description = "The countdown is empty." elif (option.lower() in ["h", "history"]): @@ -790,7 +799,7 @@ async def contributors(ctx, option=""): authors = {} for author in contributors: authors[author["author"]] = [{"progress":0, "percentage":0, "total":0}] - for message in channel["countdown"].messages: + for message in countdown.messages: for author in authors: if (author == message.author): authors[author] += [{"progress":(stats["total"] - message.number), "percentage":(authors[author][-1]["total"] + 1)/(stats["total"] - message.number + 1) * 100, "total":authors[author][-1]["total"] + 1}] @@ -810,7 +819,7 @@ async def contributors(ctx, option=""): file = discord.File(tmp.name, filename="image.png") # Add content to embed - embed.description = f"**Countdown Channel:** <#{id}>" + embed.description = f"**Countdown Channel:** <#{countdown.id}>" embed.set_image(url="attachment://image.png") elif (option == ""): # Create figure @@ -829,13 +838,13 @@ async def contributors(ctx, option=""): file = discord.File(tmp.name, filename="image.png") # Add content to embed - embed.description = f"**Countdown Channel:** <#{id}>" + 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(channel['countdown'].messages) * 100, 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) @@ -896,7 +905,7 @@ async def eta(ctx, period="24.0"): """ # Get countdown channel - channel, id = getCountdownChannel(ctx) + countdown = getCountdown(ctx) # Create temp file tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") @@ -912,7 +921,7 @@ async def eta(ctx, period="24.0"): embed.color = COLORS["error"] embed.description = "The period must be a number" else: - if (len(channel["countdown"].messages) < 2): + if (len(countdown.messages) < 2): embed.color = COLORS["embed"] embed.description = "The countdown must have at least two messages" elif (period < 0.01): @@ -920,7 +929,7 @@ async def eta(ctx, period="24.0"): embed.description = "The period cannot be less than 0.01 hours" else: # Get stats - eta = channel["countdown"].eta(timedelta(hours=period), tz=timedelta(hours=channel["timezone"])) + eta = countdown.eta(timedelta(hours=period), tz=timedelta(hours=countdown.timezone)) # Create figure fig, ax = plt.subplots() @@ -945,11 +954,11 @@ async def eta(ctx, period="24.0"): 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=channel["timezone"]) + end = eta[1][-1] + timedelta(hours=countdown.timezone) endDiff = eta[1][-1] - datetime.utcnow() # Add content to embed - embed.description = f"**Countdown Channel:** <#{id}>\n\n" + embed.description = f"**Countdown Channel:** <#{countdown.id}>\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): @@ -1147,21 +1156,21 @@ async def leaderboard(ctx, user=None): """ # Get countdown channel - channel, id = getCountdownChannel(ctx) + countdown = getCountdown(ctx) # Get leaderboard - leaderboard = channel["countdown"].leaderboard() + leaderboard = countdown.leaderboard() # Create embed embed=discord.Embed(title=":trophy: Countdown Leaderboard", color=COLORS["embed"]) # Make sure the countdown has started - if (len(channel["countdown"].messages) == 0): + if (len(countdown.messages) == 0): embed.color = COLORS["error"] embed.description = "The countdown is empty." elif (user is None): # Add description - embed.description = f"**Countdown Channel:** <#{id}>" + embed.description = f"**Countdown Channel:** <#{countdown.id}>" # Add leaderboard ranks = "" @@ -1202,7 +1211,7 @@ async def leaderboard(ctx, user=None): if (rank == None): # Get user from nickname for contributor in leaderboard: - nickname = await getNickname(channel["server"], contributor["author"]) + nickname = await getNickname(countdown.server, contributor["author"]) if (nickname.lower().startswith(user.lower())): rank = leaderboard.index(contributor) @@ -1214,11 +1223,11 @@ async def leaderboard(ctx, user=None): return # Add description - embed.description = f"**Countdown Channel:** <#{id}>\n\n" + 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(channel['countdown'].messages) * 100, 1)}%)*\n" + embed.description += f"**Total Contributions:** {leaderboard[rank]['contributions']:,} *({round(leaderboard[rank]['contributions'] / len(countdown.messages) * 100, 1)}%)*\n" # Add points breakdown rules = "" @@ -1248,10 +1257,6 @@ async def ping(ctx): embed=discord.Embed(title=":ping_pong: Pong!", color=COLORS["embed"]) embed.description = f"**Latency:** {round(bot.latency * 1000)} ms\n" - if (loaded == 1): - embed.description += "**Status:** Ready :white_check_mark:" - else: - embed.description += f"**Status:** Loading ({round(loaded * 100)}%) :clock3:" await ctx.send(embed=embed) @@ -1263,7 +1268,7 @@ async def progress(ctx): """ # Get countdown channel - channel, id = getCountdownChannel(ctx) + countdown = getCountdown(ctx) # Create temp file tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") @@ -1273,12 +1278,12 @@ async def progress(ctx): embed=discord.Embed(title=":chart_with_downwards_trend: Countdown Progress", color=COLORS["embed"]) # Make sure the countdown has started - if (len(channel["countdown"].messages) == 0): + if (len(countdown.messages) == 0): embed.color = COLORS["error"] embed.description = "The countdown is empty." else: # Get progress stats - stats = channel["countdown"].progress() + stats = countdown.progress() # Create figure fig, ax = plt.subplots() @@ -1287,7 +1292,7 @@ async def progress(ctx): fig.autofmt_xdate() # Add data to graph - x = [stats["start"] + timedelta(hours=channel["timezone"])] + [x["time"] + timedelta(hours=channel["timezone"]) for x in stats["progress"]] + 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"]] ax.plot(x, y) @@ -1296,13 +1301,13 @@ async def progress(ctx): file = discord.File(tmp.name, filename="image.png") # Calculate embed data - start = (stats["start"] + timedelta(hours=channel["timezone"])).date() + start = (stats["start"] + timedelta(hours=countdown.timezone)).date() startDiff = (datetime.utcnow() - stats["start"]).days - end = (stats["eta"] + timedelta(hours=channel["timezone"])).date() + end = (stats["eta"] + timedelta(hours=countdown.timezone)).date() endDiff = stats["eta"] - datetime.utcnow() # Add content to embed - embed.description = f"**Countdown Channel:** <#{id}>\n\n" + 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"**Start Date:** {start} ({startDiff:,} days ago)\n" @@ -1343,11 +1348,12 @@ async def reload(ctx): rawMessages.reverse() # Create countdown - data["countdowns"][str(ctx.channel.id)]["countdown"] = Countdown([], data["countdowns"][str(ctx.channel.id)]["reactions"]) + data["countdowns"][str(ctx.channel.id)].messages = [] # Load messages for rawMessage in rawMessages: - await data["countdowns"][str(ctx.channel.id)]["countdown"].parseMessage(rawMessage) + await data["countdowns"][str(ctx.channel.id)].parseMessage(rawMessage) + saveData(data) # Send final responce print(f"Reloaded messages from {bot.get_channel(ctx.channel.id)}") @@ -1366,7 +1372,7 @@ async def speed(ctx, period="24.0"): """ # Get countdown channel - channel, id = getCountdownChannel(ctx) + countdown = getCountdown(ctx) # Create temp file tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") @@ -1382,7 +1388,7 @@ async def speed(ctx, period="24.0"): embed.color = COLORS["error"] embed.description = "The period must be a number" else: - if (len(channel["countdown"].messages) == 0): + if (len(countdown.messages) == 0): embed.color = COLORS["error"] embed.description = "The countdown is empty." elif (period < 0.01): @@ -1390,9 +1396,9 @@ async def speed(ctx, period="24.0"): embed.description = "The period cannot be less than 0.01 hours" else: # Get stats - stats = channel["countdown"].progress() + stats = countdown.progress() period = timedelta(hours=period) - speed = channel["countdown"].speed(period, tz=timedelta(hours=channel["timezone"])) + speed = countdown.speed(period, tz=timedelta(hours=countdown.timezone)) # Create figure fig, ax = plt.subplots() @@ -1409,10 +1415,10 @@ async def speed(ctx, period="24.0"): file = discord.File(tmp.name, filename="image.png") # Add content to embed - embed.description = f"**Countdown Channel:** <#{id}>\n\n" + embed.description = f"**Countdown Channel:** <#{countdown.id}>\n\n" embed.description += f"**Period Size:** {period}\n" - if (len(channel["countdown"].messages) > 1): - rate = (stats['total'] - stats['current'])/((channel["countdown"].messages[-1].timestamp - channel["countdown"].messages[0].timestamp) / period) + 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"