From e22f67ab17f13399fc59760d28f7db6458d97075 Mon Sep 17 00:00:00 2001 From: LaDfBC Date: Sun, 8 Sep 2019 18:51:44 -0500 Subject: [PATCH] Working base version --- .idea/ghostballBot.iml | 12 ++ .idea/misc.xml | 4 + .idea/modules.xml | 8 + .idea/workspace.xml | 91 +++++++++++ src/__init__.py | 0 src/main/__init__.py | 0 src/main/configs.py | 51 ++++++ src/main/database_module/__init__.py | 0 .../database_classes/__init__.py | 0 .../database_classes/db_classes.py | 31 ++++ src/main/database_module/guess_dao.py | 85 ++++++++++ src/main/database_module/play_dao.py | 79 ++++++++++ src/main/db_session.py | 26 ++++ src/main/discord_module/__init__.py | 0 src/main/discord_module/bot_runner.py | 146 ++++++++++++++++++ src/main/discord_module/leaderboard_config.py | 15 ++ src/test/__init__.py | 0 17 files changed, 548 insertions(+) create mode 100644 .idea/ghostballBot.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/workspace.xml create mode 100644 src/__init__.py create mode 100644 src/main/__init__.py create mode 100644 src/main/configs.py create mode 100644 src/main/database_module/__init__.py create mode 100644 src/main/database_module/database_classes/__init__.py create mode 100644 src/main/database_module/database_classes/db_classes.py create mode 100644 src/main/database_module/guess_dao.py create mode 100644 src/main/database_module/play_dao.py create mode 100644 src/main/db_session.py create mode 100644 src/main/discord_module/__init__.py create mode 100644 src/main/discord_module/bot_runner.py create mode 100644 src/main/discord_module/leaderboard_config.py create mode 100644 src/test/__init__.py diff --git a/.idea/ghostballBot.iml b/.idea/ghostballBot.iml new file mode 100644 index 0000000..6f63a63 --- /dev/null +++ b/.idea/ghostballBot.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..65531ca --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a22a155 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..2754369 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1564362047285 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main/__init__.py b/src/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main/configs.py b/src/main/configs.py new file mode 100644 index 0000000..bcfe843 --- /dev/null +++ b/src/main/configs.py @@ -0,0 +1,51 @@ +# Names of Configurations +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +DATABASE_USERNAME = 'database_username' +DATABASE_PASSWORD = 'database_password' +DATABASE_HOST = 'database_host' +DATABASE_PORT = 'database_port' +DATABASE_NAME = 'database_name' +SEASON_1_SPREADSHEET_ID = 's1_spreadsheet_id' +SEASON_2_SPREADSHEET_ID = 's2_spreadsheet_id' +PLAYER_SPREADSHEET = 'player_spreadsheet' + +''' +Main source for configurations fetched from a startup configuration file. Includes the ability to fetch all, or fetch +one configuration once the file is loaded. + +You'll find the names of these configs above as constants that can be used throughout the rest of this repository +''' +class Configs(): + configs = {} + + def __init__(self, config_file_path): + self.config_file_path = config_file_path + self.__load_configs__() + + ''' + Fetches a single configuration by the name of that configuration. + Returns None if that configuration does not exist + ''' + def get_config_by_name(self, name): + try: + return Configs.configs[name] + except KeyError: + return None + + ''' + Fetches all configurations and returns them as a dictionary of config_key -> config_value + ''' + def get_all_configs(self): + return Configs.configs + + ''' + Performs the initial load of configurations from a startup configuration file + ''' + def __load_configs__(self): + Configs.configs = {} + config_file = open(self.config_file_path, 'r') + for line in config_file: + split_line = line.split('=') + Configs.configs[split_line[0]] = split_line[1].strip('\n') \ No newline at end of file diff --git a/src/main/database_module/__init__.py b/src/main/database_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main/database_module/database_classes/__init__.py b/src/main/database_module/database_classes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main/database_module/database_classes/db_classes.py b/src/main/database_module/database_classes/db_classes.py new file mode 100644 index 0000000..fcc4576 --- /dev/null +++ b/src/main/database_module/database_classes/db_classes.py @@ -0,0 +1,31 @@ +from sqlalchemy import Column, String, Integer, ForeignKey, Date +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import relationship + +from src.main.db_session import DatabaseSession + +Base = declarative_base() + +class Play(Base): + __tablename__ = 'play' + + play_id = Column(String, nullable=False, primary_key=True) + pitch_value = Column(Integer, nullable=True) + swing_value = Column(Integer, nullable=False) + creation_date = Column(Date, nullable=False) + + guesses = relationship(lambda : Guess) + +class Guess(Base): + __FAKE_VALUE__ = -5000 + + __tablename__ = 'guess' + member_id = Column(String, nullable=False, primary_key=True) + play_id = Column(UUID, ForeignKey(Play.play_id), nullable=False, primary_key=True) + guessed_number = Column(Integer, nullable=False) + member_name = Column(String, nullable=False) + difference = Column(Integer) + + play = relationship("Play", back_populates="guesses") diff --git a/src/main/database_module/guess_dao.py b/src/main/database_module/guess_dao.py new file mode 100644 index 0000000..dedcab0 --- /dev/null +++ b/src/main/database_module/guess_dao.py @@ -0,0 +1,85 @@ +from copy import deepcopy + +from src.main.database_module.database_classes.db_classes import Guess +from src.main.db_session import DatabaseSession + +MEMBER_ID = 'member_id' +PLAY_ID = 'play_id' +GUESSED_NUMBER = 'guessed_number' +DIFFERENCE = 'difference' +MEMBER_NAME = 'member_name' + +class GuessDAO(): + db_string = None + session = None + Session = None + engine = None + + def __init__(self): + pass + + def insert(self, guess_info): + session = DatabaseSession.session + + guess = Guess( + member_id=guess_info[MEMBER_ID], + play_id = guess_info[PLAY_ID], + guessed_number = guess_info[GUESSED_NUMBER], + member_name = guess_info[MEMBER_NAME] + ) + + existing_guess = self.__convert_all__(session\ + .query(Guess)\ + .filter(Guess.member_id == guess_info[MEMBER_ID], Guess.play_id == guess_info[PLAY_ID])) + + if len(existing_guess) == 0: + session.add(guess) + session.commit() + return True + else: + return False + + ''' + Converts the database object into a Dictionary, so that the database object is not passed out of the + datastore layer. + ''' + def __convert_all__(self, games): + converted_games = [] + for game in games: + game_dict = {} + for column in game.__dict__: + game_dict[column] = str(getattr(game, column)) + + converted_games.append(deepcopy(game_dict)) + + return converted_games + + def set_differences(self, pitch_value, play_id): + session = DatabaseSession.session + games_to_update = self.__convert_all__(session.query(Guess).filter(Guess.play_id == play_id)) + + for game in games_to_update: + difference = self.calculate_difference(pitch_value, game[GUESSED_NUMBER]) + session.query(Guess).filter(Guess.member_id == game[MEMBER_ID], Guess.play_id == game[PLAY_ID]).update({Guess.difference: difference}) + + session.commit() + + def calculate_difference(self, pitch_value, guess_value): + pitched_number = int(pitch_value) + possible_value = abs(int(guess_value) - pitched_number) + + if possible_value > 500: + return 1000 - possible_value + else: + return possible_value + + + def fetch_closest(self, num_to_fetch): + session = DatabaseSession.session + + return self.__convert_all__( + session\ + .query(Guess)\ + .order_by(Guess.difference)\ + .limit(num_to_fetch) + ) \ No newline at end of file diff --git a/src/main/database_module/play_dao.py b/src/main/database_module/play_dao.py new file mode 100644 index 0000000..46c5b32 --- /dev/null +++ b/src/main/database_module/play_dao.py @@ -0,0 +1,79 @@ +from copy import deepcopy + +from src.main.db_session import DatabaseSession +from src.main.database_module.database_classes.db_classes import Play + +PLAY_ID = 'play_id' +PITCH_VALUE = 'pitch_value' +SWING_VALUE = 'swing_value' +CREATION_DATE = 'creation_date' + +class PlayDAO(): + db_string = None + session = None + Session = None + engine = None + + def __init__(self): + pass + + def insert(self, play_info): + session = DatabaseSession.session + + play = Play( + play_id = play_info[PLAY_ID], + pitch_value = play_info[PITCH_VALUE] if PITCH_VALUE in play_info else None, + swing_value = play_info[SWING_VALUE] if SWING_VALUE in play_info else None, + creation_date = play_info[CREATION_DATE] + ) + + session.add(play) + session.commit() + + def get_play_by_id(self, input_id): + session = DatabaseSession.session + return self.__convert_all__(session.query(Play).filter(Play.play_id == input_id)) + + ''' + Checks to see if there is a play that is currently active or not + ''' + def is_active_play(self): + return self.get_active_play() != None + + def get_active_play(self): + session = DatabaseSession.session + plays = self.__convert_all__(session.query(Play).filter(Play.pitch_value == None)) + + if len(plays) > 1: + raise AssertionError("More than one active play! Can't continue!") + elif len(plays) == 0: + return None + else: + return plays[0] + + def resolve_play(self, input_pitch, input_swing): + session = DatabaseSession.session + active_id = self.get_active_play() + + session\ + .query(Play)\ + .filter(Play.pitch_value == None)\ + .update({Play.pitch_value: input_pitch, Play.swing_value: input_swing}) + session.commit() + + return active_id + + ''' + Converts the database object into a Dictionary, so that the database object is not passed out of the + datastore layer. + ''' + def __convert_all__(self, plays): + converted_plays = [] + for play in plays: + play_dict = {} + for column in play.__dict__: + play_dict[column] = str(getattr(play, column)) + + converted_plays.append(deepcopy(play_dict)) + + return converted_plays diff --git a/src/main/db_session.py b/src/main/db_session.py new file mode 100644 index 0000000..c21759e --- /dev/null +++ b/src/main/db_session.py @@ -0,0 +1,26 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import sys +sys.path.append('../../../../../src') + +from src.main.configs import Configs, DATABASE_USERNAME, DATABASE_PASSWORD, DATABASE_HOST, DATABASE_NAME + +''' +Stores a database session for use throughout the application. Must be initialized at startup before any database calls +are made and AFTER the Configurations are setup. + +This shouldn't need to be touched after startup. To use, see the sqlalchemy docs...or just start by calling Session() +and then use it to handle the necessary CRUD operations. + +You should NOT instantiate this in any method except the main application runner +''' +class DatabaseSession(): + session = None + + def __init__(self): + config_map = Configs.configs + db_string = 'postgresql://%s:%s@%s/%s' % \ + (config_map[DATABASE_USERNAME], config_map[DATABASE_PASSWORD], config_map[DATABASE_HOST], config_map[DATABASE_NAME]) + Session = sessionmaker(create_engine(db_string)) + DatabaseSession.session = Session() \ No newline at end of file diff --git a/src/main/discord_module/__init__.py b/src/main/discord_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main/discord_module/bot_runner.py b/src/main/discord_module/bot_runner.py new file mode 100644 index 0000000..1bdaf3d --- /dev/null +++ b/src/main/discord_module/bot_runner.py @@ -0,0 +1,146 @@ +import sys + +import discord +import uuid +import datetime + +from src.main.configs import Configs +from src.main.database_module.guess_dao import GuessDAO, GUESSED_NUMBER, MEMBER_ID, MEMBER_NAME +from src.main.database_module.play_dao import PlayDAO, PLAY_ID, CREATION_DATE +from src.main.db_session import DatabaseSession +from src.main.discord_module.leaderboard_config import LeaderboardConfig + +play_dao = None +guess_dao = None +bot = discord.Client() + +@bot.event +async def on_ready(): + print('Logged in as') + print(bot.user.name) + print(bot.user.id) + print('------') + +@bot.event +async def on_message(message): + if message.author == bot.user: + return + + content = message.content + + ''' + Sets up the next set of guesses. + ''' + if content.startswith('!ghostball'): + if play_dao.is_active_play(): + await bot.send_message(message.channel, "Um, I think there's already an active play. Could you close that one first, please?") + else: + play_object = {PLAY_ID : uuid.uuid4(), CREATION_DATE : datetime.datetime.now()} + play_dao.insert(play_object) + + await bot.send_message(message.channel, "@everyone, pitch is in! Send me your guesses with a !guess command.") + + if content.startswith("!guess"): + guess_value = __parse_guess__(content) + + if not play_dao.is_active_play(): + await bot.send_message(message.channel, "Hey, there's no active play! Start one up first with !ghostball.") + else: + play = play_dao.get_active_play() + guess_object = {PLAY_ID: play['play_id'], + MEMBER_ID: str(message.author.id), + GUESSED_NUMBER: guess_value, + MEMBER_NAME: str(message.author.name)} + + if guess_dao.insert(guess_object): + await bot.add_reaction(message, emoji="\N{THUMBS UP SIGN}") + + # Closes off the active play to be ready for the next set + if content.startswith('!resolve'): + # try: + pitch_value, swing_value = __parse_resolve_play__(content) + if pitch_value is None or swing_value is None: + await bot.send_message(message.channel, "Hey " + "<@" + str(message.author.id) + ">, I'm not sure what you meant. " + "You need real, numeric, values for this command to work. " + "Use !resolve and try again.") + + # Check if we have an active play + if not play_dao.is_active_play(): + await bot.send_message(message.channel, "You confused me. There's no active play so I have nothing to close!") + else: + play = play_dao.resolve_play(pitch_value, swing_value) + guess_dao.set_diff erences(pitch_value, play['play_id']) + + await bot.send_message(message.channel, + "You got it boss. Closed this play, no further guesses allowed!") + + # Likely due to too few parameters but could be any number of things + # except : + # await bot.send_message(message.channel, "Hey " + "<@" + str(message.author.id) + ">, you confused me with that message. " + # "To close an active pitch, the proper command is !resolve . " + # "Use that format and try again, ok?") + + if content.startswith('!leaderboard'): + leaderboard_config = __parse_leaderboard_message__(content) + + if leaderboard_config.should_sort_by_pure_closest(): + values = guess_dao.fetch_closest(10) + + string_to_send = '' + for i, value in enumerate(values): + string_to_send += str(i + 1) + ': ' + value['member_name'] + ', ' + value['difference'] + '\n' + + await bot.send_message(message.channel, string_to_send) + + elif leaderboard_config.should_sort_by_best_average(): + pass + else: + await bot.send_message(message.channel, "I don't understand that leaderboard command, sorry! I know it's a little confusing, so send me" + " a !help message if you want the full rundown for how to make this work!") + + if content.startswith('!help'): + help_message = __get_help_message__() + recipient = await bot.get_user_info(message.author.id) + await bot.send_message(recipient, help_message) + +def __get_help_message__(): + # Start message with person who asked for help + help_message = "Hey! I can be instructed to do any number of things! Use the following commands: \n" \ + "!guess --- This will add your guess to the currently active play. " \ + "I will give you a thumbs up if everything worked!\n" \ + "!ghostball --- Starts a new play. I'll let you know if this didn't work for some reason!\n" \ + "!help --- You just asked for this. If you ask for it again, I'll repeat myself.\n" \ + "!resolve --- Uses the pitch number and real swing number " \ + "to figure out who was closest and ends the active play.\n" \ + "\n" + + return help_message + + +def __parse_leaderboard_message__(message_content): + return LeaderboardConfig(message_content) + +def __parse_guess__(message_content): + pieces = message_content.split(' ') + try: + return pieces[1] + except TypeError: + return None + +def __parse_resolve_play__(message_content): + pieces = message_content.split(' ') + try: + return pieces[1], pieces[2] + except TypeError: + return None + +if __name__ == '__main__': + args = sys.argv + token = args[1] + file_path = args[2] + + configs = Configs(file_path) + databaseSession = DatabaseSession() + play_dao = PlayDAO() + guess_dao = GuessDAO() + bot.run(token) \ No newline at end of file diff --git a/src/main/discord_module/leaderboard_config.py b/src/main/discord_module/leaderboard_config.py new file mode 100644 index 0000000..1fc6b44 --- /dev/null +++ b/src/main/discord_module/leaderboard_config.py @@ -0,0 +1,15 @@ +class LeaderboardConfig(): + closest = True + def __init__(self, message_content): + pieces = message_content.split(' ') + if len(pieces) == 1: + return + + if pieces[1] == 'average': + self.closest = False + + def should_sort_by_pure_closest(self): + return self.closest + + def should_sort_by_best_average(self): + return not self.closest diff --git a/src/test/__init__.py b/src/test/__init__.py new file mode 100644 index 0000000..e69de29