A modular Bluesky bot that responds to weather requests from public posts, direct messages, and file-based alerts. Reply with a portrait image card or a 3-post text thread — your choice.
Image mode (POST_MODE=image) — up to 3 PNG cards posted as a reply:
| Current conditions | Forecast | On This Day |
|---|---|---|
![]() |
![]() |
![]() |
- Card 1 — current conditions: temp, feels-like, humidity, wind, gusts, pressure, sunrise/sunset
- Card 2 — 12-hour hourly + 7-day daily forecast
- Card 3 — "On this day" ERA5 temperature history (75 years), record high/low, averages
Text mode (POST_MODE=text) — a 3-post thread (also used for DM replies):
📍 New York, NY | Sat Mar 14 10:15 PM EDT
☀️ Clear sky
🌡 41°F (5°C) | Feels 32°F (-0°C)
💧 Humidity: 41%
💨 Wind: 9mph (14km/h) NNW | Gusts 18mph (29km/h)
🌧 Precip: 0.00in (0.0mm)
📊 Pressure: 1022hPa
---
⏱ Next 6 Hours — New York, NY
10PM: 41°F, ☁ 5%, 💧 0%, 💨 9mph
11PM: 39°F, ☁ 4%, 💧 0%, 💨 7mph
12AM: 37°F, ☁ 50%, 💧 0%, 💨 4mph
1AM: 35°F, ☁ 18%, 💧 0%, 💨 5mph
2AM: 34°F, ☁ 32%, 💧 1%, 💨 4mph
3AM: 34°F, ☁ 100%, 💧 1%, 💨 3mph
---
📅 Historical — New York, NY
Last year (Mar 14, 2025):
Hi 55°F (13°C) / Lo 33°F (1°C) | Precip 0.00in
10-yr avg (Mar ±7d):
Hi 57°F (14°C) / Lo 38°F (3°C) | Precip 0.14in
# 1. Clone and set up environment
cd ~/projects/bluesky_weather_bot
python3 -m venv .venv && source .venv/bin/activate
pip3 install -r requirements.txt
# 2. Configure credentials
cp .local.env.example .local.env
# Edit .local.env — set BSKY_HANDLE, BSKY_APP_PASSWORD, and POST_MODE
# 3. Run (single instance — do not start multiple)
python3 main.pyNote: Always ensure only one instance is running. Multiple processes each respond to every incoming post, resulting in duplicate replies.
Users trigger the bot by:
| Method | Example |
|---|---|
| Public post — mention + zip | @zipwx.bsky.social 80501 |
| Public post — mention + city | @zipwx.bsky.social Denver, CO |
| Public post — mention + city only | @zipwx.bsky.social Portland (returns results for all matches) |
| Direct message — zip or city | 80501 or Denver, CO or Portland |
| Direct message — home location | (no text needed — uses your saved home location) |
| File inbox | Drop a YAML file into inbox/ |
The bot replies to the original post (public) or sends a DM response. Image mode is used for public replies; DMs always use the text thread format since Bluesky DMs do not support image attachments.
Users can configure their experience by sending commands directly to the bot via Bluesky DM. Changes are stored per-user and apply to all future responses on any channel.
Just send a location — no trigger word needed:
80501
Denver, CO
Portland
If you have a home location saved, send a blank DM and the bot replies with your home weather automatically.
set home Denver, CO ← save by city
set home 80501 ← save by ZIP
clear home ← remove saved location
The bot validates the location before saving.
Both °F and °C (and mph/km/h, in/mm) are always shown. The units setting controls which appears first.
| Command | Effect |
|---|---|
imperial |
°F · mph · inches first (default) |
metric |
°C · km/h · mm first |
Aliases: use imperial, fahrenheit, use metric, celsius,
set units imperial, set units metric.
| Command | Effect |
|---|---|
settings |
Show your current units and home location |
reset |
Clear all preferences and return to defaults |
help or ? |
Show the command reference card |
All settings are loaded from .local.env (gitignored). Copy
.local.env.example to get started.
| Variable | Default | Description |
|---|---|---|
BSKY_HANDLE |
(required) | Your bot's Bluesky handle |
BSKY_APP_PASSWORD |
(required) | App password from Bluesky settings |
POST_MODE |
text |
text — 3-post thread; image — up to 3 PNG cards |
SERVER_TYPE |
laptop |
Shown in latency footer: laptop or Pi |
SKIP_HISTORICAL |
false |
Skip archive API calls — faster, useful during development |
WEATHER_CACHE_TTL_MINUTES |
30 |
How long to cache current conditions lookups |
DB_PATH |
data/zipwx.db |
SQLite database path — point to USB drive on Pi for better I/O |
INBOX_PATH |
inbox |
Directory watched for YAML alert files |
INBOX_ARCHIVE_PATH |
inbox/archive |
Processed YAML files moved here |
INBOX_ERROR_PATH |
inbox/errors |
Unparseable YAML files moved here |
INBOX_POLL_INTERVAL_SEC |
5.0 |
How often to check the inbox directory |
LOG_PATH |
logs/zipwx.log |
Log file path |
LOG_LEVEL |
INFO |
DEBUG / INFO / WARNING / ERROR |
POST_MODE=image requires Pillow:
pip3 install PillowIf Pillow is missing the bot logs a warning and falls back to text mode automatically.
Alert channels (inputs) Notify channels (outputs)
───────────────────── ─────────────────────────
FileWatcherAlertChannel ──┐
FirehoseAlertChannel ──┼──▶ ZipWx (orchestrator) ──▶ BlueskyPostNotifyChannel
DMAlertChannel ──┘ │ ──▶ BlueskyDMNotifyChannel
▼
WeatherService
(Open-Meteo API, free)
▼
Database
(SQLite WAL)
Adding a new alert channel (e.g. webhooks):
- Create
channels/alert/webhook.pysubclassingAlertChannel - Implement
start()andstop() - Register it in
bot.py'sbuild_bot()
Adding a new notification channel (e.g. email):
- Create
channels/notify/email.pysubclassingNotificationChannel - Implement
send(payload) → NotificationResult - Register it in
bot.py'sbuild_bot() - Add routing logic in
ZipWx._route()
bluesky_weather_bot/
├── main.py # entry point
├── bot.py # orchestrator (ZipWx + build_bot())
├── requirements.txt
├── .local.env.example # copy to .local.env
│
├── bluesky_weather_bot/
│ ├── channels/
│ │ ├── alert/
│ │ │ ├── base.py # AlertChannel ABC + AlertRequest dataclass
│ │ │ ├── file_watcher.py # YAML inbox watcher
│ │ │ ├── firehose.py # Bluesky public post stream
│ │ │ └── dm_poller.py # Bluesky Direct Messages
│ │ └── notify/
│ │ ├── base.py # NotificationChannel ABC + payload/result
│ │ ├── bluesky_post.py # public reply (image or text thread)
│ │ └── bluesky_dm.py # direct message reply (text only)
│ │
│ ├── weather/
│ │ ├── models.py # dataclasses: WeatherReport, CurrentConditions, etc.
│ │ ├── resolver.py # zip/city → lat/lon + timezone
│ │ ├── client.py # Open-Meteo API client (no key required)
│ │ ├── formatter.py # WeatherReport → Bluesky post strings (text mode)
│ │ ├── image_formatter.py # WeatherReport → PNG image card (image mode)
│ │ └── service.py # WeatherService facade (single entry point)
│ │
│ ├── storage/
│ │ └── db.py # SQLite: weather_cache, requests, responses
│ │
│ └── config/
│ └── settings.py # loads .local.env → Settings dataclass
│
├── docs/ # screenshots and assets
├── tests/ # pytest suite
├── data/ # SQLite database (gitignored)
├── inbox/ # YAML alert files drop here
│ ├── archive/ # processed files moved here
│ └── errors/ # unparseable files moved here
└── logs/ # log files (gitignored)
On Linux (Raspberry Pi, Ubuntu, Debian), you can run the bot as a systemd
service so it starts automatically on boot and restarts on failure.
1. Create the unit file:
sudo nano /etc/systemd/system/weather-bot.service[Unit]
Description=Bluesky Weather Bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=YOUR_USER
WorkingDirectory=/path/to/bluesky-weather-bot
EnvironmentFile=/path/to/bluesky-weather-bot/.local.env
ExecStart=/path/to/bluesky-weather-bot/.venv/bin/python3 main.py
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.targetReplace YOUR_USER and /path/to/bluesky-weather-bot with your actual
username and project directory.
2. Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable weather-bot
sudo systemctl start weather-bot3. Check status and logs:
sudo systemctl status weather-bot
journalctl -u weather-bot -f # follow live logs
journalctl -u weather-bot -n 100 # last 100 lines4. Stop or restart:
sudo systemctl stop weather-bot
sudo systemctl restart weather-botImportant:
ExecStartmust point to the venv'spython3directly (notpythonor the system Python) so all dependencies are available. See the note in Quick start about using the correct interpreter.
full_message: 'Red Rocks Park 30-day rain total: 0.71 inches #RainData #COWx'
message: 'Red Rocks Park 30-day rain total: 0.71 inches'
created_at: '2025-02-19T17:30:07+07:00'
host: Test
tags:
- COWx
- Rain
mentions:Powered by Open-Meteo — free, no API key required.
- Current: temp, feels-like, humidity, wind, gusts, cloud cover, pressure, visibility, sunrise/sunset
- Forecast: next 12 hours hourly + 7-day daily (temp, precip probability, wind, cloud cover)
- Historical comparison: same date last year + 10-year climatological average
- On this day: ERA5 reanalysis — ~75 years of daily high/low for the current date, record high/low, averages (cached annually per location)
All values shown in dual units (°F/°C, mph/km/h, in/mm). Display order is user-configurable via DM — see Personalizing via DM.



