import irc.bot import time import re import json import html import os import urllib.request import urllib.error import socket from urllib.parse import quote import xml.etree.ElementTree as ET from datetime import datetime, timedelta from zoneinfo import ZoneInfo import statsapi from pybaseball import playerid_lookup import traceback import sys import random import threading # --- CONFIGURATION --- SERVER = "irc.rizon.net" PORT = 6667 CHANNELS = ["#/g/tv", "#blog"] # Channels where a pasted x.com / twitter.com status link gets announced as # the first line of the tweet (via the keyless fxtwitter API). Lowercase. TWEET_CHANNELS = {"#/g/tv", "#blog"} TWEET_URL_RE = re.compile(r"https?://(?:www\.|mobile\.)?(?:twitter|x)\.com/(\w{1,15})/status(?:es)?/(\d+)", re.I) TWEET_COOLDOWN = 30 # seconds before the same tweet is announced again in a channel TWEET_SEEN = {} # Running log of every announced tweet, pushed to bloggin.space for the wiki page. TWEET_LOG_FILE = "tweet_log.json" TWEET_LOG_MAX = 1000 TWEET_PUSH_URL = "https://bloggin.space/api/tweet_log_update.php" TWEET_LOG_LOCK = threading.Lock() NICKNAME = "basedball" NICKSERV_PASS = os.environ.get("NICKSERV_PASS", "") # Nicknames whose commands the bot should never respond to, no matter what they say -- # guards against bot-to-bot spam loops with other bots in these channels. GTV chat # logging (see on_pubmsg) happens before this check, so an ignored nick's chat can # still be logged without the bot ever executing its commands. Matching is case-insensitive. IGNORED_NICKS = {"murasa"} # Only these nicks can use sensitive commands like !quit. Matching is case-insensitive. ADMIN_NICKS = {"tpuahsiew", "weishaupt"} # --- CLOD (Claude) INTEGRATION --- # Same proxy the bloggin.space/Clod web widget talks to. Restricted to channel # ops at runtime (see is_op below), not a static nick list, since op status # can change without editing/restarting the bot. CLOD_PROXY_URL = "https://bloggin.space/Clod/claude-proxy.php" # --- #/g/tv CHAT LOGGER --- # Logs the latest human (non-command) lines said in GTV_LOG_CHANNEL, persists them to # disk so the log survives a bot restart, and pushes them to bloggin.space for display. GTV_LOG_CHANNEL = "#/g/tv" GTV_LOG_FILE = "gtv_log.json" GTV_MAX_LINES = 20 GTV_PUSH_URL = "https://bloggin.space/api/gtv_log_update.php" GTV_ADMIN_KEY = os.environ.get("GTV_ADMIN_KEY", "") # --- COPS/BODYCAM YOUTUBE TRACKING --- # Channels checked live, on-demand, by the !cops command. Uses YouTube's public RSS feed # per channel -- no API key needed. YOUTUBE_CHANNELS = { "Sergeant Curtis": "UCPlH2h2Ckq1T-DKqw0latYA", "PoliceActivity": "UCXMYxKMh3prxnM_4kYZuB3g", "Code Blue Cam": "UCCKkuXux09y-TCg-BQxCjNA", "Law&Crime BodyCam": "UCMV3pzWIhJGLYzoHyxBjjNw", "Body Cam Watch": "UCQFvmd1tcYe4ZIw-Bqf3aLw", "Donut Operator": "UCwkm_Wcyh0pc7UUmZZfL-6w", } # ==================== IRC COLOR HELPERS ==================== def color_text(text, fg, bg=None): if bg is None: if fg == 0: return text return f"\x03{fg:02d}{text}\x03" return f"\x03{fg:02d},{bg:02d}{text}\x03" YOUTUBE_TAG = color_text("[YouTube]", 4, 0) # red text on white background # ==================== GTV CHAT LOGGER ==================== # Standard mIRC 16-color palette mapped to hex, for rendering logged chat lines as HTML. # Separate from TEAM_COLORS above, which is the bot's own numeric-index send-side palette -- # this one maps the same numeric codes to actual hex values for browser display. MIRC_HTML_COLORS = { "00": "#FFFFFF", "01": "#000000", "02": "#00007F", "03": "#009300", "04": "#FF0000", "05": "#7F0000", "06": "#9C009C", "07": "#FC7F00", "08": "#FFFF00", "09": "#00FC00", "10": "#009393", "11": "#00FFFF", "12": "#0000FC", "13": "#FF00FF", "14": "#7F7F7F", "15": "#D2D2D2", } def mirc_to_html(text): """Convert a raw IRC message with mIRC control codes into safe HTML with inline color/bold/italic/underline spans. HTML-escapes the text first so chat content can't inject markup.""" text = html.escape(text) result = [] bold = italic = underline = False fg = bg = None span_open = False i, n = 0, len(text) def open_span(): styles = [] if fg: styles.append(f"color:{fg}") if bg: styles.append(f"background-color:{bg}") if bold: styles.append("font-weight:bold") if italic: styles.append("font-style:italic") if underline: styles.append("text-decoration:underline") if styles: result.append(f'') return True return False while i < n: ch = text[i] if ch == "\x03": # color code, optionally NN[,MM] if span_open: result.append("") span_open = False i += 1 m = re.match(r"(\d{1,2})?(,(\d{1,2}))?", text[i:]) if m and m.group(0): num, _, bgnum = m.groups() fg = MIRC_HTML_COLORS.get(num.zfill(2)) if num else None bg = MIRC_HTML_COLORS.get(bgnum.zfill(2)) if bgnum else bg i += len(m.group(0)) else: fg = bg = None span_open = open_span() continue if ch == "\x02": # bold if span_open: result.append("") bold = not bold span_open = open_span() i += 1 continue if ch == "\x1D": # italic if span_open: result.append("") italic = not italic span_open = open_span() i += 1 continue if ch == "\x1F": # underline if span_open: result.append("") underline = not underline span_open = open_span() i += 1 continue if ch == "\x0F": # reset all if span_open: result.append("") span_open = False bold = italic = underline = False fg = bg = None i += 1 continue if ch == "\x16": # reverse (swap fg/bg) if span_open: result.append("") fg, bg = bg, fg span_open = open_span() i += 1 continue result.append(ch) i += 1 if span_open: result.append("") return "".join(result) # Matches bare http(s) URLs. Runs after mirc_to_html() has already HTML-escaped the # message, so there's no raw < > & left in the text to worry about breaking. GTV_URL_PATTERN = re.compile(r'(https?://[^\s<]+)') def linkify(html_text): """Wrap bare URLs in the already-mIRC-converted HTML with clickable tags.""" def _sub(m): url = m.group(1) return f'{url}' return GTV_URL_PATTERN.sub(_sub, html_text) def load_gtv_log(): try: with open(GTV_LOG_FILE, "r", encoding="utf-8") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return [] def save_gtv_log(log): """Write via a temp file + rename so a crash mid-write can't corrupt the log.""" tmp_path = GTV_LOG_FILE + ".tmp" with open(tmp_path, "w", encoding="utf-8") as f: json.dump(log, f) os.replace(tmp_path, GTV_LOG_FILE) def push_gtv_log(log): """Push the latest log to bloggin.space. Runs on the calling thread's behalf -- callers should invoke this via run_async so a slow/hanging request never blocks the main IRC loop (same reasoning as every other external call in this bot).""" try: payload = json.dumps({"admin_key": GTV_ADMIN_KEY, "log": log}).encode("utf-8") req = urllib.request.Request( GTV_PUSH_URL, data=payload, headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=10) as resp: resp.read() except Exception as e: print(f"Failed to push gtv log: {e}") def log_gtv_message(nick, raw_message): """Append a human chat line to the persisted GTV log, trim to the latest GTV_MAX_LINES, save to disk, and push to bloggin.space.""" log = load_gtv_log() log.append({ "nick": html.escape(nick), "message_html": linkify(mirc_to_html(raw_message)), "timestamp": int(time.time()), }) log = log[-GTV_MAX_LINES:] save_gtv_log(log) push_gtv_log(log) def log_tweet(tweet_id, author, text, nick): """Add an announced tweet to the running tweet log (tweet_log.json), skip tweets that are already logged, then push the whole log to bloggin.space. Holds a lock so two announcements at once can't clobber each other's write. Called from the tweet handler's own background thread.""" url = f"https://x.com/{author}/status/{tweet_id}" try: with TWEET_LOG_LOCK: try: with open(TWEET_LOG_FILE, "r", encoding="utf-8") as f: log = json.load(f) if not isinstance(log, list): log = [] except (FileNotFoundError, ValueError): log = [] if any(str(e.get("id")) == str(tweet_id) for e in log): return log.append({ "id": str(tweet_id), "url": url, "author": author, "text": text, "nick": nick, "timestamp": int(time.time()), }) log = log[-TWEET_LOG_MAX:] tmp_path = TWEET_LOG_FILE + ".tmp" with open(tmp_path, "w", encoding="utf-8") as f: json.dump(log, f) os.replace(tmp_path, TWEET_LOG_FILE) payload = json.dumps({"admin_key": GTV_ADMIN_KEY, "log": log}).encode("utf-8") req = urllib.request.Request( TWEET_PUSH_URL, data=payload, headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=10) as resp: resp.read() except Exception as e: print(f"Failed to log tweet: {e}") def strip_markdown_for_irc(text): """Convert Clod's markdown-style formatting into plain IRC-friendly text -- markdown links and bold don't render in an IRC client, so flatten them.""" text = re.sub(r'\[([^\]]+)\]\((https?://[^\s)]+)\)', r'\1 (\2)', text) # [label](url) -> label (url) text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text) # **bold** -> bold return text # mIRC standard palette used below: # 0 white 1 black 2 navy 3 green 4 red 5 brown 6 purple # 7 orange 8 yellow 9 lt.green 10 teal 11 cyan 12 royal blue 13 pink 14 grey 15 lt.grey TEAM_COLORS = { # team: (foreground, background) "yankees": (0, 2), # white on navy "red sox": (4, 2), # red on navy "blue jays": (0, 12), # white on royal blue "orioles": (7, 1), # orange on black "rays": (11, 2), # light cyan on navy "twins": (4, 2), # red on navy "tigers": (7, 2), # orange on navy "royals": (8, 12), # gold on royal blue "astros": (7, 2), # orange on navy "guardians": (4, 2), # red on navy "indians": (4, 2), # red on navy "mariners": (10, 2), # teal on navy "angels": (0, 4), # white on red "rangers": (4, 2), # red on navy "white sox": (0, 1), # white on black "athletics": (8, 3), # gold on green "phillies": (0, 4), # white on red "braves": (4, 2), # red on navy "mets": (7, 12), # orange on royal blue "nationals": (0, 4), # white on red "marlins": (7, 10), # orange on teal "cubs": (4, 12), # red on royal blue "cardinals": (0, 4), # white on red "pirates": (8, 1), # gold on black "brewers": (8, 2), # yellow on navy "reds": (4, 0), # red on white "dodgers": (12, 0), # blue on white "padres": (8, 5), # gold on brown "giants": (7, 1), # orange on black "diamondbacks": (4, 1), # red on black "rockies": (6, 1), # purple on black } # Team name/nickname substitutions applied everywhere a team name shows up in output. # Order matters: more specific (full-name) patterns must come before the plain nickname # pattern they'd otherwise get caught by, so e.g. "Los Angeles Dodgers" doesn't turn into # the redundant "Los Angeles Los Doyers". RENAME_RULES = [ (r"Cleveland Guardians", "Cleveland Indians"), (r"\bGuardians\b", "Indians"), (r"Los Angeles Dodgers", "Los Doyers"), (r"\bDodgers\b", "Los Doyers"), (r"\bBraves\b", "Barves"), (r"\bRangers\b", "Ragners"), (r"\bAstros\b", "Lastros"), (r"\bCubs\b", "Cusb"), (r"\bRoyals\b", "Roils"), (r"\bAngels\b", "Angles"), (r"\bBrewers\b", "Viewers"), (r"\bTwins\b", "Twinks"), (r"\bMets\b", "Mest"), (r"\bGiants\b", "Gints"), ] def fix_team_name(name): """Apply every rename rule in RENAME_RULES, wherever the matched text appears in the string -- not just when it's the entire string.""" if not name: return name for pattern, replacement in RENAME_RULES: name = re.sub(pattern, replacement, name, flags=re.IGNORECASE) return name def get_team_color(team_name): if not team_name: return (0, None) name_lower = team_name.lower() for key, colors in TEAM_COLORS.items(): if key in name_lower or name_lower in key: return colors return (0, None) def colorize_team(name): """Look up the team's color using its real name, then apply the nickname rename for display -- order matters, since TEAM_COLORS is keyed by the real nickname (e.g. 'brewers'), not the renamed one ('viewers').""" fg, bg = get_team_color(name) renamed = fix_team_name(name) return color_text(renamed, fg, bg) # Full official names, used to recognize team names inside raw statsapi text # (boxscore/highlights/leaders output) that we don't otherwise control the format of. TEAM_FULL_NAMES = { "yankees": "New York Yankees", "red sox": "Boston Red Sox", "blue jays": "Toronto Blue Jays", "orioles": "Baltimore Orioles", "rays": "Tampa Bay Rays", "twins": "Minnesota Twins", "tigers": "Detroit Tigers", "royals": "Kansas City Royals", "astros": "Houston Astros", "guardians": "Cleveland Guardians", "indians": "Cleveland Indians", "mariners": "Seattle Mariners", "angels": "Los Angeles Angels", "rangers": "Texas Rangers", "white sox": "Chicago White Sox", "athletics": "Athletics", "phillies": "Philadelphia Phillies", "braves": "Atlanta Braves", "mets": "New York Mets", "nationals": "Washington Nationals", "marlins": "Miami Marlins", "cubs": "Chicago Cubs", "cardinals": "St. Louis Cardinals", "pirates": "Pittsburgh Pirates", "brewers": "Milwaukee Brewers", "reds": "Cincinnati Reds", "dodgers": "Los Angeles Dodgers", "padres": "San Diego Padres", "giants": "San Francisco Giants", "diamondbacks": "Arizona Diamondbacks", "rockies": "Colorado Rockies", } # Map every recognizable string (nickname or full name) -> its TEAM_COLORS key, # longest first so "Boston Red Sox" matches before a bare "Red Sox"/"Sox" fragment would. _CANDIDATE_TO_KEY = {} for _key in TEAM_COLORS.keys(): _CANDIDATE_TO_KEY[_key.lower()] = _key for _key, _full in TEAM_FULL_NAMES.items(): _CANDIDATE_TO_KEY[_full.lower()] = _key _SORTED_CANDIDATES = sorted(_CANDIDATE_TO_KEY.keys(), key=len, reverse=True) _TEAM_NAME_PATTERN = re.compile( "(" + "|".join(re.escape(c) for c in _SORTED_CANDIDATES) + ")", re.IGNORECASE ) def colorize_team_names_in_text(text): """Find any team name/nickname inside a block of raw text and wrap it in that team's colors. Used for statsapi's own pre-formatted output (boxscore, highlights, leaders) where we don't control the exact wording or field layout.""" if not text: return text def _sub(m): matched = m.group(0) key = _CANDIDATE_TO_KEY.get(matched.lower()) if not key: return matched fg, bg = TEAM_COLORS.get(key, (0, None)) renamed = fix_team_name(matched) return color_text(renamed, fg, bg) return _TEAM_NAME_PATTERN.sub(_sub, text) def get_team_id(search_name): """Look up an MLB team's numeric ID from a partial name (e.g. 'brewers', 'reds').""" try: results = statsapi.lookup_team(search_name) if results: return results[0]['id'] except: pass return None def get_player_bio(pid): """Look up a player's current position/number/team via statsapi, using their MLBAM id. Returns None on any failure so callers can gracefully fall back to stats-only output. Retired/inactive players are detected via the 'active' flag and shown as 'Retired' rather than guessing at a stale team.""" try: data = statsapi.get('person', {'personId': pid, 'hydrate': 'currentTeam'}) people = data.get('people', []) if not people: return None person = people[0] active = bool(person.get('active', False)) position = person.get('primaryPosition', {}).get('abbreviation', '') number = person.get('primaryNumber') if active: team_raw = person.get('currentTeam', {}).get('name') team_display = colorize_team(team_raw) if team_raw else "Active" else: team_display = "Retired" return { 'position': position, 'number': number, 'team_display': team_display, 'active': active, } except: return None def format_bio_header(name, bio): """Build 'First Last (#NN POS) - Team' when bio info is available, otherwise just the name.""" if not bio: return name bits = [] if bio.get('number'): bits.append(f"#{bio['number']}") if bio.get('position'): bits.append(bio['position']) bio_str = " ".join(bits) header = f"{name} ({bio_str})" if bio_str else name return f"{header} - {bio['team_display']}" class BaseballBot(irc.bot.SingleServerIRCBot): def __init__(self, channels, nickname, server, port): super().__init__([(server, port)], nickname, nickname) self.channels_list = channels self.last_command_time = 0 self.cooldown = 3 self.current_year = datetime.now().year # Wrap the connection's privmsg so basedball's own replies in the GTV channel # get logged too, not just what other people say. IRC servers don't echo a # client's own outgoing messages back through on_pubmsg by default, so this is # a send-side hook rather than a receive-side one -- it fires regardless of # whether a handler calls c.privmsg(...) directly or goes through send_line(). self._real_privmsg = self.connection.privmsg self.connection.privmsg = self._logging_privmsg threading.Thread(target=self.console_input, daemon=True).start() def _logging_privmsg(self, target, text): self._real_privmsg(target, text) if target == GTV_LOG_CHANNEL: self.run_async(log_gtv_message, self.connection.get_nickname() or NICKNAME, text) def console_input(self): target = self.channels_list[0] while True: try: message = input() if message.strip(): self.connection.privmsg(target, message.strip()) except: break def fetch_latest_youtube_video(self, channel_id): """Return (video_id, title, url) for a channel's most recent upload, or None on failure. Used on-demand by !cops.""" try: feed_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}" req = urllib.request.Request(feed_url, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Accept": "application/atom+xml,application/xml,text/xml,*/*;q=0.9", "Accept-Language": "en-US,en;q=0.9", }) with urllib.request.urlopen(req, timeout=15) as resp: raw = resp.read() root = ET.fromstring(raw) ns = { "atom": "http://www.w3.org/2005/Atom", "yt": "http://www.youtube.com/xml/schemas/2015", } entry = root.find("atom:entry", ns) if entry is None: return None video_id_el = entry.find("yt:videoId", ns) title_el = entry.find("atom:title", ns) video_id = video_id_el.text if video_id_el is not None else None title = title_el.text if title_el is not None else "New video" if not video_id: return None url = f"https://youtu.be/{video_id}" return (video_id, title, url) except Exception as e: print(f"YouTube feed fetch failed for channel {channel_id}: {e}") return None def handle_tweet_link(self, c, target, user, tweet_id, nick=""): """Announce the first line of a pasted tweet link. Runs in a background thread via run_async so a slow request never stalls the IRC loop.""" try: key = (target.lower(), tweet_id) now = time.time() if now - TWEET_SEEN.get(key, 0) < TWEET_COOLDOWN: return TWEET_SEEN[key] = now url = f"https://api.fxtwitter.com/{user}/status/{tweet_id}" req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) tweet = data.get("tweet") or {} text = (tweet.get("text") or "").strip() author = (tweet.get("author") or {}).get("screen_name") or user first = "" for line in text.splitlines(): if line.strip(): first = line.strip() break first = re.sub(r"[\x00-\x1f]", "", html.unescape(first)) if not first: return if len(first) > 300: first = first[:297] + "..." self.send_line(c, target, f"\x0304[Tweet]\x0f \x02\x0382@{author}\x0f\x0382: {first}\x0f", delay=0) log_tweet(tweet_id, author, first, nick) except Exception: traceback.print_exc() def send_line(self, c, target, text, delay=0.5): """Send a single IRC line, then pause briefly to avoid flooding the server.""" c.privmsg(target, text) time.sleep(delay) def run_async(self, func, *args): """Run a command handler in its own background thread instead of the main IRC event loop. Every command that hits an external API (statsapi, Wikipedia, etc.) goes through this, so a slow or hanging request can't freeze the whole bot -- including replying to the server's keepalive PINGs, which would otherwise risk a timeout/disconnect. Sending replies from a background thread is already the same pattern the YouTube watcher thread uses successfully.""" threading.Thread(target=func, args=args, daemon=True).start() def is_rate_limited(self): now = time.time() if now - self.last_command_time < self.cooldown: return True self.last_command_time = now return False def is_op(self, target, nick): """Check live channel op status via the IRC library's own channel tracking, rather than a static admin list -- reflects real +o status at the moment the command is used.""" try: channel = self.channels.get(target) if channel is None: return False return channel.is_oper(nick) except Exception: return False def on_welcome(self, c, e): c.privmsg("NickServ", f"IDENTIFY {NICKSERV_PASS}") time.sleep(2) for channel in self.channels_list: c.join(channel) time.sleep(1) def on_pubmsg(self, c, e): sender_nick = e.source.nick if e.source else "" msg = e.arguments[0].strip() # Log human/other-bot (non-command) chat in the GTV channel first, before the # IGNORED_NICKS gate below. This intentionally runs even for nicks in # IGNORED_NICKS (e.g. macbot) so their chat still shows up in the log, while # the gate right after still stops the bot from ever executing their commands. # Runs in its own thread so a slow push to bloggin.space never stalls the main # IRC loop. if e.target == GTV_LOG_CHANNEL and not msg.startswith("!"): self.run_async(log_gtv_message, sender_nick, msg) if sender_nick.lower() in IGNORED_NICKS: return if e.target.lower() in TWEET_CHANNELS and not msg.startswith("!"): tm = TWEET_URL_RE.search(msg) if tm: self.run_async(self.handle_tweet_link, c, e.target, tm.group(1), tm.group(2), sender_nick) if not msg.startswith("!"): return if self.is_rate_limited(): return target = e.target cmd = msg.lower() if cmd == "!quit": if sender_nick.lower() in ADMIN_NICKS: self.disconnect() return elif cmd in ["!mlb", "!mlbhelp"]: self.send_help(c, target) elif cmd.startswith("!score") or cmd == "!yesterday": self.run_async(self.handle_scores, c, target, msg) elif cmd == "!final": self.run_async(self.handle_final, c, target) elif cmd == "!live": self.run_async(self.handle_live, c, target) elif cmd == "!tomorrow": self.run_async(self.handle_tomorrow, c, target) elif cmd == "!probable": self.run_async(self.handle_probable, c, target) elif cmd == "!random": self.run_async(self.handle_random, c, target) elif cmd.startswith("!stats "): self.run_async(self.handle_stats, c, target, msg) elif cmd.startswith("!game "): self.run_async(self.handle_game, c, target, msg) elif cmd.startswith("!stand"): self.run_async(self.handle_standings, c, target, msg) elif cmd.startswith("!boxscore"): self.run_async(self.handle_boxscore, c, target, msg) elif cmd.startswith("!highlights"): self.run_async(self.handle_highlights, c, target, msg) elif cmd.startswith("!leaders"): self.run_async(self.handle_leaders, c, target, msg) elif cmd.startswith("!roster"): self.run_async(self.handle_roster, c, target, msg) elif cmd.startswith("!award"): self.run_async(self.handle_award, c, target, msg) elif cmd == "!cops": self.run_async(self.handle_cops, c, target) elif cmd.startswith("!wiki "): self.run_async(self.handle_wiki, c, target, msg) elif cmd.startswith("!clod"): if not self.is_op(target, sender_nick): c.privmsg(target, "Ops only, chief.") return self.run_async(self.handle_clod, c, target, msg) elif cmd.startswith("!mow"): self.run_async(self.handle_mow, c, target, msg) def send_help(self, c, target): c.privmsg(target, "MLB: !score/!live/!yesterday/!final (scores), !tomorrow (schedule), !probable (pitchers), !game [team], !stand [al/nl] [div], !stats [f] [l] (hitting+pitching, season+career), !boxscore [team] (line score), !highlights [team] (video), !leaders [hr/avg/rbi/sb/obp/slg/ops/era/so/wins/saves/whip], !roster [team], !award [mvp/cyyoung/roy/hof] [year] [al/nl], !random | Other: !cops (latest bodycam channel uploads), !wiki [term] (Wikipedia summary), !clod [message] (ops only - talks to Clod/Claude), !mow [zipcode] (lawn mowing forecast)") def handle_cops(self, c, target): entries = [] for name, channel_id in YOUTUBE_CHANNELS.items(): result = self.fetch_latest_youtube_video(channel_id) if result: _, title, url = result entries.append(f"{name}: {title} - {url}") else: entries.append(f"{name}: (unavailable)") # 2 channels per line -- with 6 tracked channels that's exactly 3 lines. for i in range(0, len(entries), 2): pair = entries[i:i + 2] line = f"{YOUTUBE_TAG} " + " | ".join(pair) self.send_line(c, target, line) def handle_wiki(self, c, target, msg): try: parts = msg.split(maxsplit=1) term = parts[1].strip() if len(parts) > 1 else "" if not term: c.privmsg(target, "Usage: !wiki [search term]") return # Step 1: resolve whatever the user typed to the closest matching article title. search_url = f"https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={quote(term)}&format=json&srlimit=1" req = urllib.request.Request(search_url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=10) as resp: search_data = json.loads(resp.read()) results = search_data.get("query", {}).get("search", []) if not results: c.privmsg(target, f"No Wikipedia article found for '{term}'.") return title = results[0]["title"] # Step 2: pull a clean summary/extract for that exact title. summary_url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{quote(title.replace(' ', '_'))}" req2 = urllib.request.Request(summary_url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req2, timeout=10) as resp2: summary_data = json.loads(resp2.read()) page_title = summary_data.get("title", title) extract = summary_data.get("extract", "") page_url = summary_data.get("content_urls", {}).get("desktop", {}).get("page") if not page_url: page_url = f"https://en.wikipedia.org/wiki/{quote(title.replace(' ', '_'))}" if summary_data.get("type") == "disambiguation": c.privmsg(target, f"'{term}' is ambiguous on Wikipedia -- see: {page_url}") return if not extract: c.privmsg(target, f"{page_title} - {page_url}") return if len(extract) > 400: extract = extract[:400].rsplit(" ", 1)[0] + "..." self.send_line(c, target, f"{page_title} - {page_url}") self.send_line(c, target, extract) except Exception as e: print(f"Wiki lookup error for '{msg}': {e}") c.privmsg(target, "Error retrieving Wikipedia article.") # Lawn-mowing conditions forecast, in the style of mowcast.io. # Data: Zippopotam.us (zip -> lat/lon) + api.weather.gov (NWS hourly forecast). No API key needed. def handle_mow(self, c, target, msg): try: parts = msg.split(maxsplit=1) zipcode = parts[1].strip() if len(parts) > 1 else "" if not re.fullmatch(r"\d{5}", zipcode): c.privmsg(target, "Usage: !mow [5-digit US zip code]") return geo_url = f"https://api.zippopotam.us/us/{zipcode}" req = urllib.request.Request(geo_url, headers={"User-Agent": "Mozilla/5.0"}) try: with urllib.request.urlopen(req, timeout=10) as resp: geo_data = json.loads(resp.read()) except urllib.error.HTTPError as e: if e.code == 404: c.privmsg(target, f"Zip code '{zipcode}' not found.") return raise place = geo_data["places"][0] lat = place["latitude"] lon = place["longitude"] place_name = f'{place["place name"]}, {place["state abbreviation"]}' headers = {"User-Agent": "Basedball IRC Bot, bloggin.space"} points_url = f"https://api.weather.gov/points/{lat},{lon}" req = urllib.request.Request(points_url, headers=headers) with urllib.request.urlopen(req, timeout=10) as resp: points_data = json.loads(resp.read()) hourly_url = points_data["properties"]["forecastHourly"] req = urllib.request.Request(hourly_url, headers=headers) with urllib.request.urlopen(req, timeout=10) as resp: hourly_data = json.loads(resp.read()) periods = hourly_data["properties"]["periods"][:12] if not periods: c.privmsg(target, "No forecast data available for that zip code.") return now = periods[0] temp = now.get("temperature", 70) wind_now_match = re.search(r"\d+", now.get("windSpeed", "0")) wind_now = int(wind_now_match.group()) if wind_now_match else 0 humidity = (now.get("relativeHumidity") or {}).get("value") or 50 precip_now = (now.get("probabilityOfPrecipitation") or {}).get("value") or 0 wind_later = wind_now precip_later = precip_now for p in periods[1:6]: m = re.search(r"\d+", p.get("windSpeed", "0")) if m: wind_later = max(wind_later, int(m.group())) pr = (p.get("probabilityOfPrecipitation") or {}).get("value") or 0 precip_later = max(precip_later, pr) score = 100 score -= precip_now * 0.8 score -= max(0, wind_now - 8) * 2.5 if temp > 90 or temp < 45: score -= 20 elif temp > 85 or temp < 55: score -= 8 if humidity > 85: score -= 10 score = max(0, min(100, round(score))) if score >= 70: desc = "Excellent conditions for mowing" elif score >= 50: desc = "Good conditions for mowing" elif score >= 30: desc = "Fair conditions for mowing" else: desc = "Poor conditions for mowing" notes = [] if precip_later > precip_now + 20: notes.append("rain is likely later so get to it early") elif wind_later > wind_now + 5: notes.append("especially in the morning before winds pick up") if humidity > 85: notes.append("grass may be wet from humidity/dew") if score < 30: notes.append("consider waiting for better conditions") note = (", " + "; ".join(notes)) if notes else "" line = f" [Mowcast] MowScore {score}% {desc} today{note}. ({place_name}) " c.privmsg(target, color_text(line, 3, 0)) except Exception as e: print(f"Mow lookup error for '{msg}': {e}") c.privmsg(target, "Error retrieving mowing forecast for that zip code.") def handle_clod(self, c, target, msg): """Talk to Clod (Claude) via the same proxy the bloggin.space/Clod web widget uses -- gets the same board read/write and wiki read tools for free.""" try: question = msg[len("!clod"):].strip() if not question: c.privmsg(target, "Usage: !clod [message] (ops only)") return payload = json.dumps({"message": question}).encode("utf-8") req = urllib.request.Request( CLOD_PROXY_URL, data=payload, headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=130) as resp: raw = resp.read() data = json.loads(raw) if "error" in data: c.privmsg(target, f"Clod error: {data.get('detail') or data['error']}") return reply = strip_markdown_for_irc(data.get("reply", "")).strip() if not reply: c.privmsg(target, "Clod didn't have anything to say.") return # IRC lines are capped around 512 bytes incl. protocol overhead -- # wrap each paragraph into safe-length chunks, same approach as !roster. # Cap the whole reply at 4 lines so Clod can't flood the channel. max_len = 420 max_lines = 4 all_chunks = [] for line in reply.split("\n"): line = line.strip() if not line: continue words = line.split(" ") chunk = "" for w in words: piece = w if not chunk else " " + w if len(chunk) + len(piece) > max_len: all_chunks.append(chunk) chunk = w else: chunk += piece if chunk: all_chunks.append(chunk) truncated = len(all_chunks) > max_lines to_send = all_chunks[:max_lines] if truncated and to_send: to_send[-1] = to_send[-1].rstrip() + " [...]" for i, chunk in enumerate(to_send): if i == len(to_send) - 1: c.privmsg(target, chunk) else: self.send_line(c, target, chunk) except urllib.error.HTTPError as e: try: body = e.read().decode("utf-8", errors="replace") data = json.loads(body) detail = data.get("detail") or data.get("error") or body except Exception: detail = f"HTTP {e.code}: {e.reason}" print(f"Clod command HTTP error {e.code}: {detail}") c.privmsg(target, f"Clod error ({e.code}): {str(detail)[:400]}") except urllib.error.URLError as e: if isinstance(getattr(e, "reason", None), socket.timeout) or "timed out" in str(e).lower(): print(f"Clod command timed out: {e}") c.privmsg(target, "Clod's taking too long to answer that one (timed out). Try something simpler or ask again.") else: print(f"Clod command network error: {e}") c.privmsg(target, f"Couldn't reach Clod: {e}") except Exception as e: print(f"Clod command error: {e}") c.privmsg(target, f"Error reaching Clod: {e}") def handle_scores(self, c, target, msg): try: et = ZoneInfo("America/New_York") if "!yesterday" in msg or "!final" in msg: date = (datetime.now(et) - timedelta(days=1)).strftime("%Y-%m-%d") else: date = datetime.now(et).strftime("%Y-%m-%d") games = statsapi.schedule(date=date, sportId=1) self.send_line(c, target, f"--- Scores for {date} ---") for g in games: away = colorize_team(g.get('away_name')) home = colorize_team(g.get('home_name')) self.send_line(c, target, f"[{g.get('status')}] {away} {g.get('away_score')} @ {home} {g.get('home_score')}") except: c.privmsg(target, "Error.") def handle_final(self, c, target): self.handle_scores(c, target, "!final") def handle_live(self, c, target): try: et = ZoneInfo("America/New_York") games = statsapi.schedule(date=datetime.now(et).strftime("%Y-%m-%d"), sportId=1) def is_live(status): if not status: return False s = status.lower() # Explicitly not live, checked first so these can't accidentally match a # live keyword below (e.g. "Final" would otherwise be fine, but better safe). if any(k in s for k in ("final", "scheduled", "pre-game", "postponed", "cancelled", "canceled")): return False return any(k in s for k in ("progress", "delay", "warmup", "suspend", "challenge")) live = [g for g in games if is_live(g.get('status'))] if not live: # Debug: print every game's raw status to the console so we can tell whether # there genuinely are no live games right now, or the matching logic missed one. statuses = [(g.get('away_name'), g.get('home_name'), g.get('status')) for g in games] print(f"No live games matched. Today's raw statuses: {statuses}") c.privmsg(target, "No live games.") return for g in live: away = colorize_team(g['away_name']) home = colorize_team(g['home_name']) self.send_line(c, target, f"[{g['status']}] {away} {g['away_score']} @ {home} {g['home_score']}") except Exception as e: print(f"Live games lookup error: {e}") c.privmsg(target, "Error.") def handle_tomorrow(self, c, target): try: et = ZoneInfo("America/New_York") date = (datetime.now(et) + timedelta(days=1)).strftime("%Y-%m-%d") games = statsapi.schedule(date=date, sportId=1) self.send_line(c, target, f"--- Games for {date} ---") for g in games: away = colorize_team(g['away_name']) home = colorize_team(g['home_name']) self.send_line(c, target, f"{away} @ {home}") except: c.privmsg(target, "Error.") def handle_probable(self, c, target): try: et = ZoneInfo("America/New_York") games = statsapi.schedule(date=datetime.now(et).strftime("%Y-%m-%d"), sportId=1) for g in games: away = colorize_team(g['away_name']) home = colorize_team(g['home_name']) self.send_line(c, target, f"{away} ({g.get('away_probable_pitcher', 'TBD')}) @ {home} ({g.get('home_probable_pitcher', 'TBD')})") except: c.privmsg(target, "Error.") def handle_random(self, c, target): try: et = ZoneInfo("America/New_York") today = datetime.now(et).strftime("%Y-%m-%d") games = statsapi.schedule(date=today, sportId=1) if not games: c.privmsg(target, "No games today to pick a random player from.") return g = random.choice(games) team_name = random.choice([g.get('away_name'), g.get('home_name')]) team_id = get_team_id(team_name) if not team_id: c.privmsg(target, "Error.") return roster_text = statsapi.roster(team_id) raw_lines = [l for l in roster_text.split("\n") if l.strip()] names = [] for line in raw_lines: fields = re.split(r"\s{2,}", line.strip()) if len(fields) >= 3: names.append(fields[2]) elif len(fields) == 2: names.append(fields[1]) if not names: c.privmsg(target, "Error.") return full_name = random.choice(names) name_parts = full_name.split() if len(name_parts) < 2: c.privmsg(target, "Error.") return first, last = name_parts[0], " ".join(name_parts[1:]) self.handle_stats(c, target, f"!stats {first} {last}") except: c.privmsg(target, "Error.") def get_stat_block(self, pid, group): """Return (season_stats, career_stats) for a player's stat group ('hitting' or 'pitching'), or (None, None) if the player has no real appearances in that group (e.g. a position player's pitching stats, or vice versa).""" try: career_data = statsapi.player_stat_data(pid, group=group, type="career") if not career_data.get('stats') or not career_data['stats'][0].get('stats'): return None, None career_stats = career_data['stats'][0]['stats'] games = career_stats.get('gamesPlayed') or career_stats.get('gamesPitched') or 0 try: games = int(games) except (TypeError, ValueError): games = 0 if games <= 0: return None, None except Exception as e: print(f"Career stat lookup failed for pid {pid} group {group}: {e}") return None, None season_stats = None try: season_data = statsapi.player_stat_data(pid, group=group, type="season", season=self.current_year) if season_data.get('stats') and season_data['stats'][0].get('stats'): season_stats = season_data['stats'][0]['stats'] except Exception as e: print(f"Season stat lookup failed for pid {pid} group {group}: {e}") return season_stats, career_stats def handle_stats(self, c, target, msg): try: parts = msg.split() if len(parts) < 3: c.privmsg(target, "Usage: !stats [first] [last]") return first_in, last_in = parts[1], " ".join(parts[2:]) player = playerid_lookup(last_in, first_in, fuzzy=True) if player.empty: c.privmsg(target, "Player not found.") return pid = int(player.iloc[0]['key_mlbam']) first_name = str(player.iloc[0]['name_first']).title() last_name = str(player.iloc[0]['name_last']).title() bio = get_player_bio(pid) header = format_bio_header(f"{first_name} {last_name}", bio) # Retired players (confirmed via bio) skip the season line entirely -- no point # checking a current season that doesn't exist for them. is_retired = bool(bio and bio.get('active') is False) lines = [] hitting_season, hitting_career = self.get_stat_block(pid, "hitting") if hitting_career: if not is_retired and hitting_season: lines.append(f"{header} - {self.current_year} Hitting: AVG: {hitting_season.get('avg')} | HR: {hitting_season.get('homeRuns')} | OPS: {hitting_season.get('ops')}") lines.append(f"{header} - Career Hitting: AVG: {hitting_career.get('avg')} | HR: {hitting_career.get('homeRuns')} | OPS: {hitting_career.get('ops')}") pitching_season, pitching_career = self.get_stat_block(pid, "pitching") if pitching_career: if not is_retired and pitching_season: lines.append(f"{header} - {self.current_year} Pitching: ERA: {pitching_season.get('era')} | SO: {pitching_season.get('strikeOuts')} | WHIP: {pitching_season.get('whip')}") lines.append(f"{header} - Career Pitching: ERA: {pitching_career.get('era')} | SO: {pitching_career.get('strikeOuts')} | WHIP: {pitching_career.get('whip')}") if not lines: c.privmsg(target, f"No stats found for {header}.") return for line in lines: self.send_line(c, target, line) except: c.privmsg(target, "Error retrieving stats.") def handle_game(self, c, target, msg): try: search = msg.replace("!game", "").strip().lower() today = datetime.now(ZoneInfo("America/New_York")).strftime("%Y-%m-%d") games = statsapi.schedule(date=today, sportId=1) found = False for g in games: away_plain = fix_team_name(g['away_name']) home_plain = fix_team_name(g['home_name']) if search in away_plain.lower() or search in home_plain.lower(): away = colorize_team(g['away_name']) home = colorize_team(g['home_name']) self.send_line(c, target, f"[{g['status']}] {away} {g['away_score']} @ {home} {g['home_score']}") found = True continue if not found: c.privmsg(target, "No matching game found.") except: c.privmsg(target, "Error.") def handle_standings(self, c, target, msg): try: parts = msg.lower().split() filter_term = " ".join(parts[1:]) if len(parts) > 1 else None if filter_term: tokens = filter_term.split() if tokens[0] in ("al", "a.l."): tokens[0] = "american league" elif tokens[0] in ("nl", "n.l."): tokens[0] = "national league" filter_term = " ".join(tokens) s = statsapi.standings_data(season=self.current_year) for div in s.values(): div_name = div.get('div_name', 'Division') if filter_term and filter_term not in div_name.lower(): continue teams = div.get('teams') if not teams: self.send_line(c, target, f"{div_name}: No data.") continue self.send_line(c, target, f"--- {div_name} ---") for team in teams: raw_name = team.get('name') or team.get('team_name', 'Unknown') colored_team = colorize_team(raw_name) w = team.get('w', '?') l = team.get('l', '?') gb = team.get('gb', '-') gb_text = "GB: -" if gb in ('-', '', None) else f"GB: {gb}" self.send_line(c, target, f"{colored_team} ({w}-{l}) {gb_text}") except Exception as e: print(f"Standings Error: {e}") traceback.print_exc() c.privmsg(target, "Error retrieving standings.") def handle_boxscore(self, c, target, msg): try: search = msg[len("!boxscore"):].strip() if not search: c.privmsg(target, "Usage: !boxscore [team]") return team_id = get_team_id(search) if not team_id: c.privmsg(target, "Team not found.") return game_id = statsapi.last_game(team_id) if not game_id: c.privmsg(target, "No recent game found for that team.") return text = statsapi.linescore(game_id) lines = [l for l in text.split("\n") if l.strip()] if not lines: c.privmsg(target, "No line score available yet.") return for l in lines: self.send_line(c, target, colorize_team_names_in_text(l)) except: c.privmsg(target, "Error retrieving boxscore.") def handle_highlights(self, c, target, msg): try: search = msg[len("!highlights"):].strip() if not search: c.privmsg(target, "Usage: !highlights [team]") return team_id = get_team_id(search) if not team_id: c.privmsg(target, "Team not found.") return game_id = statsapi.last_game(team_id) if not game_id: c.privmsg(target, "No recent game found for that team.") return text = statsapi.game_highlights(game_id) if not text or not text.strip(): c.privmsg(target, "No highlights found for that game.") return blocks = [b for b in text.split("\n\n") if b.strip()] if not blocks: c.privmsg(target, "No highlights found for that game.") return for block in blocks[:3]: block_lines = [l for l in block.split("\n") if l.strip()] if block_lines: self.send_line(c, target, colorize_team_names_in_text(block_lines[0])) if len(block_lines) > 1: self.send_line(c, target, colorize_team_names_in_text(block_lines[-1])) except: c.privmsg(target, "Error retrieving highlights.") # Friendly stat names -> list of (statCode, statGroup) candidates, tried in order until # one returns data. statGroup matters: without it, ambiguous stat names like battingAverage # can resolve to the wrong side (e.g. a pitcher's "average against" instead of real hitters' # batting average). Only homeRuns/battingAverage/earnedRunAverage are confirmed against the # live API so far; the rest follow the same camelCase convention but haven't all been # individually verified. STAT_ALIASES = { "hr": [("homeRuns", "hitting")], "homers": [("homeRuns", "hitting")], "homeruns": [("homeRuns", "hitting")], "avg": [("battingAverage", "hitting")], "average": [("battingAverage", "hitting")], "battingaverage": [("battingAverage", "hitting")], "rbi": [("rbi", "hitting"), ("runsBattedIn", "hitting")], "rbis": [("rbi", "hitting"), ("runsBattedIn", "hitting")], "sb": [("stolenBases", "hitting"), ("stolenBase", "hitting")], "stolenbase": [("stolenBases", "hitting"), ("stolenBase", "hitting")], "stolenbases": [("stolenBases", "hitting"), ("stolenBase", "hitting")], "obp": [("onBasePercentage", "hitting")], "slg": [("sluggingPercentage", "hitting")], "ops": [("onBasePlusSlugging", "hitting"), ("ops", "hitting")], "era": [("earnedRunAverage", "pitching")], "so": [("strikeouts", "pitching")], "k": [("strikeouts", "pitching")], "strikeouts": [("strikeouts", "pitching")], "wins": [("wins", "pitching")], "w": [("wins", "pitching")], "saves": [("saves", "pitching")], "whip": [("whip", "pitching")], } def handle_leaders(self, c, target, msg): try: parts = msg.split() if len(parts) < 2: c.privmsg(target, "Usage: !leaders [stat] (e.g. hr, avg, rbi, sb, obp, slg, ops, era, so, wins, saves, whip)") return raw_stat = " ".join(parts[1:]).strip() key = raw_stat.lower().replace(" ", "").replace("_", "") candidates = self.STAT_ALIASES.get(key, [(raw_stat, None)]) text = None for stat_code, stat_group in candidates: try: kwargs = {"limit": 5} if stat_group: kwargs["statGroup"] = stat_group candidate_text = statsapi.league_leaders(stat_code, **kwargs) if candidate_text and candidate_text.strip(): text = candidate_text break except Exception as e: print(f"Leaders lookup error for stat '{stat_code}' (group={stat_group}): {e}") continue if not text: c.privmsg(target, f"No leader data found for '{raw_stat}'. Try hr, avg, rbi, sb, obp, slg, ops, era, so, wins, saves, or whip.") return lines = [l for l in text.split("\n") if l.strip()] if not lines: c.privmsg(target, "No leader data found for that stat.") return for l in lines: self.send_line(c, target, colorize_team_names_in_text(l)) except: c.privmsg(target, "Error retrieving leaders. Try a stat like hr, avg, or era.") def handle_roster(self, c, target, msg): try: search = msg[len("!roster"):].strip() if not search: c.privmsg(target, "Usage: !roster [team]") return team_id = get_team_id(search) if not team_id: c.privmsg(target, "Team not found.") return text = statsapi.roster(team_id) raw_lines = [l for l in text.split("\n") if l.strip()] if not raw_lines: c.privmsg(target, "No roster data found.") return players = [] for line in raw_lines: fields = re.split(r"\s{2,}", line.strip()) if len(fields) >= 3: # e.g. ['#19', 'LF', 'Some Player'] players.append(f"{fields[1]} {fields[2]}") elif len(fields) == 2: players.append(fields[1]) else: players.append(line.strip()) # Resolve a display name for the header from whatever the caller searched for team_lookup = statsapi.lookup_team(search) team_display = colorize_team(team_lookup[0]['name']) if team_lookup else colorize_team(search) roster_line = f"{team_display} Roster: " + " - ".join(players) # Safety net: IRC lines are capped around 512 bytes total (incl. protocol overhead). # If a full active roster pushes past a safe length, split into a couple of messages # instead of risking server-side truncation. max_len = 420 if len(roster_line) <= max_len: c.privmsg(target, roster_line) else: header = f"{team_display} Roster: " chunk = header first_in_chunk = True for p in players: piece = p if first_in_chunk else f" - {p}" if len(chunk) + len(piece) > max_len: self.send_line(c, target, chunk) chunk = p first_in_chunk = False else: chunk += piece first_in_chunk = False if chunk: c.privmsg(target, chunk) except: c.privmsg(target, "Error retrieving roster.") # Mapping of friendly names to real MLB award IDs. "cyyoung" has been confirmed working # against the live API; mvp/roy/hof follow the same AL*/NL* naming pattern but haven't # been explicitly tested yet. AWARD_ALIASES = { "mvp": ["ALMVP", "NLMVP"], "cy": ["ALCY", "NLCY"], "cyyoung": ["ALCY", "NLCY"], "roy": ["ALROY", "NLROY"], "rookie": ["ALROY", "NLROY"], "hof": ["MLBHOF"], "hall": ["MLBHOF"], } def handle_award(self, c, target, msg): try: parts = msg.split() if len(parts) < 2: c.privmsg(target, "Usage: !award [mvp|cyyoung|roy|hof] [year] [al|nl] (e.g. !award mvp 2024 al)") return arg = parts[1].lower() remaining = parts[2:] year = None league_filter = None for token in remaining: if token.isdigit() and len(token) == 4: year = token elif token.lower() in ("al", "nl"): league_filter = token.lower() if not year: year = str(self.current_year) if arg in self.AWARD_ALIASES: award_ids = list(self.AWARD_ALIASES[arg]) if league_filter == "al": award_ids = [a for a in award_ids if a.startswith("AL")] elif league_filter == "nl": award_ids = [a for a in award_ids if a.startswith("NL")] else: # Not a known alias -- treat it as a literal award ID (e.g. "!award MLBHOF") award_ids = [parts[1].upper()] results = [] for aid in award_ids: try: data = statsapi.get('awards', {'awardId': aid, 'season': year, 'recipients': True}) recipients = data.get('awards', []) if isinstance(data, dict) else [] if not recipients: print(f"Award debug [{aid} {year}] empty/unexpected response shape: {data}") if recipients: rec = recipients[0] # Try several plausible schemas for the player's name -- the exact # field layout differs by award type and isn't verified against the # live API from this sandbox. player_name = ( rec.get('player', {}).get('fullName') or rec.get('player', {}).get('nameFirstLast') or rec.get('recipient') or (rec.get('players', [{}])[0].get('fullName') if rec.get('players') else None) ) team_name = ( rec.get('team', {}).get('name') or rec.get('teamName') or '' ) if not player_name: # Couldn't find a name field we recognize -- dump the raw keys/record # to the console (not IRC) so the actual schema can be inspected and # the field lookup above corrected. print(f"Award debug [{aid} {year}] unrecognized recipient structure: {rec}") player_name = "Unknown" line = f"{aid} ({year}): {player_name}" if team_name: line += f" - {colorize_team(team_name)}" results.append(line) except Exception as e: print(f"Award lookup error for {aid} {year}: {e}") continue if not results: c.privmsg(target, f"No award data found for '{parts[1]}' in {year}.") return for line in results: self.send_line(c, target, line) except: c.privmsg(target, "Error retrieving award data.") if __name__ == "__main__": bot = BaseballBot(CHANNELS, NICKNAME, SERVER, PORT) bot.start()