From ee902a808e8d4ab5762fd9576df31ce936e597ff Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:22:11 -0700 Subject: [PATCH 01/13] feat: add NHC tropical cyclone auto-posting Routes TCV/TCD/TWD/TCU products from IEMBot botstalk feed and NWWS XMPP to a new TropicalCog. Posts formatted embeds with storm classification, name, summary, and product type to a Discord channel. Uses dev channel for testing, production channel when ready. --- cogs/__init__.py | 1 + cogs/iembot.py | 12 +++ cogs/nwws.py | 11 +++ cogs/tropical.py | 210 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 234 insertions(+) create mode 100644 cogs/tropical.py diff --git a/cogs/__init__.py b/cogs/__init__.py index 876222e5..6e1e2781 100644 --- a/cogs/__init__.py +++ b/cogs/__init__.py @@ -24,4 +24,5 @@ "cogs.verification", "cogs.subscriptions", "cogs.radar_history", + "cogs.tropical", ] diff --git a/cogs/iembot.py b/cogs/iembot.py index 0f34befb..75f114d5 100644 --- a/cogs/iembot.py +++ b/cogs/iembot.py @@ -373,6 +373,18 @@ async def poll_botstalk_feed(self): t = asyncio.create_task(self._handle_warning(product_id, pil_match.group(1))) t.add_done_callback(_log_task_exception) + # Route NHC tropical products (TCV, TCD, TWD, TCU) + if any(pil in product_id.upper() for pil in ("-TCV", "-TCD", "-TWD", "-TCU")): + tropical_cog = self.bot.get_cog("Tropical") + if tropical_cog: + text = await _fetch_product_text(product_id) + t = asyncio.create_task( + tropical_cog.post_tropical_product( + product_id, text or "", source="IEMBot" + ) + ) + t.add_done_callback(_log_task_exception) + if new_seqnum > self.bot.state.iembot_botstalk_last_seqnum: self.bot.state.iembot_botstalk_last_seqnum = new_seqnum await set_state("iembot_botstalk_last_seqnum", str(new_seqnum)) diff --git a/cogs/nwws.py b/cogs/nwws.py index ffc58bc0..5cfa46b7 100644 --- a/cogs/nwws.py +++ b/cogs/nwws.py @@ -587,6 +587,17 @@ async def _process_nwws_message( await reports_cog.post_report_now(product_id, raw_text, pil_prefix) logger.info(f"Triggered {pil_prefix} via XMPP") + elif any(afos_pil.startswith(x) for x in ("TCV", "TCD", "TWD", "TCU")): + tropical_cog = self.bot.get_cog("Tropical") + if tropical_cog: + pil_prefix = next( + p for p in ("TCV", "TCD", "TWD", "TCU") if afos_pil.startswith(p) + ) + await tropical_cog.post_tropical_product( + product_id, raw_text, pil_prefix, source="NWWS" + ) + logger.info(f"Triggered NHC {pil_prefix} via XMPP") + except Exception as e: logger.exception(f"Error processing XMPP message: {e}") diff --git a/cogs/tropical.py b/cogs/tropical.py new file mode 100644 index 00000000..5d7d7637 --- /dev/null +++ b/cogs/tropical.py @@ -0,0 +1,210 @@ +"""NHC Tropical cyclone product auto-poster.""" + +import logging +import re +from datetime import datetime, timezone + +import discord +from discord.ext import commands + +from config import IEM_NWSTEXT_URL +from utils.http import http_get_bytes + +logger = logging.getLogger("spc_bot") + +DEV_CHANNEL_ID = 1336294580743704607 +PROD_CHANNEL_ID = 981540312688230420 + +TROPICAL_PILS = {"TCV", "TCD", "TWD", "TCU"} +TROPICAL_OFFICES = {"KNHC", "KTPC", "PHFO"} + +_NHC_LABEL_RE = re.compile( + r"(?:NATIONAL HURRICANE CENTER|NHC|TROPICAL|HURRICANE|TROPICAL STORM)", re.IGNORECASE +) + +STORM_EMOJI = { + "TROPICAL DEPRESSION": "🌨️", + "TROPICAL STORM": "🌧️", + "HURRICANE": "🌀", + "MAJOR HURRICANE": "🌀", + "POTENTIAL TROPICAL CYCLONE": "⚠️", + "POST-TROPICAL CYCLONE": "🌬️", + "SUBTROPICAL DEPRESSION": "🌨️", + "SUBTROPICAL STORM": "🌧️", + "REMNANTS": "💨", +} + +STORM_TYPE_ORDER = [ + "REMNANTS", + "POST-TROPICAL CYCLONE", + "TROPICAL DEPRESSION", + "SUBTROPICAL DEPRESSION", + "SUBTROPICAL STORM", + "TROPICAL STORM", + "HURRICANE", + "MAJOR HURRICANE", +] + + +def _classify_storm_type(text: str) -> str: + upper = text.upper() + for t in STORM_TYPE_ORDER: + if t in upper: + return t + if "TROPICAL CYCLONE" in upper: + return "TROPICAL CYCLONE" + return None + + +def _extract_storm_name(text: str) -> str: + lines = text.splitlines() + for i, line in enumerate(lines): + upper = line.upper() + for t in STORM_TYPE_ORDER: + if t in upper: + parts = upper.split(t, 1) + if len(parts) > 1: + name = parts[1].strip().strip(".").strip() + return name.title() + return None + + +def _classify_product(product_id: str) -> str: + """Determine product type from product_id.""" + pid = product_id.upper() + if "TCV" in pid: + return "ADVISORY" + if "TCD" in pid: + return "DISCUSSION" + if "TWD" in pid: + return "OUTLOOK" + if "TCU" in pid: + return "UPDATE" + return None + + +async def _fetch_nhc_product(product_id: str) -> dict: + """Fetch and parse an NHC product from the IEM archive.""" + url = IEM_NWSTEXT_URL.format(product_id=product_id) + content, status = await http_get_bytes(url, retries=2, timeout=10) + if not content or status != 200: + return None + + text = content.decode("utf-8", errors="ignore") + if "not found" in text.lower() and len(text) < 100: + return None + + lines = text.splitlines() + header_text = [] + body_start = 0 + for i, line in enumerate(lines): + header_text.append(line) + if line.strip().startswith("ATTENTION") or line.strip().startswith("000"): + body_start = i + break + + summary_lines = [] + in_summary = False + for line in lines[body_start:]: + if "SUMMARY OF" in line.upper() or "SUMMARY INFORMATION" in line.upper(): + in_summary = True + if in_summary: + summary_lines.append(line) + if "FORECAST POSITIONS AND MAX WINDS" in line.upper(): + break + + summary = "\n".join(summary_lines).strip() if summary_lines else None + + storm_type = _classify_storm_type(text) + storm_name = _extract_storm_name(text) + + return { + "raw_text": text, + "summary": summary, + "storm_type": storm_type, + "storm_name": storm_name, + } + + +class TropicalCog(commands.Cog, name="Tropical"): + """Auto-posts NHC tropical cyclone products.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self._posted = set() + + async def post_tropical_product( + self, + product_id: str, + raw_text: str, + pil_prefix: str = None, + source: str = "IEMBot", + ): + dedup_key = f"tropical_{product_id}" + if dedup_key in self._posted: + return + self._posted.add(dedup_key) + if len(self._posted) > 1000: + self._posted.clear() + + product_type = pil_prefix or _classify_product(product_id) + if not product_type: + return + + parsed = await _fetch_nhc_product(product_id) + if not parsed: + return + + channel_id = DEV_CHANNEL_ID + channel = self.bot.get_channel(channel_id) + if not channel: + logger.warning(f"Tropical channel {channel_id} not found") + return + + storm_type = parsed["storm_type"] + storm_name = parsed["storm_name"] + emoji = STORM_EMOJI.get(storm_type, "🌀") + + title = f"{emoji} NHC {product_type}" + if storm_name: + title += f" — {storm_name}" + if storm_type: + title += f" ({storm_type})" + + embed = discord.Embed( + title=title, + color=discord.Color.orange(), + timestamp=datetime.now(timezone.utc), + ) + + for line in parsed.get("summary", "").splitlines()[:5]: + clean = line.strip() + if clean: + embed.add_field(name="Summary", value=clean[:1024], inline=False) + break + + if parsed["summary"]: + full_summary = parsed["summary"][:2048] + embed.description = full_summary + + embed.set_footer(text=f"{source} | {product_id}") + + try: + await channel.send(embed=embed) + logger.info(f"Posted tropical {product_type} for {storm_name or product_id}") + except Exception as e: + logger.exception(f"Failed to post tropical product: {e}") + + async def route_from_product_id( + self, product_id: str, raw_text: str = None, source: str = "IEMBot" + ): + pil_prefix = _classify_product(product_id) + if not pil_prefix: + return False + + await self.post_tropical_product(product_id, raw_text or "", pil_prefix, source) + return True + + +async def setup(bot: commands.Bot): + await bot.add_cog(TropicalCog(bot)) From 89adc71ba06637712b0e2df7fd0ca34fdc0582d4 Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:26:15 -0700 Subject: [PATCH 02/13] feat: add NHC IEMBot feed poller with botstalk fallback --- cogs/iembot.py | 76 +++++++++++++++++++++++++++++++++++- config.py | 1 + config/products.json | 91 +++++++++++++++++++++----------------------- 3 files changed, 119 insertions(+), 49 deletions(-) diff --git a/cogs/iembot.py b/cogs/iembot.py index 75f114d5..c019771e 100644 --- a/cogs/iembot.py +++ b/cogs/iembot.py @@ -20,7 +20,7 @@ from discord.ext import commands, tasks -from config import IEM_NWSTEXT_URL, IEMBOT_BOTSTALK_URL, IEMBOT_FEED_URL +from config import IEM_NWSTEXT_URL, IEMBOT_BOTSTALK_URL, IEMBOT_FEED_URL, IEMBOT_NHC_URL from utils.http import http_get_bytes from utils.state_store import get_product_cache, get_state, set_product_cache, set_state @@ -104,20 +104,24 @@ class IEMBotCog(commands.Cog): MANAGED_TASK_NAMES = [ ("poll_iembot_feed", "poll_iembot_feed"), ("poll_botstalk_feed", "poll_botstalk_feed"), + ("poll_nhc_feed", "poll_nhc_feed"), ] def __init__(self, bot: commands.Bot): self.bot = bot self._seqnum_loaded = False self._botstalk_seqnum_loaded = False + self._nhc_seqnum_loaded = False async def cog_load(self): self.poll_iembot_feed.start() self.poll_botstalk_feed.start() + self.poll_nhc_feed.start() def cog_unload(self): self.poll_iembot_feed.cancel() self.poll_botstalk_feed.cancel() + self.poll_nhc_feed.cancel() @tasks.loop(seconds=15) async def poll_iembot_feed(self): @@ -392,6 +396,61 @@ async def poll_botstalk_feed(self): except Exception as e: logger.warning(f"Botstalk poll error: {e}") + # ── NHC feed poller ────────────────────────────────────────── + + @tasks.loop(seconds=30) + async def poll_nhc_feed(self): + await self.bot.wait_until_ready() + if not self.bot.state.is_primary: + return + + if not self._nhc_seqnum_loaded: + try: + val = await get_state("iembot_nhc_last_seqnum") + if val: + self.bot.state.iembot_nhc_last_seqnum = int(val) + logger.info(f"Resuming nhc from seqnum {self.bot.state.iembot_nhc_last_seqnum}") + except Exception as e: + logger.warning(f"Could not load nhc seqnum: {e}") + self._nhc_seqnum_loaded = True + + try: + url = f"{IEMBOT_NHC_URL}?seqnum={getattr(self.bot.state, 'iembot_nhc_last_seqnum', 0)}" + content, status = await http_get_bytes(url, retries=2, timeout=10) + if not content or status != 200: + return + + data = _json.loads(content) + messages = data.get("messages", []) + if not messages: + return + + new_seqnum = getattr(self.bot.state, "iembot_nhc_last_seqnum", 0) + for msg in messages: + seqnum = msg.get("seqnum", 0) + if seqnum <= new_seqnum: + continue + new_seqnum = max(new_seqnum, seqnum) + + product_id = msg.get("product_id", "") + if not product_id: + continue + + text = await _fetch_product_text(product_id) + tropical_cog = self.bot.get_cog("Tropical") + if text and tropical_cog: + t = asyncio.create_task( + tropical_cog.post_tropical_product(product_id, text, source="IEMBot-NHC") + ) + t.add_done_callback(_log_task_exception) + + if new_seqnum > getattr(self.bot.state, "iembot_nhc_last_seqnum", 0): + self.bot.state.iembot_nhc_last_seqnum = new_seqnum + await set_state("iembot_nhc_last_seqnum", str(new_seqnum)) + + except Exception as e: + logger.warning(f"NHC poll error: {e}") + _PIL_TO_EVENT = { "TOR": "Tornado Warning", "SVR": "Severe Thunderstorm Warning", @@ -443,6 +502,21 @@ async def after_botstalk_loop(self): exc_info=exc, ) + @poll_nhc_feed.after_loop + async def after_nhc_loop(self): + if self.poll_nhc_feed.is_being_cancelled(): + return + task = self.poll_nhc_feed.get_task() + try: + exc = task.exception() if task else None + except Exception: + exc = None + if exc: + logger.error( + f"[TASK] poll_nhc_feed stopped: {type(exc).__name__}: {exc}", + exc_info=exc, + ) + async def setup(bot: commands.Bot): await bot.add_cog(IEMBotCog(bot)) diff --git a/config.py b/config.py index e8995313..19b13354 100644 --- a/config.py +++ b/config.py @@ -178,6 +178,7 @@ def _optional_int(*names: str) -> int: NWS_ALERTS_WARNINGS_URL = _P["nws_alerts_warnings_url"] IEMBOT_FEED_URL = _P["iembot_feed_url"] IEMBOT_BOTSTALK_URL = _P["iembot_botstalk_url"] +IEMBOT_NHC_URL = _P["iembot_nhc_url"] IEM_NWSTEXT_URL = _P["iem_nwstext_url"] WXNEXT_BASE = _P["wxnext_base_url"] WXNEXT_PAGE = _P["wxnext_page_url"] diff --git a/config/products.json b/config/products.json index eb673f93..7f59cc25 100644 --- a/config/products.json +++ b/config/products.json @@ -1,52 +1,47 @@ { - "spc_schedule": { - "1": [1, 6, 13, 20], - "2": [2, 13], - "3": [3, 15] - }, - "spc_outlook_base": "https://www.spc.noaa.gov/products/outlook", - "spc_urls_fallback": { - "1": [ - "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", - "https://www.spc.noaa.gov/products/outlook/day1probotlk_torn.gif", - "https://www.spc.noaa.gov/products/outlook/day1probotlk_wind.gif", - "https://www.spc.noaa.gov/products/outlook/day1probotlk_hail.gif" + "spc_schedule": {"1": [1, 6, 13, 20], "2": [2, 13], "3": [3, 15]}, + "spc_outlook_base": "https://www.spc.noaa.gov/products/outlook", + "spc_urls_fallback": { + "1": [ + "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", + "https://www.spc.noaa.gov/products/outlook/day1probotlk_torn.gif", + "https://www.spc.noaa.gov/products/outlook/day1probotlk_wind.gif", + "https://www.spc.noaa.gov/products/outlook/day1probotlk_hail.gif", + ], + "2": [ + "https://www.spc.noaa.gov/products/outlook/day2otlk.gif", + "https://www.spc.noaa.gov/products/outlook/day2probotlk_torn.gif", + "https://www.spc.noaa.gov/products/outlook/day2probotlk_wind.gif", + "https://www.spc.noaa.gov/products/outlook/day2probotlk_hail.gif", + ], + "3": [ + "https://www.spc.noaa.gov/products/outlook/day3otlk.gif", + "https://www.spc.noaa.gov/products/outlook/day3prob.gif", + ], + "48": ["https://www.spc.noaa.gov/products/exper/day4-8/day48prob.gif"], + }, + "scp_image_urls": [ + "https://atlas.niu.edu/forecast/scp/cfs_week1.png", + "https://atlas.niu.edu/forecast/scp/cfs_week2.png", + "https://atlas.niu.edu/forecast/scp/cfs_week3.png", + "https://atlas.niu.edu/forecast/scp/gefs_week1__CTRL.png", + "https://atlas.niu.edu/forecast/scp/gefs_week2__CTRL.png", ], - "2": [ - "https://www.spc.noaa.gov/products/outlook/day2otlk.gif", - "https://www.spc.noaa.gov/products/outlook/day2probotlk_torn.gif", - "https://www.spc.noaa.gov/products/outlook/day2probotlk_wind.gif", - "https://www.spc.noaa.gov/products/outlook/day2probotlk_hail.gif" + "wpc_image_urls": [ + "https://www.wpc.ncep.noaa.gov/qpf/94ewbg.gif", + "https://www.wpc.ncep.noaa.gov/qpf/98ewbg.gif", + "https://www.wpc.ncep.noaa.gov/qpf/99ewbg.gif", ], - "3": [ - "https://www.spc.noaa.gov/products/outlook/day3otlk.gif", - "https://www.spc.noaa.gov/products/outlook/day3prob.gif" - ], - "48": [ - "https://www.spc.noaa.gov/products/exper/day4-8/day48prob.gif" - ] - }, - "scp_image_urls": [ - "https://atlas.niu.edu/forecast/scp/cfs_week1.png", - "https://atlas.niu.edu/forecast/scp/cfs_week2.png", - "https://atlas.niu.edu/forecast/scp/cfs_week3.png", - "https://atlas.niu.edu/forecast/scp/gefs_week1__CTRL.png", - "https://atlas.niu.edu/forecast/scp/gefs_week2__CTRL.png" - ], - "wpc_image_urls": [ - "https://www.wpc.ncep.noaa.gov/qpf/94ewbg.gif", - "https://www.wpc.ncep.noaa.gov/qpf/98ewbg.gif", - "https://www.wpc.ncep.noaa.gov/qpf/99ewbg.gif" - ], - "spc_md_index_url": "https://www.spc.noaa.gov/products/md/", - "spc_watch_index_url": "https://www.spc.noaa.gov/products/watch/", - "spc_valid_watches_url": "https://www.spc.noaa.gov/products/watch/validww.png", - "nws_alerts_url": "https://api.weather.gov/alerts/active?event=Severe%20Thunderstorm%20Watch,Tornado%20Watch&status=actual", - "nws_alerts_warnings_url": "https://api.weather.gov/alerts/active?event=Tornado%20Warning,Severe%20Thunderstorm%20Warning,Flash%20Flood%20Warning&status=actual", - "iembot_feed_url": "https://weather.im/iembot-json/room/spcchat", - "iembot_botstalk_url": "https://weather.im/iembot-json/room/botstalk", - "iem_nwstext_url": "https://mesonet.agron.iastate.edu/api/1/nwstext/{product_id}", - "wxnext_base_url": "https://www2.mmm.ucar.edu/projects/ncar_ensemble/ainwp/img", - "wxnext_page_url": "https://www2.mmm.ucar.edu/projects/ncar_ensemble/ainwp/", - "spc_day1_categorical_geojson_url": "https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson" + "spc_md_index_url": "https://www.spc.noaa.gov/products/md/", + "spc_watch_index_url": "https://www.spc.noaa.gov/products/watch/", + "spc_valid_watches_url": "https://www.spc.noaa.gov/products/watch/validww.png", + "nws_alerts_url": "https://api.weather.gov/alerts/active?event=Severe%20Thunderstorm%20Watch,Tornado%20Watch&status=actual", + "nws_alerts_warnings_url": "https://api.weather.gov/alerts/active?event=Tornado%20Warning,Severe%20Thunderstorm%20Warning,Flash%20Flood%20Warning&status=actual", + "iembot_feed_url": "https://weather.im/iembot-json/room/spcchat", + "iembot_botstalk_url": "https://weather.im/iembot-json/room/botstalk", + "iembot_nhc_url": "https://weather.im/iembot-json/room/nhc", + "iem_nwstext_url": "https://mesonet.agron.iastate.edu/api/1/nwstext/{product_id}", + "wxnext_base_url": "https://www2.mmm.ucar.edu/projects/ncar_ensemble/ainwp/img", + "wxnext_page_url": "https://www2.mmm.ucar.edu/projects/ncar_ensemble/ainwp/", + "spc_day1_categorical_geojson_url": "https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson", } From 8d5f204d7ecf0621cdc6cf215f57b751a0a3bd9b Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:27:35 -0700 Subject: [PATCH 03/13] fix: add TWO PIL and restore valid JSON --- .wiki | 1 + cogs/iembot.py | 6 ++++-- config/products.json | 14 +++++++------- 3 files changed, 12 insertions(+), 9 deletions(-) create mode 160000 .wiki diff --git a/.wiki b/.wiki new file mode 160000 index 00000000..869e0f95 --- /dev/null +++ b/.wiki @@ -0,0 +1 @@ +Subproject commit 869e0f950b9d4f8d6caf7f1ef2edd3e6c0de2431 diff --git a/cogs/iembot.py b/cogs/iembot.py index c019771e..e6a457c7 100644 --- a/cogs/iembot.py +++ b/cogs/iembot.py @@ -377,8 +377,10 @@ async def poll_botstalk_feed(self): t = asyncio.create_task(self._handle_warning(product_id, pil_match.group(1))) t.add_done_callback(_log_task_exception) - # Route NHC tropical products (TCV, TCD, TWD, TCU) - if any(pil in product_id.upper() for pil in ("-TCV", "-TCD", "-TWD", "-TCU")): + # Route NHC tropical products + if any( + pil in product_id.upper() for pil in ("-TCV", "-TCD", "-TWD", "-TCU", "-TWO") + ): tropical_cog = self.bot.get_cog("Tropical") if tropical_cog: text = await _fetch_product_text(product_id) diff --git a/config/products.json b/config/products.json index 7f59cc25..82bcab50 100644 --- a/config/products.json +++ b/config/products.json @@ -6,31 +6,31 @@ "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", "https://www.spc.noaa.gov/products/outlook/day1probotlk_torn.gif", "https://www.spc.noaa.gov/products/outlook/day1probotlk_wind.gif", - "https://www.spc.noaa.gov/products/outlook/day1probotlk_hail.gif", + "https://www.spc.noaa.gov/products/outlook/day1probotlk_hail.gif" ], "2": [ "https://www.spc.noaa.gov/products/outlook/day2otlk.gif", "https://www.spc.noaa.gov/products/outlook/day2probotlk_torn.gif", "https://www.spc.noaa.gov/products/outlook/day2probotlk_wind.gif", - "https://www.spc.noaa.gov/products/outlook/day2probotlk_hail.gif", + "https://www.spc.noaa.gov/products/outlook/day2probotlk_hail.gif" ], "3": [ "https://www.spc.noaa.gov/products/outlook/day3otlk.gif", - "https://www.spc.noaa.gov/products/outlook/day3prob.gif", + "https://www.spc.noaa.gov/products/outlook/day3prob.gif" ], - "48": ["https://www.spc.noaa.gov/products/exper/day4-8/day48prob.gif"], + "48": ["https://www.spc.noaa.gov/products/exper/day4-8/day48prob.gif"] }, "scp_image_urls": [ "https://atlas.niu.edu/forecast/scp/cfs_week1.png", "https://atlas.niu.edu/forecast/scp/cfs_week2.png", "https://atlas.niu.edu/forecast/scp/cfs_week3.png", "https://atlas.niu.edu/forecast/scp/gefs_week1__CTRL.png", - "https://atlas.niu.edu/forecast/scp/gefs_week2__CTRL.png", + "https://atlas.niu.edu/forecast/scp/gefs_week2__CTRL.png" ], "wpc_image_urls": [ "https://www.wpc.ncep.noaa.gov/qpf/94ewbg.gif", "https://www.wpc.ncep.noaa.gov/qpf/98ewbg.gif", - "https://www.wpc.ncep.noaa.gov/qpf/99ewbg.gif", + "https://www.wpc.ncep.noaa.gov/qpf/99ewbg.gif" ], "spc_md_index_url": "https://www.spc.noaa.gov/products/md/", "spc_watch_index_url": "https://www.spc.noaa.gov/products/watch/", @@ -43,5 +43,5 @@ "iem_nwstext_url": "https://mesonet.agron.iastate.edu/api/1/nwstext/{product_id}", "wxnext_base_url": "https://www2.mmm.ucar.edu/projects/ncar_ensemble/ainwp/img", "wxnext_page_url": "https://www2.mmm.ucar.edu/projects/ncar_ensemble/ainwp/", - "spc_day1_categorical_geojson_url": "https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson", + "spc_day1_categorical_geojson_url": "https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson" } From ead6d5d4abc38d11bdaa7c4d44440ab5de9dc55e Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:32:50 -0700 Subject: [PATCH 04/13] feat: add all NHC product types (TWO, TCE, TCP) to routing --- cogs/iembot.py | 3 ++- cogs/nwws.py | 9 +++++---- cogs/tropical.py | 25 +++++++++++++++---------- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/cogs/iembot.py b/cogs/iembot.py index e6a457c7..383b97c7 100644 --- a/cogs/iembot.py +++ b/cogs/iembot.py @@ -379,7 +379,8 @@ async def poll_botstalk_feed(self): # Route NHC tropical products if any( - pil in product_id.upper() for pil in ("-TCV", "-TCD", "-TWD", "-TCU", "-TWO") + pil in product_id.upper() + for pil in ("-TCV", "-TCD", "-TWD", "-TCU", "-TWO", "-TCE", "-TCP") ): tropical_cog = self.bot.get_cog("Tropical") if tropical_cog: diff --git a/cogs/nwws.py b/cogs/nwws.py index 5cfa46b7..000d4667 100644 --- a/cogs/nwws.py +++ b/cogs/nwws.py @@ -587,12 +587,13 @@ async def _process_nwws_message( await reports_cog.post_report_now(product_id, raw_text, pil_prefix) logger.info(f"Triggered {pil_prefix} via XMPP") - elif any(afos_pil.startswith(x) for x in ("TCV", "TCD", "TWD", "TCU")): + elif any( + afos_pil.startswith(x) for x in ("TCV", "TCD", "TWD", "TCU", "TWO", "TCE", "TCP") + ): tropical_cog = self.bot.get_cog("Tropical") if tropical_cog: - pil_prefix = next( - p for p in ("TCV", "TCD", "TWD", "TCU") if afos_pil.startswith(p) - ) + nhc_pils = ("TCV", "TCD", "TWD", "TCU", "TWO", "TCE", "TCP") + pil_prefix = next(p for p in nhc_pils if afos_pil.startswith(p)) await tropical_cog.post_tropical_product( product_id, raw_text, pil_prefix, source="NWWS" ) diff --git a/cogs/tropical.py b/cogs/tropical.py index 5d7d7637..f0c27153 100644 --- a/cogs/tropical.py +++ b/cogs/tropical.py @@ -15,7 +15,7 @@ DEV_CHANNEL_ID = 1336294580743704607 PROD_CHANNEL_ID = 981540312688230420 -TROPICAL_PILS = {"TCV", "TCD", "TWD", "TCU"} +TROPICAL_PILS = {"TCV", "TCD", "TWD", "TCU", "TWO", "TCE", "TCP"} TROPICAL_OFFICES = {"KNHC", "KTPC", "PHFO"} _NHC_LABEL_RE = re.compile( @@ -69,17 +69,22 @@ def _extract_storm_name(text: str) -> str: return None +NHC_PRODUCT_NAMES = { + "TCV": "ADVISORY", + "TCD": "DISCUSSION", + "TWD": "TROPICAL WEATHER DISCUSSION", + "TWO": "TROPICAL WEATHER OUTLOOK", + "TCU": "UPDATE", + "TCE": "POSITION ESTIMATE", + "TCP": "PROBABILITIES", +} + + def _classify_product(product_id: str) -> str: - """Determine product type from product_id.""" pid = product_id.upper() - if "TCV" in pid: - return "ADVISORY" - if "TCD" in pid: - return "DISCUSSION" - if "TWD" in pid: - return "OUTLOOK" - if "TCU" in pid: - return "UPDATE" + for pil, name in NHC_PRODUCT_NAMES.items(): + if pil in pid: + return name return None From 45d3dcd4ac98e80f854505b6b3093505d80e7f80 Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:44:23 -0700 Subject: [PATCH 05/13] feat: add NWWS ring buffer for debugging --- cogs/nwws.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/cogs/nwws.py b/cogs/nwws.py index 000d4667..20291ef9 100644 --- a/cogs/nwws.py +++ b/cogs/nwws.py @@ -12,6 +12,9 @@ 3. API Polling (Tertiary Safety Net) """ +from collections import deque +from datetime import datetime, timezone + import asyncio import logging import re @@ -28,6 +31,15 @@ logger = logging.getLogger("spc_bot") +# Ring buffer for the last N NWWS messages (for testing/debugging) +NWWS_RING_BUFFER = deque(maxlen=200) + + +def ring_buffer_snapshot(): + """Return a list of recent NWWS messages for inspection.""" + return list(NWWS_RING_BUFFER) + + # Rust core fallback try: import spc_rust_core @@ -493,6 +505,20 @@ async def _process_nwws_message( issue_str = payload["issue"] or time.strftime("%Y%m%d%H%M", time.gmtime()) product_id = normalize_product_id(office, ttaaii, afos_pil, issue_str) + # Ring buffer for debugging + NWWS_RING_BUFFER.append( + { + "ts": received_at.isoformat() + if hasattr(received_at, "isoformat") + else str(received_at), + "product_id": product_id, + "afos_pil": afos_pil, + "office": office, + "ttaaii": ttaaii, + "headline": (raw_text or "")[:120].replace("\n", " ").strip(), + } + ) + if not is_archived: issue_val = payload["issue"] or issue_str try: From 9376a671a8884a4898333e2cf9836a262ae4cc21 Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:44:54 -0700 Subject: [PATCH 06/13] fix: remove unused datetime imports from nwws --- cogs/nwws.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cogs/nwws.py b/cogs/nwws.py index 20291ef9..4b1bcbfd 100644 --- a/cogs/nwws.py +++ b/cogs/nwws.py @@ -13,7 +13,6 @@ """ from collections import deque -from datetime import datetime, timezone import asyncio import logging From b7a7af82fc336f041176fc543ebe32871207d5cb Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:46:36 -0700 Subject: [PATCH 07/13] feat: add /nwws_buffer debug command --- cogs/tropical.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/cogs/tropical.py b/cogs/tropical.py index f0c27153..71665cd1 100644 --- a/cogs/tropical.py +++ b/cogs/tropical.py @@ -210,6 +210,33 @@ async def route_from_product_id( await self.post_tropical_product(product_id, raw_text or "", pil_prefix, source) return True + @discord.app_commands.command( + name="nwws_buffer", + description="Show last N NWWS messages in ring buffer (debug)", + ) + @discord.app_commands.describe(count="Number to show (default 5)") + async def nwws_buffer_slash(self, interaction: discord.Interaction, count: int = 5): + await interaction.response.defer(ephemeral=True) + try: + from cogs.nwws import NWWS_RING_BUFFER + + entries = list(NWWS_RING_BUFFER)[-max(1, min(count, 50)) :] + if not entries: + await interaction.followup.send("Buffer is empty.", ephemeral=True) + return + lines = [] + for e in entries: + pil = e.get("afos_pil", "?") + pid = e.get("product_id", "?")[-40:] + headline = e.get("headline", "")[:80] + lines.append("`{}` `{}` {}".format(pil, pid, headline)) + await interaction.followup.send( + "Last {} NWWS messages:\n{}".format(len(entries), "\n".join(lines)), + ephemeral=True, + ) + except Exception as ex: + await interaction.followup.send("Error: {}".format(ex), ephemeral=True) + async def setup(bot: commands.Bot): await bot.add_cog(TropicalCog(bot)) From dcd5a4af23d33a079da75499b184584254f72e2a Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:51:23 -0700 Subject: [PATCH 08/13] fix: replace ring buffer with file-based product metadata log --- cogs/nwws.py | 42 ++++++++++++++++++++++-------------------- cogs/tropical.py | 27 --------------------------- 2 files changed, 22 insertions(+), 47 deletions(-) diff --git a/cogs/nwws.py b/cogs/nwws.py index 4b1bcbfd..042e4bda 100644 --- a/cogs/nwws.py +++ b/cogs/nwws.py @@ -12,8 +12,6 @@ 3. API Polling (Tertiary Safety Net) """ -from collections import deque - import asyncio import logging import re @@ -30,13 +28,23 @@ logger = logging.getLogger("spc_bot") -# Ring buffer for the last N NWWS messages (for testing/debugging) -NWWS_RING_BUFFER = deque(maxlen=200) +# Product metadata log (separate from firehose raw-text dump) +_NWWS_PRODUCT_LOG = None + +def _ensure_product_log(): + global _NWWS_PRODUCT_LOG + if _NWWS_PRODUCT_LOG is None: + import logging -def ring_buffer_snapshot(): - """Return a list of recent NWWS messages for inspection.""" - return list(NWWS_RING_BUFFER) + _NWWS_PRODUCT_LOG = logging.getLogger("nwws_products") + _NWWS_PRODUCT_LOG.setLevel(logging.INFO) + handler = logging.handlers.RotatingFileHandler( + "cache/nwws_products.log", maxBytes=5 * 1024 * 1024, backupCount=2 + ) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + _NWWS_PRODUCT_LOG.addHandler(handler) + return _NWWS_PRODUCT_LOG # Rust core fallback @@ -504,19 +512,13 @@ async def _process_nwws_message( issue_str = payload["issue"] or time.strftime("%Y%m%d%H%M", time.gmtime()) product_id = normalize_product_id(office, ttaaii, afos_pil, issue_str) - # Ring buffer for debugging - NWWS_RING_BUFFER.append( - { - "ts": received_at.isoformat() - if hasattr(received_at, "isoformat") - else str(received_at), - "product_id": product_id, - "afos_pil": afos_pil, - "office": office, - "ttaaii": ttaaii, - "headline": (raw_text or "")[:120].replace("\n", " ").strip(), - } - ) + try: + headline = (raw_text or "")[:120].replace("\n", " ").strip() + _ensure_product_log().info( + "%s | %s | %s | %s | %s", product_id, afos_pil, office, ttaaii, headline + ) + except Exception: + pass if not is_archived: issue_val = payload["issue"] or issue_str diff --git a/cogs/tropical.py b/cogs/tropical.py index 71665cd1..f0c27153 100644 --- a/cogs/tropical.py +++ b/cogs/tropical.py @@ -210,33 +210,6 @@ async def route_from_product_id( await self.post_tropical_product(product_id, raw_text or "", pil_prefix, source) return True - @discord.app_commands.command( - name="nwws_buffer", - description="Show last N NWWS messages in ring buffer (debug)", - ) - @discord.app_commands.describe(count="Number to show (default 5)") - async def nwws_buffer_slash(self, interaction: discord.Interaction, count: int = 5): - await interaction.response.defer(ephemeral=True) - try: - from cogs.nwws import NWWS_RING_BUFFER - - entries = list(NWWS_RING_BUFFER)[-max(1, min(count, 50)) :] - if not entries: - await interaction.followup.send("Buffer is empty.", ephemeral=True) - return - lines = [] - for e in entries: - pil = e.get("afos_pil", "?") - pid = e.get("product_id", "?")[-40:] - headline = e.get("headline", "")[:80] - lines.append("`{}` `{}` {}".format(pil, pid, headline)) - await interaction.followup.send( - "Last {} NWWS messages:\n{}".format(len(entries), "\n".join(lines)), - ephemeral=True, - ) - except Exception as ex: - await interaction.followup.send("Error: {}".format(ex), ephemeral=True) - async def setup(bot: commands.Bot): await bot.add_cog(TropicalCog(bot)) From b1cc54f3a513ad81f37317b89bda5246902277ef Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:46:39 -0700 Subject: [PATCH 09/13] fix: tropical product posting crash and default channel post_tropical_product crashed on every real NHC product because parsed["summary"] is None (not missing) for most product types, and dict.get(key, default) doesn't apply the default when the key is present with a None value. No tropical product had ever successfully posted before this fix. Also switches from the hardcoded dev channel to a configurable TROPICAL_CHANNEL_ID (defaults to the existing prod channel), with a state-store override hook for future runtime configuration. --- .env.example | 1 + CHANGELOG.md | 8 ++++ CONTRIBUTING.md | 1 + PROJECT_STRUCTURE.md | 1 + cogs/tropical.py | 28 ++++++++++--- config.py | 2 + tests/test_tropical.py | 90 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 tests/test_tropical.py diff --git a/.env.example b/.env.example index dac3b15b..bbb30291 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,7 @@ GUILD_ID = your_guild_id # MANUAL_CACHE_FILE=posted_records.json # AUTO_CACHE_FILE=auto_posted_records.json # DEV_CHANNEL_ID=your_dev_channel_id (defaults to HEALTH_CHANNEL_ID then SPC_CHANNEL_ID) +# TROPICAL_CHANNEL_ID=your_tropical_channel_id (NHC tropical cyclone products; defaults to a hardcoded production channel) # NWWS_FIREHOSE_LOG=nwws_firehose.log (relative to CACHE_DIR) # GEMINI_API_KEY=your_gemini_api_key_here (optional, for AI features — Gemini provider) # OPENCODE_API_KEY=your_opencode_api_key_here (optional, for AI features — OpenCode Zen / DeepSeek) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f870e2..21070411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- **NHC Tropical Cyclone Auto-Posting**: New `cogs/tropical.py` auto-posts National Hurricane Center products (Advisory, Discussion, Tropical Weather Discussion, Tropical Weather Outlook, Update, Position Estimate, Probabilities) as they're issued, routed from the IEMBot NHC feed with NWWS XMPP and IEMBot botstalk as fallback sources. Posts include storm type/name detection, an emoji indicator, and a summary embed. Channel routing supports a runtime override (`warning_channel:tropical` state key), independent of any specific slash command. +- **`TROPICAL_CHANNEL_ID`**: New optional `.env` variable for the tropical products channel, defaulting to the existing hardcoded production channel if unset. + +### Fixed +- **Tropical Product Posting Crash**: `post_tropical_product` crashed with `AttributeError: 'NoneType' object has no attribute 'splitlines'` on every real NHC product, because most product types (anything without a "SUMMARY OF..." section) parse to `summary: None`, and `dict.get(key, default)` doesn't apply the default when the key is present with a `None` value. No tropical product had ever successfully posted before this fix. +- **Tropical Products Posting to Dev Channel**: `cogs/tropical.py` posted to the hardcoded dev channel unconditionally. Now posts to the production tropical channel by default. + ## [5.41.0] - 2026-07-19 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9bf85389..8bf86827 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,7 @@ The following variables are required or optional in `.env`: | `SOUNDING_CHANNEL_ID` | (Optional) Receives auto-posted sounding plots near active watches. Defaults to `SPC_CHANNEL_ID` if not set. | | `HEALTH_CHANNEL_ID` | (Optional) Receives bot health alerts (watchdog degraded, task failures). Defaults to `SPC_CHANNEL_ID` if not set. | | `DEV_CHANNEL_ID` | (Optional) Receives watchdog probe-degradation alerts (2/3 warning and session-reset confirmation). Defaults to `HEALTH_CHANNEL_ID` if not set. | +| `TROPICAL_CHANNEL_ID` | (Optional) Receives NHC tropical cyclone product posts. Defaults to a hardcoded production channel if not set. | Slash commands can be used from any channel — they always respond ephemerally or inline where invoked, not into the configured channels. diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index 1a1eabc7..11a68033 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -57,6 +57,7 @@ spc-bot/ │ ├── watch_format.py # Watch formatting utilities │ ├── warnings.py # NWS VTEC warning monitoring (SVR, TOR, FFW) — polling & deduplication logic │ ├── warning_channels.py # Slash commands for per-type warning channel routing (/enablewarnings, /displaysetup, /disablewarnings) +│ ├── tropical.py # NHC tropical cyclone product auto-poster (TCV, TCD, TWD, TWO, TCU, TCE, TCP) from IEMBot/NWWS/botstalk │ ├── warning_format.py # Warning styling, narrative extraction, URL generation (decoupled from warnings.py) │ ├── warning_ui.py # Discord UI views for tornado data: EnvironmentalView, TornadoPhotoView, TornadoDashboardView │ ├── reports.py # LSR and PNS monitoring; logs tornado events and DAT survey links diff --git a/cogs/tropical.py b/cogs/tropical.py index f0c27153..7cbde7f6 100644 --- a/cogs/tropical.py +++ b/cogs/tropical.py @@ -7,13 +7,13 @@ import discord from discord.ext import commands -from config import IEM_NWSTEXT_URL +from config import IEM_NWSTEXT_URL, TROPICAL_CHANNEL_ID from utils.http import http_get_bytes +from utils.state_store import get_state logger = logging.getLogger("spc_bot") DEV_CHANNEL_ID = 1336294580743704607 -PROD_CHANNEL_ID = 981540312688230420 TROPICAL_PILS = {"TCV", "TCD", "TWD", "TCU", "TWO", "TCE", "TCP"} TROPICAL_OFFICES = {"KNHC", "KTPC", "PHFO"} @@ -138,6 +138,24 @@ def __init__(self, bot: commands.Bot): self.bot = bot self._posted = set() + async def _resolve_channel(self): + """Return the channel to post tropical products to, or None if disabled.""" + override = await get_state("warning_channel:tropical") + if override == "disabled": + return None + if override: + try: + channel = self.bot.get_channel(int(override)) + except ValueError: + channel = None + if channel: + return channel + logger.warning(f"Tropical channel override {override} not found, using default") + channel = self.bot.get_channel(TROPICAL_CHANNEL_ID) + if not channel: + logger.warning(f"Tropical channel {TROPICAL_CHANNEL_ID} not found") + return channel + async def post_tropical_product( self, product_id: str, @@ -160,10 +178,8 @@ async def post_tropical_product( if not parsed: return - channel_id = DEV_CHANNEL_ID - channel = self.bot.get_channel(channel_id) + channel = await self._resolve_channel() if not channel: - logger.warning(f"Tropical channel {channel_id} not found") return storm_type = parsed["storm_type"] @@ -182,7 +198,7 @@ async def post_tropical_product( timestamp=datetime.now(timezone.utc), ) - for line in parsed.get("summary", "").splitlines()[:5]: + for line in (parsed.get("summary") or "").splitlines()[:5]: clean = line.strip() if clean: embed.add_field(name="Summary", value=clean[:1024], inline=False) diff --git a/config.py b/config.py index 19b13354..85097cb8 100644 --- a/config.py +++ b/config.py @@ -102,6 +102,7 @@ def _optional_int(*names: str) -> int: "SURVEYS_CHANNEL_ID", "WARNINGS_CHANNEL_ID", "SPC_CHANNEL_ID" ), "dev_channel_id": _optional_int("DEV_CHANNEL_ID", "HEALTH_CHANNEL_ID", "SPC_CHANNEL_ID"), + "tropical_channel_id": int(os.getenv("TROPICAL_CHANNEL_ID") or 981540312688230420), "gemini_api_key": os.getenv("GEMINI_API_KEY", ""), "opencode_api_key": os.getenv("OPENCODE_API_KEY", ""), "ai_model": os.getenv("AI_MODEL", "deepseek-v4-pro"), @@ -128,6 +129,7 @@ def _optional_int(*names: str) -> int: SPS_CHANNEL_ID = CONFIG["sps_channel_id"] SURVEYS_CHANNEL_ID = CONFIG["surveys_channel_id"] DEV_CHANNEL_ID = CONFIG["dev_channel_id"] +TROPICAL_CHANNEL_ID = CONFIG["tropical_channel_id"] GEMINI_API_KEY = CONFIG["gemini_api_key"] OPENCODE_API_KEY = CONFIG["opencode_api_key"] AI_MODEL = CONFIG["ai_model"] diff --git a/tests/test_tropical.py b/tests/test_tropical.py new file mode 100644 index 00000000..afe766d0 --- /dev/null +++ b/tests/test_tropical.py @@ -0,0 +1,90 @@ +""" +Tests for cogs/tropical.py — product classification and posting. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from cogs.tropical import ( + TropicalCog, + TROPICAL_CHANNEL_ID, + _classify_product, + _classify_storm_type, + _extract_storm_name, +) + + +# ── _classify_product ─────────────────────────────────────────────────────── + + +def test_classify_product_matches_known_pil(): + assert _classify_product("202607220600-KNHC-WTNT31-TCVAT1") == "ADVISORY" + + +def test_classify_product_returns_none_for_unknown_pil(): + assert _classify_product("202607220600-KNHC-WTNT31-XXXXAT1") is None + + +# ── _classify_storm_type / _extract_storm_name ────────────────────────────── + + +def test_classify_storm_type_hurricane(): + assert _classify_storm_type("HURRICANE ANNA ADVISORY NUMBER 5") == "HURRICANE" + + +def test_classify_storm_type_none_when_absent(): + assert _classify_storm_type("SOME UNRELATED TEXT") is None + + +def test_extract_storm_name(): + assert _extract_storm_name("HURRICANE ANNA") == "Anna" + + +# ── post_tropical_product ─────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_post_tropical_product_handles_missing_summary(): + """A product with no 'SUMMARY OF' section (most NHC PILs) parses to + summary=None — this must not crash the embed-building loop.""" + bot = MagicMock() + channel = AsyncMock() + bot.get_channel.return_value = channel + cog = TropicalCog(bot) + + parsed = { + "raw_text": "HURRICANE ANNA ADVISORY NUMBER 5", + "summary": None, + "storm_type": "HURRICANE", + "storm_name": "Anna", + } + + with patch("cogs.tropical._fetch_nhc_product", AsyncMock(return_value=parsed)), patch( + "cogs.tropical.get_state", AsyncMock(return_value=None) + ): + await cog.post_tropical_product("202607220600-KNHC-WTNT31-TCVAT1", "", "ADVISORY") + + channel.send.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_post_tropical_product_posts_to_prod_channel(): + bot = MagicMock() + channel = AsyncMock() + bot.get_channel.return_value = channel + cog = TropicalCog(bot) + + parsed = { + "raw_text": "HURRICANE ANNA ADVISORY NUMBER 5", + "summary": None, + "storm_type": "HURRICANE", + "storm_name": "Anna", + } + + with patch("cogs.tropical._fetch_nhc_product", AsyncMock(return_value=parsed)), patch( + "cogs.tropical.get_state", AsyncMock(return_value=None) + ): + await cog.post_tropical_product("202607220600-KNHC-WTNT31-TCVAT1", "", "ADVISORY") + + bot.get_channel.assert_called_once_with(TROPICAL_CHANNEL_ID) From 50f820fccca5f69c5d58ef753679b7e665af5a56 Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:52:28 -0700 Subject: [PATCH 10/13] fix: correct swapped NHC product labels (TCP/TCV) TCP (the actual Public Advisory, containing 'ADVISORY NUMBER X') was mislabeled PROBABILITIES, and TCV (the Watch/Warning summary product) was mislabeled ADVISORY. Confirmed live: Hurricane Fausto Advisory 14 and Tropical Storm Bertha Advisory 12 both posted as 'NHC PROBABILITIES' instead of 'NHC ADVISORY'. Corrected per NHC's product descriptions (nhc.noaa.gov/aboutnhcprod.shtml). --- CHANGELOG.md | 3 ++- cogs/tropical.py | 4 ++-- tests/test_tropical.py | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21070411..1be4a12d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Added -- **NHC Tropical Cyclone Auto-Posting**: New `cogs/tropical.py` auto-posts National Hurricane Center products (Advisory, Discussion, Tropical Weather Discussion, Tropical Weather Outlook, Update, Position Estimate, Probabilities) as they're issued, routed from the IEMBot NHC feed with NWWS XMPP and IEMBot botstalk as fallback sources. Posts include storm type/name detection, an emoji indicator, and a summary embed. Channel routing supports a runtime override (`warning_channel:tropical` state key), independent of any specific slash command. +- **NHC Tropical Cyclone Auto-Posting**: New `cogs/tropical.py` auto-posts National Hurricane Center products (Public Advisory, Discussion, Tropical Weather Discussion, Tropical Weather Outlook, Update, Position Estimate, Watch/Warning Summary) as they're issued, routed from the IEMBot NHC feed with NWWS XMPP and IEMBot botstalk as fallback sources. Posts include storm type/name detection, an emoji indicator, and a summary embed. Channel routing supports a runtime override (`warning_channel:tropical` state key), independent of any specific slash command. - **`TROPICAL_CHANNEL_ID`**: New optional `.env` variable for the tropical products channel, defaulting to the existing hardcoded production channel if unset. ### Fixed - **Tropical Product Posting Crash**: `post_tropical_product` crashed with `AttributeError: 'NoneType' object has no attribute 'splitlines'` on every real NHC product, because most product types (anything without a "SUMMARY OF..." section) parse to `summary: None`, and `dict.get(key, default)` doesn't apply the default when the key is present with a `None` value. No tropical product had ever successfully posted before this fix. - **Tropical Products Posting to Dev Channel**: `cogs/tropical.py` posted to the hardcoded dev channel unconditionally. Now posts to the production tropical channel by default. +- **Wrong NHC Product Labels**: `TCP` (the actual Public Advisory — "SUMMARY OF ... ADVISORY NUMBER X") was mislabeled "PROBABILITIES", and `TCV` (the Tropical Cyclone Watch/Warning summary product) was mislabeled "ADVISORY", swapping the two most common tropical products' titles. Confirmed live against Hurricane Fausto Advisory 14 and Tropical Storm Bertha Advisory 12, both of which posted as "NHC PROBABILITIES" instead of "NHC ADVISORY". Corrected per NHC's own product descriptions (nhc.noaa.gov/aboutnhcprod.shtml). ## [5.41.0] - 2026-07-19 diff --git a/cogs/tropical.py b/cogs/tropical.py index 7cbde7f6..dd829e5f 100644 --- a/cogs/tropical.py +++ b/cogs/tropical.py @@ -70,13 +70,13 @@ def _extract_storm_name(text: str) -> str: NHC_PRODUCT_NAMES = { - "TCV": "ADVISORY", + "TCP": "ADVISORY", "TCD": "DISCUSSION", "TWD": "TROPICAL WEATHER DISCUSSION", "TWO": "TROPICAL WEATHER OUTLOOK", "TCU": "UPDATE", "TCE": "POSITION ESTIMATE", - "TCP": "PROBABILITIES", + "TCV": "WATCH/WARNING SUMMARY", } diff --git a/tests/test_tropical.py b/tests/test_tropical.py index afe766d0..eb1dd4dc 100644 --- a/tests/test_tropical.py +++ b/tests/test_tropical.py @@ -19,7 +19,7 @@ def test_classify_product_matches_known_pil(): - assert _classify_product("202607220600-KNHC-WTNT31-TCVAT1") == "ADVISORY" + assert _classify_product("202607220600-KNHC-WTNT31-TCPAT1") == "ADVISORY" def test_classify_product_returns_none_for_unknown_pil(): @@ -63,7 +63,7 @@ async def test_post_tropical_product_handles_missing_summary(): with patch("cogs.tropical._fetch_nhc_product", AsyncMock(return_value=parsed)), patch( "cogs.tropical.get_state", AsyncMock(return_value=None) ): - await cog.post_tropical_product("202607220600-KNHC-WTNT31-TCVAT1", "", "ADVISORY") + await cog.post_tropical_product("202607220600-KNHC-WTNT31-TCPAT1", "", "ADVISORY") channel.send.assert_awaited_once() @@ -85,6 +85,6 @@ async def test_post_tropical_product_posts_to_prod_channel(): with patch("cogs.tropical._fetch_nhc_product", AsyncMock(return_value=parsed)), patch( "cogs.tropical.get_state", AsyncMock(return_value=None) ): - await cog.post_tropical_product("202607220600-KNHC-WTNT31-TCVAT1", "", "ADVISORY") + await cog.post_tropical_product("202607220600-KNHC-WTNT31-TCPAT1", "", "ADVISORY") bot.get_channel.assert_called_once_with(TROPICAL_CHANNEL_ID) From de3b77d1ce180e1f2ddef6ecf8ff4f75c18134ac Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:00:56 -0700 Subject: [PATCH 11/13] fix: bound tropical product summary and move full text to a thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summary block only stopped at a marker Public Advisories never contain, so it silently captured everything to the end of the product (watches/warnings, full discussion, hazards, next advisory, signature) and posted it all inline in the channel. Now bounded to the next section header (all-caps line + dashed underline), matching NHC's own document structure. Also adds a discussion thread on the channel message carrying the full raw product text, matching the SPC Outlook/MD thread convention — the channel message stays a short summary. --- CHANGELOG.md | 2 + cogs/tropical.py | 58 +++++++++++++++++--------- tests/test_tropical.py | 94 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1be4a12d..a32d9456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,13 @@ All notable changes to this project will be documented in this file. ### Added - **NHC Tropical Cyclone Auto-Posting**: New `cogs/tropical.py` auto-posts National Hurricane Center products (Public Advisory, Discussion, Tropical Weather Discussion, Tropical Weather Outlook, Update, Position Estimate, Watch/Warning Summary) as they're issued, routed from the IEMBot NHC feed with NWWS XMPP and IEMBot botstalk as fallback sources. Posts include storm type/name detection, an emoji indicator, and a summary embed. Channel routing supports a runtime override (`warning_channel:tropical` state key), independent of any specific slash command. - **`TROPICAL_CHANNEL_ID`**: New optional `.env` variable for the tropical products channel, defaulting to the existing hardcoded production channel if unset. +- **Tropical Product Threads**: Tropical posts now create a discussion thread attached to the message, with the full raw product text posted there (matching the SPC Outlook/MD thread convention) — the channel message stays a short summary. ### Fixed - **Tropical Product Posting Crash**: `post_tropical_product` crashed with `AttributeError: 'NoneType' object has no attribute 'splitlines'` on every real NHC product, because most product types (anything without a "SUMMARY OF..." section) parse to `summary: None`, and `dict.get(key, default)` doesn't apply the default when the key is present with a `None` value. No tropical product had ever successfully posted before this fix. - **Tropical Products Posting to Dev Channel**: `cogs/tropical.py` posted to the hardcoded dev channel unconditionally. Now posts to the production tropical channel by default. - **Wrong NHC Product Labels**: `TCP` (the actual Public Advisory — "SUMMARY OF ... ADVISORY NUMBER X") was mislabeled "PROBABILITIES", and `TCV` (the Tropical Cyclone Watch/Warning summary product) was mislabeled "ADVISORY", swapping the two most common tropical products' titles. Confirmed live against Hurricane Fausto Advisory 14 and Tropical Storm Bertha Advisory 12, both of which posted as "NHC PROBABILITIES" instead of "NHC ADVISORY". Corrected per NHC's own product descriptions (nhc.noaa.gov/aboutnhcprod.shtml). +- **Entire Product Text Posted Inline**: The "Summary" section only stopped at a marker (`FORECAST POSITIONS AND MAX WINDS`) that Public Advisories never contain, so it silently captured everything from "SUMMARY OF..." to the end of the product — Watches and Warnings, the full Discussion and Outlook, Hazards, Next Advisory, and the forecaster's signature — and posted all of it inline in the channel. Now bounded to the next section header (the next all-caps line followed by a dashed underline), matching NHC's own document structure, and the full text moved to the thread instead. ## [5.41.0] - 2026-07-19 diff --git a/cogs/tropical.py b/cogs/tropical.py index dd829e5f..0573b611 100644 --- a/cogs/tropical.py +++ b/cogs/tropical.py @@ -108,15 +108,25 @@ async def _fetch_nhc_product(product_id: str) -> dict: body_start = i break + # The "Summary" section (location/movement/pressure) is bounded by the next + # section header — an all-caps line immediately followed by a dashed + # underline, e.g. "WATCHES AND WARNINGS\n--------------------". The + # summary header itself has the same dashed-underline shape, so start + # looking for the *next* one two lines after the summary header to skip + # over its own underline. + body_lines = lines[body_start:] summary_lines = [] - in_summary = False - for line in lines[body_start:]: - if "SUMMARY OF" in line.upper() or "SUMMARY INFORMATION" in line.upper(): - in_summary = True - if in_summary: - summary_lines.append(line) - if "FORECAST POSITIONS AND MAX WINDS" in line.upper(): - break + for idx, line in enumerate(body_lines): + upper = line.strip().upper() + if "SUMMARY OF" in upper or "SUMMARY INFORMATION" in upper: + end = len(body_lines) + for j in range(idx + 2, len(body_lines) - 1): + underline = body_lines[j + 1].strip() + if underline and set(underline) == {"-"} and len(underline) > 3: + end = j + break + summary_lines = body_lines[idx:end] + break summary = "\n".join(summary_lines).strip() if summary_lines else None @@ -197,24 +207,34 @@ async def post_tropical_product( color=discord.Color.orange(), timestamp=datetime.now(timezone.utc), ) - - for line in (parsed.get("summary") or "").splitlines()[:5]: - clean = line.strip() - if clean: - embed.add_field(name="Summary", value=clean[:1024], inline=False) - break - if parsed["summary"]: - full_summary = parsed["summary"][:2048] - embed.description = full_summary - + embed.description = parsed["summary"][:2048] embed.set_footer(text=f"{source} | {product_id}") try: - await channel.send(embed=embed) + msg = await channel.send(embed=embed) logger.info(f"Posted tropical {product_type} for {storm_name or product_id}") except Exception as e: logger.exception(f"Failed to post tropical product: {e}") + return + + thread_name = f"{storm_name or 'NHC'} {product_type}".strip()[:100] + thread = None + try: + thread = await msg.create_thread(name=thread_name, auto_archive_duration=1440) + except Exception as e: + logger.warning(f"Failed to create thread for {product_id}: {e}") + + full_text_embed = discord.Embed( + title=f"Full Text — {product_type}", + description=parsed["raw_text"][:4096], + color=discord.Color.dark_gray(), + ) + target = thread or channel + try: + await target.send(embed=full_text_embed) + except Exception as e: + logger.warning(f"Failed to post full text for {product_id}: {e}") async def route_from_product_id( self, product_id: str, raw_text: str = None, source: str = "IEMBot" diff --git a/tests/test_tropical.py b/tests/test_tropical.py index eb1dd4dc..59f25087 100644 --- a/tests/test_tropical.py +++ b/tests/test_tropical.py @@ -12,8 +12,67 @@ _classify_product, _classify_storm_type, _extract_storm_name, + _fetch_nhc_product, ) +# Real TCP text (Hurricane Fausto Advisory 14) — the "Summary" section must +# stop before "WATCHES AND WARNINGS", not swallow the rest of the product. +FAUSTO_TCP = """WTPZ31 KNHC 220900 +TCPEP1 + +BULLETIN +HURRICANE FAUSTO ADVISORY NUMBER 14 +NWS NATIONAL HURRICANE CENTER MIAMI FL EP072026 +1100 PM HST TUE JUL 21 2026 + +SUMMARY OF 1100 PM HST...0900 UTC...INFORMATION +----------------------------------------------- +LOCATION...16.7N 120.7W +ABOUT 820 MI...1320 KM WSW OF THE SOUTHERN TIP OF BAJA CALIFORNIA +MAXIMUM SUSTAINED WINDS...85 MPH...140 KM/H +PRESENT MOVEMENT...WNW OR 285 DEGREES AT 8 MPH...13 KM/H +MINIMUM CENTRAL PRESSURE...981 MB...28.97 INCHES + + +WATCHES AND WARNINGS +-------------------- +There are no coastal watches or warnings in effect. + + +DISCUSSION AND OUTLOOK +---------------------- +At 1100 PM HST (0900 UTC), the center of Hurricane Fausto was +located near latitude 16.7 North, longitude 120.7 West. + +NEXT ADVISORY +------------- +Next complete advisory at 500 AM HST. + +$$ +Forecaster Kelly +""" + + +# ── _fetch_nhc_product ────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_fetch_nhc_product_summary_stops_before_next_section(): + """Regression test: the summary block must not swallow WATCHES AND + WARNINGS / DISCUSSION AND OUTLOOK / NEXT ADVISORY / the forecaster + signature — only the location/movement/pressure block.""" + with patch( + "cogs.tropical.http_get_bytes", + AsyncMock(return_value=(FAUSTO_TCP.encode(), 200)), + ): + parsed = await _fetch_nhc_product("202607220900-KNHC-WTPZ31-TCPEP1") + + assert parsed is not None + assert "LOCATION...16.7N 120.7W" in parsed["summary"] + assert "WATCHES AND WARNINGS" not in parsed["summary"] + assert "DISCUSSION AND OUTLOOK" not in parsed["summary"] + assert "Forecaster Kelly" not in parsed["summary"] + # ── _classify_product ─────────────────────────────────────────────────────── @@ -88,3 +147,38 @@ async def test_post_tropical_product_posts_to_prod_channel(): await cog.post_tropical_product("202607220600-KNHC-WTNT31-TCPAT1", "", "ADVISORY") bot.get_channel.assert_called_once_with(TROPICAL_CHANNEL_ID) + + +@pytest.mark.asyncio +async def test_post_tropical_product_posts_full_text_in_thread(): + """The channel message should carry only the short summary; the full + raw product text goes to a thread on that message, not the channel.""" + bot = MagicMock() + channel = AsyncMock() + main_msg = AsyncMock() + thread = AsyncMock() + channel.send.return_value = main_msg + main_msg.create_thread.return_value = thread + bot.get_channel.return_value = channel + cog = TropicalCog(bot) + + parsed = { + "raw_text": "FULL RAW PRODUCT TEXT " * 50, + "summary": "SUMMARY OF 1100 PM HST...INFORMATION\nLOCATION...16.7N 120.7W", + "storm_type": "HURRICANE", + "storm_name": "Anna", + } + + with patch("cogs.tropical._fetch_nhc_product", AsyncMock(return_value=parsed)), patch( + "cogs.tropical.get_state", AsyncMock(return_value=None) + ): + await cog.post_tropical_product("202607220600-KNHC-WTNT31-TCPAT1", "", "ADVISORY") + + channel.send.assert_awaited_once() + main_embed = channel.send.call_args[1]["embed"] + assert "FULL RAW PRODUCT TEXT" not in (main_embed.description or "") + + main_msg.create_thread.assert_awaited_once() + thread.send.assert_awaited_once() + full_text_embed = thread.send.call_args[1]["embed"] + assert "FULL RAW PRODUCT TEXT" in full_text_embed.description From c277c2de4fa8491fa20b6f9ac1f8d763ea4446f1 Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:26:19 -0700 Subject: [PATCH 12/13] fix: Saffir-Simpson embed colors, proper emoji for TD/major hurricane --- cogs/tropical.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/cogs/tropical.py b/cogs/tropical.py index 0573b611..061f5d5a 100644 --- a/cogs/tropical.py +++ b/cogs/tropical.py @@ -23,17 +23,29 @@ ) STORM_EMOJI = { - "TROPICAL DEPRESSION": "🌨️", + "TROPICAL DEPRESSION": "☁️", "TROPICAL STORM": "🌧️", "HURRICANE": "🌀", - "MAJOR HURRICANE": "🌀", + "MAJOR HURRICANE": "⚠️🌀⚠️", "POTENTIAL TROPICAL CYCLONE": "⚠️", "POST-TROPICAL CYCLONE": "🌬️", - "SUBTROPICAL DEPRESSION": "🌨️", + "SUBTROPICAL DEPRESSION": "☁️", "SUBTROPICAL STORM": "🌧️", "REMNANTS": "💨", } +STORM_COLORS = { + "TROPICAL DEPRESSION": discord.Color.from_str("#6b7b8d"), + "TROPICAL STORM": discord.Color.from_str("#f5d76e"), + "SUBTROPICAL DEPRESSION": discord.Color.from_str("#6b7b8d"), + "SUBTROPICAL STORM": discord.Color.from_str("#f5d76e"), + "HURRICANE": discord.Color.from_str("#f39c12"), + "MAJOR HURRICANE": discord.Color.from_str("#e74c3c"), + "POTENTIAL TROPICAL CYCLONE": discord.Color.from_str("#d35400"), + "POST-TROPICAL CYCLONE": discord.Color.from_str("#95a5a6"), + "REMNANTS": discord.Color.from_str("#7f8c8d"), +} + STORM_TYPE_ORDER = [ "REMNANTS", "POST-TROPICAL CYCLONE", @@ -202,9 +214,10 @@ async def post_tropical_product( if storm_type: title += f" ({storm_type})" + embed_color = STORM_COLORS.get(storm_type, discord.Color.orange()) embed = discord.Embed( title=title, - color=discord.Color.orange(), + color=embed_color, timestamp=datetime.now(timezone.utc), ) if parsed["summary"]: From adafd823e39eb28c87407e9c652ff0b538a004b9 Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:35:17 -0700 Subject: [PATCH 13/13] fix: Saffir-Simpson scale with user-specified colors and emoji --- cogs/tropical.py | 81 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/cogs/tropical.py b/cogs/tropical.py index 061f5d5a..400867fa 100644 --- a/cogs/tropical.py +++ b/cogs/tropical.py @@ -22,30 +22,51 @@ r"(?:NATIONAL HURRICANE CENTER|NHC|TROPICAL|HURRICANE|TROPICAL STORM)", re.IGNORECASE ) -STORM_EMOJI = { - "TROPICAL DEPRESSION": "☁️", - "TROPICAL STORM": "🌧️", - "HURRICANE": "🌀", - "MAJOR HURRICANE": "⚠️🌀⚠️", - "POTENTIAL TROPICAL CYCLONE": "⚠️", - "POST-TROPICAL CYCLONE": "🌬️", - "SUBTROPICAL DEPRESSION": "☁️", - "SUBTROPICAL STORM": "🌧️", - "REMNANTS": "💨", +SAFFIR_SIMPSON_COLORS = { + "TD": 0x5DBAFF, + "TS": 0x00FBF4, + "CAT1": 0xFFFFCD, + "CAT2": 0xFEE775, + "CAT3": 0xFFC140, + "CAT4": 0xFF8F21, + "CAT5": 0xFF6060, } -STORM_COLORS = { - "TROPICAL DEPRESSION": discord.Color.from_str("#6b7b8d"), - "TROPICAL STORM": discord.Color.from_str("#f5d76e"), - "SUBTROPICAL DEPRESSION": discord.Color.from_str("#6b7b8d"), - "SUBTROPICAL STORM": discord.Color.from_str("#f5d76e"), - "HURRICANE": discord.Color.from_str("#f39c12"), - "MAJOR HURRICANE": discord.Color.from_str("#e74c3c"), - "POTENTIAL TROPICAL CYCLONE": discord.Color.from_str("#d35400"), - "POST-TROPICAL CYCLONE": discord.Color.from_str("#95a5a6"), - "REMNANTS": discord.Color.from_str("#7f8c8d"), +SAFFIR_EMOJI = { + "TD": "☁️", + "TS": "🌧️", + "CAT1": "🌀", + "CAT2": "🌀", + "CAT3": "⚠️🌀⚠️", + "CAT4": "⚠️🌀⚠️", + "CAT5": "⚠️🌀⚠️", } + +def _winds_to_category(wind_mph: float) -> str: + if wind_mph < 39: + return "TD" + if wind_mph < 74: + return "TS" + if wind_mph < 96: + return "CAT1" + if wind_mph < 111: + return "CAT2" + if wind_mph < 130: + return "CAT3" + if wind_mph < 157: + return "CAT4" + return "CAT5" + + +def _parse_max_wind(text: str) -> float | None: + """Extract maximum sustained wind speed in MPH from advisory text.""" + m = re.search(r"MAXIMUM\s+SUSTAINED\s+WINDS[\.\s:]+?(\d+)\s*MPH", text, re.IGNORECASE) + if m: + return float(m.group(1)) + return None + + STORM_TYPE_ORDER = [ "REMNANTS", "POST-TROPICAL CYCLONE", @@ -206,15 +227,25 @@ async def post_tropical_product( storm_type = parsed["storm_type"] storm_name = parsed["storm_name"] - emoji = STORM_EMOJI.get(storm_type, "🌀") + wind_mph = _parse_max_wind(parsed["raw_text"]) + ss_cat = _winds_to_category(wind_mph) if wind_mph else None + + emoji = SAFFIR_EMOJI.get(ss_cat or "", "🌀") + embed_color = SAFFIR_SIMPSON_COLORS.get(ss_cat or "", 0xF39C12) title = f"{emoji} NHC {product_type}" if storm_name: title += f" — {storm_name}" - if storm_type: - title += f" ({storm_type})" - - embed_color = STORM_COLORS.get(storm_type, discord.Color.orange()) + category_label = storm_type or "" + if ss_cat and ss_cat.startswith("CAT"): + cat_num = ss_cat.replace("CAT", "") + category_label = f"Cat {cat_num} {storm_type}" if storm_type else f"Cat {cat_num}" + elif ss_cat == "TS": + category_label = "Tropical Storm" + elif ss_cat == "TD": + category_label = "Tropical Depression" + if category_label: + title += f" ({category_label})" embed = discord.Embed( title=title, color=embed_color,