generalized aoc bot

This commit is contained in:
Stefan Harmuth 2023-11-26 14:22:15 +01:00
parent d88c1d443a
commit 5b06d59c93
7 changed files with 331 additions and 127 deletions

View File

@ -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 <day_of_month>`
# Not using PyCharm?
Just comment out the call() to CHARMS near the end of start_day.py
Run `./aoc_bot.py`

294
aoc_bot.py Normal file
View File

@ -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()

29
config.example.json Normal file
View File

@ -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!"
}
}

41
main.py
View File

@ -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)

View File

@ -1 +1,2 @@
shs-tools ~= 0.3
requests~=2.31.0

View File

@ -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)

View File

@ -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])