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/.wiki b/.wiki new file mode 160000 index 00000000..869e0f95 --- /dev/null +++ b/.wiki @@ -0,0 +1 @@ +Subproject commit 869e0f950b9d4f8d6caf7f1ef2edd3e6c0de2431 diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e71270..1481f7bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,22 @@ 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 (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. - **Sounding AI Summary Threads**: Autoposted sounding AI summaries now create a dedicated discussion thread attached to the sounding plot message instead of posting as an inline reply (#612, #614). The thread is named `Sounding: {station} — {time}` and auto-archives after 24h. Falls back gracefully to inline reply when thread creation is unavailable. Applies to all RAOB and ACARS autopost paths (high-risk monitoring, special soundings, watch-triggered, and standard synoptic-time posts). - **MD AI Summary Threads**: Mesoscale Discussion AI summaries now create a thread named `MD #{num}` on the MD message instead of posting a standalone message in the channel (#613, #614). The AI Analysis button detects the thread and posts inside it with an ephemeral link to the user. Falls back to channel posting when thread creation fails. + +### 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. - **Thread Cache Fallback (`_resolve_message_thread`)**: Button handlers for outlook, sounding, and MD summaries now use a shared helper that checks `message.thread` (cached property) first, then falls back to `message.fetch_thread()` from the API (#614). This handles edge cases where Discord hasn't populated the thread cache, preventing spurious channel fallback. - **NEXRAD Radar Loops (`/radar` command)**: New `radar_gif` Rust crate fetches NEXRAD Level II volumes from the public AWS S3 archive, decodes them using BowEcho's `nexrad_io` and `render2d` crates, and outputs animated GIFs. The `/radar` slash command accepts site ID (e.g. KTLX), product (reflectivity/velocity/spectrum width/ZDR/CC/KDP/PHIDP), frame count, and a zoom level. The render2d dependency renders publication-quality polar radar images with proper color tables and range-folding handling, including state/county outlines and city labels. - **Radar Zoom Control**: The `/radar` command now exposes a `zoom` option — Storm-Scale (~75km), Regional (~150km, default), Wide (~300km), or Full Range (~460km) — using `render2d`'s viewport rendering API to display any of those ranges at full resolution instead of a fixed full-range disk. Storm-scale zoom makes mesocyclone couplets and tornadic debris signatures actually resolvable. - **Mesocyclone/TVS Rotation Detection**: `/radar` now runs BowEcho's operational rotation-detection algorithm (Stumpf/Mitchell) on every frame by default, overlaying color-coded markers (weak circulation through TVS) with Vrot labels, and reporting the loop's peak Vrot/ΔV (with timestamp) in the embed even when the strongest rotation occurred on an earlier frame than the most recent one. - **PTDS (Probability of Tornadic Debris Signature)**: New `/radar` product choice combining reflectivity, correlation coefficient, and differential reflectivity into a single 0–100% debris-signature confidence score, rendered with a dedicated probability color scale. Score is also sampled at each scan's strongest detected couplet and reported scan-by-scan in the embed. PTDS is explicitly experimental — the embed carries a visible disclaimer and a low score should not be read as ruling out a tornado. - -### Fixed - **Missing `beautifulsoup4` Dependency**: `sounderpy`'s ACARS data module imports `bs4` directly but never declares it as a dependency, and the package was only ever present transitively. Pinned `beautifulsoup4` explicitly in `requirements.txt` so a fresh install (e.g. a rebuilt CI image) doesn't break every sounding-related test with `ModuleNotFoundError`. ## [5.41.0] - 2026-07-19 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/__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..383b97c7 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): @@ -373,6 +377,21 @@ 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 + if any( + 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: + 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)) @@ -380,6 +399,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", @@ -431,6 +505,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/cogs/nwws.py b/cogs/nwws.py index ffc58bc0..042e4bda 100644 --- a/cogs/nwws.py +++ b/cogs/nwws.py @@ -28,6 +28,25 @@ logger = logging.getLogger("spc_bot") +# 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 + + _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 try: import spc_rust_core @@ -493,6 +512,14 @@ 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) + 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 try: @@ -587,6 +614,18 @@ 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", "TWO", "TCE", "TCP") + ): + tropical_cog = self.bot.get_cog("Tropical") + if tropical_cog: + 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" + ) + 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..400867fa --- /dev/null +++ b/cogs/tropical.py @@ -0,0 +1,295 @@ +"""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, 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 + +TROPICAL_PILS = {"TCV", "TCD", "TWD", "TCU", "TWO", "TCE", "TCP"} +TROPICAL_OFFICES = {"KNHC", "KTPC", "PHFO"} + +_NHC_LABEL_RE = re.compile( + r"(?:NATIONAL HURRICANE CENTER|NHC|TROPICAL|HURRICANE|TROPICAL STORM)", re.IGNORECASE +) + +SAFFIR_SIMPSON_COLORS = { + "TD": 0x5DBAFF, + "TS": 0x00FBF4, + "CAT1": 0xFFFFCD, + "CAT2": 0xFEE775, + "CAT3": 0xFFC140, + "CAT4": 0xFF8F21, + "CAT5": 0xFF6060, +} + +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", + "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 + + +NHC_PRODUCT_NAMES = { + "TCP": "ADVISORY", + "TCD": "DISCUSSION", + "TWD": "TROPICAL WEATHER DISCUSSION", + "TWO": "TROPICAL WEATHER OUTLOOK", + "TCU": "UPDATE", + "TCE": "POSITION ESTIMATE", + "TCV": "WATCH/WARNING SUMMARY", +} + + +def _classify_product(product_id: str) -> str: + pid = product_id.upper() + for pil, name in NHC_PRODUCT_NAMES.items(): + if pil in pid: + return name + 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 + + # 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 = [] + 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 + + 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 _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, + 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 = await self._resolve_channel() + if not channel: + return + + storm_type = parsed["storm_type"] + storm_name = parsed["storm_name"] + 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}" + 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, + timestamp=datetime.now(timezone.utc), + ) + if parsed["summary"]: + embed.description = parsed["summary"][:2048] + embed.set_footer(text=f"{source} | {product_id}") + + try: + 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" + ): + 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)) diff --git a/config.py b/config.py index e8995313..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"] @@ -178,6 +180,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..82bcab50 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" } diff --git a/tests/test_tropical.py b/tests/test_tropical.py new file mode 100644 index 00000000..59f25087 --- /dev/null +++ b/tests/test_tropical.py @@ -0,0 +1,184 @@ +""" +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, + _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 ─────────────────────────────────────────────────────── + + +def test_classify_product_matches_known_pil(): + assert _classify_product("202607220600-KNHC-WTNT31-TCPAT1") == "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-TCPAT1", "", "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-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