- Python 83.4%
- HTML 13.5%
- CSS 2.8%
- JavaScript 0.2%
| .claude | ||
| .forgejo/workflows | ||
| alembic | ||
| app | ||
| docs | ||
| static | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| alembic.ini | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| pytest.ini | ||
| README.md | ||
| renovate.json | ||
| requirements-test.txt | ||
| requirements.txt | ||
| SECURITY_AUDIT_REMEDIATION_PLAN.md | ||
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).
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.
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 |
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, fire weather outlooks, 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
- Forecast and Radar buttons
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.
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).
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
- 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
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.