"""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 =` _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)