Files
Keysat 5faa5ae4d6 Email-proposal review over Matrix + a bot role (v0.1.0:89)
The email-capture "proposed grid notes" gain two review surfaces:

1. Inline source email — each proposed-note card on the Email Capture page
   gets a "View email" toggle that lazily fetches the existing
   GET /api/email/detail and shows from/to/cc/date/subject + scrollable body,
   so a reviewer can judge the note against the email it was drafted from.

2. CRM->Matrix review bridge — the CRM (box, stdlib, no matrix-nio) can't post
   to Matrix, so the intake bot (Spark) PULLS: GET /api/intake/email-proposals
   returns to_post/open/to_close work-lists; the bot posts a review card
   (metadata + snippet + draft note) to a dedicated review room
   (MATRIX_EMAIL_REVIEW_ROOM) and relays in-thread yes / no / NL-edit
   (POST .../{id}/decide, note revised via local Qwen). Decisions sync both
   ways: web decide -> bot announces + closes the thread; Matrix decide -> the
   web panel's ~25s poll clears the card. State lives CRM-side in the new
   email_proposal_matrix side row (email-integration migration 0003, additive
   + idempotent CREATE TABLE IF NOT EXISTS), so it survives a bot restart.

Adds a 'bot' role (authenticated, never admin; require_bot_or_admin) to gate
the email-proposal endpoints rather than handing the bot full admin — the
principled base for the coming agentic capabilities. Role controls reach;
the draft->approve gate still controls autonomy (a human approves every write).

Deploy split: endpoints + migration + role + frontend ship in the s9pk; the
bot poll loop + review-room handling ship on the Spark. The bot's CRM user
must be flipped member->bot and joined to the review room (one-time).

Tests: backend/test_email_proposal_matrix.py + matrix_intake/test_email_proposals.py
(30/30 suite green, render-smoke green, migration verified twice on a DB copy).
2026-06-18 09:51:41 -05:00

71 lines
2.9 KiB
Python

"""Config for the Matrix intake bot — Matrix creds + the dedicated intake room.
Spark settings (SPARK_CONTROL_URL, CHAT_MODEL, …) are NOT read here; they come from the
reused ingest client (see spark.py), which loads the same repo .env. This module only owns
the Matrix connection and the CRM API target for the write-back leg (M2).
"""
import os
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def load_env(path=None):
"""Populate os.environ from the repo .env (setdefault — never clobber a real env var)."""
path = path or os.path.join(REPO_ROOT, ".env")
if not os.path.exists(path):
return
with open(path, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
load_env()
def _require(name):
val = os.environ.get(name, "").strip()
if not val:
raise RuntimeError(f"matrix_intake: required env var {name} is not set (see .env.example)")
return val
# Matrix connection (resolved lazily so importing this module for tests never requires creds).
def matrix_settings():
return {
"homeserver": _require("MATRIX_HOMESERVER"),
"user_id": _require("MATRIX_USER"),
"token": _require("MATRIX_ACCESS_TOKEN"),
"device_id": os.environ.get("MATRIX_DEVICE_ID", "ten31-intake-bot"),
"intake_room": _require("MATRIX_INTAKE_ROOM"),
}
# CRM API target for the write-back leg (M2). The CRM has no service-key auth path — auth is
# Bearer-JWT via /api/auth/login — so the bot logs in as a DEDICATED service user (a normal
# CRM user, created by an admin) and reuses the existing auth. Creds live in .env, never code.
def crm_settings():
return {
"base": os.environ.get("CRM_API_BASE", "http://127.0.0.1:8080").rstrip("/"),
"username": os.environ.get("CRM_BOT_USERNAME", "").strip(),
"password": os.environ.get("CRM_BOT_PASSWORD", ""),
"verify_tls": os.environ.get("CRM_API_VERIFY_TLS", "true").lower() in ("1", "true", "yes", "on"),
}
# Team-member names (comma-separated in INTAKE_TEAM_ROSTER), fed to the parser so a teammate's
# name reads as the person DOING outreach, not the investor (see parse.build_system). Optional —
# unset/empty just means no roster framing, i.e. the prior behavior.
def team_roster():
return [n.strip() for n in os.environ.get("INTAKE_TEAM_ROSTER", "").split(",") if n.strip()]
# Dedicated room for reviewing CRM-drafted email-activity proposals (the CRM→Matrix push leg).
# Separate from the intake room so high-volume email proposals don't drown the conversational
# intake flow. Unset/empty disables the whole email-review poll loop (the bot just does intake).
def email_review_room():
return os.environ.get("MATRIX_EMAIL_REVIEW_ROOM", "").strip()