Skip to content

Commit dd6af5e

Browse files
committed
Updated notification system
1 parent 43f3e7f commit dd6af5e

File tree

10 files changed

+427
-63
lines changed

10 files changed

+427
-63
lines changed

IMPROVEMENTS.md

Lines changed: 0 additions & 50 deletions
This file was deleted.

NOTIFICATION_SYSTEM.md

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Notification System
2+
3+
Developer reference for the notification subsystem. For user-facing setup (webhook URLs, RSS endpoint, social media links), see [README.md](../README.md). For cloud credential provisioning, see [CONFIGURATION.md](../CONFIGURATION.md).
4+
5+
---
6+
7+
## Overview
8+
9+
When the reported system status changes (e.g. green to yellow), `check_status.py` dispatches notifications to all configured channels:
10+
11+
| Channel | Module | Credentials required | Notes |
12+
|---|---|---|---|
13+
| Bluesky | `lib/notifiers/bluesky.py` | Yes | Posts via AT Protocol |
14+
| Mastodon | `lib/notifiers/mastodon.py` | Yes | Posts via Mastodon API |
15+
| RSS | `lib/notifiers/rss.py` | No | Always enabled; writes XML to cache |
16+
| Webhooks | `lib/notifiers/webhooks.py` | Yes (URLs) | Auto-detects Slack, Discord, Teams |
17+
18+
All channels use shared status messages from `lib/notifiers/messages.py`.
19+
20+
---
21+
22+
## Architecture
23+
24+
### Call Flow
25+
26+
```
27+
check_status.py
28+
├── detect status, apply hysteresis → reported_status
29+
├── determine should_notify (see Notification Triggers below)
30+
31+
├── notify_status_change(status, previous_status, delay_summaries, timestamp)
32+
│ ├── bluesky.post_to_bluesky() # if BLUESKY_* env vars set
33+
│ ├── mastodon.post_to_mastodon() # if MASTODON_* env vars set
34+
│ ├── rss.update_rss_feed() # always
35+
│ └── webhooks.send_webhooks() # if WEBHOOK_URLS set
36+
37+
├── check results for failures
38+
└── if no failures: cache_data['last_notified_status'] = current_reported
39+
```
40+
41+
The dispatcher (`lib/notifiers/dispatcher.py`) checks environment variables for each channel. Unconfigured channels return a `skipped` result immediately without attempting any network calls.
42+
43+
### Notification Triggers
44+
45+
`check_status.py` sends notifications in two cases:
46+
47+
1. **Hysteresis transition**`apply_status_hysteresis()` reports `status_changed=True` and there was a previous reported status. This is the normal path: the smoothed status has changed.
48+
49+
2. **Missed notification recovery** — The hysteresis status did not change, but `last_notified_status` differs from the current reported status. This catches cases where a previous notification attempt failed partway through (some channels succeeded, some didn't), so the next successful check retries the notification.
50+
51+
```python
52+
# Case 1: hysteresis transition
53+
if hysteresis_result['status_changed'] and previous_reported_status is not None:
54+
should_notify = True
55+
56+
# Case 2: missed notification recovery
57+
elif previous_last_notified is not None and previous_last_notified != current_reported:
58+
should_notify = True
59+
```
60+
61+
---
62+
63+
## Channel Return Format
64+
65+
Every channel function returns a dict with this common structure:
66+
67+
```python
68+
{
69+
'success': bool, # True if the notification was delivered
70+
'skipped': bool, # True if channel is not configured (credentials missing)
71+
'error': str, # Error message, or None on success
72+
# ... plus channel-specific fields (see below)
73+
}
74+
```
75+
76+
### Tri-state logic
77+
78+
| `success` | `skipped` | Meaning |
79+
|---|---|---|
80+
| `True` | `False` | Delivered successfully |
81+
| `False` | `True` | Channel not configured — not a failure |
82+
| `False` | `False` | Attempted but failed (network error, auth error, etc.) |
83+
84+
`check_status.py` treats `skipped` results as non-failures. Only `success=False, skipped=False` results count as failures and prevent `last_notified_status` from being updated.
85+
86+
### Channel-specific fields
87+
88+
| Channel | Extra fields |
89+
|---|---|
90+
| Bluesky | `uri` — AT Protocol URI of the created post |
91+
| Mastodon | `url` — URL of the created post |
92+
| RSS | `path` — file path or `gs://` URL where the feed was written |
93+
| Webhooks | `sent` — count of successful webhooks, `failed` — count of failed webhooks |
94+
95+
---
96+
97+
## Duplicate Prevention
98+
99+
### `last_notified_status`
100+
101+
The cache stores `last_notified_status` — the status string (`'green'`, `'yellow'`, `'red'`) that was last successfully sent to all channels.
102+
103+
**Update rule:** `last_notified_status` is only updated when *no channel fails*. If any configured channel fails (not skipped, but actually fails), the value is left unchanged so the next check cycle will retry via the missed notification recovery path.
104+
105+
**Flow:**
106+
107+
```
108+
1. Read previous_last_notified from cache
109+
2. Determine should_notify (transition or recovery)
110+
3. Dispatch to all channels
111+
4. If any_failed:
112+
last_notified_status stays unchanged → next cycle retries
113+
5. If no failures:
114+
last_notified_status = current_reported → no retry needed
115+
```
116+
117+
### Interaction with hysteresis
118+
119+
The hysteresis system (`apply_status_hysteresis()`) prevents rapid status flips by requiring consistent readings before changing `reported_status`. Notifications are gated behind hysteresis — a notification is only sent when `reported_status` actually changes, not on every raw detection fluctuation.
120+
121+
---
122+
123+
## Configuration
124+
125+
### Environment Variables
126+
127+
| Variable | Channel | Description |
128+
|---|---|---|
129+
| `BLUESKY_HANDLE` | Bluesky | Account handle (e.g. `munimetro.bsky.social`) |
130+
| `BLUESKY_APP_PASSWORD` | Bluesky | App password for the account |
131+
| `MASTODON_INSTANCE` | Mastodon | Instance URL (e.g. `https://mastodon.social`) |
132+
| `MASTODON_ACCESS_TOKEN` | Mastodon | Access token for the account |
133+
| `WEBHOOK_URLS` | Webhooks | Comma-separated list of webhook URLs |
134+
135+
RSS requires no environment variables — it writes to the local cache directory (or GCS when `CLOUD_RUN` is set).
136+
137+
For cloud credential setup and secret management, see [CONFIGURATION.md](../CONFIGURATION.md).
138+
139+
### Webhook Platform Detection
140+
141+
`send_webhooks()` auto-detects the platform from the URL and formats the payload accordingly:
142+
143+
| URL pattern | Platform | Payload format |
144+
|---|---|---|
145+
| `hooks.slack.com` | Slack | Slack incoming webhook |
146+
| `discord.com/api/webhooks` | Discord | Discord embed |
147+
| `webhook.office.com` / `.logic.azure.com` | Teams | MessageCard |
148+
| Anything else | Generic | JSON with `status`, `previous_status`, `description`, `delay_summaries`, `timestamp` |
149+
150+
---
151+
152+
## Key Files
153+
154+
| File | Purpose |
155+
|---|---|
156+
| `lib/notifiers/__init__.py` | Public API: re-exports `notify_status_change` and channel functions |
157+
| `lib/notifiers/dispatcher.py` | `notify_status_change()` — dispatches to all channels, checks env vars |
158+
| `lib/notifiers/bluesky.py` | `post_to_bluesky()` — AT Protocol client |
159+
| `lib/notifiers/mastodon.py` | `post_to_mastodon()` — Mastodon API client |
160+
| `lib/notifiers/rss.py` | `update_rss_feed()`, `read_rss_feed()` — RSS 2.0 feed generation |
161+
| `lib/notifiers/webhooks.py` | `send_webhooks()` — multi-platform webhook delivery |
162+
| `lib/notifiers/messages.py` | `STATUS_MESSAGES` — shared message templates |
163+
| `api/check_status.py` | Notification trigger logic, `last_notified_status` management |

api/check_status.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ def check_status(should_write_cache=False, interval_seconds=None):
290290
for channel, notify_result in notify_results.items():
291291
if notify_result['success']:
292292
print(f" {channel}: OK")
293-
elif 'Not configured' in str(notify_result.get('error', '')):
293+
elif notify_result.get('skipped'):
294294
pass # Unconfigured channels are not failures
295295
else:
296296
print(f" {channel}: Failed - {notify_result.get('error', 'Unknown error')}")

lib/notifiers/bluesky.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def post_to_bluesky(status, previous_status=None, delay_summaries=None):
3232
if not handle or not app_password:
3333
return {
3434
'success': False,
35+
'skipped': True,
3536
'uri': None,
3637
'error': 'BLUESKY_HANDLE and BLUESKY_APP_PASSWORD environment variables required'
3738
}
@@ -61,12 +62,14 @@ def post_to_bluesky(status, previous_status=None, delay_summaries=None):
6162

6263
return {
6364
'success': True,
65+
'skipped': False,
6466
'uri': post.uri,
6567
'error': None
6668
}
6769
except Exception as e:
6870
return {
6971
'success': False,
72+
'skipped': False,
7073
'uri': None,
7174
'error': str(e)
7275
}

lib/notifiers/dispatcher.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ def notify_status_change(status, previous_status, delay_summaries=None, timestam
2222
Returns:
2323
dict: Results from each notifier channel
2424
{
25-
'bluesky': {'success': bool, 'uri': str, 'error': str},
26-
'mastodon': {'success': bool, 'url': str, 'error': str},
27-
'rss': {'success': bool, 'path': str, 'error': str},
28-
'webhooks': {'success': bool, 'sent': int, 'failed': int, 'error': str}
25+
'bluesky': {'success': bool, 'skipped': bool, 'uri': str, 'error': str},
26+
'mastodon': {'success': bool, 'skipped': bool, 'url': str, 'error': str},
27+
'rss': {'success': bool, 'skipped': bool, 'path': str, 'error': str},
28+
'webhooks': {'success': bool, 'skipped': bool, 'sent': int, 'failed': int, 'error': str}
2929
}
30+
'skipped' is True when the channel is not configured (no credentials/URLs set).
3031
"""
3132
results = {}
3233

@@ -40,6 +41,7 @@ def notify_status_change(status, previous_status, delay_summaries=None, timestam
4041
else:
4142
results['bluesky'] = {
4243
'success': False,
44+
'skipped': True,
4345
'uri': None,
4446
'error': 'Not configured (BLUESKY_HANDLE/BLUESKY_APP_PASSWORD not set)'
4547
}
@@ -54,6 +56,7 @@ def notify_status_change(status, previous_status, delay_summaries=None, timestam
5456
else:
5557
results['mastodon'] = {
5658
'success': False,
59+
'skipped': True,
5760
'url': None,
5861
'error': 'Not configured (MASTODON_INSTANCE/MASTODON_ACCESS_TOKEN not set)'
5962
}

lib/notifiers/mastodon.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def post_to_mastodon(status, previous_status=None, delay_summaries=None):
3232
if not instance or not access_token:
3333
return {
3434
'success': False,
35+
'skipped': True,
3536
'url': None,
3637
'error': 'MASTODON_INSTANCE and MASTODON_ACCESS_TOKEN environment variables required'
3738
}
@@ -64,12 +65,14 @@ def post_to_mastodon(status, previous_status=None, delay_summaries=None):
6465

6566
return {
6667
'success': True,
68+
'skipped': False,
6769
'url': result.get('url'),
6870
'error': None
6971
}
7072
except Exception as e:
7173
return {
7274
'success': False,
75+
'skipped': False,
7376
'url': None,
7477
'error': str(e)
7578
}

lib/notifiers/rss.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ def update_rss_feed(status, description=None, delay_summaries=None, timestamp=No
304304
if not _write_items(items):
305305
return {
306306
'success': False,
307+
'skipped': False,
307308
'path': None,
308309
'error': 'Failed to write RSS items'
309310
}
@@ -313,19 +314,22 @@ def update_rss_feed(status, description=None, delay_summaries=None, timestamp=No
313314
if write_rss_feed(xml_content):
314315
return {
315316
'success': True,
317+
'skipped': False,
316318
'path': get_rss_path(),
317319
'error': None
318320
}
319321
else:
320322
return {
321323
'success': False,
324+
'skipped': False,
322325
'path': None,
323326
'error': 'Failed to write RSS feed'
324327
}
325328

326329
except Exception as e:
327330
return {
328331
'success': False,
332+
'skipped': False,
329333
'path': None,
330334
'error': str(e)
331335
}

lib/notifiers/webhooks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ def send_webhooks(status, previous_status=None, delay_summaries=None, timestamp=
171171
if not urls:
172172
return {
173173
'success': False,
174+
'skipped': True,
174175
'sent': 0,
175176
'failed': 0,
176177
'error': 'Not configured (WEBHOOK_URLS not set)',
@@ -206,6 +207,7 @@ def send_webhooks(status, previous_status=None, delay_summaries=None, timestamp=
206207

207208
return {
208209
'success': failed == 0 and sent > 0,
210+
'skipped': False,
209211
'sent': sent,
210212
'failed': failed,
211213
'error': first_error,

0 commit comments

Comments
 (0)