From dde48359edd5d4063c31ee00301fad887e8dc5bf Mon Sep 17 00:00:00 2001 From: Vivek Chand Date: Fri, 20 Mar 2026 02:26:44 +0100 Subject: [PATCH] feat: Webhook & Slack/Discord alerting integrations (closes vivekchand/clawmetry#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _send_slack_alert() with rich attachment format (color-coded by severity) - Add _send_discord_alert() with Discord embed format (color-coded by severity) - Add _get_alert_channels() / _dispatch_alert_to_channels() helpers - All _fire_alert() calls now auto-dispatch to configured channels - Add alert_channels DB table (v2 schema: id, name, type, webhook_url, enabled, timestamps) - Migration: auto-detect & recreate table if old v1 schema (config column) detected - New API endpoints: GET/POST /api/alerts/channels — list or create channel integrations PUT/DELETE /api/alerts/channels/ — update or remove a channel POST /api/alerts/channels//test — send test alert to a specific channel - Budget & Alerts modal: new Integrations tab with add/test/delete UI - 10 new tests, all 143 pass --- dashboard.py | 478 +++++++++++++++++++++++++++++++++++++++++++++- tests/test_api.py | 115 +++++++++++ 2 files changed, 591 insertions(+), 2 deletions(-) diff --git a/dashboard.py b/dashboard.py index 3bf081e..cd3bb6d 100755 --- a/dashboard.py +++ b/dashboard.py @@ -424,7 +424,35 @@ def _budget_init_db(): ON alert_history(fired_at DESC); CREATE INDEX IF NOT EXISTS idx_alert_history_rule ON alert_history(rule_id, fired_at DESC); + CREATE TABLE IF NOT EXISTS alert_channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + webhook_url TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ); """) + # Migration: ensure alert_channels uses v2 schema with webhook_url (not config) + try: + cols = [r[1] for r in db.execute("PRAGMA table_info(alert_channels)").fetchall()] + needs_recreate = cols and ('config' in cols or 'webhook_url' not in cols) + if needs_recreate: + # v1 schema detected — drop and recreate with v2 schema + db.execute("DROP TABLE IF EXISTS alert_channels") + db.execute("""CREATE TABLE alert_channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + webhook_url TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""") + db.commit() + except Exception as e: + print(f"Warning: alert_channels migration failed: {e}") db.close() @@ -650,6 +678,8 @@ def _fire_alert(rule_id, alert_type, message, channels=None): _send_telegram_alert(message) elif ch == 'webhook': pass # webhook sending handled by custom alert rules + # Also dispatch to configured Slack/Discord/webhook channels + _dispatch_alert_to_channels(message) def _send_telegram_alert(message): @@ -696,6 +726,84 @@ def _send_webhook_alert(url, alert_data): pass +def _send_slack_alert(webhook_url, message, alert_type='alert'): + """Send alert to a Slack incoming webhook URL.""" + if not webhook_url: + return + try: + import urllib.request as _ur + colour = {'alert': '#E01E5A', 'warning': '#ECB22E', 'info': '#2EB67D'}.get(alert_type, '#E01E5A') + payload = json.dumps({ + 'attachments': [{ + 'color': colour, + 'fallback': f'[ClawMetry] {message}', + 'text': f'*ClawMetry Alert*\n{message}', + 'footer': 'ClawMetry', + 'ts': int(time.time()), + }] + }).encode() + req = _ur.Request(webhook_url, data=payload, headers={'Content-Type': 'application/json'}, method='POST') + _ur.urlopen(req, timeout=10) + except Exception as e: + print(f"Warning: Slack alert failed: {e}") + + +def _send_discord_alert(webhook_url, message, alert_type='alert'): + """Send alert to a Discord webhook URL.""" + if not webhook_url: + return + try: + import urllib.request as _ur + colour = {'alert': 0xE01E5A, 'warning': 0xECB22E, 'info': 0x2EB67D}.get(alert_type, 0xE01E5A) + payload = json.dumps({ + 'embeds': [{ + 'title': 'ClawMetry Alert', + 'description': message, + 'color': colour, + 'timestamp': datetime.utcnow().isoformat(), + 'footer': {'text': 'ClawMetry'}, + }] + }).encode() + req = _ur.Request(webhook_url, data=payload, headers={'Content-Type': 'application/json'}, method='POST') + _ur.urlopen(req, timeout=10) + except Exception as e: + print(f"Warning: Discord alert failed: {e}") + + +def _get_alert_channels(): + """Get all configured alert channel destinations.""" + try: + with _fleet_db_lock: + db = _fleet_db() + rows = db.execute("SELECT * FROM alert_channels ORDER BY created_at DESC").fetchall() + db.close() + return [dict(r) for r in rows] + except Exception: + return [] + + +def _dispatch_alert_to_channels(message, alert_type='alert'): + """Dispatch an alert message to all enabled alert channels (Slack, Discord, webhook).""" + channels = _get_alert_channels() + for ch in channels: + if not ch.get('enabled'): + continue + ch_type = ch.get('type', '') + url = ch.get('webhook_url', '') + if not url: + continue + try: + if ch_type == 'slack': + _send_slack_alert(url, message, alert_type) + elif ch_type == 'discord': + _send_discord_alert(url, message, alert_type) + elif ch_type == 'webhook': + _send_webhook_alert(url, {'type': alert_type, 'message': message, 'timestamp': time.time()}) + except Exception as e: + print(f"Warning: Alert dispatch to {ch_type} failed: {e}") + + + def _get_alert_rules(): """Get all alert rules.""" try: @@ -2601,6 +2709,7 @@ def get_local_ip(): + @@ -2679,6 +2788,24 @@ def get_local_ip():
+ + @@ -7307,6 +7676,24 @@ def get_local_ip():
+ +