"""CRM API client for the intake bot's write-back leg. The bot authenticates as a dedicated service user (Bearer JWT via /api/auth/login — the CRM has no service-key path) and reuses the CRM's OWN canonical write endpoint (/api/fundraising/log-communication) for both new-investor and existing-note cases, rather than mutating the grid itself. That endpoint creates the grid row (create_investor_if_missing), upserts the contact, logs the communication, appends a visible note, and re-syncs the relational tables + audit — exactly as a UI grid edit would. We only tag provenance (source="matrix_intake"). The payload builder is a pure function so it's unit-tested offline. """ import json import ssl import urllib.error import urllib.request from urllib.parse import urlencode import settings _token = None def _http(method, path, body=None, token=None): s = settings.crm_settings() url = s["base"] + path data = json.dumps(body).encode("utf-8") if body is not None else None headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" req = urllib.request.Request(url, data=data, method=method, headers=headers) ctx = None if url.lower().startswith("https") and not s["verify_tls"]: ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE try: with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: raw = resp.read() return resp.status, (json.loads(raw) if raw else {}) except urllib.error.HTTPError as exc: raw = exc.read() try: payload = json.loads(raw) if raw else {} except Exception: payload = {"raw": raw.decode("utf-8", "replace")} return exc.code, payload def _login(): global _token s = settings.crm_settings() if not s["username"] or not s["password"]: raise RuntimeError("CRM bot creds not set (CRM_BOT_USERNAME / CRM_BOT_PASSWORD)") status, data = _http("POST", "/api/auth/login", {"username": s["username"], "password": s["password"]}) if status != 200 or not data.get("token"): raise RuntimeError(f"CRM login failed ({status}): {data.get('error') or data}") _token = data["token"] return _token def _authed(method, path, body=None): """Call the CRM with the cached token; re-login once on a 401 (token expiry).""" global _token token = _token or _login() status, data = _http(method, path, body, token=token) if status == 401: token = _login() status, data = _http(method, path, body, token=token) return status, data def match(proposal): """Return {'id', 'name'} for an existing investor matching this proposal, else None.""" q = proposal.get("investor_name") or proposal.get("contact_name") or "" email = proposal.get("contact_email") or "" if not q and not email: return None qs = urlencode({"q": q, "email": email}) status, data = _authed("GET", f"/api/intake/match?{qs}") if status != 200: raise RuntimeError(f"intake match failed ({status}): {data.get('error') or data}") m = (data.get("data") or {}).get("match") if not m: return None return {"id": m["id"], "name": m.get("investor_name") or q} def build_commit_payload(proposal): """Pure: map a proposal to the /api/fundraising/log-communication request body. Existing investor (carries _match_id) → target that exact grid row. Otherwise create the investor if missing. The note becomes the communication body; the email is only sent when it survived parse's source-text integrity check.""" contact = { "name": proposal.get("contact_name") or proposal.get("investor_name") or "", "email": proposal.get("contact_email") or "", "title": proposal.get("contact_title") or "", } note = proposal.get("note") or "" payload = { "contact": contact, "type": "note", "body": note, "subject": "Intake (Matrix)" if proposal.get("intent") != "meeting_note" else "Note (Matrix)", "append_note": True, "source": "matrix_intake", } match_id = proposal.get("_match_id") if match_id: payload["row_id"] = match_id else: payload["investor_name"] = proposal.get("investor_name") or proposal.get("contact_name") or "" payload["create_investor_if_missing"] = True return payload def commit(proposal): """Write the approved proposal to the CRM; return a short human summary for the thread.""" payload = build_commit_payload(proposal) status, data = _authed("POST", "/api/fundraising/log-communication", payload) if status not in (200, 201): raise RuntimeError(f"log-communication failed ({status}): {data.get('error') or data}") row = (data.get("data") or {}).get("row") or {} name = row.get("investor_name") or payload.get("investor_name") or "investor" if proposal.get("_match_id"): return f"Logged note to **{name}**." return f"Added **{name}** to the grid" + (" with a note." if payload.get("body") else ".")