1489 lines
62 KiB
Python
1489 lines
62 KiB
Python
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'<span style="{";".join(styles)}">')
|
|
return True
|
|
return False
|
|
|
|
while i < n:
|
|
ch = text[i]
|
|
|
|
if ch == "\x03": # color code, optionally NN[,MM]
|
|
if span_open:
|
|
result.append("</span>")
|
|
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("</span>")
|
|
bold = not bold
|
|
span_open = open_span()
|
|
i += 1
|
|
continue
|
|
|
|
if ch == "\x1D": # italic
|
|
if span_open:
|
|
result.append("</span>")
|
|
italic = not italic
|
|
span_open = open_span()
|
|
i += 1
|
|
continue
|
|
|
|
if ch == "\x1F": # underline
|
|
if span_open:
|
|
result.append("</span>")
|
|
underline = not underline
|
|
span_open = open_span()
|
|
i += 1
|
|
continue
|
|
|
|
if ch == "\x0F": # reset all
|
|
if span_open:
|
|
result.append("</span>")
|
|
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("</span>")
|
|
fg, bg = bg, fg
|
|
span_open = open_span()
|
|
i += 1
|
|
continue
|
|
|
|
result.append(ch)
|
|
i += 1
|
|
|
|
if span_open:
|
|
result.append("</span>")
|
|
|
|
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 <a> tags."""
|
|
def _sub(m):
|
|
url = m.group(1)
|
|
return f'<a href="{url}" target="_blank" rel="noopener">{url}</a>'
|
|
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()
|