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,103 @@
|
||||
"""Pending-proposal store + the in-thread approval state machine.
|
||||
|
||||
The one piece of state in the bot: a proposal awaiting a human's yes/edit/no, keyed by the
|
||||
Matrix thread root (the bot's proposal lives in a thread rooted at the user's message, and
|
||||
the user replies inside that thread). In-memory and ephemeral by design — a restart drops
|
||||
pending proposals (the user just re-sends), matching matrix-bridge's stateless-by-default
|
||||
ethos. Nothing here writes to the CRM; the bot calls the CRM client only after `approve`.
|
||||
"""
|
||||
|
||||
# field aliases accepted in `edit <field>=<value>`
|
||||
_EDIT_ALIASES = {
|
||||
"name": "investor_name", "investor": "investor_name", "firm": "investor_name", "org": "investor_name",
|
||||
"contact": "contact_name", "person": "contact_name",
|
||||
"email": "contact_email",
|
||||
"title": "contact_title", "role": "contact_title",
|
||||
"note": "note",
|
||||
}
|
||||
|
||||
_YES = {"yes", "y", "approve", "approved", "ok", "confirm", "go", "👍", "✅"}
|
||||
_NO = {"no", "n", "cancel", "discard", "reject", "stop", "👎", "❌"}
|
||||
|
||||
|
||||
class ProposalStore:
|
||||
def __init__(self):
|
||||
self._pending = {} # thread_root -> proposal dict
|
||||
|
||||
def put(self, thread_root, proposal):
|
||||
self._pending[thread_root] = proposal
|
||||
|
||||
def get(self, thread_root):
|
||||
return self._pending.get(thread_root)
|
||||
|
||||
def pop(self, thread_root):
|
||||
return self._pending.pop(thread_root, None)
|
||||
|
||||
def has(self, thread_root):
|
||||
return thread_root in self._pending
|
||||
|
||||
|
||||
def _parse_edit(text):
|
||||
"""Parse 'edit field=value' (also 'field: value'); return (canonical_field, value) or None."""
|
||||
body = text.strip()
|
||||
if body.lower().startswith("edit "):
|
||||
body = body[5:].strip()
|
||||
for sep in ("=", ":"):
|
||||
if sep in body:
|
||||
field, value = body.split(sep, 1)
|
||||
field = field.strip().lower()
|
||||
canon = _EDIT_ALIASES.get(field)
|
||||
value = value.strip()
|
||||
if canon and value:
|
||||
return canon, value
|
||||
# Not a known field on this separator — try the next one rather than bail,
|
||||
# so e.g. "note: see deck=v2" still parses (split on ':' not the inner '=').
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def interpret_reply(text):
|
||||
"""Classify a threaded reply to a pending proposal.
|
||||
|
||||
Returns one of:
|
||||
("approve", None) | ("reject", None) | ("edit", (field, value)) | ("unknown", None)
|
||||
"""
|
||||
t = (text or "").strip()
|
||||
low = t.lower()
|
||||
if low in _YES:
|
||||
return ("approve", None)
|
||||
if low in _NO:
|
||||
return ("reject", None)
|
||||
edit = _parse_edit(t)
|
||||
if edit:
|
||||
return ("edit", edit)
|
||||
return ("unknown", None)
|
||||
|
||||
|
||||
def apply_edit(proposal, field, value):
|
||||
"""Return a copy of the proposal with one field changed."""
|
||||
updated = dict(proposal)
|
||||
updated[field] = value
|
||||
return updated
|
||||
|
||||
|
||||
def render(proposal):
|
||||
"""Render a proposal as the in-thread message a human approves."""
|
||||
if proposal.get("intent") == "meeting_note":
|
||||
head = f"📝 Proposed **meeting note** for **{proposal.get('investor_name') or proposal.get('contact_name') or '?'}**"
|
||||
else:
|
||||
head = f"📇 Proposed **new investor**: **{proposal.get('investor_name') or proposal.get('contact_name') or '?'}**"
|
||||
lines = [head]
|
||||
fields = [
|
||||
("Investor", proposal.get("investor_name")),
|
||||
("Contact", proposal.get("contact_name")),
|
||||
("Email", proposal.get("contact_email")),
|
||||
("Title", proposal.get("contact_title")),
|
||||
("Note", proposal.get("note")),
|
||||
]
|
||||
for label, val in fields:
|
||||
if val:
|
||||
lines.append(f"· {label}: {val}")
|
||||
lines.append("")
|
||||
lines.append("Reply **yes** to commit, **edit field=value** to change a field, or **no** to discard.")
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user