Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions .wiki
Submodule .wiki added at 869e0f
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions PROJECT_STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cogs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@
"cogs.verification",
"cogs.subscriptions",
"cogs.radar_history",
"cogs.tropical",
]
91 changes: 90 additions & 1 deletion cogs/iembot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -373,13 +377,83 @@ 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))

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",
Expand Down Expand Up @@ -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))
39 changes: 39 additions & 0 deletions cogs/nwws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")

Comment on lines +617 to +628

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

NWWS path passes the raw AFOS PIL as pil_prefix, bypassing the product-name mapping the PR set out to fix.

post_tropical_product uses pil_prefix or _classify_product(product_id) for the display label — i.e. it expects pil_prefix, when supplied, to already be the resolved human-readable name (that's exactly what route_from_product_id in cogs/tropical.py does: pil_prefix = _classify_product(product_id)). Here pil_prefix is set to the raw 3-letter code (e.g. "TCP") and passed straight through, so product_type becomes the literal string "TCP" instead of "ADVISORY". Every NWWS-sourced tropical post will show the raw PIL in its title/thread name, not the corrected label the CHANGELOG describes.

🐛 Suggested fix — don't pass the raw PIL; let it resolve via `_classify_product`
             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"
+                        product_id, raw_text, source="NWWS"
                     )
                     logger.info(f"Triggered NHC {pil_prefix} via XMPP")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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")
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, source="NWWS"
)
logger.info(f"Triggered NHC {pil_prefix} via XMPP")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cogs/nwws.py` around lines 617 - 628, Update the NWWS tropical dispatch in
the handler containing `post_tropical_product` so it does not pass the raw
`pil_prefix` code as the third argument. Pass no resolved prefix (or otherwise
let `post_tropical_product` invoke `_classify_product(product_id)`), while
preserving the existing tropical PIL detection and NWWS source value.

except Exception as e:
logger.exception(f"Error processing XMPP message: {e}")

Expand Down
Loading
Loading