diff --git a/main.py b/main.py index adeacfe..6a6a78d 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,7 @@ CG3: static-mappe serveres fra /static/*. AUTH: /auth/login + /auth/callback for klient OAuth2 onboarding. CG5a: POST /notify/webhook — push til Slack/Teams/Chat/Discord/etc. CG5b: POST /notify/email — SendGrid digest til kunde-e-post. +CG5c: POST /notify/sms — Twilio SMS, Guard+-tier, spike-varsler. CG5-onboard: GET /onboard + POST /onboard/complete — klient-onboarding wizard. """ @@ -49,6 +50,10 @@ import sendgrid from sendgrid.helpers.mail import Mail AGENT_ID = "osvauco-opax" + +# Guard+-tiers som får tilgang til SMS-varsler +SMS_ALLOWED_TIERS = {"guard", "shield", "enterprise"} + app = FastAPI( title="OSVauco OPAX Agent", description="Agent for OSVauco-OPAX platform.", @@ -168,7 +173,7 @@ class BudgetUpdateRequest(BaseModel): budget: float -# ── NOTIFICATION MODELS (CG5a + CG5b) ──────────────────────────────────────── +# ── NOTIFICATION MODELS (CG5a + CG5b + CG5c) ────────────────────────────────── class WebhookNotifyRequest(BaseModel): url: str = Field(..., description="Webhook-URL (Slack/Teams/Google Chat/Discord/custom)") event: str = Field("custom", description="Event-type: spike | digest | budget | anomaly | custom") @@ -183,6 +188,31 @@ class EmailNotifyRequest(BaseModel): body_html: Optional[str] = Field(None, description="HTML-innhold. Auto-generert fra billing-data hvis tom.") payload: Optional[Dict[str, Any]] = Field(None, description="Valgfri rådata å inkludere i e-post") +class SmsNotifyRequest(BaseModel): + """ + CG5c: SMS-varsel via Twilio REST API. + Kun tilgjengelig for Guard+-tier (guard | shield | enterprise). + Brukes primært til kritiske kostnadsspikes — hold meldinger korte (<160 tegn). + """ + to: str = Field( + ..., + description="Mottakers mobilnummer i E.164-format, f.eks. +4791234567", + pattern=r"^\+[1-9]\d{7,14}$", + ) + body: str = Field( + ..., + description="Meldingstekst. Maks 160 tegn anbefalt (ett SMS-segment).", + max_length=320, + ) + event: str = Field( + "spike", + description="Event-type: spike | budget | anomaly | custom", + ) + tier: str = Field( + ..., + description="Kundens tier. Må være guard, shield eller enterprise.", + ) + # ── ONBOARDING MODEL (CG5-onboard) ──────────────────────────────────────────── class OnboardCompleteRequest(BaseModel): @@ -196,13 +226,13 @@ class OnboardCompleteRequest(BaseModel): email_to: Optional[str] = None -# ── NOTIFICATION SERVICE (CG5a + CG5b) ─────────────────────────────────────── +# ── NOTIFICATION SERVICE (CG5a + CG5b + CG5c) ───────────────────────────────── class NotificationService: """ Felles delivery layer for CostGuard-varsler. CG5a: send_webhook() — poster til hvilken som helst webhook-URL CG5b: send_email() — sender via SendGrid - Brukt av POST /notify/webhook og POST /notify/email. + CG5c: send_sms() — sender via Twilio REST (Guard+-tier) """ # Event-type → emoji + standardtekst @@ -271,7 +301,6 @@ class NotificationService: meta = self._event_meta(req.event) subject = req.subject or f"{meta['emoji']} CostGuard: {meta['label']} — {datetime.date.today()}" - # Auto-generer HTML-innhold fra billing-data hvis ikke oppgitt if req.body_html: html_content = req.body_html else: @@ -293,6 +322,71 @@ class NotificationService: print(f"[NotificationService] email error: {e}", file=sys.stderr) return {"status": "error", "detail": str(e)} + async def send_sms(self, req: SmsNotifyRequest) -> dict: + """ + CG5c: Send SMS via Twilio REST API (ingen Twilio SDK — kun httpx). + Guard+-tier påkrevd. Konfigureres via env-vars: + TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER + """ + # Guard+-tier-sjekk + if req.tier.lower() not in SMS_ALLOWED_TIERS: + return { + "status": "not_allowed", + "detail": f"SMS krever Guard+-tier. Aktuæll tier: '{req.tier}'. " + f"Oppgrader på opax.vauco.no/onboard.", + } + + account_sid = os.environ.get("TWILIO_ACCOUNT_SID") + auth_token = os.environ.get("TWILIO_AUTH_TOKEN") + from_number = os.environ.get("TWILIO_FROM_NUMBER") + + if not all([account_sid, auth_token, from_number]): + return { + "status": "not_configured", + "detail": "En eller flere Twilio-env-vars mangler: " + "TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER", + } + + meta = self._event_meta(req.event) + # Bygg meldingstekst med event-prefix hvis den ikke allerede starter med emoji + sms_body = req.body + if not sms_body.startswith(meta["emoji"]): + sms_body = f"{meta['emoji']} CostGuard: {sms_body}" + + # Twilio Messages API — form-encoded POST + twilio_url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json" + try: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post( + twilio_url, + data={"From": from_number, "To": req.to, "Body": sms_body}, + auth=(account_sid, auth_token), + ) + data = resp.json() + print( + f"[NotificationService] sms → {req.to} " + f"sid={data.get('sid','?')} status={data.get('status','?')}" + ) + if resp.status_code >= 400: + return { + "status": "error", + "http_status": resp.status_code, + "twilio_code": data.get("code"), + "detail": data.get("message", resp.text[:200]), + } + return { + "status": "ok", + "sid": data.get("sid"), + "to": req.to, + "sms_status": data.get("status"), + "event": req.event, + } + except httpx.TimeoutException: + return {"status": "error", "detail": "Twilio timeout (>15s)"} + except Exception as e: + print(f"[NotificationService] sms error: {e}", file=sys.stderr) + return {"status": "error", "detail": str(e)} + async def _build_digest_html(self, extra_payload: Optional[dict] = None) -> str: """Henter billing-data og bygger HTML-digest.""" try: @@ -343,7 +437,7 @@ class NotificationService: _notifier = NotificationService() -# ── NOTIFY ENDPOINTS (CG5a + CG5b) ─────────────────────────────────────────── +# ── NOTIFY ENDPOINTS (CG5a + CG5b + CG5c) ───────────────────────────────────── async def _notify_webhook(request: Request, req: WebhookNotifyRequest): result = await _notifier.send_webhook(req) @@ -363,6 +457,26 @@ async def _notify_email(request: Request, req: EmailNotifyRequest): app.add_api_route("/notify/email", endpoint=require_auth(_notify_email), methods=["POST"]) +async def _notify_sms(request: Request, req: SmsNotifyRequest): + """ + CG5c: POST /notify/sms — Guard+-tier SMS via Twilio. + HTTP-svar: + 200 — sendt OK + 402 — tier ikke Guard+ (payment required / upgrade) + 503 — Twilio ikke konfigurert (env-vars mangler) + 500 — Twilio-feil + """ + result = await _notifier.send_sms(req) + if result["status"] == "not_allowed": + return JSONResponse(status_code=402, content=result) + if result["status"] == "not_configured": + return JSONResponse(status_code=503, content=result) + status_code = 200 if result["status"] == "ok" else 500 + return JSONResponse(status_code=status_code, content=result) + +app.add_api_route("/notify/sms", endpoint=require_auth(_notify_sms), methods=["POST"]) + + # ── ONBOARDING ENDPOINTS (CG5-onboard) ─────────────────────────────────────── @app.get("/onboard") @@ -378,21 +492,19 @@ def onboard_wizard(): async def onboard_complete(req: OnboardCompleteRequest): """ CG5-onboard: Lagrer kundeprofil i Firestore og sender velkomstvarsler - via eksisterende POST /notify/webhook og POST /notify/email. - Ingen nye notification-endepunkter — gjenbruker _notifier singleton. + via eksisterende _notifier (send_webhook + send_email). """ - # 1. Lagre i Firestore (best-effort — feiler ikke onboarding ved DB-feil) if db: try: db.collection("onboarded_customers").document(req.gcp_project).set({ - "company": req.company, - "contact": req.contact, - "email": req.email, - "gcp_project": req.gcp_project, - "tier": req.tier, - "channels": req.channels, - "webhook_url": req.webhook_url, - "email_to": req.email_to, + "company": req.company, + "contact": req.contact, + "email": req.email, + "gcp_project": req.gcp_project, + "tier": req.tier, + "channels": req.channels, + "webhook_url": req.webhook_url, + "email_to": req.email_to, "onboarded_at": firestore.SERVER_TIMESTAMP, }) print(f"[Onboard] {req.gcp_project} lagret i Firestore") @@ -401,9 +513,8 @@ async def onboard_complete(req: OnboardCompleteRequest): notify_results: Dict[str, Any] = {} - # 2. Webhook-velkomstmelding (via eksisterende /notify/webhook-logikk) if req.webhook_url and "webhook" in req.channels: - wh_result = await _notifier.send_webhook(WebhookNotifyRequest( + notify_results["webhook"] = await _notifier.send_webhook(WebhookNotifyRequest( url=req.webhook_url, event="custom", title=f"🎉 {req.company} er koblet til CostGuard!", @@ -412,15 +523,13 @@ async def onboard_complete(req: OnboardCompleteRequest): f"Du mottar første kostnadsrapport innen 24 timer." ), payload={ - "bedrift": req.company, - "tier": req.tier, - "prosjekt": req.gcp_project, - "dato": datetime.date.today().isoformat(), + "bedrift": req.company, + "tier": req.tier, + "prosjekt": req.gcp_project, + "dato": datetime.date.today().isoformat(), }, )) - notify_results["webhook"] = wh_result - # 3. Velkomst-e-post (via eksisterende /notify/email-logikk) if req.email_to and "email" in req.channels: TIER_LABELS = { "starter": "Starter ($499/mnd)", @@ -429,7 +538,7 @@ async def onboard_complete(req: OnboardCompleteRequest): "enterprise": "Enterprise ($3.500+/mnd)", } tier_label = TIER_LABELS.get(req.tier, req.tier) - em_result = await _notifier.send_email(EmailNotifyRequest( + notify_results["email"] = await _notifier.send_email(EmailNotifyRequest( to=req.email_to, event="custom", subject=f"✅ Velkommen til CostGuard — {req.company}", @@ -446,20 +555,16 @@ async def onboard_complete(req: OnboardCompleteRequest): - - + - - + - - + - - +
Bedrift{req.company}
{req.company}
GCP-prosjekt{req.gcp_project}
{req.gcp_project}
Tier{tier_label}
{tier_label}
Kanaler{', '.join(req.channels)}
{', '.join(req.channels)}

Du mottar din første kostnadsrapport innen 24 timer.

@@ -476,9 +581,7 @@ async def onboard_complete(req: OnboardCompleteRequest):
""", )) - notify_results["email"] = em_result - # 4. Intern Slack-notis til Vauco (opsjonelt — krever VAUCO_INTERNAL_WEBHOOK) internal_wh = os.environ.get("VAUCO_INTERNAL_WEBHOOK") if internal_wh: try: @@ -493,7 +596,7 @@ async def onboard_complete(req: OnboardCompleteRequest): print(f"[Onboard] Intern Slack-feil: {e}", file=sys.stderr) return { - "status": "ok", + "status": "ok", "customer": req.gcp_project, "company": req.company, "channels": req.channels, @@ -621,10 +724,6 @@ app.add_api_route("/billing/budget", endpoint=require_auth(set_budget), methods= @app.post("/billing/email-report") async def trigger_email_report(): - """ - Legacy scheduler-endepunkt. Kaller NotificationService.send_email() internt. - Beholdes for bakoverkompatibilitet med Cloud Scheduler. - """ result = await _notifier.send_email(EmailNotifyRequest(event="digest")) if result["status"] == "not_configured": return JSONResponse(status_code=200, content={"status": "not_configured", "error": result["detail"]})