feat(CG5c): POST /notify/sms — Twilio SMS for Guard+-tier, spike-varsler
This commit is contained in:
parent
c3357d38aa
commit
f7e64fcea0
153
main.py
153
main.py
|
|
@ -8,6 +8,7 @@ CG3: static-mappe serveres fra /static/*.
|
||||||
AUTH: /auth/login + /auth/callback for klient OAuth2 onboarding.
|
AUTH: /auth/login + /auth/callback for klient OAuth2 onboarding.
|
||||||
CG5a: POST /notify/webhook — push til Slack/Teams/Chat/Discord/etc.
|
CG5a: POST /notify/webhook — push til Slack/Teams/Chat/Discord/etc.
|
||||||
CG5b: POST /notify/email — SendGrid digest til kunde-e-post.
|
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.
|
CG5-onboard: GET /onboard + POST /onboard/complete — klient-onboarding wizard.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -49,6 +50,10 @@ import sendgrid
|
||||||
from sendgrid.helpers.mail import Mail
|
from sendgrid.helpers.mail import Mail
|
||||||
|
|
||||||
AGENT_ID = "osvauco-opax"
|
AGENT_ID = "osvauco-opax"
|
||||||
|
|
||||||
|
# Guard+-tiers som får tilgang til SMS-varsler
|
||||||
|
SMS_ALLOWED_TIERS = {"guard", "shield", "enterprise"}
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="OSVauco OPAX Agent",
|
title="OSVauco OPAX Agent",
|
||||||
description="Agent for OSVauco-OPAX platform.",
|
description="Agent for OSVauco-OPAX platform.",
|
||||||
|
|
@ -168,7 +173,7 @@ class BudgetUpdateRequest(BaseModel):
|
||||||
budget: float
|
budget: float
|
||||||
|
|
||||||
|
|
||||||
# ── NOTIFICATION MODELS (CG5a + CG5b) ────────────────────────────────────────
|
# ── NOTIFICATION MODELS (CG5a + CG5b + CG5c) ──────────────────────────────────
|
||||||
class WebhookNotifyRequest(BaseModel):
|
class WebhookNotifyRequest(BaseModel):
|
||||||
url: str = Field(..., description="Webhook-URL (Slack/Teams/Google Chat/Discord/custom)")
|
url: str = Field(..., description="Webhook-URL (Slack/Teams/Google Chat/Discord/custom)")
|
||||||
event: str = Field("custom", description="Event-type: spike | digest | budget | anomaly | 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.")
|
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")
|
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) ────────────────────────────────────────────
|
# ── ONBOARDING MODEL (CG5-onboard) ────────────────────────────────────────────
|
||||||
class OnboardCompleteRequest(BaseModel):
|
class OnboardCompleteRequest(BaseModel):
|
||||||
|
|
@ -196,13 +226,13 @@ class OnboardCompleteRequest(BaseModel):
|
||||||
email_to: Optional[str] = None
|
email_to: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ── NOTIFICATION SERVICE (CG5a + CG5b) ───────────────────────────────────────
|
# ── NOTIFICATION SERVICE (CG5a + CG5b + CG5c) ─────────────────────────────────
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
"""
|
"""
|
||||||
Felles delivery layer for CostGuard-varsler.
|
Felles delivery layer for CostGuard-varsler.
|
||||||
CG5a: send_webhook() — poster til hvilken som helst webhook-URL
|
CG5a: send_webhook() — poster til hvilken som helst webhook-URL
|
||||||
CG5b: send_email() — sender via SendGrid
|
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
|
# Event-type → emoji + standardtekst
|
||||||
|
|
@ -271,7 +301,6 @@ class NotificationService:
|
||||||
meta = self._event_meta(req.event)
|
meta = self._event_meta(req.event)
|
||||||
subject = req.subject or f"{meta['emoji']} CostGuard: {meta['label']} — {datetime.date.today()}"
|
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:
|
if req.body_html:
|
||||||
html_content = req.body_html
|
html_content = req.body_html
|
||||||
else:
|
else:
|
||||||
|
|
@ -293,6 +322,71 @@ class NotificationService:
|
||||||
print(f"[NotificationService] email error: {e}", file=sys.stderr)
|
print(f"[NotificationService] email error: {e}", file=sys.stderr)
|
||||||
return {"status": "error", "detail": str(e)}
|
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:
|
async def _build_digest_html(self, extra_payload: Optional[dict] = None) -> str:
|
||||||
"""Henter billing-data og bygger HTML-digest."""
|
"""Henter billing-data og bygger HTML-digest."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -343,7 +437,7 @@ class NotificationService:
|
||||||
_notifier = NotificationService()
|
_notifier = NotificationService()
|
||||||
|
|
||||||
|
|
||||||
# ── NOTIFY ENDPOINTS (CG5a + CG5b) ───────────────────────────────────────────
|
# ── NOTIFY ENDPOINTS (CG5a + CG5b + CG5c) ─────────────────────────────────────
|
||||||
|
|
||||||
async def _notify_webhook(request: Request, req: WebhookNotifyRequest):
|
async def _notify_webhook(request: Request, req: WebhookNotifyRequest):
|
||||||
result = await _notifier.send_webhook(req)
|
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"])
|
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) ───────────────────────────────────────
|
# ── ONBOARDING ENDPOINTS (CG5-onboard) ───────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/onboard")
|
@app.get("/onboard")
|
||||||
|
|
@ -378,10 +492,8 @@ def onboard_wizard():
|
||||||
async def onboard_complete(req: OnboardCompleteRequest):
|
async def onboard_complete(req: OnboardCompleteRequest):
|
||||||
"""
|
"""
|
||||||
CG5-onboard: Lagrer kundeprofil i Firestore og sender velkomstvarsler
|
CG5-onboard: Lagrer kundeprofil i Firestore og sender velkomstvarsler
|
||||||
via eksisterende POST /notify/webhook og POST /notify/email.
|
via eksisterende _notifier (send_webhook + send_email).
|
||||||
Ingen nye notification-endepunkter — gjenbruker _notifier singleton.
|
|
||||||
"""
|
"""
|
||||||
# 1. Lagre i Firestore (best-effort — feiler ikke onboarding ved DB-feil)
|
|
||||||
if db:
|
if db:
|
||||||
try:
|
try:
|
||||||
db.collection("onboarded_customers").document(req.gcp_project).set({
|
db.collection("onboarded_customers").document(req.gcp_project).set({
|
||||||
|
|
@ -401,9 +513,8 @@ async def onboard_complete(req: OnboardCompleteRequest):
|
||||||
|
|
||||||
notify_results: Dict[str, Any] = {}
|
notify_results: Dict[str, Any] = {}
|
||||||
|
|
||||||
# 2. Webhook-velkomstmelding (via eksisterende /notify/webhook-logikk)
|
|
||||||
if req.webhook_url and "webhook" in req.channels:
|
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,
|
url=req.webhook_url,
|
||||||
event="custom",
|
event="custom",
|
||||||
title=f"🎉 {req.company} er koblet til CostGuard!",
|
title=f"🎉 {req.company} er koblet til CostGuard!",
|
||||||
|
|
@ -418,9 +529,7 @@ async def onboard_complete(req: OnboardCompleteRequest):
|
||||||
"dato": datetime.date.today().isoformat(),
|
"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:
|
if req.email_to and "email" in req.channels:
|
||||||
TIER_LABELS = {
|
TIER_LABELS = {
|
||||||
"starter": "Starter ($499/mnd)",
|
"starter": "Starter ($499/mnd)",
|
||||||
|
|
@ -429,7 +538,7 @@ async def onboard_complete(req: OnboardCompleteRequest):
|
||||||
"enterprise": "Enterprise ($3.500+/mnd)",
|
"enterprise": "Enterprise ($3.500+/mnd)",
|
||||||
}
|
}
|
||||||
tier_label = TIER_LABELS.get(req.tier, req.tier)
|
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,
|
to=req.email_to,
|
||||||
event="custom",
|
event="custom",
|
||||||
subject=f"✅ Velkommen til CostGuard — {req.company}",
|
subject=f"✅ Velkommen til CostGuard — {req.company}",
|
||||||
|
|
@ -446,20 +555,16 @@ async def onboard_complete(req: OnboardCompleteRequest):
|
||||||
<table style='width:100%;border-collapse:collapse;margin:20px 0'>
|
<table style='width:100%;border-collapse:collapse;margin:20px 0'>
|
||||||
<tr style='background:#efefef'>
|
<tr style='background:#efefef'>
|
||||||
<td style='padding:8px 12px;font-weight:700'>Bedrift</td>
|
<td style='padding:8px 12px;font-weight:700'>Bedrift</td>
|
||||||
<td style='padding:8px 12px'>{req.company}</td>
|
<td style='padding:8px 12px'>{req.company}</td></tr>
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style='padding:8px 12px;font-weight:700'>GCP-prosjekt</td>
|
<td style='padding:8px 12px;font-weight:700'>GCP-prosjekt</td>
|
||||||
<td style='padding:8px 12px'><code>{req.gcp_project}</code></td>
|
<td style='padding:8px 12px'><code>{req.gcp_project}</code></td></tr>
|
||||||
</tr>
|
|
||||||
<tr style='background:#efefef'>
|
<tr style='background:#efefef'>
|
||||||
<td style='padding:8px 12px;font-weight:700'>Tier</td>
|
<td style='padding:8px 12px;font-weight:700'>Tier</td>
|
||||||
<td style='padding:8px 12px'>{tier_label}</td>
|
<td style='padding:8px 12px'>{tier_label}</td></tr>
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style='padding:8px 12px;font-weight:700'>Kanaler</td>
|
<td style='padding:8px 12px;font-weight:700'>Kanaler</td>
|
||||||
<td style='padding:8px 12px'>{', '.join(req.channels)}</td>
|
<td style='padding:8px 12px'>{', '.join(req.channels)}</td></tr>
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
<p>Du mottar din første kostnadsrapport innen 24 timer.</p>
|
<p>Du mottar din første kostnadsrapport innen 24 timer.</p>
|
||||||
<div style='text-align:center;margin:24px 0'>
|
<div style='text-align:center;margin:24px 0'>
|
||||||
|
|
@ -476,9 +581,7 @@ async def onboard_complete(req: OnboardCompleteRequest):
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
))
|
))
|
||||||
notify_results["email"] = em_result
|
|
||||||
|
|
||||||
# 4. Intern Slack-notis til Vauco (opsjonelt — krever VAUCO_INTERNAL_WEBHOOK)
|
|
||||||
internal_wh = os.environ.get("VAUCO_INTERNAL_WEBHOOK")
|
internal_wh = os.environ.get("VAUCO_INTERNAL_WEBHOOK")
|
||||||
if internal_wh:
|
if internal_wh:
|
||||||
try:
|
try:
|
||||||
|
|
@ -621,10 +724,6 @@ app.add_api_route("/billing/budget", endpoint=require_auth(set_budget), methods=
|
||||||
|
|
||||||
@app.post("/billing/email-report")
|
@app.post("/billing/email-report")
|
||||||
async def trigger_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"))
|
result = await _notifier.send_email(EmailNotifyRequest(event="digest"))
|
||||||
if result["status"] == "not_configured":
|
if result["status"] == "not_configured":
|
||||||
return JSONResponse(status_code=200, content={"status": "not_configured", "error": result["detail"]})
|
return JSONResponse(status_code=200, content={"status": "not_configured", "error": result["detail"]})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user