Files
raven-notify/main.py
T
admin d0219b90f0 feat: initial raven-notify — Discord + email alerts via POST /notify
FastAPI at port 8400. Both channels fire on every notification.
Grafana webhook endpoint ready for later wiring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 11:18:03 +02:00

242 lines
8.7 KiB
Python

import json
import logging
import os
import smtplib
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from email.mime.text import MIMEText
from pathlib import Path
import httpx
from fastapi import FastAPI
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO, format="%(asctime)s [raven] %(message)s")
logger = logging.getLogger("raven")
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "")
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM = os.getenv("SMTP_FROM", "")
SMTP_TO = os.getenv("SMTP_TO", "")
SITES_DIR = Path(os.getenv("SITES_DIR", "/opt/sites"))
AGENT_OS_DIR = Path(os.getenv("AGENT_OS_DIR", "/opt/agent-os"))
PORT = int(os.getenv("PORT", "8400"))
MAX_HISTORY = 50
SEVERITY_COLORS = {"critical": 15158332, "warning": 16776960, "info": 3066993}
class NotifyRequest(BaseModel):
message: str
severity: str = "info"
source: str = "unknown"
def _history_path() -> Path:
return SITES_DIR / "raven" / "history.json"
def _load_history() -> list:
p = _history_path()
if not p.exists():
return []
try:
return json.loads(p.read_text())
except Exception:
return []
def _save_history(history: list):
p = _history_path()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(history[-MAX_HISTORY:], indent=2))
def _send_discord(message: str, severity: str, source: str, timestamp: str):
if not DISCORD_WEBHOOK_URL:
logger.warning("DISCORD_WEBHOOK_URL not set — skipping Discord")
return
color = SEVERITY_COLORS.get(severity.lower(), 3066993)
payload = {
"embeds": [{
"title": f"[{severity.upper()}] {source}",
"description": message,
"color": color,
"timestamp": timestamp,
"footer": {"text": "raven-notify · nxm.co.za"},
}]
}
try:
r = httpx.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10)
r.raise_for_status()
logger.info(f"discord: sent [{severity}] {source}")
except Exception as e:
logger.error(f"discord send failed: {e}")
def _send_email(message: str, severity: str, source: str):
if not all([SMTP_USER, SMTP_PASSWORD, SMTP_TO]):
logger.warning("SMTP not configured — skipping email")
return
subject = f"[{severity.upper()}] {source}{message[:80]}"
body = f"Severity: {severity.upper()}\nSource: {source}\n\n{message}"
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = SMTP_FROM or SMTP_USER
msg["To"] = SMTP_TO
try:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as smtp:
smtp.ehlo()
smtp.starttls()
smtp.login(SMTP_USER, SMTP_PASSWORD)
smtp.sendmail(SMTP_FROM or SMTP_USER, [SMTP_TO], msg.as_string())
logger.info(f"email: sent [{severity}] {source}")
except Exception as e:
logger.error(f"email send failed: {e}")
def _render_html(history: list, timestamp: str) -> str:
rows = ""
for entry in reversed(history[-20:]):
sev = entry.get("severity", "info")
colors = {"critical": "#f85149", "warning": "#d29922", "info": "#3fb950"}
color = colors.get(sev, "#8b949e")
ts = entry.get("timestamp", "")
rows += f"""
<tr>
<td><span data-utc="{ts}"></span></td>
<td style="color:{color};font-weight:bold">{sev.upper()}</td>
<td class="src">{entry.get('source', '')}</td>
<td>{entry.get('message', '')}</td>
</tr>"""
total = len(history)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Raven — Notifications</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; }}
h1 {{ color: #58a6ff; margin-bottom: 0.25rem; }}
.desc {{ color: #8b949e; font-size: 0.9rem; margin: 0.4rem 0 0.25rem; }}
.meta {{ color: #8b949e; font-size: 0.85rem; margin-bottom: 2rem; }}
.meta a {{ color: #8b949e; }}
h2 {{ color: #8b949e; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; margin: 2rem 0 0.75rem; }}
table {{ width: 100%; border-collapse: collapse; }}
td {{ padding: 0.5rem 0.75rem; border-bottom: 1px solid #21262d; font-size: 0.88rem; vertical-align: top; }}
td:first-child {{ white-space: nowrap; color: #8b949e; min-width: 9rem; }}
td:nth-child(2) {{ white-space: nowrap; min-width: 6rem; }}
.src {{ color: #79c0ff; white-space: nowrap; min-width: 8rem; }}
td:last-child {{ line-height: 1.5; }}
.empty {{ color: #8b949e; font-size: 0.9rem; padding: 1rem 0; }}
</style>
</head>
<body>
<h1>Raven — Notifications</h1>
<p class="desc">Sends alerts and notifications via Discord and email. Triggered by Varys (service down/up) and Grafana (infrastructure alerts).</p>
<p class="meta">Updated <span data-utc="{timestamp}"></span> &nbsp;·&nbsp; {total} total &nbsp;·&nbsp; <a href="/">← home</a></p>
<h2>Recent Notifications (last 20)</h2>
{"<table><tbody>" + rows + "</tbody></table>" if rows else '<p class="empty">No notifications yet.</p>'}
<script>
document.querySelectorAll('[data-utc]').forEach(el => {{
el.textContent = new Date(el.dataset.utc).toLocaleString(undefined, {{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}});
}});
</script>
</body>
</html>"""
def _render_md(history: list, timestamp: str) -> str:
now = timestamp[:16].replace("T", " ") + " UTC"
lines = [f"# Raven — Notification Log\n\nUpdated: {now} | {len(history)} total\n"]
for e in reversed(history[-10:]):
ts = e.get("timestamp", "")[:16].replace("T", " ") + " UTC"
lines.append(f"- [{e.get('severity','?').upper()}] {ts} · **{e.get('source','')}** — {e.get('message','')}")
return "\n".join(lines)
def _write_status(history: list, last_event: dict = None):
timestamp = datetime.now(timezone.utc).isoformat()
out_dir = SITES_DIR / "raven"
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "index.html").write_text(_render_html(history, timestamp))
(out_dir / "last-output.md").write_text(_render_md(history, timestamp))
log_dir = AGENT_OS_DIR / "logs" / "raven-notify"
log_dir.mkdir(parents=True, exist_ok=True)
last_msg = (
f"last: [{last_event['severity']}] {last_event['source']}{last_event['message'][:50]}"
if last_event else "running, no notifications yet"
)
(log_dir / "last-run.json").write_text(json.dumps({
"agent": "raven-notify",
"timestamp": timestamp,
"status": "success",
"result": f"{len(history)} notifications total; {last_msg}",
}, indent=2))
def _do_notify(message: str, severity: str, source: str) -> dict:
timestamp = datetime.now(timezone.utc).isoformat()
severity = severity.lower()
entry = {"timestamp": timestamp, "severity": severity, "source": source, "message": message}
history = _load_history()
history.append(entry)
_save_history(history)
_send_discord(message, severity, source, timestamp)
_send_email(message, severity, source)
_write_status(history, entry)
logger.info(f"notified [{severity}] {source}: {message[:80]}")
return {"ok": True, "timestamp": timestamp}
@asynccontextmanager
async def lifespan(app: FastAPI):
history = _load_history()
_write_status(history)
logger.info(f"raven-notify started on port {PORT}{len(history)} notifications in history")
yield
app = FastAPI(title="raven-notify", lifespan=lifespan)
@app.get("/health")
def health():
return {"status": "ok", "agent": "raven-notify"}
@app.post("/notify")
def handle_notify(req: NotifyRequest):
return _do_notify(req.message, req.severity, req.source)
@app.post("/webhook/grafana")
def handle_grafana(payload: dict):
status = payload.get("status", "firing")
alerts = payload.get("alerts", [])
if alerts:
for alert in alerts:
name = alert.get("labels", {}).get("alertname", "unknown")
msg = alert.get("annotations", {}).get("description", name)
sev = "critical" if alert.get("status", status) == "firing" else "info"
_do_notify(msg, sev, f"grafana/{name}")
return {"ok": True, "processed": len(alerts)}
msg = payload.get("message", "") or payload.get("title", "Grafana Alert")
sev = "critical" if status == "firing" else "info"
return _do_notify(msg, sev, "grafana")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=PORT)