Add Matrix intake bot (M1+M2): typed message → approved fundraising-grid write
New backend/matrix_intake/ runs as its own process (matrix-nio isolated from the stdlib CRM): local-Qwen parse via Spark Control → in-thread human approval (yes/edit/no) → write through the CRM's own log-communication endpoint, tagged source=matrix_intake. Adds read-only GET /api/intake/match (returns grid row id, no-duplicate contract); threads provenance through handle_log_fundraising_communication. Reviewer-passed: pop-before-commit closes a double-approve race; edit-grammar fix. Text-only v1; business-card photo (M3) deferred (no Spark vision model). 26/26 tests green; live Matrix smoke pending deploy.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""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 ".")
|
||||
Reference in New Issue
Block a user