Skip to content

Commit 1b618ba

Browse files
committed
Add webhooks, status badge
1 parent 2136232 commit 1b618ba

File tree

12 files changed

+1111
-18
lines changed

12 files changed

+1111
-18
lines changed

README.md

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Real-time monitoring of San Francisco's Muni Metro subway using OpenCV-based com
66

77
URL: https://munimet.ro
88

9+
Current status: [![Muni Metro Status](https://munimet.ro/badge.svg)](https://munimet.ro)
10+
911
This project was "vibe coded" using Anthropic's Claude Code. Uses deterministic computer vision analysis; no ML models or external AI services required.
1012

1113
## Quick Start
@@ -62,21 +64,19 @@ See [deploy/README.md](deploy/README.md) for detailed deployment instructions.
6264

6365
### Credentials Setup (Optional)
6466

65-
To enable Bluesky status posting, configure credentials:
67+
To enable social media posting and webhook notifications:
6668

6769
```bash
68-
# Local development - saves to .env file
69-
python3 scripts/setup/setup-credentials.py
70+
# Social media credentials (Bluesky, Mastodon)
71+
python3 scripts/setup/setup-credentials.py # Local (.env file)
72+
python3 scripts/setup/setup-credentials.py --cloud # Google Cloud Secret Manager
7073

71-
# Cloud deployment - saves to Google Cloud Secret Manager
72-
python3 scripts/setup/setup-credentials.py --cloud
74+
# Webhook URLs (Slack, Discord, Teams, etc.)
75+
python3 scripts/setup/manage-webhooks.py # Local (.env file)
76+
python3 scripts/setup/manage-webhooks.py --cloud # Google Cloud Secret Manager
7377
```
7478

75-
The setup script will prompt for:
76-
- **Bluesky handle** - Your account (e.g., `munimetro.bsky.social`)
77-
- **Bluesky app password** - Generate at [bsky.app/settings/app-passwords](https://bsky.app/settings/app-passwords)
78-
79-
Credentials are optional - the app works without them, but won't post status updates to Bluesky.
79+
Credentials and webhooks are optional - the app works without them, but won't post status updates or send notifications.
8080

8181
## Accessing Training Data
8282

@@ -140,14 +140,18 @@ munimet.ro/
140140
│ ├── train_detector.py # Train ID detection (OCR)
141141
│ ├── station_constants.py # Station definitions
142142
│ ├── analytics.py # SQLite-based delay analytics
143-
│ └── notifiers/ # Notification channels (Bluesky, RSS)
143+
│ ├── badge.py # SVG status badge generator
144+
│ └── notifiers/ # Notification channels (Bluesky, Mastodon, RSS, webhooks)
144145
145146
├── scripts/ # Development and utility scripts
146147
│ ├── analyze.py # CLI tool for image analysis
147148
│ ├── detect_stations.py # Station detection CLI
148149
│ ├── detection_viewer.py # Interactive detection viewer
149150
│ ├── validate.sh # Local validation (lint + tests)
150-
│ └── install-hooks.sh # Git hooks installer
151+
│ ├── install-hooks.sh # Git hooks installer
152+
│ └── setup/ # Setup and configuration scripts
153+
│ ├── setup-credentials.py # Social media credentials
154+
│ └── manage-webhooks.py # Webhook URL manager
151155
152156
├── api/ # Production web API
153157
│ ├── api.py # Falcon web server
@@ -243,9 +247,59 @@ Users
243247
- **Smart Caching** - Best-of-three smoothing reduces false positives (~30ms local response time)
244248
- **Cloud Native** - Serverless deployment on Google Cloud Run with automatic scaling
245249
- **No ML Dependencies** - No PyTorch or large model files required
246-
- **Multi-Channel Notifications** - Status updates via Bluesky and RSS feed
250+
- **Multi-Channel Notifications** - Status updates via Bluesky, Mastodon, RSS, and webhooks (Slack, Discord, Teams)
247251
- **Delay Analytics** - SQLite-based tracking with visual dashboard for delay patterns
248252

253+
## Integrations
254+
255+
### Status Badge
256+
257+
Embed a live status badge on any site or README:
258+
259+
```markdown
260+
[![Muni Metro Status](https://munimet.ro/badge.svg)](https://munimet.ro)
261+
```
262+
263+
The badge updates automatically and shows the current system status (green/yellow/red).
264+
265+
### Webhooks
266+
267+
Get notified on Slack, Discord, Microsoft Teams, or any HTTP endpoint when the system status changes.
268+
269+
```bash
270+
# Interactive manager — add, remove, test, list webhooks
271+
python scripts/setup/manage-webhooks.py # Local (.env)
272+
python scripts/setup/manage-webhooks.py --cloud # Google Cloud Secret Manager
273+
274+
# Non-interactive
275+
python scripts/setup/manage-webhooks.py --add https://hooks.slack.com/services/T00/B00/xxx
276+
python scripts/setup/manage-webhooks.py --remove https://hooks.slack.com/services/T00/B00/xxx
277+
python scripts/setup/manage-webhooks.py --list
278+
python scripts/setup/manage-webhooks.py --test # Send a test notification
279+
```
280+
281+
Slack, Discord, and Teams URLs are auto-detected and receive platform-native payloads (rich embeds, action buttons, etc.). All other URLs receive a generic JSON payload:
282+
283+
```json
284+
{
285+
"status": "yellow",
286+
"previous_status": "green",
287+
"description": "Uh oh: Muni's not feeling well",
288+
"delay_summaries": ["Westbound delay at Powell"],
289+
"timestamp": "2026-03-01T12:00:00",
290+
"url": "https://munimet.ro",
291+
"badge_url": "https://munimet.ro/badge.svg"
292+
}
293+
```
294+
295+
### RSS Feed
296+
297+
Subscribe to status changes via RSS at `https://munimet.ro/feed.xml`. Works with any RSS reader, and can be bridged to Slack or Discord using their built-in RSS integrations.
298+
299+
### Social Media
300+
301+
Follow [@munimetro.bsky.social](https://bsky.app/profile/munimetro.bsky.social) on Bluesky or [@MuniMetro@mastodon.social](https://mastodon.social/@MuniMetro) on Mastodon for status updates.
302+
249303
## Development Workflow
250304

251305
1. **Collect Data** - Run `download_muni_image.py` periodically

api/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,28 @@ Analytics dashboard showing delay statistics.
181181
- Delays by hour of day (bar chart)
182182
- Delays by day of week (bar chart)
183183

184+
### GET /badge.svg
185+
186+
Embeddable status badge (shields.io style). Shows current system status as a small SVG image suitable for embedding in READMEs, dashboards, or external sites.
187+
188+
**Response** (200 OK): SVG image
189+
190+
**Example**:
191+
192+
```markdown
193+
[![Muni Metro Status](https://munimet.ro/badge.svg)](https://munimet.ro)
194+
```
195+
196+
**Status values**:
197+
| Status | Label | Color |
198+
|--------|-------|-------|
199+
| green | on track | green |
200+
| yellow | delays | yellow |
201+
| red | down | red |
202+
| unknown | unknown | gray |
203+
204+
**Caching**: 1 minute (`Cache-Control: public, max-age=60`)
205+
184206
### GET /analytics-data
185207

186208
JSON API for delay analytics.
@@ -318,6 +340,23 @@ curl https://munimetro-api-438243686292.us-west1.run.app/status
318340
- `ENABLE_FALLBACK`: `false` - Cache-only mode (no fallback)
319341
- `PORT`: `8000` - HTTP server port
320342

343+
### Webhooks
344+
345+
- `WEBHOOK_URLS`: Comma-separated list of webhook URLs to notify on status changes
346+
347+
Slack, Discord, and Microsoft Teams URLs are auto-detected and receive platform-native payloads. All other URLs receive a generic JSON payload.
348+
349+
Manage webhooks with the interactive script:
350+
351+
```bash
352+
python scripts/setup/manage-webhooks.py # Local (.env)
353+
python scripts/setup/manage-webhooks.py --cloud # Secret Manager
354+
python scripts/setup/manage-webhooks.py --list # Show current webhooks
355+
python scripts/setup/manage-webhooks.py --test # Send test notification
356+
python scripts/setup/manage-webhooks.py --add URL # Add a webhook
357+
python scripts/setup/manage-webhooks.py --remove URL # Remove a webhook
358+
```
359+
321360
See [CONFIGURATION.md](../CONFIGURATION.md) for complete configuration reference.
322361

323362
## Troubleshooting

api/api.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434
# Add parent directory to path for lib imports
3535
sys.path.insert(0, str(PROJECT_ROOT))
36-
from lib.muni_lib import download_muni_image, detect_muni_status, read_cache, read_cached_image
36+
from lib.muni_lib import download_muni_image, detect_muni_status, read_cache, read_cached_image, read_cached_badge
3737
from lib.config import (
3838
CACHE_MAX_AGE,
3939
STALENESS_FRESH,
@@ -482,6 +482,33 @@ def on_get(self, req, resp):
482482
resp.set_header('Cache-Control', 'public, max-age=1800')
483483

484484

485+
class BadgeResource:
486+
"""Serve a pre-generated SVG status badge for embedding on external sites."""
487+
488+
def on_get(self, req, resp):
489+
"""Handle GET request to /badge.svg"""
490+
# Serve pre-generated badge from cache (written by checker job)
491+
badge_svg = read_cached_badge()
492+
493+
if not badge_svg:
494+
# Fallback: generate on the fly from current status
495+
from lib.badge import generate_badge
496+
497+
status = None
498+
cache_data = read_cache()
499+
if cache_data:
500+
reported = cache_data.get('reported_status') or cache_data.get('best_status')
501+
if reported:
502+
status = reported.get('status')
503+
badge_svg = generate_badge(status)
504+
505+
resp.status = falcon.HTTP_200
506+
resp.content_type = 'image/svg+xml; charset=utf-8'
507+
resp.text = badge_svg
508+
# Cache for 1 minute — badge is updated by checker job every 3 min
509+
resp.set_header('Cache-Control', 'public, max-age=60')
510+
511+
485512
class StaticResource:
486513
"""Serve the frontend HTML files with proper caching."""
487514
def __init__(self, filename, content_type):
@@ -547,6 +574,7 @@ def __init__(self, filename):
547574
falcon_app.add_route('/latest-image', LatestImageResource())
548575
falcon_app.add_route('/feed.xml', RSSFeedResource())
549576
falcon_app.add_route('/analytics-data', AnalyticsResource())
577+
falcon_app.add_route('/badge.svg', BadgeResource())
550578
falcon_app.add_route('/analytics', TextResource('analytics.html'))
551579

552580
# Wrap with WhiteNoise for efficient static file serving with compression and caching

api/check_status.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
# Add parent directory to path for lib imports
2525
sys.path.insert(0, str(PROJECT_ROOT))
26-
from lib.muni_lib import download_muni_image, detect_muni_status, read_cache, write_cache, write_cached_image, calculate_best_status
26+
from lib.muni_lib import download_muni_image, detect_muni_status, read_cache, write_cache, write_cached_image, write_cached_badge, calculate_best_status
2727
from lib.detection import apply_status_hysteresis
2828
from lib.notifiers import notify_status_change
2929
from lib.analytics import log_status_check
@@ -253,11 +253,15 @@ def check_status(should_write_cache=False, interval_seconds=None):
253253
if len(statuses) > 1:
254254
history = ' -> '.join(s['status'] for s in statuses)
255255
print(f" History: [{history}], Reported: {reported_status['status']}")
256-
# Also cache the image for the dashboard
256+
# Also cache the image and badge for the dashboard
257257
if write_cached_image(result['filepath']):
258258
print(f" Image cached")
259259
else:
260260
print(f" Image cache failed")
261+
if write_cached_badge(reported_status['status']):
262+
print(f" Badge cached")
263+
else:
264+
print(f" Badge cache failed")
261265

262266
# Notify all channels — done early so notifications aren't lost to timeouts.
263267
# Determines whether to notify due to a new transition or a missed previous notification.

lib/badge.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
SVG badge generator for MuniMet.ro status.
3+
4+
Generates shields.io-style flat badges showing current system status.
5+
"""
6+
7+
# Badge colors (shields.io conventions)
8+
_COLORS = {
9+
'green': '#4c1',
10+
'yellow': '#dfb317',
11+
'red': '#e05d44',
12+
'unknown': '#9f9f9f',
13+
}
14+
15+
_LABELS = {
16+
'green': 'on track',
17+
'yellow': 'delays',
18+
'red': 'down',
19+
'unknown': 'unknown',
20+
}
21+
22+
# Approximate character width for Verdana 11px (shields.io uses this)
23+
_CHAR_WIDTH = 6.8
24+
_PADDING = 10
25+
_LABEL_TEXT = 'MuniMet.ro'
26+
27+
28+
def generate_badge(status):
29+
"""
30+
Generate an SVG badge for the given status.
31+
32+
Args:
33+
status: One of 'green', 'yellow', 'red', or None for unknown.
34+
35+
Returns:
36+
str: SVG markup string.
37+
"""
38+
status = status if status in _COLORS else 'unknown'
39+
color = _COLORS[status]
40+
value_text = _LABELS[status]
41+
42+
# Calculate widths
43+
label_width = int(len(_LABEL_TEXT) * _CHAR_WIDTH + _PADDING * 2)
44+
value_width = int(len(value_text) * _CHAR_WIDTH + _PADDING * 2)
45+
total_width = label_width + value_width
46+
47+
label_x = label_width / 2
48+
value_x = label_width + value_width / 2
49+
50+
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{total_width}" height="20" role="img" aria-label="{_LABEL_TEXT}: {value_text}">
51+
<title>{_LABEL_TEXT}: {value_text}</title>
52+
<linearGradient id="s" x2="0" y2="100%">
53+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
54+
<stop offset="1" stop-opacity=".1"/>
55+
</linearGradient>
56+
<clipPath id="r">
57+
<rect width="{total_width}" height="20" rx="3" fill="#fff"/>
58+
</clipPath>
59+
<g clip-path="url(#r)">
60+
<rect width="{label_width}" height="20" fill="#555"/>
61+
<rect x="{label_width}" width="{value_width}" height="20" fill="{color}"/>
62+
<rect width="{total_width}" height="20" fill="url(#s)"/>
63+
</g>
64+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
65+
<text aria-hidden="true" x="{label_x * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)">{_LABEL_TEXT}</text>
66+
<text x="{label_x * 10}" y="140" transform="scale(.1)">{_LABEL_TEXT}</text>
67+
<text aria-hidden="true" x="{value_x * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)">{value_text}</text>
68+
<text x="{value_x * 10}" y="140" transform="scale(.1)">{value_text}</text>
69+
</g>
70+
</svg>'''

lib/muni_lib.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,74 @@ def write_cached_image(image_path):
177177
return False
178178

179179

180+
def write_cached_badge(status):
181+
"""
182+
Generate and write a status badge SVG to cache (local file or Cloud Storage).
183+
184+
Args:
185+
status: Current status ('green', 'yellow', 'red')
186+
187+
Returns:
188+
bool: True if successful, False otherwise
189+
"""
190+
from lib.badge import generate_badge
191+
192+
svg_content = generate_badge(status)
193+
cache_path = get_cache_path()
194+
195+
try:
196+
if cache_path.startswith('gs://'):
197+
from lib.gcs_utils import parse_gcs_path, gcs_upload_from_string
198+
199+
bucket_name, _ = parse_gcs_path(cache_path)
200+
201+
gcs_upload_from_string(
202+
bucket_name,
203+
'badge.svg',
204+
svg_content,
205+
content_type='image/svg+xml'
206+
)
207+
return True
208+
else:
209+
badge_path = os.path.join(os.path.dirname(cache_path), 'badge.svg')
210+
os.makedirs(os.path.dirname(badge_path), exist_ok=True)
211+
with open(badge_path, 'w') as f:
212+
f.write(svg_content)
213+
return True
214+
except Exception as e:
215+
print(f"Error writing cached badge: {e}")
216+
return False
217+
218+
219+
def read_cached_badge():
220+
"""
221+
Read the cached badge SVG from local file or Cloud Storage.
222+
223+
Returns:
224+
str: SVG content, or None if not found
225+
"""
226+
cache_path = get_cache_path()
227+
228+
try:
229+
if cache_path.startswith('gs://'):
230+
from lib.gcs_utils import parse_gcs_path, gcs_download_as_string
231+
232+
bucket_name, _ = parse_gcs_path(cache_path)
233+
content = gcs_download_as_string(bucket_name, 'badge.svg')
234+
if content is not None:
235+
return content.decode('utf-8') if isinstance(content, bytes) else content
236+
return None
237+
else:
238+
badge_path = os.path.join(os.path.dirname(cache_path), 'badge.svg')
239+
if os.path.exists(badge_path):
240+
with open(badge_path, 'r') as f:
241+
return f.read()
242+
return None
243+
except Exception as e:
244+
print(f"Error reading cached badge: {e}")
245+
return None
246+
247+
180248
def read_cached_image():
181249
"""
182250
Read the cached image from local file or Cloud Storage.

0 commit comments

Comments
 (0)