- Python 89.2%
- HTML 8.7%
- CSS 1.3%
- JavaScript 0.5%
- Shell 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .claude | ||
| .forgejo/workflows | ||
| alembic | ||
| app | ||
| scripts | ||
| static | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| alembic.ini | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| docker-entrypoint.sh | ||
| Dockerfile | ||
| pytest.ini | ||
| README.md | ||
| renovate.json | ||
| requirements-test.txt | ||
| requirements.txt | ||
| ruff.toml | ||
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_PORTin.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=developmentSESSION_HTTPS_ONLY=falseAUTH_REDIRECT_URI=http://localhost:8000/auth/callback
For production, set:
APP_ENV=productionSESSION_HTTPS_ONLY=trueAUTHENTIK_URLandAUTH_REDIRECT_URItohttps://...values- a strong
SECRET_KEYof at least 32 characters AUTHENTIK_URLto the Authentik base URL only, not a provider path such as/application/o/...
2. Configure Authentik
In your Authentik admin panel:
- Create an OAuth2 / OpenID Connect provider
- Set the redirect URI to
http://your-host:8000/auth/callbackfor local development, orhttps://your-host/auth/callbackin production - Create an Application pointing to that provider
- Create two groups (names are configurable in
.env):weatherbot-admins— full admin accessweatherbot-users— standard user access- Users in neither group get read-only access
- Copy the Client ID and Client Secret into
.env - If your Authentik provider's issuer URL ends in a provider slug that is different from the OAuth client ID, also set
AUTHENTIK_ISSUERto the issuer shown by Authentik, for examplehttps://auth.example.com/application/o/weatherbot/
Notes:
AUTHENTIK_URLshould be the Authentik site root such ashttps://auth.example.comAUTHENTIK_ISSUERshould 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 — setAPP_BIND=0.0.0.0in.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.yml — CI (main) |
every push to main |
tests only |
release.yml — Release |
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
Security-related settings
APP_ENVdefaults todevelopment. Set it toproductionorstagingto enable stricter startup validation.SESSION_HTTPS_ONLYcontrols whether session and CSRF cookies use theSecureflag. It now defaults totruein production andfalsein development.SESSION_SAME_SITEcontrols the SameSite policy for session and CSRF cookies. Supported values arelax,strict, andnone.AUTH_REDIRECT_URImust match your Authentik application configuration. In production it must usehttps://.AUTHENTIK_ISSUERis optional. Set it when Authentik's issuer URL is not exactlyAUTHENTIK_URL + /application/o/ + AUTHENTIK_CLIENT_ID + /.AUTHENTIK_URLshould 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_KEYis weak or shorter than 32 charactersSESSION_HTTPS_ONLYis disabled- required OIDC settings are missing
AUTHENTIK_URLorAUTH_REDIRECT_URIusehttp://instead ofhttps://APP_BASE_URL,PUBLIC_LINK_BASE_URL, orSHORT_LINK_BASE_URLare set to insecurehttp://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_alertswhose lifecycle ended more thanRETENTION_SENT_ALERTS_DAYSago. "Ended" iscoalesce(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_deliverieslast touched more thanRETENTION_NOTIFICATION_DELIVERIES_DAYSago. Most leave earlier via the cascade fromsent_alerts; this bounds the rest.ai_summary_recordsand theirai_summary_attemptsolder thanRETENTION_AI_RECORDS_DAYS.redirect_tokensunused forRETENTION_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_DAYSpastretained_until. nws_alert_snapshotsolder thanRETENTION_SNAPSHOTS_DAYS— the raw official source history.lightning_clustersolder thanRETENTION_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_tokensuntouched forRETENTION_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 unlessreplace=trueis 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 JSONschema_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
- Go to Locations → + Add Location
- Search by city name or address, then click or drag the pin to fine-tune
- Set a minimum severity threshold — alerts below this level are ignored for this location
- 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, orWarning) - 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}/publicand 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:
- 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". - 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_MINUTESis 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:
- Select a location from the dropdown
- Optionally override the minimum severity for this specific pairing (leave as "Default" to use the location's own threshold)
- 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
- 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
- APScheduler calls
GET /alerts/active?area=STATE1,STATE2every 60 seconds (one request covers all monitored states) - Each alert's
affectedZonesis checked against the NWS zone IDs stored for each location - Matching alerts are checked against the
sent_alertstable for deduplication - 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
- Are recorded in
Live dashboard updates
- Browser connects to
GET /sse(FastAPIStreamingResponse) - Server does
LISTEN weatherbot_eventson a dedicated asyncpg connection - Alert processor's
pg_notifyputs a message in the queue - SSE handler pushes the event to the browser
- 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.