aefb2aa119
Four bot-side UX fixes surfaced by the live smoke: - Post a brief pointer in the main timeline (a reply to the user's message) alongside the in-thread proposal card, so proposals aren't missed inside a thread. Pointer only — approvals still happen in the thread, where the note is visible (you can't make an informed yes/no without seeing it). - A bare yes/no typed in the main timeline while a proposal is pending now gets a "reply in the thread" redirect instead of "couldn't tell what to record." - Clearer commit confirmations: "Created a new grid entry for X" vs "Logged a note on X (existing grid entry)." - Send a blank communication subject when a note is present so the grid's one-line note summary shows the note text, not the "(Matrix)" label (provenance stays in source="matrix_intake").
115 lines
4.2 KiB
Python
115 lines
4.2 KiB
Python
"""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 any_pending(self):
|
|
return bool(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)
|
|
|
|
|
|
def summary_line(proposal):
|
|
"""A brief one-liner for the main-timeline nudge; the full card lives in the thread."""
|
|
name = proposal.get("investor_name") or proposal.get("contact_name") or "?"
|
|
if proposal.get("intent") == "meeting_note":
|
|
return f"📝 Proposed a meeting note for **{name}** — see the thread to review & approve."
|
|
return f"📇 Proposed a new investor: **{name}** — see the thread to review & approve."
|