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 af523d7ae325fb34c8dcd477605791a30fa845b8
parent f05a87f3a2ed8dc6b4846396c196da7ed7436b3e
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Fri, 25 Jun 2021 14:04:16 -0700

Implement SQLite database

Diffstat:
M.gitignore | 1+
MREADME.md | 2+-
Mbot.py | 1582++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mrequirements.txt | 1+
Msetup.py | 3+--
5 files changed, 804 insertions(+), 785 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,2 +1,3 @@ .venv data.json +data.db diff --git a/README.md b/README.md @@ -23,7 +23,7 @@ A Discord bot to facilitate countdowns. 5. Open `data.json` (which was generated by `setup.py`) and add your bot's token. ```json - {"token": "YOUR_TOKEN_HERE", "countdowns": {}} + {"token": "YOUR_TOKEN_HERE", "prefixes": ["c."]} ``` 6. Run the bot diff --git a/bot.py b/bot.py @@ -1,5 +1,4 @@ # Import dependencies -import copy from datetime import datetime, timedelta import discord from discord.ext import commands @@ -9,308 +8,52 @@ from matplotlib import pyplot as plt from matplotlib.ticker import PercentFormatter import os import re +from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, ForeignKey +from sqlalchemy.orm import sessionmaker, relationship +from sqlalchemy.ext.declarative import declarative_base import tempfile -# Global variables -data = {} -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, -} -COLORS = { - "error": 0xD52C42, - "embed": 0x248AD1, -} - - - -# Error classes -class MessageNotAllowedError(Exception): - """Raised when someone posts twice in a row.""" - pass - -class MessageIncorrectError(Exception): - """Raised when someone posts an incorrect number.""" - pass - - - -# Static methods -async def getUsername(id): - """ - Get a username from a user ID. - - Parameters - ---------- - id : int - The user ID. - - Returns - ------- - str - The username (ex: "user#0000"). - """ - - user = await bot.fetch_user(id) - return f"{user.name}#{user.discriminator}" - -async def getNickname(server, id): - """ - Get a user's nickname in a server - - Parameters - ---------- - server : int - The server ID - id : int - The user ID +# Connect to database +engine = create_engine("sqlite:///data.db") - Returns - ------- - str - The nickname - """ - - return (await (bot.get_guild(server)).fetch_member(id)).nick or await getUsername(id) - -def saveData(data): - """ - Save countdown data to the data.json file. - - Parameters - ---------- - data : dict - The countdown data - """ - - # Copy data - obj = copy.deepcopy(data) - - # 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, - }] +# Define tables +Base = declarative_base() +class Countdown(Base): + __tablename__ = "countdown" - # Save data - with open(os.path.join(os.path.dirname(__file__), "data.json"), "w") as f: - return json.dump(obj, f) - -def getCountdown(ctx, resortToFirst=True): - """ - Get the most relevant countdown to a certain context. - - Parameters - ---------- - ctx - The context - resortToFirst : bool - Whether to return the 1st countdown if no relevant countdowns are found - - Returns - ------- - Countdown - The countdown - """ - - # Countdown channel - global data - if (str(ctx.channel.id) in data["countdowns"]): - 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] - if (len(serverChannels) > 0): - return data["countdowns"][serverChannels[0]] - - # No countdown channels - if (len(data["countdowns"]) == 0): - raise Exception("Countdown channel not found.") - - # Return default countdown channel - if resortToFirst: - return list(data["countdowns"].values())[0] - else: - raise Exception("Countdown channel not found.") - -def getPrefix(bot, ctx): - """ - Get the bot prefix for a certain context. - - Parameters - ---------- - bot - The bot - ctx - The context - """ - - # 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 - - # 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] - # Get list of prefixes - prefixes = [] - for channel in serverChannels: - prefixes += data["countdowns"][channel].prefixes - if (len(prefixes) > 0): - return list(dict.fromkeys(prefixes)) - - # Return default prefixes - return data["prefixes"] - - - -# Message class -class Message: - """ - Represents a single, valid, countdown message. - - Attributes - ---------- - id : int - The message ID. - channel : int - The channel ID. - author : int - The message author ID. - number : int - The message content. - """ - - def __init__(self, obj): - 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 - else: return self.id == o.id - - - -# Countdown class -class Countdown: - """ - Represents a countdown. - - Attributes - ---------- - messages : list - The (valid) messages belonging to the countdown. - - Methods - ------- - addMessage - Add a message to the list of messages. - parseMessage - Parse a message and adds it to the list of messages. - """ - - 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 + 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 list of messages. + Add a message to the countdown Parameters ---------- message : Message - The message object. + The message object Raises ------ MessageNotAllowedError - If the author posted the last message. + If the author posted the last message MessageIncorrectError - If the message content is incorrect. + If the message content is incorrect """ - if (len(self.messages) != 0 and message.author == self.messages[-1].author): + 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] - async def parseMessage(self, rawMessage): - """ - Parse a message and add it to the list of messages. - - Notes - ----- - If the message is invalid or incorrect, a reacted will be added accordingly. - - Parameters - ---------- - rawMessage : obj - The raw Discord message object. - """ - - try: - # Parse message - message = Message(rawMessage) - - # Add message - self.addMessage(message) - - # Mark important messages - if (str(message.number) in self.reactions): - for reaction in self.reactions[str(message.number)]: - try: - await rawMessage.add_reaction(reaction) - except: - pass - if (self.messages[0].number >= 500 and message.number % (self.messages[0].number // 50) == 0): - await rawMessage.pin() - except MessageNotAllowedError: - await rawMessage.add_reaction("⛔") - except MessageIncorrectError: - await rawMessage.add_reaction("❌") - except: - pass - def contributors(self): """ Get countdown contributor statistics. @@ -322,14 +65,14 @@ class Countdown: """ # Get contributors - authors = list(set([x.author for x in self.messages])) + 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 == author]), + "contributions":len([x for x in self.messages if x.author_id == author]), }] # Sort contributors by contributions @@ -338,7 +81,7 @@ class Countdown: # Return contributors return contributors - def eta(self, period=timedelta(days=1), tz=timedelta(hours=0)): + def eta(self, period=timedelta(days=1)): """ Get countdown eta statistics. @@ -346,8 +89,6 @@ class Countdown: ---------- period : timedelta The period size. The default is 1 day. - tz : timedelta - The timezone. The default is +0 (UTC) Returns ------- @@ -360,26 +101,26 @@ class Countdown: return [[], []] # Initialize period data - periodEnd = self.messages[0].timestamp + tz + period + periodEnd = self.messages[0].timestamp + timedelta(hours=self.timezone) + period lastMessage = 0 # Initialize result and add first data point - data = [[self.messages[0].timestamp + tz], [self.messages[0].timestamp + tz]] + 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 + tz + end = self.messages[-1].timestamp + timedelta(hours=self.timezone) else: - end = datetime.utcnow() + tz + 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 + tz < periodEnd): + 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 + tz)) / timedelta(days=1)) + 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] @@ -426,9 +167,9 @@ class Countdown: # Calculate contributor points points = {} for message in self.messages: - if (message.author not in points): - points[message.author] = { - "author": message.author, + if (message.author_id not in points): + points[message.author_id] = { + "author": message.author_id, "breakdown": { "1000s": 0, "1001s": 0, @@ -442,16 +183,16 @@ class Countdown: "First Number": 0, }, } - if (message.number == self.messages[0].number): points[message.author]["breakdown"]["First Number"] += 1 - elif (message.number % 1000 == 0): points[message.author]["breakdown"]["1000s"] += 1 - elif (message.number % 1000 == 1): points[message.author]["breakdown"]["1001s"] += 1 - elif (message.number % 200 == 0): points[message.author]["breakdown"]["200s"] += 1 - elif (message.number % 200 == 1): points[message.author]["breakdown"]["201s"] += 1 - elif (message.number % 100 == 0): points[message.author]["breakdown"]["100s"] += 1 - elif (message.number % 100 == 1): points[message.author]["breakdown"]["101s"] += 1 - elif (message.number in primes): points[message.author]["breakdown"]["Prime Numbers"] += 1 - elif (message.number % 2 == 1): points[message.author]["breakdown"]["Odd Numbers"] += 1 - else: points[message.author]["breakdown"]["Even Numbers"] += 1 + 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 = [] @@ -511,7 +252,7 @@ class Countdown: "eta": eta, } - def speed(self, period=timedelta(days=1), tz=timedelta(hours=0)): + def speed(self, period=timedelta(days=1)): """ Get countdown speed statistics. @@ -519,8 +260,6 @@ class Countdown: ---------- periodLength : timedelta The period size. The default is 1 day. - tz : timedelta - The timezone. The default is +0 (UTC) Returns ------- @@ -533,7 +272,7 @@ class Countdown: 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 + tz - period >= periodStart): + while (message.timestamp + timedelta(hours=self.timezone) - period >= periodStart): periodStart += period # Add new period if needed @@ -547,30 +286,294 @@ class Countdown: # Return speed statistics return data +class Prefix(Base): + __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): + __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): + __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) + +# Create database +Base.metadata.create_all(bind=engine) +Session = sessionmaker(bind=engine) + + + +# Global variables +data = {} +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, +} +COLORS = { + "error": 0xD52C42, + "embed": 0x248AD1, +} + + + +# Error classes +class MessageNotAllowedError(Exception): + """Raised when someone posts twice in a row.""" + pass + +class MessageIncorrectError(Exception): + """Raised when someone posts an incorrect number.""" + pass + + + +# Static methods +async def getUsername(id): + """ + Get a username from a user ID. + + Parameters + ---------- + id : int + The user ID. + + Returns + ------- + str + The username (ex: "user#0000"). + """ + + user = await bot.fetch_user(id) + return f"{user.name}#{user.discriminator}" + +async def getNickname(server, id): + """ + Get a user's nickname in a server + + Parameters + ---------- + 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(id) + +def getCountdown(session, id): + """ + Get a countdown object + + Parameters + ---------- + session : sqlalchemy.orm.Session + The database session to use + id : int + The countdown id + + Returns + ------- + Countdown + The Countdown + """ + + return session.query(Countdown).filter(Countdown.id == id).first() + +def getContextCountdown(session, ctx, resortToFirst=True): + """ + Get the most relevant countdown to a certain context. + + Parameters + ---------- + session : sqlalchemy.orm.Session + The database session to use + ctx : discord.ext.commands.Context + The context + resortToFirst : bool + Whether to return the 1st countdown if no relevant countdowns are found + + Returns + ------- + Countdown + The countdown + """ + + global data + + if (isinstance(ctx.channel, discord.channel.TextChannel)): + # Countdown channel + countdown = getCountdown(session, ctx.channel.guild.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 + + # First countdown channel + countdown = session.query(Countdown).first() + if (resortToFirst and countdown): return countdown + else: raise Exception("Countdown channel not found") + +def getPrefix(bot, ctx): + """ + Get the bot prefix for a certain context. + + Parameters + ---------- + bot : commands.Bot + The bot + ctx : discord.ext.commands.Context + The context + """ + + with Session() as session: + # Countdown channel + global data + countdown = getCountdown(session, ctx.channel.id) + if (countdown and len(countdown.prefixes) > 0): + return [x.value 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 for x in countdown.prefixes] + if (len(prefixes) > 0): + return list(dict.fromkeys(prefixes)) + + # Return default prefixes + return data["prefixes"] + +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(",","")), + ) + +async def addMessage(countdown, rawMessage): + """ + Parse a message and add it to a countdown + + Notes + ----- + If the message is invalid or incorrect, a reacted will be added accordingly + + Parameters + ---------- + countdown : Countdown + The countdown + rawMessage : discord.Message + The Discord message object + + Returns + ------- + bool + 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 + +async def loadCountdown(bot, countdown): + """ + Loads countdown messages from a Discord countdown + + Parameters + ---------- + bot : commands.Bot + The bot to load messages with + countdown : Countdown + The countdown to load messages for + """ + + # Clear countdown + countdown.messages = [] + + # Get Discord messages + rawMessages = await bot.get_channel(countdown.id).history(limit=10100).flatten() + rawMessages.reverse() + + # Add messages to countdown + for rawMessage in rawMessages: + await addMessage(countdown, rawMessage) + # Load countdown data with open(os.path.join(os.path.dirname(__file__), "data.json"), "a+") as f: f.seek(0) - 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] + data = json.load(f) @@ -589,11 +592,19 @@ async def on_ready(): @bot.event async def on_message(obj): + # Respond to @mentions if bot.user in obj.mentions: 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)].parseMessage(obj) + + # Parse countdown message + with Session() as session: + countdown = getCountdown(session, obj.channel.id) + if (countdown and obj.author.name != "countdown-bot"): + # Add message to countdown and commit changes + if (await addMessage(countdown, obj)): session.commit() + + # Run commands try: await bot.process_commands(obj) except: @@ -619,44 +630,48 @@ async def activate(ctx): Turns a channel into a countdown """ - # Channel is already a coutndown - if (str(ctx.channel.id) in data["countdowns"]): - embed = discord.Embed(title="Error", description="This channel is already a countdown", color=COLORS["error"]) - await ctx.send(embed=embed) + with Session() as session: + # Channel is already a coutndown + if (getCountdown(session, ctx.channel.id)): + embed = discord.Embed(title="Error", description="This channel is already a countdown", color=COLORS["error"]) + await ctx.send(embed=embed) - # Channel is a DM - elif (not isinstance(ctx.channel, discord.channel.TextChannel)): - embed = discord.Embed(title="Error", description="This command must be run inside a server", color=COLORS["error"]) - await ctx.send(embed=embed) + # Channel is a DM + elif (not isinstance(ctx.channel, discord.channel.TextChannel)): + embed = discord.Embed(title="Error", description="This command must be run inside a server", color=COLORS["error"]) + await ctx.send(embed=embed) - # User isn't authorized - elif (not ctx.message.author.guild_permissions.administrator): - embed = discord.Embed(title="Error", description="You must be an administrator to turn a channel into a countdown", color=COLORS["error"]) - await ctx.send(embed=embed) - - # Channel is valid - else: - # Create countdown channel - data["countdowns"][str(ctx.channel.id)] = Countdown(ctx.channel.id, [], ctx.channel.guild.id, 0, data["prefixes"], {}) + # User isn't authorized + elif (not ctx.message.author.guild_permissions.administrator): + embed = discord.Embed(title="Error", description="You must be an administrator to turn a channel into a countdown", color=COLORS["error"]) + await ctx.send(embed=embed) - # Send initial responce - print(f"Activated {bot.get_channel(ctx.channel.id)} as a countdown") - embed = discord.Embed(title=":clock3: Loading Countdown", description="@here This channel is now a countdown\nPlease wait to start counting", color=COLORS["embed"]) - msg = await ctx.send(embed=embed) + # Channel is valid + else: + # 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 data["prefixes"]], + reactions = [], + messages = [], + ) - # Get messages - rawMessages = await bot.get_channel(ctx.channel.id).history(limit=10100).flatten() - rawMessages.reverse() + # Send initial responce + print(f"Activated {bot.get_channel(ctx.channel.id)} as a countdown") + embed = discord.Embed(title=":clock3: Loading Countdown", description="@here This channel is now a countdown\nPlease wait to start counting", color=COLORS["embed"]) + msg = await ctx.send(embed=embed) - # Load messages - for rawMessage in rawMessages: - await data["countdowns"][str(ctx.channel.id)].parseMessage(rawMessage) - saveData(data) + # Load countdown + await loadCountdown(bot, countdown) + session.add(countdown) + session.commit() - # Send final responce - print(f"Loaded messages from {bot.get_channel(ctx.channel.id)}") - embed = discord.Embed(title=":white_check_mark: Countdown Activated", description="@here This channel is now a countdown\nYou may start counting!", color=COLORS["embed"]) - await msg.edit(embed=embed) + # Send final responce + print(f"Loaded messages from {bot.get_channel(ctx.channel.id)}") + embed = discord.Embed(title=":white_check_mark: Countdown Activated", description="@here This channel is now a countdown\nYou may start counting!", color=COLORS["embed"]) + await msg.edit(embed=embed) @@ -666,23 +681,24 @@ async def analytics(ctx): Shows all countdown analytics """ - # Get countdown channel - countdown = getCountdown(ctx) + with Session() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) - # Check if countdown is empty - 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) + # Check if countdown is empty + 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) - # Run analytics commands - else: - await contributors(ctx, "") - await contributors(ctx, "history") - 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) + # Run analytics commands + else: + await contributors(ctx, "") + await contributors(ctx, "history") + 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) @@ -695,69 +711,70 @@ async def config(ctx, key=None, *args): # Create embed embed = discord.Embed(title=":gear: Countdown Settings", color=COLORS["embed"]) - # Get countdown channel - try: - 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:** <#{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+{countdown.timezone}\n" - if (len(countdown.reactions) == 0): - embed.description += f"**Reactions:** none\n" - else: - embed.description += f"**Reactions:**\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" - elif (len(args) == 0): + with Session() as session: + # Get countdown channel + try: + countdown = getContextCountdown(session, ctx, resortToFirst=False) + except: embed.color = COLORS["error"] - embed.description = f"Please provide a value for the setting" - elif (key in ["tz", "timezone"]): - embed.description = f"Done" - try: - countdown.timezone = int(args[0]) - except: + 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:** <#{countdown.id}>\n" + embed.description += f"**Command Prefixes:** `{'`, `'.join([x.value for x in countdown.prefixes])}`\n" + if (countdown.timezone < 0): + embed.description += f"**Countdown Timezone:** UTC-{-1 * countdown.timezone}\n" + else: + 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 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" + elif (not ctx.message.author.guild_permissions.administrator): + embed.color = COLORS["error"] + embed.description = f"You must be an administrator to modify settings" + elif (len(args) == 0): + embed.color = COLORS["error"] + embed.description = f"Please provide a value for the setting" + elif (key in ["tz", "timezone"]): + embed.description = f"Done" try: - countdown.timezone = float(args[0]) + countdown.timezone = int(args[0]) + except: + try: + countdown.timezone = float(args[0]) + except: + embed.color = COLORS["error"] + embed.description = f"Invalid timezone: {args[0]}" + elif (key in ["prefix", "prefixes"]): + countdown.prefixes = [Prefix(countdown_id=ctx.channel.id, value=x) for x in args] + embed.description = f"Done" + elif (key in ["react"]): + try: + number = int(args[0]) + if (number < 0): + embed.color = COLORS["error"] + embed.description = f"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: embed.color = COLORS["error"] - embed.description = f"Invalid timezone: {args[0]}" - elif (key in ["prefix", "prefixes"]): - countdown.prefixes = args - embed.description = f"Done" - elif (key in ["react"]): - try: - number = int(args[0]) - if (number < 0): - embed.color = COLORS["error"] - embed.description = f"Number must be greater than zero" - elif (len(args) == 1): - if (str(number) in countdown.reactions): - del countdown.reactions[str(number)] - embed.description = f"Removed reactions for #{number}" - else: - countdown.reactions[str(number)] = args[1:] - embed.description = f"Updated reactions for #{number}" - except: + embed.description = f"Invalid number: {args[0]}" + else: embed.color = COLORS["error"] - embed.description = f"Invalid number: {args[0]}" - else: - embed.color = COLORS["error"] - embed.description = f"Setting not found: `{key}`\n" - embed.description += f"Use `{(await bot.get_prefix(ctx))[0]}help config` to view the list of settings" + embed.description = f"Setting not found: `{key}`\n" + embed.description += f"Use `{(await bot.get_prefix(ctx))[0]}help config` to view the list of settings" - # Save changes - saveData(data) + # Save changes + session.commit() # Send embed await ctx.send(embed=embed) @@ -770,90 +787,91 @@ async def contributors(ctx, option=""): Shows information about countdown contributors """ - # Get countdown channel - countdown = getCountdown(ctx) + with Session() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) - # Create temp file - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") - tmp.close() + # Create temp file + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + tmp.close() - # Get stats - stats = countdown.progress() - contributors = countdown.contributors() + # Get stats + stats = countdown.progress() + contributors = countdown.contributors() - # Create embed - embed=discord.Embed(title=":busts_in_silhouette: Countdown Contributors", color=COLORS["embed"]) + # Create embed + embed=discord.Embed(title=":busts_in_silhouette: Countdown Contributors", color=COLORS["embed"]) - # Make sure the countdown has started - if (len(countdown.messages) == 0): - embed.color = COLORS["error"] - embed.description = "The countdown is empty." - elif (option.lower() in ["h", "history"]): - # Create figure - fig, ax = plt.subplots() - ax.set_xlabel("Progress") - ax.set_ylabel("Percentage of Contributions") - ax.yaxis.set_major_formatter(PercentFormatter()) - - # Get historical contributor data - authors = {} - for author in contributors: - authors[author["author"]] = [{"progress":0, "percentage":0, "total":0}] - 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}] - else: - authors[author] += [{"progress":(stats["total"] - message.number), "percentage":(authors[author][-1]["total"] + 0)/(stats["total"] - message.number + 1) * 100, "total":authors[author][-1]["total"] + 0}] - - # Plot data and add legend - for author in list(authors.keys())[:min(len(authors), 15)]: - # Top 15 contributors get included in the legend - ax.plot([x["progress"] for x in authors[author]], [x["percentage"] for x in authors[author]], label=await getUsername(author)) - for author in list(authors.keys())[15:max(len(authors), 15)]: - ax.plot([x["progress"] for x in authors[author]], [x["percentage"] for x in authors[author]]) - ax.legend(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}>" - embed.set_image(url="attachment://image.png") - elif (option == ""): - # Create figure - fig, ax = plt.subplots() - - # 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) - - # Add legend - ax.legend(pieData[0], [await getUsername(i) for i in x[:min(len(x), 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.set_image(url="attachment://image.png") - else: - embed.color = COLORS["error"] - embed.description = f"Unrecognized option: `{option}`\n" - embed.description += f"Use `{(await bot.get_prefix(ctx))[0]}help contributors` to view help information" + # Make sure the countdown has started + if (len(countdown.messages) == 0): + embed.color = COLORS["error"] + embed.description = "The countdown is empty." + elif (option.lower() in ["h", "history"]): + # Create figure + fig, ax = plt.subplots() + ax.set_xlabel("Progress") + ax.set_ylabel("Percentage of Contributions") + ax.yaxis.set_major_formatter(PercentFormatter()) + + # Get historical contributor data + authors = {} + for author in contributors: + authors[author["author"]] = [{"progress":0, "percentage":0, "total":0}] + for message in countdown.messages: + for author in authors: + if (author == message.author_id): + 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}] + else: + authors[author] += [{"progress":(stats["total"] - message.number), "percentage":(authors[author][-1]["total"] + 0)/(stats["total"] - message.number + 1) * 100, "total":authors[author][-1]["total"] + 0}] + + # Plot data and add legend + for author in list(authors.keys())[:min(len(authors), 15)]: + # Top 15 contributors get included in the legend + ax.plot([x["progress"] for x in authors[author]], [x["percentage"] for x in authors[author]], label=await getUsername(author)) + for author in list(authors.keys())[15:max(len(authors), 15)]: + ax.plot([x["progress"] for x in authors[author]], [x["percentage"] for x in authors[author]]) + ax.legend(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}>" + embed.set_image(url="attachment://image.png") + elif (option == ""): + # Create figure + fig, ax = plt.subplots() + + # 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) + + # Add legend + ax.legend(pieData[0], [await getUsername(i) for i in x[:min(len(x), 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.set_image(url="attachment://image.png") + else: + embed.color = COLORS["error"] + embed.description = f"Unrecognized option: `{option}`\n" + embed.description += f"Use `{(await bot.get_prefix(ctx))[0]}help contributors` to view help information" # Send embed try: @@ -875,26 +893,28 @@ async def deactivate(ctx): Deactivates a countdown channel """ - # Channel isn't a countdown - if (str(ctx.channel.id) not in data["countdowns"]): - embed = discord.Embed(title="Error", description="This channel isn't a countdown", color=COLORS["error"]) - await ctx.send(embed=embed) + with Session() as session: + # Channel isn't a countdown + countdown = getCountdown(session, ctx.channel.id) + if (not countdown): + embed = discord.Embed(title="Error", description="This channel isn't a countdown", color=COLORS["error"]) + await ctx.send(embed=embed) - # User isn't authorized - elif (not ctx.message.author.guild_permissions.administrator): - embed = discord.Embed(title="Error", description="You must be an administrator to deactivate a countdown channel", color=COLORS["error"]) - await ctx.send(embed=embed) + # User isn't authorized + elif (not ctx.author.guild_permissions.administrator): + embed = discord.Embed(title="Error", description="You must be an administrator to deactivate a countdown channel", color=COLORS["error"]) + await ctx.send(embed=embed) - # Channel is valid - else: - # Add channel data - del data["countdowns"][str(ctx.channel.id)] - saveData(data) + # Channel is valid + else: + # Delete countdown + session.delete(countdown) + session.commit() - # Send initial responce - print(f"Deactivated {bot.get_channel(ctx.channel.id)} as a countdown") - embed = discord.Embed(title=":octagonal_sign: Countdown Deactivated", description="@here This channel is no longer a countdown", color=COLORS["embed"]) - await ctx.send(embed=embed) + # Send responce + print(f"Deactivated {bot.get_channel(ctx.channel.id)} as a countdown") + embed = discord.Embed(title=":octagonal_sign: Countdown Deactivated", description="@here This channel is no longer a countdown", color=COLORS["embed"]) + await ctx.send(embed=embed) @@ -904,68 +924,69 @@ async def eta(ctx, period="24.0"): Shows information about the estimated completion date """ - # Get countdown channel - countdown = getCountdown(ctx) + with Session() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) - # Create temp file - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") - tmp.close() + # Create temp file + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + tmp.close() - # Create embed - embed=discord.Embed(title=":calendar: Countdown Estimated Completion Date", color=COLORS["embed"]) + # Create embed + embed=discord.Embed(title=":calendar: Countdown Estimated Completion Date", color=COLORS["embed"]) - # Parse period - try: - period = float(period) - except: - embed.color = COLORS["error"] - embed.description = "The period must be a number" - else: - if (len(countdown.messages) < 2): - embed.color = COLORS["embed"] - embed.description = "The countdown must have at least two messages" - elif (period < 0.01): + # Parse period + try: + period = float(period) + except: embed.color = COLORS["error"] - embed.description = "The period cannot be less than 0.01 hours" + embed.description = "The period must be a number" else: - # Get stats - eta = countdown.eta(timedelta(hours=period), tz=timedelta(hours=countdown.timezone)) - - # Create figure - fig, ax = plt.subplots() - ax.set_xlabel("Time") - fig.autofmt_xdate() - - # Add ETA data to graph - ax.plot(eta[0], eta[1], "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") - - # Add legend - ax.legend() - - # Save graph - fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) - 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() - - # Add content to embed - 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): - embed.description += f"**Actual Completion Date:** {end.date()} ({(-1 * endDiff).days:,} days ago)\n" + if (len(countdown.messages) < 2): + embed.color = COLORS["embed"] + embed.description = "The countdown must have at least two messages" + elif (period < 0.01): + embed.color = COLORS["error"] + embed.description = "The period cannot be less than 0.01 hours" else: - embed.description += f"**Current Estimate:** {end.date()} ({endDiff.days:,} days from now)\n" - embed.set_image(url="attachment://image.png") + # Get stats + eta = countdown.eta(timedelta(hours=period)) + + # Create figure + fig, ax = plt.subplots() + ax.set_xlabel("Time") + fig.autofmt_xdate() + + # Add ETA data to graph + ax.plot(eta[0], eta[1], "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") + + # Add legend + ax.legend() + + # Save graph + fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) + 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() + + # Add content to embed + 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): + embed.description += f"**Actual Completion Date:** {end.date()} ({(-1 * endDiff).days:,} days ago)\n" + else: + embed.description += f"**Current Estimate:** {end.date()} ({endDiff.days:,} days from now)\n" + embed.set_image(url="attachment://image.png") # Send embed try: @@ -1155,94 +1176,95 @@ async def leaderboard(ctx, user=None): Shows the countdown leaderboard """ - # Get countdown channel - countdown = getCountdown(ctx) + with Session() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) - # Get leaderboard - leaderboard = countdown.leaderboard() + # Get leaderboard + leaderboard = countdown.leaderboard() - # Create embed - embed=discord.Embed(title=":trophy: Countdown Leaderboard", color=COLORS["embed"]) + # Create embed + embed=discord.Embed(title=":trophy: Countdown Leaderboard", color=COLORS["embed"]) - # Make sure the countdown has started - 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:** <#{countdown.id}>" - - # 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" - 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) - - # Add leaderboard rules - rules = "" - values = "" - for rule in POINT_RULES: - rules += f"{rule}\n" - values += f"{POINT_RULES[rule]} 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: - rank = None - if (re.match("^\d+$", user) and int(user) > 0 and int(user) <= len(leaderboard)): - # Get user from rank - rank = int(user) - 1 - elif (re.match("^<@!\d+>$", user) and int(user[3:-1]) in [x["author"] for x in leaderboard]): - # Get user from mention - rank = [x["author"] for x in leaderboard].index(int(user[3:-1])) + # Make sure the countdown has started + 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:** <#{countdown.id}>" + + # 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" + 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) + + # Add leaderboard rules + rules = "" + values = "" + for rule in POINT_RULES: + rules += f"{rule}\n" + values += f"{POINT_RULES[rule]} 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 from username - for contributor in leaderboard: - username = await getUsername(contributor["author"]) - if (username.lower().startswith(user.lower())): - rank = leaderboard.index(contributor) - - if (rank == None): - # Get user from nickname + rank = None + if (re.match("^\d+$", user) and int(user) > 0 and int(user) <= len(leaderboard)): + # Get user from rank + rank = int(user) - 1 + elif (re.match("^<@!\d+>$", user) and int(user[3:-1]) in [x["author"] for x in leaderboard]): + # Get user from mention + rank = [x["author"] for x in leaderboard].index(int(user[3:-1])) + else: + # Get user from username for contributor in leaderboard: - nickname = await getNickname(countdown.server, contributor["author"]) - if (nickname.lower().startswith(user.lower())): + username = await getUsername(contributor["author"]) + if (username.lower().startswith(user.lower())): rank = leaderboard.index(contributor) if (rank == None): - # User not found - embed.color = COLORS["error"] - embed.description = f"User not found: `{user}`" - await ctx.send(embed=embed) - return - - # 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" - - # 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" - else: - percentage += "0%\n" - embed.add_field(name="Category", value=rules, inline=True) - embed.add_field(name="Points", value=points, inline=True) - embed.add_field(name="Percentage", value=percentage, inline=True) + # Get user from nickname + for contributor in leaderboard: + nickname = await getNickname(countdown.server_id, contributor["author"]) + if (nickname.lower().startswith(user.lower())): + rank = leaderboard.index(contributor) + + if (rank == None): + # User not found + embed.color = COLORS["error"] + embed.description = f"User not found: `{user}`" + await ctx.send(embed=embed) + return + + # 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" + + # 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" + else: + percentage += "0%\n" + embed.add_field(name="Category", value=rules, inline=True) + embed.add_field(name="Points", value=points, inline=True) + embed.add_field(name="Percentage", value=percentage, inline=True) # Send embed await ctx.send(embed=embed) @@ -1267,55 +1289,56 @@ async def progress(ctx): Shows information about countdown progress """ - # Get countdown channel - countdown = getCountdown(ctx) + with Session() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) - # Create temp file - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") - tmp.close() + # Create temp file + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + tmp.close() - # Create embed - embed=discord.Embed(title=":chart_with_downwards_trend: Countdown Progress", color=COLORS["embed"]) + # Create embed + embed=discord.Embed(title=":chart_with_downwards_trend: Countdown Progress", color=COLORS["embed"]) - # Make sure the countdown has started - if (len(countdown.messages) == 0): - embed.color = COLORS["error"] - embed.description = "The countdown is empty." - else: - # Get progress stats - stats = countdown.progress() - - # Create figure - fig, ax = plt.subplots() - ax.set_xlabel("Time") - ax.set_ylabel("Progress") - 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"]] - ax.plot(x, y) - - # Save graph - fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) - file = discord.File(tmp.name, filename="image.png") - - # Calculate embed data - 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() - - # 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"**Start Date:** {start} ({startDiff:,} days ago)\n" - if endDiff < timedelta(seconds=0): - embed.description += f"**End Date:** {end} ({(-1 * endDiff).days:,} days ago)\n" + # Make sure the countdown has started + if (len(countdown.messages) == 0): + embed.color = COLORS["error"] + embed.description = "The countdown is empty." else: - embed.description += f"**Estimated End Date:** {end} ({endDiff.days:,} days from now)\n" - embed.set_image(url="attachment://image.png") + # Get progress stats + stats = countdown.progress() + + # Create figure + fig, ax = plt.subplots() + ax.set_xlabel("Time") + ax.set_ylabel("Progress") + 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"]] + ax.plot(x, y) + + # Save graph + fig.savefig(tmp.name, bbox_inches="tight", pad_inches=0.2) + file = discord.File(tmp.name, filename="image.png") + + # Calculate embed data + 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() + + # 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"**Start Date:** {start} ({startDiff:,} days ago)\n" + if endDiff < timedelta(seconds=0): + embed.description += f"**End Date:** {end} ({(-1 * endDiff).days:,} days ago)\n" + else: + embed.description += f"**Estimated End Date:** {end} ({endDiff.days:,} days from now)\n" + embed.set_image(url="attachment://image.png") # Send embed try: @@ -1337,31 +1360,25 @@ async def reload(ctx): Reloads the countdown cache """ - if (str(ctx.channel.id) in data["countdowns"]): - # Send inital responce - print(f"Reloading messages from {bot.get_channel(ctx.channel.id)}") - embed = discord.Embed(title=":clock3: Reloading Countdown Cache", description="Please wait to continue counting.", color=COLORS["embed"]) - msg = await ctx.channel.send(embed=embed) - - # Get messages - rawMessages = await bot.get_channel(ctx.channel.id).history(limit=10100).flatten() - rawMessages.reverse() - - # Create countdown - data["countdowns"][str(ctx.channel.id)].messages = [] - - # Load messages - for rawMessage in rawMessages: - 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)}") - 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: - embed = discord.Embed(title="Error", description="This command must be used in a countdown channel", color = COLORS["error"]) - await ctx.channel.send(embed=embed) + with Session() as session: + countdown = getCountdown(session, ctx.channel.id) + if (countdown): + # Send inital responce + print(f"Reloading messages from {bot.get_channel(ctx.channel.id)}") + 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(bot, countdown) + session.commit() + + # Send final responce + print(f"Reloaded messages from {bot.get_channel(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: + embed = discord.Embed(title="Error", description="This command must be used in a countdown channel", color = COLORS["error"]) + await ctx.channel.send(embed=embed) @@ -1371,61 +1388,62 @@ async def speed(ctx, period="24.0"): Shows information about countdown speed """ - # Get countdown channel - countdown = getCountdown(ctx) + with Session() as session: + # Get countdown channel + countdown = getContextCountdown(session, ctx) - # Create temp file - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") - tmp.close() + # Create temp file + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + tmp.close() - # Create embed - embed=discord.Embed(title=":stopwatch: Countdown Speed", color=COLORS["embed"]) + # Create embed + embed=discord.Embed(title=":stopwatch: Countdown Speed", color=COLORS["embed"]) - # Parse period - try: - period = float(period) - except: - embed.color = COLORS["error"] - embed.description = "The period must be a number" - else: - if (len(countdown.messages) == 0): - embed.color = COLORS["error"] - embed.description = "The countdown is empty." - elif (period < 0.01): + # Parse period + try: + period = float(period) + except: embed.color = COLORS["error"] - embed.description = "The period cannot be less than 0.01 hours" + embed.description = "The period must be a number" else: - # Get stats - stats = countdown.progress() - period = timedelta(hours=period) - speed = countdown.speed(period, tz=timedelta(hours=countdown.timezone)) - - # Create figure - fig, ax = plt.subplots() - ax.set_xlabel("Time") - ax.set_ylabel("Progress per Period") - 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") - - # 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}>\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) + if (len(countdown.messages) == 0): + embed.color = COLORS["error"] + embed.description = "The countdown is empty." + elif (period < 0.01): + embed.color = COLORS["error"] + embed.description = "The period cannot be less than 0.01 hours" 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.set_image(url="attachment://image.png") + # Get stats + stats = countdown.progress() + period = timedelta(hours=period) + speed = countdown.speed(period) + + # Create figure + fig, ax = plt.subplots() + ax.set_xlabel("Time") + ax.set_ylabel("Progress per Period") + 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") + + # 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}>\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.set_image(url="attachment://image.png") # Send embed try: diff --git a/requirements.txt b/requirements.txt @@ -1,2 +1,3 @@ discord matplotlib +sqlalchemy diff --git a/setup.py b/setup.py @@ -6,7 +6,6 @@ import os with open(os.path.join(os.path.dirname(__file__), "data.json"), "w") as f: data = { "token": "YOUR_TOKEN_HERE", - "prefixes": ["c."], - "countdowns": {} + "prefixes": ["c."] } json.dump(data, f)