From 5b06d59c9310e6390a301de806c090c41d7a1a3d Mon Sep 17 00:00:00 2001 From: Stefan Harmuth Date: Sun, 26 Nov 2023 14:22:15 +0100 Subject: [PATCH] generalized aoc bot --- README.md | 16 +-- aoc_bot.py | 294 ++++++++++++++++++++++++++++++++++++++++++++ config.example.json | 29 +++++ main.py | 41 ------ requirements.txt | 3 +- skel_day.py | 24 ---- start_day.py | 51 -------- 7 files changed, 331 insertions(+), 127 deletions(-) create mode 100644 aoc_bot.py create mode 100644 config.example.json delete mode 100644 main.py delete mode 100644 skel_day.py delete mode 100644 start_day.py diff --git a/README.md b/README.md index 59a23c9..31b51ef 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,12 @@ -# aoc_template +# AoC Bot -Template for yearly AoC-Repositories +Announce gained stars from private leaderboard into IRC Channel # Usage -- Clone repository (or use as template in gitea) +- Clone repository - Run `pip install -r requirements.txt` -- Update main.py and start_day.py: set YEAR (near the top) to the respective year -- Create a file named ".session" next to your main.py containing the contents of your aoc-session cookie +- Copy config.example.json to config.json and edit according to your needs +- Create the "session_file" (s. config) containing the contents of your aoc-session cookie -On a given day, just call `./start_day.py -d ` - -# Not using PyCharm? - -Just comment out the call() to CHARMS near the end of start_day.py +Run `./aoc_bot.py` diff --git a/aoc_bot.py b/aoc_bot.py new file mode 100644 index 0000000..0181f8a --- /dev/null +++ b/aoc_bot.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +import argparse +import json +import os.path + +import requests +import sys +from datetime import datetime, timedelta +from time import sleep +from tools.datafiles import JSONFile +from tools.irc import IrcBot, ServerMessage +from tools.tools import human_readable_time_from_delta + + +class AOCBot: + def __init__(self, config_file: str = "config.json"): + self.__irc_server = None + self.__irc_port = None + self.__irc_channel = None + self.__irc_nick = None + self.__irc_user = None + self.__irc_real_name = None + self.__aoc_group_id = None + self.__aoc_session_file = None + self.__aoc_session_id = None + self.__aoc_user_agent = None + self.__cache_file = None + self.__cache_data = None + self.__irc_bot = None + self._load_config(config_file) + + def _load_config(self, config_file: str): + config = JSONFile(config_file, False) + try: + self.__irc_server = config["IRC"]["server"] + self.__irc_port = config["IRC"]["port"] + self.__irc_channel = config["IRC"]["channel"] + self.__irc_nick = config["IRC"]["nick"] + self.__irc_user = config["IRC"]["user"] + self.__irc_real_name = config["IRC"]["name"] + self.__aoc_group_id = config["AOC"]["group_id"] + self.__aoc_session_file = config["AOC"]["session_file"] + self.__aoc_user_agent = config["AOC"]["user_agent"] + self.__cache_file = config["AOC"]["cache_file"] + except AttributeError as e: + print("CONFIGURATION ERROR: %s" % e) + + def fetch_leaderboard(self, year: int = datetime.now().year) -> dict: + return json.loads( + requests.get( + "https://adventofcode.com/%d/leaderboard/private/view/%s.json" + % (year, self.__aoc_group_id), + headers={"User-Agent": self.__aoc_user_agent}, + cookies={"session": self.__aoc_session_id}, + ).content + ) + + def command_info(self, msg_from: str, message: str): + self.__irc_bot.privmsg( + msg_from, + "I am %s => %s" + % (self.__irc_bot.getUser().nickname, self.__irc_bot.getUser().username), + ) + self.__irc_bot.privmsg(msg_from, "I am currently in the following channels:") + for c in self.__irc_bot.getChannelList(): + self.__irc_bot.privmsg(msg_from, "%s => %s" % (c.name, c.topic)) + + def command_today(self, msg_from: str, message: str): + if not message: + day = str(datetime.now().day) + else: + day = message.split(" ")[0] + try: + if not (1 <= int(day) <= 25): + self.__irc_bot.privmsg(self.__irc_channel, "Invalid day: " + day) + return + except ValueError: + self.__irc_bot.privmsg(self.__irc_channel, "Invalid day: " + day) + return + day_start = datetime.today().replace(hour=6, minute=0, second=0, day=int(day)) + + today_list = [] + for member, member_data in self.__cache_data.items(): + if ( + not member.startswith("__") + and "days" in member_data + and day in member_data["days"] + ): + today_list.append(member) + + self.__irc_bot.privmsg( + self.__irc_channel, + "Day %s's leaderboard (last updated: %s):" + % (day, self.__cache_data["__last_update__"]), + ) + for i, member in enumerate( + sorted( + today_list, + key=lambda x: self.__cache_data[x]["days"][day]["score"], + reverse=True, + ) + ): + if i > 3: + sleep(1) # don't flood + + if "1" in self.__cache_data[member]["days"][day]: + p1_time = "in " + human_readable_time_from_delta( + datetime.fromisoformat(self.__cache_data[member]["days"][day]["1"]) + - day_start + ) + else: + p1_time = "*not yet solved*" + + if "2" in self.__cache_data[member]["days"][day]: + p2_time = "in " + human_readable_time_from_delta( + datetime.fromisoformat(self.__cache_data[member]["days"][day]["2"]) + - day_start + ) + else: + p2_time = "not yet solved" + + self.__irc_bot.privmsg( + self.__irc_channel, + "%d) %s (Scores: total: %d, today: %d) p1 %s, p2 %s" + % ( + i + 1, + self.__cache_data[member]["name"], + self.__cache_data[member]["score"], + self.__cache_data[member]["days"][day]["score"], + p1_time, + p2_time, + ), + ) + + def command_quit(self, msg_from: str, message: str): + if msg_from.startswith("stha!") or msg_from.startswith("Pennywise!"): + self.__irc_bot.privmsg(msg_from, "Oh, ok ... bye :'(") + self.__irc_bot.quit() + sys.exit(0) + + def on_raw(self, msg_from: str, msg_type: str, msg_to: str, message: str): + print( + "[%s] <%s> (%s) -> <%s>: %s" + % (datetime.now().strftime("%H:%M:%S"), msg_from, msg_type, msg_to, message) + ) + + def calc_scores(self): + member_count = len( + [x for x in self.__cache_data.keys() if not x.startswith("__")] + ) + for day in map(str, range(1, 26)): + p1_times = [] + p2_times = [] + for member, member_data in self.__cache_data.items(): + if member.startswith("__") or day not in member_data["days"]: + continue + + self.__cache_data[member]["days"][day]["score"] = 0 + if "1" in member_data["days"][day]: + p1_times.append(member_data["days"][day]["1"]) + if "2" in member_data["days"][day]: + p2_times.append(member_data["days"][day]["2"]) + + for member, member_data in self.__cache_data.items(): + if member.startswith("__") or day not in member_data["days"]: + continue + + if ( + "1" in member_data["days"][day] + and member_data["days"][day]["1"] in p1_times + ): + score = member_count - sorted(p1_times).index( + member_data["days"][day]["1"] + ) + self.__cache_data[member]["days"][day]["score"] += score + + if ( + "2" in member_data["days"][day] + and member_data["days"][day]["2"] in p2_times + ): + score = member_count - sorted(p2_times).index( + member_data["days"][day]["2"] + ) + self.__cache_data[member]["days"][day]["score"] += score + + def update_leaderboard(self): + try: + new_leaderboard = self.fetch_leaderboard() + except Exception: + return # didn't work this time? Well, we'll just try again in 15min ... + + now = datetime.now() + + new_stars = {} + for member, member_data in new_leaderboard["members"].items(): + if member not in self.__cache_data: + self.__cache_data[member] = { + "name": member_data["name"], + "days": {}, + } + + self.__cache_data[member]["global_score"] = int(member_data["global_score"]) + self.__cache_data[member]["score"] = int(member_data["local_score"]) + self.__cache_data[member]["stars"] = int(member_data["stars"]) + for day in member_data["completion_day_level"]: + day_start = datetime(now.year, 12, int(day), 6, 0, 0) + if day not in self.__cache_data[member]["days"]: + self.__cache_data[member]["days"][day] = {} + + for part in member_data["completion_day_level"][day]: + if part not in self.__cache_data[member]["days"][day]: + completion_time = datetime.fromtimestamp( + member_data["completion_day_level"][day][part][ + "get_star_ts" + ] + ) + if member_data["name"] not in new_stars: + new_stars[member_data["name"]] = {} + + finishing_time = human_readable_time_from_delta( + completion_time - day_start + ) + new_stars[member_data["name"]][ + "d" + day + "p" + part + ] = finishing_time + self.__cache_data[member]["days"][day][ + part + ] = completion_time.isoformat() + + if len(new_stars) > 0: + self.__irc_bot.privmsg(self.__irc_channel, "New Stars found:") + for member, parts in new_stars.items(): + line = member + ": " + line += ", ".join( + "%s (%s)" % (part, new_stars[member][part]) + for part in sorted(new_stars[member].keys()) + ) + + self.__irc_bot.privmsg(self.__irc_channel, line) + + self.__cache_data["__last_update__"] = datetime.now().isoformat() + self.__cache_data.save() + self.calc_scores() + + def start(self): + self.__cache_data = JSONFile(self.__cache_file, create=True) + self.__aoc_session_id = ( + open(self.__aoc_session_file, "r").readlines()[0].strip() + ) + + self.__irc_bot = IrcBot( + server=self.__irc_server, + port=self.__irc_port, + nick=self.__irc_nick, + username=self.__irc_user, + realname=self.__irc_real_name, + ) + self.__irc_bot.join(self.__irc_channel) + self.__irc_bot.schedule( + "update_leaderboard", timedelta(minutes=15), self.update_leaderboard + ) + self.__irc_bot.on(ServerMessage.RAW, self.on_raw) + self.__irc_bot.register_channel_command( + "!info", self.__irc_channel, self.command_info + ) + self.__irc_bot.register_channel_command( + "!today", self.__irc_channel, self.command_today + ) + self.__irc_bot.register_channel_command( + "!day", self.__irc_channel, self.command_today + ) + self.__irc_bot.register_privmsg_command("info", self.command_info) + self.__irc_bot.register_privmsg_command("quit", self.command_quit) + self.__irc_bot.run() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "-c", + "--config-file", + help="config file to use (default: config.json)", + default="config.json", + required=False, + ) + args = parser.parse_args() + + config_file = args.config_file + if not os.path.exists(config_file): + print("Config File not found: %s" % config_file) + sys.exit(1) + + aoc_bot = AOCBot(config_file) + aoc_bot.start() diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..cfa62a5 --- /dev/null +++ b/config.example.json @@ -0,0 +1,29 @@ +// As JSON does not allow comments, remove all lines starting with // before starting the bot +{ + "IRC": { + // IRC Server to connect to + "server": "irc.server.com", + "port": 6667, + + // Channel to join + "channel": "#mycoolaocchannel", + + // The Bots IRC identity + "nick": "AoCBot", + "user": "aucbot", + "name": "Advent of Code Leaderboard Announcer" + }, + "AOC": { + // The content of your aoc session cookie should be stored in this file + "session_file": ".session", + + // Where to save cache data + "cache_file": "aocbot.cache.json", + + // AOC Leaderboard ID (at the end of the URL if you view your leaderboard) + "group_id": 123456, + + // Eric wants to know who's querying his API. At least insert some contact data here if he wants to talk to you + "user_agent": "My owner did not configure me. Don't hesitate to block me if needed!" + } +} diff --git a/main.py b/main.py deleted file mode 100644 index c02586c..0000000 --- a/main.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 - -import tools.aoc -import argparse -import importlib -import os - -YEAR = 2018 -TIMEIT_NUMBER = 50 - -argument_parser = argparse.ArgumentParser() -argument_parser.add_argument("-d", "--day", help="specify day to process; leave empty for ALL days", type=int) -argument_parser.add_argument("-p", "--part", help="run only part x", choices=[1, 2], type=int) -argument_parser.add_argument("--timeit", help="measure execution time", action="store_true", default=False) -argument_parser.add_argument( - "--timeit-number", - help="build average time over this many executions", - type=int, - default=TIMEIT_NUMBER -) -argument_parser.add_argument("-v", "--verbose", help="show test case outputs", action="store_true", default=False) -flags = argument_parser.parse_args() - -import_day = "" -if flags.day: - import_day = "%02d" % flags.day - -imported = [] -for _, _, files in os.walk(tools.aoc.BASE_PATH): - for f in files: - if f.startswith('day' + import_day) and f.endswith('.py'): - lib_name = f[:-3] - globals()[lib_name] = importlib.import_module(lib_name) - imported.append(lib_name) - - break - -for lib in sorted(imported): - day = int(lib[-2:]) - day_class = getattr(globals()[lib], "Day")(YEAR, day) - day_class.run(flags.part if flags.part else 3, flags.verbose, flags.timeit, flags.timeit_number) diff --git a/requirements.txt b/requirements.txt index e58fa34..941d21b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -shs-tools ~= 0.3 \ No newline at end of file +shs-tools ~= 0.3 +requests~=2.31.0 \ No newline at end of file diff --git a/skel_day.py b/skel_day.py deleted file mode 100644 index bf3331b..0000000 --- a/skel_day.py +++ /dev/null @@ -1,24 +0,0 @@ -from tools.aoc import AOCDay -from typing import Any - - -class Day(AOCDay): - inputs = [ - [ - (None, "input%DAY%"), - ], - [ - (None, "input%DAY%"), - ] - ] - - def part1(self) -> Any: - return "" - - def part2(self) -> Any: - return "" - - -if __name__ == '__main__': - day = Day(%YEAR%, %DAY%) - day.run(verbose=True) diff --git a/start_day.py b/start_day.py deleted file mode 100644 index aa8d394..0000000 --- a/start_day.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -from argparse import ArgumentParser -from datetime import datetime -from os.path import exists -from platform import system -from subprocess import call -from time import sleep -import webbrowser - - -YEAR = 2018 -CHARMS = { - 'Linux': '/usr/local/bin/charm', - 'Windows': r'C:\Users\pennywise\AppData\Local\JetBrains\Toolbox\scripts\pycharm.cmd' -} - -arg_parser = ArgumentParser() -arg_parser.add_argument("-d", "--day", help="start a specific day (default: today)", type=int) -args = arg_parser.parse_args() - -DAY = args.day or datetime.now().day - -if YEAR < 2015 or not 1 <= DAY <= 25: - print("Invalid year or day for year: %d, day: %d" % (YEAR, DAY)) - exit() - -day_file = "day%02d.py" % DAY -if exists(day_file): - print(day_file, "already exists. Use that one!") - exit() - -with open("skel_day.py", "r") as IN: - with open(day_file, "w") as OUT: - while in_line := IN.readline(): - OUT.write(in_line.replace("%YEAR%", str(YEAR)).replace("%DAY%", str(DAY))) - -start = datetime(YEAR, 12, DAY, 6, 0, 0) -now = datetime.now() -if start > now: - time_wait = start - now - if time_wait.days > 0: - print("Do you really want to wait %d days?" % time_wait.days) - exit() - - for x in range(time_wait.seconds, -1, -1): - print("Day starts in %02ds.\r") - sleep(1) - -call([CHARMS[system()], day_file]) -webbrowser.open("https://adventofcode.com/%d/day/%d" % (YEAR, DAY)) -call(["git", "add", day_file])