diff --git a/backend/matrix_intake/bot.py b/backend/matrix_intake/bot.py index ed81c95..8122b5f 100644 --- a/backend/matrix_intake/bot.py +++ b/backend/matrix_intake/bot.py @@ -31,10 +31,18 @@ async def main(): client = AsyncClient(mx["homeserver"], mx["user_id"]) client.restore_login(user_id=mx["user_id"], device_id=mx["device_id"], access_token=mx["token"]) say = matrix_io.make_say(client) + nudge = matrix_io.make_reply(client) store = proposals.ProposalStore() intake_room = mx["intake_room"] async def handle_intake(room_id, root, text): + # A bare yes/no/approve typed in the MAIN timeline (not inside a proposal's thread) is + # an easy slip — point the user back to the thread rather than parse it as a new intake. + action, _ = proposals.interpret_reply(text) + if action in ("approve", "reject") and store.any_pending(): + await nudge(room_id, "👉 To approve, reject, or edit a proposal, open its **thread** " + "and reply there — the note is in the thread.", root) + return try: proposal = await asyncio.to_thread(parse.parse_message, text) except Exception as exc: # Spark/Qwen unreachable or bad response @@ -56,6 +64,9 @@ async def main(): pass store.put(root, proposal) await say(room_id, proposals.render(proposal) + hint, root) + # Also drop a brief, un-threaded reply in the main timeline so the proposal isn't + # easy to miss inside a thread (the full card + yes/edit/no stay in the thread). + await nudge(room_id, proposals.summary_line(proposal), root) async def handle_reply(room_id, root, text): action, payload = proposals.interpret_reply(text) diff --git a/backend/matrix_intake/crm_client.py b/backend/matrix_intake/crm_client.py index 58db465..3f5f1ce 100644 --- a/backend/matrix_intake/crm_client.py +++ b/backend/matrix_intake/crm_client.py @@ -97,11 +97,16 @@ def build_commit_payload(proposal): "title": proposal.get("contact_title") or "", } note = proposal.get("note") or "" + # The CRM's grid note line uses subject-or-body for its one-line summary, so a non-empty + # subject hides the actual note text. Send a blank subject when there's a note (let the note + # itself show in the grid); fall back to a provenance label only when there's nothing to + # show. Provenance is recorded via source="matrix_intake" either way. + intent_label = "Note (Matrix)" if proposal.get("intent") == "meeting_note" else "Intake (Matrix)" payload = { "contact": contact, "type": "note", "body": note, - "subject": "Intake (Matrix)" if proposal.get("intent") != "meeting_note" else "Note (Matrix)", + "subject": "" if note.strip() else intent_label, "append_note": True, "source": "matrix_intake", } @@ -123,5 +128,5 @@ def commit(proposal): row = (data.get("data") or {}).get("row") or {} name = row.get("investor_name") or payload.get("investor_name") or "investor" if proposal.get("_match_id"): - return f"Logged note to **{name}**." - return f"Added **{name}** to the grid" + (" with a note." if payload.get("body") else ".") + return f"Logged a note on **{name}** (existing grid entry)." + return f"Created a new grid entry for **{name}**" + (" and logged a note." if payload.get("body") else ".") diff --git a/backend/matrix_intake/matrix_io.py b/backend/matrix_intake/matrix_io.py index 3b3dbe9..f7cc4f5 100644 --- a/backend/matrix_intake/matrix_io.py +++ b/backend/matrix_intake/matrix_io.py @@ -53,3 +53,21 @@ def make_say(client): for chunk in split_message(text): await client.room_send(room_id, "m.room.message", thread_content(chunk, thread_root)) return say + + +def reply_content(text, reply_to_event_id): + """Build a plain (non-threaded) reply: shows in the MAIN timeline as a reply to + reply_to_event_id, unlike thread_content() which lands the message inside a thread.""" + content = {"msgtype": "m.text", "body": text} + if reply_to_event_id: + content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}} + return content + + +def make_reply(client): + """Return an async reply(room_id, text, reply_to) that posts a plain main-timeline reply — + the brief 'proposed X — see thread' nudge alongside the in-thread proposal card.""" + async def reply(room_id, text, reply_to): + for chunk in split_message(text): + await client.room_send(room_id, "m.room.message", reply_content(chunk, reply_to)) + return reply diff --git a/backend/matrix_intake/proposals.py b/backend/matrix_intake/proposals.py index e5855c9..45ba094 100644 --- a/backend/matrix_intake/proposals.py +++ b/backend/matrix_intake/proposals.py @@ -36,6 +36,9 @@ class ProposalStore: 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.""" @@ -101,3 +104,11 @@ def render(proposal): 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." diff --git a/backend/matrix_intake/test_crm_client.py b/backend/matrix_intake/test_crm_client.py index b97f9c6..d5a73fb 100644 --- a/backend/matrix_intake/test_crm_client.py +++ b/backend/matrix_intake/test_crm_client.py @@ -46,6 +46,18 @@ def test_no_email_sends_empty_string_not_none(): assert out["contact"]["email"] == "" +def test_subject_blank_when_note_present_else_provenance_label(): + # The CRM's grid note line uses subject-or-body, so a blank subject lets the note text show. + with_note = crm_client.build_commit_payload( + {"intent": "meeting_note", "investor_name": "Acme", "note": "sent the deck", "_match_id": "r1"}) + assert with_note["subject"] == "" + assert with_note["body"] == "sent the deck" + # no note text → fall back to a provenance label so the grid line isn't empty + no_note = crm_client.build_commit_payload( + {"intent": "new_investor", "investor_name": "Beta", "contact_name": "X", "note": None}) + assert no_note["subject"] == "Intake (Matrix)" + + if __name__ == "__main__": fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] for fn in fns: diff --git a/backend/matrix_intake/test_matrix_io.py b/backend/matrix_intake/test_matrix_io.py new file mode 100644 index 0000000..19d20c6 --- /dev/null +++ b/backend/matrix_intake/test_matrix_io.py @@ -0,0 +1,37 @@ +"""Tests for matrix_io content builders — pure dict shaping, no network.""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import matrix_io # noqa: E402 + + +def test_reply_content_is_plain_main_timeline_reply(): + c = matrix_io.reply_content("hi", "$evt1") + rel = c["m.relates_to"] + assert rel["m.in_reply_to"]["event_id"] == "$evt1" + # a plain reply must NOT carry a thread relation, or it'd land in the thread + # instead of the main timeline (the whole point of the nudge). + assert "rel_type" not in rel + + +def test_reply_content_without_target_has_no_relation(): + c = matrix_io.reply_content("hi", None) + assert "m.relates_to" not in c + assert c["body"] == "hi" + + +def test_thread_content_stays_threaded(): + c = matrix_io.thread_content("hi", "$root1") + rel = c["m.relates_to"] + assert rel["rel_type"] == "m.thread" + assert rel["event_id"] == "$root1" + + +if __name__ == "__main__": + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + for fn in fns: + fn() + print(f"ok {fn.__name__}") + print(f"\n{len(fns)} passed") diff --git a/backend/matrix_intake/test_proposals.py b/backend/matrix_intake/test_proposals.py index 0d35743..c3a4cf7 100644 --- a/backend/matrix_intake/test_proposals.py +++ b/backend/matrix_intake/test_proposals.py @@ -22,6 +22,15 @@ def test_store_put_get_pop(): assert s.pop("$missing") is None +def test_store_any_pending(): + s = proposals.ProposalStore() + assert not s.any_pending() + s.put("$r", SAMPLE) + assert s.any_pending() + s.pop("$r") + assert not s.any_pending() + + def test_interpret_yes_variants(): for t in ("yes", "Y", "approve", " ok ", "👍"): assert proposals.interpret_reply(t)[0] == "approve", t @@ -87,6 +96,15 @@ def test_render_meeting_note_variant(): assert "meeting note" in proposals.render(note).lower() +def test_summary_line_new_vs_note(): + new_line = proposals.summary_line(SAMPLE) + assert "Acme Capital" in new_line and "new investor" in new_line.lower() + note_line = proposals.summary_line(dict(SAMPLE, intent="meeting_note")) + assert "Acme Capital" in note_line and "meeting note" in note_line.lower() + # the nudge must point the user to the thread, where the actual action lives + assert "thread" in new_line.lower() + + if __name__ == "__main__": fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] for fn in fns: