A bot that warns of severe weather in configurable locations
  • Python 89.2%
  • HTML 8.7%
  • CSS 1.3%
  • JavaScript 0.5%
  • Shell 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-07 06:41:49 +00:00
.claude Allow WebFetch of NOAA mapservices domain in Claude settings 2026-07-27 15:32:36 -05:00
.forgejo/workflows CI: per-run Postgres container name and ephemeral port (#165) 2026-07-31 21:18:58 -05:00
alembic Make notifier radar actually animate, and split the flag that never meant what it said (#184) 2026-08-19 16:35:37 -05:00
app Fix radar capture, which rc3 broke by changing what _fetch_radar returns (#184) 2026-08-21 00:02:45 -05:00
scripts Add the history indexes the benchmark justified, and the benchmark (#141) 2026-08-01 02:16:03 -05:00
static Add the Explorer event detail timeline (#139) 2026-08-01 00:09:22 -05:00
tests Fix radar capture, which rc3 broke by changing what _fetch_radar returns (#184) 2026-08-21 00:02:45 -05:00
.dockerignore Show app version in UI with Forgejo update check 2026-06-23 20:30:40 -05:00
.env.example Unify retention onto one 13-month horizon (#133) 2026-07-28 10:00:36 -05:00
.gitattributes Harden container, add Postgres backups, add CD rollback + prod docs 2026-07-18 11:47:06 -05:00
.gitignore Harden container, add Postgres backups, add CD rollback + prod docs 2026-07-18 11:47:06 -05:00
alembic.ini Initial implementation of WeatherBot 2026-03-26 16:13:34 -05:00
CHANGELOG.md Add v2.0.0-rc4 changelog entry 2026-08-21 00:46:44 -05:00
CLAUDE.md Add Postgres integration test tier and close named coverage gaps 2026-07-18 09:57:52 -05:00
docker-compose.yml Harden container, add Postgres backups, add CD rollback + prod docs 2026-07-18 11:47:06 -05:00
docker-entrypoint.sh Harden container, add Postgres backups, add CD rollback + prod docs 2026-07-18 11:47:06 -05:00
Dockerfile chore(deps): update dependency python 2026-07-25 01:32:52 +00:00
pytest.ini Add Postgres integration test tier and close named coverage gaps 2026-07-18 09:57:52 -05:00
README.md Nowcast: sample a pixel neighborhood and say when forecast frames are missing (#162) 2026-07-31 20:33:17 -05:00
renovate.json Auto-merge low-risk test-dependency updates in Renovate 2026-06-23 18:52:34 -05:00
requirements-test.txt chore(deps): update dependency ruff to v0.16.6 2026-09-07 06:23:40 +00:00
requirements.txt chore(deps): update dependency fastapi to v0.140.7 2026-07-27 18:11:55 +00:00
ruff.toml CI: run migrations + Postgres tier on real Postgres; add ruff + pip-audit 2026-07-18 09:57:52 -05:00

WeatherBot

Self-hosted weather monitoring bot. Watches an arbitrary number of US locations via the National Weather Service (NWS) API and delivers alerts to Discord, Signal, Matrix, webhooks, Pushover, and SMS via Twilio or Voip.ms. A FastAPI web app provides a live-updating dashboard and configuration UI — no page reloads required.


Features

  • Real-time NWS alerts — polls active watches, warnings, and advisories every 60 seconds, batched across all monitored states in a single API call
  • Per-location alert routing — each location independently routes to any set of notification channels
  • Per-location event filtering — exclude alert types you don't care about (e.g. Flood, Dense Fog) on a location-by-location basis
  • Multi-channel notifications: Discord, Signal, Matrix, Webhooks, Pushover, SMS via Twilio or Voip.ms (all optional)
  • Radar map — interactive Leaflet map with NWS CONUS WMS overlay; switch between Base Reflectivity, Composite Reflectivity, Echo Tops, and Precipitation Type
  • Forecast modal — tabbed view with 7-day forecast, Area Forecast Discussion (AFD), and Severe Weather Summary (active alerts + recent SPS/SVS statements, last 3 hours only)
  • Live dashboard — HTMX + Server-Sent Events push updates to the browser without page reload; location cards show current alert status (WARNING / WATCH / ADVISORY / OK)
  • Authentik OIDC login with three role levels: admin, user, read-only
  • Location sharing — admins can share personal locations with specific users by email
  • Leaflet.js map picker with Nominatim geocoding for adding locations
  • Public per-location pages (no login required, opt-in per location)
  • Compact outbound notification links and SMS budgets via optional short/public link bases
  • Configurable host port via APP_PORT in .env

Quick Start

1. Copy and configure environment

cp .env.example .env

Edit .env. At minimum you must set:

Variable What to put
POSTGRES_PASSWORD Any strong password
SECRET_KEY Output of openssl rand -hex 32
NWS_USER_AGENT e.g. WeatherBot/1.0 you@example.com (required by NWS API)
AUTHENTIK_URL Base URL of your Authentik instance
AUTHENTIK_CLIENT_ID OAuth2 client ID from Authentik
AUTHENTIK_CLIENT_SECRET OAuth2 client secret from Authentik

For local development, keep:

  • APP_ENV=development
  • SESSION_HTTPS_ONLY=false
  • AUTH_REDIRECT_URI=http://localhost:8000/auth/callback

For production, set:

  • APP_ENV=production
  • SESSION_HTTPS_ONLY=true
  • AUTHENTIK_URL and AUTH_REDIRECT_URI to https://... values
  • a strong SECRET_KEY of at least 32 characters
  • AUTHENTIK_URL to the Authentik base URL only, not a provider path such as /application/o/...

2. Configure Authentik

In your Authentik admin panel:

  1. Create an OAuth2 / OpenID Connect provider
  2. Set the redirect URI to http://your-host:8000/auth/callback for local development, or https://your-host/auth/callback in production
  3. Create an Application pointing to that provider
  4. Create two groups (names are configurable in .env):
    • weatherbot-admins — full admin access
    • weatherbot-users — standard user access
    • Users in neither group get read-only access
  5. Copy the Client ID and Client Secret into .env
  6. If your Authentik provider's issuer URL ends in a provider slug that is different from the OAuth client ID, also set AUTHENTIK_ISSUER to the issuer shown by Authentik, for example https://auth.example.com/application/o/weatherbot/

Notes:

  • AUTHENTIK_URL should be the Authentik site root such as https://auth.example.com
  • AUTHENTIK_ISSUER should be the full OIDC issuer URL for the provider when needed

3. Start services

docker compose up -d

Database migrations run automatically on startup. The app will be available at http://localhost:8000 (or whatever APP_PORT is set to).

Network exposure. By default the app publishes its port only on the loopback interface (127.0.0.1), so it is reachable from the host but not directly from the LAN. Put a TLS-terminating reverse proxy (Caddy, nginx, Traefik, …) in front of it for any real access. To bind on all interfaces instead — e.g. for a trusted LAN, still ideally behind TLS — set APP_BIND=0.0.0.0 in .env. The bind takes the form ${APP_BIND:-127.0.0.1}:${APP_PORT:-8000}:8000.

The container also runs as a non-root user (appuser, UID 10001) and ships a Docker healthcheck against /health; no action is needed for either.

4. Register Signal (one-time, if using Signal)

Signal requires linking to a phone number before it can send messages. This is a one-time step:

docker compose exec signal-cli signal-cli link -n "WeatherBot"

Open the printed URL on your phone under Settings → Linked Devices. Then in .env:

SIGNAL_ENABLED=true
SIGNAL_SENDER=+15555555555   # the number you linked

Restart the app: docker compose restart app

5. Access the webapp

Open http://localhost:8000, click Sign in with Authentik, and log in.


Updating

After pulling new code, rebuild and restart the app container:

docker compose up -d --build app

Database migrations run automatically on startup.


Backups

WeatherBot's only durable state is PostgreSQL (the named postgres_data volume). scripts/backup.sh takes a logical dump — pg_dump piped through gzip to a timestamped backups/weatherbot-<UTC>.sql.gz — and prunes dumps older than RETENTION_DAYS (default 14). Backups land in ./backups, which is outside the postgres_data volume so a corrupt or deleted volume doesn't take the dumps with it.

If Proxmox/VM snapshots already cover the Postgres data, these logical dumps are complementary: they are smaller, portable, and restorable into a fresh database without rolling back the whole VM.

Option A — opt-in backup sidecar (Compose profile)

A db-backup service is defined under the backup profile, so it does not start with a normal docker compose up -d. Enable it explicitly:

docker compose --profile backup up -d db-backup

It loops pg_dump every BACKUP_INTERVAL_SECONDS (default 86400, i.e. daily) and honors BACKUP_RETENTION_DAYS (default 14). Both are settable in .env. Stop it with docker compose --profile backup stop db-backup.

Option B — host cron

Run the same script from the host on a schedule (works because it dumps via docker compose exec db pg_dump):

# daily at 03:15, from the repo root
15 3 * * * cd /path/to/WeatherBot && ./scripts/backup.sh >> backups/backup.log 2>&1

Restore

Pick a dump and restore it into the running db container. This overwrites current data, so stop the app first:

# 1. Stop the app so nothing writes while restoring.
docker compose stop app

# 2. Drop and recreate the database (clean restore), then load the dump.
docker compose exec -T db psql -U weatherbot -d postgres \
  -c "DROP DATABASE IF EXISTS weatherbot;" -c "CREATE DATABASE weatherbot;"
gunzip -c backups/weatherbot-<UTC>.sql.gz | \
  docker compose exec -T db psql -U weatherbot -d weatherbot

# 3. Bring the app back (migrations run automatically on startup).
docker compose up -d app

If you cannot drop the database (active connections), stop the app first (as above) or restart the db container before the DROP. For a dump taken with --format=custom you would use pg_restore instead of psql; the default script produces a plain SQL dump, so psql is correct here.


Production deploy

Neither pipeline deploys a host — both instances are Ansible-managed from iac-repo (weatherbot-ansible/) and move at release cadence, dev first:

Workflow Trigger Does
cd.ymlCI (main) every push to main tests only
release.ymlRelease a v* tag publishes the image (no deploy)

The old merge-time SSH deploy of dev was retired with the production migration (iac-repo#219, 2026-08-01): it rebuilt the stack from a git checkout under the same compose project name the Ansible role owns, so every merge would have silently reverted dev to the pre-Ansible layout. A green run here means the tests passed — deployment state lives in iac-repo's weatherbot_image pin.

release.yml builds and pushes git.rhoving.com/rbrooks/weatherbot:<version> on a version tag — and that is all it does. Deploying is an operator step in iac-repo, which deliberately keeps production credentials out of CI (a deploy-from-CI job would need an SSH key and the vault password in Actions secrets — root-equivalent access to the production host). The committed weatherbot_image pin in iac-repo is the deployed state.

Production tracks stable release tags, never main. To ship a release:

# 1. Tag; release.yml builds and pushes the image.
git tag v1.9.0 && git push origin v1.9.0

# 2. In iac-repo: bump weatherbot_image to 1.9.0 in
#    inventory/group_vars/weatherbot/vars.yml, commit via PR.

# 3. Soak on dev first, then production (from iac-repo/weatherbot-ansible):
ansible-playbook site.yml -l docker-test
ansible-playbook site.yml -l docker-host

The play pulls the pinned tag, waits for /health, and restores the previously running image if the new one fails to come up — which matters because Alembic migrations run inside the app's startup lifespan.

The image push gates on the REGISTRY_TOKEN secret and skips cleanly when it is unset, so the workflow is safe to merge before the secret exists.

Manual deploy (the pre-release.yml procedure, still valid)

Deploy a tagged release manually:

cd /path/to/WeatherBot

# 1. Fetch tags and check out the release you intend to run.
git fetch --tags origin
git checkout v1.1.0            # the release tag

# 2. Build with the version stamped from the tag, then start.
export APP_VERSION="$(git describe --tags --always)"
docker compose up -d --build app

# 3. Verify health (migrations run automatically on startup via the lifespan).
for i in $(seq 1 20); do
  curl -fsS http://127.0.0.1:${APP_PORT:-8000}/health >/dev/null && break
  sleep 2
done
curl -fsS http://127.0.0.1:${APP_PORT:-8000}/health

Set production env in .env before deploying (APP_ENV=production, SESSION_HTTPS_ONLY=true, HTTPS OIDC URLs, a strong SECRET_KEY); the app fails fast at startup otherwise (see Production Startup Validation). Keep the port on 127.0.0.1 (default) behind a TLS reverse proxy.

Manual rollback

If a release is bad, redeploy the previous known-good tag:

git checkout v1.0.0           # the previous release tag
export APP_VERSION="$(git describe --tags --always)"
docker compose up -d --build app
curl -fsS http://127.0.0.1:${APP_PORT:-8000}/health

Schema migrations are forward-only; if a bad release included a migration, restore the database from a backup taken before the upgrade in addition to checking out the older tag.

The same forward-only caveat applies to the Ansible play's automatic rollback: it restores the previous image, not the previous schema. A release whose migration ran before the app failed its health check needs a database restore, not just a re-deploy.


Environment Variables

  • APP_ENV defaults to development. Set it to production or staging to enable stricter startup validation.
  • SESSION_HTTPS_ONLY controls whether session and CSRF cookies use the Secure flag. It now defaults to true in production and false in development.
  • SESSION_SAME_SITE controls the SameSite policy for session and CSRF cookies. Supported values are lax, strict, and none.
  • AUTH_REDIRECT_URI must match your Authentik application configuration. In production it must use https://.
  • AUTHENTIK_ISSUER is optional. Set it when Authentik's issuer URL is not exactly AUTHENTIK_URL + /application/o/ + AUTHENTIK_CLIENT_ID + /.
  • AUTHENTIK_URL should remain the base Authentik URL. Do not include /application/o/... in this setting.

Production Startup Validation

When APP_ENV is production or staging, WeatherBot now fails fast on insecure configuration. Startup will be blocked if:

  • SECRET_KEY is weak or shorter than 32 characters
  • SESSION_HTTPS_ONLY is disabled
  • required OIDC settings are missing
  • AUTHENTIK_URL or AUTH_REDIRECT_URI use http:// instead of https://
  • APP_BASE_URL, PUBLIC_LINK_BASE_URL, or SHORT_LINK_BASE_URL are set to insecure http:// URLs
Variable Required Default Description
PUBLIC_LINK_BASE_URL No none Optional public base URL for links placed in outbound notifications; useful when the notification host differs from the app host
SHORT_LINK_BASE_URL No none Optional compact link base for outbound notifications; takes precedence over PUBLIC_LINK_BASE_URL and should route the same paths as WeatherBot
PUBLIC_ALERT_PAGES_ENABLED No false Enables public /p/{token} targets for NWS alert detail pages and eligible SPC outlook impact pages
PUBLIC_ALERT_RETENTION_HOURS No 12 Keeps public NWS alert links available for this many hours after alert expiration or cancellation
PUBLIC_ALERT_TOKEN_LENGTH No 10 Length for generated base62 public-page tokens; must be between 8 and 32
APP_PORT 8000 Host port the app is exposed on
APP_BASE_URL Public root URL for this WeatherBot instance; used for unauthenticated media URLs such as Webex SPC outlook GIFs
POSTGRES_PASSWORD PostgreSQL password
SECRET_KEY Session signing key (openssl rand -hex 32)
NWS_USER_AGENT NWS API User-Agent header (must include contact email)
AUTHENTIK_URL Base URL of your Authentik instance
AUTHENTIK_CLIENT_ID OAuth2 client ID
AUTHENTIK_CLIENT_SECRET OAuth2 client secret
AUTHENTIK_ISSUER derived from AUTHENTIK_URL + AUTHENTIK_CLIENT_ID Explicit OIDC issuer URL when Authentik uses a provider slug/path that differs from the client ID
AUTHENTIK_ADMIN_GROUP weatherbot-admins Authentik group name that grants admin role
AUTHENTIK_USER_GROUP weatherbot-users Authentik group name that grants user role
AUTH_REDIRECT_URI http://localhost:8000/auth/callback Must match the redirect URI registered in Authentik
DISCORD_BOT_TOKEN Discord bot token; Discord disabled if blank
MATRIX_HOMESERVER Matrix homeserver URL
MATRIX_USER Matrix bot user ID (e.g. @weatherbot:example.com)
MATRIX_PASSWORD Matrix bot password
SIGNAL_ENABLED false Set true after completing Signal device linking
SIGNAL_SENDER Sender phone number in E.164 format
TWILIO_ACCOUNT_SID Twilio account SID; SMS disabled if blank
TWILIO_AUTH_TOKEN Twilio auth token
TWILIO_FROM_NUMBER Twilio sender number in E.164 format
VOIPMS_API_USERNAME Voip.ms API username (account email)
VOIPMS_API_PASSWORD Voip.ms API password set in the portal
VOIPMS_API_URL https://voip.ms/api/v1/rest.php Voip.ms REST endpoint
ALERT_POLL_INTERVAL 60 Alert polling interval in seconds
SPC_POLL_INTERVAL_MINUTES 5 SPC convective/fire/MCD polling interval in minutes
FORECAST_CACHE_MINUTES 30 How long to cache forecast data
RADAR_CACHE_MINUTES 5 How long to cache server-side radar images
PRECIP_NOWCAST_ENABLED true Show a per-location precipitation nowcast ("rain starting in ~X min") on the dashboard, derived from RainViewer nowcast tiles sampled at the location's point (~1h horizon). Lazy, cached, and fail-soft; set false to hide it
ALERT_RADAR_SNAPSHOT_ENABLED false Capture the radar image when an alert first fires and attach it to the alert record; the public alert page then shows a "Radar at alert time" snapshot. Best-effort; adds a per-fire radar fetch + disk write
LIGHTNING_PLACEFILE_URL (empty) Secret. Weather Pulse / AllisonHouse ENTLN placefile URL (carries a subscriber token — never commit it). Setting it enables the Lightning toggle on the authenticated dashboard radar map. Empty disables the overlay. See Lightning overlay
LIGHTNING_ON_PUBLIC_PAGES false Gate showing lightning on the unauthenticated public pages. Default off for data-license reasons; Phase A does not render lightning on public pages yet
LIGHTNING_POLL_INTERVAL_MINUTES 2 How often the lightning proximity poll runs. A near no-op unless a location sets a proximity radius. See Lightning proximity alerts
LIGHTNING_ALERT_COOLDOWN_MINUTES 30 Deprecated / unused. Superseded by edge-triggered alerting (LIGHTNING_ALERT_CLEAR_MINUTES); kept only so existing .env files still load
LIGHTNING_ALERT_CLEAR_MINUTES 10 How long a location's radius must be clear of lightning before the next detected presence re-arms and alerts again
LIGHTNING_ALERT_MAX_AGE_MINUTES 15 Only alert on lightning clusters newer than this (stale strikes near a location don't fire a heads-up)
HISTORY_HORIZON_DAYS 400 The unified record horizon (~13 months). Every history table below follows this unless explicitly overridden
RETENTION_SENT_ALERTS_DAYS (horizon) Delete sent_alerts whose lifecycle ended longer ago than this
RETENTION_AI_RECORDS_DAYS (horizon) Delete ai_summary_records + ai_summary_attempts older than this
RETENTION_SPC_PAGES_BUFFER_DAYS (horizon) Delete SPC outlook pages (+ impacts, transitions) this many days past retained_until
RETENTION_SNAPSHOTS_DAYS (horizon) Delete nws_alert_snapshots older than this
RETENTION_ALERT_RADAR_FRAMES_DAYS (horizon) Delete periodic radar frames (and their PNGs) older than this
RETENTION_NOTIFICATION_DELIVERIES_DAYS (horizon) Delete delivery-ledger rows last touched longer ago than this
RETENTION_REDIRECT_TOKENS_DAYS (horizon) Delete short-link redirect tokens unused for longer than this
RETENTION_LIGHTNING_CLUSTERS_DAYS 90 Deliberate exception. Archived ENTLN clusters — highest-volume history, fastest-decaying value
RETENTION_PUBLIC_TOKENS_DAYS 30 Deliberate exception. Disabled/revoked public_tokens — credential hygiene, not history
RADAR_CACHE_RETENTION_DAYS 7 Deliberate exception. SPC outlook PNG/GIF disk cache — a fetch cache, regenerable upstream

Data retention and cleanup

A daily background job (app/services/retention.py) prunes tables and the SPC image cache that would otherwise grow forever. Each table is cleaned independently — one failure is logged and skipped without aborting the rest, and child rows are deleted before parents.

One horizon, not many windows. Every history table follows HISTORY_HORIZON_DAYS (400 days ≈ 13 months). Before this, the windows disagreed — 90 days for alerts, 180 for snapshots, 30 for AI summaries and SPC context — so an event 100 days old still had its alert row and snapshot but had already lost its AI summary and its SPC context, and history rotted in inconsistent, confusing ways. 13 months is chosen so a year-over-year comparison ("this July vs last July") always has both sides.

Set an individual RETENTION_* variable only to make a deliberate exception; left unset, it follows the horizon. Raising HISTORY_HORIZON_DAYS keeps more history for the Explorer, at proportional storage cost.

What the job removes:

  • sent_alerts whose lifecycle ended more than RETENTION_SENT_ALERTS_DAYS ago. "Ended" is coalesce(cleared_at, expires, sent_at) — an alert that never got cleared (because the feed dropped it, it was superseded, or the process restarted mid-cycle) still ages out on its expiry or, failing that, its send time.
  • notification_deliveries last touched more than RETENTION_NOTIFICATION_DELIVERIES_DAYS ago. Most leave earlier via the cascade from sent_alerts; this bounds the rest.
  • ai_summary_records and their ai_summary_attempts older than RETENTION_AI_RECORDS_DAYS.
  • redirect_tokens unused for RETENTION_REDIRECT_TOKENS_DAYS, keyed on last use rather than creation so a short link still circulating in someone's chat history is not broken.
  • SPC outlook pages and their impact locations and risk transitions once they are RETENTION_SPC_PAGES_BUFFER_DAYS past retained_until.
  • nws_alert_snapshots older than RETENTION_SNAPSHOTS_DAYS — the raw official source history.
  • lightning_clusters older than RETENTION_LIGHTNING_CLUSTERS_DAYS (90 by default, shorter than the horizon on purpose).
  • periodic radar frames and their PNGs older than RETENTION_ALERT_RADAR_FRAMES_DAYS, plus any snapshot PNG no surviving row references.
  • disabled/revoked public_tokens untouched for RETENTION_PUBLIC_TOKENS_DAYS (active tokens are never deleted).
  • SPC outlook PNG/GIF disk-cache files older than RADAR_CACHE_RETENTION_DAYS.

SPC Mesoscale Discussion (MCD) alert rows are now written with a real expiry (issuance + 6h) instead of never expiring, so they clear via the normal expiry job and become prunable like other SPC records. No all-clear notification is sent for MCD expiry.

Configuration backup and restore

Admins can export and import the entire WeatherBot configuration as a single JSON file from Settings → Configuration Backup & Restore, independent of the PostgreSQL backup below. This is useful for dev↔prod parity and disaster recovery.

  • Export (GET /api/config/export) downloads all locations, notification channels, and channel↔location subscriptions (with per-subscription settings). Because it is a complete DR backup, the file includes the full channel config, secrets in plaintext (webhook auth headers, bot tokens, phone numbers). The payload and UI both warn about this — store the file securely.
  • Import (POST /api/config/import) is additive and idempotent: it matches on natural keys (location name + coordinates, channel name + type) so re-importing does not create duplicates. Existing items are updated in place; new locations trigger NWS resolution automatically (same as the normal create path). Nothing is deleted unless replace=true is passed, which removes locations/channels absent from the uploaded file. Personal (non-global) items are re-homed to the importing admin, since user IDs are environment-specific. The import validates the JSON schema_version.

Public alert and SPC detail pages

When PUBLIC_ALERT_PAGES_ENABLED=true, WeatherBot stores public NWS alert snapshots and reusable public tokens for alert lifecycles that reach an eligible notification channel. Dashboard-only alerts still record normally, but they do not create public pages unless a page already exists for that location/lifecycle. The public resolver is GET /p/{token} and does not require login.

Public pages show the configured location name and state, official NWS alert text, local timing, WeatherBot match context, source attribution, current status, update/all-clear timeline, generated alert map when VTEC supports one, last successful NWS alert fetch time when known, and conservative same-location related context. They intentionally do not show addresses, exact coordinates, owner identity, channel metadata, webhook URLs, phone numbers, provider metadata, or auth-only dashboard links.

Public alert links stay available through the alert lifecycle plus PUBLIC_ALERT_RETENTION_HOURS. Expired, canceled, cleared, and all-clear-sent pages remain readable during retention and clearly state that the alert is no longer active. Missing, disabled, revoked, unsupported-target, or expired tokens return 404. Pages send X-Robots-Tag: noindex, nofollow and conservative no-store cache headers.

SPC outlook impact pages are also stored for tracked-location matches and are available to logged-in users from SPC dashboard cards at /spc/outlooks/{page_id}. They cover Day 1-3 convective outlooks, Day 1-2 fire weather outlooks (categorical ELEV/CRIT/EXTR), and Day 4-8 probabilistic outlooks. When public pages are enabled and at least one impacted location is marked public, WeatherBot creates a public SPC token that renders a reduced /p/{token} view with only public-enabled impacted locations, SPC narrative text, timing, and maps. Public SPC pages do not show exact coordinates, owner identity, channel metadata, internal location links, or notification-channel counts.

Public NWS alert pages can link to a related public SPC outlook impact page when WeatherBot finds active same-location SPC context and an active public SPC token exists. These links are labeled as related context and do not imply causation.

SMS alert and all-clear messages include the compact public details link when a public/short/app base URL is configured and the link fits the SMS budget. Webhook, Discord bot, Webex, Pushover, Signal, and Matrix notifications include the same link in a predictable Details field, URL slot, or message line where the channel supports it.

If SHORT_LINK_BASE_URL or PUBLIC_LINK_BASE_URL points at a short host, route the same WeatherBot paths to the app or redirect them to the canonical app host without changing the path, for example https://w.example/p/{token} -> https://weatherbot.example/p/{token}. WeatherBot does not expose a generic public open redirect; public tokens resolve only to stored WeatherBot public targets in the MVP. Apply rate limiting for /p/* at the reverse proxy or edge layer until an app-native limiter is added.


User Roles

Roles are sourced from Authentik group membership and re-synced on every login.

Role Permissions
admin Create and manage global locations (visible to all users); manage all notification channels; change location visibility and sharing; access all dashboards
user Create and manage personal locations (visible only to themselves and admins); create personal notification channels; view all dashboards
readonly View dashboards and alert history only; no create/edit/delete

Locations

Adding a location

  1. Go to Locations+ Add Location
  2. Search by city name or address, then click or drag the pin to fine-tune
  3. Set a minimum severity threshold — alerts below this level are ignored for this location
  4. Click Add Location

After saving, the app resolves NWS data in the background (WFO, forecast grid, alert zone, nearest radar station). The location card shows a spinner until this completes. If resolution fails, a Retry button appears.

Editing a location

Click Edit on any location row to open the edit modal. Available fields:

  • Name — display name
  • Visibility (admin only) — see Location Visibility below
  • Min Severity — minimum alert level to process for this location (Advisory, Watch, or Warning)
  • Excluded alert keywords — comma-separated keywords; any alert whose event name contains a match is silently ignored for this location (e.g. Flood, Dense Fog, Rip Current). Case-insensitive substring match.
  • Enable public page — allows the location to appear on login-free public views, including /api/locations/{id}/public and reduced public SPC outlook impact pages when applicable

Location visibility

Admins can set visibility on any location:

Visibility Who can see it
Global All logged-in users
Personal — shared explicitly Only the admin (owner) plus any users explicitly added

When set to Personal, a Shared With section appears in the edit modal. Enter a registered user's email address and click Add to grant them access. Click on a chip to revoke it. Changes are immediate — no need to save separately.

Shared users see the location on their dashboard and can subscribe their notification channels to it, but cannot edit or delete it.


Dashboard

The dashboard shows all locations visible to the current user and a live feed of active alerts.

Location cards

Each card displays:

  • Location name
  • Alert status badge — reflects current active alerts in the database:
    • 🔴 WARNING — an active warning is in effect
    • 🟠 WATCH — an active watch is in effect
    • 🟡 ADVISORY — an active advisory is in effect
    • 🟢 OK — no active alerts
  • Zone, state, and radar station identifiers
  • Precipitation nowcast — a short-range "💧 rain starting in ~X min" / "🌧 rain easing in ~X min" / "☀ no precip expected" line when available (see below)
  • Forecast and Radar buttons

Precipitation nowcast

Each location card shows a short-range precipitation nowcast when PRECIP_NOWCAST_ENABLED is on (the default). WeatherBot samples RainViewer's public radar-mosaic nowcast tiles at the location's exact point and reports whether precip is falling there now and when it is expected to start or ease within RainViewer's forecast horizon (typically ~30 minutes of future frames, i.e. roughly the next hour). The line is fetched lazily after the dashboard loads, cached server-side for a few minutes, and fails silently — it never blocks page render and simply disappears when unavailable.

Caveats: this uses RainViewer's global radar mosaic (not the local NEXRAD product), and sampling a small (~±1 km) pixel neighborhood around the point is an approximation — treat it as a quick heads-up, not a precise forecast. When RainViewer serves no forward-looking nowcast frames (it happens), the line says "no forecast frames available" rather than implying a forecast was made. NWS alerts remain the authoritative warning source.

Forecast modal

Clicking Forecast opens a tabbed modal:

Tab Content
Forecast 7-day period forecast with temperature, wind, and detailed description
Discussion Latest Area Forecast Discussion (AFD) from the location's Weather Forecast Office — the meteorologist's written analysis
Severe Summary Active NWS alerts affecting the location's zones, plus Special Weather Statements and Severe Weather Statements from the WFO issued in the last 3 hours

Radar modal

Clicking Radar opens an interactive Leaflet map centered on the location with live NWS radar data overlaid. Use the layer buttons to switch products:

Layer Description
Base Reflectivity Standard NEXRAD base radar (default)
Composite Reflectivity Maximum reflectivity across all elevation scans
Echo Tops Height of radar returns — useful for estimating storm depth
Precip Type Precipitation type classification (rain, snow, mix, etc.)

Radar data is sourced from the NWS CONUS GeoServer WMS. The map supports full zoom with no tile level restrictions.

Lightning overlay

When LIGHTNING_PLACEFILE_URL is configured, the radar modal gains a Lightning toggle. Turning it on plots recent ENTLN (Earth Networks Total Lightning Network) strike clusters near the current map view as small circle markers, colored by recency (red ≤ 5 min, orange ≤ 15 min, yellow older). The overlay re-fetches when you pan/zoom the map and auto-refreshes every 60 seconds while active. If the feed isn't configured the toggle is disabled with an explanatory tooltip; if the feed is temporarily unavailable a transient "Lightning data unavailable" notice appears and the toggle stays on.

The data comes from a Weather Pulse (AllisonHouse) ENTLN placefile, which requires an active subscription. The placefile URL contains a secret subscriber token — set it only via the LIGHTNING_PLACEFILE_URL environment variable and never commit it. WeatherBot fetches and parses the placefile server-side and returns only the parsed strike points to the browser, so the token is never exposed to clients.

The overlay is authenticated-only: it is available on the logged-in dashboard, not on the public alert/location pages. Public rendering is gated separately by LIGHTNING_ON_PUBLIC_PAGES (default off for data-license reasons) and is not implemented in this phase. Commercial lightning data typically carries redistribution restrictions — keep it behind authentication unless your subscription license explicitly permits public display.

Lightning proximity alerts

Beyond the dashboard overlay, WeatherBot can notify you when lightning is detected near a location. This is opt-in on two levels and requires LIGHTNING_PLACEFILE_URL to be configured:

  1. Per location — set a Lightning alert radius (mi) on the location (edit the location; blank or 0 = off). This is the proximity distance that counts as "nearby".
  2. Per subscription — enable the Lightning toggle on a channel↔location subscription. Only channels with this toggle receive lightning notifications for that location.

A background poll (every LIGHTNING_POLL_INTERVAL_MINUTES, default 2) checks each opted-in location against the lightning feed and sends a "Lightning detected within N mi of {location}" heads-up when a recent strike cluster falls inside the radius. Behavior:

  • Recency — only clusters newer than LIGHTNING_ALERT_MAX_AGE_MINUTES (default 15) trigger an alert, so stale strikes don't fire.
  • Edge-triggered — a location fires once when lightning moves into its radius (a transition from clear to present), then stays silent while lightning remains present (no re-fire every poll). Once the radius has been clear of lightning for LIGHTNING_ALERT_CLEAR_MINUTES (default 10), the next detected presence is treated as a fresh entry and alerts again. LIGHTNING_ALERT_COOLDOWN_MINUTES is deprecated / unused — this edge-triggered logic supersedes the old fixed re-alert cooldown.
  • Quiet hours — lightning heads-ups are advisory-class, so they respect a subscription's quiet-hours window (unlike a life-safety warning, which bypasses it).

With no location setting a radius (the default), the poll selects nothing and does no work — zero behavior change.


Notification Channels

Creating a channel

Go to Channels+ Add Channel. Choose a type and fill in the type-specific config fields. All channels have an Animated radar GIF toggle (used when the channel sends radar images with alerts).

Channel types

Discord

guild_id:    Server (guild) ID — right-click the server icon → Copy Server ID
channel_id:  Text channel ID — right-click the channel → Copy Channel ID

The Discord bot must be invited to the server with permission to send messages and attach files. Bot token goes in DISCORD_BOT_TOKEN.

Slash commands available in Discord:

  • /weather <location> — current forecast for a monitored location
  • /radar <location> — radar image
  • /alerts — list of active alerts across all locations

Signal

recipients:  List of phone numbers in E.164 format (e.g. +15555555555)

Requires the one-time Signal device linking step (see Quick Start).

Matrix

room_id:  Matrix room ID (e.g. !abc123:example.com)

The bot must be invited to the room. Credentials go in MATRIX_* env vars.

Commands available in Matrix:

  • !weather <location>
  • !radar <location>
  • !alerts

Webhook

url:     Endpoint URL to POST/PUT to
method:  POST or PUT

The webhook receives a JSON payload with event, severity, headline, location, and expires fields.

SMS

provider:     twilio or voipms
to_number:    Recipient phone number
from_number:  Voip.ms DID to send from (Voip.ms only)

Messages are plain text (no images).

SMS bodies are intentionally compact and target a 140-character budget. The formatter prioritizes event/severity, configured location name, local expiration time, and one urgent official instruction phrase when available, then drops optional context if needed.

For Twilio, set TWILIO_* in .env.

For Voip.ms, set VOIPMS_* in .env, enable API access in the Voip.ms portal, and whitelist the IP address of the WeatherBot server. Voip.ms documents API SMS via its sendSMS method and notes that A2P/business texting may require separate verification depending on your usage.

Webex

bot_token:  Bot token from developer.webex.com/my-apps
room_id:    Webex space/room ID to post to

Create a Bot at developer.webex.com, add it to the target Webex space, then copy the bot token and the space's Room ID (available via the Webex API or the developer portal). Alerts are sent as markdown messages with the Iowa State warning polygon image attached inline. All-clears include the expiry image with the "Event No Longer Active" overlay. For inline SPC animated outlook GIFs, set APP_BASE_URL, PUBLIC_LINK_BASE_URL, or SHORT_LINK_BASE_URL to a public HTTPS root that can serve or redirect /media/spc-outlook/...gif.

Pushover

user_key:   User key from pushover.net/settings
app_token:  Application API token from pushover.net/apps
priority:   -2 (silent) to 2 (emergency with acknowledgment)

Priority levels:

Value Behavior
-2 No notification sound or alert
-1 Quiet — delivered silently
0 Normal priority
1 High priority — bypasses quiet hours
2 Emergency — repeats every 60s until acknowledged; requires acknowledgment

Radar images can be attached (up to 2.5 MB; Pushover supports image attachments).

ntfy

topic:       Required. The ntfy topic to publish to (treat it like a secret — anyone
             who knows it can subscribe on a public server).
server_url:  Optional. Defaults to https://ntfy.sh; point at your own self-hosted
             ntfy server instead.
token:       Optional. Bearer token for an authenticated topic.
username:    Optional. Basic-auth username (used with `password`).
password:    Optional. Basic-auth password (used with `username`).
priority:    Optional, 1 (min) to 5 (max). Defaults by severity: advisory=3,
             watch=4, warning=5.

ntfy is a self-hostable pub/sub push notification service — a lightweight, self-hosted alternative to Pushover. Create/subscribe to a topic in the ntfy app (or curl -d "hi" ntfy.sh/mytopic), then use that topic name here. Tapping the notification opens the alert's details page.

Subscribing a location to a channel

On the Channels page, each channel card has a subscription form:

  1. Select a location from the dropdown
  2. Optionally override the minimum severity for this specific pairing (leave as "Default" to use the location's own threshold)
  3. Optionally configure:
    • Daily forecast time — one digest per local day at that location's local time
    • Quiet hours start/end — suppress non-bypass notifications during the local quiet window
    • SPC Outlooks, MCDs, and Fire — opt in to SPC-derived notifications for this subscription
  4. Click Add

Subscribed locations appear as a list on the channel card. Click to unsubscribe.

A location with no subscriptions is dashboard-only — alerts appear on the web UI but are not sent anywhere.

Subscription edit modal controls currently include:

  • Min Severity
  • Daily Forecast Time
  • Quiet Hours Start / End
  • Digest above (alerts/hr) — outbreak digest mode (see below); blank = off
  • SPC Convective Outlooks
  • Mesoscale Discussions (coarse state match in v1)
  • SPC Fire Outlooks

Alert Filtering

Minimum severity threshold

Each location has a Min Severity setting controlling the lowest level of alert it processes:

Setting What gets through
Advisory Advisories, watches, and warnings
Watch Watches and warnings only
Warning Warnings only

Channel subscriptions can further override this threshold for a specific location/channel pair.

Excluded event keywords

To ignore specific alert types at a location (e.g. you don't need flood alerts for a hilltop location), add keywords in the location's Edit modal under Excluded alert keywords.

Enter comma-separated keywords. Any NWS alert whose event name contains one of the keywords (case-insensitive substring match) will be skipped entirely for that location — it won't appear on the dashboard or be sent to any channel.

Examples:

Keywords What gets filtered
Flood Flood Watch, Flood Warning, Flash Flood Watch, Flash Flood Warning, …
Dense Fog Dense Fog Advisory
Rip Current Rip Current Statement
Flood, Dense Fog Both of the above

Notification Policy

Subscription-level controls now apply beyond basic NWS alerts:

  • Quiet hours suppress NWS watches/advisories, SPC outlooks, SPC discussions, forecast digests, and non-warning lifted notifications
  • NWS warnings bypass quiet hours
  • Forecast digests send at most once per subscription per local day after the configured local send time
  • Lifted / all-clear notifications are tracked separately from alert clearing so quiet-hour deferrals can retry later without duplicating sends

Outbreak digest mode

On a high-activity day a single channel can receive dozens of per-alert messages for one location. Set a subscription's Digest above (alerts/hr) threshold to batch the overflow: once that many alerts have been dispatched immediately within DIGEST_WINDOW_MINUTES (default 60), further alerts for that (channel, location) are deferred and delivered together as one periodic Weather Alert Digest every DIGEST_FLUSH_MINUTES (default 30). The digest summarizes the batched alerts by event (e.g. "5 alerts in the past 60 min for Austin: 3× Flash Flood Warning, 2× Severe Thunderstorm Warning") at the highest severity in the batch, and is rendered by every notifier with no special configuration.

  • Opt-in per subscription. Leave the field blank (or 0) and behavior is unchanged — every alert is sent individually, exactly as before.
  • Warning-class alerts are never batched. Tornado, flash flood, and other warning-severity alerts always dispatch immediately regardless of the digest threshold — a life-safety warning is never held for up to DIGEST_FLUSH_MINUTES. Only advisory- and watch-class alerts are digested (that's the noise digest mode is meant to tame).
  • Deferred alerts still appear on the dashboard immediately (the dashboard record is never deferred); only the per-channel message is batched.
  • Digest sends respect quiet hours: a non-warning-class digest that falls inside the subscription's quiet window waits for a later flush.

Current implementation note:

  • notifier code still uses send_all_clear() naming internally even though the lifecycle behavior now maps more closely to a dedicated lifted-notification flow

Architecture

docker compose
  ├── app          FastAPI + APScheduler + Discord bot + Matrix client (port 8000)
  ├── db           PostgreSQL 16
  └── signal-cli   bbernhard/signal-cli-rest-api

Alert pipeline

  1. APScheduler calls GET /alerts/active?area=STATE1,STATE2 every 60 seconds (one request covers all monitored states)
  2. Each alert's affectedZones is checked against the NWS zone IDs stored for each location
  3. Matching alerts are checked against the sent_alerts table for deduplication
  4. New alerts that pass the severity threshold and keyword filters:
    • Are recorded in sent_alerts
    • Trigger pg_notify('weatherbot_events', ...) for the SSE pipeline
    • Are dispatched to each subscribed notification channel

Live dashboard updates

  1. Browser connects to GET /sse (FastAPI StreamingResponse)
  2. Server does LISTEN weatherbot_events on a dedicated asyncpg connection
  3. Alert processor's pg_notify puts a message in the queue
  4. SSE handler pushes the event to the browser
  5. HTMX SSE extension swaps the affected DOM fragments — no page reload

NWS data resolution

When a location is created, a background task calls GET /points/{lat},{lon} to resolve:

  • Weather Forecast Office (WFO) code
  • Forecast grid coordinates (for forecast and hourly forecast)
  • Forecast zone ID and county zone ID (for alert matching)
  • Nearest radar station
  • State abbreviation (for batching alert queries)

This data is stored on the location record and never re-fetched (unless you use Retry after an error).


Development

# Install dependencies
pip install -r requirements.txt

# Copy and configure env
cp .env.example .env

# Run migrations (requires a running PostgreSQL)
alembic upgrade head

# Start dev server with hot reload
uvicorn app.main:app --reload

The app expects DATABASE_URL to point to a reachable PostgreSQL 16 instance. All other services (Discord, Matrix, Signal) are optional and disabled if the relevant env vars are not set.

Database migrations

Migrations live in alembic/versions/. To create a new one after changing a model:

alembic revision --autogenerate -m "describe your change"
alembic upgrade head

Migrations run automatically on Docker startup via the app's lifespan handler.