diff --git a/Dockerfile b/Dockerfile
index aec1306..675316f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,4 +3,4 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
-CMD ["python", "main.py"]
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8400"]
diff --git a/main.py b/main.py
index 361451b..3530bbb 100644
--- a/main.py
+++ b/main.py
@@ -9,6 +9,7 @@ from pathlib import Path
import httpx
from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO, format="%(asctime)s [raven] %(message)s")
@@ -29,6 +30,26 @@ MAX_HISTORY = 50
SEVERITY_COLORS = {"critical": 15158332, "warning": 16776960, "info": 3066993}
+def _state_path() -> Path:
+ return SITES_DIR / "raven" / "state.json"
+
+
+def _load_state() -> dict:
+ p = _state_path()
+ if not p.exists():
+ return {"email_enabled": True}
+ try:
+ return json.loads(p.read_text())
+ except Exception:
+ return {"email_enabled": True}
+
+
+def _save_state(state: dict):
+ p = _state_path()
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(json.dumps(state, indent=2))
+
+
class NotifyRequest(BaseModel):
message: str
severity: str = "info"
@@ -78,6 +99,9 @@ def _send_discord(message: str, severity: str, source: str, timestamp: str):
def _send_email(message: str, severity: str, source: str):
+ if not _load_state().get("email_enabled", True):
+ logger.info("email: disabled by toggle — skipping")
+ return
if not all([SMTP_USER, SMTP_PASSWORD, SMTP_TO]):
logger.warning("SMTP not configured — skipping email")
return
@@ -98,7 +122,7 @@ def _send_email(message: str, severity: str, source: str):
logger.error(f"email send failed: {e}")
-def _render_html(history: list, timestamp: str) -> str:
+def _render_html(history: list, timestamp: str, email_enabled: bool = True) -> str:
rows = ""
for entry in reversed(history[-20:]):
sev = entry.get("severity", "info")
@@ -114,40 +138,80 @@ def _render_html(history: list, timestamp: str) -> str:
"""
total = len(history)
+ email_color = "#3fb950" if email_enabled else "#f85149"
+ email_label = "ENABLED" if email_enabled else "DISABLED"
+ btn_label = "Disable Email" if email_enabled else "Enable Email"
return f"""
Raven — Notifications
+
+
+
+
Raven — Notifications
Sends alerts and notifications via Discord and email. Triggered by Varys (service down/up) and Grafana (infrastructure alerts).
- Updated · {total} total · ← home
+ Updated · {total} total
+
+ Email alerts: {email_label}
+
+
Recent Notifications (last 20)
{"" if rows else 'No notifications yet.
'}
+
"""
@@ -163,9 +227,10 @@ def _render_md(history: list, timestamp: str) -> str:
def _write_status(history: list, last_event: dict = None):
timestamp = datetime.now(timezone.utc).isoformat()
+ email_enabled = _load_state().get("email_enabled", True)
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 / "index.html").write_text(_render_html(history, timestamp, email_enabled))
(out_dir / "last-output.md").write_text(_render_md(history, timestamp))
log_dir = AGENT_OS_DIR / "logs" / "raven-notify"
@@ -209,6 +274,13 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="raven-notify", lifespan=lifespan)
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["POST"],
+ allow_headers=["*"],
+)
+
@app.get("/health")
def health():
@@ -220,15 +292,32 @@ def handle_notify(req: NotifyRequest):
return _do_notify(req.message, req.severity, req.source)
+@app.post("/email/toggle")
+def toggle_email():
+ state = _load_state()
+ state["email_enabled"] = not state.get("email_enabled", True)
+ _save_state(state)
+ _write_status(_load_history())
+ logger.info(f"email alerts {'enabled' if state['email_enabled'] else 'disabled'} via toggle")
+ return {"ok": True, "email_enabled": state["email_enabled"]}
+
+
@app.post("/webhook/grafana")
def handle_grafana(payload: dict):
status = payload.get("status", "firing")
alerts = payload.get("alerts", [])
if alerts:
for alert in alerts:
+ alert_status = alert.get("status", status)
name = alert.get("labels", {}).get("alertname", "unknown")
- msg = alert.get("annotations", {}).get("description", name)
- sev = "critical" if alert.get("status", status) == "firing" else "info"
+ if alert_status == "resolved":
+ # Grafana always sends the firing annotation text in resolved payloads —
+ # override with a clear recovery message so Discord/email isn't confusing.
+ msg = f"✅ RESOLVED: {name} — alert has cleared, service is back online."
+ sev = "info"
+ else:
+ msg = alert.get("annotations", {}).get("description", name)
+ sev = "critical"
_do_notify(msg, sev, f"grafana/{name}")
return {"ok": True, "processed": len(alerts)}
msg = payload.get("message", "") or payload.get("title", "Grafana Alert")
@@ -239,3 +328,45 @@ def handle_grafana(payload: dict):
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=PORT)
+
+
+# ---------------------------------------------------------------------------
+# Jon Snow approval gate — action notification endpoint
+# ---------------------------------------------------------------------------
+
+class ActionNotifyRequest(BaseModel):
+ description: str
+ approve_url: str
+ reject_url: str
+ action_type: str
+ source: str = "jon-snow"
+
+
+@app.post("/notify-with-actions")
+def handle_action_notify(req: ActionNotifyRequest):
+ """Send a Discord embed with clickable approve/reject links for Jon Snow's approval gate."""
+ if not DISCORD_WEBHOOK_URL:
+ logger.warning("DISCORD_WEBHOOK_URL not set — skipping Discord")
+ return {"ok": False, "error": "no webhook configured"}
+
+ payload = {
+ "embeds": [{
+ "title": "⚡ Action Approval Required",
+ "description": f"**{req.source}** wants to: `{req.description}`",
+ "color": 16776960, # yellow
+ "fields": [
+ {"name": "Action Type", "value": f"`{req.action_type}`", "inline": True},
+ {"name": "Expires", "value": "15 minutes", "inline": True},
+ ],
+ "footer": {"text": "jon-snow approval gate · nxm.co.za"},
+ }],
+ "content": f"✅ **Approve:** {req.approve_url}\n❌ **Reject:** {req.reject_url}",
+ }
+ try:
+ r = httpx.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10)
+ r.raise_for_status()
+ logger.info(f"discord: action approval request sent for {req.action_type}")
+ return {"ok": True}
+ except Exception as e:
+ logger.error(f"discord action notify failed: {e}")
+ return {"ok": False, "error": str(e)}