diff --git a/.gitignore b/.gitignore
index a322757..6863f38 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ certs/
*.cer
*custom*.mako
+.idea/
.vscode/
# Byte-compiled / optimized / DLL files
diff --git a/bots/lemmy_mlb_game_threads/__init__.py b/bots/lemmy_mlb_game_threads/__init__.py
new file mode 100755
index 0000000..694febc
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/__init__.py
@@ -0,0 +1,6340 @@
+#!/usr/bin/env python
+# encoding=utf-8
+"""MLB Game Thread Bot
+by Todd Roberts
+"""
+
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.schedulers import SchedulerNotRunningError
+from datetime import datetime, timedelta
+import json
+import pytz
+import requests
+import sys
+import traceback
+import tzlocal
+import time
+
+import threading
+
+import redball
+from redball import database as rbdb, logger
+
+import os
+
+from mako.lookup import TemplateLookup
+import mako.exceptions
+
+import pyprowl
+import statsapi
+
+from . import plaw
+
+__version__ = "1.0.0"
+
+GENERIC_DATA_LOCK = threading.Lock()
+GAME_DATA_LOCK = threading.Lock()
+
+
+def run(bot, settings):
+ thisBot = Bot(bot, settings)
+ thisBot.run()
+
+
+class Bot(object):
+ def __init__(self, bot, settings):
+ self.bot = bot
+ self.settings = settings
+ self.staleThreads = []
+ self.BOT_PATH = os.path.dirname(os.path.realpath(__file__))
+ self.BOT_TEMPLATE_PATH = []
+ if self.settings.get("Bot", {}).get("TEMPLATE_PATH", "") != "":
+ self.BOT_TEMPLATE_PATH.append(self.settings["Bot"]["TEMPLATE_PATH"])
+ self.BOT_TEMPLATE_PATH.append(os.path.join(self.BOT_PATH, "templates"))
+
+ self.LOOKUP = TemplateLookup(directories=self.BOT_TEMPLATE_PATH)
+
+ def run(self):
+ self.log = logger.init_logger(
+ logger_name="redball.bots." + threading.current_thread().name,
+ log_to_console=self.settings.get("Logging", {}).get("LOG_TO_CONSOLE", True),
+ log_to_file=self.settings.get("Logging", {}).get("LOG_TO_FILE", True),
+ log_path=redball.LOG_PATH,
+ log_file="{}.log".format(threading.current_thread().name),
+ file_log_level=self.settings.get("Logging", {}).get("FILE_LOG_LEVEL"),
+ log_retention=self.settings.get("Logging", {}).get("LOG_RETENTION", 7),
+ console_log_level=self.settings.get("Logging", {}).get("CONSOLE_LOG_LEVEL"),
+ clear_first=True,
+ propagate=False,
+ )
+ self.log.debug(
+ "Game Thread Bot v{} received settings: {}. Template path: {}".format(
+ __version__, self.settings, self.BOT_TEMPLATE_PATH
+ )
+ )
+
+ # Check db for tables and create if necessary
+ self.dbTablePrefix = self.settings.get("Database").get(
+ "dbTablePrefix", "gdt{}_".format(self.bot.id)
+ )
+ self.build_tables()
+
+ # Initialize Lemmy API connection
+ self.init_lemmy()
+
+ # Initialize scheduler
+ if "SCHEDULER" in vars(self.bot):
+ # Scheduler already exists, maybe bot restarted
+ sch_jobs = self.bot.SCHEDULER.get_jobs()
+ self.log.warning(
+ f"Scheduler already exists on bot startup with the following job(s): {sch_jobs}"
+ )
+ # Remove all jobs and shut down so we can start fresh
+ for x in sch_jobs:
+ x.remove()
+ try:
+ self.bot.SCHEDULER.shutdown()
+ except SchedulerNotRunningError as e:
+ self.log.debug(f"Could not shut down scheduler because: {e}")
+
+ self.bot.SCHEDULER = BackgroundScheduler(
+ timezone=tzlocal.get_localzone()
+ if str(tzlocal.get_localzone()) != "local"
+ else "America/New_York"
+ )
+ self.bot.SCHEDULER.start()
+
+ self.bot.detailedState = {
+ "summary": {
+ "text": "Starting up, please wait 1 minute...",
+ "html": "Starting up, please wait 1 minute...",
+ "markdown": "Starting up, please wait 1 minute...",
+ }
+ } # Initialize detailed state to a wait message
+
+ # Start a scheduled task to update self.bot.detailedState every minute
+ if not next(
+ (
+ x
+ for x in self.bot.SCHEDULER.get_jobs()
+ if x.name == f"bot-{self.bot.id}-statusUpdateTask"
+ ),
+ None,
+ ):
+ self.bot.SCHEDULER.add_job(
+ self.bot_state,
+ "interval",
+ name=f"bot-{self.bot.id}-statusUpdateTask",
+ minutes=1,
+ )
+ else:
+ self.log.debug(
+ f"The bot-{self.bot.id}-statusUpdateTask scheduled job already exists."
+ )
+
+ settings_date = datetime.today().strftime("%Y-%m-%d")
+ if self.settings.get("MLB", {}).get("GAME_DATE_OVERRIDE", "") != "":
+ # Bot config says to treat specified date as 'today'
+ todayOverrideFlag = True
+ else:
+ todayOverrideFlag = False
+
+ # Initialize var to old info about weekly thread
+ # This should be separate from other threads because
+ # it doesn't turn over daily
+ self.weekly = {}
+
+ while redball.SIGNAL is None and not self.bot.STOP:
+ # This is the daily loop
+ # Refresh settings
+ if settings_date != datetime.today().strftime("%Y-%m-%d"):
+ self.refresh_settings()
+ settings_date = datetime.today().strftime("%Y-%m-%d")
+
+ # Get info about configured team
+ if self.settings.get("MLB", {}).get("TEAM", "") == "":
+ self.log.critical("No team selected! Set MLB > TEAM in Bot Config.")
+ self.bot.STOP = True
+ break
+
+ self.myTeam = self.get_team(
+ self.settings.get("MLB", {}).get("TEAM", "").split("|")[1],
+ s=datetime.now().strftime(
+ "%Y"
+ ), # band-aid due to MLB defaulting team page to 2021 season in July 2020
+ )
+ self.log.info("Configured team: {}".format(self.myTeam["name"]))
+
+ if todayOverrideFlag:
+ todayOverrideFlag = (
+ False # Only override once, then go back to current date
+ )
+ self.log.info(
+ "Overriding game date per GAME_DATE_OVERRIDE setting [{}].".format(
+ self.settings["MLB"]["GAME_DATE_OVERRIDE"]
+ )
+ )
+ try:
+ todayObj = datetime.strptime(
+ self.settings["MLB"]["GAME_DATE_OVERRIDE"], "%Y-%m-%d"
+ )
+ except Exception as e:
+ self.log.error(
+ "Error overriding game date. Falling back to today's date. Error: {}".format(
+ e
+ )
+ )
+ self.error_notification(
+ "Error overriding game date. Falling back to today's date"
+ )
+ todayObj = datetime.today()
+ else:
+ todayObj = datetime.today()
+
+ self.today = {
+ "Y-m-d": todayObj.strftime("%Y-%m-%d"),
+ "Ymd": todayObj.strftime("%Y%m%d"),
+ "Y": todayObj.strftime("%Y"),
+ }
+ self.log.debug("Today is {}".format(self.today["Y-m-d"]))
+
+ # Get season state
+ self.seasonState = self.get_seasonState(self.myTeam["id"])
+ self.log.debug("Season state: {}".format(self.seasonState))
+
+ # Get today's games
+ todayGamePks = self.get_gamePks(t=self.myTeam["id"], d=self.today["Y-m-d"])
+ self.THREADS = {} # Clear yesterday's threads
+ self.activeGames = {} # Clear yesterday's flags
+ self.commonData = {} # Clear data dict every day to save memory
+ self.collect_data(0) # Collect generic data
+
+ if len(todayGamePks) == 0:
+ # Off day thread
+ self.log.info("No games today!")
+ self.off_day() # Start the off day thread update process and enter a loop
+ if redball.SIGNAL is not None or self.bot.STOP:
+ break
+ else:
+ self.log.info(
+ "Found {} game(s) for today: {}".format(
+ len(todayGamePks), todayGamePks
+ )
+ )
+ # Initialize activeGames and THREADS dicts (will hold game info and thread lemmy objects)
+ for x in todayGamePks:
+ self.activeGames.update(
+ {x: {"STOP_FLAG": False, "POST_STOP_FLAG": False}}
+ )
+ self.THREADS.update({x: {}})
+
+ self.activeGames.update(
+ {"gameday": {"STOP_FLAG": False}}
+ ) # Game day thread is not specific to a gamePk
+
+ self.log.debug("activeGames: {}".format(self.activeGames))
+
+ for pk in todayGamePks:
+ self.commonData.update({pk: {"gamePk": pk}})
+
+ # Collect data for all games
+ self.collect_data(todayGamePks)
+
+ # Check if all MLB games are postponed (league is suspended)
+ if not next(
+ (
+ True
+ for x in self.commonData[0]["leagueSchedule"]
+ if x["status"]["codedGameState"] != "D"
+ ),
+ False,
+ ):
+ # All games are postponed -- assume league is suspended
+ self.log.info(
+ "All of today's MLB games are postponed. Assuming the league is suspended and treating as off day..."
+ )
+ self.commonData.update({"seasonSuspended": True})
+ # Remove game data from cache since we're treating as off day
+ self.activeGames.pop("gameday")
+ for pk in todayGamePks:
+ self.commonData.pop(pk)
+ self.activeGames.pop(pk)
+
+ self.off_day()
+ else:
+ if not self.settings.get("Game Day Thread", {}).get(
+ "ENABLED", True
+ ):
+ self.log.info("Game day thread disabled.")
+ self.activeGames["gameday"].update({"STOP_FLAG": True})
+ else:
+ # Spawn a thread to wait for post time and then keep game day thread updated
+ self.THREADS.update(
+ {
+ "GAMEDAY_THREAD": threading.Thread(
+ target=self.gameday_thread_update_loop,
+ args=(todayGamePks,),
+ name="bot-{}-{}-gameday".format(
+ self.bot.id, self.bot.name.replace(" ", "-")
+ ),
+ daemon=True,
+ )
+ }
+ )
+ self.THREADS["GAMEDAY_THREAD"].start()
+ self.log.debug(
+ "Started game day thread {}.".format(
+ self.THREADS["GAMEDAY_THREAD"]
+ )
+ )
+
+ # Game thread update processes
+ for (
+ pk
+ ) in (
+ todayGamePks
+ ): # Loop through gamePks since each game gets its own thread
+ if self.settings.get("Game Thread", {}).get("ENABLED", True):
+ # Spawn separate thread to wait for post time and then keep game thread updated
+ self.THREADS[pk].update(
+ {
+ "GAME_THREAD": threading.Thread(
+ target=self.game_thread_update_loop,
+ args=(pk,),
+ name="bot-{}-{}-game-{}".format(
+ self.bot.id,
+ self.bot.name.replace(" ", "-"),
+ pk,
+ ),
+ daemon=True,
+ )
+ }
+ )
+ self.THREADS[pk]["GAME_THREAD"].start()
+ self.log.debug(
+ "Started game thread {}.".format(
+ self.THREADS[pk]["GAME_THREAD"]
+ )
+ )
+ else:
+ self.log.info(
+ "Game thread is disabled! [pk: {}]".format(pk)
+ )
+ self.activeGames[pk].update({"STOP_FLAG": True})
+
+ if self.settings.get("Post Game Thread", {}).get(
+ "ENABLED", True
+ ):
+ # Spawn separate thread to wait for game to be final and then submit and keep post game thread updated
+ self.THREADS[pk].update(
+ {
+ "POSTGAME_THREAD": threading.Thread(
+ target=self.postgame_thread_update_loop,
+ args=(pk,),
+ name="bot-{}-{}-postgame-{}".format(
+ self.bot.id,
+ self.bot.name.replace(" ", "-"),
+ pk,
+ ),
+ daemon=True,
+ )
+ }
+ )
+ self.THREADS[pk]["POSTGAME_THREAD"].start()
+ self.log.debug(
+ "Started post game thread {}.".format(
+ self.THREADS[pk]["POSTGAME_THREAD"]
+ )
+ )
+ else:
+ self.log.info(
+ "Post game thread is disabled! [pk: {}]".format(pk)
+ )
+ self.activeGames[pk].update({"POST_STOP_FLAG": True})
+
+ # Loop over active games, make sure submit/update and comment threads are running
+ while (
+ len(
+ [
+ {k: v}
+ for k, v in self.activeGames.items()
+ if not v.get("STOP_FLAG", True)
+ or not v.get("POST_STOP_FLAG", True)
+ ]
+ )
+ > 0
+ and redball.SIGNAL is None
+ and not self.bot.STOP
+ ):
+ for pk in (
+ pk
+ for pk in self.activeGames
+ if not self.activeGames[pk]["STOP_FLAG"]
+ and isinstance(pk, int)
+ and pk > 0
+ ):
+ # Check submit/update thread for game thread
+ try:
+ if not self.settings.get("Game Thread", {}).get(
+ "ENABLED", True
+ ):
+ # Game thread is disabled, so don't start an update thread...
+ pass
+ elif (
+ pk > 0
+ and not self.activeGames[pk]["STOP_FLAG"]
+ and self.THREADS[pk].get("GAME_THREAD")
+ and isinstance(
+ self.THREADS[pk]["GAME_THREAD"],
+ threading.Thread,
+ )
+ and self.THREADS[pk]["GAME_THREAD"].is_alive()
+ ):
+ self.log.debug(
+ "Game thread for game {} looks fine...".format(
+ pk
+ )
+ ) # debug - need this here to see if the condition is working when the thread crashes
+ # pass
+ elif pk > 0 and self.activeGames[pk]["STOP_FLAG"]:
+ # Game thread is already done
+ pass
+ else:
+ raise Exception(
+ "Game {} thread update process is not running!".format(
+ pk
+ )
+ )
+ except Exception as e:
+ if "is not running" in str(e):
+ self.log.error(
+ "Game {} thread update process is not running. Attempting to start.".format(
+ pk
+ )
+ )
+ self.error_notification(
+ f"Game {pk} thread update process is not running"
+ )
+ self.THREADS[pk].update(
+ {
+ "GAME_THREAD": threading.Thread(
+ target=self.game_thread_update_loop,
+ args=(pk,),
+ name="bot-{}-{}-game-{}".format(
+ self.bot.id,
+ self.bot.name.replace(" ", "-"),
+ pk,
+ ),
+ daemon=True,
+ )
+ }
+ )
+ self.THREADS[pk]["GAME_THREAD"].start()
+ self.log.debug(
+ "Started game thread {}.".format(
+ self.THREADS[pk]["GAME_THREAD"]
+ )
+ )
+ else:
+ raise
+
+ # Check submit/update thread for post game thread
+ try:
+ if not self.settings.get("Post Game Thread", {}).get(
+ "ENABLED", True
+ ):
+ # Post game thread is disabled, so don't start an update thread...
+ pass
+ elif (
+ pk > 0
+ and not self.activeGames[pk]["POST_STOP_FLAG"]
+ and self.THREADS[pk].get("POSTGAME_THREAD")
+ and isinstance(
+ self.THREADS[pk]["POSTGAME_THREAD"],
+ threading.Thread,
+ )
+ and self.THREADS[pk]["POSTGAME_THREAD"].is_alive()
+ ):
+ self.log.debug(
+ "Post game thread for game {} looks fine...".format(
+ pk
+ )
+ ) # debug - need this here to see if the condition is working when the thread crashes
+ # pass
+ elif pk > 0 and self.activeGames[pk]["POST_STOP_FLAG"]:
+ # Post game thread is already done
+ pass
+ else:
+ raise Exception(
+ "Post game {} thread update process is not running!".format(
+ pk
+ )
+ )
+ except Exception as e:
+ if "is not running" in str(e):
+ self.log.error(
+ "Post game {} thread update process is not running. Attempting to start.".format(
+ pk
+ )
+ )
+ self.error_notification(
+ f"Game {pk} post game thread update process is not running"
+ )
+ self.THREADS[pk].update(
+ {
+ "POSTGAME_THREAD": threading.Thread(
+ target=self.postgame_thread_update_loop,
+ args=(pk,),
+ name="bot-{}-{}-postgame-{}".format(
+ self.bot.id,
+ self.bot.name.replace(" ", "-"),
+ pk,
+ ),
+ daemon=True,
+ )
+ }
+ )
+ self.THREADS[pk]["POSTGAME_THREAD"].start()
+ self.log.debug(
+ "Started post game thread {}.".format(
+ self.THREADS[pk]["POSTGAME_THREAD"]
+ )
+ )
+ else:
+ raise
+
+ # Check comment thread
+ try:
+ if not self.settings.get("Game Thread", {}).get(
+ "ENABLED", True
+ ) or not self.settings.get("Comments", {}).get(
+ "ENABLED", True
+ ):
+ # Game thread or commenting is disabled, so don't start a comment thread...
+ pass
+ elif (
+ pk > 0
+ and not self.activeGames[pk]["STOP_FLAG"]
+ and self.THREADS[pk].get("COMMENT_THREAD")
+ and isinstance(
+ self.THREADS[pk]["COMMENT_THREAD"],
+ threading.Thread,
+ )
+ and self.THREADS[pk]["COMMENT_THREAD"].is_alive()
+ ):
+ self.log.debug(
+ "Comment thread for game {} looks fine...".format(
+ pk
+ )
+ ) # debug - need this here to see if the condition is working when the thread crashes
+ pass
+ elif pk > 0 and not self.activeGames[pk].get(
+ "gameThread"
+ ):
+ # Game thread isn't posted yet, so comment process should not be running
+ self.log.debug(
+ "Not starting comment process because game thread is not posted."
+ )
+ pass
+ elif pk > 0 and self.activeGames[pk]["STOP_FLAG"]:
+ # Game thread is already done
+ self.log.debug(
+ "Not starting comment process because game thread is already done updating."
+ )
+ pass
+ elif self.commonData.get(pk, {}).get(
+ "schedule", {}
+ ).get("status", {}).get(
+ "abstractGameCode"
+ ) == "F" or self.commonData.get(
+ pk, {}
+ ).get(
+ "schedule", {}
+ ).get(
+ "status", {}
+ ).get(
+ "codedGameState"
+ ) in [
+ "C",
+ "D",
+ "U",
+ "T",
+ ]:
+ # Game is over, so don't add any comments
+ self.log.debug(
+ "Not starting comment process because game is over."
+ )
+ pass
+ else:
+ raise Exception(
+ "Game {} comment process is not running!".format(
+ pk
+ )
+ )
+ except Exception as e:
+ if "is not running" in str(e):
+ self.log.error(
+ "Game {} comment process is not running. Attempting to start.".format(
+ pk
+ )
+ )
+ self.error_notification(
+ f"Game {pk} comment process is not running"
+ )
+ self.THREADS[pk].update(
+ {
+ "COMMENT_THREAD": threading.Thread(
+ target=self.monitor_game_plays,
+ args=(
+ pk,
+ self.activeGames[pk]["gameThread"],
+ ),
+ name="bot-{}-{}-game-{}-comments".format(
+ self.bot.id,
+ self.bot.name.replace(" ", "-"),
+ pk,
+ ),
+ daemon=True,
+ )
+ }
+ )
+ self.THREADS[pk]["COMMENT_THREAD"].start()
+ self.log.debug(
+ "Started comment thread {}.".format(
+ self.THREADS[pk]["COMMENT_THREAD"]
+ )
+ )
+ else:
+ raise
+
+ # Make sure game day thread update process is running
+ try:
+ if not self.settings.get("Game Day Thread", {}).get(
+ "ENABLED", True
+ ):
+ # Game day thread is disabled, so don't start an update thread...
+ pass
+ elif (
+ not self.activeGames["gameday"]["STOP_FLAG"]
+ and self.THREADS.get("GAMEDAY_THREAD")
+ and isinstance(
+ self.THREADS["GAMEDAY_THREAD"], threading.Thread
+ )
+ and self.THREADS["GAMEDAY_THREAD"].is_alive()
+ ):
+ self.log.debug(
+ "Game day update thread looks fine..."
+ ) # debug - need this here to see if the condition is working when the thread crashes
+ # pass
+ elif self.activeGames["gameday"]["STOP_FLAG"]:
+ # Game day thread is already done
+ pass
+ else:
+ raise Exception(
+ "Game day thread update process is not running!"
+ )
+ except Exception as e:
+ if "is not running" in str(e):
+ self.log.error(
+ "Game day thread update process is not running. Attempting to start. Error: {}".format(
+ e
+ )
+ )
+ self.error_notification(
+ "Game day thread update process is not running"
+ )
+ self.THREADS.update(
+ {
+ "GAMEDAY_THREAD": threading.Thread(
+ target=self.gameday_thread_update_loop,
+ args=(todayGamePks,),
+ name="bot-{}-{}-gameday".format(
+ self.bot.id,
+ self.bot.name.replace(" ", "-"),
+ ),
+ daemon=True,
+ )
+ }
+ )
+ self.THREADS["GAMEDAY_THREAD"].start()
+ self.log.debug(
+ "Started game day thread {}.".format(
+ self.THREADS["GAMEDAY_THREAD"]
+ )
+ )
+ else:
+ raise
+
+ if (
+ len(
+ [
+ {k: v}
+ for k, v in self.activeGames.items()
+ if not v.get("STOP_FLAG", True)
+ or not v.get("POST_STOP_FLAG", True)
+ ]
+ )
+ > 0
+ ):
+ # There are still games pending/in progress
+ self.log.debug(
+ "Active games/threads: {}".format(
+ [
+ k
+ for k, v in self.activeGames.items()
+ if not v.get("STOP_FLAG", True)
+ or not v.get("POST_STOP_FLAG", True)
+ ]
+ )
+ )
+ self.log.debug(
+ "Active threads: {}".format(
+ [
+ t
+ for t in threading.enumerate()
+ if t.name.startswith(
+ "bot-{}-{}".format(
+ self.bot.id,
+ self.bot.name.replace(" ", "-"),
+ )
+ )
+ ]
+ )
+ )
+ self.sleep(30)
+ else:
+ break
+
+ if redball.SIGNAL is not None or self.bot.STOP:
+ break
+
+ self.log.info("All done for today! Going into end of day loop...")
+ self.eod_loop(self.today["Y-m-d"])
+
+ self.bot.SCHEDULER.shutdown()
+ self.log.info("Bot {} (id={}) exiting...".format(self.bot.name, self.bot.id))
+ self.bot.detailedState = {
+ "lastUpdated": datetime.today().strftime("%m/%d/%Y %I:%M:%S %p"),
+ "summary": {
+ "text": "Bot has been stopped.",
+ "html": "Bot has been stopped.",
+ "markdown": "Bot has been stopped.",
+ },
+ }
+
+ def off_day(self):
+ if (
+ self.seasonState.startswith("off") or self.seasonState == "post:out"
+ ) and self.settings.get("Off Day Thread", {}).get("SUPPRESS_OFFSEASON", True):
+ self.log.info("Suppressing off day thread during offseason.")
+ self.activeGames.update({"off": {"STOP_FLAG": True}})
+ elif not self.settings.get("Off Day Thread", {}).get("ENABLED", True):
+ self.log.info("Off day thread disabled.")
+ self.activeGames.update({"off": {"STOP_FLAG": True}})
+ else:
+ # Spawn a thread to wait for post time and then keep off day thread updated
+ self.activeGames.update(
+ {"off": {"STOP_FLAG": False}}
+ ) # Off day thread is not specific to a gamePk
+ self.THREADS.update(
+ {
+ "OFFDAY_THREAD": threading.Thread(
+ target=self.off_thread_update_loop,
+ name="bot-{}-{}-offday".format(
+ self.bot.id, self.bot.name.replace(" ", "-")
+ ),
+ daemon=True,
+ )
+ }
+ )
+ self.THREADS["OFFDAY_THREAD"].start()
+ self.log.debug(
+ "Started off day thread {}.".format(self.THREADS["OFFDAY_THREAD"])
+ )
+
+ while (
+ len([{k: v} for k, v in self.activeGames.items() if not v["STOP_FLAG"]])
+ and redball.SIGNAL is None
+ and not self.bot.STOP
+ ):
+ try:
+ if not self.settings.get("Off Day Thread", {}).get("ENABLED", True):
+ # Off day thread is disabled, so don't start an update thread...
+ pass
+ elif (
+ not self.activeGames["off"]["STOP_FLAG"]
+ and self.THREADS.get("OFFDAY_THREAD")
+ and isinstance(self.THREADS["OFFDAY_THREAD"], threading.Thread)
+ and self.THREADS["OFFDAY_THREAD"].is_alive()
+ ):
+ self.log.debug(
+ "Off day update thread looks fine..."
+ ) # debug - need this here to see if the condition is working when the thread crashes
+ # pass
+ elif self.activeGames["off"]["STOP_FLAG"]:
+ # Off day thread is already done
+ pass
+ else:
+ raise Exception("Off day thread update process is not running!")
+ except Exception as e:
+ self.log.error(
+ "Off day thread update process is not running. Attempting to start. (Error: {})".format(
+ e
+ )
+ )
+ self.error_notification("Off day thread update process is not running")
+ self.THREADS.update(
+ {
+ "OFFDAY_THREAD": threading.Thread(
+ target=self.off_thread_update_loop,
+ name="bot-{}-{}-offday".format(
+ self.bot.id, self.bot.name.replace(" ", "-")
+ ),
+ daemon=True,
+ )
+ }
+ )
+ self.THREADS["OFFDAY_THREAD"].start()
+ self.log.debug(
+ "Started off day thread {}.".format(self.THREADS["OFFDAY_THREAD"])
+ )
+
+ if (
+ len(
+ [
+ {k: v}
+ for k, v in self.activeGames.items()
+ if not v.get("STOP_FLAG", False)
+ or not v.get("POST_STOP_FLAG", False)
+ ]
+ )
+ > 0
+ ):
+ # There are still active threads
+ self.log.debug(
+ "Active games/threads: {}".format(
+ [
+ k
+ for k, v in self.activeGames.items()
+ if not v["STOP_FLAG"] or not v.get("POST_STOP_FLAG", True)
+ ]
+ )
+ )
+ self.log.debug(
+ "Active threads: {}".format(
+ [
+ t
+ for t in threading.enumerate()
+ if t.name.startswith(
+ "bot-{}-{}".format(
+ self.bot.id, self.bot.name.replace(" ", "-")
+ )
+ )
+ ]
+ )
+ )
+ self.sleep(30)
+ else:
+ break
+
+ def off_thread_update_loop(self):
+ skipFlag = None # Will be set later if off day thread edit should be skipped
+
+ # Check/wait for time to submit off day thread
+ self.activeGames["off"].update(
+ {
+ "postTime_local": datetime.strptime(
+ datetime.today().strftime(
+ "%Y-%m-%d "
+ + self.settings.get("Off Day Thread", {}).get(
+ "POST_TIME", "05:00"
+ )
+ ),
+ "%Y-%m-%d %H:%M",
+ )
+ }
+ )
+ while (
+ datetime.today() < self.activeGames["off"]["postTime_local"]
+ and redball.SIGNAL is None
+ and not self.bot.STOP
+ ):
+ if (
+ self.activeGames["off"]["postTime_local"] - datetime.today()
+ ).total_seconds() > 3600:
+ self.log.info(
+ "Off day thread should not be posted for a long time ({}). Sleeping for an hour...".format(
+ self.activeGames["off"]["postTime_local"]
+ )
+ )
+ self.sleep(3600)
+ elif (
+ self.activeGames["off"]["postTime_local"] - datetime.today()
+ ).total_seconds() > 1800:
+ self.log.info(
+ "Off day thread post time is still more than 30 minutes away ({}). Sleeping for a half hour...".format(
+ self.activeGames["off"]["postTime_local"]
+ )
+ )
+ self.sleep(1800)
+ else:
+ self.log.info(
+ "Off day thread post time is approaching ({}). Sleeping until then...".format(
+ self.activeGames["off"]["postTime_local"]
+ )
+ )
+ self.sleep(
+ (
+ self.activeGames["off"]["postTime_local"] - datetime.today()
+ ).total_seconds()
+ )
+
+ if redball.SIGNAL is not None or self.bot.STOP:
+ return
+
+ # Unsticky stale threads
+ if self.settings.get("Reddit", {}).get("STICKY", False):
+ """
+ # Make sure the subreddit's sticky posts are marked as stale
+ try:
+ sticky1 = self.subreddit.sticky(1)
+ if sticky1.author == self.reddit.user.me() and sticky1 not in self.staleThreads:
+ self.staleThreads.append(sticky1)
+
+ sticky2 = self.subreddit.sticky(2)
+ if sticky2.author == self.reddit.user.me() and sticky2 not in self.staleThreads:
+ self.staleThreads.append(sticky2)
+ except Exception:
+ # Exception likely due to no post being stickied (or only one), so ignore it...
+ pass
+ """
+ if len(self.staleThreads):
+ self.unsticky_threads(self.staleThreads)
+ self.staleThreads = []
+
+ # Check DB for existing off day thread
+ oq = "select * from {}threads where gamePk = {} and type='off' and deleted=0;".format(
+ self.dbTablePrefix, self.today["Ymd"]
+ )
+ offThread = rbdb.db_qry(oq, closeAfter=True, logg=self.log)
+
+ offDayThread = None
+ if len(offThread) > 0:
+ self.log.info(
+ "Off Day Thread found in database [{}].".format(offThread[0]["id"])
+ )
+ offDayThread = self.lemmy.getPost(offThread[0]["id"])
+ if not offDayThread["creator"]["name"]:
+ self.log.warning("Off day thread appears to have been deleted.")
+ q = "update {}threads set deleted=1 where id='{}';".format(
+ self.dbTablePrefix, offDayThread["post"]["id"]
+ )
+ u = rbdb.db_qry(q, commit=True, closeAfter=True, logg=self.log)
+ if isinstance(u, str):
+ self.log.error(
+ "Error marking thread as deleted in database: {}".format(u)
+ )
+
+ offDayThread = None
+ else:
+ if offDayThread["post"]["body"].find("\n\nLast Updated") != -1:
+ offDayThreadText = offDayThread["post"]["body"][
+ 0 : offDayThread["post"]["body"].find("\n\nLast Updated:")
+ ]
+ elif offDayThread["post"]["body"].find("\n\nPosted") != -1:
+ offDayThreadText = offDayThread["post"]["body"][
+ 0 : offDayThread["post"]["body"].find("\n\nPosted:")
+ ]
+ else:
+ offDayThreadText = offDayThread["post"]["body"]
+
+ self.activeGames["off"].update(
+ {
+ "offDayThreadText": offDayThreadText,
+ "offDayThread": offDayThread,
+ "offDayThreadTitle": offDayThread["post"]["name"]
+ if offDayThread not in [None, False]
+ else None,
+ }
+ )
+ # Only sticky when posting the thread
+ if self.settings.get('Reddit',{}).get('STICKY',False): self.sticky_thread(offDayThread)
+
+ if not offDayThread:
+ (offDayThread, offDayThreadText) = self.prep_and_post(
+ "off",
+ postFooter="""
+
+Posted: """
+ + self.convert_timezone(
+ datetime.utcnow(), self.myTeam["venue"]["timeZone"]["id"]
+ ).strftime("%m/%d/%Y %I:%M:%S %p %Z")
+ + ", Update Interval: {} Minutes".format(
+ self.settings.get("Off Day Thread", {}).get("UPDATE_INTERVAL", 5)
+ ),
+ )
+ self.activeGames["off"].update(
+ {
+ "offDayThreadText": offDayThreadText,
+ "offDayThread": offDayThread,
+ "offDayThreadTitle": offDayThread["post"]["name"]
+ if offDayThread not in [None, False]
+ else None,
+ }
+ )
+ skipFlag = True
+
+ while (
+ not self.activeGames["off"]["STOP_FLAG"]
+ and redball.SIGNAL is None
+ and not self.bot.STOP
+ ):
+ # Keep off day thread updated
+ if skipFlag:
+ # Skip check/edit since skip flag is set
+ skipFlag = None
+ self.log.debug(
+ "Skip flag is set, off day thread was just submitted/edited and does not need to be checked."
+ )
+ else:
+ try:
+ # Update generic data for division games and no-no/perfect game watch
+ self.collect_data(0)
+ # self.log.debug('data passed into render_template: {}'.format(self.commonData))#debug
+ text = self.render_template(
+ thread="off",
+ templateType="thread",
+ data=self.commonData,
+ settings=self.settings,
+ )
+ self.log.debug("Rendered off day thread text: {}".format(text))
+ if (
+ text != self.activeGames["off"]["offDayThreadText"]
+ and text != ""
+ ):
+ self.activeGames["off"].update({"offDayThreadText": text})
+ text += (
+ """
+
+Last Updated: """
+ + self.convert_timezone(
+ datetime.utcnow(),
+ self.myTeam["venue"]["timeZone"]["id"],
+ ).strftime("%m/%d/%Y %I:%M:%S %p %Z")
+ + ", Update Interval: {} Minutes".format(
+ self.settings.get("Off Day Thread", {}).get(
+ "UPDATE_INTERVAL", 5
+ )
+ )
+ )
+ text = self._truncate_post(text)
+ self.lemmy.editPost(offDayThread["post"]["id"], text)
+ self.log.info("Off day thread edits submitted.")
+ self.count_check_edit(offDayThread["post"]["id"], "NA", edit=True)
+ self.log_last_updated_date_in_db(offDayThread["post"]["id"])
+ elif text == "":
+ self.log.info(
+ "Skipping off day thread edit since thread text is blank..."
+ )
+ else:
+ self.log.info("No changes to off day thread.")
+ self.count_check_edit(offDayThread["post"]["id"], "NA", edit=False)
+ except Exception as e:
+ self.log.error("Error editing off day thread: {}".format(e))
+ self.error_notification("Error editing off day thread")
+
+ update_off_thread_until = self.settings.get("Off Day Thread", {}).get(
+ "UPDATE_UNTIL", "All MLB games are final"
+ )
+ if update_off_thread_until not in [
+ "Do not update",
+ "All division games are final",
+ "All MLB games are final",
+ ]:
+ # Unsupported value, use default
+ update_off_thread_until = "All MLB games are final"
+
+ if update_off_thread_until == "Do not update":
+ # Setting says not to update
+ self.log.info(
+ "Stopping off day thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames["off"].update({"STOP_FLAG": True})
+ break
+ elif update_off_thread_until == "All division games are final":
+ if not next(
+ (
+ True
+ for x in self.commonData[0]["leagueSchedule"]
+ if x["status"]["abstractGameCode"] != "F"
+ and x["status"]["codedGameState"] not in ["C", "D", "U", "T"]
+ and self.myTeam["division"]["id"]
+ in [
+ x["teams"]["away"]["team"].get("division", {}).get("id"),
+ x["teams"]["home"]["team"].get("division", {}).get("id"),
+ ]
+ ),
+ False,
+ ):
+ # Division games are all final
+ self.log.info(
+ "All division games are final. Stopping off day thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames["off"].update({"STOP_FLAG": True})
+ break
+ elif update_off_thread_until == "All MLB games are final":
+ if not next(
+ (
+ True
+ for x in self.commonData[0]["leagueSchedule"]
+ if x["status"]["abstractGameCode"] != "F"
+ and x["status"]["codedGameState"] not in ["C", "D", "U", "T"]
+ ),
+ False,
+ ):
+ # MLB games are all final
+ self.log.info(
+ "All MLB games are final. Stopping off day thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames["off"].update({"STOP_FLAG": True})
+ break
+
+ self.log.debug(
+ "Off day thread stop criteria not met ({}).".format(
+ update_off_thread_until
+ )
+ ) # debug - need this to tell if logic is working
+
+ # Update interval is in minutes (seconds for game thread only)
+ odtWait = self.settings.get("Off Day Thread", {}).get("UPDATE_INTERVAL", 5)
+ if odtWait < 1:
+ odtWait = 1
+ self.log.info("Sleeping for {} minutes...".format(odtWait))
+ self.sleep(odtWait * 60)
+
+ if redball.SIGNAL is not None or self.bot.STOP:
+ self.log.debug("Caught a stop signal...")
+
+ # Mark off day thread as stale so it will be unstickied tomorrow
+ if offDayThread:
+ self.staleThreads.append(offDayThread)
+
+ self.log.debug("Ending off day update thread...")
+ return
+
+ def gameday_thread_update_loop(self, todayGamePks):
+ pk = "gameday" # Game day thread is not specific to a gamePk
+ skipFlag = None # Will be set later if game day thread edit should be skipped
+
+ # Check/wait for time to submit game day thread
+ self.activeGames[pk].update(
+ {
+ "postTime_local": datetime.strptime(
+ datetime.today().strftime(
+ "%Y-%m-%d "
+ + self.settings.get("Game Day Thread", {}).get(
+ "POST_TIME", "05:00"
+ )
+ ),
+ "%Y-%m-%d %H:%M",
+ )
+ }
+ )
+ self.log.debug(
+ "Game day thread post time: {}".format(
+ self.activeGames[pk]["postTime_local"]
+ )
+ )
+ while (
+ datetime.today() < self.activeGames[pk]["postTime_local"]
+ and redball.SIGNAL is None
+ and not self.bot.STOP
+ ):
+ if (
+ self.activeGames[pk]["postTime_local"] - datetime.today()
+ ).total_seconds() > 3600:
+ self.log.info(
+ "Game day thread should not be posted for a long time ({}). Sleeping for an hour...".format(
+ self.activeGames[pk]["postTime_local"]
+ )
+ )
+ self.sleep(3600)
+ elif (
+ self.activeGames[pk]["postTime_local"] - datetime.today()
+ ).total_seconds() > 1800:
+ self.log.info(
+ "Game day thread post time is still more than 30 minutes away ({}). Sleeping for a half hour...".format(
+ self.activeGames[pk]["postTime_local"]
+ )
+ )
+ self.sleep(1800)
+ else:
+ self.log.info(
+ "Game day thread post time is approaching ({}). Sleeping until then...".format(
+ self.activeGames[pk]["postTime_local"]
+ )
+ )
+ self.sleep(
+ (
+ self.activeGames[pk]["postTime_local"] - datetime.today()
+ ).total_seconds()
+ )
+
+ if redball.SIGNAL is not None or self.bot.STOP:
+ self.log.debug("Caught a stop signal...")
+ return
+
+ # Unsticky stale threads
+ # if self.settings.get("Reddit", {}).get("STICKY", False):
+ # """
+ # # Make sure the subreddit's sticky posts are marked as stale
+ # try:
+ # sticky1 = self.subreddit.sticky(1)
+ # if sticky1.author == self.reddit.user.me() and sticky1 not in self.staleThreads and not next((True for v in self.activeGames.values() if sticky1 in [v.get('gameThread'), v.get('postGameThread')]), None):
+ # self.log.debug('Marking {} as stale (top sticky slot).'.format(sticky1.id))
+ # self.staleThreads.append(sticky1)
+
+ # sticky2 = self.subreddit.sticky(2)
+ # if sticky2.author == self.reddit.user.me() and sticky2 not in self.staleThreads and not next((True for v in self.activeGames.values() if sticky2 in [v.get('gameThread'), v.get('postGameThread')]), None):
+ # self.log.debug('Marking {} as stale (bottom sticky slot).'.format(sticky2.id))
+ # self.staleThreads.append(sticky2)
+ # except Exception:
+ # # Exception likely due to no post being stickied (or only one), so ignore it...
+ # pass
+ # """
+
+ # if len(self.staleThreads):
+ # self.unsticky_threads(self.staleThreads)
+ # self.staleThreads = []
+
+ # Check if game day thread already posted (record in threads table with gamePk and type='gameday' for any of today's gamePks)
+ gdq = "select * from {}threads where type='gameday' and gamePk in ({}) and gameDate = '{}' and deleted=0;".format(
+ self.dbTablePrefix,
+ ",".join(str(i) for i in todayGamePks),
+ self.today["Y-m-d"],
+ )
+ gdThread = rbdb.db_qry(gdq, closeAfter=True, logg=self.log)
+
+ gameDayThread = None
+ if len(gdThread) > 0:
+ self.log.info(
+ "Game Day Thread found in database [{}].".format(gdThread[0]["id"])
+ )
+ gameDayThread = self.lemmy.getPost(gdThread[0]["id"])
+ if not gameDayThread["creator"]["name"]:
+ self.log.warning("Game day thread appears to have been deleted.")
+ q = "update {}threads set deleted=1 where id='{}';".format(
+ self.dbTablePrefix, gameDayThread["post"]["id"]
+ )
+ u = rbdb.db_qry(q, commit=True, closeAfter=True, logg=self.log)
+ if isinstance(u, str):
+ self.log.error(
+ "Error marking thread as deleted in database: {}".format(u)
+ )
+
+ gameDayThread = None
+ else:
+ if gameDayThread["post"]["body"].find("\n\nLast Updated") != -1:
+ gameDayThreadText = gameDayThread["post"]["body"][
+ 0 : gameDayThread["post"]["body"].find("\n\nLast Updated:")
+ ]
+ elif gameDayThread["post"]["body"].find("\n\nPosted") != -1:
+ gameDayThreadText = gameDayThread["post"]["body"][
+ 0 : gameDayThread["post"]["body"].find("\n\nPosted:")
+ ]
+ else:
+ gameDayThreadText = gameDayThread["post"]["body"]
+
+ self.activeGames[pk].update(
+ {
+ "gameDayThreadText": gameDayThreadText,
+ "gameDayThread": gameDayThread,
+ "gameDayThreadTitle": gameDayThread["post"]["name"]
+ if gameDayThread not in [None, False]
+ else None,
+ }
+ )
+ # Only sticky when posting the thread
+ if self.settings.get('Reddit',{}).get('STICKY',False): self.sticky_thread(gameDayThread)
+
+ # Check if post game thread is already posted, and skip game day thread if so
+ if (
+ sum(
+ 1
+ for k, v in self.activeGames.items()
+ if k not in [0, "weekly", "off", "gameday"] and v.get("postGameThread")
+ )
+ == len(self.activeGames) - 1
+ ):
+ # Post game thread is already posted for all games
+ self.log.info(
+ "Post game thread is already submitted for all games, but game day thread is not. Skipping game day thread..."
+ )
+ skipFlag = True
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ else:
+ # Check DB (record in pkThreads with gamePk and type='post' and gameDate=today)
+ pgq = "select * from {}threads where type='post' and gamePk in ({}) and gameDate = '{}' and deleted=0;".format(
+ self.dbTablePrefix,
+ ",".join(
+ [
+ str(x)
+ for x in self.activeGames.keys()
+ if isinstance(x, int) and x > 0 and x != pk
+ ]
+ ),
+ self.today["Y-m-d"],
+ )
+ pgThread = rbdb.db_qry(pgq, closeAfter=True, logg=self.log)
+
+ if len(pgThread) == len(self.activeGames) - 1:
+ self.log.info(
+ "Post Game Thread found in database for all games, but game day thread not posted yet. Skipping game day thread.."
+ )
+ skipFlag = True
+ self.activeGames[pk].update({"STOP_FLAG": True})
+
+ if not gameDayThread and not skipFlag:
+ # Submit game day thread
+ (gameDayThread, gameDayThreadText) = self.prep_and_post(
+ "gameday",
+ todayGamePks,
+ postFooter="""
+
+Posted: """
+ + self.convert_timezone(
+ datetime.utcnow(), self.myTeam["venue"]["timeZone"]["id"]
+ ).strftime("%m/%d/%Y %I:%M:%S %p %Z")
+ + ", Update Interval: {} Minutes".format(
+ self.settings.get("Game Day Thread", {}).get("UPDATE_INTERVAL", 5)
+ ),
+ )
+ self.activeGames[pk].update(
+ {
+ "gameDayThreadText": gameDayThreadText,
+ "gameDayThread": gameDayThread,
+ "gameDayThreadTitle": gameDayThread["post"]["name"]
+ if gameDayThread not in [None, False]
+ else None,
+ }
+ )
+ skipFlag = True
+
+ if gameDayThread:
+ self.activeGames[pk].update({"gameDayThread": gameDayThread})
+ for x in todayGamePks:
+ # Associate game day thread with each of today's gamePks
+ self.activeGames[x].update({"gameDayThread": gameDayThread})
+ self.log.debug("Associated game day thread with gamePk {}".format(x))
+
+ while (
+ not self.activeGames[pk]["STOP_FLAG"]
+ and redball.SIGNAL is None
+ and not self.bot.STOP
+ ):
+ # Keep game day thread updated
+ if skipFlag:
+ # Skip check/edit since skip flag is set
+ skipFlag = None
+ self.log.debug(
+ "Skip flag is set, game day thread was just submitted/edited and does not need to be checked."
+ )
+ else:
+ try:
+ # Update data for this game
+ self.collect_data(todayGamePks)
+ # Update generic data for division games and no-no/perfect game watch
+ self.collect_data(0)
+ # self.log.debug('data passed into render_template: {}'.format(self.commonData))#debug
+ text = self.render_template(
+ thread="gameday",
+ templateType="thread",
+ data=self.commonData,
+ settings=self.settings,
+ )
+ self.log.debug("Rendered game day thread text: {}".format(text))
+ if (
+ text != self.activeGames[pk].get("gameDayThreadText")
+ and text != ""
+ ):
+ self.activeGames[pk].update({"gameDayThreadText": text})
+ text += (
+ """
+
+Last Updated: """
+ + self.convert_timezone(
+ datetime.utcnow(),
+ self.myTeam["venue"]["timeZone"]["id"],
+ ).strftime("%m/%d/%Y %I:%M:%S %p %Z")
+ + ", Update Interval: {} Minutes".format(
+ self.settings.get("Game Day Thread", {}).get(
+ "UPDATE_INTERVAL", 5
+ )
+ )
+ )
+ text = self._truncate_post(text)
+ self.lemmy.editPost(
+ self.activeGames[pk]["gameDayThread"]["post"]["id"],
+ body=text,
+ )
+ self.log.info("Game day thread edits submitted.")
+ self.count_check_edit(
+ self.activeGames[pk]["gameDayThread"]["post"]["id"],
+ "NA",
+ edit=True,
+ )
+ self.log_last_updated_date_in_db(
+ self.activeGames[pk]["gameDayThread"]["post"]["id"]
+ )
+ elif text == "":
+ self.log.info(
+ "Skipping game day thread edit since thread text is blank..."
+ )
+ else:
+ self.log.info("No changes to game day thread.")
+ self.count_check_edit(
+ self.activeGames[pk]["gameDayThread"]["post"]["id"],
+ "NA",
+ edit=False,
+ )
+ except Exception as e:
+ self.log.error("Error editing game day thread: {}".format(e))
+ self.error_notification("Error editing game day thread")
+
+ update_gameday_thread_until = self.settings.get("Game Day Thread", {}).get(
+ "UPDATE_UNTIL", "Game thread is posted"
+ )
+ if update_gameday_thread_until not in [
+ "Do not update",
+ "Game thread is posted",
+ "My team's games are final",
+ "All division games are final",
+ "All MLB games are final",
+ ]:
+ # Unsupported value, use default
+ update_gameday_thread_until = "Game thread is posted"
+
+ if not self.settings.get("Game Day Thread", {}).get("ENABLED", True):
+ # Game day thread is already posted, but disabled. Don't update it.
+ self.log.info(
+ "Stopping game day thread update loop because game day thread is disabled."
+ )
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ break
+ elif update_gameday_thread_until == "Do not update":
+ # Setting says not to update
+ self.log.info(
+ "Stopping game day thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ break
+ elif update_gameday_thread_until == "Game thread is posted":
+ if next(
+ (
+ True
+ for k, v in self.activeGames.items()
+ if v.get("gameThread") or v.get("postGameThread")
+ ),
+ False,
+ ) or next(
+ (
+ True
+ for k, v in self.commonData.items()
+ if k != 0
+ and (
+ v["schedule"]["status"]["abstractGameCode"] == "F"
+ or v["schedule"]["status"]["codedGameState"]
+ in ["C", "D", "U", "T"]
+ )
+ ),
+ False,
+ ):
+ # Game thread is posted
+ self.log.info(
+ "At least one game thread is posted (or post game thread is posted, or game is final). Stopping game day thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ break
+ elif update_gameday_thread_until == "My team's games are final":
+ if not next(
+ (
+ True
+ for k, v in self.commonData.items()
+ if k != 0
+ and v["schedule"]["status"]["abstractGameCode"] != "F"
+ and v["schedule"]["status"]["codedGameState"]
+ not in ["C", "D", "U", "T"]
+ ),
+ False,
+ ):
+ # My team's games are all final
+ self.log.info(
+ "My team's games are all final. Stopping game day thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ break
+ elif update_gameday_thread_until == "All division games are final":
+ if not next(
+ (
+ True
+ for x in self.commonData[0]["leagueSchedule"]
+ if x["status"]["abstractGameCode"] != "F"
+ and x["status"]["codedGameState"] not in ["C", "D", "U", "T"]
+ and self.myTeam["division"]["id"]
+ in [
+ x["teams"]["away"]["team"].get("division", {}).get("id"),
+ x["teams"]["home"]["team"].get("division", {}).get("id"),
+ ]
+ ),
+ False,
+ ):
+ # Division games are all final
+ self.log.info(
+ "All division games are final. Stopping game day thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ break
+ elif update_gameday_thread_until == "All MLB games are final":
+ if not next(
+ (
+ True
+ for x in self.commonData[0]["leagueSchedule"]
+ if x["status"]["abstractGameCode"] != "F"
+ and x["status"]["codedGameState"] not in ["C", "D", "U", "T"]
+ ),
+ False,
+ ):
+ # MLB games are all final
+ self.log.info(
+ "All MLB games are final. Stopping game day thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ break
+
+ self.log.debug(
+ "Game day thread stop criteria not met ({}).".format(
+ update_gameday_thread_until
+ )
+ ) # debug - need this to tell if logic is working
+
+ # Update interval is in minutes (seconds for game thread only)
+ gdtWait = self.settings.get("Game Day Thread", {}).get("UPDATE_INTERVAL", 5)
+ if gdtWait < 1:
+ gdtWait = 1
+ self.log.info("Sleeping for {} minutes...".format(gdtWait))
+ self.sleep(gdtWait * 60)
+
+ if redball.SIGNAL is not None or self.bot.STOP:
+ self.log.debug("Caught a stop signal...")
+ return
+
+ # Mark game day thread as stale
+ if self.activeGames[pk].get("gameDayThread"):
+ self.staleThreads.append(self.activeGames[pk]["gameDayThread"])
+
+ self.log.debug("Ending gameday update thread...")
+ return
+
+ def game_thread_update_loop(self, pk):
+ skipFlag = (
+ None # Will be set later if game thread submit/edit should be skipped
+ )
+
+ # Check if game thread is already posted
+ gq = "select * from {}threads where type='game' and gamePk = {} and gameDate = '{}' and deleted=0;".format(
+ self.dbTablePrefix, pk, self.today["Y-m-d"]
+ )
+ gThread = rbdb.db_qry(gq, closeAfter=True, logg=self.log)
+
+ gameThread = None
+ if len(gThread) > 0:
+ self.log.info(
+ "Game {} Thread found in database [{}]".format(pk, gThread[0]["id"])
+ )
+ gameThread = self.lemmy.getPost(gThread[0]["id"])
+ if not gameThread["creator"]["name"]:
+ self.log.warning("Game thread appears to have been deleted.")
+ q = "update {}threads set deleted=1 where id='{}';".format(
+ self.dbTablePrefix, gameThread["post"]["id"]
+ )
+ u = rbdb.db_qry(q, commit=True, closeAfter=True, logg=self.log)
+ if isinstance(u, str):
+ self.log.error(
+ "Error marking thread as deleted in database: {}".format(u)
+ )
+
+ gameThread = None
+ else:
+ if gameThread["post"]["body"].find("\n\nLast Updated") != -1:
+ gameThreadText = gameThread["post"]["body"][
+ 0 : gameThread["post"]["body"].find("\n\nLast Updated:")
+ ]
+ elif gameThread["post"]["body"].find("\n\nPosted") != -1:
+ gameThreadText = gameThread["post"]["body"][
+ 0 : gameThread["post"]["body"].find("\n\nPosted:")
+ ]
+ else:
+ gameThreadText = gameThread["post"]["body"]
+
+ self.activeGames[pk].update(
+ {
+ "gameThread": gameThread,
+ "gameThreadText": gameThreadText,
+ "gameThreadTitle": gameThread["post"]["name"]
+ if gameThread not in [None, False]
+ else None,
+ }
+ )
+ # Only sticky when posting the thread
+ # if self.settings.get('Reddit',{}).get('STICKY',False): self.sticky_thread(self.activeGames[pk]['gameThread'])
+
+ if not gameThread:
+ # Check if post game thread is already posted, and skip game thread if so
+ if self.activeGames[pk].get("postGameThread"):
+ # Post game thread is already known
+ self.log.info(
+ "Post game thread is already submitted, but game thread is not. Skipping game thread..."
+ )
+ skipFlag = True
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ else:
+ # Check DB (record in pkThreads with gamePk and type='post' and gameDate=today)
+ pgq = "select * from {}threads where type='post' and gamePk = {} and gameDate = '{}' and deleted=0;".format(
+ self.dbTablePrefix, pk, self.today["Y-m-d"]
+ )
+ pgThread = rbdb.db_qry(pgq, closeAfter=True, logg=self.log)
+
+ if len(pgThread) > 0:
+ self.log.info(
+ "Post Game Thread found in database, but game thread not posted yet. Skipping game thread.."
+ )
+ skipFlag = True
+ self.activeGames[pk].update({"STOP_FLAG": True})
+
+ if not gameThread and not skipFlag:
+ # Determine time to post
+ gameStart = self.commonData[pk]["gameTime"]["bot"]
+ postBy = tzlocal.get_localzone().localize(
+ datetime.strptime(
+ datetime.today().strftime(
+ "%Y-%m-%d "
+ + self.settings.get("Game Thread", {}).get("POST_BY", "23:59")
+ ),
+ "%Y-%m-%d %H:%M",
+ )
+ )
+ minBefore = int(
+ self.settings.get("Game Thread", {}).get("MINUTES_BEFORE", 180)
+ )
+ minBefore_time = gameStart - timedelta(minutes=minBefore)
+ self.activeGames[pk].update(
+ {
+ "postTime": min(gameStart, postBy, minBefore_time),
+ "postTime_local": min(gameStart, postBy, minBefore_time).replace(
+ tzinfo=None
+ ),
+ }
+ )
+ self.log.debug(
+ "Game {} thread post time: {} (min of Game Start: {}, Post By: {}, Min Before: {})".format(
+ pk,
+ self.activeGames[pk]["postTime"],
+ gameStart,
+ postBy,
+ minBefore_time,
+ )
+ )
+
+ if (
+ self.commonData[pk]["schedule"]["doubleHeader"] != "N"
+ and self.commonData[pk]["schedule"]["gameNumber"] == 2
+ ):
+ otherGame = next(
+ (
+ v
+ for k, v in self.commonData.items()
+ if k not in [0, pk]
+ and v.get("schedule", {}).get("doubleHeader") in ["Y", "S"]
+ and v.get("schedule", {}).get("gameNumber") == 1
+ ),
+ None,
+ )
+ if otherGame:
+ self.log.debug(
+ "Other Game ({}) abstractGameCode: {} - codedGameState: {}".format(
+ otherGame["schedule"]["gamePk"],
+ otherGame["schedule"]["status"]["abstractGameCode"],
+ otherGame["schedule"]["status"]["codedGameState"],
+ )
+ )
+ else:
+ self.log.warning("Other Game in doubleheader pair not found.")
+
+ # Check/wait for time to submit game thread
+ while (
+ datetime.today() < self.activeGames[pk]["postTime_local"]
+ and redball.SIGNAL is None
+ and not self.bot.STOP
+ and not self.activeGames[pk].get("gameThread")
+ ) or (
+ self.commonData[pk]["schedule"]["doubleHeader"] != "N"
+ and self.commonData[pk]["schedule"]["gameNumber"] == 2
+ ):
+ # Refresh game status
+ with GAME_DATA_LOCK:
+ self.get_gameStatus(pk, self.today["Y-m-d"])
+
+ if self.commonData[pk]["schedule"]["status"][
+ "abstractGameCode"
+ ] == "F" or self.commonData[pk]["schedule"]["status"][
+ "codedGameState"
+ ] in [
+ "C",
+ "D",
+ "U",
+ "T",
+ ]:
+ # codedGameState - Suspended: U, T; Cancelled: C, Postponed: D
+ if not self.settings.get("Post Game Thread", {}).get(
+ "ENABLED", True
+ ):
+ # Post game thread is disabled
+ if not self.settings.get("Game Thread", {}).get(
+ "ENABLED", True
+ ):
+ # Game thread is also disabled
+ skipFlag = True
+ else:
+ # Need to post game thread because post game thread is disabled
+ skipFlag = False
+ else:
+ # Post game thread is enabled, so skip game thread
+ self.log.info(
+ "Game {} is {}; skipping game thread...".format(
+ pk,
+ self.commonData[pk]["schedule"]["status"][
+ "detailedState"
+ ],
+ )
+ )
+ skipFlag = True
+ break
+ elif (
+ self.commonData[pk]["schedule"]["status"]["abstractGameCode"] == "L"
+ ):
+ # Game is already in live status (including warmup), so submit the game thread!
+ self.log.info(
+ "It's technically not time to submit the game {} thread yet, but the game status is Live. Proceeding...".format(
+ pk
+ )
+ )
+ break
+ elif (
+ self.commonData[pk]["schedule"]["doubleHeader"] == "Y"
+ and self.commonData[pk]["schedule"]["gameNumber"] == 2
+ and (
+ self.commonData[otherGame["schedule"]["gamePk"]]["schedule"][
+ "status"
+ ]["abstractGameCode"]
+ == "F"
+ or self.commonData[otherGame["schedule"]["gamePk"]]["schedule"][
+ "status"
+ ]["codedGameState"]
+ in ["C", "D", "U", "T"]
+ )
+ ):
+ # Straight doubleheader game 2 - post time doesn't matter, submit post after game 1 is final
+ # But wait 5 minutes and update common data to pull in new records
+ self.log.info(
+ "Waiting 5 minutes and then proceeding with game thread for straight doubleheader game 2 ({}) because doubleheader game 1 is {}.".format(
+ pk,
+ self.commonData[otherGame["schedule"]["gamePk"]][
+ "schedule"
+ ]["status"]["detailedState"],
+ )
+ )
+ self.sleep(300)
+ self.log.info(
+ "Proceeding with game thread for straight doubleheader game 2 ({}) because doubleheader game 1 is {}.".format(
+ pk,
+ self.commonData[otherGame["schedule"]["gamePk"]][
+ "schedule"
+ ]["status"]["detailedState"],
+ )
+ )
+ break
+ elif (
+ datetime.today() > self.activeGames[pk]["postTime_local"]
+ and self.commonData[pk]["schedule"]["doubleHeader"] == "S"
+ and self.commonData[pk]["schedule"]["gameNumber"] == 2
+ and (
+ self.commonData[otherGame["schedule"]["gamePk"]]["schedule"][
+ "status"
+ ]["abstractGameCode"]
+ == "F"
+ or self.commonData[otherGame["schedule"]["gamePk"]]["schedule"][
+ "status"
+ ]["codedGameState"]
+ in ["C", "D", "U", "T"]
+ )
+ ):
+ # Split doubleheader game 2 - honor post time, but only after game 1 is final
+ self.log.info(
+ "Proceeding with game thread for split doubleheader game 2 ({}) because doubleheader game 1 is {} and post time is reached.".format(
+ pk,
+ self.commonData[otherGame["schedule"]["gamePk"]][
+ "schedule"
+ ]["status"]["detailedState"],
+ )
+ )
+ break
+ elif not self.settings.get("Game Thread", {}).get("ENABLED", True):
+ # Game thread is disabled
+ self.log.info("Game thread is disabled.")
+ skipFlag = True
+ break
+
+ if (
+ self.activeGames[pk]["postTime_local"] - datetime.today()
+ ).total_seconds() >= 600:
+ self.log.info(
+ "Waiting for time to submit game {} thread: {}{}. Sleeping for 10 minutes...".format(
+ pk,
+ self.activeGames[pk]["postTime"],
+ " (will hold past post time until doubleheader game 1 ({}) is final)".format(
+ otherGame["schedule"]["gamePk"]
+ )
+ if self.commonData[pk]["schedule"]["doubleHeader"] != "N"
+ and self.commonData[pk]["schedule"]["gameNumber"] != 1
+ else "",
+ )
+ )
+ self.sleep(600)
+ elif (
+ (
+ self.activeGames[pk]["postTime_local"] - datetime.today()
+ ).total_seconds()
+ < 0
+ and self.commonData[pk]["schedule"]["doubleHeader"] != "N"
+ and self.commonData[pk]["schedule"]["gameNumber"] != 1
+ ):
+ self.log.info(
+ "Game {} thread post time has passed, but holding until doubleheader game 1 ({}) is final (currently {}). Sleeping for 5 minutes....".format(
+ pk,
+ otherGame["schedule"]["gamePk"],
+ self.commonData[otherGame["schedule"]["gamePk"]][
+ "schedule"
+ ]["status"]["detailedState"],
+ )
+ )
+ self.sleep(300)
+ else:
+ self.log.info(
+ "Game {} thread should be posted soon, sleeping until then ({})...".format(
+ pk, self.activeGames[pk]["postTime"]
+ )
+ )
+ self.sleep(
+ (
+ self.activeGames[pk]["postTime_local"] - datetime.today()
+ ).total_seconds()
+ )
+
+ if redball.SIGNAL is not None or self.bot.STOP:
+ self.log.debug("Caught a stop signal...")
+ return
+
+ # Unsticky stale threads
+ if self.settings.get("Reddit", {}).get("STICKY", False):
+ # Make sure game day thread is marked as stale, since we want the game thread to be sticky instead
+ if (
+ self.activeGames.get("gameday", {}).get("gameDayThread")
+ and self.activeGames["gameday"]["gameDayThread"]
+ not in self.staleThreads
+ ):
+ self.staleThreads.append(
+ self.activeGames["gameday"]["gameDayThread"]
+ )
+
+ if len(self.staleThreads):
+ self.unsticky_threads(self.staleThreads)
+ self.staleThreads = []
+
+ if skipFlag or not self.settings.get("Game Thread", {}).get(
+ "ENABLED", True
+ ):
+ # Skip game thread since skip flag is set or game thread is disabled
+ if self.settings.get("Game Thread", {}).get("ENABLED", True):
+ self.log.info("Skipping game {} thread...".format(pk))
+ else:
+ self.log.info(
+ "Game thread is disabled, so skipping for game {}...".format(pk)
+ )
+ else:
+ # Submit Game Thread
+ self.log.info("Preparing to post game {} thread...".format(pk))
+ (gameThread, gameThreadText) = self.prep_and_post(
+ "game",
+ pk,
+ postFooter="""
+
+Posted: """
+ + self.convert_timezone(
+ datetime.utcnow(), self.myTeam["venue"]["timeZone"]["id"]
+ ).strftime("%m/%d/%Y %I:%M:%S %p %Z"),
+ )
+ self.activeGames[pk].update(
+ {
+ "gameThread": gameThread,
+ "gameThreadText": gameThreadText,
+ "gameThreadTitle": gameThread["post"]["name"]
+ if gameThread not in [None, False]
+ else None,
+ }
+ )
+ # Thread is not posted, so don't start an update loop
+ if not self.activeGames[pk].get("gameThread") and self.settings.get(
+ "Game Thread", {}
+ ).get("ENABLED", True):
+ # Game thread is enabled but failed to post
+ self.log.info("Game thread not posted. Ending update loop...")
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ return # TODO: Determine why thread is not posted and retry if temporary issue
+
+ skipFlag = True # Skip first edit since the thread was just posted
+
+ if (
+ self.settings.get("Comments", {}).get("ENABLED", True)
+ and self.activeGames[pk].get("gameThread")
+ and not (
+ self.commonData[pk]["schedule"]["status"]["abstractGameCode"] == "F"
+ or self.commonData[pk]["schedule"]["status"]["codedGameState"]
+ in ["C", "D", "U", "T"]
+ )
+ ):
+ if self.THREADS[pk].get("COMMENT_THREAD") and isinstance(
+ self.THREADS[pk]["COMMENT_THREAD"], threading.Thread
+ ):
+ # Thread is already running...
+ pass
+ else:
+ # Spawn separate thread to submit notable play comments in game thread
+ self.THREADS[pk].update(
+ {
+ "COMMENT_THREAD": threading.Thread(
+ target=self.monitor_game_plays,
+ args=(pk, self.activeGames[pk]["gameThread"]),
+ name="bot-{}-{}-game-{}-comments".format(
+ self.bot.id, self.bot.name.replace(" ", "-"), pk
+ ),
+ daemon=True,
+ )
+ }
+ )
+ self.THREADS[pk]["COMMENT_THREAD"].start()
+ self.log.debug(
+ "Started comment thread {}.".format(
+ self.THREADS[pk]["COMMENT_THREAD"]
+ )
+ )
+ else:
+ if not self.activeGames[pk].get("gameThread"):
+ self.log.info(
+ "Game thread is not posted (even though it should be), so not starting comment process! [pk: {}]".format(
+ pk
+ )
+ )
+ elif self.commonData[pk]["schedule"]["status"][
+ "abstractGameCode"
+ ] == "F" or self.commonData[pk]["schedule"]["status"]["codedGameState"] in [
+ "C",
+ "D",
+ "U",
+ "T",
+ ]:
+ self.log.info(
+ "Game is over, so not starting comment process! [pk: {}]".format(pk)
+ )
+ else:
+ self.log.info(
+ "Commenting is disabled, so not starting comment process! [pk: {}]".format(
+ pk
+ )
+ )
+
+ while (
+ not self.activeGames[pk]["STOP_FLAG"]
+ and redball.SIGNAL is None
+ and not self.bot.STOP
+ ):
+ if skipFlag:
+ # Skip edit since thread was just posted
+ skipFlag = None
+ self.log.debug(
+ "Skip flag is set, game {} thread does not need to be edited.".format(
+ pk
+ )
+ )
+ else:
+ # Re-generate game thread code, compare to current code, edit if different
+ # Update generic data
+ self.collect_data(0)
+ # Update data for this game
+ self.collect_data(pk)
+ text = self.render_template(
+ thread="game",
+ templateType="thread",
+ data=self.commonData,
+ gamePk=pk,
+ settings=self.settings,
+ )
+ self.log.debug("rendered game {} thread text: {}".format(pk, text))
+ if text != self.activeGames[pk].get("gameThreadText") and text != "":
+ self.activeGames[pk].update({"gameThreadText": text})
+ # Add last updated timestamp
+ text += """
+
+Last Updated: """ + self.convert_timezone(
+ datetime.utcnow(), self.myTeam["venue"]["timeZone"]["id"]
+ ).strftime(
+ "%m/%d/%Y %I:%M:%S %p %Z"
+ )
+ text = self._truncate_post(text)
+ self.lemmy.editPost(
+ self.activeGames[pk]["gameThread"]["post"]["id"], body=text
+ )
+ self.log.info("Edits submitted for {} game thread.".format(pk))
+ self.count_check_edit(
+ self.activeGames[pk]["gameThread"]["post"]["id"],
+ self.commonData[pk]["schedule"]["status"]["statusCode"],
+ edit=True,
+ )
+ self.log_last_updated_date_in_db(
+ self.activeGames[pk]["gameThread"]["post"]["id"]
+ )
+ elif text == "":
+ self.log.info(
+ "Skipping game thread {} edit since thread text is blank...".format(
+ pk
+ )
+ )
+ else:
+ self.log.info("No changes to {} game thread.".format(pk))
+ self.count_check_edit(
+ self.activeGames[pk]["gameThread"]["post"]["id"],
+ self.commonData[pk]["schedule"]["status"]["statusCode"],
+ edit=False,
+ )
+
+ update_game_thread_until = self.settings.get("Game Thread", {}).get(
+ "UPDATE_UNTIL", ""
+ )
+ if update_game_thread_until not in [
+ "Do not update",
+ "My team's games are final",
+ "All division games are final",
+ "All MLB games are final",
+ ]:
+ # Unsupported value, use default
+ update_game_thread_until = "All MLB games are final"
+
+ if update_game_thread_until == "Do not update":
+ # Setting says not to update
+ self.log.info(
+ "Stopping game thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ break
+ elif update_game_thread_until == "My team's games are final":
+ if not next(
+ (
+ True
+ for x in self.activeGames.keys()
+ if isinstance(x, int)
+ and x > 0
+ and self.commonData[x]["schedule"]["status"]["abstractGameCode"]
+ != "F"
+ and self.commonData[x]["schedule"]["status"]["codedGameState"]
+ not in ["C", "D", "U", "T"]
+ ),
+ False,
+ ):
+ # My team's games are all final
+ self.log.info(
+ "My team's games are all final. Stopping game thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ break
+ elif update_game_thread_until == "All division games are final":
+ if ( # This game is final
+ self.commonData[pk]["schedule"]["status"]["abstractGameCode"] == "F"
+ or self.commonData[pk]["schedule"]["status"]["codedGameState"]
+ in [
+ "C",
+ "D",
+ "U",
+ "T",
+ ] # Suspended: U, T; Cancelled: C, Postponed: D
+ ) and not next( # And all division games are final
+ (
+ True
+ for x in self.commonData[0]["leagueSchedule"]
+ if x["status"]["abstractGameCode"] != "F"
+ and x["status"]["codedGameState"] not in ["C", "D", "U", "T"]
+ and self.myTeam["division"]["id"]
+ in [
+ x["teams"]["away"]["team"].get("division", {}).get("id"),
+ x["teams"]["home"]["team"].get("division", {}).get("id"),
+ ]
+ ),
+ False,
+ ):
+ # Division games are all final
+ self.log.info(
+ "All division games are final. Stopping game thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ break
+ elif update_game_thread_until == "All MLB games are final":
+ if ( # This game is final
+ self.commonData[pk]["schedule"]["status"]["abstractGameCode"] == "F"
+ or self.commonData[pk]["schedule"]["status"]["codedGameState"]
+ in [
+ "C",
+ "D",
+ "U",
+ "T",
+ ] # Suspended: U, T; Cancelled: C, Postponed: D
+ ) and not next( # And all MLB games are final
+ (
+ True
+ for x in self.commonData[0]["leagueSchedule"]
+ if x["status"]["abstractGameCode"] != "F"
+ and x["status"]["codedGameState"] not in ["C", "D", "U", "T"]
+ ),
+ False,
+ ):
+ # MLB games are all final
+ self.log.info(
+ "All MLB games are final. Stopping game thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"STOP_FLAG": True})
+ break
+
+ self.log.debug(
+ "Game thread stop criteria not met ({}).".format(
+ update_game_thread_until
+ )
+ ) # debug - need this to tell if logic is working
+
+ if self.commonData[pk]["schedule"]["status"]["detailedState"].startswith(
+ "Delayed"
+ ) and self.commonData[pk]["schedule"]["status"]["abstractGameCode"] not in [
+ "I",
+ "IZ",
+ "IH",
+ ]:
+ # I: In Progress, IZ: Delayed: About to Resume, IH: Instant Replay
+ # Update interval is in minutes (seconds only when game is live)
+ gtnlWait = self.settings.get("Game Thread", {}).get(
+ "UPDATE_INTERVAL_NOT_LIVE", 1
+ )
+ if gtnlWait < 1:
+ gtnlWait = 1
+ self.log.info(
+ "Game {} is delayed (abstractGameCode: {}, codedGameState: {}), sleeping for {} minutes...".format(
+ pk,
+ self.commonData[pk]["schedule"]["status"]["abstractGameCode"],
+ self.commonData[pk]["schedule"]["status"]["codedGameState"],
+ gtnlWait,
+ )
+ )
+ self.sleep(gtnlWait * 60)
+ elif self.commonData[pk]["schedule"]["status"]["abstractGameCode"] == "L":
+ # Update interval is in seconds (minutes for all other cases)
+ gtWait = self.settings.get("Game Thread", {}).get("UPDATE_INTERVAL", 10)
+ if gtWait < 1:
+ gtWait = 1
+ self.log.info(
+ "Game {} is live (abstractGameCode: {}, codedGameState: {}), sleeping for {} seconds...".format(
+ pk,
+ self.commonData[pk]["schedule"]["status"]["abstractGameCode"],
+ self.commonData[pk]["schedule"]["status"]["codedGameState"],
+ gtWait,
+ )
+ )
+ self.sleep(gtWait)
+ else:
+ # Update interval is in minutes (seconds only when game is live)
+ gtnlWait = self.settings.get("Game Thread", {}).get(
+ "UPDATE_INTERVAL_NOT_LIVE", 1
+ )
+ if gtnlWait < 1:
+ gtnlWait = 1
+ self.log.info(
+ "Game {} is not live (abstractGameCode: {}, codedGameState: {}), sleeping for {} minutes...".format(
+ pk,
+ self.commonData[pk]["schedule"]["status"]["abstractGameCode"],
+ self.commonData[pk]["schedule"]["status"]["codedGameState"],
+ gtnlWait,
+ )
+ )
+ self.sleep(gtnlWait * 60)
+
+ if redball.SIGNAL is not None or self.bot.STOP:
+ self.log.debug("Caught a stop signal...")
+ return
+
+ self.log.info("All finished with game {}!".format(pk))
+ self.activeGames[pk].update({"STOP_FLAG": True})
+
+ # Mark game thread as stale
+ if self.activeGames[pk].get("gameThread"):
+ self.staleThreads.append(self.activeGames[pk]["gameThread"])
+
+ self.log.debug("Ending game update thread...")
+ return
+
+ def postgame_thread_update_loop(self, pk):
+ skipFlag = (
+ None # Will be set later if post game thread submit/edit should be skipped
+ )
+
+ while redball.SIGNAL is None and not self.bot.STOP:
+ if not self.settings.get("Game Thread", {}).get("ENABLED", True):
+ # Game thread is disabled, so ensure we have fresh data
+ self.log.info(
+ f"Game thread is disabled for game {pk}, so updating data... current status: (abstractGameCode: {self.commonData[pk]['schedule']['status']['abstractGameCode']}, codedGameState: {self.commonData[pk]['schedule']['status']['codedGameState']})"
+ )
+ # Update generic data
+ self.collect_data(0)
+ # Update data for this game
+ self.collect_data(pk)
+ if self.commonData[pk]["schedule"]["status"][
+ "abstractGameCode"
+ ] == "F" or self.commonData[pk]["schedule"]["status"]["codedGameState"] in [
+ "C",
+ "D",
+ "U",
+ "T",
+ ]:
+ # Suspended: U, T; Cancelled: C, Postponed: D
+ # Game is over
+ self.log.info(
+ "Game {} is over (abstractGameCode: {}, codedGameState: {}). Proceeding with post game thread...".format(
+ pk,
+ self.commonData[pk]["schedule"]["status"]["abstractGameCode"],
+ self.commonData[pk]["schedule"]["status"]["codedGameState"],
+ )
+ )
+ break
+ elif self.activeGames[pk]["STOP_FLAG"] and self.settings.get(
+ "Game Thread", {}
+ ).get("ENABLED", True):
+ # Game thread process is enabled and has stopped, but game status isn't final yet... get fresh data!
+ self.log.info(
+ f"Game {pk} thread process has ended, but cached game status is still (abstractGameCode: {self.commonData[pk]['schedule']['status']['abstractGameCode']}, codedGameState: {self.commonData[pk]['schedule']['status']['codedGameState']}). Refreshing data..."
+ )
+ # Update generic data
+ self.collect_data(0)
+ # Update data for this game
+ self.collect_data(pk)
+ else:
+ self.log.debug(
+ "Game {} is not yet final (abstractGameCode: {}, codedGameState: {}). Sleeping for 1 minute...".format(
+ pk,
+ self.commonData[pk]["schedule"]["status"]["abstractGameCode"],
+ self.commonData[pk]["schedule"]["status"]["codedGameState"],
+ )
+ )
+ self.sleep(60)
+
+ if redball.SIGNAL is not None or self.bot.STOP:
+ self.log.debug("Caught a stop signal...")
+ return
+
+ # Unsticky stale threads
+ if self.settings.get("Reddit", {}).get("STICKY", False):
+ # Make sure game thread is marked as stale, since we want the post game thread to be sticky instead
+ if (
+ self.activeGames[pk].get("gameThread")
+ and self.activeGames[pk]["gameThread"] not in self.staleThreads
+ ):
+ self.staleThreads.append(self.activeGames[pk]["gameThread"])
+
+ if len(self.staleThreads):
+ self.unsticky_threads(self.staleThreads)
+ self.staleThreads = []
+
+ # TODO: Skip for (straight?) doubleheader game 1?
+ # TODO: Loop in case thread creation fails due to title template error or API error? At least break from update loop...
+ # Game is over - check if postgame thread already posted (record in pkThreads with gamePk and type='post' and gameDate=today)
+ pgq = "select * from {}threads where type='post' and gamePk = {} and gameDate = '{}' and deleted=0;".format(
+ self.dbTablePrefix, pk, self.today["Y-m-d"]
+ )
+ pgThread = rbdb.db_qry(pgq, closeAfter=True, logg=self.log)
+
+ postGameThread = None
+ if len(pgThread) > 0:
+ self.log.info(
+ "Post Game Thread found in database [{}].".format(pgThread[0]["id"])
+ )
+ postGameThread = self.lemmy.getPost(pgThread[0]["id"])
+ if not postGameThread["post"]["id"]:
+ self.log.warning("Post game thread appears to have been deleted.")
+ q = "update {}threads set deleted=1 where id='{}';".format(
+ self.dbTablePrefix, postGameThread["post"]["id"]
+ )
+ u = rbdb.db_qry(q, commit=True, closeAfter=True, logg=self.log)
+ if isinstance(u, str):
+ self.log.error(
+ "Error marking thread as deleted in database: {}".format(u)
+ )
+
+ postGameThread = None
+ else:
+ if postGameThread["post"]["body"].find("\n\nLast Updated") != -1:
+ postGameThreadText = postGameThread["post"]["body"][
+ 0 : postGameThread["post"]["body"].find("\n\nLast Updated:")
+ ]
+ elif postGameThread["post"]["body"].find("\n\nPosted") != -1:
+ postGameThreadText = postGameThread["post"]["body"][
+ 0 : postGameThread["post"]["body"].find("\n\nPosted:")
+ ]
+ else:
+ postGameThreadText = postGameThread["post"]["body"]
+
+ self.activeGames[pk].update(
+ {
+ "postGameThread": postGameThread,
+ "postGameThreadText": postGameThreadText,
+ "postGameThreadTitle": postGameThread["post"]["name"]
+ if postGameThread not in [None, False]
+ else None,
+ }
+ )
+ # Only sticky when posting the thread
+ if self.settings.get('Reddit',{}).get('STICKY',False): self.sticky_thread(self.activeGames[pk]['postGameThread'])
+
+ game_result = (
+ "EXCEPTION"
+ if self.commonData[pk]["schedule"]["status"]["codedGameState"]
+ in ["C", "D", "U", "T"] # Suspended: U, T; Cancelled: C, Postponed: D
+ else "TIE"
+ if self.commonData[pk]["schedule"]["teams"]["home"]["score"]
+ == self.commonData[pk]["schedule"]["teams"]["away"]["score"]
+ else "WIN"
+ if (
+ (
+ self.commonData[pk]["schedule"]["teams"]["home"]["score"]
+ > self.commonData[pk]["schedule"]["teams"]["away"]["score"]
+ and self.commonData[pk]["homeAway"] == "home"
+ )
+ or (
+ self.commonData[pk]["schedule"]["teams"]["home"]["score"]
+ < self.commonData[pk]["schedule"]["teams"]["away"]["score"]
+ and self.commonData[pk]["homeAway"] == "away"
+ )
+ )
+ else "LOSS"
+ if (
+ (
+ self.commonData[pk]["schedule"]["teams"]["home"]["score"]
+ < self.commonData[pk]["schedule"]["teams"]["away"]["score"]
+ and self.commonData[pk]["homeAway"] == "home"
+ )
+ or (
+ self.commonData[pk]["schedule"]["teams"]["home"]["score"]
+ > self.commonData[pk]["schedule"]["teams"]["away"]["score"]
+ and self.commonData[pk]["homeAway"] == "away"
+ )
+ )
+ else "EXCEPTION"
+ )
+ wanted_results = self.settings.get("Post Game Thread", {}).get(
+ "ONLY_IF_THESE_RESULTS", ["ALL"]
+ )
+ if "ALL" not in wanted_results and game_result not in wanted_results:
+ self.log.info(
+ f"Game result: [{game_result}] is not in the list of wanted results (Post Game Thread > ONLY_IF_THESE_RESULTS): {wanted_results}, skipping post game thread..."
+ )
+ self.activeGames[pk].update({"POST_STOP_FLAG": True})
+ elif not postGameThread:
+ # Submit post game thread
+ (postGameThread, postGameThreadText) = self.prep_and_post(
+ "post",
+ pk,
+ postFooter="""
+
+Posted: """
+ + self.convert_timezone(
+ datetime.utcnow(), self.myTeam["venue"]["timeZone"]["id"]
+ ).strftime("%m/%d/%Y %I:%M:%S %p %Z"),
+ )
+ self.activeGames[pk].update(
+ {
+ "postGameThread": postGameThread,
+ "postGameThreadText": postGameThreadText,
+ "postGameThreadTitle": postGameThread["post"]["name"]
+ if postGameThread not in [None, False]
+ else None,
+ }
+ )
+ if not postGameThread:
+ self.log.info("Post game thread not posted. Ending update loop...")
+ self.activeGames[pk].update({"POST_STOP_FLAG": True})
+ return # TODO: Determine why thread is not posted and retry for temporary issue
+
+ skipFlag = True # No need to edit since the thread was just posted
+
+ while (
+ not self.activeGames[pk]["POST_STOP_FLAG"]
+ and redball.SIGNAL is None
+ and not self.bot.STOP
+ ):
+ # Keep the thread updated until stop threshold is reached
+ if skipFlag:
+ skipFlag = None
+ self.log.debug(
+ "Skipping edit for post game {} thread per skip flag...".format(pk)
+ )
+ else:
+ try:
+ # Update generic data
+ self.collect_data(0)
+ # Update data for this game
+ self.collect_data(pk)
+ text = self.render_template(
+ thread="post",
+ templateType="thread",
+ data=self.commonData,
+ gamePk=pk,
+ settings=self.settings,
+ )
+ self.log.debug(
+ "Rendered post game {} thread text: {}".format(pk, text)
+ )
+ if (
+ text != self.activeGames[pk]["postGameThreadText"]
+ and text != ""
+ ):
+ self.activeGames[pk]["postGameThreadText"] = text
+ text += """
+
+Last Updated: """ + self.convert_timezone(
+ datetime.utcnow(), self.myTeam["venue"]["timeZone"]["id"]
+ ).strftime(
+ "%m/%d/%Y %I:%M:%S %p %Z"
+ )
+ text = self._truncate_post(text)
+ self.lemmy.editPost(
+ self.activeGames[pk]["postGameThread"]["post"]["id"],
+ body=text,
+ )
+
+ self.log.info("Post game {} thread edits submitted.".format(pk))
+ self.log_last_updated_date_in_db(
+ self.activeGames[pk]["postGameThread"]["post"]["id"]
+ )
+ self.count_check_edit(
+ self.activeGames[pk]["postGameThread"]["post"]["id"],
+ self.commonData[pk]["schedule"]["status"]["statusCode"],
+ edit=True,
+ )
+ elif text == "":
+ self.log.info(
+ "Skipping post game {} thread edit since thread text is blank...".format(
+ pk
+ )
+ )
+ else:
+ self.log.info("No changes to post game thread.")
+ self.count_check_edit(
+ self.activeGames[pk]["postGameThread"]["post"]["id"],
+ self.commonData[pk]["schedule"]["status"]["statusCode"],
+ edit=False,
+ )
+ except Exception as e:
+ self.log.error(
+ "Error editing post game {} thread: {}".format(pk, e)
+ )
+ self.error_notification(f"Error editing {pk} post game thread")
+
+ update_postgame_thread_until = self.settings.get(
+ "Post Game Thread", {}
+ ).get("UPDATE_UNTIL", "An hour after thread is posted")
+ if update_postgame_thread_until not in [
+ "Do not update",
+ "An hour after thread is posted",
+ "All division games are final",
+ "All MLB games are final",
+ ]:
+ # Unsupported value, use default
+ update_postgame_thread_until = "An hour after thread is posted"
+
+ if update_postgame_thread_until == "Do not update":
+ # Setting says not to update
+ self.log.info(
+ "Stopping post game thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"POST_STOP_FLAG": True})
+ break
+ elif update_postgame_thread_until == "An hour after thread is posted":
+ if (
+ datetime.utcnow() - datetime.strptime(self.activeGames[pk]["postGameThread"]["post"]["published"],
+ '%Y-%m-%dT%H:%M:%S.%f')
+ >= timedelta(hours=1)
+ ):
+ # Post game thread was posted more than an hour ago
+ self.log.info(
+ "Post game thread was posted an hour ago. Stopping post game thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"POST_STOP_FLAG": True})
+ break
+ elif update_postgame_thread_until == "All division games are final":
+ if not next(
+ (
+ True
+ for x in self.commonData[0]["leagueSchedule"]
+ if x["status"]["abstractGameCode"] != "F"
+ and x["status"]["codedGameState"] not in ["C", "D", "U", "T"]
+ and self.myTeam["division"]["id"]
+ in [
+ x["teams"]["away"]["team"].get("division", {}).get("id"),
+ x["teams"]["home"]["team"].get("division", {}).get("id"),
+ ]
+ ),
+ False,
+ ):
+ # Division games are all final
+ self.log.info(
+ "All division games are final. Stopping post game thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"POST_STOP_FLAG": True})
+ break
+ elif update_postgame_thread_until == "All MLB games are final":
+ if not next(
+ (
+ True
+ for x in self.commonData[0]["leagueSchedule"]
+ if x["status"]["abstractGameCode"] != "F"
+ and x["status"]["codedGameState"] not in ["C", "D", "U", "T"]
+ ),
+ False,
+ ):
+ # MLB games are all final
+ self.log.info(
+ "All MLB games are final. Stopping post game thread update loop per UPDATE_UNTIL setting."
+ )
+ self.activeGames[pk].update({"POST_STOP_FLAG": True})
+ break
+
+ # Update interval is in minutes (seconds for game thread only)
+ pgtWait = self.settings.get("Post Game Thread", {}).get(
+ "UPDATE_INTERVAL", 5
+ )
+ if pgtWait < 1:
+ pgtWait = 1
+ self.log.info(
+ "Post game {} thread update threshold ({}) not yet reached. Sleeping for {} minute(s)...".format(
+ pk, update_postgame_thread_until, pgtWait
+ )
+ )
+ self.sleep(pgtWait * 60)
+
+ if redball.SIGNAL is not None or self.bot.STOP:
+ self.log.debug("Caught a stop signal...")
+ return
+
+ # Mark post game thread as stale if sticky is enabled
+ if postGameThread:
+ self.staleThreads.append(postGameThread)
+ self.log.debug("Ending post game update thread...")
+ return # All done with this game!
+
+ def monitor_game_plays(self, pk, gameThread):
+ # Check if gumbo data exists for pk
+ if not self.commonData.get(pk, {}).get("gumbo"):
+ self.collect_data(gamePk=pk)
+
+ if gameThread:
+ gameThreadId = gameThread["post"]["id"]
+ else:
+ self.log.error("No game thread provided!")
+ return
+
+ myTeamBattingEvents = self.settings.get("Comments", {}).get(
+ "MYTEAM_BATTING_EVENTS", []
+ )
+ self.log.debug(f"Monitored myTeamBattingEvents: [{myTeamBattingEvents}]")
+ myTeamPitchingEvents = self.settings.get("Comments", {}).get(
+ "MYTEAM_PITCHING_EVENTS", []
+ )
+ self.log.debug(f"Monitored myTeamPitchingEvents: [{myTeamPitchingEvents}]")
+ processedAtBatRecord = self.get_processedAtBats_from_db(pk, gameThreadId)
+ if not processedAtBatRecord:
+ self.log.error(
+ "Error retrieving processed at bat info from database. Unable to determine which at bats have already been processed. Starting at the beginning..."
+ )
+ processedAtBats = {}
+ else:
+ processedAtBats = processedAtBatRecord.get("processedAtBats", {})
+ self.log.debug(
+ "Loaded processedAtBats from db: {}. Full record: {}".format(
+ processedAtBats, processedAtBatRecord
+ )
+ )
+
+ while (
+ not self.activeGames[pk]["STOP_FLAG"]
+ and redball.SIGNAL is None
+ and not self.bot.STOP
+ ):
+ # Loop through plays that haven't yet been fully processed
+ for atBat in (
+ a
+ for a in self.commonData[pk]["gumbo"]["liveData"]["plays"]["allPlays"]
+ if a.get("atBatIndex")
+ and a["atBatIndex"]
+ >= max([int(k) for k in processedAtBats.keys()], default=0)
+ ):
+ if redball.SIGNAL is not None or self.bot.STOP:
+ self.log.debug("Breaking loop due to stop signal...")
+ break
+
+ myTeamBatting = (
+ True
+ if (
+ atBat["about"]["isTopInning"]
+ and self.commonData.get(pk, {}).get("homeAway", "") == "away"
+ )
+ or (
+ not atBat["about"]["isTopInning"]
+ and self.commonData.get(pk, {}).get("homeAway", "") == "home"
+ )
+ else False
+ )
+ if not processedAtBats.get(str(atBat["atBatIndex"])):
+ # Add at bat to the tracking dict - c: isComplete, a: actionIndex (abbreviated to save DB space)
+ processedAtBats.update(
+ {str(atBat["atBatIndex"]): {"c": False, "a": []}}
+ )
+ self.log.debug(
+ f"Processing atBatIndex [{atBat['atBatIndex']}] - first time seeing this atBatIndex - actionIndex: {atBat['actionIndex']}"
+ )
+ elif processedAtBats[str(atBat["atBatIndex"])]["c"]:
+ # Already finished processing this at bat
+ self.log.debug(
+ "Already processed atBatIndex {}.".format(atBat["atBatIndex"])
+ )
+ continue
+ else:
+ # Processed this at bat but it wasn't complete yet
+ self.log.debug(
+ f"Processing atBatIndex [{atBat['atBatIndex']}] - prior processing state: {processedAtBats.get(str(atBat['atBatIndex']),'not found')} - actionIndex: {atBat['actionIndex']}"
+ )
+
+ for actionIndex in (
+ x
+ for x in atBat["actionIndex"]
+ if x not in processedAtBats[str(atBat["atBatIndex"])]["a"]
+ ):
+ if redball.SIGNAL is not None or self.bot.STOP:
+ self.log.debug("Breaking loop due to stop signal...")
+ break
+ # Process action
+ self.log.debug(
+ "Processing actionIndex {} for atBatIndex {}: [{}] (myTeamBatting: {}).".format(
+ actionIndex,
+ atBat["atBatIndex"],
+ atBat["playEvents"][actionIndex],
+ myTeamBatting,
+ )
+ )
+ if (
+ (
+ atBat["playEvents"][actionIndex]["details"].get(
+ "eventType",
+ atBat["playEvents"][actionIndex]["details"]
+ .get("event", "")
+ .lower()
+ .replace(" ", "_"),
+ )
+ in myTeamBattingEvents
+ and myTeamBatting
+ )
+ or (
+ atBat["playEvents"][actionIndex]["details"].get(
+ "eventType",
+ atBat["playEvents"][actionIndex]["details"]
+ .get("event", "")
+ .lower()
+ .replace(" ", "_"),
+ )
+ in myTeamPitchingEvents
+ and not myTeamBatting
+ )
+ or (
+ "scoring_play" in myTeamBattingEvents
+ and atBat["playEvents"][actionIndex]["details"].get(
+ "isScoringPlay"
+ )
+ and myTeamBatting
+ )
+ or (
+ "scoring_play" in myTeamPitchingEvents
+ and atBat["playEvents"][actionIndex]["details"].get(
+ "isScoringPlay"
+ )
+ and not myTeamBatting
+ )
+ ):
+ # Event type is wanted
+ self.log.debug(
+ "Detected {}{} event (myTeamBatting: {}).".format(
+ atBat["playEvents"][actionIndex]["details"].get(
+ "eventType",
+ atBat["playEvents"][actionIndex]["details"]
+ .get("event", "")
+ .lower()
+ .replace(" ", "_"),
+ ),
+ "/scoring_play"
+ if atBat["playEvents"][actionIndex]["details"].get(
+ "isScoringPlay"
+ )
+ else "",
+ myTeamBatting,
+ )
+ )
+ text = self.render_template(
+ thread="comment",
+ templateType="body",
+ data=self.commonData,
+ gamePk=pk,
+ settings=self.settings,
+ actionOrResult="action",
+ myTeamBatting=myTeamBatting,
+ atBat=atBat,
+ actionIndex=actionIndex,
+ eventType=atBat["playEvents"][actionIndex]["details"].get(
+ "eventType",
+ atBat["playEvents"][actionIndex]["details"]
+ .get("event", "")
+ .lower()
+ .replace(" ", "_"),
+ ),
+ )
+ self.log.debug("Rendered comment text: {}".format(text))
+ if text != "":
+ try:
+ commentObj = self.lemmy.submitComment(
+ gameThreadId, text, language_id=37
+ )
+ self.log.info(
+ "Submitted comment to game thread {} for actionIndex {} for atBatIndex {}: {}".format(
+ gameThreadId,
+ actionIndex,
+ atBat["atBatIndex"],
+ text,
+ )
+ )
+ self.insert_comment_to_db(
+ pk=pk,
+ gameThreadId=gameThreadId,
+ atBatIndex=atBat["atBatIndex"],
+ actionIndex=actionIndex,
+ isScoringPlay=1
+ if atBat["playEvents"][actionIndex]["details"].get(
+ "isScoringPlay"
+ )
+ else 0,
+ eventType=atBat["playEvents"][actionIndex][
+ "details"
+ ].get(
+ "eventType",
+ atBat["playEvents"][actionIndex]["details"]
+ .get("event", "")
+ .lower()
+ .replace(" ", "_"),
+ ),
+ myTeamBatting=1 if myTeamBatting else 0,
+ commentId=commentObj["comment"]["id"],
+ dateCreated=commentObj["comment"]["published"],
+ dateUpdated=commentObj["comment"]["published"],
+ deleted=0,
+ )
+ except Exception as e:
+ self.log.error(
+ "Error submitting comment to game thread {} for actionIndex {} for atBatIndex {}: {}".format(
+ gameThreadId,
+ actionIndex,
+ atBat["atBatIndex"],
+ e,
+ )
+ )
+ self.error_notification(
+ f"Error submitting comment to game {pk} thread {gameThreadId} for actionIndex {actionIndex} of atBatIndex {atBat['atBatIndex']}"
+ )
+ else:
+ self.log.warning(
+ "Not submitting comment to game thread {} for actionIndex {} for atBatIndex {} because text is blank... ".format(
+ gameThreadId, actionIndex, atBat["atBatIndex"]
+ )
+ )
+ self.error_notification(
+ f"Comment body is blank for game {pk} thread {gameThreadId} for actionIndex {actionIndex} of atBatIndex {atBat['atBatIndex']}"
+ )
+ else:
+ # Event not wanted
+ self.log.debug(
+ "Event {} not wanted.".format(
+ atBat["playEvents"][actionIndex]["details"].get(
+ "eventType",
+ atBat["playEvents"][actionIndex]["details"]
+ .get("event", "")
+ .lower()
+ .replace(" ", "_"),
+ )
+ )
+ )
+
+ # Add actionIndex so we don't process it again
+ processedAtBats[str(atBat["atBatIndex"])]["a"].append(actionIndex)
+
+ if atBat["about"]["isComplete"]:
+ # At bat is complete, so process the result
+ self.log.debug(
+ "Processing result for atBatIndex {}: [{}] (myTeamBatting: {}).".format(
+ atBat["atBatIndex"], atBat, myTeamBatting
+ )
+ )
+ if (
+ (
+ atBat["result"].get(
+ "eventType",
+ atBat["result"]
+ .get("event", "")
+ .lower()
+ .replace(" ", "_"),
+ )
+ in myTeamBattingEvents
+ and myTeamBatting
+ )
+ or (
+ atBat["result"].get(
+ "eventType",
+ atBat["result"]
+ .get("event", "")
+ .lower()
+ .replace(" ", "_"),
+ )
+ in myTeamPitchingEvents
+ and not myTeamBatting
+ )
+ or (
+ "scoring_play" in myTeamBattingEvents
+ and atBat["about"].get("isScoringPlay")
+ and myTeamBatting
+ )
+ or (
+ "scoring_play" in myTeamPitchingEvents
+ and atBat["about"].get("isScoringPlay")
+ and not myTeamBatting
+ )
+ ):
+ # Event type is wanted
+ self.log.debug(
+ "Detected {}{} event (myTeamBatting: {}).".format(
+ atBat["result"].get(
+ "eventType",
+ atBat["result"]
+ .get("event", "")
+ .lower()
+ .replace(" ", "_"),
+ ),
+ "/scoring_play"
+ if atBat["about"].get("isScoringPlay")
+ else "",
+ myTeamBatting,
+ )
+ )
+ text = self.render_template(
+ thread="comment",
+ templateType="body",
+ data=self.commonData,
+ gamePk=pk,
+ settings=self.settings,
+ actionOrResult="result",
+ myTeamBatting=myTeamBatting,
+ atBat=atBat,
+ actionIndex=None,
+ eventType=atBat["result"].get(
+ "eventType",
+ atBat["result"]
+ .get("event", "")
+ .lower()
+ .replace(" ", "_"),
+ ),
+ )
+ self.log.debug("Rendered comment text: {}".format(text))
+ if text != "":
+ try:
+ commentObj = self.lemmy.submitComment(
+ gameThreadId, text, language_id=37
+ )
+ self.log.info(
+ "Submitted comment to game thread {} for result of atBatIndex {}: {}".format(
+ gameThreadId, atBat["atBatIndex"], text
+ )
+ )
+ self.insert_comment_to_db(
+ pk=pk,
+ gameThreadId=gameThreadId,
+ atBatIndex=atBat["atBatIndex"],
+ actionIndex=None,
+ isScoringPlay=1
+ if atBat["about"].get("isScoringPlay")
+ else 0,
+ eventType=atBat["result"].get(
+ "eventType",
+ atBat["result"]
+ .get("event", "")
+ .lower()
+ .replace(" ", "_"),
+ ),
+ myTeamBatting=1 if myTeamBatting else 0,
+ commentId=commentObj["comment"]["id"],
+ dateCreated=commentObj["comment"]["published"],
+ dateUpdated=commentObj["comment"]["published"],
+ deleted=0,
+ )
+ except Exception as e:
+ self.log.error(
+ "Error submitting comment to game thread {} for result of atBatIndex {}: {}".format(
+ gameThreadId, atBat["atBatIndex"], e
+ )
+ )
+ self.error_notification(
+ f"Error submitting comment to game {pk} thread {gameThreadId} for result of atBatIndex {atBat['atBatIndex']}"
+ )
+ else:
+ self.log.warning(
+ "Not submitting comment to game thread {} for result of atBatIndex {} because text is blank... ".format(
+ gameThreadId, atBat["atBatIndex"]
+ )
+ )
+ self.error_notification(
+ f"Comment body is blank for game {pk} thread {gameThreadId} for result of atBatIndex {atBat['atBatIndex']}"
+ )
+ else:
+ # Event not wanted
+ self.log.debug(
+ "Event {} not wanted.".format(
+ atBat["result"].get(
+ "eventType",
+ atBat["result"]
+ .get("event", "")
+ .lower()
+ .replace(" ", "_"),
+ )
+ )
+ )
+
+ # Mark atBatIndex as processed
+ processedAtBats[str(atBat["atBatIndex"])].update({"c": True})
+
+ # Update DB with current processedAtBats
+ self.update_processedAtBats_in_db(pk, gameThreadId, processedAtBats)
+
+ if not self.activeGames.get(pk):
+ self.log.warning("Game {} is no longer being tracked!".format(pk))
+ break
+ elif self.activeGames[pk]["STOP_FLAG"]:
+ self.log.info("Game {} thread stop flag is set.".format(pk))
+ break
+ elif self.commonData[pk]["schedule"]["status"]["detailedState"].startswith(
+ "Delayed"
+ ) and self.commonData[pk]["schedule"]["status"]["abstractGameCode"] not in [
+ "I",
+ "IZ",
+ "IH",
+ ]:
+ # I: In Progress, IZ: Delayed: About to Resume, IH: Instant Replay
+ # Update interval is in minutes (seconds only when game is live)
+ gtnlWait = self.settings.get("Game Thread", {}).get(
+ "UPDATE_INTERVAL_NOT_LIVE", 1
+ )
+ if gtnlWait < 1:
+ gtnlWait = 1
+ self.log.info(
+ "Game {} is delayed (abstractGameCode: {}, codedGameState: {}), sleeping for {} minutes...".format(
+ pk,
+ self.commonData[pk]["schedule"]["status"]["abstractGameCode"],
+ self.commonData[pk]["schedule"]["status"]["codedGameState"],
+ gtnlWait,
+ )
+ )
+ self.sleep(gtnlWait * 60)
+ elif self.commonData[pk]["schedule"]["status"]["abstractGameCode"] == "L":
+ # Update interval is in seconds (minutes for all other cases)
+ gtWait = self.settings.get("Game Thread", {}).get("UPDATE_INTERVAL", 10)
+ if gtWait < 1:
+ gtWait = 1
+ self.log.info(
+ "Game {} is live (abstractGameCode: {}, codedGameState: {}), sleeping for {} seconds...".format(
+ pk,
+ self.commonData[pk]["schedule"]["status"]["abstractGameCode"],
+ self.commonData[pk]["schedule"]["status"]["codedGameState"],
+ gtWait,
+ )
+ )
+ self.sleep(gtWait)
+ else:
+ # Update interval is in minutes (seconds only when game is live)
+ gtnlWait = self.settings.get("Game Thread", {}).get(
+ "UPDATE_INTERVAL_NOT_LIVE", 1
+ )
+ if gtnlWait < 1:
+ gtnlWait = 1
+ self.log.info(
+ "Game {} is not live (abstractGameCode: {}, codedGameState: {}), sleeping for {} minutes...".format(
+ pk,
+ self.commonData[pk]["schedule"]["status"]["abstractGameCode"],
+ self.commonData[pk]["schedule"]["status"]["codedGameState"],
+ gtnlWait,
+ )
+ )
+ self.sleep(gtnlWait * 60)
+
+ if redball.SIGNAL is not None or self.bot.STOP:
+ self.log.debug("Caught a stop signal...")
+ return
+
+ self.log.info("Stopping game play monitor process.")
+ return
+
+ def insert_comment_to_db(
+ self,
+ pk,
+ gameThreadId,
+ atBatIndex,
+ actionIndex,
+ isScoringPlay,
+ eventType,
+ myTeamBatting,
+ commentId,
+ dateCreated,
+ dateUpdated,
+ deleted,
+ ):
+ q = (
+ """INSERT INTO {}comments (
+ gamePk,
+ gameThreadId,
+ atBatIndex,
+ actionIndex,
+ isScoringPlay,
+ eventType,
+ myTeamBatting,
+ commentId,
+ dateCreated,
+ dateUpdated,
+ deleted
+ ) VALUES (
+ ?,?,?,?,?,?,?,?,?,?,?
+ );""".format(
+ self.dbTablePrefix
+ ),
+ (
+ pk,
+ gameThreadId,
+ atBatIndex,
+ actionIndex,
+ isScoringPlay,
+ eventType,
+ myTeamBatting,
+ commentId,
+ dateCreated,
+ dateUpdated,
+ deleted,
+ ),
+ )
+ i = rbdb.db_qry(q, commit=True, closeAfter=True, logg=self.log)
+ if isinstance(i, str):
+ self.log.error("Error inserting comment into database: {}".format(i))
+ return False
+ else:
+ return True
+
+ def get_processedAtBats_from_db(self, pk, gameThreadId):
+ sq = (
+ "SELECT * FROM {}processedAtBats WHERE gamePk=? and gameThreadId=?;".format(
+ self.dbTablePrefix
+ )
+ )
+ local_args = (pk, gameThreadId)
+ s = rbdb.db_qry((sq, local_args), fetchone=True, closeAfter=True, logg=self.log)
+ if isinstance(s, str):
+ # Error querying for existing row
+ self.log.error(
+ "Error querying for existing row in {}processedAtBats table for gamePk {} and threadId {}: {}".format(
+ self.dbTablePrefix, pk, gameThreadId, s
+ )
+ )
+ elif not s:
+ # Row does not exist; insert it
+ self.log.debug(
+ "Creating record in {}processedAtBats table...".format(
+ self.dbTablePrefix
+ )
+ )
+ ts = time.time()
+ emptyDict = json.dumps({})
+ q = "insert into {}processedAtBats (gamePk, gameThreadId, processedAtBats, dateCreated, dateUpdated) values ({}, '{}', '{}', '{}', '{}');".format(
+ self.dbTablePrefix,
+ pk,
+ gameThreadId,
+ emptyDict,
+ ts,
+ ts,
+ )
+
+ r = rbdb.db_qry(q, commit=True, closeAfter=True, logg=self.log)
+ if isinstance(r, str):
+ # Error inserting/updating row
+ self.log.error(
+ "Error inserting/updating row in {}processedAtBats table for gamePk {} and gameThreadId {}: {}".format(
+ self.dbTablePrefix, pk, gameThreadId, r
+ )
+ )
+ else:
+ return {
+ "gamePk": pk,
+ "gameThreadId": gameThreadId,
+ "processedAtBats": {},
+ "dateCreated": ts,
+ "dateUpdated": ts,
+ }
+ else:
+ # Row already exists; return the record
+ s.update({"processedAtBats": json.loads(s.get("processedAtBats", "{}"))})
+ self.log.debug(
+ "Found record in {}processedAtBats table: {}".format(
+ self.dbTablePrefix, s
+ )
+ )
+ return s
+
+ return None
+
+ def update_processedAtBats_in_db(self, pk, gameThreadId, processedAtBats):
+ q = "UPDATE {}processedAtBats SET processedAtBats=?, dateUpdated=? where gamePk=? and gameThreadId=?;".format(
+ self.dbTablePrefix
+ )
+ local_args = (json.dumps(processedAtBats), time.time(), pk, gameThreadId)
+ r = rbdb.db_qry((q, local_args), commit=True, closeAfter=True, logg=self.log)
+ if isinstance(r, str):
+ # Error inserting/updating row
+ self.log.error(
+ "Error updating row in {}processedAtBats table for gamePk {} and gameThreadId {}: {}".format(
+ self.dbTablePrefix, pk, gameThreadId, r
+ )
+ )
+ else:
+ return True
+
+ return False
+
+ def patch_dict(self, theDict, patch):
+ # theDict = dict to patch
+ # patch = patch to apply to theDict
+ # return patched dict
+ if redball.DEV:
+ self.log.debug(f"theDict to be patched: {theDict}")
+ self.log.debug(f"patch to be applied: {patch}")
+ for x in patch:
+ self.log.debug(f"x:{patch.index(x)}, len(patch): {len(patch)}")
+ for d in x.get("diff", []):
+ if redball.DEV:
+ self.log.debug(f"d:{d}")
+
+ try:
+ if d.get("op") is not None:
+ value = d.get("value")
+ if value is not None or d.get("op") == "remove":
+ path = d.get("path", "").split("/")
+ target = theDict
+ for i, p in enumerate(path[1:]):
+ if redball.DEV:
+ self.log.debug(
+ f"i:{i}, p:{p}, type(target):{type(target)}"
+ )
+
+ if i == len(path) - 2:
+ # end of the path--set the value
+ if d.get("op") == "add":
+ if isinstance(target, list):
+ if redball.DEV:
+ self.log.debug(
+ f"appending [{value}] to target; target type:{type(target)}"
+ )
+
+ target.append(value)
+ continue
+ elif isinstance(target, dict):
+ if redball.DEV:
+ self.log.debug(
+ f"setting target[{p}] to [{value}]; target type:{type(target)}"
+ )
+
+ target[p] = value
+ continue
+ elif d.get("op") == "remove":
+ if redball.DEV:
+ self.log.debug(
+ f"removing target[{p}]; target type:{type(target)}, target len:{len(target if not isinstance(target, int) and not isinstance(target, bool) else '')}"
+ )
+
+ try:
+ if isinstance(target, list):
+ if int(p) < len(target):
+ target.pop(
+ int(p)
+ if isinstance(target, list)
+ else p
+ )
+ else:
+ self.log.warning(
+ f"Index {p} does not exist in target list: {target}"
+ )
+ elif isinstance(target, dict):
+ if p in target.keys():
+ target.pop(p)
+ else:
+ self.log.warning(
+ f"Key {p} does not exist in target dict: {target}"
+ )
+ else:
+ self.log.warning(
+ f"Not sure how to remove {p} from target: {target}"
+ )
+ except Exception as e:
+ self.log.error(
+ f"Error removing {path}: {e}"
+ )
+ self.error_notification(
+ f"Error patching dict--cannot remove {path} from target [{target}]"
+ )
+
+ continue
+ elif d.get("op") == "replace":
+ if redball.DEV:
+ self.log.debug(
+ f"updating target[{p}] to [{value}]; target type:{type(target)}"
+ )
+
+ if isinstance(target, list):
+ if len(target) > 0 and len(target) > int(p):
+ target[int(p)] = value
+ elif int(p) == len(target):
+ if redball.DEV:
+ self.log.debug(
+ "op=replace, but provided index does not exist yet (it's next up); appending value to list"
+ )
+
+ target.append(value)
+ else:
+ self.log.warning(
+ f"Data discrepancy found while patching gumbo data: List is not long enough to replace index {p} (len: {len(target)})"
+ )
+ return False
+ else:
+ target[p] = value
+
+ continue
+ elif (
+ isinstance(target, dict)
+ and target.get(
+ int(p) if isinstance(target, list) else p
+ )
+ is None
+ ) or (
+ isinstance(target, list) and len(target) <= int(p)
+ ):
+ # key does not exist
+ if isinstance(path[i + 1], int):
+ # next hop is a list
+ if redball.DEV:
+ self.log.debug(
+ f"missing key, adding list for target[{p}]"
+ )
+
+ if isinstance(target, list):
+ if len(target) == int(p):
+ target.append([])
+ else:
+ self.log.warning(
+ f"Data discrepancy found while patching gumbo data: List is not long enough to append index [{p}] (len: {len(target)})."
+ )
+ return False
+ else:
+ target[p] = []
+ elif i == len(path) - 3 and d.get("op") == "add":
+ # next hop is the target key to add
+ # do nothing, because it will be handled on the next loop
+ if redball.DEV:
+ self.log.debug(
+ "missing key, but not a problem because op=add; continuing..."
+ )
+ continue
+ else:
+ # next hop is a dict
+ if redball.DEV:
+ self.log.debug(
+ f"missing key, adding dict for target[{p}]"
+ )
+
+ if isinstance(target, list):
+ if len(target) == int(p):
+ target.append({})
+ else:
+ self.log.warning(
+ f"Data discrepancy found while patching gumbo data: List is not long enough (len: {len(target)}) to append index [{p}]."
+ )
+ return False
+ else:
+ target[p] = {}
+ # point to next key in the path
+ target = target[
+ int(p) if isinstance(target, list) else p
+ ]
+ if redball.DEV:
+ self.log.debug(
+ f"type(target) after next hop: {type(target)}"
+ )
+ else:
+ # No value to add
+ if redball.DEV:
+ self.log.debug("no value")
+ else:
+ # No op
+ if redball.DEV:
+ self.log.debug("no op")
+ except Exception as e:
+ self.log.error(f"Error patching gumbo data: {e}")
+ self.error_notification(f"Error patching gumbo data: {e}")
+ return False
+
+ self.log.debug("Patch complete.")
+ return True
+
+ def get_gameStatus(self, pk, d=None):
+ # pk = gamePk, d = date ('%Y-%m-%d')
+ params = {
+ "sportId": 1,
+ "fields": "dates,date,totalGames,games,gamePk,gameDate,status,statusCode,abstractGameCode,detailedState,abstractGameState,codedGameState,startTimeTBD",
+ }
+ if d:
+ params.update({"date": d})
+ s = self.api_call("schedule", params)
+ games = s["dates"][
+ next((i for i, x in enumerate(s["dates"]) if x["date"] == d), 0)
+ ]["games"]
+ status = games[next((i for i, x in enumerate(games) if x["gamePk"] == pk), 0)][
+ "status"
+ ]
+ if not self.commonData.get(pk):
+ self.commonData.update({pk: {"schedule": {}}})
+ self.commonData[pk]["schedule"].update({"status": status})
+
+ return True
+
+ def get_gamePks(self, t=None, o=None, d=None, sd=None, ed=None):
+ # t = teamId, o = opponentId, d = date ('%Y-%m-%d'), sd = start date, ed = end date
+ params = {"sportId": 1, "fields": "dates,games,gamePk"}
+ if t:
+ params.update({"teamId": t})
+
+ if o and t:
+ params.update({"opponentId": o}) # opponentId not supported without teamId
+
+ if d:
+ params.update({"date": d})
+ elif sd and ed:
+ params.update({"startDate": sd, "endDate": ed})
+
+ s = self.api_call("schedule", params)
+ pks = []
+ for date in s.get("dates", []):
+ for game in date.get("games"):
+ if game.get("gamePk"):
+ pks.append(game["gamePk"])
+
+ return pks
+
+ def get_seasonState(self, t=None):
+ self.log.debug(
+ f"myteam league seasondateinfo: {self.myTeam['league']['seasonDateInfo']}"
+ )
+ if self.settings.get("MLB", {}).get("SEASON_STATE_OVERRIDE"):
+ self.log.debug("Overriding season state per SEASON_STATE_OVERRIDE setting")
+ return self.settings["MLB"]["SEASON_STATE_OVERRIDE"]
+ elif self.myTeam["league"]["seasonDateInfo"].get(
+ "springStartDate"
+ ) and datetime.strptime(
+ self.myTeam["league"]["seasonDateInfo"]["springStartDate"],
+ "%Y-%m-%d",
+ ) <= datetime.strptime(
+ self.today["Y-m-d"], "%Y-%m-%d"
+ ) < datetime.strptime(
+ self.myTeam["league"]["seasonDateInfo"]["regularSeasonStartDate"],
+ "%Y-%m-%d",
+ ):
+ # Preseason (includes day or two in between pre and regular season)
+ return "pre"
+ elif (
+ not self.myTeam["league"]["seasonDateInfo"].get("springStartDate")
+ and datetime.strptime(self.today["Y-m-d"], "%Y-%m-%d")
+ < datetime.strptime(
+ self.myTeam["league"]["seasonDateInfo"]["regularSeasonStartDate"],
+ "%Y-%m-%d",
+ )
+ ) or (
+ datetime.strptime(self.today["Y-m-d"], "%Y-%m-%d")
+ < datetime.strptime(
+ self.myTeam["league"]["seasonDateInfo"]["springStartDate"],
+ "%Y-%m-%d",
+ )
+ ):
+ # Offseason (prior to season)
+ return "off:before"
+ elif (
+ datetime.strptime(
+ self.myTeam["league"]["seasonDateInfo"]["regularSeasonStartDate"],
+ "%Y-%m-%d",
+ )
+ <= datetime.strptime(self.today["Y-m-d"], "%Y-%m-%d")
+ <= datetime.strptime(
+ self.myTeam["league"]["seasonDateInfo"]["regularSeasonEndDate"],
+ "%Y-%m-%d",
+ )
+ ):
+ # Regular season
+ return "regular"
+ elif (
+ datetime.strptime(
+ self.myTeam["league"]["seasonDateInfo"]["regularSeasonEndDate"],
+ "%Y-%m-%d",
+ )
+ < datetime.strptime(self.today["Y-m-d"], "%Y-%m-%d")
+ <= datetime.strptime(
+ self.myTeam["league"]["seasonDateInfo"]["postSeasonEndDate"], "%Y-%m-%d"
+ )
+ ):
+ # Postseason
+ if (
+ len(
+ self.get_gamePks(
+ t=t,
+ sd=self.today["Y-m-d"],
+ ed=self.myTeam["league"]["seasonDateInfo"]["postSeasonEndDate"],
+ )
+ )
+ > 0
+ ):
+ # The team is in the postseason, hooray!
+ return "post:in"
+ else:
+ # Check if there is a game scheduled where my team is part of a TBD
+ sc = self.get_schedule_data(
+ sd=self.today["Y-m-d"],
+ ed=self.myTeam["league"]["seasonDateInfo"]["postSeasonEndDate"],
+ )
+ all_teams = []
+ for d in sc["dates"]:
+ for g in d["games"]:
+ all_teams.extend(
+ [
+ g["teams"]["away"]["team"]["name"],
+ g["teams"]["home"]["team"]["name"],
+ ]
+ )
+ all_teams = set(all_teams)
+ team = self.get_team(t, h="")
+ team_found = next(
+ (x for x in all_teams if team["abbreviation"] in x), False
+ )
+ if team_found:
+ # The team is in the postseason!
+ self.log.info(
+ f"Team determined to be in the postseason based on a game scheduled with a TBD team that includes [{team['abbreviation']}] in the name: [{team_found}]."
+ )
+ return "post:in"
+ else:
+ # Better luck next year...
+ return "post:out"
+ elif datetime.strptime(self.today["Y-m-d"], "%Y-%m-%d") > datetime.strptime(
+ self.myTeam["league"]["seasonDateInfo"]["postSeasonEndDate"], "%Y-%m-%d"
+ ):
+ # Offseason (after season)
+ return "off:after"
+ else:
+ # No idea...
+ return None
+
+ def get_nextGame(self, t=None):
+ # t = teamId, default will by self.myTeam['id']
+ if t == self.myTeam["id"]:
+ seasonState = self.seasonState
+ else:
+ seasonState = self.get_seasonState(t)
+
+ if seasonState == "off:before":
+ season = int(self.today["Y"])
+ the_date = (
+ datetime.strptime(
+ self.myTeam["league"]["seasonDateInfo"]["springStartDate"],
+ "%Y-%m-%d",
+ )
+ if self.myTeam["league"]["seasonDateInfo"].get("springStartDate")
+ else (
+ datetime.strptime(
+ self.myTeam["league"]["seasonDateInfo"][
+ "regularSeasonStartDate"
+ ],
+ "%Y-%m-%d",
+ )
+ )
+ )
+ start_date = (the_date - timedelta(days=3)).strftime("%Y-%m-%d")
+ end_date = (the_date + timedelta(days=7)).strftime("%Y-%m-%d")
+ elif seasonState in ["pre", "regular", "post:in"]:
+ season = int(self.today["Y"])
+ start_date = self.today["Y-m-d"]
+ end_date = (
+ datetime.strptime(self.today["Y-m-d"], "%Y-%m-%d") + timedelta(days=10)
+ ).strftime("%Y-%m-%d")
+ elif seasonState in ["post:out", "off:after"]:
+ season = int(self.today["Y"]) + 1
+ seasonInfo = self.api_call("seasons", {"season": season, "sportId": 1})[
+ "seasons"
+ ]
+ if len(seasonInfo) and seasonInfo[0].get("seasonStartDate"):
+ seasonInfo = seasonInfo[0]
+ start_date = (
+ datetime.strptime(seasonInfo["seasonStartDate"], "%Y-%m-%d")
+ - timedelta(days=3)
+ ).strftime("%Y-%m-%d")
+ end_date = (
+ datetime.strptime(seasonInfo["seasonStartDate"], "%Y-%m-%d")
+ + timedelta(days=7)
+ ).strftime("%Y-%m-%d")
+ else:
+ self.log.warning(
+ "Dates not published for {} season yet! Checking for games in next 10 days instead...".format(
+ season
+ )
+ )
+ start_date = self.today["Y-m-d"]
+ end_date = (
+ datetime.strptime(self.today["Y-m-d"], "%Y-%m-%d")
+ + timedelta(days=10)
+ ).strftime("%Y-%m-%d")
+ else:
+ # Don't know what to do here
+ self.log.error("Unknown season state, cannot determine next game...")
+ return {}
+
+ sc = self.get_schedule_data(t=t, sd=start_date, ed=end_date)
+ if len(sc["dates"]) == 0:
+ # No games found
+ self.log.debug("No games found!")
+ return {}
+ else:
+ lookAfter = (
+ self.commonData[max(self.commonData.keys())]["gameTime"]["utc"]
+ if len(self.commonData) > 1
+ else datetime.strptime(
+ (
+ datetime.strptime(self.today["Y-m-d"], "%Y-%m-%d")
+ + timedelta(days=1)
+ ).strftime("%Y-%m-%d")
+ + "T"
+ + datetime.utcnow().strftime("%H:%M:%SZ"),
+ "%Y-%m-%dT%H:%M:%SZ",
+ ).replace(
+ tzinfo=pytz.utc
+ ) # UTC time is in the next day
+ if (
+ datetime.strptime(
+ self.today["Y-m-d"]
+ + "T"
+ + datetime.now().strftime("%H:%M:%SZ"),
+ "%Y-%m-%dT%H:%M:%SZ",
+ ).replace(tzinfo=tzlocal.get_localzone())
+ - datetime.strptime(
+ self.today["Y-m-d"]
+ + "T"
+ + datetime.utcnow().strftime("%H:%M:%SZ"),
+ "%Y-%m-%dT%H:%M:%SZ",
+ ).replace(tzinfo=pytz.utc)
+ ).total_seconds()
+ / 60
+ / 60
+ / 24
+ > 0.5 # With "today" date, difference between time in UTC and local is more than 12hr
+ else datetime.strptime(
+ self.today["Y-m-d"] + "T" + datetime.utcnow().strftime("%H:%M:%SZ"),
+ "%Y-%m-%dT%H:%M:%SZ",
+ ).replace(
+ tzinfo=pytz.utc
+ ) # Date is still the same when time is converted to UTC
+ )
+ self.log.debug(
+ "Looking for next game starting after {} ({})...".format(
+ lookAfter,
+ "start time of game {}".format(max(self.commonData.keys()))
+ if len(self.commonData) > 1
+ else "current time",
+ )
+ )
+ nextGame = next(
+ (
+ date["games"][game_index]
+ for date in sc["dates"]
+ for game_index, game in enumerate(date["games"])
+ if datetime.strptime(
+ game["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc)
+ > lookAfter
+ ),
+ {},
+ )
+
+ if nextGame:
+ nextGame.update(
+ {
+ "gameTime": {
+ "myTeam": self.convert_timezone(
+ datetime.strptime(
+ nextGame["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc),
+ self.myTeam["venue"]["timeZone"]["id"],
+ ),
+ "homeTeam": self.convert_timezone(
+ datetime.strptime(
+ nextGame["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc),
+ nextGame["venue"]["timeZone"]["id"],
+ ),
+ "bot": self.convert_timezone(
+ datetime.strptime(
+ nextGame["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc),
+ "local",
+ ),
+ "utc": datetime.strptime(
+ nextGame["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc),
+ }
+ }
+ )
+
+ self.log.debug("Found next game for team {}: {}".format(t, nextGame))
+
+ return nextGame
+
+ return {}
+
+ def collect_data(self, gamePk):
+ """Collect data to be available for template rendering"""
+
+ # Need to use cached data because multiple threads will be trying to update the same data at the same time
+ cache_seconds = self.settings.get("MLB", {}).get("API_CACHE_SECONDS", 5)
+ if cache_seconds < 0:
+ cache_seconds = 5 # Use default of 5 seconds if negative value provided
+
+ if gamePk == 0:
+ # Generic data used by all threads
+ with (
+ GENERIC_DATA_LOCK
+ ): # Use lock to prevent multiple threads from updating data at the same time
+ if self.commonData.get(gamePk) and self.commonData[gamePk].get(
+ "lastUpdate", datetime.today() - timedelta(hours=1)
+ ) >= datetime.today() - timedelta(seconds=cache_seconds):
+ self.log.debug(
+ "Using cached data for gamePk {}, updated {} seconds ago.".format(
+ gamePk,
+ (
+ datetime.today() - self.commonData[gamePk]["lastUpdate"]
+ ).total_seconds(),
+ )
+ )
+ return False
+ else:
+ self.log.debug(
+ "Collecting data for gamePk {} with StatsAPI v{}".format(
+ gamePk, statsapi.__version__
+ )
+ )
+
+ pkData = {} # temp dict to hold the data until it's complete
+
+ # Date that represents 'today'
+ pkData.update({"today": self.today})
+
+ # Update standings info
+ pkData.update(
+ {"standings": statsapi.standings_data()}
+ ) # TODO: something similar to api_call()?
+
+ # Update schedule data for today's other games - for no-no watch & division/league scoreboard
+ ls = self.get_schedule_data(
+ d=self.today["Y-m-d"],
+ h="team(division,league),linescore,flags,venue(timezone)",
+ )
+ pkData.update({"leagueSchedule": []})
+ y = next(
+ (
+ i
+ for i, x in enumerate(ls["dates"])
+ if x["date"] == self.today["Y-m-d"]
+ ),
+ None,
+ )
+ if y is not None:
+ games = ls["dates"][y]["games"]
+ for (
+ x
+ ) in (
+ games
+ ): # Include all games and filter out current gamePk when displaying
+ if x["doubleHeader"] == "Y" and x["gameNumber"] == 2:
+ # Find DH game 1
+ otherGame = next(
+ (
+ {
+ "gamePk": v["schedule"]["gamePk"],
+ "gameDate": v["schedule"]["gameDate"],
+ }
+ for k, v in self.commonData.items()
+ if k not in [0, "weekly", "off", "gameday"]
+ and v.get("schedule", {}).get("gamePk")
+ != x["gamePk"]
+ and v.get("schedule", {}).get("doubleHeader") == "Y"
+ and v.get("schedule", {}).get("gameNumber") == 1
+ and v.get("schedule", {})
+ .get("teams", {})
+ .get("home", {})
+ .get("team", {})
+ .get("id")
+ in [
+ x.get("teams", {})
+ .get("home", {})
+ .get("team", {})
+ .get("id"),
+ x.get("teams", {})
+ .get("away", {})
+ .get("team", {})
+ .get("id"),
+ ]
+ ),
+ None,
+ )
+ self.log.debug(
+ f"Result of check for DH game 1 in commonData: {otherGame}"
+ )
+
+ if not otherGame:
+ # Check league schedule
+ otherGame = next(
+ (
+ {
+ "gamePk": v["gamePk"],
+ "gameDate": v["gameDate"],
+ }
+ for v in games
+ if v.get("gamePk") != x["gamePk"]
+ and v.get("doubleHeader") == "Y"
+ and v.get("gameNumber") == 1
+ and v.get("teams", {})
+ .get("home", {})
+ .get("team", {})
+ .get("id")
+ in [
+ x.get("teams", {})
+ .get("home", {})
+ .get("team", {})
+ .get("id"),
+ x.get("teams", {})
+ .get("away", {})
+ .get("team", {})
+ .get("id"),
+ ]
+ ),
+ None,
+ )
+ self.log.debug(
+ f"Result of check for DH game 1 in leagueSchedule: {otherGame}"
+ )
+
+ if not otherGame:
+ # Get schedule data from MLB
+ self.log.debug(
+ f"Getting schedule data for team id [{self.myTeam['id']}] and date [{self.today['Y-m-d']}]..."
+ )
+ sched = self.api_call(
+ "schedule",
+ {
+ "sportId": 1,
+ "date": self.today["Y-m-d"],
+ "teamId": self.myTeam["id"],
+ "fields": "dates,date,games,gamePk,gameDate,doubleHeader,gameNumber",
+ },
+ )
+ schedGames = sched["dates"][
+ next(
+ (
+ i
+ for i, y in enumerate(sched["dates"])
+ if y["date"] == self.today["Y-m-d"]
+ ),
+ 0,
+ )
+ ]["games"]
+ otherGame = next(
+ (
+ v
+ for v in schedGames
+ if v.get("gamePk") != x["gamePk"]
+ and v.get("doubleHeader") == "Y"
+ and v.get("gameNumber") == 1
+ ),
+ None,
+ )
+ self.log.debug(
+ f"Result of check for DH game 1 in MLB schedule data: {otherGame}"
+ )
+
+ if otherGame:
+ # Replace gameDate for straight doubleheader game 2 to reflect game 1 + 3 hours
+ self.log.debug(f"DH Game 1: {otherGame['gamePk']}")
+ x["gameDate"] = (
+ datetime.strptime(
+ otherGame["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc)
+ + timedelta(hours=3)
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
+ self.log.info(
+ f"Replaced game time for DH Game 2 [{x['gamePk']}] to 3 hours after Game 1 [{otherGame['gamePk']}] game time: [{x['gameDate']}]"
+ )
+ else:
+ self.log.debug(
+ f"Failed to find DH game 1 for DH game 2 [{x['gamePk']}]"
+ )
+
+ # Convert game time to myTeam's timezone as well as local (homeTeam's) timezone
+ x.update(
+ {
+ "gameTime": {
+ "myTeam": self.convert_timezone(
+ datetime.strptime(
+ x["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc),
+ self.myTeam["venue"]["timeZone"]["id"],
+ ),
+ "bot": self.convert_timezone(
+ datetime.strptime(
+ x["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc),
+ "local",
+ ),
+ "utc": datetime.strptime(
+ x["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc),
+ }
+ }
+ )
+ if x["venue"].get("timeZone", {}).get("id"):
+ x["gameTime"].update(
+ {
+ "homeTeam": self.convert_timezone(
+ datetime.strptime(
+ x["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc),
+ self.myTeam["venue"]["timeZone"]["id"],
+ ),
+ }
+ )
+ else:
+ self.log.warn(
+ f"Game {x['gamePk']} has no venue timezone. Using myTeam timezone in place of home team timezone."
+ )
+ x["gameTime"].update(
+ {
+ "homeTeam": self.convert_timezone(
+ datetime.strptime(
+ x["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc),
+ x["venue"]["timeZone"]["id"],
+ ),
+ }
+ )
+ pkData["leagueSchedule"].append(x)
+ else:
+ self.log.debug("There are no games at all today.")
+
+ # self.myTeam - updated daily
+ pkData.update({"myTeam": self.myTeam})
+
+ # My team's season state
+ pkData["myTeam"].update({"seasonState": self.seasonState})
+
+ # My team's next game
+ pkData["myTeam"].update(
+ {"nextGame": self.get_nextGame(self.myTeam["id"])}
+ )
+
+ # Include team community dict
+ pkData.update({"teamSubs": self.teamSubs})
+
+ # Team leaders (hitting, pitching)
+ # Team stats
+ # Last / next game schedule info
+ # Last game recap/highlights/score/decisions
+ # Next opponent team info/record/standings
+
+ pkData.update({"lastUpdate": datetime.today()})
+
+ # Make the data available
+ self.commonData.update({gamePk: pkData})
+ else:
+ # gamePk was provided, update game-specific data
+ # Get generic data if it doesn't exist
+ if not self.commonData.get(0):
+ self.collect_data(0)
+
+ with (
+ GAME_DATA_LOCK
+ ): # Use lock to prevent multiple threads from updating data at the same time
+ # Update game-specific data
+ if not isinstance(gamePk, list):
+ gamePks = [gamePk]
+ else:
+ gamePks = [x for x in gamePk]
+
+ if len(gamePks) == 0:
+ self.log.warning("No gamePks to collect data for.")
+ return False
+
+ for pk in gamePks[:]:
+ if self.commonData.get(pk) and self.commonData[pk].get(
+ "lastUpdate", datetime.today() - timedelta(hours=1)
+ ) >= datetime.today() - timedelta(seconds=cache_seconds):
+ self.log.debug(
+ "Using cached data for gamePk {}, updated {} seconds ago.".format(
+ pk,
+ (
+ datetime.today() - self.commonData[pk]["lastUpdate"]
+ ).total_seconds(),
+ )
+ )
+ gamePks.remove(pk)
+ else:
+ self.log.debug(
+ "Collecting data for gamePk {} with StatsAPI v{}".format(
+ pk, statsapi.__version__
+ )
+ )
+
+ if len(gamePks) == 0:
+ self.log.debug("Using cached data for all gamePks.")
+ return False
+
+ self.log.debug("Getting schedule data for gamePks: {}".format(gamePks))
+ s = self.get_schedule_data(
+ ",".join(str(i) for i in gamePks), self.today["Y-m-d"]
+ )
+ for pk in gamePks:
+ self.log.debug("Collecting data for pk: {}".format(pk))
+ pkData = {} # temp dict to hold the data until it's complete
+
+ # Schedule data includes status, highlights, weather, broadcasts, probable pitchers, officials, and team info (incl. score)
+ games = s["dates"][
+ next(
+ (
+ i
+ for i, x in enumerate(s["dates"])
+ if x["date"] == self.today["Y-m-d"]
+ ),
+ 0,
+ )
+ ]["games"]
+ game = games[
+ next((i for i, x in enumerate(games) if x["gamePk"] == pk), 0)
+ ]
+ pkData.update({"schedule": game})
+ self.log.debug("Appended schedule for pk {}".format(pk))
+
+ if game["doubleHeader"] == "Y" and game["gameNumber"] == 2:
+ # Find DH game 1
+ otherGame = next(
+ (
+ {
+ "gamePk": v["schedule"]["gamePk"],
+ "gameDate": v["schedule"]["gameDate"],
+ }
+ for k, v in self.commonData.items()
+ if k not in [0, "weekly", "off", "gameday"]
+ and v.get("schedule", {}).get("gamePk")
+ != game["gamePk"]
+ and v.get("schedule", {}).get("doubleHeader") == "Y"
+ and v.get("schedule", {}).get("gameNumber") == 1
+ and self.myTeam["id"]
+ in [
+ v.get("teams", {})
+ .get("home", {})
+ .get("team", {})
+ .get("id"),
+ v.get("teams", {})
+ .get("away", {})
+ .get("team", {})
+ .get("id"),
+ ]
+ ),
+ None,
+ )
+ self.log.debug(
+ f"Result of check for DH game 1 in commonData: {otherGame}"
+ )
+
+ if not otherGame:
+ # Check league schedule
+ otherGame = next(
+ (
+ {"gamePk": v["gamePk"], "gameDate": v["gameDate"]}
+ for v in self.commonData.get(0, {}).get(
+ "leagueSchedule", []
+ )
+ if v.get("gamePk") != game["gamePk"]
+ and v.get("doubleHeader") == "Y"
+ and v.get("gameNumber") == 1
+ and self.myTeam["id"]
+ in [
+ v.get("teams", {})
+ .get("home", {})
+ .get("team", {})
+ .get("id"),
+ v.get("teams", {})
+ .get("away", {})
+ .get("team", {})
+ .get("id"),
+ ]
+ ),
+ None,
+ )
+ self.log.debug(
+ f"Result of check for DH game 1 in leagueSchedule: {otherGame}"
+ )
+
+ if not otherGame:
+ # Get schedule data from MLB
+ self.log.debug(
+ f"Getting schedule data for team id [{self.myTeam['id']}] and date [{self.today['Y-m-d']}]..."
+ )
+ sched = self.api_call(
+ "schedule",
+ {
+ "sportId": 1,
+ "date": self.today["Y-m-d"],
+ "teamId": self.myTeam["id"],
+ "fields": "dates,date,games,gamePk,gameDate,doubleHeader,gameNumber",
+ },
+ )
+ schedGames = sched["dates"][
+ next(
+ (
+ i
+ for i, x in enumerate(sched["dates"])
+ if x["date"] == self.today["Y-m-d"]
+ ),
+ 0,
+ )
+ ]["games"]
+ otherGame = next(
+ (
+ v
+ for v in schedGames
+ if v.get("gamePk") != game["gamePk"]
+ and v.get("doubleHeader") == "Y"
+ and v.get("gameNumber") == 1
+ ),
+ None,
+ )
+ self.log.debug(
+ f"Result of check for DH game 1 in MLB schedule data: {otherGame}"
+ )
+
+ if otherGame:
+ # Replace gameDate for straight doubleheader game 2 to reflect game 1 + 3 hours
+ self.log.debug(f"DH Game 1: {otherGame['gamePk']}")
+ game["gameDate"] = (
+ datetime.strptime(
+ otherGame["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc)
+ + timedelta(hours=3)
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
+ self.log.info(
+ f"Replaced game time for DH Game 2 [{game['gamePk']}] to 3 hours after Game 1 [{otherGame['gamePk']}] game time: [{game['gameDate']}] -- pkData['schedule']['gameDate']: [{pkData['schedule']['gameDate']}]"
+ )
+ else:
+ self.log.debug(
+ f"Failed to find DH game 1 for DH game 2 [{game['gamePk']}]"
+ )
+ # Store game time in myTeam's timezone as well as local (homeTeam's) timezone
+ pkData.update(
+ {
+ "gameTime": {
+ "myTeam": self.convert_timezone(
+ datetime.strptime(
+ pkData["schedule"]["gameDate"],
+ "%Y-%m-%dT%H:%M:%SZ",
+ ).replace(tzinfo=pytz.utc),
+ self.commonData[0]["myTeam"]["venue"]["timeZone"][
+ "id"
+ ],
+ ),
+ "homeTeam": self.convert_timezone(
+ datetime.strptime(
+ pkData["schedule"]["gameDate"],
+ "%Y-%m-%dT%H:%M:%SZ",
+ ).replace(tzinfo=pytz.utc),
+ pkData["schedule"]["venue"]["timeZone"]["id"],
+ ),
+ "bot": self.convert_timezone(
+ datetime.strptime(
+ pkData["schedule"]["gameDate"],
+ "%Y-%m-%dT%H:%M:%SZ",
+ ).replace(tzinfo=pytz.utc),
+ "local",
+ ),
+ "utc": datetime.strptime(
+ pkData["schedule"]["gameDate"], "%Y-%m-%dT%H:%M:%SZ"
+ ).replace(tzinfo=pytz.utc),
+ }
+ }
+ )
+ self.log.debug("Added gameTime for pk {}".format(pk))
+
+ # Store a key to indicate if myTeam is home or away
+ pkData.update(
+ {
+ "homeAway": "home"
+ if game["teams"]["home"]["team"]["id"] == self.myTeam["id"]
+ else "away"
+ }
+ )
+ self.log.debug("Added homeAway for pk {}".format(pk))
+
+ # Team info for opponent - same info as myTeam, but stored in pk dict because it's game-specific
+ pkData.update(
+ {
+ "oppTeam": self.get_team(
+ game["teams"]["away"]["team"]["id"]
+ if pkData["homeAway"] == "home"
+ else game["teams"]["home"]["team"]["id"]
+ )
+ }
+ )
+ self.log.debug("Added oppTeam for pk {}".format(pk))
+
+ # Update gumbo data
+ gumboParams = {
+ "gamePk": pk,
+ "hydrate": "credits,alignment,flags",
+ }
+ # Get updated list of timestamps
+ self.log.debug("Getting timestamps for pk {}".format(pk))
+ timestamps = self.api_call("game_timestamps", {"gamePk": pk})
+ if (
+ not self.commonData.get(pk, {}).get("gumbo")
+ or (
+ self.commonData[pk]["gumbo"]
+ .get("metaData", {})
+ .get("timeStamp", "")
+ == ""
+ or self.commonData[pk]["gumbo"]["metaData"]["timeStamp"]
+ not in timestamps
+ or len(timestamps)
+ - timestamps.index(
+ self.commonData[pk]["gumbo"]["metaData"]["timeStamp"]
+ )
+ > 3
+ )
+ or (
+ self.settings.get("Bot", {}).get(
+ "FULL_GUMBO_WHEN_FINAL", True
+ )
+ and (
+ self.commonData.get(pk, {})
+ .get("schedule", {})
+ .get("status", {})
+ .get("abstractGameCode")
+ == "F"
+ or self.commonData.get(pk, {})
+ .get("schedule", {})
+ .get("status", {})
+ .get("codedGameState")
+ in [
+ "C",
+ "D",
+ "U",
+ "T",
+ ]
+ )
+ )
+ ):
+ # Get full gumbo
+ self.log.debug("Getting full gumbo data for pk {}".format(pk))
+ gumbo = self.api_call("game", gumboParams)
+ else:
+ self.log.debug(
+ f"Latest timestamp from StatsAPI: {timestamps[-1]}; latest timestamp in gumbo cache: {self.commonData[pk]['gumbo'].get('metaData', {}).get('timeStamp')} for pk {pk}"
+ )
+
+ gumbo = self.commonData[pk].get("gumbo", {})
+ if len(timestamps) == 0 or timestamps[-1] == gumbo.get(
+ "metaData", {}
+ ).get("timeStamp"):
+ # We're up to date
+ self.log.debug(
+ "Gumbo data is up to date for pk {}".format(pk)
+ )
+ else:
+ # Get diff patch to bring us up to date
+ self.log.debug(
+ "Getting gumbo diff patch for pk {}".format(pk)
+ )
+ diffPatch = self.api_call(
+ "game_diff",
+ {
+ "gamePk": pk,
+ "startTimecode": gumbo["metaData"]["timeStamp"],
+ "endTimecode": timestamps[-1:],
+ },
+ force=True,
+ ) # use force=True due to MLB-StatsAPI bug #31
+ # Check if patch is actually the full gumbo data
+ if isinstance(diffPatch, dict) and diffPatch.get("gamePk"):
+ # Full gumbo data was returned
+ self.log.debug(
+ f"Full gumbo data was returned instead of a patch for pk {pk}. No need to patch!"
+ )
+ gumbo = diffPatch
+ else:
+ # Patch the dict
+ self.log.debug(
+ "Patching gumbo data for pk {}".format(pk)
+ )
+ if self.patch_dict(
+ self.commonData[pk]["gumbo"], diffPatch
+ ): # Patch in place
+ # True result —- patching was successful
+ gumbo = self.commonData[pk][
+ "gumbo"
+ ] # Carry forward
+ else:
+ # Get full gumbo
+ self.log.debug(
+ "Since patching encountered an error, getting full gumbo data for pk {}".format(
+ pk
+ )
+ )
+ gumbo = self.api_call("game", gumboParams)
+
+ # Include gumbo data
+ pkData.update({"timestamps": timestamps, "gumbo": gumbo})
+ self.log.debug("Added gumbo data for pk {}".format(pk))
+
+ # Formatted Boxscore Info
+ pkData.update({"boxscore": self.format_boxscore_data(gumbo)})
+ self.log.debug("Added boxscore for pk {}".format(pk))
+
+ # Update hitter stats vs. probable pitchers - only prior to game start if data already exists
+ if (
+ not pkData.get("awayBattersVsProb")
+ and not pkData.get("homeBattersVsProb")
+ ) or (
+ (
+ pkData["schedule"]["status"]["abstractGameCode"] != "L"
+ or pkData["schedule"]["status"]["statusCode"] == "PW"
+ )
+ and pkData["schedule"]["status"]["abstractGameCode"] != "F"
+ ):
+ self.log.debug(
+ "Adding batter vs probable pitchers for pk {}".format(pk)
+ )
+ pkData.update(
+ {
+ "awayBattersVsProb": self.get_batter_stats_vs_pitcher(
+ batters=pkData.get("gumbo", {})
+ .get("liveData", {})
+ .get("boxscore", {})
+ .get("teams", {})
+ .get("away", {})
+ .get("batters", []),
+ pitcher=pkData["schedule"]["teams"]["home"]
+ .get("probablePitcher", {})
+ .get("id", 0),
+ ),
+ "homeBattersVsProb": self.get_batter_stats_vs_pitcher(
+ batters=pkData.get("gumbo", {})
+ .get("liveData", {})
+ .get("boxscore", {})
+ .get("teams", {})
+ .get("home", {})
+ .get("batters", []),
+ pitcher=pkData["schedule"]["teams"]["away"]
+ .get("probablePitcher", {})
+ .get("id", 0),
+ ),
+ }
+ )
+
+ # Opponent last game recap/score/decisions/highlights if not against myTeam (only if doesn't already exist in data dict)
+
+ # Probable pitcher career stats vs. team - can't find the data for this
+ # pkData.update({'awayProbVsTeamStats':self.get_pitching_stats_vs_team()})
+ # pkData.update({'homeProbVsTeamStats':self.get_pitching_stats_vs_team()})
+
+ pkData.update({"lastUpdate": datetime.today()})
+ self.log.debug("Added lastUpdate for pk {}".format(pk))
+
+ # Make the data available
+ self.commonData.update({pk: pkData})
+ self.log.debug("Updated commonData with data for pk {}".format(pk))
+
+ if redball.DEV:
+ self.log.debug(
+ "Data available for threads: {}".format(self.commonData)
+ ) # debug
+
+ return True
+
+ def format_boxscore_data(self, gumbo):
+ """Adapted from MLB-StatsAPI module.
+ Given gumbo data, format lists of batters, pitchers, and other boxscore data
+ """
+
+ boxData = {}
+ """boxData holds the dict to be returned"""
+
+ # Add away column headers
+ awayBatters = [
+ {
+ "namefield": gumbo["gameData"]["teams"]["away"]["teamName"]
+ + " Batters",
+ "ab": "AB",
+ "r": "R",
+ "h": "H",
+ "rbi": "RBI",
+ "bb": "BB",
+ "k": "K",
+ "lob": "LOB",
+ "avg": "AVG",
+ "ops": "OPS",
+ "personId": 0,
+ "substitution": False,
+ "note": "",
+ "name": gumbo["gameData"]["teams"]["away"]["teamName"] + " Batters",
+ "position": "",
+ "obp": "OBP",
+ "slg": "SLG",
+ "battingOrder": "",
+ }
+ ]
+ for batterId_int in [
+ x
+ for x in gumbo["liveData"]["boxscore"]["teams"]["away"]["batters"]
+ if gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + str(x)
+ ].get("battingOrder")
+ ]:
+ batterId = str(batterId_int)
+ namefield = (
+ str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["battingOrder"]
+ )[0]
+ if str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["battingOrder"]
+ )[-1]
+ == "0"
+ else " "
+ )
+ namefield += " " + gumbo["liveData"]["boxscore"]["teams"]["away"][
+ "players"
+ ]["ID" + batterId]["stats"]["batting"].get("note", "")
+ namefield += (
+ gumbo["gameData"]["players"]["ID" + batterId]["boxscoreName"]
+ + " "
+ + gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["position"]["abbreviation"]
+ )
+ batter = {
+ "namefield": namefield,
+ "ab": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["atBats"]
+ ),
+ "r": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["runs"]
+ ),
+ "h": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["hits"]
+ ),
+ "rbi": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["rbi"]
+ ),
+ "bb": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["baseOnBalls"]
+ ),
+ "k": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["strikeOuts"]
+ ),
+ "lob": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["leftOnBase"]
+ ),
+ "avg": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["seasonStats"]["batting"]["avg"]
+ ),
+ "ops": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["seasonStats"]["batting"]["ops"]
+ ),
+ "personId": batterId_int,
+ "battingOrder": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["battingOrder"]
+ ),
+ "substitution": False
+ if str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["battingOrder"]
+ )[-1]
+ == "0"
+ else True,
+ "note": gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"].get("note", ""),
+ "name": gumbo["gameData"]["players"]["ID" + batterId]["boxscoreName"],
+ "position": gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["position"]["abbreviation"],
+ "obp": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["seasonStats"]["batting"]["obp"]
+ ),
+ "slg": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + batterId
+ ]["seasonStats"]["batting"]["slg"]
+ ),
+ }
+ awayBatters.append(batter)
+
+ # Add home column headers
+ homeBatters = [
+ {
+ "namefield": gumbo["gameData"]["teams"]["home"]["teamName"]
+ + " Batters",
+ "ab": "AB",
+ "r": "R",
+ "h": "H",
+ "rbi": "RBI",
+ "bb": "BB",
+ "k": "K",
+ "lob": "LOB",
+ "avg": "AVG",
+ "ops": "OPS",
+ "personId": 0,
+ "substitution": False,
+ "note": "",
+ "name": gumbo["gameData"]["teams"]["home"]["teamName"] + " Batters",
+ "position": "",
+ "obp": "OBP",
+ "slg": "SLG",
+ "battingOrder": "",
+ }
+ ]
+ for batterId_int in [
+ x
+ for x in gumbo["liveData"]["boxscore"]["teams"]["home"]["batters"]
+ if gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + str(x)
+ ].get("battingOrder")
+ ]:
+ batterId = str(batterId_int)
+ namefield = (
+ str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["battingOrder"]
+ )[0]
+ if str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["battingOrder"]
+ )[-1]
+ == "0"
+ else " "
+ )
+ namefield += " " + gumbo["liveData"]["boxscore"]["teams"]["home"][
+ "players"
+ ]["ID" + batterId]["stats"]["batting"].get("note", "")
+ namefield += (
+ gumbo["gameData"]["players"]["ID" + batterId]["boxscoreName"]
+ + " "
+ + gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["position"]["abbreviation"]
+ )
+ batter = {
+ "namefield": namefield,
+ "ab": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["atBats"]
+ ),
+ "r": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["runs"]
+ ),
+ "h": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["hits"]
+ ),
+ "rbi": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["rbi"]
+ ),
+ "bb": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["baseOnBalls"]
+ ),
+ "k": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["strikeOuts"]
+ ),
+ "lob": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"]["leftOnBase"]
+ ),
+ "avg": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["seasonStats"]["batting"]["avg"]
+ ),
+ "ops": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["seasonStats"]["batting"]["ops"]
+ ),
+ "personId": batterId_int,
+ "battingOrder": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["battingOrder"]
+ ),
+ "substitution": False
+ if str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["battingOrder"]
+ )[-1]
+ == "0"
+ else True,
+ "note": gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["stats"]["batting"].get("note", ""),
+ "name": gumbo["gameData"]["players"]["ID" + batterId]["boxscoreName"],
+ "position": gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["position"]["abbreviation"],
+ "obp": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["seasonStats"]["batting"]["obp"]
+ ),
+ "slg": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + batterId
+ ]["seasonStats"]["batting"]["slg"]
+ ),
+ }
+ homeBatters.append(batter)
+
+ boxData.update({"awayBatters": awayBatters})
+ boxData.update({"homeBatters": homeBatters})
+
+ # Add away team totals
+ boxData.update(
+ {
+ "awayBattingTotals": {
+ "namefield": "Totals",
+ "ab": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "batting"
+ ]["atBats"]
+ ),
+ "r": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "batting"
+ ]["runs"]
+ ),
+ "h": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "batting"
+ ]["hits"]
+ ),
+ "rbi": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "batting"
+ ]["rbi"]
+ ),
+ "bb": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "batting"
+ ]["baseOnBalls"]
+ ),
+ "k": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "batting"
+ ]["strikeOuts"]
+ ),
+ "lob": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "batting"
+ ]["leftOnBase"]
+ ),
+ "avg": "",
+ "ops": "",
+ "obp": "",
+ "slg": "",
+ "name": "Totals",
+ "position": "",
+ "note": "",
+ "substitution": False,
+ "battingOrder": "",
+ "personId": 0,
+ }
+ }
+ )
+ # Add home team totals
+ boxData.update(
+ {
+ "homeBattingTotals": {
+ "namefield": "Totals",
+ "ab": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "batting"
+ ]["atBats"]
+ ),
+ "r": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "batting"
+ ]["runs"]
+ ),
+ "h": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "batting"
+ ]["hits"]
+ ),
+ "rbi": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "batting"
+ ]["rbi"]
+ ),
+ "bb": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "batting"
+ ]["baseOnBalls"]
+ ),
+ "k": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "batting"
+ ]["strikeOuts"]
+ ),
+ "lob": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "batting"
+ ]["leftOnBase"]
+ ),
+ "avg": "",
+ "ops": "",
+ "obp": "",
+ "slg": "",
+ "name": "Totals",
+ "position": "",
+ "note": "",
+ "substitution": False,
+ "battingOrder": "",
+ "personId": 0,
+ }
+ }
+ )
+
+ # Get batting notes
+ awayBattingNotes = {}
+ for n in gumbo["liveData"]["boxscore"]["teams"]["away"]["note"]:
+ awayBattingNotes.update(
+ {len(awayBattingNotes): n["label"] + "-" + n["value"]}
+ )
+
+ homeBattingNotes = {}
+ for n in gumbo["liveData"]["boxscore"]["teams"]["home"]["note"]:
+ homeBattingNotes.update(
+ {len(homeBattingNotes): n["label"] + "-" + n["value"]}
+ )
+
+ boxData.update({"awayBattingNotes": awayBattingNotes})
+ boxData.update({"homeBattingNotes": homeBattingNotes})
+
+ # Get pitching box
+ # Add away column headers
+ awayPitchers = [
+ {
+ "namefield": gumbo["gameData"]["teams"]["away"]["teamName"]
+ + " Pitchers",
+ "ip": "IP",
+ "h": "H",
+ "r": "R",
+ "er": "ER",
+ "bb": "BB",
+ "k": "K",
+ "hr": "HR",
+ "era": "ERA",
+ "p": "P",
+ "s": "S",
+ "name": gumbo["gameData"]["teams"]["away"]["teamName"] + " Pitchers",
+ "personId": 0,
+ "note": "",
+ }
+ ]
+ for pitcherId_int in gumbo["liveData"]["boxscore"]["teams"]["away"]["pitchers"]:
+ if pitcherId_int == 0:
+ self.log.warning("Invalid pitcher id found: 0")
+ continue
+
+ pitcherId = str(pitcherId_int)
+ namefield = gumbo["gameData"]["players"]["ID" + pitcherId]["boxscoreName"]
+ namefield += (
+ " "
+ + gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"].get("note", "")
+ if gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"].get("note")
+ else ""
+ )
+ pitcher = {
+ "namefield": namefield,
+ "ip": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["inningsPitched"]
+ ),
+ "h": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["hits"]
+ ),
+ "r": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["runs"]
+ ),
+ "er": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["earnedRuns"]
+ ),
+ "bb": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["baseOnBalls"]
+ ),
+ "k": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["strikeOuts"]
+ ),
+ "hr": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["homeRuns"]
+ ),
+ "p": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"].get(
+ "pitchesThrown",
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"].get("numberOfPitches", 0),
+ )
+ ),
+ "s": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["strikes"]
+ ),
+ "era": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["seasonStats"]["pitching"]["era"]
+ ),
+ "name": gumbo["gameData"]["players"]["ID" + pitcherId]["boxscoreName"],
+ "personId": pitcherId_int,
+ "note": gumbo["liveData"]["boxscore"]["teams"]["away"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"].get("note", ""),
+ }
+ awayPitchers.append(pitcher)
+
+ boxData.update({"awayPitchers": awayPitchers})
+
+ # Add home column headers
+ homePitchers = [
+ {
+ "namefield": gumbo["gameData"]["teams"]["home"]["teamName"]
+ + " Pitchers",
+ "ip": "IP",
+ "h": "H",
+ "r": "R",
+ "er": "ER",
+ "bb": "BB",
+ "k": "K",
+ "hr": "HR",
+ "era": "ERA",
+ "p": "P",
+ "s": "S",
+ "name": gumbo["gameData"]["teams"]["home"]["teamName"] + " Pitchers",
+ "personId": 0,
+ "note": "",
+ }
+ ]
+ for pitcherId_int in gumbo["liveData"]["boxscore"]["teams"]["home"]["pitchers"]:
+ if pitcherId_int == 0:
+ self.log.warning("Invalid pitcher id found: 0")
+ continue
+
+ pitcherId = str(pitcherId_int)
+ namefield = gumbo["gameData"]["players"]["ID" + pitcherId]["boxscoreName"]
+ namefield += (
+ " "
+ + gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"].get("note", "")
+ if gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"].get("note")
+ else ""
+ )
+ pitcher = {
+ "namefield": namefield,
+ "ip": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["inningsPitched"]
+ ),
+ "h": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["hits"]
+ ),
+ "r": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["runs"]
+ ),
+ "er": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["earnedRuns"]
+ ),
+ "bb": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["baseOnBalls"]
+ ),
+ "k": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["strikeOuts"]
+ ),
+ "hr": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["homeRuns"]
+ ),
+ "p": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"].get(
+ "pitchesThrown",
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"].get("numberOfPitches", 0),
+ )
+ ),
+ "s": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"]["strikes"]
+ ),
+ "era": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["seasonStats"]["pitching"]["era"]
+ ),
+ "name": gumbo["gameData"]["players"]["ID" + pitcherId]["boxscoreName"],
+ "personId": pitcherId_int,
+ "note": gumbo["liveData"]["boxscore"]["teams"]["home"]["players"][
+ "ID" + pitcherId
+ ]["stats"]["pitching"].get("note", ""),
+ }
+ homePitchers.append(pitcher)
+
+ boxData.update({"homePitchers": homePitchers})
+
+ # Get away team totals
+ boxData.update(
+ {
+ "awayPitchingTotals": {
+ "namefield": "Totals",
+ "ip": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "pitching"
+ ]["inningsPitched"]
+ ),
+ "h": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "pitching"
+ ]["hits"]
+ ),
+ "r": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "pitching"
+ ]["runs"]
+ ),
+ "er": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "pitching"
+ ]["earnedRuns"]
+ ),
+ "bb": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "pitching"
+ ]["baseOnBalls"]
+ ),
+ "k": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "pitching"
+ ]["strikeOuts"]
+ ),
+ "hr": str(
+ gumbo["liveData"]["boxscore"]["teams"]["away"]["teamStats"][
+ "pitching"
+ ]["homeRuns"]
+ ),
+ "p": "",
+ "s": "",
+ "era": "",
+ "name": "Totals",
+ "personId": 0,
+ "note": "",
+ }
+ }
+ )
+
+ # Get home team totals
+ boxData.update(
+ {
+ "homePitchingTotals": {
+ "namefield": "Totals",
+ "ip": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "pitching"
+ ]["inningsPitched"]
+ ),
+ "h": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "pitching"
+ ]["hits"]
+ ),
+ "r": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "pitching"
+ ]["runs"]
+ ),
+ "er": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "pitching"
+ ]["earnedRuns"]
+ ),
+ "bb": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "pitching"
+ ]["baseOnBalls"]
+ ),
+ "k": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "pitching"
+ ]["strikeOuts"]
+ ),
+ "hr": str(
+ gumbo["liveData"]["boxscore"]["teams"]["home"]["teamStats"][
+ "pitching"
+ ]["homeRuns"]
+ ),
+ "p": "",
+ "s": "",
+ "era": "",
+ "name": "Totals",
+ "personId": 0,
+ "note": "",
+ }
+ }
+ )
+
+ # Get game info
+ boxData.update({"gameBoxInfo": gumbo["liveData"]["boxscore"].get("info", [])})
+
+ return boxData
+
+ def get_batter_stats_vs_pitcher(self, batters, pitcher):
+ # batters = list of personIds, pitcher = personId
+ if batters == [] or pitcher == 0:
+ return []
+ params = {
+ "personIds": ",".join([str(x) for x in batters]),
+ "hydrate": "stats(group=[hitting],type=[vsPlayer],opposingPlayerId={},sportId=1)".format(
+ pitcher
+ ),
+ }
+ r = self.api_call("people", params)
+
+ return r["people"]
+
+ def get_pitching_stats_vs_team(self, personId, teamId):
+ # Not working yet--can't find endpoint to return career pitching stats vs. team
+ params = {
+ "personIds": personId,
+ "hydrate": "stats(group=[pitching],type=[vsTeamTotal],opposingTeamId={},sportId=1)".format(
+ teamId
+ ),
+ }
+ vsTeamStats = self.api_call("people", params)
+ return vsTeamStats["people"][0]["stats"][0]["splits"][0]["stat"]
+
+ def get_schedule_data(
+ self,
+ pks=None,
+ d=None,
+ sd=None,
+ ed=None,
+ t=None,
+ h="team(division,league),game(content(editorial(preview),decisions,gamenotes,highlights(highlights))),linescore,scoringplays,probablePitcher(note),broadcasts(all),venue(timezone),weather,officials,flags",
+ ):
+ # pks = single gamePk or comma-separated list (string), d = date ('%Y-%m-%d'), sd = start date, ed = end date,
+ # t = teamId, h = hydration(s)
+ # "hydrations" : [ "team", "tickets", "game(content)", "game(content(all))", "game(content(media(all)))", "game(content(editorial(all)))", "game(content(highlights(all)))", "game(content(editorial(preview)))", "game(content(editorial(recap)))", "game(content(editorial(articles)))", "game(content(editorial(wrap)))", "game(content(media(epg)))", "game(content(media(milestones)))", "game(content(highlights(scoreboard)))", "game(content(highlights(scoreboardPreview)))", "game(content(highlights(highlights)))", "game(content(highlights(gamecenter)))", "game(content(highlights(milestone)))", "game(content(highlights(live)))", "game(content(media(featured)))", "game(content(summary))", "game(content(gamenotes))", "game(tickets)", "game(atBatTickets)", "game(promotions)", "game(atBatPromotions)", "game(sponsorships)", "linescore", "decisions", "scoringplays", "broadcasts", "broadcasts(all)", "radioBroadcasts", "metadata", "game(seriesSummary)", "seriesStatus", "event(performers)", "event(promotions)", "event(timezone)", "event(tickets)", "event(venue)", "event(designations)", "event(game)", "event(status)", "venue", "weather", "gameInfo", "officials", "probableOfficials" ]
+ # team(division,league),game(content(editorial(preview),gamenotes,highlights(highlights))),linescore,decisions,probablePitcher(note),broadcasts(all),venue(timezone),weather,officials,flags
+ params = {"sportId": 1}
+ if pks:
+ params.update({"gamePks": pks})
+ if d:
+ params.update({"date": d})
+ elif sd and ed:
+ params.update({"startDate": sd, "endDate": ed})
+ if t:
+ params.update({"teamId": t})
+ if h:
+ params.update({"hydrate": h})
+ s = self.api_call("schedule", params)
+ return s
+
+ def get_team(self, t, h="league,division,venue(timezone)", s=None):
+ # t = teamId, h = hydrate, s = season
+ params = {"teamId": t, "hydrate": h}
+ if s:
+ params.update({"season": s})
+
+ return self.api_call("team", params)["teams"][0]
+
+ def log_last_updated_date_in_db(self, threadId, t=None):
+ # threadId = Reddit thread id that was edited, t = timestamp of edit
+ q = "update {}threads set dateUpdated=? where id=?;".format(self.dbTablePrefix)
+ if not t:
+ t = time.time()
+ localArgs = (t, threadId)
+ i = rbdb.db_qry((q, localArgs), commit=True, closeAfter=True, logg=self.log)
+ if isinstance(i, str):
+ self.log.error("Error updating thread edit date in database: {}".format(i))
+ return False
+ else:
+ return True
+
+ def insert_thread_to_db(self, pk, threadId, threadType):
+ # pk = gamePk (or list of gamePks), threadId = thread object returned from Reddit (OFF+date for off day threads), threadType = ['gameday', 'game', 'post', 'off', 'weekly']
+ q = "insert or ignore into {}threads (gamePk, type, gameDate, id, dateCreated, dateUpdated) values".format(
+ self.dbTablePrefix
+ )
+ if isinstance(pk, list):
+ for k in pk:
+ if q[:-1] == ")":
+ q += ","
+ q += " ({}, '{}', '{}', '{}', {}, {})".format(
+ k,
+ threadType,
+ self.today["Y-m-d"],
+ threadId,
+ time.time(),
+ time.time(),
+ )
+ else:
+ q += " ({}, '{}', '{}', '{}', {}, {})".format(
+ pk, threadType, self.today["Y-m-d"], threadId, time.time(), time.time()
+ )
+
+ q += ";"
+ i = rbdb.db_qry(q, commit=True, closeAfter=True, logg=self.log)
+ if isinstance(i, str):
+ self.log.error("Error inserting thread into database: {}".format(i))
+ return False
+ else:
+ return True
+
+ def count_check_edit(self, threadId, status, edit=False):
+ # threadId = reddit thread id, status = game status (statusCode, code for detailed state)
+ sq = "select checks,edits from {}thread_edits where threadId='{}' and status='{}';".format(
+ self.dbTablePrefix, threadId, status
+ )
+ s = rbdb.db_qry(sq, closeAfter=True, logg=self.log)
+ if isinstance(s, str):
+ # Error querying for existing row
+ self.log.error(
+ "Error querying for existing row in {}thread_edits table for threadId {} and status {}: {}".format(
+ self.dbTablePrefix, threadId, status, s
+ )
+ )
+ return False
+ elif s != []:
+ # Row already exists; increment edit count
+ q = "update {}thread_edits set checks=checks+1,{} dateUpdated='{}' where threadId='{}' and status='{}';".format(
+ self.dbTablePrefix,
+ " edits=edits+1," if edit else "",
+ time.time(),
+ threadId,
+ status,
+ )
+ else:
+ # Row does not exist; insert it
+ q = "insert into {}thread_edits (threadId, status, checks, edits, dateCreated, dateUpdated) values ('{}', '{}', {}, {}, '{}', '{}');".format(
+ self.dbTablePrefix,
+ threadId,
+ status,
+ 1,
+ 1 if edit else 0,
+ time.time(),
+ time.time(),
+ )
+
+ r = rbdb.db_qry(q, commit=True, closeAfter=True, logg=self.log)
+ if isinstance(r, str):
+ # Error inserting/updating row
+ self.log.error(
+ "Error inserting/updating row in {}thread_edits table for threadId {} and status {}: {}".format(
+ self.dbTablePrefix, threadId, status, r
+ )
+ )
+ return False
+
+ return True
+
+ def prep_and_post(self, thread, pk=None, postFooter=None):
+ # thread = ['weekly', 'off', 'gameday', 'game', 'post']
+ # pk = gamePk or list of gamePks
+ # postFooter = text to append to post body, but not to include in return text value
+ # (normally contains a timestamp that would prevent comparison next time to check for changes)
+
+ # Collect data for the game(s), or skip if no pk provided (generic data collected at start of daily loop)
+ if pk:
+ self.collect_data(pk)
+
+ try:
+ title = self.render_template(
+ thread=thread,
+ templateType="title",
+ data=self.commonData,
+ gamePk=pk,
+ settings=self.settings,
+ )
+ self.log.debug("Rendered {} title: {}".format(thread, title))
+ except Exception as e:
+ self.log.error("Error rendering {} title: {}".format(thread, e))
+ title = None
+ self.error_notification(f"Error rendering title for {thread} thread")
+
+ sticky = self.settings.get("Reddit", {}).get("STICKY", False) is True
+ title_mod = (
+ self.settings.get("Weekly Thread", {}).get("TITLE_MOD", "")
+ if thread == "weekly"
+ else self.settings.get("Off Day Thread", {}).get("TITLE_MOD", "")
+ if thread == "off"
+ else self.settings.get("Game Day Thread", {}).get("TITLE_MOD", "")
+ if thread == "gameday"
+ else self.settings.get("Game Thread", {}).get("TITLE_MOD", "")
+ if thread == "game"
+ else self.settings.get("Post Game Thread", {}).get("TITLE_MOD", "")
+ if thread == "post"
+ else ""
+ )
+ if "upper" in title_mod.lower():
+ title = title.upper()
+ self.log.info(
+ f"Converted {thread} title to upper case per TITLE_MOD setting: [{title}]"
+ )
+ if "lower" in title_mod.lower():
+ title = title.lower()
+ self.log.info(
+ f"Converted {thread} title to lower case per TITLE_MOD setting: [{title}]"
+ )
+ if "title" in title_mod.lower():
+ title = title.title()
+ self.log.info(
+ f"Converted {thread} title to title case per TITLE_MOD setting: [{title}]"
+ )
+ if "nospaces" in title_mod.lower():
+ title = title.replace(" ", "")
+ self.log.info(
+ f"Removed spaces from {thread} title per TITLE_MOD setting: [{title}]"
+ )
+
+ # Check if post already exists
+ theThread = None
+ text = ""
+ try:
+ for p in self.lemmy.listPosts():
+ if (
+ p["creator"]["name"] == self.lemmy.username
+ and p["post"]["name"] == title
+ ):
+ # Found existing thread...
+ self.log.info("Found an existing {} thread...".format(thread))
+ theThread = p
+ if theThread["post"]["body"].find("\n\nLast Updated") != -1:
+ text = theThread["post"]["body"][
+ 0 : theThread["post"]["body"].find("\n\nLast Updated:")
+ ]
+ elif theThread["post"]["body"].find("\n\nPosted") != -1:
+ text = theThread["post"]["body"][
+ 0 : theThread["post"]["body"].find("\n\nPosted:")
+ ]
+ else:
+ text = theThread["post"]["body"]
+
+ if sticky:
+ self.sticky_thread(theThread)
+
+ break
+ except Exception as e:
+ self.log.error("Error checking community for existing posts: {}".format(e))
+ self.error_notification("Error checking community for existing posts")
+
+ if not theThread:
+ try:
+ text = self.render_template(
+ thread=thread,
+ templateType="thread",
+ data=self.commonData,
+ gamePk=pk,
+ settings=self.settings,
+ )
+ self.log.debug("Rendered {} text: {}".format(thread, text))
+ except Exception as e:
+ self.log.error("Error rendering {} text: {}".format(thread, e))
+ text = None
+ self.error_notification(
+ f"{thread.title()} thread not posted due to failure rendering title or text."
+ )
+
+ if not (title and text):
+ self.log.error(
+ "Thread not posted due to failure rendering title or text."
+ )
+ return (None, text)
+
+ fullText = text + (postFooter if isinstance(postFooter, str) else "")
+
+ # Submit thread
+ try:
+ theThread = self.submit_lemmy_post(
+ title=title,
+ text=fullText,
+ sticky=sticky,
+ )
+ self.log.info("Submitted {} thread: ({}).".format(thread, theThread))
+ except Exception as e:
+ self.log.error("Error submitting {} thread: {}".format(thread, e))
+ theThread = None
+ self.error_notification(f"Error submitting {thread} thread")
+
+ if theThread:
+ if isinstance(pk, list):
+ self.log.debug(
+ "List of gamePks to associate in DB with {} thread: {}...".format(
+ thread, pk
+ )
+ )
+ for x in pk:
+ self.log.debug(
+ "Inserting {} thread into DB for game {}...".format(thread, x)
+ )
+ self.insert_thread_to_db(x, theThread["post"]["id"], thread)
+ elif pk:
+ self.log.debug(
+ "Inserting {} thread into DB for game {}...".format(thread, pk)
+ )
+ self.insert_thread_to_db(pk, theThread["post"]["id"], thread)
+ else:
+ self.log.debug(
+ "Inserting {} thread into db as {}...".format(
+ thread, self.today["Ymd"]
+ )
+ )
+ self.insert_thread_to_db(
+ int(self.today["Ymd"]), theThread["post"]["id"], thread
+ )
+
+ # Check for Prowl notification
+ prowlKey = self.settings.get("Prowl", {}).get("THREAD_POSTED_API_KEY", "")
+ prowlPriority = self.settings.get("Prowl", {}).get(
+ "THREAD_POSTED_PRIORITY", ""
+ )
+ if prowlKey == "" or prowlPriority == "":
+ self.log.debug("Prowl notifications are disabled or not configured.")
+ else:
+ self.notify_prowl(
+ apiKey=prowlKey,
+ event=f"{self.myTeam['teamName']} {thread.title()} Thread Posted",
+ description=f"""{self.myTeam['teamName']} {thread} thread was posted to c/{self.settings["Lemmy"]["COMMUNITY_NAME"]} at {self.convert_timezone(datetime.utcfromtimestamp(theThread.created_utc),'local').strftime('%I:%M %p %Z')}\nThread title: {theThread.title}\nURL: {theThread.shortlink}""",
+ priority=prowlPriority,
+ url=theThread.shortlink,
+ appName=f"redball - {self.bot.name}",
+ )
+ else:
+ self.log.warning("No thread object present. Something went wrong!")
+
+ return (theThread, text)
+
+ def notify_prowl(
+ self, apiKey, event, description, priority=0, url=None, appName="redball"
+ ):
+ # Send a notification to Prowl
+ p = pyprowl.Prowl(apiKey=apiKey, appName=appName)
+
+ self.log.debug(
+ f"Sending notification to Prowl with API Key: {apiKey}. Event: {event}, Description: {description}, Priority: {priority}, URL: {url}..."
+ )
+ try:
+ p.notify(
+ event=event,
+ description=description,
+ priority=priority,
+ url=url,
+ )
+ self.log.info("Notification successfully sent to Prowl!")
+ return True
+ except Exception as e:
+ self.log.error("Error sending notification to Prowl: {}".format(e))
+ return False
+
+ def error_notification(self, action):
+ # Generate and send notification to Prowl for errors
+ prowlKey = self.settings.get("Prowl", {}).get("ERROR_API_KEY", "")
+ prowlPriority = self.settings.get("Prowl", {}).get("ERROR_PRIORITY", "")
+ newline = "\n"
+ if prowlKey != "" and prowlPriority != "":
+ self.notify_prowl(
+ apiKey=prowlKey,
+ event=f"{self.bot.name} - {action}!",
+ description=f"{action} for bot: [{self.bot.name}]!\n\n{newline.join(traceback.format_exception(*sys.exc_info()))}",
+ priority=prowlPriority,
+ appName=f"redball - {self.bot.name}",
+ )
+
+ def submit_lemmy_post(
+ self,
+ title,
+ text,
+ sub=None,
+ sticky=False,
+ ):
+ if sub:
+ community = self.lemmy.getCommunity(sub)
+ else:
+ community = self.lemmy.community
+
+ title = title.strip("\n")
+ text = self._truncate_post(text)
+ post = self.lemmy.submitPost(
+ title=title,
+ body=text,
+ )
+ self.log.info("Thread ({}) submitted: {}".format(title, post))
+
+ if sticky:
+ self.lemmy.stickyPost(post["post"]["id"])
+
+ return post
+
+ def render_template(self, thread, templateType, **kwargs):
+ setting = "{}_TEMPLATE".format(templateType.upper())
+ template = (
+ self.settings.get("Weekly Thread", {}).get(setting, "")
+ if thread == "weekly"
+ else self.settings.get("Off Day Thread", {}).get(setting, "")
+ if thread == "off"
+ else self.settings.get("Game Day Thread", {}).get(setting, "")
+ if thread == "gameday"
+ else self.settings.get("Game Thread", {}).get(setting, "")
+ if thread == "game"
+ else self.settings.get("Post Game Thread", {}).get(setting, "")
+ if thread == "post"
+ else self.settings.get("Comments", {}).get(setting, "")
+ if thread == "comment"
+ else ""
+ )
+ try:
+ template = self.LOOKUP.get_template(template)
+ return template.render(**kwargs)
+ except Exception:
+ self.log.error(
+ "Error rendering template [{}] for {} {}. Falling back to default template. Error: {}".format(
+ template.filename,
+ thread,
+ templateType,
+ mako.exceptions.text_error_template().render(),
+ )
+ )
+ self.error_notification(
+ f"Error rendering {thread} {templateType} template [{template.filename}]"
+ )
+ try:
+ template = self.LOOKUP.get_template(
+ "{}_{}.mako".format(thread, templateType)
+ )
+ return template.render(**kwargs)
+ except Exception:
+ self.log.error(
+ "Error rendering default template for thread [{}] and type [{}]: {}".format(
+ thread,
+ templateType,
+ mako.exceptions.text_error_template().render(),
+ )
+ )
+ self.error_notification(
+ f"Error rendering default {thread} {templateType} template [{template.filename}]"
+ )
+
+ return ""
+
+ def sticky_thread(self, thread):
+ self.log.info("Stickying thread [{}]...".format(thread["post"]["id"]))
+ try:
+ self.lemmy.stickyPost(thread["post"]["id"])
+ self.log.info("Thread [{}] stickied...".format(thread["post"]["id"]))
+ except Exception:
+ self.log.warning(
+ "Sticky of thread [{}] failed. Check mod privileges or the thread may have already been sticky.".format(
+ thread["post"]["id"]
+ )
+ )
+
+ def unsticky_threads(self, threads):
+ for t in threads:
+ try:
+ self.log.debug(
+ "Attempting to unsticky thread [{}]".format(t["post"]["id"])
+ )
+ self.lemmy.unStickyPost(t["post"]["id"])
+ except Exception:
+ self.log.debug(
+ "Unsticky of thread [{}] failed. Check mod privileges or the thread may not have been sticky.".format(
+ t.id
+ )
+ )
+
+ def api_call(self, endpoint, params, retries=-1, force=False):
+ s = {}
+ while retries != 0:
+ try:
+ s = statsapi.get(endpoint, params, force=force)
+ break
+ except Exception as e:
+ if retries == 0:
+ self.log.error(
+ "Error encountered while querying StatsAPI. Continuing. Error: {}".format(
+ e
+ )
+ )
+ elif retries > 0:
+ retries -= 1
+ self.log.error(
+ "Error encountered while querying StatsAPI. Retrying in 30 seconds ({} additional retries remaining). Error: {}".format(
+ retries, e
+ )
+ )
+ self.sleep(30)
+ else:
+ self.log.error(
+ "Error encountered while querying StatsAPI. Retrying in 30 seconds. Error: {}".format(
+ e
+ )
+ )
+ self.sleep(30)
+
+ return s
+
+ def build_tables(self):
+ queries = []
+ queries.append(
+ """CREATE TABLE IF NOT EXISTS {}threads (
+ gamePk integer not null,
+ type text not null,
+ gameDate text not null,
+ id text not null,
+ dateCreated text not null,
+ dateUpdated text not null,
+ deleted integer default 0,
+ unique (gamePk, type, id, gameDate, deleted)
+ );""".format(
+ self.dbTablePrefix
+ )
+ )
+
+ queries.append(
+ """CREATE TABLE IF NOT EXISTS {}thread_edits (
+ threadId text not null,
+ status text not null,
+ checks integer default 0,
+ edits integer default 0,
+ dateCreated text not null,
+ dateUpdated text not null,
+ unique (threadId, status)
+ );""".format(
+ self.dbTablePrefix
+ )
+ )
+
+ queries.append(
+ """CREATE TABLE IF NOT EXISTS {}processedAtBats (
+ id integer primary key autoincrement,
+ gamePk integer not null,
+ gameThreadId text not null,
+ processedAtBats text not null,
+ dateCreated text not null,
+ dateUpdated text not null
+ );""".format(
+ self.dbTablePrefix
+ )
+ )
+
+ queries.append(
+ """CREATE TABLE IF NOT EXISTS {}comments (
+ id integer primary key autoincrement,
+ gamePk integer not null,
+ gameThreadId text not null,
+ atBatIndex integer not null,
+ actionIndex integer,
+ isScoringPlay integer,
+ eventType text,
+ myTeamBatting integer,
+ commentId text not null,
+ dateCreated text not null,
+ dateUpdated text not null,
+ deleted integer default 0
+ );""".format(
+ self.dbTablePrefix
+ )
+ )
+
+ self.log.debug(
+ "Executing queries to build {} tables: {}".format(len(queries), queries)
+ )
+ results = rbdb.db_qry(queries, commit=True, closeAfter=True, logg=self.log)
+ if None in results:
+ self.log.debug("One or more queries failed: {}".format(results))
+ else:
+ self.log.debug("Building of tables complete. Results: {}".format(results))
+
+ return True
+
+ def refresh_settings(self):
+ self.prevSettings = self.settings
+ self.settings = self.bot.get_config()
+ if self.prevSettings["Logging"] != self.settings["Logging"]:
+ # reload logger
+ self.log = logger.init_logger(
+ logger_name="redball.bots." + threading.current_thread().name,
+ log_to_console=self.settings.get("Logging", {}).get(
+ "LOG_TO_CONSOLE", True
+ ),
+ log_to_file=self.settings.get("Logging", {}).get("LOG_TO_FILE", True),
+ log_path=redball.LOG_PATH,
+ log_file="{}.log".format(threading.current_thread().name),
+ file_log_level=self.settings.get("Logging", {}).get("FILE_LOG_LEVEL"),
+ console_log_level=self.settings.get("Logging", {}).get(
+ "CONSOLE_LOG_LEVEL"
+ ),
+ clear_first=True,
+ propagate=False,
+ )
+ self.log.info("Restarted logger with new settings")
+
+ if (
+ self.prevSettings["Reddit Auth"] != self.settings["Reddit Auth"]
+ or self.prevSettings["Reddit"] != self.settings["Reddit"]
+ ):
+ self.log.info(
+ "Detected new Reddit Authorization info. Re-initializing Reddit API..."
+ )
+ self.init_lemmy()
+
+ self.log.debug("Refreshed settings: {}".format(self.settings))
+
+ def init_lemmy(self):
+ self.log.debug(f"Initiating Lemmy API with plaw")
+ with redball.REDDIT_AUTH_LOCKS[str(self.bot.redditAuth)]:
+ try:
+ # Check for Lemmy
+ instance_name = self.settings.get("Lemmy", {}).get("INSTANCE_NAME", "")
+ username = self.settings.get("Lemmy", {}).get("USERNAME", "")
+ password = self.settings.get("Lemmy", {}).get("PASSWORD", "")
+ community = self.settings.get("Lemmy", {}).get("COMMUNITY_NAME")
+
+ if "" in [instance_name, username, password, community]:
+ self.log.warn("Lemmy not fully configured")
+
+ self.lemmy = plaw.Lemmy(instance_name, username, password)
+ self.community = self.lemmy.getCommunity(community)
+ except Exception as e:
+ self.log.error(
+ "Error encountered attempting to initialize Lemmy: {}".format(e)
+ )
+ self.error_notification("Error initializing Lemmy")
+ raise
+
+ def eod_loop(self, today):
+ # today = date that's already been finished ('%Y-%m-%d')
+ # return when datetime.today().strftime('%Y-%m-%d') is not equal to today param (it's the next day)
+ while redball.SIGNAL is None and not self.bot.STOP:
+ # End of day loop
+ if redball.SIGNAL is not None or self.bot.STOP:
+ break
+ elif today != datetime.today().strftime("%Y-%m-%d"):
+ self.log.info("It's a brand new day!")
+ break
+ else:
+ i = 0
+ while True:
+ if redball.SIGNAL is None and not self.bot.STOP:
+ i += 1
+ if today != datetime.today().strftime("%Y-%m-%d"):
+ break
+ elif i == 600:
+ self.log.info("Still waiting for the next day...")
+ break
+ time.sleep(1)
+ else:
+ break
+
+ def sleep(self, t):
+ # t = total number of seconds to sleep before returning
+ i = 0
+ while redball.SIGNAL is None and not self.bot.STOP and i < t:
+ i += 1
+ time.sleep(1)
+
+ def convert_timezone(self, dt, convert_to="America/New_York"):
+ # dt = datetime object to convert, convert_to = timezone to convert to (e.g. 'America/New_York', or 'local' for local bot timezone)
+ if not dt.tzinfo:
+ dt = dt.replace(tzinfo=pytz.utc)
+
+ if convert_to == "local":
+ to_tz = tzlocal.get_localzone()
+ else:
+ to_tz = pytz.timezone(convert_to)
+
+ return dt.astimezone(to_tz)
+
+ teamSubs = {
+ 142: "/c/minnesotatwins@fanaticus.social",
+ 145: "/c/whitesox@fanaticus.social",
+ 116: "/c/motorcitykitties@fanaticus.social",
+ 118: "/c/kcroyals@fanaticus.social",
+ 114: "/c/clevelandguardians@fanaticus.social",
+ 140: "/c/texasrangers@fanaticus.social",
+ 117: "/c/astros@fanaticus.social",
+ 133: "/c/oaklandathletics@fanaticus.social",
+ 108: "/c/angelsbaseball@fanaticus.social",
+ 136: "/c/mariners@fanaticus.social",
+ 111: "/c/redsox@fanaticus.social",
+ 147: "/c/nyyankees@fanaticus.social",
+ 141: "/c/torontobluejays@fanaticus.social",
+ 139: "/c/tampabayrays@fanaticus.social",
+ 110: "/c/orioles@fanaticus.social",
+ 138: "/c/cardinals@fanaticus.social",
+ 113: "/c/reds@fanaticus.social",
+ 134: "/c/buccos@fanaticus.social",
+ 112: "/c/chicubs@fanaticus.social",
+ 158: "/c/brewers@fanaticus.social",
+ 137: "/c/sfgiants@fanaticus.social",
+ 109: "/c/azdiamondbacks@fanaticus.social",
+ 115: "/c/coloradorockies@fanaticus.social",
+ 119: "/c/dodgers@fanaticus.social",
+ 135: "/c/padres@fanaticus.social",
+ 143: "/c/phillies@fanaticus.social",
+ 121: "/c/newyorkmets@fanaticus.social",
+ 146: "/c/miamimarlins@fanaticus.social",
+ 120: "/c/nationals@fanaticus.social",
+ 144: "/c/braves@fanaticus.social",
+ 0: "/c/baseball@fanaticus.social",
+ "mlb": "/c/baseball@fanaticus.social",
+ }
+
+ def bot_state(self):
+ """Return current state...
+ Current date being monitored
+ Games being monitored with current status
+ Threads pending and post time
+ Threads posted
+ """
+ try:
+ botStatus = {
+ "lastUpdated": datetime.today().strftime("%m/%d/%Y %I:%M:%S %p"),
+ "myTeam": {
+ "id": self.myTeam["id"],
+ "name": self.myTeam["name"],
+ "shortName": self.myTeam["shortName"],
+ "teamName": self.myTeam["teamName"],
+ "link": self.myTeam["link"],
+ },
+ "today": self.today,
+ "seasonState": self.seasonState,
+ "weeklyThread": {
+ "enabled": self.settings.get("Weekly Thread", {}).get(
+ "ENABLED", True
+ )
+ and (
+ (
+ (
+ self.seasonState.startswith("off")
+ or self.seasonState == "post:out"
+ )
+ and self.settings.get("Weekly Thread", {}).get(
+ "OFFSEASON_ONLY", True
+ )
+ )
+ or not self.settings.get("Weekly Thread", {}).get(
+ "OFFSEASON_ONLY", True
+ )
+ ),
+ "postTime": self.weekly.get("postTime_local").strftime(
+ "%m/%d/%Y %I:%M:%S %p"
+ )
+ if isinstance(self.weekly.get("postTime_local"), datetime)
+ else "",
+ "posted": True if self.weekly.get("weeklyThread") else False,
+ "id": self.weekly.get("weeklyThread")["post"]["id"]
+ if self.weekly.get("weeklyThread")
+ else None,
+ "url": self.weekly.get("weeklyThread")["post"]["ap_id"]
+ if self.weekly.get("weeklyThread")
+ else None,
+ "title": self.weekly.get("weeklyThreadTitle")
+ if self.weekly.get("weeklyThread")
+ else None,
+ },
+ "offDayThread": {
+ "enabled": self.settings.get("Off Day Thread", {}).get(
+ "ENABLED", True
+ ),
+ "postTime": self.activeGames.get("off", {})
+ .get("postTime_local")
+ .strftime("%m/%d/%Y %I:%M:%S %p")
+ if isinstance(
+ self.activeGames.get("off", {}).get("postTime_local"), datetime
+ )
+ else "",
+ "posted": True
+ if self.activeGames.get("off", {}).get("offDayThread")
+ else False,
+ "id": self.activeGames.get("off", {}).get("offDayThread")["post"][
+ "id"
+ ]
+ if self.activeGames.get("off", {}).get("offDayThread")
+ else None,
+ "url": self.activeGames.get("off", {}).get("offDayThread")["post"][
+ "ap_id"
+ ]
+ if self.activeGames.get("off", {}).get("offDayThread")
+ else None,
+ "title": self.activeGames.get("off", {}).get("offDayThreadTitle")
+ if self.activeGames.get("off", {}).get("offDayThread")
+ else None,
+ },
+ "gameDayThread": {
+ "enabled": self.settings.get("Game Day Thread", {}).get(
+ "ENABLED", True
+ ),
+ "postTime": self.activeGames.get("gameday", {})
+ .get("postTime_local")
+ .strftime("%m/%d/%Y %I:%M:%S %p")
+ if isinstance(
+ self.activeGames.get("gameday", {}).get("postTime_local"),
+ datetime,
+ )
+ else "",
+ "posted": True
+ if self.activeGames.get("gameday", {}).get("gameDayThread")
+ else False,
+ "id": self.activeGames.get("gameday", {}).get("gameDayThread")[
+ "post"
+ ]["id"]
+ if self.activeGames.get("gameday", {}).get("gameDayThread")
+ else None,
+ "url": self.activeGames.get("gameday", {}).get("gameDayThread")[
+ "post"
+ ]["ap_id"]
+ if self.activeGames.get("gameday", {}).get("gameDayThread")
+ else None,
+ "title": self.activeGames.get("gameday", {}).get(
+ "gameDayThreadTitle"
+ )
+ if self.activeGames.get("gameday", {}).get("gameDayThread")
+ else None,
+ },
+ "games": [
+ {
+ k: {
+ "gamePk": k,
+ "status": self.commonData[k]["schedule"]["status"],
+ "oppTeam": self.commonData[k]["oppTeam"],
+ "homeAway": self.commonData[k]["homeAway"],
+ "threads": {
+ "game": {
+ "enabled": self.settings.get("Game Thread", {}).get(
+ "ENABLED", True
+ ),
+ "postTime": v.get("postTime_local").strftime(
+ "%m/%d/%Y %I:%M:%S %p"
+ )
+ if isinstance(v.get("postTime_local"), datetime)
+ else "",
+ "posted": True if v.get("gameThread") else False,
+ "id": v.get("gameThread")["post"]["id"]
+ if v.get("gameThread")
+ else None,
+ "url": v.get("gameThread")["post"]["ap_id"]
+ if v.get("gameThread")
+ else None,
+ "title": v.get("gameThreadTitle")
+ if v.get("gameThread")
+ else None,
+ },
+ "post": {
+ "enabled": self.settings.get(
+ "Post Game Thread", {}
+ ).get("ENABLED", True),
+ "posted": True
+ if v.get("postGameThread")
+ else False,
+ "id": v.get("postGameThread")["post"]["id"]
+ if v.get("postGameThread")
+ else None,
+ "url": v.get("postGameThread")["post"]["ap_id"]
+ if v.get("postGameThread")
+ else None,
+ "title": v.get("postGameThreadTitle")
+ if v.get("postGameThread")
+ else None,
+ },
+ },
+ }
+ }
+ for k, v in self.activeGames.items()
+ if isinstance(k, int) and k > 0
+ ],
+ }
+ botStatus["myTeam"].pop("nextGame", None) # Not used, junks up the log
+
+ botStatus.update(
+ {
+ "summary": {
+ "text": "Today is {}. Season state: {}.".format(
+ botStatus["today"]["Y-m-d"], botStatus["seasonState"]
+ ),
+ "html": "Today is {}. Season state: {}.".format(
+ botStatus["today"]["Y-m-d"], botStatus["seasonState"]
+ ),
+ "markdown": "Today is {}. Season state: {}.".format(
+ botStatus["today"]["Y-m-d"], botStatus["seasonState"]
+ ),
+ }
+ }
+ )
+
+ # Weekly thread
+ botStatus["summary"]["text"] += "\n\nWeekly thread{}".format(
+ " disabled."
+ if not botStatus["weeklyThread"]["enabled"]
+ else " suppressed except during off season."
+ if self.settings.get("Weekly Thread", {}).get("OFFSEASON_ONLY", True)
+ and not (
+ botStatus["seasonState"].startswith("off")
+ or botStatus["seasonState"] == "post:out"
+ )
+ else " failed to post (check log for error)"
+ if not botStatus["weeklyThread"]["posted"]
+ and datetime.strptime(
+ botStatus["weeklyThread"]["postTime"], "%m/%d/%Y %I:%M:%S %p"
+ )
+ < datetime.today()
+ else " post time: {}".format(botStatus["weeklyThread"]["postTime"])
+ if not botStatus["weeklyThread"]["posted"]
+ else ": {} ({} - {})".format(
+ botStatus["weeklyThread"]["title"],
+ botStatus["weeklyThread"]["id"],
+ botStatus["weeklyThread"]["url"],
+ )
+ )
+ botStatus["summary"][
+ "html"
+ ] += "
Weekly thread{}".format(
+ " disabled."
+ if not botStatus["weeklyThread"]["enabled"]
+ else " suppressed except during off season."
+ if self.settings.get("Weekly Thread", {}).get("OFFSEASON_ONLY", True)
+ and not (
+ botStatus["seasonState"].startswith("off")
+ or botStatus["seasonState"] == "post:out"
+ )
+ else " failed to post (check log for error)"
+ if not botStatus["weeklyThread"]["posted"]
+ and datetime.strptime(
+ botStatus["weeklyThread"]["postTime"], "%m/%d/%Y %I:%M:%S %p"
+ )
+ < datetime.today()
+ else " post time: {}".format(botStatus["weeklyThread"]["postTime"])
+ if not botStatus["weeklyThread"]["posted"]
+ else ': {} ({})'.format(
+ botStatus["weeklyThread"]["title"],
+ botStatus["weeklyThread"]["url"],
+ botStatus["weeklyThread"]["id"],
+ )
+ )
+ botStatus["summary"]["markdown"] += "\n\n**Weekly thread**{}".format(
+ " disabled."
+ if not botStatus["weeklyThread"]["enabled"]
+ else " suppressed except during off season."
+ if self.settings.get("Weekly Thread", {}).get("OFFSEASON_ONLY", True)
+ and not (
+ botStatus["seasonState"].startswith("off")
+ or botStatus["seasonState"] == "post:out"
+ )
+ else " failed to post (check log for error)"
+ if not botStatus["weeklyThread"]["posted"]
+ and datetime.strptime(
+ botStatus["weeklyThread"]["postTime"], "%m/%d/%Y %I:%M:%S %p"
+ )
+ < datetime.today()
+ else " post time: {}".format(botStatus["weeklyThread"]["postTime"])
+ if not botStatus["weeklyThread"]["posted"]
+ else ": {} ([{}]({}))".format(
+ botStatus["weeklyThread"]["title"],
+ botStatus["weeklyThread"]["id"],
+ botStatus["weeklyThread"]["url"],
+ )
+ )
+
+ if len(botStatus["games"]) == 0:
+ # Off Day
+ botStatus["summary"][
+ "text"
+ ] += "\n\nToday is an off day{}.\n\nOff day thread{}".format(
+ " (Season Suspended)"
+ if self.commonData.get("seasonSuspended")
+ else "",
+ " disabled."
+ if not botStatus["offDayThread"]["enabled"]
+ else " suppressed during off season."
+ if self.settings.get("Off Day Thread", {}).get(
+ "SUPPRESS_OFFSEASON", True
+ )
+ and (
+ botStatus["seasonState"].startswith("off")
+ or botStatus["seasonState"] == "post:out"
+ )
+ else " failed to post (check log for error)"
+ if not botStatus["offDayThread"]["posted"]
+ and datetime.strptime(
+ botStatus["offDayThread"]["postTime"], "%m/%d/%Y %I:%M:%S %p"
+ )
+ < datetime.today()
+ else " post time: {}".format(botStatus["offDayThread"]["postTime"])
+ if not botStatus["offDayThread"]["posted"]
+ else ": {} ({} - {})".format(
+ botStatus["offDayThread"]["title"],
+ botStatus["offDayThread"]["id"],
+ botStatus["offDayThread"]["url"],
+ ),
+ )
+ botStatus["summary"][
+ "html"
+ ] += "
Today is an off day{}.
Off day thread{}".format(
+ " (Season Suspended)"
+ if self.commonData.get("seasonSuspended")
+ else "",
+ " disabled."
+ if not botStatus["offDayThread"]["enabled"]
+ else " suppressed during off season."
+ if self.settings.get("Off Day Thread", {}).get(
+ "SUPPRESS_OFFSEASON", True
+ )
+ and (
+ botStatus["seasonState"].startswith("off")
+ or botStatus["seasonState"] == "post:out"
+ )
+ else " failed to post (check log for error)"
+ if not botStatus["offDayThread"]["posted"]
+ and datetime.strptime(
+ botStatus["offDayThread"]["postTime"], "%m/%d/%Y %I:%M:%S %p"
+ )
+ < datetime.today()
+ else " post time: {}".format(botStatus["offDayThread"]["postTime"])
+ if not botStatus["offDayThread"]["posted"]
+ else ': {} ({})'.format(
+ botStatus["offDayThread"]["title"],
+ botStatus["offDayThread"]["url"],
+ botStatus["offDayThread"]["id"],
+ ),
+ )
+ botStatus["summary"][
+ "markdown"
+ ] += "\n\nToday is an off day{}.\n\n**Off day thread**{}".format(
+ " (**Season Suspended**)"
+ if self.commonData.get("seasonSuspended")
+ else "",
+ " disabled."
+ if not botStatus["offDayThread"]["enabled"]
+ else " suppressed during off season."
+ if self.settings.get("Off Day Thread", {}).get(
+ "SUPPRESS_OFFSEASON", True
+ )
+ and (
+ botStatus["seasonState"].startswith("off")
+ or botStatus["seasonState"] == "post:out"
+ )
+ else " failed to post (check log for error)"
+ if not botStatus["offDayThread"]["posted"]
+ and datetime.strptime(
+ botStatus["offDayThread"]["postTime"], "%m/%d/%Y %I:%M:%S %p"
+ )
+ < datetime.today()
+ else " post time: {}".format(botStatus["offDayThread"]["postTime"])
+ if not botStatus["offDayThread"]["posted"]
+ else ": {} ([{}]({}))".format(
+ botStatus["offDayThread"]["title"],
+ botStatus["offDayThread"]["id"],
+ botStatus["offDayThread"]["url"],
+ ),
+ )
+ else:
+ # Game Day
+ botStatus["summary"][
+ "text"
+ ] += "\n\nI am monitoring {} game{}: {}.".format(
+ len(botStatus["games"]),
+ "s" if len(botStatus["games"]) > 1 else "",
+ [k for x in botStatus["games"] for k, v in x.items()],
+ )
+ botStatus["summary"][
+ "html"
+ ] += "
I am monitoring {} game{}: {}.".format(
+ len(botStatus["games"]),
+ "s" if len(botStatus["games"]) > 1 else "",
+ [k for x in botStatus["games"] for k, v in x.items()],
+ )
+ botStatus["summary"][
+ "markdown"
+ ] += "\n\nI am monitoring **{} game{}**: {}.".format(
+ len(botStatus["games"]),
+ "s" if len(botStatus["games"]) > 1 else "",
+ [k for x in botStatus["games"] for k, v in x.items()],
+ )
+
+ botStatus["summary"]["text"] += "\n\nGame Day Thread{}.".format(
+ " disabled"
+ if not botStatus["gameDayThread"]["enabled"]
+ else " skipped or failed to post (check log for error)"
+ if not botStatus["gameDayThread"]["posted"]
+ and datetime.strptime(
+ botStatus["gameDayThread"]["postTime"], "%m/%d/%Y %I:%M:%S %p"
+ )
+ < datetime.today()
+ else " post time: {}".format(botStatus["gameDayThread"]["postTime"])
+ if not botStatus["gameDayThread"]["posted"]
+ else ": {} ({} - {})".format(
+ botStatus["gameDayThread"]["title"],
+ botStatus["gameDayThread"]["id"],
+ botStatus["gameDayThread"]["url"],
+ )
+ )
+ botStatus["summary"][
+ "html"
+ ] += "
Game Day Thread{}.".format(
+ " disabled"
+ if not botStatus["gameDayThread"]["enabled"]
+ else " skipped or failed to post (check log for error)"
+ if not botStatus["gameDayThread"]["posted"]
+ and datetime.strptime(
+ botStatus["gameDayThread"]["postTime"], "%m/%d/%Y %I:%M:%S %p"
+ )
+ < datetime.today()
+ else " post time: {}".format(botStatus["gameDayThread"]["postTime"])
+ if not botStatus["gameDayThread"]["posted"]
+ else ': {} ({})'.format(
+ botStatus["gameDayThread"]["title"],
+ botStatus["gameDayThread"]["url"],
+ botStatus["gameDayThread"]["id"],
+ )
+ )
+ botStatus["summary"]["markdown"] += "\n\n**Game Day Thread**{}.".format(
+ " disabled"
+ if not botStatus["gameDayThread"]["enabled"]
+ else " suppressed during off season."
+ if self.settings.get("Off Day Thread", {}).get(
+ "SUPPRESS_OFFSEASON", True
+ )
+ and (
+ botStatus["seasonState"].startswith("off")
+ or botStatus["seasonState"] == "post:out"
+ )
+ else " skipped or failed to post (check log for error)"
+ if not botStatus["gameDayThread"]["posted"]
+ and datetime.strptime(
+ botStatus["gameDayThread"]["postTime"], "%m/%d/%Y %I:%M:%S %p"
+ )
+ < datetime.today()
+ else " post time: {}".format(botStatus["gameDayThread"]["postTime"])
+ if not botStatus["gameDayThread"]["posted"]
+ else ": {} ([{}]({}))".format(
+ botStatus["gameDayThread"]["title"],
+ botStatus["gameDayThread"]["id"],
+ botStatus["gameDayThread"]["url"],
+ )
+ )
+
+ if len(botStatus["games"]) > 0:
+ # Game and Post Game Thread(s)
+ for x in botStatus["games"]:
+ for k, v in x.items():
+ botStatus["summary"]["text"] += "{}".format(
+ "\n\n{} ({}):\nGame thread{}{}".format(
+ k,
+ v.get("status", {}).get(
+ "detailedState", "Unknown Status"
+ ),
+ " disabled."
+ if not v["threads"]["game"]["enabled"]
+ else " skipped"
+ if v["threads"]["game"].get("postTime", "") == ""
+ and not v["threads"]["game"]["posted"]
+ else " not posted (check log for errors; this is normal if DH Game 2)"
+ if not v["threads"]["game"]["posted"]
+ and datetime.strptime(
+ v["threads"]["game"]["postTime"],
+ "%m/%d/%Y %I:%M:%S %p",
+ )
+ < datetime.today()
+ else " post time: {}.".format(
+ v["threads"]["game"]["postTime"]
+ )
+ if not v["threads"]["game"]["posted"]
+ else ": {} ({} - {})".format(
+ v["threads"]["game"]["title"],
+ v["threads"]["game"]["id"],
+ v["threads"]["game"]["url"],
+ ),
+ "\n\nPost game thread: {} ({} - {}).".format(
+ v["threads"]["post"]["title"],
+ v["threads"]["post"]["id"],
+ v["threads"]["post"]["url"],
+ )
+ if v["threads"]["post"]["posted"]
+ else "\n\nPost game thread disabled."
+ if not v["threads"]["post"]["enabled"]
+ else "",
+ )
+ )
+
+ for x in botStatus["games"]:
+ for k, v in x.items():
+ botStatus["summary"]["html"] += "{}".format(
+ "
{} ({}):
Game thread{}{}".format(
+ k,
+ v.get("status", {}).get(
+ "detailedState", "Unknown Status"
+ ),
+ " disabled."
+ if not v["threads"]["game"]["enabled"]
+ else " skipped"
+ if v["threads"]["game"].get("postTime", "") == ""
+ and not v["threads"]["game"]["posted"]
+ else " not posted (check log for errors; this is normal if DH Game 2)"
+ if not v["threads"]["game"]["posted"]
+ and datetime.strptime(
+ v["threads"]["game"]["postTime"],
+ "%m/%d/%Y %I:%M:%S %p",
+ )
+ < datetime.today()
+ else " post time: {}.".format(
+ v["threads"]["game"]["postTime"]
+ )
+ if not v["threads"]["game"]["posted"]
+ else ': {} ({})'.format(
+ v["threads"]["game"]["title"],
+ v["threads"]["game"]["url"],
+ v["threads"]["game"]["id"],
+ ),
+ '
Post game thread: {} ({}).'.format(
+ v["threads"]["post"]["title"],
+ v["threads"]["post"]["url"],
+ v["threads"]["post"]["id"],
+ )
+ if v["threads"]["post"]["posted"]
+ else "
Post game thread disabled."
+ if not v["threads"]["post"]["enabled"]
+ else "",
+ )
+ )
+
+ for x in botStatus["games"]:
+ for k, v in x.items():
+ botStatus["summary"]["markdown"] += "{}".format(
+ "\n\n**{}** ({}):\n\n**Game thread**{}{}".format(
+ k,
+ v.get("status", {}).get(
+ "detailedState", "Unknown Status"
+ ),
+ " disabled."
+ if not v["threads"]["game"]["enabled"]
+ else " skipped"
+ if v["threads"]["game"].get("postTime", "") == ""
+ and not v["threads"]["game"]["posted"]
+ else " not posted (check log for errors; this is normal if DH Game 2)"
+ if not v["threads"]["game"]["posted"]
+ and datetime.strptime(
+ v["threads"]["game"]["postTime"],
+ "%m/%d/%Y %I:%M:%S %p",
+ )
+ < datetime.today()
+ else " post time: {}.".format(
+ v["threads"]["game"]["postTime"]
+ )
+ if not v["threads"]["game"]["posted"]
+ else ": {} ([{}]({}))".format(
+ v["threads"]["game"]["title"],
+ v["threads"]["game"]["id"],
+ v["threads"]["game"]["url"],
+ ),
+ "\n\n**Post game thread**: {} ([{}]({})).".format(
+ v["threads"]["post"]["title"],
+ v["threads"]["post"]["id"],
+ v["threads"]["post"]["url"],
+ )
+ if v["threads"]["post"]["posted"]
+ else "\n\n>**Post game thread** disabled."
+ if not v["threads"]["post"]["enabled"]
+ else "",
+ )
+ )
+
+ botStatus["summary"]["text"] += "\n\nLast Updated: {}".format(
+ botStatus["lastUpdated"]
+ )
+ botStatus["summary"][
+ "html"
+ ] += "
Last Updated: {}".format(
+ botStatus["lastUpdated"]
+ )
+ botStatus["summary"]["markdown"] += "\n\n**Last Updated**: {}".format(
+ botStatus["lastUpdated"]
+ )
+ except Exception as e:
+ botStatus = {
+ "lastUpdated": datetime.today().strftime("%m/%d/%Y %I:%M:%S %p"),
+ "summary": {
+ "text": "Error retrieving bot state: {}".format(e),
+ "html": "Error retrieving bot state: {}".format(e),
+ "markdown": "Error retrieving bot state: {}".format(e),
+ },
+ }
+ self.error_notification("Error retrieving bot state")
+
+ self.log.debug("Bot Status: {}".format(botStatus)) # debug
+ self.bot.detailedState = botStatus
+
+ def _truncate_post(self, text):
+ warning_text = " \ # Truncated, post length limit reached."
+ max_length = self.settings.get("Lemmy", {}).get("POST_CHARACTER_LIMIT", 10000) - len(warning_text)
+ if len(text) >= max_length:
+ new_text = text[0:max_length - 1]
+ new_text += warning_text
+ else:
+ new_text = text
+ return new_text
diff --git a/bots/lemmy_mlb_game_threads/plaw/__init__.py b/bots/lemmy_mlb_game_threads/plaw/__init__.py
new file mode 100644
index 0000000..5815e14
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/plaw/__init__.py
@@ -0,0 +1 @@
+from .lemmy import Lemmy
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/plaw/lemmy.py b/bots/lemmy_mlb_game_threads/plaw/lemmy.py
new file mode 100644
index 0000000..7cd61e5
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/plaw/lemmy.py
@@ -0,0 +1,145 @@
+from .requestor import Requestor, HttpType
+
+
+class Lemmy:
+ def __enter__(self):
+ """Handle the context manager open."""
+ return self
+
+ def __exit__(self, *_args):
+ """Handle the context manager close."""
+
+ def __init__(self, instance, username, password):
+ self.instance = instance
+ self.username = username
+
+ # Login, get token, and set as header for future
+ self._req = Requestor({})
+ self.auth_token = self.login(username, password)
+ self._req.headers.update({"Authorization": "Bearer " + self.auth_token})
+ # print(self._req.headers.get("Authorization"))
+
+ def login(self, username, password):
+ url = self.instance + "/api/v3/user/login"
+ res_data = self._req.request(
+ HttpType.POST, url, {"username_or_email": username, "password": password}
+ )
+ return res_data["jwt"]
+
+ def getCommunity(self, name):
+ url = self.instance + "/api/v3/community"
+ res = self._req.request(HttpType.GET, url, {"name": name})
+
+ self.community = res["community_view"]["community"]
+ return self.community
+
+ def listPosts(self, sort=None):
+ url = self.instance + "/api/v3/post/list"
+ res = self._req.request(
+ HttpType.GET,
+ url,
+ {"sort": sort or "New", "community_id": self.community["id"]},
+ )
+
+ return res["posts"]
+
+ def getPost(self, id):
+ url = self.instance + "/api/v3/post"
+ res = self._req.request(
+ HttpType.GET,
+ url,
+ {"id": id},
+ )
+
+ return res["post_view"]
+
+ def submitPost(self, title=None, body=None, url=None):
+ api_url = self.instance + "/api/v3/post"
+ res = self._req.request(
+ HttpType.POST,
+ api_url,
+ {
+ "auth": self.auth_token,
+ "community_id": self.community["id"],
+ "name": title,
+ "body": body,
+ "url": url,
+ },
+ )
+
+ return res["post_view"]
+
+ def editPost(self, post_id, title=None, body=None, url=None):
+ api_url = self.instance + "/api/v3/post"
+ data = {
+ "auth": self.auth_token,
+ "post_id": post_id,
+ }
+ if title:
+ data["name"] = title
+ if body:
+ data["body"] = body
+ if url:
+ data["url"] = url
+
+ res = self._req.request(HttpType.PUT, api_url, data)
+
+ return res["post_view"]
+
+ def submitComment(self, post_id, content, language_id=None, parent_id=None):
+ api_url = self.instance + "/api/v3/comment"
+
+ data = {
+ "auth": self.auth_token,
+ "content": content,
+ "post_id": post_id,
+ }
+
+ if language_id:
+ data["language_id"] = language_id
+ if parent_id:
+ data["parent_id"] = parent_id
+
+ res = self._req.request(
+ HttpType.POST,
+ api_url,
+ data,
+ )
+
+ return res["comment_view"]
+
+ def stickyPost(self, post_id):
+ api_url = self.instance + "/api/v3/post/feature"
+
+ data = {
+ "auth": self.auth_token,
+ "post_id": post_id,
+ "feature_type": "Community",
+ "featured": True
+ }
+
+ res = self._req.request(
+ HttpType.POST,
+ api_url,
+ data,
+ )
+
+ return res["post_view"]
+
+ def unStickyPost(self, post_id):
+ api_url = self.instance + "/api/v3/post/feature"
+
+ data = {
+ "auth": self.auth_token,
+ "post_id": post_id,
+ "feature_type": "Community",
+ "featured": False
+ }
+
+ res = self._req.request(
+ HttpType.POST,
+ api_url,
+ data,
+ )
+
+ return res["post_view"]
diff --git a/bots/lemmy_mlb_game_threads/plaw/requestor.py b/bots/lemmy_mlb_game_threads/plaw/requestor.py
new file mode 100644
index 0000000..57d6e96
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/plaw/requestor.py
@@ -0,0 +1,32 @@
+from enum import Enum
+import requests
+import json
+from typing import Dict, Any, TypeVar
+
+T = TypeVar("T")
+
+class HttpType(Enum):
+ GET = "GET"
+ POST = "POST"
+ PUT = "PUT"
+ DELETE = "DELETE"
+ # ... any other HTTP methods you need
+
+class Requestor:
+ def __init__(self, headers: Dict[str, str]):
+ self.headers = headers
+
+ def request(self, type_: HttpType, url: str, form: Dict[str, Any]) -> T:
+ if type_ == HttpType.GET:
+ response = requests.get(url, params=form, headers=self.headers)
+ else:
+ headers = {
+ "Content-Type": "application/json",
+ **self.headers,
+ }
+ response = requests.request(type_.value, url, data=json.dumps(form), headers=headers)
+
+ if response.status_code != 200:
+ raise Exception(response.text) # Adjust this according to how your API returns errors
+
+ return response.json()
diff --git a/bots/lemmy_mlb_game_threads/templates/boxscore.mako b/bots/lemmy_mlb_game_threads/templates/boxscore.mako
new file mode 100644
index 0000000..be18d1e
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/boxscore.mako
@@ -0,0 +1,296 @@
+<%page args="boxStyle='wide'" />
+<%
+ import copy
+ def playerLink(name, personId):
+ return '[{}](http://mlb.mlb.com/team/player.jsp?player_id={})'.format(name,str(personId))
+%>
+% if boxStyle.lower() == 'wide':
+ ## Wide Batting Boxes
+ ## Displaying ops instead of avg and slg, to save a little space
+ <%
+ wideBattingBox = ''
+ awayBatters = copy.deepcopy(data[gamePk]['boxscore']['awayBatters'])
+ homeBatters = copy.deepcopy(data[gamePk]['boxscore']['homeBatters'])
+
+ ##Make sure the home and away batter lists are the same length
+ while len(awayBatters) > len(homeBatters):
+ homeBatters.append({'namefield':'','ab':'','r':'','h':'','rbi':'','bb':'','k':'','lob':'','avg':'','ops':'','obp':'','slg':'','name':'','position':'','battingOrder':'-1','note':'','personId':0})
+
+ while len(awayBatters) < len(homeBatters):
+ awayBatters.append({'namefield':'','ab':'','r':'','h':'','rbi':'','bb':'','k':'','lob':'','avg':'','ops':'','obp':'','slg':'','name':'','position':'','battingOrder':'-1','note':'','personId':0})
+
+ ##Get team totals
+ awayBatters.append(data[gamePk]['boxscore']['awayBattingTotals'])
+ homeBatters.append(data[gamePk]['boxscore']['homeBattingTotals'])
+
+ ##Build the batting box!
+ if len(awayBatters) > 2:
+ for i in range(0,len(awayBatters)):
+ if awayBatters[i]['namefield'].find('Batters') == -1 and awayBatters[i]['namefield'] != 'Totals':
+ awayBatters[i].update({'namefield' : awayBatters[i]['note'] + playerLink(awayBatters[i]['name'],awayBatters[i]['personId']) + ' - ' + awayBatters[i]['position']})
+
+ wideBattingBox += '|'
+ if awayBatters[i]['battingOrder'] != '' and awayBatters[i]['battingOrder'][-2:]=='00':
+ wideBattingBox += awayBatters[i]['battingOrder'][0]
+
+ wideBattingBox += '|{namefield}|{ab}|{r}|{h}|{rbi}|{bb}|{k}|{lob}|{avg}|{ops}| |'.format(**awayBatters[i])
+
+ if homeBatters[i]['namefield'].find('Batters') == -1 and homeBatters[i]['namefield'] != 'Totals':
+ homeBatters[i].update({'namefield' : homeBatters[i]['note'] + playerLink(homeBatters[i]['name'],homeBatters[i]['personId']) + ' - ' + homeBatters[i]['position']})
+
+ if homeBatters[i]['battingOrder'] != '' and homeBatters[i]['battingOrder'][-2:]=='00':
+ wideBattingBox += homeBatters[i]['battingOrder'][0]
+
+ wideBattingBox += '|{namefield}|{ab}|{r}|{h}|{rbi}|{bb}|{k}|{lob}|{avg}|{ops}|\n'.format(**homeBatters[i])
+ if i==0:
+ wideBattingBox += '|:--'*(11*2+1) + '|\n' # Left-align cols for away, home, plus separator
+
+ ##Get batting notes
+ awayBattingNotes = copy.deepcopy(data[gamePk]['boxscore']['awayBattingNotes'])
+ homeBattingNotes = copy.deepcopy(data[gamePk]['boxscore']['homeBattingNotes'])
+
+ ##Make sure notes are the same size
+ while len(awayBattingNotes) > len(homeBattingNotes):
+ homeBattingNotes.update({len(homeBattingNotes):''})
+
+ while len(homeBattingNotes) > len(awayBattingNotes):
+ awayBattingNotes.update({len(awayBattingNotes):''})
+
+ ##Get batting and fielding info
+ awayBoxInfo = {'BATTING':'', 'FIELDING':''}
+ homeBoxInfo = {'BATTING':'', 'FIELDING':''}
+ for infoType in ['BATTING','FIELDING']:
+ ## if (infoType=='BATTING' and battingInfo) or (infoType=='FIELDING' and fieldingInfo):
+ for z in (x for x in data[gamePk]['gumbo']['liveData']['boxscore']['teams']['away']['info'] if x.get('title')==infoType):
+ for x in z['fieldList']:
+ awayBoxInfo[infoType] += ('**' + x.get('label','') + '**: ' + x.get('value','') + ' ')
+
+ for z in (x for x in data[gamePk]['gumbo']['liveData']['boxscore']['teams']['home']['info'] if x.get('title')==infoType):
+ for x in z['fieldList']:
+ homeBoxInfo[infoType] += ('**' + x.get('label','') + '**: ' + x.get('value','') + ' ')
+
+ if len(awayBattingNotes) or len(homeBattingNotes) or len(awayBoxInfo['BATTING']) or len(awayBoxInfo['FIELDING']):
+ wideBattingBox += '\n\n|{}|{}|\n|:--|:--|\n'.format(data[gamePk]['schedule']['teams']['away']['team']['teamName'], data[gamePk]['schedule']['teams']['home']['team']['teamName'])
+
+ if len(awayBattingNotes) or len(homeBattingNotes):
+ for i in range(0,len(awayBattingNotes)):
+ wideBattingBox += '|{}|{}|\n'.format(awayBattingNotes[i], homeBattingNotes[i])
+
+ if awayBoxInfo['BATTING'] != homeBoxInfo['BATTING']:
+ wideBattingBox += '|{}{}|{}{}|\n'.format('BATTING: ' if len(awayBoxInfo['BATTING']) else '', awayBoxInfo['BATTING'], 'BATTING: ' if len(homeBoxInfo['BATTING']) else '', homeBoxInfo['BATTING'])
+
+ if awayBoxInfo['FIELDING'] != homeBoxInfo['FIELDING']:
+ wideBattingBox += '|{}{}|{}{}|\n'.format('FIELDING: ' if len(awayBoxInfo['FIELDING']) else '', awayBoxInfo['FIELDING'], 'FIELDING: ' if len(homeBoxInfo['FIELDING']) else '', homeBoxInfo['FIELDING'])
+
+ %>
+${wideBattingBox}
+
+ ## Wide Pitching Boxes
+ <%
+ widePitchingBox = ''
+ awayPitchers = copy.deepcopy(data[gamePk]['boxscore']['awayPitchers'])
+ homePitchers = copy.deepcopy(data[gamePk]['boxscore']['homePitchers'])
+
+ ## Make sure the home and away pitcher lists are the same length
+ while len(awayPitchers) > len(homePitchers):
+ homePitchers.append({'namefield':'','ip':'','h':'','r':'','er':'','bb':'','k':'','hr':'','era':'','p':'','s':'','name':'','personId':0,'note':''})
+
+ while len(awayPitchers) < len(homePitchers):
+ awayPitchers.append({'namefield':'','ip':'','h':'','r':'','er':'','bb':'','k':'','hr':'','era':'','p':'','s':'','name':'','personId':0,'note':''})
+
+ ## Get team totals
+ awayPitchers.append(data[gamePk]['boxscore']['awayPitchingTotals'])
+ homePitchers.append(data[gamePk]['boxscore']['homePitchingTotals'])
+
+ #Build the pitching box!
+ if len(awayPitchers) > 2:
+ for i in range(0,len(awayPitchers)):
+ if i==0:
+ awayPitchers[i].update({'ps':'P-S'})
+ homePitchers[i].update({'ps':'P-S'})
+ else:
+ if awayPitchers[i]['p'] != '':
+ awayPitchers[i].update({'ps':'{}-{}'.format(awayPitchers[i]['p'], awayPitchers[i]['s'])})
+ else:#if awayPitchers[i]['name'] == '':
+ awayPitchers[i].update({'ps':''})
+
+ if homePitchers[i]['p'] != '':
+ homePitchers[i].update({'ps':'{}-{}'.format(homePitchers[i]['p'], homePitchers[i]['s'])})
+ else:#if homePitchers[i]['name'] == '':
+ homePitchers[i].update({'ps':''})
+
+ widePitchingBox += '|'
+ widePitchingBox += playerLink(awayPitchers[i]['name'],awayPitchers[i]['personId']) if awayPitchers[i]['personId']!=0 else awayPitchers[i]['name']
+ widePitchingBox += (' ' + awayPitchers[i]['note']) if len(awayPitchers[i]['note'])>0 else ''
+ widePitchingBox += '|{ip}|{h}|{r}|{er}|{bb}|{k}|{hr}|{ps}|{era}| '.format(**awayPitchers[i])
+ widePitchingBox += '|'
+ widePitchingBox += playerLink(homePitchers[i]['name'],homePitchers[i]['personId']) if homePitchers[i]['personId']!=0 else homePitchers[i]['name']
+ widePitchingBox += (' ' + homePitchers[i]['note']) if len(homePitchers[i]['note'])>0 else ''
+ widePitchingBox += '|{ip}|{h}|{r}|{er}|{bb}|{k}|{hr}|{ps}|{era}|\n'.format(**homePitchers[i])
+ if i==0:
+ widePitchingBox += '|:--'*(10*2+1) + '|\n'
+
+ %>
+${widePitchingBox}
+% elif boxStyle.lower() == 'stacked':
+ ## Stacked Batting Boxes
+ ## Away Batting
+ <%
+ if 1==1:
+ awayBattingBox = ''
+ awayBatters = copy.deepcopy(data[gamePk]['boxscore']['awayBatters'])
+
+ ## Get team totals
+ awayBatters.append(data[gamePk]['boxscore']['awayBattingTotals'])
+
+ ##Build the batting box!
+ if len(awayBatters) > 2:
+ for i in range(0,len(awayBatters)):
+ if awayBatters[i]['namefield'].find('Batters') == -1 and awayBatters[i]['namefield'] != 'Totals':
+ awayBatters[i].update({'namefield' : awayBatters[i]['note'] + playerLink(awayBatters[i]['name'],awayBatters[i]['personId']) + ' - ' + awayBatters[i]['position']})
+
+ awayBattingBox += '|' + (awayBatters[i]['battingOrder'][0] if awayBatters[i]['battingOrder'] != '' and awayBatters[i]['battingOrder'][-2:]=='00' else '') + '|{namefield}|{ab}|{r}|{h}|{rbi}|{bb}|{k}|{lob}|{avg}|{obp}|{slg}|\n'.format(**awayBatters[i])
+ if i==0:
+ awayBattingBox += '|:--'*12 + '|\n' # Left-align cols for away, plus separator
+
+ ##Get batting notes
+ awayBattingNotes = data[gamePk]['boxscore']['awayBattingNotes']
+
+ ##Get batting and fielding info
+ awayBoxInfo = {'BATTING':'', 'FIELDING':''}
+ for infoType in ['BATTING','FIELDING']:
+ ## if (infoType=='BATTING' and battingInfo) or (infoType=='FIELDING' and fieldingInfo):
+ for z in (x for x in data[gamePk]['gumbo']['liveData']['boxscore']['teams']['away']['info'] if x.get('title')==infoType):
+ for x in z['fieldList']:
+ awayBoxInfo[infoType] += ('**' + x.get('label','') + '**: ' + x.get('value','') + ' ')
+
+ if len(awayBattingNotes) or len(awayBoxInfo['BATTING']) or len(awayBoxInfo['FIELDING']):
+ awayBattingBox += '\n\n|{}|\n|:--|\n'.format(data[gamePk]['schedule']['teams']['away']['team']['teamName'])
+
+ if len(awayBattingNotes):
+ awayBattingBox += '|{}|\n'.format(' '.join(awayBattingNotes.values()))
+
+ if len(awayBoxInfo['BATTING']):
+ awayBattingBox += '|{}{}|\n'.format('BATTING: ' if len(awayBoxInfo['BATTING']) else '', awayBoxInfo['BATTING'])
+
+ if len(awayBoxInfo['FIELDING']):
+ awayBattingBox += '|{}{}|\n'.format('FIELDING: ' if len(awayBoxInfo['FIELDING']) else '', awayBoxInfo['FIELDING'])
+
+ %>
+${awayBattingBox}
+
+ ## Home Batting
+ <%
+ homeBattingBox = ''
+ homeBatters = copy.deepcopy(data[gamePk]['boxscore']['homeBatters'])
+
+ ## Get team totals
+ homeBatters.append(data[gamePk]['boxscore']['homeBattingTotals'])
+
+ ##Build the batting box!
+ if len(homeBatters) > 2:
+ for i in range(0,len(homeBatters)):
+ if homeBatters[i]['namefield'].find('Batters') == -1 and homeBatters[i]['namefield'] != 'Totals':
+ homeBatters[i].update({'namefield' : homeBatters[i]['note'] + playerLink(homeBatters[i]['name'],homeBatters[i]['personId']) + ' - ' + homeBatters[i]['position']})
+
+ homeBattingBox += '|' + (homeBatters[i]['battingOrder'][0] if homeBatters[i]['battingOrder'] != '' and homeBatters[i]['battingOrder'][-2:]=='00' else '') + '|{namefield}|{ab}|{r}|{h}|{rbi}|{bb}|{k}|{lob}|{avg}|{obp}|{slg}|\n'.format(**homeBatters[i])
+ if i==0:
+ homeBattingBox += '|:--'*12 + '|\n' # Left-align cols for home, plus separator
+
+ ##Get batting notes
+ homeBattingNotes = data[gamePk]['boxscore']['homeBattingNotes']
+
+ ##Get batting and fielding info
+ homeBoxInfo = {'BATTING':'', 'FIELDING':''}
+ for infoType in ['BATTING','FIELDING']:
+ ## if (infoType=='BATTING' and battingInfo) or (infoType=='FIELDING' and fieldingInfo):
+ for z in (x for x in data[gamePk]['gumbo']['liveData']['boxscore']['teams']['home']['info'] if x.get('title')==infoType):
+ for x in z['fieldList']:
+ homeBoxInfo[infoType] += ('**' + x.get('label','') + '**: ' + x.get('value','') + ' ')
+
+ if len(homeBattingNotes) or len(homeBoxInfo['BATTING']) or len(homeBoxInfo['FIELDING']):
+ homeBattingBox += '\n\n|{}|\n|:--|\n'.format(data[gamePk]['schedule']['teams']['home']['team']['teamName'])
+
+ if len(homeBattingNotes):
+ homeBattingBox += '|{}|\n'.format(' '.join(homeBattingNotes.values()))
+
+ if len(homeBoxInfo['BATTING']):
+ homeBattingBox += '|{}{}|\n'.format('BATTING: ' if len(homeBoxInfo['BATTING']) else '', homeBoxInfo['BATTING'])
+
+ if len(homeBoxInfo['FIELDING']):
+ homeBattingBox += '|{}{}|\n'.format('FIELDING: ' if len(homeBoxInfo['FIELDING']) else '', homeBoxInfo['FIELDING'])
+
+ %>
+${homeBattingBox}
+
+ ## Stacked Pitching Boxes
+ ## Away Pitching
+ <%
+ awayPitchingBox = ''
+ awayPitchers = copy.deepcopy(data[gamePk]['boxscore']['awayPitchers'])
+
+ ## Get team totals
+ awayPitchers.append(data[gamePk]['boxscore']['awayPitchingTotals'])
+
+ #Build the pitching box!
+ if len(awayPitchers) > 2:
+ if len(awayPitchers):
+ for i in range(0,len(awayPitchers)):
+ if i==0:
+ awayPitchers[i].update({'ps':'P-S'})
+ elif awayPitchers[i]['p'] != '':
+ awayPitchers[i].update({'ps':'{}-{}'.format(awayPitchers[i]['p'], awayPitchers[i]['s'])})
+ else:#if awayPitchers[i]['name'] == '':
+ awayPitchers[i].update({'ps':''})
+
+ awayPitchingBox += '|'
+ awayPitchingBox += playerLink(awayPitchers[i]['name'],awayPitchers[i]['personId']) if awayPitchers[i]['personId']!=0 else awayPitchers[i]['name']
+ awayPitchingBox += (' ' + awayPitchers[i]['note']) if len(awayPitchers[i]['note'])>0 else ''
+ awayPitchingBox += '|{ip}|{h}|{r}|{er}|{bb}|{k}|{hr}|{ps}|{era}|\n'.format(**awayPitchers[i])
+ if i==0:
+ awayPitchingBox += '|:--'*10 + '|\n'
+
+ %>
+${awayPitchingBox}
+
+ ## Home Pitching
+ <%
+ homePitchingBox = ''
+ homePitchers = copy.deepcopy(data[gamePk]['boxscore']['homePitchers'])
+
+ ## Get team totals
+ homePitchers.append(data[gamePk]['boxscore']['homePitchingTotals'])
+
+ #Build the pitching box!
+ if len(homePitchers) > 2:
+ if len(homePitchers):
+ for i in range(0,len(homePitchers)):
+ if i==0:
+ homePitchers[i].update({'ps':'P-S'})
+ elif homePitchers[i]['p'] != '':
+ homePitchers[i].update({'ps':'{}-{}'.format(homePitchers[i]['p'], homePitchers[i]['s'])})
+ else:#if homePitchers[i]['name'] == '':
+ homePitchers[i].update({'ps':''})
+
+ homePitchingBox += '|'
+ homePitchingBox += playerLink(homePitchers[i]['name'],homePitchers[i]['personId']) if homePitchers[i]['personId']!=0 else homePitchers[i]['name']
+ homePitchingBox += (' ' + homePitchers[i]['note']) if len(homePitchers[i]['note'])>0 else ''
+ homePitchingBox += '|{ip}|{h}|{r}|{er}|{bb}|{k}|{hr}|{ps}|{era}|\n'.format(**homePitchers[i])
+ if i==0:
+ homePitchingBox += '|:--'*10 + '|\n'
+
+ %>
+${homePitchingBox}
+
+% endif
+
+## Include game info if game is final
+% if data[gamePk]['schedule']['status']['abstractGameCode'] == 'F' and boxStyle.lower() != 'none':
+ % if len(data[gamePk]['boxscore']['gameBoxInfo']):
+|Game Info|
+|:--|
+ % endif
+ % for x in data[gamePk]['boxscore']['gameBoxInfo']:
+|${x['label'] + (': ' if x.get('value') else '') + x.get('value','')}|
+ % endfor
+% endif
diff --git a/bots/lemmy_mlb_game_threads/templates/comment_body.mako b/bots/lemmy_mlb_game_threads/templates/comment_body.mako
new file mode 100644
index 0000000..4029490
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/comment_body.mako
@@ -0,0 +1,88 @@
+% if settings.get("Comments", {}).get("MYTEAM_BATTING_HEADER" if myTeamBatting else "MYTEAM_PITCHING_HEADER"):
+## Header is defined for all events
+${settings['Comments']["MYTEAM_BATTING_HEADER" if myTeamBatting else "MYTEAM_PITCHING_HEADER"]}
+
+% endif
+% if actionOrResult == 'action':
+## Event occurred during an at-bat
+ % if settings.get("Comments", {}).get(("MYTEAM_BATTING_HEADER_" if myTeamBatting else "MYTEAM_PITCHING_HEADER_") + eventType.upper()):
+ ## Header is defined for this event
+${settings['Comments'][("MYTEAM_BATTING_HEADER_" if myTeamBatting else "MYTEAM_PITCHING_HEADER_") + eventType.upper()]}
+
+ % else:
+ ## Use event for header
+${'### '} ${atBat["playEvents"][actionIndex]["details"].get("event", "")}
+
+ ## End if custom event header
+ % endif
+## The event description:
+${atBat["playEvents"][actionIndex]["details"].get('description', "")}
+ % if settings.get("Comments", {}).get(("MYTEAM_BATTING_FOOTER_" if myTeamBatting else "MYTEAM_PITCHING_FOOTER_") +eventType.upper()):
+ ## Footer is defined for this event
+
+${settings['Comments'][("MYTEAM_BATTING_FOOTER_" if myTeamBatting else "MYTEAM_PITCHING_FOOTER_") + eventType.upper()]}
+ ## End if custom event footer
+ % endif
+## End if actionOrResult == 'action'
+% elif actionOrResult == 'result':
+## Event is the result of an at-bat
+ % if settings.get("Comments", {}).get(("MYTEAM_BATTING_HEADER_" if myTeamBatting else "MYTEAM_PITCHING_HEADER_") + eventType.upper()):
+ ## Header is defined for this event
+${settings['Comments'][("MYTEAM_BATTING_HEADER_" if myTeamBatting else "MYTEAM_PITCHING_HEADER_") + eventType.upper()]}
+
+ ## End if custom event header
+ %elif eventType == 'home_run':
+ ## Use standard home run headers
+ % if atBat['result']['rbi'] == 4:
+# GRAND SLAM!
+ % else:
+# HOME RUN!
+ % endif
+ ## End if eventType == 'home_run'
+ % elif eventType == 'strikeout':
+ % if atBat['playEvents'][atBat['pitchIndex'][-1]]['details']['code'] == 'C':
+${'# '} ꓘ
+ % else:
+${'### '} K
+ % endif
+ ## End if custom event header
+ % else:
+${'### '} ${atBat['result']['event']}
+ ## End else (if eventType == * or custom event header)
+ % endif
+## The event description:
+${atBat['result']['description']}
+ % if len(atBat['pitchIndex']) > 0 and atBat['playEvents'][atBat['pitchIndex'][-1]].get('pitchData'):
+ ## Event has pitch data
+
+Pitch from ${atBat['matchup']['pitcher']['fullName']}: \
+% if atBat['playEvents'][atBat['pitchIndex'][-1]].get('preCount'): # preCount is no longer included in the data, so don't list pitch count as 0-0
+${atBat['playEvents'][atBat['pitchIndex'][-1]].get('preCount',{}).get('balls','0')}-${atBat['playEvents'][atBat['pitchIndex'][-1]].get('preCount',{}).get('strikes','0')} \
+% endif
+${atBat['playEvents'][atBat['pitchIndex'][-1]].get('details',{}).get('type',{}).get('description','Unknown pitch type')} @ ${atBat['playEvents'][atBat['pitchIndex'][-1]].get('pitchData',{}).get('startSpeed','-')} mph
+ % endif
+ % if len(atBat['pitchIndex']) > 0 and atBat['playEvents'][atBat['pitchIndex'][-1]].get('hitData'):
+ ## Event has hit data
+
+Hit by ${atBat['matchup']['batter']['fullName']}: Launched ${atBat['playEvents'][atBat['pitchIndex'][-1]].get('hitData',{}).get('launchSpeed','-')} mph at ${atBat['playEvents'][atBat['pitchIndex'][-1]].get('hitData',{}).get('launchAngle','-')}° for a total distance of ${atBat['playEvents'][atBat['pitchIndex'][-1]].get('hitData',{}).get('totalDistance','-')} ft
+ % endif
+ % if atBat['about']['isScoringPlay']:
+ ## Event is a scoring play, so include the updated score (and inning)
+
+${atBat['about']['halfInning'].capitalize()} ${atBat['about']['inning']} - \
+${max(atBat['result']['homeScore'], atBat['result']['awayScore'])}-${min(atBat['result']['homeScore'], atBat['result']['awayScore'])}
+${'TIE' if atBat['result']['homeScore'] == atBat['result']['awayScore'] else data[0]['myTeam']['teamName'] if ((atBat['result']['homeScore'] > atBat['result']['awayScore'] and data[gamePk]['homeAway']=='home') or (atBat['result']['awayScore'] > atBat['result']['homeScore'] and data[gamePk]['homeAway']=='away')) else data[gamePk]['oppTeam']['teamName']}
+ % endif
+ % if settings.get("Comments", {}).get(("MYTEAM_BATTING_FOOTER_" if myTeamBatting else "MYTEAM_PITCHING_FOOTER_") + eventType.upper()):
+ ## Footer is defined for this event
+
+${settings['Comments'][("MYTEAM_BATTING_FOOTER_" if myTeamBatting else "MYTEAM_PITCHING_FOOTER_") + eventType.upper()]}
+ ## End if custom event footer
+ % endif
+## End elif actionOrResult == 'result'
+% endif
+% if settings.get("Comments", {}).get("MYTEAM_BATTING_FOOTER" if myTeamBatting else "MYTEAM_PITCHING_FOOTER"):
+## Footer is defined for all events
+
+${settings['Comments']["MYTEAM_BATTING_FOOTER" if myTeamBatting else "MYTEAM_PITCHING_FOOTER"]}
+% endif
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/comment_webhook.mako b/bots/lemmy_mlb_game_threads/templates/comment_webhook.mako
new file mode 100644
index 0000000..8b82431
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/comment_webhook.mako
@@ -0,0 +1,3 @@
+## This template produces data in JSON format suitable for Discord webhooks
+## Be sure to include \n for line breaks, because actual linebreaks will be stripped out!
+{"content": "${commentText.replace('"','\"').replace('\r','').replace('\n','\\n')}"}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/comment_webhook_telegram.mako b/bots/lemmy_mlb_game_threads/templates/comment_webhook_telegram.mako
new file mode 100644
index 0000000..976e5c5
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/comment_webhook_telegram.mako
@@ -0,0 +1,3 @@
+## This template produces data in JSON format suitable for Telegram webhooks
+## Be sure to include \n for line breaks, because actual linebreaks will be stripped out!
+{"text": "${commentText.replace('"','\"').replace('\r','').replace('\n','\\n')}"}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/decisions.mako b/bots/lemmy_mlb_game_threads/templates/decisions.mako
new file mode 100644
index 0000000..c6bf8ea
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/decisions.mako
@@ -0,0 +1,25 @@
+<%page args="gamePk" />
+<%
+ def playerLink(name, personId):
+ return '[{}](http://mlb.mlb.com/team/player.jsp?player_id={})'.format(name,str(personId))
+
+ if data[gamePk]["gumbo"]["liveData"].get("decisions"):
+ decisions = data[gamePk]["gumbo"]["liveData"]["decisions"]
+ decisions_prepared = []
+ for k, v in decisions.items():
+ seasonStats = data[gamePk]["gumbo"]["liveData"]["boxscore"]["teams"]["away"]["players"].get(f"ID{v['id']}", data[gamePk]["gumbo"]["liveData"]["boxscore"]["teams"]["home"]["players"].get(f"ID{v['id']}", {})).get("seasonStats", {}).get("pitching", {})
+ detail = (
+ f"({seasonStats.get('holds', '-')}, {seasonStats.get('era', '-.--')})" if k == "hold"
+ else f"({seasonStats.get('saves', '-')}, {seasonStats.get('era', '-.--')})" if k == "save"
+ else f"({seasonStats.get('wins', '-')}-{seasonStats.get('losses', '-')}, {seasonStats.get('era', '-.--')})"
+ )
+ decisions_prepared.append(f"{k.title()}: {playerLink(v.get('fullName', 'Unknown'), v.get('id', 0))} {detail}")
+ else:
+ return
+ if not len(decisions_prepared):
+ return
+%>
+${'### '}Decisions
+% for d in decisions_prepared:
+* ${d}
+% endfor
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/division_scoreboard.mako b/bots/lemmy_mlb_game_threads/templates/division_scoreboard.mako
new file mode 100644
index 0000000..54f1b0e
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/division_scoreboard.mako
@@ -0,0 +1,75 @@
+<%page args="gamePk=0,include_wc=False,wc_num=5" />
+## My team's division games
+<%
+ from datetime import datetime
+ import pytz
+ if isinstance(gamePk, int):
+ gamePks = [gamePk]
+ elif isinstance(gamePk, list):
+ gamePks = [x for x in gamePk]
+ else:
+ gamePks = [0]
+ if include_wc:
+ wc_teams = []
+ wc_temp = []
+ for d in data[0]['standings'].values():
+ for t in d["teams"]:
+ if t.get("wc_rank") != "-" and int(t.get("wc_rank", 0)) <= wc_num:
+ wc_teams.append(t["team_id"])
+ wc_temp.append({"rank": t["wc_rank"], "id": t["team_id"], "name": t["name"]})
+ divGames = [
+ x for x in data[0]['leagueSchedule']
+ if (
+ data[0]['myTeam']['division']['id']
+ in [
+ x['teams']['away']['team'].get('division',{}).get('id'),
+ x['teams']['home']['team'].get('division',{}).get('id')
+ ] or (
+ include_wc
+ and data[0]['myTeam']['league']['id'] in [
+ x['teams']['away']['team'].get('league',{}).get('id'),
+ x['teams']['home']['team'].get('league',{}).get('id')
+ ] and any(
+ True for i in wc_teams if i in [
+ x['teams']['away']['team'].get('id'),
+ x['teams']['home']['team'].get('id')
+ ]
+ )
+ )
+ ) and x['gamePk'] not in gamePks
+ ]
+ if not len(divGames):
+ if include_wc:
+ title = 'Division & Wild Card Scoreboard: There are no other games!'
+ else:
+ title = 'Around the Division: There are no other division teams playing!'
+ else:
+ if include_wc:
+ title = '### Division & Wild Card Scoreboard'
+ else:
+ title = '### Division Scoreboard'
+%>\
+${title}
+% for x in divGames:
+${x['teams']['away']['team']['abbreviation']} ${x['teams']['away'].get('score',0) if x['status']['abstractGameCode'] in ['L','F'] else ''} @ \
+${x['teams']['home']['team']['abbreviation']} ${x['teams']['home'].get('score',0) if x['status']['abstractGameCode'] in ['L','F'] else ''} \
+% if x['status']['statusCode'] == 'PW':
+Warmup
+% elif x['gameTime']['utc'] > datetime.utcnow().replace(tzinfo=pytz.utc) and x['status']['abstractGameCode'] != 'F':
+${x['gameTime']['myTeam'].strftime('%I:%M %p %Z')}
+% elif x['status']['abstractGameCode'] == 'L':
+- ${x['linescore']['inningState']} ${x['linescore']['currentInning']}${((', ' + str(x['linescore']['outs']) + ' Out') + ('s' if x['linescore']['outs']!=1 else '')) if x['linescore']['inningState'] in ['Top','Bottom'] else ''}
+% elif x['status']['abstractGameCode'] == 'F':
+- ${x['status']['detailedState']}
+% endif
+
+% endfor
+##${x['teams']['away']['team']['league']['abbreviation']}
+##${x['teams']['away']['team']['division']['abbreviation']}
+##${x['teams']['home']['team']['league']['abbreviation']}
+##${x['teams']['home']['team']['division']['abbreviation']}
+## codedGameState - Suspended: U, T; Cancelled: C, Postponed: D
+##${x['status']['codedGameState']}
+##${x['status']['abstractGameState']}
+##${x['status']['abstractGameCode']}
+##${x['status']['statusCode']}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/game_info.mako b/bots/lemmy_mlb_game_threads/templates/game_info.mako
new file mode 100644
index 0000000..6270e37
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/game_info.mako
@@ -0,0 +1,114 @@
+<%page args="gamePk" />
+${'### '}Links & Info
+## Venue & weather
+% if data[gamePk]['schedule'].get('weather') and len(data[gamePk]['schedule']['weather']) and (data[gamePk]['schedule']['weather'].get('temp') or data[gamePk]['schedule']['weather'].get('condition') or data[gamePk]['schedule']['weather'].get('wind') != 'null mph, null'):
+* Current conditions at ${data[gamePk]['schedule']['venue']['name']}: ${data[gamePk]['schedule']['weather']['temp']+'°F' if data[gamePk]['schedule']['weather'].get('temp') else ''} ${'- ' + data[gamePk]['schedule']['weather']['condition'] if data[gamePk]['schedule']['weather'].get('condition') else ''} ${'- Wind ' + data[gamePk]['schedule']['weather']['wind'] if data[gamePk]['schedule']['weather'].get('wind') != 'null mph, null' else ''}
+% else:
+* Venue: ${data[gamePk]['schedule']['venue']['name']}
+% endif
+<%
+ awayTv = [x for x in data[gamePk]['schedule'].get('broadcasts', []) if x.get('type')=='TV' and x.get('homeAway')=='away' and not x.get('isNational',False)]
+ awayRadio = [x for x in data[gamePk]['schedule'].get('broadcasts', []) if x.get('type') in ['FM','AM'] and x.get('homeAway')=='away' and not x.get('isNational',False)]
+ homeTv = [x for x in data[gamePk]['schedule'].get('broadcasts', []) if x.get('type')=='TV' and x.get('homeAway')=='home' and not x.get('isNational',False)]
+ homeRadio = [x for x in data[gamePk]['schedule'].get('broadcasts', []) if x.get('type') in ['FM','AM'] and x.get('homeAway')=='home' and not x.get('isNational',False)]
+ nationalTv = [x for x in data[gamePk]['schedule'].get('broadcasts', []) if x.get('type')=='TV' and x.get('isNational')]
+ nationalRadio = [x for x in data[gamePk]['schedule'].get('broadcasts', []) if x.get('type')=='Radio' and x.get('isNational')]
+%>\
+* TV: \
+<%
+ tv = ''
+ flag = False
+ if len(nationalTv):
+ flag = True
+ tv += '**National**:'
+ used = []
+ while len(nationalTv):
+ r = nationalTv.pop()
+ if r['name'] not in used:
+ tv += ' {}{}'.format(r['name'],' (' + r['language'] + ')' if r['language']!='en' else '')
+ used.append(r['name'])
+ if len(nationalTv) and nationalTv[0]['name'] not in used: tv += ','
+
+ if len(awayTv):
+ if flag: tv += ', '
+ flag = True
+ tv += '**{}**:'.format(data[gamePk]['schedule']['teams']['away']['team']['teamName'])
+ used = []
+ while len(awayTv):
+ r = awayTv.pop()
+ if r['name'] not in used:
+ tv += ' {}{}'.format(r['name'],' (' + r['language'] + ')' if r['language']!='en' else '')
+ used.append(r['name'])
+ if len(awayTv) and awayTv[0]['name'] not in used: tv += ','
+
+ if len(homeTv):
+ if flag: tv += ', '
+ flag = None
+ tv += '**{}**:'.format(data[gamePk]['schedule']['teams']['home']['team']['teamName'])
+ used = []
+ while len(homeTv):
+ r = homeTv.pop()
+ if r['name'] not in used:
+ tv += ' {}{}'.format(r['name'],' (' + r['language'] + ')' if r['language']!='en' else '')
+ used.append(r['name'])
+ if len(homeTv) and homeTv[0]['name'] not in used: tv += ','
+
+ if tv == '': tv = 'None'
+%>\
+${tv}
+* Radio: \
+<%
+ radio = ''
+ flag = False
+ if len(nationalRadio):
+ flag = True
+ radio += '**National**:'
+ used = []
+ while len(nationalRadio):
+ r = nationalRadio.pop()
+ if r['name'] not in used:
+ radio += ' {}{}'.format(r['name'],' (' + r['language'] + ')' if r['language']!='en' else '')
+ used.append(r['name'])
+ if len(nationalRadio) and nationalRadio[0]['name'] not in used: radio += ','
+
+ if len(awayRadio):
+ if flag: radio += ', '
+ flag = True
+ radio += '**{}**:'.format(data[gamePk]['schedule']['teams']['away']['team']['teamName'])
+ used = []
+ while len(awayRadio):
+ r = awayRadio.pop()
+ if r['name'] not in used:
+ radio += ' {}{}'.format(r['name'],' (' + r['language'] + ')' if r['language']!='en' else '')
+ used.append(r['name'])
+ if len(awayRadio) and awayRadio[0]['name'] not in used: radio += ','
+
+ if len(homeRadio):
+ if flag: radio += ', '
+ flag = None
+ radio += '**{}**:'.format(data[gamePk]['schedule']['teams']['home']['team']['teamName'])
+ used = []
+ while len(homeRadio):
+ r = homeRadio.pop()
+ radio += ' {}{}'.format(r['name'],' (' + r['language'] + ')' if r['language']!='en' else '')
+ if len(homeRadio) and homeRadio[0]['name'] not in used: radio += ','
+
+ if radio == '': radio = 'None'
+%>\
+${radio}
+* [MLB Gameday](https://www.mlb.com/gameday/${gamePk}/)
+##* Game Notes: [${data[gamePk]['schedule']['teams']['away']['team']['teamName']}](http://mlb.mlb.com/mlb/presspass/gamenotes.jsp?c_id=${data[gamePk]['schedule']['teams']['away']['team']['fileCode']}), [${data[gamePk]['schedule']['teams']['home']['team']['teamName']}](http://mlb.mlb.com/mlb/presspass/gamenotes.jsp?c_id=${data[gamePk]['schedule']['teams']['home']['team']['fileCode']})
+% if (data[gamePk]['schedule']['status']['abstractGameCode'] == 'L' and data[gamePk]['schedule']['status']['statusCode'] != 'PW') or data[gamePk]['schedule']['status']['abstractGameCode'] == 'F':
+##* ${'[Strikezone Map](http://www.brooksbaseball.net/pfxVB/zoneTrack.php?month={}&day={}&year={}&game=gid_{})'.format(data[gamePk]['gameTime']['homeTeam'].strftime('%m'), data[gamePk]['gameTime']['homeTeam'].strftime('%d'), data[gamePk]['gameTime']['homeTeam'].strftime('%Y'), data[gamePk]['gumbo']['gameData']['game']['id'].replace('/','_').replace('-','_'))}
+* ${'[Game Graphs](http://www.fangraphs.com/livewins.aspx?date={}&team={}&dh={}&season={})'.format( data[gamePk]['gameTime']['homeTeam'].strftime("%Y-%m-%d"), data[gamePk]['schedule']['teams']['home']['team']['teamName'].replace(' ','%20'), data[gamePk]['schedule']['gameNumber'] if data[gamePk]['schedule']['doubleHeader']!='N' else 0, data[gamePk]['gameTime']['homeTeam'].strftime('%Y'))}
+% endif
+% if data[gamePk]['schedule']['status']['abstractGameCode'] == 'P' or data[gamePk]['schedule']['status']['statusCode'] == 'PW':
+* ${f"[Statcast Game Preview](https://baseballsavant.mlb.com/preview?game_pk={gamePk})"}
+% else:
+* ${f"[Savant Gamefeed](https://baseballsavant.mlb.com/gamefeed?gamePk={gamePk})"}
+% endif
+% for n in range(1, 6):
+% if settings.get('Game Thread',{}).get(f'LINK_{n}',''):
+* [${settings['Game Thread'][f'LINK_{n}'].split("|")[0]}](${settings['Game Thread'][f'LINK_{n}'].split("|")[1].replace('gamePk', str(gamePk))})
+% endif
+% endfor
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/game_thread-footer_only.mako b/bots/lemmy_mlb_game_threads/templates/game_thread-footer_only.mako
new file mode 100644
index 0000000..28c7180
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/game_thread-footer_only.mako
@@ -0,0 +1 @@
+${settings.get('Game Thread',{}).get('FOOTER','')}
diff --git a/bots/lemmy_mlb_game_threads/templates/game_thread.mako b/bots/lemmy_mlb_game_threads/templates/game_thread.mako
new file mode 100644
index 0000000..1898db1
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/game_thread.mako
@@ -0,0 +1,136 @@
+<%
+ from datetime import datetime
+ import pytz
+%>\
+## Header: Team names with links to team subs and matchup image, followed by game date
+${'# '}<%include file="matchup.mako" args="dateFormat='%a, %b %d'" />
+
+## Game status: show detailed state and then list first pitch time if game hasn't started yet and isn't final
+${'### '}Game Status: ${data[gamePk]['schedule']['status']['detailedState']} \
+% if data[gamePk]['schedule']['status'].get('reason') and len(data[gamePk]['schedule']['status']['reason']) > 0 and data[gamePk]['schedule']['status']['reason'] not in data[gamePk]['schedule']['status']['detailedState']:
+due to ${data[gamePk]['schedule']['status']['reason']} \
+% endif
+% if data[gamePk]['gameTime']['utc'] > datetime.utcnow().replace(tzinfo=pytz.utc) and data[gamePk]['schedule']['status']['abstractGameCode'] != 'F':
+%if not (data[gamePk]['schedule']['doubleHeader'] == 'Y' and data[gamePk]['schedule']['gameNumber'] == 2):
+- First Pitch is scheduled for ${data[gamePk]['gameTime']['myTeam'].strftime('%I:%M %p %Z')}
+
+% endif
+% elif (data[gamePk]['schedule']['status']['abstractGameCode'] == 'L' and data[gamePk]['schedule']['status']['statusCode'] != 'PW') or (data[gamePk]['schedule']['status']['abstractGameCode'] == 'F' and data[gamePk]['schedule']['status']['codedGameState'] not in ['C','D']):
+## Game status is live and not warmup, so include info about inning and outs on the game status line
+- <%include file="score.mako" /> \
+% if data[gamePk]['schedule']['status']['abstractGameCode'] != 'F':
+## Only include inning if game is in progress (status!=F since we're already inside the other condition)
+
+% if data[gamePk]['schedule']['linescore']['inningState'] == "Middle" and data[gamePk]['schedule']['linescore']['currentInningOrdinal'] == "7th":
+Seventh inning stretch\
+% else:
+${data[gamePk]['schedule']['linescore']['inningState']} of the ${data[gamePk]['schedule']['linescore']['currentInningOrdinal']}\
+% endif
+## current pitcher/batter matchup, count, on deck, in hole -- or due up
+<%
+ currentPlay = data[gamePk]['gumbo']["liveData"].get('plays',{}).get('currentPlay',{})
+ offense = data[gamePk]['gumbo']["liveData"].get('linescore',{}).get('offense',{})
+ defense = data[gamePk]['gumbo']["liveData"].get('linescore',{}).get('defense',{})
+ outs =currentPlay.get('count',{}).get('outs','0')
+ comingUpTeamName = data[gamePk]['schedule']['teams']['away']['team']['teamName'] if data[gamePk]['schedule']['teams']['away']['team']['id'] == offense.get('team',{}).get('id') else data[gamePk]['schedule']['teams']['home']['team']['teamName']
+%>\
+% if outs == 3:
+% if offense.get('batter',{}).get('fullName'):
+## due up batter is in the data, so include them
+${' '}with ${offense.get('batter',{}).get('fullName','*Unknown*')}, \
+${offense.get('onDeck',{}).get('fullName','*Unknown')}, \
+and ${offense.get('inHole',{}).get('fullName','*Unknown')} \
+due up for the ${comingUpTeamName}
+% else:
+## due up batter is not in the data, so just put the team name due up
+with the ${comingUpTeamName} coming up to bat
+% endif
+% else:
+## else condition from if outs == 3--inning is in progress, so list outs, runners, matchup, on deck, and in hole
+## Outs
+, ${data[gamePk]['schedule']['linescore']['outs']} out${'s' if data[gamePk]['schedule']['linescore']['outs'] != 1 else ''}\
+<%
+ runners = (
+ "bases empty" if not offense.get('first') and not offense.get('second') and not offense.get('third')
+ else "runner on first" if offense.get('first') and not offense.get('second') and not offense.get('third')
+ else "runner on second" if not offense.get('first') and offense.get('second') and not offense.get('third')
+ else "runner on third" if not offense.get('first') and not offense.get('second') and offense.get('third')
+ else "runners on first and second" if offense.get('first') and offense.get('second') and not offense.get('third')
+ else "runners on first and third" if offense.get('first') and not offense.get('second') and offense.get('third')
+ else "runners on second and third" if not offense.get('first') and offense.get('second') and offense.get('third')
+ else "bases loaded" if offense.get('first') and offense.get('second') and offense.get('third')
+ else None
+ )
+ ondeck = offense.get('onDeck',{}).get('fullName')
+ inthehole = offense.get('inHole',{}).get('fullName')
+%>\
+${(', ' + runners) if runners else ''}\
+## Count
+, ${currentPlay.get('count',{}).get('balls','0')}-${currentPlay.get('count',{}).get('strikes','0')} count \
+## Matchup
+with ${currentPlay.get('matchup',{}).get('pitcher',{}).get('fullName','*Unknown*')} pitching and ${currentPlay.get('matchup',{}).get('batter',{}).get('fullName','*Unknown*')} batting. \
+## Ondeck
+% if ondeck:
+${ondeck} is on deck\
+## In hole
+% if inthehole:
+, and ${inthehole} is in the hole.
+% endif
+% endif
+% endif
+% endif
+% endif
+
+## Broadcasts, gameday link, (strikezone map commented out since it doesn't seem to have data), (game notes commented out due to new press pass requirement)
+<%include file="game_info.mako" />
+
+<%
+ wc_standings = settings.get('Game Thread',{}).get('WILDCARD_STANDINGS', False)
+ wc_scoreboard = settings.get('Game Thread',{}).get('WILDCARD_SCOREBOARD', False)
+ wc_num = settings.get('Game Thread',{}).get('WILDCARD_NUM_TO_SHOW', 5)
+%>\
+## Game status is not final or live (except warmup), include standings, probable pitchers, lineups if posted
+% if (data[gamePk]['schedule']['status']['abstractGameCode'] != 'L' or data[gamePk]['schedule']['status']['statusCode'] == 'PW') and data[gamePk]['schedule']['status']['abstractGameCode'] != 'F':
+% if data[0]['myTeam']['seasonState'] == 'regular' and data[gamePk]['schedule']['gameType'] == "R":
+<%include file="standings.mako" args="include_wc=wc_standings,wc_num=wc_num"/>
+% endif
+
+<%include file="probable_pitchers.mako" />
+
+<%include file="lineups.mako" args="boxStyle=settings.get('Game Thread',{}).get('BOXSCORE_STYLE','wide')" />
+% endif
+
+
+## If game status is final, or live and not warmup, include boxscore, scoring plays, highlights, and linescore
+% if (data[gamePk]['schedule']['status']['abstractGameCode'] == 'L' and data[gamePk]['schedule']['status']['statusCode'] != 'PW') or data[gamePk]['schedule']['status']['abstractGameCode'] == 'F':
+<%include file="boxscore.mako" args="boxStyle=settings.get('Game Thread',{}).get('BOXSCORE_STYLE','wide')" />
+
+% if not settings.get('Game Thread',{}).get('SUPPRESS_SCORING_PLAYS', False):
+<%include file="scoring_plays.mako" />
+
+% endif
+<%include file="highlights.mako" />
+
+<%include file="linescore.mako" />
+% endif
+% if data[gamePk]['schedule']['status']['abstractGameCode'] == 'F' and not settings.get('Game Thread',{}).get('SUPPRESS_DECISIONS', False):
+<%include file="decisions.mako" />
+
+% endif
+% if data[0]['myTeam'].get('division'): # will skip for All Star teams
+% if data[0]['myTeam']['seasonState'] != 'post:in':
+## division scoreboard
+<%include file="division_scoreboard.mako" args="gamePk=gamePk,include_wc=wc_scoreboard,wc_num=wc_num" />
+% else:
+## league scoreboard
+${'### Around the League' if any(x for x in data[0]['leagueSchedule'] if x['gamePk'] != gamePk) else 'Around the League: There are no other games!'}
+<%include file="league_scoreboard.mako" args="gamePk=gamePk" />
+% endif
+% endif
+
+## no-no/perfecto watch
+<%include file="no-no_watch.mako" />
+
+
+## configurable footer text
+${settings.get('Game Thread',{}).get('FOOTER','')}
diff --git a/bots/lemmy_mlb_game_threads/templates/game_title.mako b/bots/lemmy_mlb_game_threads/templates/game_title.mako
new file mode 100644
index 0000000..9c33089
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/game_title.mako
@@ -0,0 +1,25 @@
+<%
+ from datetime import datetime
+ prefix = settings.get("Game Thread", {}).get("TITLE_PREFIX","Game Thread:")
+ dateFormat = settings.get("Game Thread", {}).get("DATE_FORMAT","%a, %b %d @ %I:%M %p %Z")
+ dhDateFormat = settings.get("Game Thread", {}).get("DATE_FORMAT_DH","%a, %b %d")
+%>\
+${prefix + (" " if len(prefix) and not prefix.endswith(" ") else "")}\
+%if data[gamePk]['schedule'].get('seriesDescription') and data[gamePk]['schedule'].get('gameType') not in ['R','I','S','E']:
+${data[gamePk]['schedule']['seriesDescription'] + ' '}\
+${'Game ' + str(data[gamePk]['schedule']['seriesGameNumber']) + ' - ' if data[gamePk]['schedule'].get('seriesGameNumber') and data[gamePk]['schedule'].get('gamesInSeries',1) > 1 else '- '}\
+%endif
+%if data[0]["myTeam"].get("allStarStatus"):
+${data[gamePk]['schedule']['teams']['away']['team']['teamName']} @ ${data[gamePk]['schedule']['teams']['home']['team']['teamName']}\
+%else:
+${data[gamePk]['schedule']['teams']['away']['team']['teamName']} (${data[gamePk]['schedule']['teams']['away']['leagueRecord']['wins']}-${data[gamePk]['schedule']['teams']['away']['leagueRecord']['losses']})\
+ @ ${data[gamePk]['schedule']['teams']['home']['team']['teamName']} (${data[gamePk]['schedule']['teams']['home']['leagueRecord']['wins']}-${data[gamePk]['schedule']['teams']['home']['leagueRecord']['losses']})\
+%endif
+%if data[gamePk]['schedule']['doubleHeader'] == 'Y' and data[gamePk]['schedule']['gameNumber'] == 2:
+ - ${data[gamePk]['gameTime']['myTeam'].strftime(dhDateFormat)} - Doubleheader Game 2
+%else:
+ - ${data[gamePk]['gameTime']['myTeam'].strftime(dateFormat)}
+%endif
+%if data[gamePk]['schedule']['doubleHeader'] == 'S':
+ - Doubleheader Game ${data[gamePk]['schedule']['gameNumber']}
+%endif
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/game_webhook.mako b/bots/lemmy_mlb_game_threads/templates/game_webhook.mako
new file mode 100644
index 0000000..90bbb7b
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/game_webhook.mako
@@ -0,0 +1,2 @@
+## This template produces data in JSON format suitable for Discord webhooks
+{"content":"**Game Thread Posted!**\n${theThread.title}\n${theThread.shortlink}"}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/game_webhook_telegram.mako b/bots/lemmy_mlb_game_threads/templates/game_webhook_telegram.mako
new file mode 100644
index 0000000..d20a5c0
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/game_webhook_telegram.mako
@@ -0,0 +1,2 @@
+## This template produces data in JSON format suitable for Telegram webhooks
+{"text":"Game Thread Posted!\n${theThread.title}\n${theThread.shortlink}"}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/gameday_thread.mako b/bots/lemmy_mlb_game_threads/templates/gameday_thread.mako
new file mode 100644
index 0000000..3b6b690
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/gameday_thread.mako
@@ -0,0 +1,50 @@
+## loop through gamePks for game info, pass gamePk into each template
+% for pk in (x for x in data.keys() if x!=0):
+% if data[pk]['schedule'].get('seriesDescription') and data[pk]['schedule'].get('gameType') not in ['R','I','S','E']:
+${'# ' + data[pk]['schedule']['seriesDescription'] + ' '}\
+${'Game ' + str(data[pk]['schedule']['seriesGameNumber']) + ' - ' if data[pk]['schedule'].get('seriesGameNumber') and data[pk]['schedule'].get('gamesInSeries',1) > 1 else '- '}\
+% else:
+${'### '}\
+% endif
+<%include file="matchup.mako" args="gamePk=pk,dateFormat='%I:%M %p %Z'" />
+
+## Game status: show detailed state and then list first pitch time if game hasn't started yet and isn't final
+${'### '}Game Status: ${data[pk]['schedule']['status']['detailedState']} \
+% if data[pk]['schedule']['status'].get('reason') and len(data[pk]['schedule']['status']['reason']) > 0 and data[pk]['schedule']['status']['reason'] not in data[pk]['schedule']['status']['detailedState']:
+due to ${data[pk]['schedule']['status']['reason']} \
+% endif
+
+## Broadcasts, gameday link, (strikezone map commented out since it doesn't seem to have data), (game notes commented out due to new press pass requirement)
+<%include file="game_info.mako" args="gamePk=pk" />
+
+## probable pitchers
+<%include file="probable_pitchers.mako" args="gamePk=pk" />
+
+## lineups vs. probable pitchers
+<%include file="lineups.mako" args="gamePk=pk,boxStyle=settings.get('Game Day Thread',{}).get('BOXSCORE_STYLE','wide')" />
+
+## probable pitchers vs. teams
+% endfor
+
+<%
+ wc_standings = settings.get('Game Day Thread',{}).get('WILDCARD_STANDINGS', False)
+ wc_scoreboard = settings.get('Game Day Thread',{}).get('WILDCARD_SCOREBOARD', False)
+ wc_num = settings.get('Game Day Thread',{}).get('WILDCARD_NUM_TO_SHOW', 5)
+%>\
+% if data[0]['myTeam']['seasonState'] == 'regular' and data[pk]['schedule']['gameType'] == "R":
+<%include file="standings.mako" args="include_wc=wc_standings,wc_num=wc_num"/>
+% endif
+
+% if data[0]['myTeam'].get('division'): # will skip for All Star teams
+% if data[0]['myTeam']['seasonState'] != 'post:in':
+## division scoreboard
+<%include file="division_scoreboard.mako" args="gamePk=list(data.keys()),include_wc=wc_scoreboard,wc_num=wc_num" />
+% else:
+## league scoreboard
+${'### Around the League' if any(x for x in data[0]['leagueSchedule'] if x['gamePk'] not in data.keys()) else 'Around the League: There are no other games!'}
+<%include file="league_scoreboard.mako" args="gamePk=list(data.keys())" />
+% endif
+% endif
+
+## Configurable footer text
+${settings.get('Game Day Thread',{}).get('FOOTER','')}
diff --git a/bots/lemmy_mlb_game_threads/templates/gameday_title.mako b/bots/lemmy_mlb_game_threads/templates/gameday_title.mako
new file mode 100644
index 0000000..04c4e1b
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/gameday_title.mako
@@ -0,0 +1,9 @@
+<%
+ from datetime import datetime
+ prefix = settings.get("Game Day Thread", {}).get("TITLE_PREFIX","Game Day Thread")
+ dateFormat = settings.get("Game Day Thread", {}).get("DATE_FORMAT","%A, %B %d")
+%>\
+% if not data[0]["myTeam"].get("allStarStatus"):
+${data[0]['myTeam']['teamName']} \
+% endif
+${prefix + (" " if len(prefix) and not prefix.endswith(" ") else "")}- ${datetime.strptime(data[0]['today']['Ymd'],'%Y%m%d').strftime(dateFormat)}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/gameday_webhook.mako b/bots/lemmy_mlb_game_threads/templates/gameday_webhook.mako
new file mode 100644
index 0000000..f0b1b35
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/gameday_webhook.mako
@@ -0,0 +1,2 @@
+## This template produces data in JSON format suitable for Discord webhooks
+{"content":"**Game Day Thread Posted!**\n${theThread.title}\n${theThread.shortlink}"}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/gameday_webhook_telegram.mako b/bots/lemmy_mlb_game_threads/templates/gameday_webhook_telegram.mako
new file mode 100644
index 0000000..6a72ff3
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/gameday_webhook_telegram.mako
@@ -0,0 +1,2 @@
+## This template produces data in JSON format suitable for Telegram webhooks
+{"text":"Game Day Thread Posted!\n${theThread.title}\n${theThread.shortlink}"}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/highlights.mako b/bots/lemmy_mlb_game_threads/templates/highlights.mako
new file mode 100644
index 0000000..a4b52d8
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/highlights.mako
@@ -0,0 +1,27 @@
+## Game Highlights
+<%
+ sortedHighlights = []
+ if not data[gamePk]['schedule']['content'].get('highlights',{}).get('highlights') or not data[gamePk]['schedule']['content'].get('highlights',{}).get('highlights',{}).get('items'):
+ return
+ else:
+ unorderedHighlights = {}
+ for i in (x for x in data[gamePk]['schedule']['content']['highlights']['highlights']['items'] if isinstance(x,dict) and x['type']=='video'):
+ unorderedHighlights.update({i['date'] : i})
+
+ for x in sorted(unorderedHighlights):
+ sortedHighlights.append(unorderedHighlights[x])
+%>
+% if len(sortedHighlights) > 0:
+|Team|Highlight|
+|:--|:--|
+% for p in sortedHighlights:
+<%
+ team_id = int(next((t.get('value') for t in p.get('keywordsAll',{}) if t.get('type')=='team_id'),0))
+ team_abbrev = next((b['team']['abbreviation'] for a,b in data[gamePk]['schedule']['teams'].items() if b['team']['id']==team_id),'')
+ hdLink = next((v.get('url') for v in p.get('playbacks',{}) if v.get('name')=='mp4Avc'),'')
+%>\
+|[${team_abbrev}](${data[0]['teamSubs'].get(team_id, '')})\
+|[${p['headline']} (${p.get('duration', '?:??')})](${hdLink})|
+##|[${p['description']} (${p['duration']})](${hdLink})|
+% endfor
+% endif
diff --git a/bots/lemmy_mlb_game_threads/templates/league_scoreboard.mako b/bots/lemmy_mlb_game_threads/templates/league_scoreboard.mako
new file mode 100644
index 0000000..299372b
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/league_scoreboard.mako
@@ -0,0 +1,36 @@
+<%page args="gamePk=0" />
+## All MLB games (except current gamePk when applicable)
+<%
+ from datetime import datetime
+ import pytz
+ if isinstance(gamePk, int):
+ gamePks = [gamePk]
+ elif isinstance(gamePk, list):
+ gamePks = [x for x in gamePk]
+ else:
+ gamePks = [0]
+%>\
+% for x in (x for x in data[0]['leagueSchedule'] if x['gamePk'] not in gamePks):
+${x['teams']['away']['team']['abbreviation']} ${x['teams']['away'].get('score',0) if x['status']['abstractGameCode'] in ['L','F'] else ''} @ \
+${x['teams']['home']['team']['abbreviation']} ${x['teams']['home'].get('score',0) if x['status']['abstractGameCode'] in ['L','F'] else ''} \
+% if x['status']['statusCode'] == 'PW':
+Warmup
+% elif x['gameTime']['utc'] > datetime.utcnow().replace(tzinfo=pytz.utc) and x['status']['abstractGameCode'] != 'F':
+${x['gameTime']['myTeam'].strftime('%I:%M %p %Z')}
+% elif x['status']['abstractGameCode'] == 'L':
+- ${x['linescore']['inningState']} ${x['linescore']['currentInning']}${((', ' + str(x['linescore']['outs']) + ' Out') + ('s' if x['linescore']['outs']!=1 else '')) if x['linescore']['inningState'] in ['Top','Bottom'] else ''}
+% elif x['status']['abstractGameCode'] == 'F':
+- ${x['status']['detailedState']}
+% endif
+
+
+% endfor
+##${x['teams']['away']['team']['league']['abbreviation']}
+##${x['teams']['away']['team']['division']['abbreviation']}
+##${x['teams']['home']['team']['league']['abbreviation']}
+##${x['teams']['home']['team']['division']['abbreviation']}
+## codedGameState - Suspended: U, T; Cancelled: C, Postponed: D
+##${x['status']['codedGameState']}
+##${x['status']['abstractGameState']}
+##${x['status']['abstractGameCode']}
+##${x['status']['statusCode']}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/linescore.mako b/bots/lemmy_mlb_game_threads/templates/linescore.mako
new file mode 100644
index 0000000..e8265c7
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/linescore.mako
@@ -0,0 +1,46 @@
+## Linescore
+<%
+ if not data[gamePk]['schedule'].get('linescore',{}).get('innings'):
+ return
+
+ header_row = []
+ home = []
+ away = []
+ for i in data[gamePk]['schedule']['linescore']['innings']:
+ header_row.append(str(i['num']))
+ away.append(str(i['away'].get('runs','')))
+ home.append(str(i['home'].get('runs','')))
+
+ if len(data[gamePk]['schedule']['linescore']['innings']) < 9:
+ for i in range(len(data[gamePk]['schedule']['linescore']['innings'])+1, 10):
+ header_row.append(str(i))
+ away.append(' ')
+ home.append(' ')
+
+ header_row.extend(['','R','H','E','LOB'])
+ away.extend([
+ '',
+ str(data[gamePk]['schedule']['linescore']['teams']['away'].get('runs',0)),
+ str(data[gamePk]['schedule']['linescore']['teams']['away'].get('hits',0)),
+ str(data[gamePk]['schedule']['linescore']['teams']['away'].get('errors',0)),
+ str(data[gamePk]['schedule']['linescore']['teams']['away'].get('leftOnBase',0))
+ ])
+ home.extend([
+ '',
+ str(data[gamePk]['schedule']['linescore']['teams']['home'].get('runs',0)),
+ str(data[gamePk]['schedule']['linescore']['teams']['home'].get('hits',0)),
+ str(data[gamePk]['schedule']['linescore']['teams']['home'].get('errors',0)),
+ str(data[gamePk]['schedule']['linescore']['teams']['home'].get('leftOnBase',0))
+ ])
+%>
+% for r in [['',header_row],[data[gamePk]['schedule']['teams']['away']['team']['teamName'],away],[data[gamePk]['schedule']['teams']['home']['team']['teamName'],home]]:
+|${r[0]}|\
+% for ri in r[1]:
+${ri}|\
+% endfor
+
+% if r[0]=='':
+|:--|\
+${':--|'*len(r[1])}
+% endif
+% endfor
diff --git a/bots/lemmy_mlb_game_threads/templates/lineups.mako b/bots/lemmy_mlb_game_threads/templates/lineups.mako
new file mode 100644
index 0000000..4ad6b8c
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/lineups.mako
@@ -0,0 +1,121 @@
+<%page args="gamePk,boxStyle='wide'" />
+<%
+ def playerLink(name, personId):
+ if name=='' or personId==0: return ''
+ return '[{}](http://mlb.mlb.com/team/player.jsp?player_id={})'.format(name,str(personId))
+
+ if not len(data[gamePk]['gumbo']['liveData']['boxscore']['teams']['away']['batters']) and not len(data[gamePk]['gumbo']['liveData']['boxscore']['teams']['home']['batters']):
+ # lineups not posted
+ return
+
+ if not len(data[gamePk]['awayBattersVsProb']):
+ # no stats against probable pitcher, or no probable pitcher announced
+ # just list the lineups
+ awayLineupOnly = True
+ else:
+ awayLineupOnly = False
+
+ if not len(data[gamePk]['homeBattersVsProb']):
+ # no stats against probable pitcher, or no probable pitcher announced
+ # just list the lineups
+ homeLineupOnly = True
+ else:
+ homeLineupOnly = False
+
+ awayBatters = data[gamePk]['gumbo']['liveData']['boxscore']['teams']['away']['batters']
+ homeBatters = data[gamePk]['gumbo']['liveData']['boxscore']['teams']['home']['batters']
+%>\
+% if boxStyle.lower() == 'wide':
+ ## Wide Batting Boxes
+<%
+ ##Make sure the home and away batter lists are the same length
+ while len(awayBatters) > len(homeBatters):
+ homeBatters.append(0)
+
+ while len(awayBatters) < len(homeBatters):
+ awayBatters.append(0)
+%>\
+## Build the box
+% if len(awayBatters):
+|${data[gamePk]['schedule']['teams']['away']['team']['teamName']} Lineup${' vs. ' + playerLink(data[gamePk]['gumbo']["gameData"]["players"]['ID'+str(data[gamePk]['schedule']['teams']['home']['probablePitcher']['id'])]['boxscoreName'],data[gamePk]['schedule']['teams']['home']['probablePitcher']['id']) + '|AVG|OPS|AB|HR|RBI|K' if not awayLineupOnly else ''}|\
+|${data[gamePk]['schedule']['teams']['home']['team']['teamName']} Lineup${' vs. ' + playerLink(data[gamePk]['gumbo']["gameData"]["players"]['ID'+str(data[gamePk]['schedule']['teams']['away']['probablePitcher']['id'])]['boxscoreName'],data[gamePk]['schedule']['teams']['away']['probablePitcher']['id']) + '|AVG|OPS|AB|HR|RBI|K' if not homeLineupOnly else ''}|
+|${':--|'*(15 if not awayLineupOnly else 3)}
+% for i in range(0,len(awayBatters)):
+<%
+ a = next((x for x in data[gamePk]['awayBattersVsProb'] if x['id']==awayBatters[i]),{})
+ aStats = next((s['splits'][0]['stat'] for s in a.get('stats',{}) if s['type']['displayName']=='vsPlayerTotal' and s['group']['displayName']=='hitting' and s['totalSplits'] > 0),{})
+ h = next((x for x in data[gamePk]['homeBattersVsProb'] if x['id']==homeBatters[i]),{})
+ hStats = next((s['splits'][0]['stat'] for s in h.get('stats',{}) if s['type']['displayName']=='vsPlayerTotal' and s['group']['displayName']=='hitting' and s['totalSplits'] > 0),{})
+%>\
+% if awayBatters[i] != 0:
+|${i+1} ${playerLink(data[gamePk]['gumbo']["gameData"]["players"]['ID'+str(awayBatters[i])]['boxscoreName'],awayBatters[i]) + ' - ' + data[gamePk]['gumbo']['liveData']['boxscore']['teams']['away']['players']['ID'+str(awayBatters[i])]['position']['abbreviation']}|\
+${str(aStats.get('avg','-'))+'|' if not awayLineupOnly else ''}\
+${str(aStats.get('ops','-'))+'|' if not awayLineupOnly else ''}\
+${str(aStats.get('atBats','-'))+'|' if not awayLineupOnly else ''}\
+${str(aStats.get('homeRuns','-'))+'|' if not awayLineupOnly else ''}\
+${str(aStats.get('rbi','-'))+'|' if not awayLineupOnly else ''}\
+${str(aStats.get('strikeOuts','-'))+'|' if not awayLineupOnly else ''}\
+% else:
+${'||||||||' if not awayLineupOnly else '||'}\
+% endif
+% if homeBatters[i] != 0:
+|${i+1} ${playerLink(data[gamePk]['gumbo']["gameData"]["players"]['ID'+str(homeBatters[i])]['boxscoreName'],homeBatters[i]) + ' - ' + data[gamePk]['gumbo']['liveData']['boxscore']['teams']['home']['players']['ID'+str(homeBatters[i])]['position']['abbreviation']}|\
+${str(hStats.get('avg','-'))+'|' if not homeLineupOnly else ''}\
+${str(hStats.get('ops','-'))+'|' if not homeLineupOnly else ''}\
+${str(hStats.get('atBats','-'))+'|' if not homeLineupOnly else ''}\
+${str(hStats.get('homeRuns','-'))+'|' if not homeLineupOnly else ''}\
+${str(hStats.get('rbi','-'))+'|' if not homeLineupOnly else ''}\
+${str(hStats.get('strikeOuts','-'))+'|' if not homeLineupOnly else ''}
+% else:
+${'||||||||' if not homeLineupOnly else '||'}
+% endif
+% endfor
+% endif
+% elif boxStyle.lower() == 'stacked':
+## Stacked Batting Boxes
+## Away
+% if len(awayBatters):
+|${data[gamePk]['schedule']['teams']['away']['team']['teamName']} Lineup${' vs. ' + playerLink(data[gamePk]['gumbo']["gameData"]["players"]['ID'+str(data[gamePk]['schedule']['teams']['home']['probablePitcher']['id'])]['boxscoreName'],data[gamePk]['schedule']['teams']['home']['probablePitcher']['id']) + '|AVG|OPS|AB|HR|RBI|K|' if not awayLineupOnly else '|'}
+|${':--|'*(7 if not awayLineupOnly else 2)}
+% for i in range(0,len(awayBatters)):
+<%
+ a = next((x for x in data[gamePk]['awayBattersVsProb'] if x['id']==awayBatters[i]),{})
+ aStats = next((s['splits'][0]['stat'] for s in a.get('stats',{}) if s['type']['displayName']=='vsPlayerTotal' and s['group']['displayName']=='hitting' and s['totalSplits'] > 0),{})
+%>\
+% if awayBatters[i] != 0:
+|${i+1} ${playerLink(data[gamePk]['gumbo']["gameData"]["players"]['ID'+str(awayBatters[i])]['boxscoreName'],awayBatters[i]) + ' - ' + data[gamePk]['gumbo']['liveData']['boxscore']['teams']['away']['players']['ID'+str(awayBatters[i])]['position']['abbreviation']}|\
+${str(aStats.get('avg','-'))+'|' if not awayLineupOnly else ''}\
+${str(aStats.get('ops','-'))+'|' if not awayLineupOnly else ''}\
+${str(aStats.get('atBats','-'))+'|' if not awayLineupOnly else ''}\
+${str(aStats.get('homeRuns','-'))+'|' if not awayLineupOnly else ''}\
+${str(aStats.get('rbi','-'))+'|' if not awayLineupOnly else ''}\
+${str(aStats.get('strikeOuts','-')) if not awayLineupOnly else ''}|
+% else:
+|${'|'*(7 if not awayLineupOnly else 2)}
+% endif
+% endfor
+% endif
+
+## Home
+% if len(homeBatters):
+|${data[gamePk]['schedule']['teams']['home']['team']['teamName']} Lineup${' vs. ' + playerLink(data[gamePk]['gumbo']["gameData"]["players"]['ID'+str(data[gamePk]['schedule']['teams']['away']['probablePitcher']['id'])]['boxscoreName'],data[gamePk]['schedule']['teams']['away']['probablePitcher']['id']) + '|AVG|OPS|AB|HR|RBI|K|' if not homeLineupOnly else '|'}
+|${':--|'*(7 if not homeLineupOnly else 2)}
+% for i in range(0,len(homeBatters)):
+<%
+ h = next((x for x in data[gamePk]['homeBattersVsProb'] if x['id']==homeBatters[i]),{})
+ hStats = next((s['splits'][0]['stat'] for s in h.get('stats',{}) if s['type']['displayName']=='vsPlayerTotal' and s['group']['displayName']=='hitting' and s['totalSplits'] > 0),{})
+%>\
+% if homeBatters[i] != 0:
+|${i+1} ${playerLink(data[gamePk]['gumbo']["gameData"]["players"]['ID'+str(homeBatters[i])]['boxscoreName'],homeBatters[i]) + ' - ' + data[gamePk]['gumbo']['liveData']['boxscore']['teams']['home']['players']['ID'+str(homeBatters[i])]['position']['abbreviation']}|\
+${str(hStats.get('avg','-'))+'|' if not homeLineupOnly else ''}\
+${str(hStats.get('ops','-'))+'|' if not homeLineupOnly else ''}\
+${str(hStats.get('atBats','-'))+'|' if not homeLineupOnly else ''}\
+${str(hStats.get('homeRuns','-'))+'|' if not homeLineupOnly else ''}\
+${str(hStats.get('rbi','-'))+'|' if not homeLineupOnly else ''}\
+${str(hStats.get('strikeOuts','-')) if not homeLineupOnly else ''}|
+% else:
+|${'|'*(7 if not homeLineupOnly else 2)}
+% endif
+% endfor
+% endif
+% endif
diff --git a/bots/lemmy_mlb_game_threads/templates/matchup.mako b/bots/lemmy_mlb_game_threads/templates/matchup.mako
new file mode 100644
index 0000000..eb87f70
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/matchup.mako
@@ -0,0 +1,17 @@
+<%page args="gamePk,dateFormat='%a, %b %d @ %I:%M %p %Z'" />\
+## Matchup header: Team names with links to team subs and matchup image, followed by game date
+[${data[gamePk]['schedule']['teams']['away']['team']['teamName']}](${data[0]['teamSubs'].get(data[gamePk]['schedule']['teams']['away']['team']['id'], data[0]['teamSubs'][0])}) \
+%if settings.get("Bot", {}).get("SUPPRESS_MATCHUP_IMAGE", False) or data[gamePk]['schedule']['teams']['away']['team']['fileCode'] + data[gamePk]['schedule']['teams']['home']['team']['fileCode'] == 'laatl':
+@ \
+%else:
+[@](http://mlb.mlb.com/images/2017_ipad/684/${data[gamePk]['schedule']['teams']['away']['team']['fileCode'] + data[gamePk]['schedule']['teams']['home']['team']['fileCode']}_684.jpg) \
+%endif
+[${data[gamePk]['schedule']['teams']['home']['team']['teamName']}](${data[0]['teamSubs'].get(data[gamePk]['schedule']['teams']['home']['team']['id'], data[0]['teamSubs'][0])}) \
+%if data[gamePk]['schedule']['doubleHeader'] == 'Y' and data[gamePk]['schedule']['gameNumber'] == 2:
+- ${data[gamePk]['gameTime']['myTeam'].strftime('%a, %b %d')} - Doubleheader Game 2
+%else:
+- ${data[gamePk]['gameTime']['myTeam'].strftime(dateFormat)} \
+%endif
+%if data[gamePk]['schedule']['doubleHeader'] == 'S':
+- Doubleheader Game ${data[gamePk]['schedule']['gameNumber']}
+%endif
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/next_game.mako b/bots/lemmy_mlb_game_threads/templates/next_game.mako
new file mode 100644
index 0000000..f1c80a2
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/next_game.mako
@@ -0,0 +1,17 @@
+<%
+ from datetime import datetime
+ import tzlocal
+
+ if len(data[0]['myTeam']['nextGame']):
+ daysTilNextGame = (data[0]['myTeam']['nextGame']['gameTime']['bot'] - tzlocal.get_localzone().localize(datetime.today())).days
+ else:
+ daysTilNextGame = -1
+%>
+% if len(data[0]['myTeam']['nextGame']):
+**Next ${data[0]['myTeam']['teamName']} Game**: \
+${data[0]['myTeam']['nextGame']['gameTime']['myTeam'].strftime('%a, %b %d, %I:%M %p %Z')}\
+${' @ ' + data[0]['myTeam']['nextGame']['teams']['home']['team']['teamName'] if data[0]['myTeam']['nextGame']['teams']['away']['team']['id']==data[0]['myTeam']['id'] else ' vs. ' + data[0]['myTeam']['nextGame']['teams']['away']['team']['teamName']}\
+% if daysTilNextGame > 0:
+ (${daysTilNextGame} day${'s' if daysTilNextGame>1 else ''})
+% endif
+% endif
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/no-no_watch.mako b/bots/lemmy_mlb_game_threads/templates/no-no_watch.mako
new file mode 100644
index 0000000..8012945
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/no-no_watch.mako
@@ -0,0 +1,36 @@
+<%page args="gamePk=0" />
+## Games across the league that are currently no-hitters or perfect (excluding current gamePk)
+<%
+ games = (x for x in data[0]['leagueSchedule'] if data[0]['myTeam']['id'] not in [x['teams']['away']['team']['id'], x['teams']['home']['team']['id']] and ((x['status']['abstractGameCode'] == 'L' and x['status']['statusCode'] != 'PW') or (x['status']['abstractGameCode'] == 'F' and x['status']['codedGameState'] not in ['C','D'])) and x.get('linescore',{}).get('currentInning',0) > 4 and (x['flags'].get('noHitter') or x['flags'].get('perfectGame')) and x['gamePk'] != gamePk)
+%>
+% for x in games:
+% if x['flags'].get('perfectGame'):
+${'### Perfect Game Alert'}
+
+|${x['linescore']['inningState'][0] + ' ' + str(x['linescore']['currentInning']) if x['status']['abstractGameCode'] == 'L' else x['status']['detailedState']}|R|H|E|
+|:--|:--|:--|:--|
+|${x['teams']['away']['team']['teamName']}|${x['linescore']['teams']['away'].get('runs',0)}|${x['linescore']['teams']['away'].get('hits',0)}|${x['linescore']['teams']['away'].get('errors',0)}|
+|${x['teams']['home']['team']['teamName']}|${x['linescore']['teams']['home'].get('runs',0)}|${x['linescore']['teams']['home'].get('hits',0)}|${x['linescore']['teams']['home'].get('errors',0)}|
+
+% elif x['flags'].get('noHitter'):
+${'### No-Hitter Alert'}
+
+|${x['linescore']['inningState'][0] + ' ' + str(x['linescore']['currentInning']) if x['status']['abstractGameCode'] == 'L' else x['status']['detailedState']}|R|H|E|
+|:--|:--|:--|:--|
+|${x['teams']['away']['team']['teamName']}|${x['linescore']['teams']['away'].get('runs',0)}|${x['linescore']['teams']['away'].get('hits',0)}|${x['linescore']['teams']['away'].get('errors',0)}|
+|${x['teams']['home']['team']['teamName']}|${x['linescore']['teams']['home'].get('runs',0)}|${x['linescore']['teams']['home'].get('hits',0)}|${x['linescore']['teams']['home'].get('errors',0)}|
+
+% endif
+% endfor
+##${x['teams']['away']['team']['league']['abbreviation']}
+##${x['teams']['away']['team']['division']['abbreviation']}
+##${x['teams']['home']['team']['league']['abbreviation']}
+##${x['teams']['home']['team']['division']['abbreviation']}
+## codedGameState - Suspended: U, T; Cancelled: C, Postponed: D
+##${x['status']['codedGameState']}
+##${x['status']['abstractGameState']}
+##${x['status']['abstractGameCode']}
+##${x['status']['statusCode']}
+##${x['flags']['noHitter']}
+##${x['flags']['perfectGame']}
+##((x['status']['abstractGameCode'] == 'L' and x['status']['statusCode'] != 'PW') or (x['status']['abstractGameCode'] == 'F' and x['status']['codedGameState'] not in ['C','D']))
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/off_thread-footer_only.mako b/bots/lemmy_mlb_game_threads/templates/off_thread-footer_only.mako
new file mode 100644
index 0000000..626d40e
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/off_thread-footer_only.mako
@@ -0,0 +1 @@
+${settings.get('Off Day Thread',{}).get('FOOTER','')}
diff --git a/bots/lemmy_mlb_game_threads/templates/off_thread.mako b/bots/lemmy_mlb_game_threads/templates/off_thread.mako
new file mode 100644
index 0000000..fdd3ff9
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/off_thread.mako
@@ -0,0 +1,22 @@
+% if data[0]['myTeam']['seasonState'] in ['pre','regular']:
+${'**Around the Division**' if any(x for x in data[0]['leagueSchedule'] if data[0]['myTeam']['division']['id'] in [x['teams']['away']['team'].get('division',{}).get('id'), x['teams']['home']['team'].get('division',{}).get('id')]) else 'Around the Division: There are no other division teams playing!'}
+<%include file="division_scoreboard.mako" />
+
+% if data[0]['myTeam']['seasonState'] == 'regular':
+<%include file="standings.mako" />
+% endif
+% elif data[0]['myTeam']['seasonState'] in ['off:before', 'off:after']:
+
+% elif data[0]['myTeam']['seasonState'] in ['post:out', 'post:in']:
+${'### Around the League' if len(data[0]['leagueSchedule']) else 'Around the League: There are no games today!'}
+<%include file="league_scoreboard.mako" args="gamePk='off'"/>
+% endif
+
+## no-no/perfecto watch
+<%include file="no-no_watch.mako" />
+
+% if data[0]['myTeam']['seasonState'] not in ['post:out']:
+<%include file="next_game.mako" />
+% endif
+
+${settings.get('Off Day Thread',{}).get('FOOTER','')}
diff --git a/bots/lemmy_mlb_game_threads/templates/off_title.mako b/bots/lemmy_mlb_game_threads/templates/off_title.mako
new file mode 100644
index 0000000..24bbb32
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/off_title.mako
@@ -0,0 +1,10 @@
+<%
+ from datetime import datetime
+ prefix = settings.get("Off Day Thread", {}).get("TITLE_PREFIX","Off Day Thread")
+ dateFormat = settings.get("Off Day Thread", {}).get("DATE_FORMAT","%A, %B %d")
+%>\
+${data[0]['myTeam']['teamName']} \
+${prefix if data[0]['myTeam']['seasonState'] in ['pre','regular','post:in'] else ''}\
+${'Postseason Discussion Thread' if data[0]['myTeam']['seasonState'] == 'post:out' else ''}\
+${'Offseason Discussion Thread' if data[0]['myTeam']['seasonState'].startswith('off') else ''}\
+ - ${datetime.strptime(data[0]['today']['Ymd'],'%Y%m%d').strftime(dateFormat)}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/off_webhook.mako b/bots/lemmy_mlb_game_threads/templates/off_webhook.mako
new file mode 100644
index 0000000..70d3195
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/off_webhook.mako
@@ -0,0 +1,2 @@
+## This template produces data in JSON format suitable for Discord webhooks
+{"content":"**Off Day Thread Posted!**\n${theThread.title}\n${theThread.shortlink}"}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/off_webhook_telegram.mako b/bots/lemmy_mlb_game_threads/templates/off_webhook_telegram.mako
new file mode 100644
index 0000000..56944dc
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/off_webhook_telegram.mako
@@ -0,0 +1,2 @@
+## This template produces data in JSON format suitable for Telegram webhooks
+{"text":"Off Day Thread Posted!\n${theThread.title}\n${theThread.shortlink}"}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/post_game_info.mako b/bots/lemmy_mlb_game_threads/templates/post_game_info.mako
new file mode 100644
index 0000000..7cd621d
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/post_game_info.mako
@@ -0,0 +1,12 @@
+<%page args="gamePk" />
+${'### '}Links & Info
+* [MLB Gameday](https://www.mlb.com/gameday/${gamePk}/)
+##* Game Notes: [${data[gamePk]['schedule']['teams']['away']['team']['teamName']}](http://mlb.mlb.com/mlb/presspass/gamenotes.jsp?c_id=${data[gamePk]['schedule']['teams']['away']['team']['fileCode']}), [${data[gamePk]['schedule']['teams']['home']['team']['teamName']}](http://mlb.mlb.com/mlb/presspass/gamenotes.jsp?c_id=${data[gamePk]['schedule']['teams']['home']['team']['fileCode']})
+##* ${'[Strikezone Map](http://www.brooksbaseball.net/pfxVB/zoneTrack.php?month={}&day={}&year={}&game=gid_{})'.format(data[gamePk]['gameTime']['homeTeam'].strftime('%m'), data[gamePk]['gameTime']['homeTeam'].strftime('%d'), data[gamePk]['gameTime']['homeTeam'].strftime('%Y'), data[gamePk]['gumbo']['gameData']['game']['id'].replace('/','_').replace('-','_'))}
+* ${'[Game Graphs](http://www.fangraphs.com/livewins.aspx?date={}&team={}&dh={}&season={})'.format( data[gamePk]['gameTime']['homeTeam'].strftime("%Y-%m-%d"), data[gamePk]['schedule']['teams']['home']['team']['teamName'].replace(' ','%20'), data[gamePk]['schedule']['gameNumber'] if data[gamePk]['schedule']['doubleHeader']!='N' else 0, data[gamePk]['gameTime']['homeTeam'].strftime('%Y'))}
+* ${f"[Savant Gamefeed](https://baseballsavant.mlb.com/gamefeed?gamePk={gamePk})"}
+% for n in range(1, 6):
+% if settings.get('Post Game Thread',{}).get(f'LINK_{n}',''):
+* [${settings['Post Game Thread'][f'LINK_{n}'].split("|")[0]}](${settings['Post Game Thread'][f'LINK_{n}'].split("|")[1].replace('gamePk', str(gamePk))})
+% endif
+% endfor
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/post_thread-footer_only.mako b/bots/lemmy_mlb_game_threads/templates/post_thread-footer_only.mako
new file mode 100644
index 0000000..98bb64f
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/post_thread-footer_only.mako
@@ -0,0 +1 @@
+${settings.get('Post Game Thread',{}).get('FOOTER','')}
diff --git a/bots/lemmy_mlb_game_threads/templates/post_thread.mako b/bots/lemmy_mlb_game_threads/templates/post_thread.mako
new file mode 100644
index 0000000..7303b2c
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/post_thread.mako
@@ -0,0 +1,56 @@
+<%
+ from datetime import datetime
+ import pytz
+%>\
+## Header: Team names with links to team subs and matchup image, followed by game date
+${'# '}<%include file="matchup.mako" args="dateFormat='%a, %b %d'" />
+
+## Game status: show detailed state and final score
+${'### '}Game Status: ${data[gamePk]['schedule']['status']['detailedState']} \
+% if data[gamePk]['schedule']['status'].get('reason') and len(data[gamePk]['schedule']['status']['reason']) > 0 and data[gamePk]['schedule']['status']['reason'] not in data[gamePk]['schedule']['status']['detailedState']:
+due to ${data[gamePk]['schedule']['status']['reason']} \
+% endif
+% if data[gamePk]['schedule']['status']['abstractGameCode'] == 'F' and data[gamePk]['schedule']['status']['codedGameState'] not in ['C','D']:
+- <%include file="score.mako" />
+% endif
+
+## Weather, gameday link, (strikezone map commented out since it doesn't seem to have data), (game notes commented out due to new press pass requirement)
+<%include file="post_game_info.mako" />
+
+<%include file="boxscore.mako" args="boxStyle=settings.get('Post Game Thread',{}).get('BOXSCORE_STYLE','wide')" />
+
+% if not settings.get('Post Game Thread',{}).get('SUPPRESS_SCORING_PLAYS', False):
+<%include file="scoring_plays.mako" />
+
+% endif
+<%include file="highlights.mako" />
+
+<%include file="linescore.mako" />
+
+% if not settings.get('Post Game Thread',{}).get('SUPPRESS_DECISIONS', False):
+<%include file="decisions.mako" />
+
+% endif
+% if data[0]['myTeam'].get('division'): # will skip for All Star teams
+% if data[0]['myTeam']['seasonState'].startswith('post'):
+## league scoreboard during post season
+${'### Around the League' if any(x for x in data[0]['leagueSchedule'] if x['gamePk'] != gamePk) else 'Around the League: There are no other games!'}
+<%include file="league_scoreboard.mako" args="gamePk=gamePk" />
+% else:
+<%
+ wc = settings.get('Game Thread',{}).get('WILDCARD_SCOREBOARD', False)
+ wc_num = settings.get('Game Thread',{}).get('WILDCARD_NUM_TO_SHOW', 5)
+%>\
+## division scoreboard during pre and regular season
+<%include file="division_scoreboard.mako" args="gamePk=gamePk,include_wc=wc,wc_num=wc_num" />
+% endif
+% endif
+
+## no-no/perfecto watch - whole league
+<%include file="no-no_watch.mako" />
+
+## Next game
+<%include file="next_game.mako" />
+
+## Configurable footer text
+${settings.get('Post Game Thread',{}).get('FOOTER','')}
diff --git a/bots/lemmy_mlb_game_threads/templates/post_title.mako b/bots/lemmy_mlb_game_threads/templates/post_title.mako
new file mode 100644
index 0000000..ca57c48
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/post_title.mako
@@ -0,0 +1,52 @@
+<%
+ from datetime import datetime
+ prefix = settings.get("Post Game Thread", {}).get("TITLE_PREFIX","")
+ dateFormat = settings.get("Post Game Thread", {}).get("DATE_FORMAT","%a, %b %d @ %I:%M %p %Z")
+ dhDateFormat = settings.get("Post Game Thread", {}).get("DATE_FORMAT_DH","%a, %b %d")
+%>\
+${prefix + (" " if len(prefix) and not prefix.endswith(" ") else "")}\
+% if data[gamePk]['schedule'].get('seriesDescription') and data[gamePk]['schedule'].get('gameType') not in ['R','I','S','E']:
+${data[gamePk]['schedule']['seriesDescription']}\
+${' Game ' + str(data[gamePk]['schedule']['seriesGameNumber']) if data[gamePk]['schedule'].get('seriesGameNumber') and data[gamePk]['schedule'].get('gamesInSeries',1) > 1 else ''}\
+ - \
+% endif
+\
+% if data[gamePk]['schedule']['status']['codedGameState'] in ['U','T','C','D']:
+## Exception Status (codedGameState - Suspended: U, T; Cancelled: C, Postponed: D)
+${settings.get("Post Game Thread", {}).get("TITLE_PREFIX_EXCEPTION","")}The ${data[0]['myTeam']['teamName']} Game is ${data[gamePk]['schedule']['status']['detailedState']}\
+% else:
+% if data[gamePk]['schedule']['teams']['home']['score'] == data[gamePk]['schedule']['teams']['away']['score']:
+## Tie
+${settings.get("Post Game Thread", {}).get("TITLE_PREFIX_TIE","")}The ${data[0]['myTeam']['teamName']} tied the ${data[gamePk]['oppTeam']['teamName']} with a score of ${data[gamePk]['schedule']['teams'][data[gamePk]['homeAway']]['score']}-${data[gamePk]['schedule']['teams']['home' if data[gamePk]['homeAway']=='away' else 'away']['score']}\
+% elif data[gamePk]['schedule']['teams']['home']['score'] > data[gamePk]['schedule']['teams']['away']['score']:
+## Home Team Won
+% if data[gamePk]['homeAway'] == 'home':
+## Home Win
+${settings.get("Post Game Thread", {}).get("TITLE_PREFIX_HOME_WIN","")}The ${data[0]['myTeam']['teamName']} defeated the ${data[gamePk]['oppTeam']['teamName']} by a score of ${data[gamePk]['schedule']['teams'][data[gamePk]['homeAway']]['score']}-${data[gamePk]['schedule']['teams']['home' if data[gamePk]['homeAway']=='away' else 'away']['score']}\
+% else:
+## Home Loss
+${settings.get("Post Game Thread", {}).get("TITLE_PREFIX_HOME_LOSS","")}The ${data[0]['myTeam']['teamName']} fell to the ${data[gamePk]['oppTeam']['teamName']} by a score of ${data[gamePk]['schedule']['teams']['home' if data[gamePk]['homeAway']=='away' else 'away']['score']}-${data[gamePk]['schedule']['teams'][data[gamePk]['homeAway']]['score']}\
+% endif
+% elif data[gamePk]['schedule']['teams']['away']['score'] > data[gamePk]['schedule']['teams']['home']['score']:
+## Away Team Won
+% if data[gamePk]['homeAway'] == 'away':
+## Road Win
+${settings.get("Post Game Thread", {}).get("TITLE_PREFIX_ROAD_WIN","")}The ${data[0]['myTeam']['teamName']} defeated the ${data[gamePk]['oppTeam']['teamName']} by a score of ${data[gamePk]['schedule']['teams'][data[gamePk]['homeAway']]['score']}-${data[gamePk]['schedule']['teams']['home' if data[gamePk]['homeAway']=='away' else 'away']['score']}\
+% else:
+## Road Loss
+${settings.get("Post Game Thread", {}).get("TITLE_PREFIX_ROAD_LOSS","")}The ${data[0]['myTeam']['teamName']} fell to the ${data[gamePk]['oppTeam']['teamName']} by a score of ${data[gamePk]['schedule']['teams']['home' if data[gamePk]['homeAway']=='away' else 'away']['score']}-${data[gamePk]['schedule']['teams'][data[gamePk]['homeAway']]['score']}\
+% endif
+% else:
+## Unknown game state, use generic Post Game Thread title
+${data[0]['myTeam']['teamName']} Post Game Thread\
+% endif
+% endif
+\
+%if data[gamePk]['schedule']['doubleHeader'] == 'Y' and data[gamePk]['schedule']['gameNumber'] == 2:
+ - ${data[gamePk]['gameTime']['myTeam'].strftime(dhDateFormat)} - Doubleheader Game 2
+%else:
+ - ${data[gamePk]['gameTime']['myTeam'].strftime(dateFormat)}\
+%endif
+%if data[gamePk]['schedule']['doubleHeader'] == 'S':
+ - Doubleheader Game ${data[gamePk]['schedule']['gameNumber']}
+%endif
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/post_webhook.mako b/bots/lemmy_mlb_game_threads/templates/post_webhook.mako
new file mode 100644
index 0000000..077bf05
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/post_webhook.mako
@@ -0,0 +1,2 @@
+## This template produces data in JSON format suitable for Discord webhooks
+{"content":"**Post Game Thread Posted!**\n${theThread.title}\n${theThread.shortlink}"}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/post_webhook_telegram.mako b/bots/lemmy_mlb_game_threads/templates/post_webhook_telegram.mako
new file mode 100644
index 0000000..b991e59
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/post_webhook_telegram.mako
@@ -0,0 +1,2 @@
+## This template produces data in JSON format suitable for Telegram webhooks
+{"text":"Post Game Thread Posted!\n${theThread.title}\n${theThread.shortlink}"}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/probable_pitchers.mako b/bots/lemmy_mlb_game_threads/templates/probable_pitchers.mako
new file mode 100644
index 0000000..85799cd
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/probable_pitchers.mako
@@ -0,0 +1,30 @@
+<%page args="gamePk" />
+## Probable Pitchers
+<%
+ def playerLink(name, personId):
+ return '[{}](http://mlb.mlb.com/team/player.jsp?player_id={})'.format(name,str(personId))
+
+ awayPitcher = data[gamePk]['schedule']['teams']['away'].get('probablePitcher',{})
+ awayPitcherData = data[gamePk]['gumbo']["liveData"]["boxscore"]["teams"]['away']['players'].get('ID'+str(awayPitcher.get('id','')),{'person':{'fullName':'TBD'}})
+ homePitcher = data[gamePk]['schedule']['teams']['home'].get('probablePitcher',{})
+ homePitcherData = data[gamePk]['gumbo']["liveData"]["boxscore"]["teams"]['home']['players'].get('ID'+str(homePitcher.get('id','')),{'person':{'fullName':'TBD'}})
+%>\
+||Probable Pitcher (Season Stats)|Report|
+|:--|:--|:--|
+|[${data[gamePk]['gumbo']["gameData"]["teams"]['away']['teamName']}](${data[0]['teamSubs'].get(data[gamePk]['gumbo']["gameData"]["teams"]['away']['id'], data[0]['teamSubs'][0])})|\
+${playerLink(awayPitcherData['person']['fullName'],awayPitcherData['person']['id']) if awayPitcherData['person']['fullName'] != 'TBD' else 'TBD'}\
+% if awayPitcherData['person']['fullName'] != 'TBD':
+ (${awayPitcherData['seasonStats']['pitching'].get('wins',0)}-${awayPitcherData['seasonStats']['pitching'].get('losses',0)}, ${awayPitcherData['seasonStats']['pitching'].get('era','0')} ERA, ${awayPitcherData['seasonStats']['pitching'].get('inningsPitched',0)} IP)|\
+% else:
+|\
+% endif
+${awayPitcher.get('note','No report posted.')}|
+\
+|[${data[gamePk]['gumbo']["gameData"]["teams"]['home']['teamName']}](${data[0]['teamSubs'].get(data[gamePk]['gumbo']["gameData"]["teams"]['home']['id'], data[0]['teamSubs'][0])})|\
+${playerLink(homePitcherData['person']['fullName'],homePitcherData['person']['id']) if homePitcherData['person']['fullName'] != 'TBD' else 'TBD'}\
+% if homePitcherData['person']['fullName'] != 'TBD':
+ (${homePitcherData['seasonStats']['pitching'].get('wins',0)}-${homePitcherData['seasonStats']['pitching'].get('losses',0)}, ${homePitcherData['seasonStats']['pitching'].get('era','-')} ERA, ${homePitcherData['seasonStats']['pitching'].get('inningsPitched',0)} IP)|\
+% else:
+|\
+% endif
+${homePitcher.get('note','No report posted.')}|
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/score.mako b/bots/lemmy_mlb_game_threads/templates/score.mako
new file mode 100644
index 0000000..abe2f50
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/score.mako
@@ -0,0 +1,13 @@
+% if None in [data[gamePk]['schedule']['teams']['away'].get('score'), data[gamePk]['schedule']['teams']['home'].get('score')]:
+${''}\
+% else:
+Score: ${sorted([data[gamePk]['schedule']['teams']['away'].get('score', 0),data[gamePk]['schedule']['teams']['home'].get('score', 0)],reverse=True)[0]}-\
+${sorted([data[gamePk]['schedule']['teams']['away'].get('score', 0),data[gamePk]['schedule']['teams']['home'].get('score', 0)],reverse=True)[1]} \
+% if data[gamePk]['schedule']['teams']['away'].get('score', 0) > data[gamePk]['schedule']['teams']['home'].get('score', 0):
+${data[gamePk]['schedule']['teams']['away']['team']['teamName']}\
+% elif data[gamePk]['schedule']['teams']['away'].get('score', 0) < data[gamePk]['schedule']['teams']['home'].get('score', 0):
+${data[gamePk]['schedule']['teams']['home']['team']['teamName']}\
+% elif data[gamePk]['schedule']['teams']['away'].get('score', 0) == data[gamePk]['schedule']['teams']['home'].get('score', 0):
+${''}\
+% endif
+% endif
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/scoring_plays.mako b/bots/lemmy_mlb_game_threads/templates/scoring_plays.mako
new file mode 100644
index 0000000..9e55f42
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/scoring_plays.mako
@@ -0,0 +1,25 @@
+## Scoring plays
+<%
+ sortedPlays = []
+ if not len(data[gamePk]['gumbo']["liveData"]["plays"].get('scoringPlays', [])):
+ return
+ else:
+ unorderedPlays = {}
+ for i in data[gamePk]['gumbo']["liveData"]["plays"]["scoringPlays"]:
+ play = next(
+ (p for p in data[gamePk]['gumbo']["liveData"]["plays"]["allPlays"] if p.get("atBatIndex") == i),
+ None,
+ )
+ if play and play['result'].get('description'):
+ unorderedPlays.update({play["about"]["endTime"]: play})
+
+ for x in sorted(unorderedPlays):
+ sortedPlays.append(unorderedPlays[x])
+%>
+% if len(sortedPlays) > 0:
+|Inning|Scoring Play|Score|
+|:--|:--|:--|
+ % for p in sortedPlays:
+|${p['about']['halfInning'][0:1].upper() + p['about']['halfInning'][1:]} ${str(p['about']['inning'])}|${p['result']['description']}|${str(max(p['result']['awayScore'],p['result']['homeScore']))+'-'+str(min(p['result']['awayScore'],p['result']['homeScore']))}${(' ' + data[gamePk]['schedule']['teams']['away']['team']['abbreviation'] if p['result']['awayScore'] > p['result']['homeScore'] else ' ' + data[gamePk]['schedule']['teams']['home']['team']['abbreviation']) if p['result']['awayScore']!=p['result']['homeScore'] else ''}|
+ % endfor
+% endif
diff --git a/bots/lemmy_mlb_game_threads/templates/standings.mako b/bots/lemmy_mlb_game_threads/templates/standings.mako
new file mode 100644
index 0000000..be43651
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/standings.mako
@@ -0,0 +1,59 @@
+<%page args="include_wc=False,wc_num=5" />
+## Standings for myTeam's division only:
+|${data[0]['myTeam']['division']['abbreviation']} Rank|Team|W|L|GB (E#)|WC Rank|WC GB (E#)|
+|:--|:--|:--|:--|:--|:--|:--|
+% for t in data[0]['standings'][data[0]['myTeam']['division']['id']]['teams']:
+% if t['team_id'] == data[0]['myTeam']['id']:
+|**${t['div_rank']}**|**[${t['name']}](${data[0]['teamSubs'][t['team_id']]})**|**${t['w']}**|**${t['l']}**|**${t['gb']} (${t['elim_num']})**|**${t['wc_rank']}**|**${t['wc_gb']} (${t['wc_elim_num']})**|
+% else:
+|${t['div_rank']}|[${t['name']}](${data[0]['teamSubs'][t['team_id']]})|${t['w']}|${t['l']}|${t['gb']} (${t['elim_num']})|${t['wc_rank']}|${t['wc_gb']} (${t['wc_elim_num']})|
+% endif
+% endfor
+% if include_wc:
+## Wild Card standings for myTeam's league
+<%
+ wc_standings = []
+ for div_id, div_standings in data[0]['standings'].items():
+ if div_standings.get("div_name").split()[0] == data[0]['myTeam']['league']['nameShort']:
+ for div_st in div_standings.get("teams", []):
+ if div_st.get("wc_rank") != "-" and int(div_st.get("wc_rank", 0)) <= wc_num:
+ wc_standings.append(div_st)
+ wc_standings = sorted(wc_standings, key=lambda t: int(t["wc_rank"]))
+%>\
+% if len(wc_standings):
+
+|WC Rank|Team|W|L|WC GB (E#)|
+|:--|:--|:--|:--|:--|
+% for t in wc_standings:
+% if t['team_id'] == data[0]['myTeam']['id']:
+|**${t['wc_rank']}**|**[${t['name']}](${data[0]['teamSubs'][t['team_id']]})**|**${t['w']}**|**${t['l']}**|**${t['wc_gb']} (${t['wc_elim_num']})**|
+% else:
+|${t['wc_rank']}|[${t['name']}](${data[0]['teamSubs'][t['team_id']]})|${t['w']}|${t['l']}|${t['wc_gb']} (${t['wc_elim_num']})|
+% endif
+% endfor
+% endif
+% endif
+## Remove %doc tag and corresponding /%doc tag below to uncomment
+<%doc>
+## Standings for other divisions in myTeam's league
+% for div in (data[0]['standings'][x] for x in data[0]['standings'] if data[0]['standings'][x]['div_name'].split()[0] == data[0]['myTeam']['league']['nameShort'] and data[0]['standings'][x]['div_name'] != data[0]['myTeam']['division']['name']):
+
+|${''.join([w[0] for w in div['div_name'].split()])} Rank|Team|W|L|GB (E#)|WC Rank|WC GB (E#)|
+|:--|:--|:--|:--|:--|:--|:--|:--|
+% for t in div['teams']:
+|${t['div_rank']}|[${t['name']}](${data[0]['teamSubs'][t['team_id']]})|${t['w']}|${t['l']}|${t['gb']} (${t['elim_num']})|${t['wc_rank']}|${t['wc_gb']} (${t['wc_elim_num']})|
+% endfor
+% endfor
+%doc>
+## Remove %doc tag and corresponding /%doc tag below to uncomment
+<%doc>
+## Standings for other league
+% for div in (data[0]['standings'][x] for x in data[0]['standings'] if data[0]['standings'][x]['div_name'].split()[0] != data[0]['myTeam']['league']['nameShort']):
+
+|${''.join([w[0] for w in div['div_name'].split()])} Rank|Team|W|L|GB (E#)|WC Rank|WC GB (E#)|
+|:--|:--|:--|:--|:--|:--|:--|:--|
+% for t in div['teams']:
+|${t['div_rank']}|[${t['name']}](${data[0]['teamSubs'][t['team_id']]})|${t['w']}|${t['l']}|${t['gb']} (${t['elim_num']})|${t['wc_rank']}|${t['wc_gb']} (${t['wc_elim_num']})|
+% endfor
+% endfor
+%doc>
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/trash_thread-footer_only.mako b/bots/lemmy_mlb_game_threads/templates/trash_thread-footer_only.mako
new file mode 100644
index 0000000..28c7180
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/trash_thread-footer_only.mako
@@ -0,0 +1 @@
+${settings.get('Game Thread',{}).get('FOOTER','')}
diff --git a/bots/lemmy_mlb_game_threads/templates/trash_thread.mako b/bots/lemmy_mlb_game_threads/templates/trash_thread.mako
new file mode 100644
index 0000000..c3413d3
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/trash_thread.mako
@@ -0,0 +1,136 @@
+<%
+ from datetime import datetime
+ import pytz
+%>\
+## Header: Team names with links to team subs and matchup image, followed by game date
+${'# '}<%include file="matchup.mako" args="dateFormat='%a, %b %d'" />
+
+## Game status: show detailed state and then list first pitch time if game hasn't started yet and isn't final
+${'### '}Game Status: ${data[gamePk]['schedule']['status']['detailedState']} \
+% if data[gamePk]['schedule']['status'].get('reason') and len(data[gamePk]['schedule']['status']['reason']) > 0 and data[gamePk]['schedule']['status']['reason'] not in data[gamePk]['schedule']['status']['detailedState']:
+due to ${data[gamePk]['schedule']['status']['reason']} \
+% endif
+% if data[gamePk]['gameTime']['utc'] > datetime.utcnow().replace(tzinfo=pytz.utc) and data[gamePk]['schedule']['status']['abstractGameCode'] != 'F':
+%if not (data[gamePk]['schedule']['doubleHeader'] == 'Y' and data[gamePk]['schedule']['gameNumber'] == 2):
+- First Pitch is scheduled for ${data[gamePk]['gameTime']['myTeam'].strftime('%I:%M %p %Z')}
+
+% endif
+% elif (data[gamePk]['schedule']['status']['abstractGameCode'] == 'L' and data[gamePk]['schedule']['status']['statusCode'] != 'PW') or (data[gamePk]['schedule']['status']['abstractGameCode'] == 'F' and data[gamePk]['schedule']['status']['codedGameState'] not in ['C','D']):
+## Game status is live and not warmup, so include info about inning and outs on the game status line
+- <%include file="score.mako" /> \
+% if data[gamePk]['schedule']['status']['abstractGameCode'] != 'F':
+## Only include inning if game is in progress (status!=F since we're already inside the other condition)
+
+% if data[gamePk]['schedule']['linescore']['inningState'] == "Middle" and data[gamePk]['schedule']['linescore']['currentInningOrdinal'] == "7th":
+Seventh inning stretch\
+% else:
+${data[gamePk]['schedule']['linescore']['inningState']} of the ${data[gamePk]['schedule']['linescore']['currentInningOrdinal']}\
+% endif
+## current pitcher/batter matchup, count, on deck, in hole -- or due up
+<%
+ currentPlay = data[gamePk]['gumbo']["liveData"].get('plays',{}).get('currentPlay',{})
+ offense = data[gamePk]['gumbo']["liveData"].get('linescore',{}).get('offense',{})
+ defense = data[gamePk]['gumbo']["liveData"].get('linescore',{}).get('defense',{})
+ outs =currentPlay.get('count',{}).get('outs','0')
+ comingUpTeamName = data[gamePk]['schedule']['teams']['away']['team']['teamName'] if data[gamePk]['schedule']['teams']['away']['team']['id'] == offense.get('team',{}).get('id') else data[gamePk]['schedule']['teams']['home']['team']['teamName']
+%>\
+% if outs == 3:
+% if offense.get('batter',{}).get('fullName'):
+## due up batter is in the data, so include them
+${' '}with ${offense.get('batter',{}).get('fullName','*Unknown*')}, \
+${offense.get('onDeck',{}).get('fullName','*Unknown')}, \
+and ${offense.get('inHole',{}).get('fullName','*Unknown')} \
+due up for the ${comingUpTeamName}
+% else:
+## due up batter is not in the data, so just put the team name due up
+with the ${comingUpTeamName} coming up to bat
+% endif
+% else:
+## else condition from if outs == 3--inning is in progress, so list outs, runners, matchup, on deck, and in hole
+## Outs
+, ${data[gamePk]['schedule']['linescore']['outs']} out${'s' if data[gamePk]['schedule']['linescore']['outs'] != 1 else ''}\
+<%
+ runners = (
+ "bases empty" if not offense.get('first') and not offense.get('second') and not offense.get('third')
+ else "runner on first" if offense.get('first') and not offense.get('second') and not offense.get('third')
+ else "runner on second" if not offense.get('first') and offense.get('second') and not offense.get('third')
+ else "runner on third" if not offense.get('first') and not offense.get('second') and offense.get('third')
+ else "runners on first and second" if offense.get('first') and offense.get('second') and not offense.get('third')
+ else "runners on first and third" if offense.get('first') and not offense.get('second') and offense.get('third')
+ else "runners on second and third" if not offense.get('first') and offense.get('second') and offense.get('third')
+ else "bases loaded" if offense.get('first') and offense.get('second') and offense.get('third')
+ else None
+ )
+ ondeck = offense.get('onDeck',{}).get('fullName')
+ inthehole = offense.get('inHole',{}).get('fullName')
+%>\
+${(', ' + runners) if runners else ''}\
+## Count
+, ${currentPlay.get('count',{}).get('balls','0')}-${currentPlay.get('count',{}).get('strikes','0')} count \
+## Matchup
+with ${currentPlay.get('matchup',{}).get('pitcher',{}).get('fullName','*Unknown*')} pitching and ${currentPlay.get('matchup',{}).get('batter',{}).get('fullName','*Unknown*')} batting. \
+## Ondeck
+% if ondeck:
+${ondeck} is on deck\
+## In hole
+% if inthehole:
+, and ${inthehole} is in the hole.
+% endif
+% endif
+% endif
+% endif
+% endif
+
+${'### '} Trash Talk Thread Rules
+
+* Trash talk stays in this thread only, retaliating in other subs will result in a permanent ban
+* Fans of all teams are welcome!
+* Trash talk != direct personal insults
+* No rooting for injuries
+* No politics
+* Upvote good trash talk
+* Don’t downvote just because someone roots for another team
+* Flair up, or else you will be mocked for being naked
+
+## Broadcasts, gameday link, (strikezone map commented out since it doesn't seem to have data), (game notes commented out due to new press pass requirement)
+<%include file="game_info.mako" />
+
+## Game status is not final or live (except warmup), include standings, probable pitchers, lineups if posted
+% if (data[gamePk]['schedule']['status']['abstractGameCode'] != 'L' or data[gamePk]['schedule']['status']['statusCode'] == 'PW') and data[gamePk]['schedule']['status']['abstractGameCode'] != 'F':
+% if data[0]['myTeam']['seasonState'] == 'regular' and data[gamePk]['schedule']['gameType'] == "R":
+<%include file="standings.mako" />
+% endif
+
+<%include file="probable_pitchers.mako" />
+
+<%include file="lineups.mako" args="boxStyle=settings.get('Game Thread',{}).get('BOXSCORE_STYLE','wide')" />
+% endif
+
+
+## If game status is final, or live and not warmup, include boxscore, scoring plays, highlights, and linescore
+% if (data[gamePk]['schedule']['status']['abstractGameCode'] == 'L' and data[gamePk]['schedule']['status']['statusCode'] != 'PW') or data[gamePk]['schedule']['status']['abstractGameCode'] == 'F':
+<%include file="boxscore.mako" args="boxStyle=settings.get('Game Thread',{}).get('BOXSCORE_STYLE','wide')" />
+
+<%include file="scoring_plays.mako" />
+
+<%include file="highlights.mako" />
+
+<%include file="linescore.mako" />
+% endif
+
+% if data[0]['myTeam']['seasonState'] != 'post:in':
+## division scoreboard
+${'### Around the Division' if any(x for x in data[0]['leagueSchedule'] if data[0]['myTeam']['division']['id'] in [x['teams']['away']['team'].get('division',{}).get('id'), x['teams']['home']['team'].get('division',{}).get('id')] and x['gamePk'] != gamePk) else 'Around the Division: There are no other division teams playing!'}
+<%include file="division_scoreboard.mako" args="gamePk=gamePk" />
+% else:
+## league scoreboard
+${'### Around the League' if any(x for x in data[0]['leagueSchedule'] if x['gamePk'] != gamePk) else 'Around the League: There are no other games!'}
+<%include file="league_scoreboard.mako" args="gamePk=gamePk" />
+% endif
+
+## no-no/perfecto watch
+<%include file="no-no_watch.mako" />
+
+
+## configurable footer text
+${settings.get('Game Thread',{}).get('FOOTER','')}
diff --git a/bots/lemmy_mlb_game_threads/templates/weekly_thread-footer_only.mako b/bots/lemmy_mlb_game_threads/templates/weekly_thread-footer_only.mako
new file mode 100644
index 0000000..8dddc1b
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/weekly_thread-footer_only.mako
@@ -0,0 +1 @@
+${settings.get('Weekly Thread',{}).get('FOOTER','')}
diff --git a/bots/lemmy_mlb_game_threads/templates/weekly_thread.mako b/bots/lemmy_mlb_game_threads/templates/weekly_thread.mako
new file mode 100644
index 0000000..3566e5c
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/weekly_thread.mako
@@ -0,0 +1,3 @@
+<%include file="next_game.mako" />
+
+${settings.get('Weekly Thread',{}).get('FOOTER','')}
diff --git a/bots/lemmy_mlb_game_threads/templates/weekly_title.mako b/bots/lemmy_mlb_game_threads/templates/weekly_title.mako
new file mode 100644
index 0000000..458fb50
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/weekly_title.mako
@@ -0,0 +1,9 @@
+<%
+ from datetime import datetime
+ dateFormat = settings.get("Weekly Thread", {}).get("DATE_FORMAT","%A, %B %d")
+%>\
+Weekly ${data[0]['myTeam']['teamName']} \
+${'Discussion Thread' if data[0]['myTeam']['seasonState'] in ['pre','regular','post:in'] else ''}\
+${'Postseason Discussion Thread' if data[0]['myTeam']['seasonState'] == 'post:out' else ''}\
+${'Offseason Discussion Thread' if data[0]['myTeam']['seasonState'].startswith('off') else ''}\
+ - ${datetime.strptime(data[0]['today']['Ymd'],'%Y%m%d').strftime(dateFormat)}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/weekly_webhook.mako b/bots/lemmy_mlb_game_threads/templates/weekly_webhook.mako
new file mode 100644
index 0000000..87d3a19
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/weekly_webhook.mako
@@ -0,0 +1,2 @@
+## This template produces data in JSON format suitable for Discord webhooks
+{"content":"**Weekly Thread Posted!**\n${theThread.title}\n${theThread.shortlink}"}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads/templates/weekly_webhook_telegram.mako b/bots/lemmy_mlb_game_threads/templates/weekly_webhook_telegram.mako
new file mode 100644
index 0000000..b26aaab
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads/templates/weekly_webhook_telegram.mako
@@ -0,0 +1,2 @@
+## This template produces data in JSON format suitable for Telegram webhooks
+{"text":"Weekly Thread Posted!\n${theThread.title}\n${theThread.shortlink}"}
\ No newline at end of file
diff --git a/bots/lemmy_mlb_game_threads_config.json b/bots/lemmy_mlb_game_threads_config.json
new file mode 100644
index 0000000..05b7383
--- /dev/null
+++ b/bots/lemmy_mlb_game_threads_config.json
@@ -0,0 +1,1322 @@
+{
+ "Game Day Thread": [
+ {
+ "key": "BOXSCORE_STYLE",
+ "description": "Boxscore layout for game day threads. Wide: traditional side-by-side layout. Stacked: Away box first, then Home box below.",
+ "type": "str",
+ "val": "Stacked",
+ "options": [
+ "None",
+ "Wide",
+ "Stacked"
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "ENABLED",
+ "description": "Enable posting of game day threads.",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "FLAIR",
+ "description": "Flair to add to game day thread post.",
+ "type": "str",
+ "val": "Game Day Thread",
+ "options": [],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "LIVE_DISCUSSION",
+ "description": "Submit posts as `live discussions` instead of traditional comment threads on new Reddit (old Reddit will still show traditional comment threads).",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "FOOTER",
+ "description": "Footer text to include at the bottom of the thread.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "POST_TIME",
+ "description": "Time of day when game day thread should be posted. Include zero-padded hour and minute in 24 hour time, e.g. 08:00 or 14:30.",
+ "type": "str",
+ "val": "05:00",
+ "options": [],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "SUGGESTED_SORT",
+ "description": "Suggested sort to set on the game day thread post.",
+ "type": "str",
+ "val": "new",
+ "options": [
+ "",
+ "confidence",
+ "top",
+ "new",
+ "controversial",
+ "old",
+ "random",
+ "qa"
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "SUPPRESS_MINUTES",
+ "description": "Game day thread will be skipped if game thread will be posted within this many minutes. Set to 0 to suppress game day thread only if it is already time to post game thread. Set to -1 to never suppress game day thread.",
+ "type": "int",
+ "val": 60,
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "THREAD_TEMPLATE",
+ "description": "Template for the thread body. Don't modify the standard template file because your changes will be lost when updating. Make a copy instead and put the filename here. Path: /redball/bots/game_threads/templates/",
+ "type": "str",
+ "val": "gameday_thread.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "TITLE_PREFIX",
+ "description": "Prefix to include at the beginning of the thread title, after the team name. Trailing space and separator dash will be added.",
+ "type": "str",
+ "val": "Game Day Thread",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "TITLE_TEMPLATE",
+ "description": "Template for the post title. Don't modify the standard template file because your changes will be lost when updating. Make a copy instead and put the filename here. Path: /redball/bots/game_threads/templates/",
+ "type": "str",
+ "val": "gameday_title.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "UPDATE_INTERVAL",
+ "description": "Number of minutes between game day thread updates.",
+ "type": "int",
+ "val": 5,
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "UPDATE_UNTIL",
+ "description": "Keep updating the thread until the selected threshold is reached.",
+ "type": "str",
+ "val": "Game thread is posted",
+ "options": [
+ "Do not update",
+ "Game thread is posted",
+ "My team's games are final",
+ "All division games are final",
+ "All MLB games are final"
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "WEBHOOK_URL",
+ "description": "URL to call when thread is posted.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WEBHOOK_TEMPLATE",
+ "description": "Template to parse for body of call to WEBHOOK_URL.",
+ "type": "str",
+ "val": "gameday_webhook.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WILDCARD_NUM_TO_SHOW",
+ "description": "Number of wild card teams to consider for WILDCARD_SCOREBOARD and WILDCARD_STANDINGS.",
+ "type": "int",
+ "val": 5,
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WILDCARD_SCOREBOARD",
+ "description": "Include top wildcard teams' games along with division scoreboard.",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "WILDCARD_STANDINGS",
+ "description": "Include wild card standings.",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ }
+ ],
+ "Game Thread": [
+ {
+ "key": "BOXSCORE_STYLE",
+ "description": "Boxscore layout for game and post game threads. Wide: traditional side-by-side layout. Stacked: Away box first, then Home box below (both batting boxes and then both pitching boxes).",
+ "type": "str",
+ "val": "Stacked",
+ "options": [
+ "None",
+ "Wide",
+ "Stacked"
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "ENABLED",
+ "description": "Enable posting of game threads.",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "FLAIR",
+ "description": "Flair to add to game thread post.",
+ "type": "str",
+ "val": "Game Thread",
+ "options": [],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "LIVE_DISCUSSION",
+ "description": "Submit posts as `live discussions` instead of traditional comment threads on new Reddit (old Reddit will still show traditional comment threads).",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "LOCK_GAMEDAY_THREAD",
+ "description": "Lock game day thread when the game thread is submitted.",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "LINK_IN_GAMEDAY_THREAD",
+ "description": "Post a stickied comment in the game day thread, linking to the game thread, when the game thread is submitted. Add a setting with key=GAMEDAY_THREAD_MESSAGE to override the comment text; use markdown format for the link and just put (link)--e.g. [game thread](link).",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "FOOTER",
+ "description": "Footer text to include at the bottom of the thread.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "MINUTES_BEFORE",
+ "description": "How many minutes before the scheduled game start time to post the game thread.",
+ "type": "int",
+ "val": 120,
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "POST_BY",
+ "description": "Time of day by which game thread should be posted, if the configured time comes before MINUTES_BEFORE setting. Include zero-padded hour and minute in 24 hour time, e.g. 08:00 or 14:30.",
+ "type": "str",
+ "val": "19:30",
+ "options": [],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "SUGGESTED_SORT",
+ "description": "Suggested sort to set on the game thread post.",
+ "type": "str",
+ "val": "new",
+ "options": [
+ "",
+ "confidence",
+ "top",
+ "new",
+ "controversial",
+ "old",
+ "random",
+ "qa"
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "THREAD_TEMPLATE",
+ "description": "Template for the thread body. Don't modify the standard template file because your changes will be lost when updating. Make a copy instead and put the filename here. Path: /redball/bots/game_threads/templates/",
+ "type": "str",
+ "val": "game_thread.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "TITLE_PREFIX",
+ "description": "Prefix to include at the beginning of the thread title (include : or - if desired). Trailing space will be added if not blank.",
+ "type": "str",
+ "val": "Game Thread:",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "TITLE_TEMPLATE",
+ "description": "Template for the post title. Don't modify the standard template file because your changes will be lost when updating. Make a copy instead and put the filename here. Path: /redball/bots/game_threads/templates/",
+ "type": "str",
+ "val": "game_title.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "UPDATE_INTERVAL",
+ "description": "Number of seconds between game thread updates.",
+ "type": "int",
+ "val": 10,
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "UPDATE_INTERVAL_NOT_LIVE",
+ "description": "Number of minutes between game thread updates while the game is not live.",
+ "type": "int",
+ "val": 1,
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "UPDATE_UNTIL",
+ "description": "Keep updating the thread until the selected threshold is reached.",
+ "type": "str",
+ "val": "All MLB games are final",
+ "options": [
+ "Do not update",
+ "My team's games are final",
+ "All division games are final",
+ "All MLB games are final"
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "MSG_BASEBALLBOT",
+ "description": "Send a message to BaseballBot with a link to the game thread. Disable while testing!",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WEBHOOK_URL",
+ "description": "URL to call when thread is posted.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WEBHOOK_TEMPLATE",
+ "description": "Template to parse for body of call to WEBHOOK_URL.",
+ "type": "str",
+ "val": "game_webhook.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WILDCARD_NUM_TO_SHOW",
+ "description": "Number of wild card teams to consider for WILDCARD_SCOREBOARD and WILDCARD_STANDINGS.",
+ "type": "int",
+ "val": 5,
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WILDCARD_SCOREBOARD",
+ "description": "Include top wildcard teams' games along with division scoreboard.",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "WILDCARD_STANDINGS",
+ "description": "Include wild card standings.",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ }
+ ],
+ "Comments": [
+ {
+ "key": "ENABLED",
+ "description": "Submit comments in game threads for notable plays.",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "MYTEAM_BATTING_HEADER",
+ "description": "Header text to include at the top of each comment when my team is batting.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "MYTEAM_BATTING_FOOTER",
+ "description": "Footer text to include at the bottom of each comment when my team is batting.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "MYTEAM_PITCHING_HEADER",
+ "description": "Header text to include at the top of each comment when my team is pitching.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "MYTEAM_PITCHING_FOOTER",
+ "description": "Footer text to include at the bottom of each comment when my team is pitching.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "MYTEAM_PITCHING_EVENTS",
+ "description": "Events that will trigger comments while configured team is pitching. Separate multiple events with commas. Event codes can be found at https://statsapi.mlb.com/api/v1/eventTypes. Also available: 'scoring_play'.",
+ "type": "list",
+ "val": "pitching_substitution, strikeout_triple_play, triple_play",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "MYTEAM_BATTING_EVENTS",
+ "description": "Events that will trigger comments while configured team is batting. Separate multiple events with commas. Event codes can be found at https://statsapi.mlb.com/api/v1/eventTypes. Also available: 'scoring_play'.",
+ "type": "list",
+ "val": "scoring_play, pitching_substitution",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "BODY_TEMPLATE",
+ "description": "Template for the comment body. Don't modify the standard template file because your changes will be lost when updating. Make a copy instead and put the filename here. Path: /redball/bots/game_threads/templates/",
+ "type": "str",
+ "val": "comment_body.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WEBHOOK_URL",
+ "description": "URL to call when comment is posted.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WEBHOOK_TEMPLATE",
+ "description": "Template to parse for body of call to WEBHOOK_URL.",
+ "type": "str",
+ "val": "comment_webhook.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ }
+ ],
+ "Logging": [
+ {
+ "key": "CONSOLE_LOG_LEVEL",
+ "description": "Console Log Level",
+ "type": "str",
+ "val": "INFO",
+ "options": [
+ "DEBUG",
+ "INFO",
+ "WARNING",
+ "ERROR"
+ ],
+ "subkeys": [],
+ "parent_key": "LOG_TO_CONSOLE"
+ },
+ {
+ "key": "FILE_LOG_LEVEL",
+ "description": "File Log Level",
+ "type": "str",
+ "val": "DEBUG",
+ "options": [
+ "DEBUG",
+ "INFO",
+ "WARNING",
+ "ERROR"
+ ],
+ "subkeys": [],
+ "parent_key": "LOG_TO_FILE"
+ },
+ {
+ "key": "LOG_TO_CONSOLE",
+ "description": "Log to Console",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [
+ "CONSOLE_LOG_LEVEL"
+ ],
+ "parent_key": ""
+ },
+ {
+ "key": "LOG_TO_FILE",
+ "description": "Log to File",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [
+ "FILE_LOG_LEVEL",
+ "LOG_RETENTION"
+ ],
+ "parent_key": ""
+ },
+ {
+ "key": "LOG_RETENTION",
+ "description": "Log File Retention (Days)",
+ "type": "int",
+ "val": 7,
+ "options": [],
+ "subkeys": [],
+ "parent_key": "LOG_TO_FILE"
+ },
+ {
+ "key": "PROPAGATE",
+ "description": "Propagate Logs",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": ""
+ }
+ ],
+ "MLB": [
+ {
+ "key": "API_CACHE_SECONDS",
+ "description": "Number of seconds to cache data from MLB Stats API",
+ "type": "int",
+ "val": 5,
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "GAME_DATE_OVERRIDE",
+ "description": "Leave blank in most cases. Use this to force the bot to treat a specific date as 'today', and then go back to current day. Date format: %Y-%m-%d, e.g. 2019-10-25.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "TEAM",
+ "description": "My Team",
+ "type": "str",
+ "val": "",
+ "options": [
+ "",
+ "Arizona Diamondbacks|109",
+ "Atlanta Braves|144",
+ "Baltimore Orioles|110",
+ "Boston Red Sox|111",
+ "Chicago Cubs|112",
+ "Chicago White Sox|145",
+ "Cincinnati Reds|113",
+ "Cleveland Guardians|114",
+ "Colorado Rockies|115",
+ "Detroit Tigers|116",
+ "Houston Astros|117",
+ "Kansas City Royals|118",
+ "Los Angeles Angels|108",
+ "Los Angeles Dodgers|119",
+ "Miami Marlins|146",
+ "Milwaukee Brewers|158",
+ "Minnesota Twins|142",
+ "New York Mets|121",
+ "New York Yankees|147",
+ "Oakland Athletics|133",
+ "Philadelphia Phillies|143",
+ "Pittsburgh Pirates|134",
+ "San Diego Padres|135",
+ "San Francisco Giants|137",
+ "Seattle Mariners|136",
+ "St. Louis Cardinals|138",
+ "Tampa Bay Rays|139",
+ "Texas Rangers|140",
+ "Toronto Blue Jays|141",
+ "Washington Nationals|120",
+ "American League All-Stars|159",
+ "National League All-Stars|160"
+ ],
+ "subkeys": [],
+ "parent_key": ""
+ }
+ ],
+ "Off Day Thread": [
+ {
+ "key": "ENABLED",
+ "description": "Enable posting of off day threads.",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "FLAIR",
+ "description": "Flair to add to off day thread post.",
+ "type": "str",
+ "val": "Off Day Thread",
+ "options": [],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "LIVE_DISCUSSION",
+ "description": "Submit posts as `live discussions` instead of traditional comment threads on new Reddit (old Reddit will still show traditional comment threads).",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "FOOTER",
+ "description": "Footer text to include at the bottom of the thread.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "POST_TIME",
+ "description": "Time of day when off day thread should be posted. Include zero-padded hour and minute in 24 hour time, e.g. 08:00 or 14:30.",
+ "type": "str",
+ "val": "05:00",
+ "options": [],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "SUGGESTED_SORT",
+ "description": "Suggested sort to set on the off day thread post.",
+ "type": "str",
+ "val": "new",
+ "options": [
+ "",
+ "confidence",
+ "top",
+ "new",
+ "controversial",
+ "old",
+ "random",
+ "qa"
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "SUPPRESS_OFFSEASON",
+ "description": "Suppress off day threads during the offseason (including postseason if eliminated).",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "THREAD_TEMPLATE",
+ "description": "Template for the thread body. Don't modify the standard template file because your changes will be lost when updating. Make a copy instead and put the filename here. Path: /redball/bots/game_threads/templates/",
+ "type": "str",
+ "val": "off_thread.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "TITLE_PREFIX",
+ "description": "Prefix to include at the beginning of the thread title, after the team name. Applies only during regular season, or post season while still in contention. Trailing space and separator dash will be added.",
+ "type": "str",
+ "val": "Off Day Thread",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "TITLE_TEMPLATE",
+ "description": "Template for the post title. Don't modify the standard template file because your changes will be lost when updating. Make a copy instead and put the filename here. Path: /redball/bots/game_threads/templates/",
+ "type": "str",
+ "val": "off_title.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "UPDATE_INTERVAL",
+ "description": "Number of minutes between off day thread updates.",
+ "type": "int",
+ "val": 5,
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "UPDATE_UNTIL",
+ "description": "Keep updating the thread until the selected threshold is reached.",
+ "type": "str",
+ "val": "All division games are final",
+ "options": [
+ "Do not update",
+ "All division games are final",
+ "All MLB games are final"
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "WEBHOOK_URL",
+ "description": "URL to call when thread is posted.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WEBHOOK_TEMPLATE",
+ "description": "Template to parse for body of call to WEBHOOK_URL.",
+ "type": "str",
+ "val": "off_webhook.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ }
+ ],
+ "Post Game Thread": [
+ {
+ "key": "BOXSCORE_STYLE",
+ "description": "Boxscore layout for post game threads. Wide: traditional side-by-side layout. Stacked: Away box first, then Home box below (both batting boxes and then both pitching boxes).",
+ "type": "str",
+ "val": "Stacked",
+ "options": [
+ "None",
+ "Wide",
+ "Stacked"
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "ENABLED",
+ "description": "Enable posting of post game threads.",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "FLAIR",
+ "description": "Flair to add to post game thread post.",
+ "type": "str",
+ "val": "Post Game Thread",
+ "options": [],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "LIVE_DISCUSSION",
+ "description": "Submit posts as `live discussions` instead of traditional comment threads on new Reddit (old Reddit will still show traditional comment threads).",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "LOCK_GAME_THREAD",
+ "description": "Lock game thread when the post game thread is submitted.",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "LINK_IN_GAME_THREAD",
+ "description": "Post a stickied comment in the game thread, linking to the post game thread, when the post game thread is submitted. Add a setting with key=GAME_THREAD_MESSAGE to override the comment text; use markdown format for the link and just put (link)--e.g. [post game thread](link).",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "FOOTER",
+ "description": "Footer text to include at the bottom of the thread.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "SUGGESTED_SORT",
+ "description": "Suggested sort to set on the post game thread post.",
+ "type": "str",
+ "val": "new",
+ "options": [
+ "",
+ "confidence",
+ "top",
+ "new",
+ "controversial",
+ "old",
+ "random",
+ "qa"
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "THREAD_TEMPLATE",
+ "description": "Template for the thread body. Don't modify the standard template file because your changes will be lost when updating. Make a copy instead and put the filename here. Path: /redball/bots/game_threads/templates/",
+ "type": "str",
+ "val": "post_thread.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "TITLE_PREFIX",
+ "description": "Prefix to include at the beginning of the thread title (include : or - if desired). Trailing space will be added if not blank.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "TITLE_TEMPLATE",
+ "description": "Template for the post title. Don't modify the standard template file because your changes will be lost when updating. Make a copy instead and put the filename here. Path: /redball/bots/game_threads/templates/",
+ "type": "str",
+ "val": "post_title.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "UPDATE_INTERVAL",
+ "description": "Number of minutes between post game thread updates.",
+ "type": "int",
+ "val": 5,
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "UPDATE_UNTIL",
+ "description": "Keep updating the thread until the selected threshold is reached.",
+ "type": "str",
+ "val": "An hour after thread is posted",
+ "options": [
+ "Do not update",
+ "An hour after thread is posted",
+ "All division games are final",
+ "All MLB games are final"
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "WEBHOOK_URL",
+ "description": "URL to call when thread is posted.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WEBHOOK_TEMPLATE",
+ "description": "Template to parse for body of call to WEBHOOK_URL.",
+ "type": "str",
+ "val": "post_webhook.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ }
+ ],
+ "Reddit": [
+ {
+ "key": "FLAIR_MODE",
+ "description": "Method of adding flair to posts. Depends on subreddit configuration.",
+ "type": "str",
+ "val": "mod",
+ "options": [
+ "mod",
+ "submitter",
+ ""
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "INBOX_REPLIES",
+ "description": "Subscribe to inbox replies for submitted posts and comments.",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "STICKY",
+ "description": "Sticky submitted posts (requires mod rights in the subreddit).",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "SUBREDDIT",
+ "description": "Subreddit where posts should be submitted.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": null
+ }
+ ],
+ "Bot": [
+ {
+ "key": "TEMPLATE_PATH",
+ "description": "Path where custom templates are stored (defaults to the same path as the standard templates).",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": null
+ }
+ ],
+ "Weekly Thread": [
+ {
+ "key": "ENABLED",
+ "description": "Enable posting of weekly threads.",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "FLAIR",
+ "description": "Flair to add to weekly thread post.",
+ "type": "str",
+ "val": "Weekly Thread",
+ "options": [],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "LIVE_DISCUSSION",
+ "description": "Submit posts as `live discussions` instead of traditional comment threads on new Reddit (old Reddit will still show traditional comment threads).",
+ "type": "bool",
+ "val": false,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "FOOTER",
+ "description": "Footer text to include at the bottom of the thread.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "POST_TIME",
+ "description": "Time of day each Monday when weekly thread should be posted. Include zero-padded hour and minute in 24 hour time, e.g. 08:00 or 14:30.",
+ "type": "str",
+ "val": "05:00",
+ "options": [],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "SUGGESTED_SORT",
+ "description": "Suggested sort to set on the weekly thread post.",
+ "type": "str",
+ "val": "new",
+ "options": [
+ "",
+ "confidence",
+ "top",
+ "new",
+ "controversial",
+ "old",
+ "random",
+ "qa"
+ ],
+ "subkeys": [],
+ "parent_key": null
+ },
+ {
+ "key": "OFFSEASON_ONLY",
+ "description": "Submit weekly threads only during the offseason (including postseason if eliminated).",
+ "type": "bool",
+ "val": true,
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "THREAD_TEMPLATE",
+ "description": "Template for the thread body. Don't modify the standard template file because your changes will be lost when updating. Make a copy instead and put the filename here. Path: /redball/bots/game_threads/templates/",
+ "type": "str",
+ "val": "weekly_thread.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "TITLE_TEMPLATE",
+ "description": "Template for the post title. Don't modify the standard template file because your changes will be lost when updating. Make a copy instead and put the filename here. Path: /redball/bots/game_threads/templates/",
+ "type": "str",
+ "val": "weekly_title.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WEBHOOK_URL",
+ "description": "URL to call when thread is posted.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "WEBHOOK_TEMPLATE",
+ "description": "Template to parse for body of call to WEBHOOK_URL.",
+ "type": "str",
+ "val": "weekly_webhook.mako",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ }
+ ],
+ "Prowl": [
+ {
+ "key": "ERROR_API_KEY",
+ "description": "API Key for sending error notifications to Prowl.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [
+ "ERROR_PRIORITY"
+ ],
+ "parent_key": ""
+ },
+ {
+ "key": "ERROR_PRIORITY",
+ "description": "Priority when sending error notifications to Prowl (leave blank to disable).",
+ "type": "str",
+ "val": "",
+ "options": [
+ "",
+ "-2",
+ "-1",
+ "0",
+ "1",
+ "2"
+ ],
+ "subkeys": [],
+ "parent_key": "ERROR_API_KEY"
+ },
+ {
+ "key": "THREAD_POSTED_API_KEY",
+ "description": "API Key for sending thread posted notifications to Prowl.",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [
+ "THREAD_POSTED_PRIORITY"
+ ],
+ "parent_key": ""
+ },
+ {
+ "key": "THREAD_POSTED_PRIORITY",
+ "description": "Priority when sending thread posted notifications to Prowl (leave blank to disable).",
+ "type": "str",
+ "val": "",
+ "options": [
+ "",
+ "-2",
+ "-1",
+ "0",
+ "1",
+ "2"
+ ],
+ "subkeys": [],
+ "parent_key": "THREAD_POSTED_API_KEY"
+ }
+ ],
+ "Twitter": [
+ {
+ "key": "TWEET_THREAD_POSTED",
+ "description": "Tweet when threads are posted (see wiki for info).",
+ "type": "bool",
+ "val": "false",
+ "options": [
+ true,
+ false
+ ],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "CONSUMER_KEY",
+ "description": "Twitter Consumer Key (see wiki)",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "CONSUMER_SECRET",
+ "description": "Twitter Consumer Secret (see wiki)",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "ACCESS_TOKEN",
+ "description": "Twitter Access Token (see wiki)",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "ACCESS_SECRET",
+ "description": "Twitter Access Secret (see wiki)",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ }
+ ],
+ "Lemmy": [
+ {
+ "key": "INSTANCE_NAME",
+ "description": "Full instance domain name (including protocol)",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "COMMUNITY_NAME",
+ "description": "Name of lemmy community to target",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "POST_CHARACTER_LIMIT",
+ "description": "Maximum number of characters to post.",
+ "type": "int",
+ "val": 10000,
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "USERNAME",
+ "description": "Username of bot user",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ },
+ {
+ "key": "PASSWORD",
+ "description": "Password for bot user",
+ "type": "str",
+ "val": "",
+ "options": [],
+ "subkeys": [],
+ "parent_key": ""
+ }
+ ]
+}
\ No newline at end of file
diff --git a/redball/database.py b/redball/database.py
index 7a2a33c..aa14b51 100644
--- a/redball/database.py
+++ b/redball/database.py
@@ -307,6 +307,7 @@ def build_tables(tables, logg=log):
"""INSERT OR IGNORE INTO rb_botTypes (name, description, moduleName)
VALUES
('game-threads', 'MLB Game Threads', 'game_threads'),
+ ('lemmy-mlb-game-threads', 'Lemmy MLB Game Threads', 'lemmy_mlb_game_threads'),
('nfl-game-threads', 'NFL Game Threads', 'nfl_game_threads'),
('mlb-data', 'MLB Data', 'mlb_data'),
('comment-response', 'Comment Response', 'comment_response'),
diff --git a/redball/upgrade.py b/redball/upgrade.py
index bea2250..075a9a4 100644
--- a/redball/upgrade.py
+++ b/redball/upgrade.py
@@ -445,4 +445,15 @@ def upgrade_database(toVer=None):
time.time()
),
],
+ 14: [
+ # Add Sidebar Updater bot type
+ """INSERT OR IGNORE INTO rb_botTypes (name, description, moduleName)
+ VALUES
+ ('lemmy-mlb-game-threads', 'Lemmy MLB Game Threads', 'lemmy_mlb_game_threads')
+ ;""",
+ # Update DB version
+ "UPDATE rb_meta SET val='14', lastUpdate='{}' WHERE key='dbVersion';".format(
+ time.time()
+ ),
+ ],
}
diff --git a/requirements.txt b/requirements.txt
index 8210323..30ae99e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,7 +2,7 @@ cherrypy
requests
apscheduler
pytz
-tzlocal
+tzlocal<3
praw
mlb-statsapi
pyOpenSSL
@@ -10,4 +10,4 @@ mako
pyprowl
python-twitter
bs4
-discord
+discord
\ No newline at end of file