Matrix intake: main-timeline nudge, clearer messages, note text in grid
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").
This commit is contained in:
@@ -31,10 +31,18 @@ async def main():
|
|||||||
client = AsyncClient(mx["homeserver"], mx["user_id"])
|
client = AsyncClient(mx["homeserver"], mx["user_id"])
|
||||||
client.restore_login(user_id=mx["user_id"], device_id=mx["device_id"], access_token=mx["token"])
|
client.restore_login(user_id=mx["user_id"], device_id=mx["device_id"], access_token=mx["token"])
|
||||||
say = matrix_io.make_say(client)
|
say = matrix_io.make_say(client)
|
||||||
|
nudge = matrix_io.make_reply(client)
|
||||||
store = proposals.ProposalStore()
|
store = proposals.ProposalStore()
|
||||||
intake_room = mx["intake_room"]
|
intake_room = mx["intake_room"]
|
||||||
|
|
||||||
async def handle_intake(room_id, root, text):
|
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:
|
try:
|
||||||
proposal = await asyncio.to_thread(parse.parse_message, text)
|
proposal = await asyncio.to_thread(parse.parse_message, text)
|
||||||
except Exception as exc: # Spark/Qwen unreachable or bad response
|
except Exception as exc: # Spark/Qwen unreachable or bad response
|
||||||
@@ -56,6 +64,9 @@ async def main():
|
|||||||
pass
|
pass
|
||||||
store.put(root, proposal)
|
store.put(root, proposal)
|
||||||
await say(room_id, proposals.render(proposal) + hint, root)
|
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):
|
async def handle_reply(room_id, root, text):
|
||||||
action, payload = proposals.interpret_reply(text)
|
action, payload = proposals.interpret_reply(text)
|
||||||
|
|||||||
@@ -97,11 +97,16 @@ def build_commit_payload(proposal):
|
|||||||
"title": proposal.get("contact_title") or "",
|
"title": proposal.get("contact_title") or "",
|
||||||
}
|
}
|
||||||
note = proposal.get("note") 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 = {
|
payload = {
|
||||||
"contact": contact,
|
"contact": contact,
|
||||||
"type": "note",
|
"type": "note",
|
||||||
"body": 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,
|
"append_note": True,
|
||||||
"source": "matrix_intake",
|
"source": "matrix_intake",
|
||||||
}
|
}
|
||||||
@@ -123,5 +128,5 @@ def commit(proposal):
|
|||||||
row = (data.get("data") or {}).get("row") or {}
|
row = (data.get("data") or {}).get("row") or {}
|
||||||
name = row.get("investor_name") or payload.get("investor_name") or "investor"
|
name = row.get("investor_name") or payload.get("investor_name") or "investor"
|
||||||
if proposal.get("_match_id"):
|
if proposal.get("_match_id"):
|
||||||
return f"Logged note to **{name}**."
|
return f"Logged a note on **{name}** (existing grid entry)."
|
||||||
return f"Added **{name}** to the grid" + (" with a note." if payload.get("body") else ".")
|
return f"Created a new grid entry for **{name}**" + (" and logged a note." if payload.get("body") else ".")
|
||||||
|
|||||||
@@ -53,3 +53,21 @@ def make_say(client):
|
|||||||
for chunk in split_message(text):
|
for chunk in split_message(text):
|
||||||
await client.room_send(room_id, "m.room.message", thread_content(chunk, thread_root))
|
await client.room_send(room_id, "m.room.message", thread_content(chunk, thread_root))
|
||||||
return say
|
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
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class ProposalStore:
|
|||||||
def has(self, thread_root):
|
def has(self, thread_root):
|
||||||
return thread_root in self._pending
|
return thread_root in self._pending
|
||||||
|
|
||||||
|
def any_pending(self):
|
||||||
|
return bool(self._pending)
|
||||||
|
|
||||||
|
|
||||||
def _parse_edit(text):
|
def _parse_edit(text):
|
||||||
"""Parse 'edit field=value' (also 'field: value'); return (canonical_field, value) or None."""
|
"""Parse 'edit field=value' (also 'field: value'); return (canonical_field, value) or None."""
|
||||||
@@ -101,3 +104,11 @@ def render(proposal):
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Reply **yes** to commit, **edit field=value** to change a field, or **no** to discard.")
|
lines.append("Reply **yes** to commit, **edit field=value** to change a field, or **no** to discard.")
|
||||||
return "\n".join(lines)
|
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."
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ def test_no_email_sends_empty_string_not_none():
|
|||||||
assert out["contact"]["email"] == ""
|
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__":
|
if __name__ == "__main__":
|
||||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||||
for fn in fns:
|
for fn in fns:
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -22,6 +22,15 @@ def test_store_put_get_pop():
|
|||||||
assert s.pop("$missing") is None
|
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():
|
def test_interpret_yes_variants():
|
||||||
for t in ("yes", "Y", "approve", " ok ", "👍"):
|
for t in ("yes", "Y", "approve", " ok ", "👍"):
|
||||||
assert proposals.interpret_reply(t)[0] == "approve", t
|
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()
|
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__":
|
if __name__ == "__main__":
|
||||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||||
for fn in fns:
|
for fn in fns:
|
||||||
|
|||||||
Reference in New Issue
Block a user