From 7e01b2d456d31da43db9e0d38be67354a555c265 Mon Sep 17 00:00:00 2001 From: tpuahsiew Date: Fri, 25 Sep 2026 11:07:08 +0000 Subject: [PATCH] Initial commit: Basedball IRC bot --- .gitignore | 4 + bot.py | 1488 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1492 insertions(+) create mode 100644 .gitignore create mode 100644 bot.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b822711 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +venv/ +__pycache__/ +*.bak* +*.json diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..2e1b8bb --- /dev/null +++ b/bot.py @@ -0,0 +1,1488 @@ +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()