"""Outbound delivery to Zendesk and Jira. Three independent gates have to be open before anything leaves this process: 1. the integration is configured (subdomain/email/token present) 2. its feature flag is on - CX_FEATURE_ZENDESK / CX_FEATURE_JIRA 3. sending is globally enabled - CX_FEATURE_SEND_ENABLED A demo or staging instance simply leaves the third off, and then no combination of clicks can email a customer. Every send is recorded as a case event before it is attempted, so an audit trail exists even when the call fails. """ from __future__ import annotations import datetime as dt from typing import Any, Optional import httpx from sqlalchemy.orm import Session from .config import get_settings from .models import Case, CaseEvent, CaseStatus, User from .services import add_event, set_status settings = get_settings() class DeliveryError(RuntimeError): pass def _guard(kind: str, scope: str = "default") -> None: if not settings.feature_send_enabled: raise DeliveryError( "Sending is disabled on this instance (CX_FEATURE_SEND_ENABLED is off). " "The payload is ready but nothing will leave the server." ) if kind == "zendesk" and not settings.zendesk_ready: raise DeliveryError("Zendesk is not configured. Set CX_FEATURE_ZENDESK plus " "CX_ZENDESK_SUBDOMAIN, CX_ZENDESK_EMAIL and CX_ZENDESK_TOKEN.") if kind == "jira": creds = settings.jira_for(scope) if not (settings.feature_jira and creds["base"] and creds["email"] and creds["token"]): prefix = "CX_RUNPOD_JIRA_" if scope == "runpod" else "CX_JIRA_" raise DeliveryError( f"Jira ({scope}) is not configured. Set CX_FEATURE_JIRA plus {prefix}BASE, " f"{prefix}EMAIL and {prefix}TOKEN - or leave the {prefix}* values blank to reuse " "the default instance." ) def sends_today(db: Session) -> int: since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=1) return (db.query(CaseEvent) .filter(CaseEvent.action.in_(["zendesk_sent", "jira_created"])) .filter(CaseEvent.created_at >= since).count()) def _check_cap(db: Session) -> None: used = sends_today(db) if used >= settings.send_daily_cap: raise DeliveryError( f"Daily send cap reached ({used}/{settings.send_daily_cap}). Raise CX_SEND_DAILY_CAP " "if this is deliberate - the cap exists so a loop cannot mail every customer." ) # --- Zendesk ---------------------------------------------------------------- async def send_zendesk(db: Session, case: Case, actor: User, *, to: str, subject: str, body: str, priority: str = "normal", tags: Optional[list[str]] = None, public: Optional[bool] = None) -> dict[str, Any]: _guard("zendesk") _check_cap(db) if not to.strip(): raise DeliveryError("No recipient address.") base = f"https://{settings.zendesk_subdomain}.zendesk.com/api/v2" auth = (f"{settings.zendesk_email}/token", settings.zendesk_token) external_id = f"cx-triage-{case.fingerprint}" async with httpx.AsyncClient(timeout=30) as client: # Search first so a re-diagnosed alert comments on the existing ticket # instead of opening a second one for the same customer. found = await client.get(f"{base}/search.json", params={"query": f'type:ticket external_id:"{external_id}"'}, auth=auth) existing = None if found.status_code == 200: results = found.json().get("results") or [] existing = results[0] if results else None comment = {"body": body, "public": settings.zendesk_default_public if public is None else public} if existing: resp = await client.put(f"{base}/tickets/{existing['id']}.json", json={"ticket": {"comment": comment}}, auth=auth) action = "updated" else: payload = {"ticket": { "subject": subject, "comment": comment, "requester": {"name": to.split("@")[0], "email": to}, "priority": priority, "type": "incident", "tags": tags or ["cx-triage", f"alert-{case.kind}"], "external_id": external_id, }} resp = await client.post(f"{base}/tickets.json", json=payload, auth=auth) action = "created" if resp.status_code not in (200, 201): add_event(db, case, actor, "zendesk_failed", f"HTTP {resp.status_code}: {resp.text[:300]}") db.commit() raise DeliveryError(f"Zendesk returned {resp.status_code}: {resp.text[:300]}") ticket = resp.json().get("ticket") or {} ticket_id = str(ticket.get("id") or (existing or {}).get("id") or "") url = f"https://{settings.zendesk_subdomain}.zendesk.com/agent/tickets/{ticket_id}" case.zendesk_ticket_id = ticket_id case.zendesk_ticket_url = url add_event(db, case, actor, "zendesk_sent", f"Ticket {ticket_id} {action} for {to}", {"ticket_id": ticket_id, "to": to, "subject": subject, "action": action}) if case.status in (CaseStatus.NEW, CaseStatus.INVESTIGATING): set_status(db, case, CaseStatus.CUSTOMER_CONTACTED, actor, f"Zendesk ticket {ticket_id}") db.commit() return {"ok": True, "ticket_id": ticket_id, "url": url, "action": action} # --- Jira ------------------------------------------------------------------- async def create_jira(db: Session, case: Case, actor: User, *, summary: str, description: str, project: str = "", issue_type: str = "", labels: Optional[list[str]] = None, scope: str = "default") -> dict[str, Any]: """Raise a Jira issue on the instance that owns this kind of work. `scope="runpod"` targets the RunPod/RMA project, which may live on an entirely different Atlassian site - hence separate credentials rather than just a different project key. """ _guard("jira", scope) _check_cap(db) creds = settings.jira_for(scope) base = creds["base"].rstrip("/") auth = (creds["email"], creds["token"]) label = f"cx-triage-{case.fingerprint}" all_labels = sorted(set((labels or []) + ["cx-triage", label])) async with httpx.AsyncClient(timeout=30) as client: found = await client.get(f"{base}/rest/api/3/search", params={"jql": f'labels = "{label}"', "maxResults": 1}, auth=auth) if found.status_code == 200 and (found.json().get("issues") or []): issue = found.json()["issues"][0] key = issue["key"] url = f"{base}/browse/{key}" case.jira_issue_key, case.jira_issue_url = key, url add_event(db, case, actor, "jira_exists", f"Issue {key} already exists for this case") db.commit() return {"ok": True, "key": key, "url": url, "action": "existing"} payload = {"fields": { "project": {"key": project or creds["project"]}, "summary": summary[:250], "issuetype": {"name": issue_type or creds["issue_type"]}, "labels": all_labels, "description": { "type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": description[:30000]}]}], }, }} resp = await client.post(f"{base}/rest/api/3/issue", json=payload, auth=auth) if resp.status_code not in (200, 201): add_event(db, case, actor, "jira_failed", f"HTTP {resp.status_code}: {resp.text[:300]}") db.commit() raise DeliveryError(f"Jira returned {resp.status_code}: {resp.text[:300]}") key = resp.json().get("key", "") url = f"{base}/browse/{key}" case.jira_issue_key, case.jira_issue_url = key, url add_event(db, case, actor, "jira_created", f"Issue {key} created", {"key": key, "summary": summary}) if case.status in (CaseStatus.NEW, CaseStatus.INVESTIGATING): set_status(db, case, CaseStatus.ESCALATED_INFRA, actor, f"Jira {key}") db.commit() return {"ok": True, "key": key, "url": url, "action": "created"}