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:
+62
-1
@@ -1215,6 +1215,45 @@ def sync_fundraising_relational(conn, grid, views, actor_user_id=None):
|
||||
))
|
||||
run_fundraising_automations(conn)
|
||||
|
||||
def find_intake_match(conn, q, email=None):
|
||||
"""Find an existing fundraising-grid investor for the intake bot's new-vs-existing hint.
|
||||
|
||||
Scans the canonical grid blob (not the derived tables) so the returned `id` is the grid
|
||||
row id that handle_log_fundraising_communication matches on — keeping the bot's proposal
|
||||
consistent with where the write actually lands (no duplicate-investor risk). Matches by
|
||||
normalized investor_name first (the write's own key), then falls back to a contact email.
|
||||
Deleted investors are absent from the blob; graveyarded ones remain (a note on them is
|
||||
still valid), so no extra filtering is needed."""
|
||||
row = conn.execute("SELECT grid_json FROM fundraising_state WHERE id = 'main'").fetchone()
|
||||
if not row or not row['grid_json']:
|
||||
return None
|
||||
try:
|
||||
grid = json.loads(row['grid_json'])
|
||||
except Exception:
|
||||
return None
|
||||
rows = grid.get('rows', []) if isinstance(grid, dict) else []
|
||||
wanted_name = _normalize_text(q) if q else ''
|
||||
wanted_email = (email or '').strip().lower()
|
||||
email_hit = None
|
||||
for r in rows:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
rid = str(r.get('id') or '').strip()
|
||||
if not rid:
|
||||
continue
|
||||
name = str(r.get('investor_name') or '').strip()
|
||||
if wanted_name and _normalize_text(name) == wanted_name:
|
||||
return {"id": rid, "investor_name": name, "matched_on": "name"}
|
||||
if wanted_email and email_hit is None:
|
||||
contacts = r.get('contacts')
|
||||
if isinstance(contacts, list):
|
||||
for c in contacts:
|
||||
if isinstance(c, dict) and str(c.get('email') or '').strip().lower() == wanted_email:
|
||||
email_hit = {"id": rid, "investor_name": name, "matched_on": "email"}
|
||||
break
|
||||
return email_hit
|
||||
|
||||
|
||||
def ensure_fundraising_state_row(conn):
|
||||
existing = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone()
|
||||
if not existing:
|
||||
@@ -1829,6 +1868,10 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
if path == '/api/outreach/radar':
|
||||
return self.handle_outreach_radar(user)
|
||||
|
||||
# Matrix intake bot — new-vs-existing lookup for its in-thread proposal
|
||||
if path == '/api/intake/match':
|
||||
return self.handle_intake_match(user, params)
|
||||
|
||||
# Users
|
||||
if path == '/api/users':
|
||||
return self.handle_list_users(user)
|
||||
@@ -2747,6 +2790,9 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
contact_in = body.get('contact')
|
||||
append_note = bool(body.get('append_note', True))
|
||||
create_investor_if_missing = bool(body.get('create_investor_if_missing', False))
|
||||
# Provenance: where this logged communication originated (grid UI vs the Matrix
|
||||
# intake bot). Default preserves prior behavior; callers may override.
|
||||
comm_source = (str(body.get('source') or 'fundraising_grid').strip() or 'fundraising_grid')[:64]
|
||||
|
||||
if not row_id and not investor_name_in:
|
||||
return self.send_error_json("row_id or investor_name is required")
|
||||
@@ -2863,7 +2909,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
user['user_id']
|
||||
))
|
||||
conn.execute("UPDATE contacts SET updated_at = ? WHERE id = ?", (now(), contact_id))
|
||||
log_audit(conn, user['user_id'], 'communication', comm_id, 'create', {"source": "fundraising_grid"})
|
||||
log_audit(conn, user['user_id'], 'communication', comm_id, 'create', {"source": comm_source})
|
||||
|
||||
iso_day = now()[:10]
|
||||
target_row['last_communication_date'] = iso_day
|
||||
@@ -2900,6 +2946,21 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
return self.send_json({"data": {"communication": comm, "row": target_row, "version": next_version}}, 201)
|
||||
|
||||
def handle_intake_match(self, user, params):
|
||||
"""Read-only: does an investor matching this intake already exist? Used by the
|
||||
Matrix intake bot to label its in-thread proposal new-vs-existing. Returns the
|
||||
grid row id so an approved note lands on exactly that investor."""
|
||||
q = str(params.get('q') or '').strip()
|
||||
email = str(params.get('email') or '').strip()
|
||||
if not q and not email:
|
||||
return self.send_error_json("q or email is required")
|
||||
conn = get_db()
|
||||
try:
|
||||
match = find_intake_match(conn, q, email)
|
||||
finally:
|
||||
conn.close()
|
||||
return self.send_json({"data": {"match": match}})
|
||||
|
||||
def handle_update_communication(self, user, comm_id, body):
|
||||
conn = get_db()
|
||||
existing = conn.execute("SELECT id FROM communications WHERE id = ?", (comm_id,)).fetchone()
|
||||
|
||||
Reference in New Issue
Block a user