Redact whole review threads on decision (replies too)

The bot was granted a redact/mod power level in the review room, so it can now
clear a resolved thread entirely, not just the card: redact_thread redacts the
card root then scans recent history for its m.thread replies (the human's yes/no
+ any bot messages) and redacts those too, so decided threads drop out of the
threads view, not only the main timeline. Drops the in-thread confirmation on a
successful decision (the thread clearing is the ack; a confirmation would keep
the thread alive). redact_resolved.py extended to also clear replies of already-
resolved threads for the one-time backfill. Bot-only; no s9pk change.
This commit is contained in:
Keysat
2026-06-18 12:32:06 -05:00
parent 9044641b08
commit b2690c4342
3 changed files with 59 additions and 25 deletions
+41 -12
View File
@@ -11,7 +11,7 @@ Lifts matrix-bridge's prime-then-listen + threaded-reply plumbing. Config: repo
"""
import asyncio
from nio import AsyncClient, MatrixRoom, RoomMessageText
from nio import AsyncClient, MatrixRoom, MessageDirection, RoomMessageText
import crm_client
import email_proposals
@@ -27,6 +27,7 @@ UNCLEAR_HELP = (
)
EMAIL_POLL_SEC = 20 # how often the bot polls the CRM for new/decided email-activity proposals
MAX_THREAD_SCAN_PAGES = 8 # how far back to scan for a resolved thread's replies before redacting
async def main():
@@ -163,14 +164,42 @@ async def main():
await say(room_id, "I didn't catch that.\n\n" + proposals.render_disambiguation(proposal), root)
async def redact_card(event_id):
"""Remove a decided card from the room so only undecided ones remain. Redacting our OWN
card needs no special power; in Element a redacted message drops out of the timeline. (To
also wipe the human's yes/no reply for a fully-empty thread, give the bot a redact/mod
power level — not required for this.)"""
"""Redact one event (best-effort). Redacting our OWN message needs no special power;
redacting someone else's reply needs the bot to hold a redact/mod power level."""
try:
await client.room_redact(review_room, event_id, reason="proposal resolved")
except Exception as exc:
print(f"matrix-intake: could not redact card {event_id}: {exc}", flush=True)
print(f"matrix-intake: could not redact {event_id}: {exc}", flush=True)
async def redact_thread(root):
"""Clear a resolved thread: redact the card AND every reply under it, so the thread drops
out of the threads view (not just the main timeline). The card is ours (always redactable);
the human's yes/no reply needs the bot's redact/mod power — if it lacks power that redact
just no-ops and the reply lingers. Finds replies by scanning recent room history for
m.thread events pointing at this root (the triggering reply is already synced, so a
backward scan from the current token includes it)."""
await redact_card(root)
token = getattr(client, "next_batch", None)
if not token:
return
try:
scanned = 0
for _ in range(MAX_THREAD_SCAN_PAGES):
resp = await client.room_messages(review_room, start=token,
direction=MessageDirection.back, limit=100)
chunk = getattr(resp, "chunk", None)
if not chunk:
break
for ev in chunk:
rel = ((getattr(ev, "source", None) or {}).get("content", {}) or {}).get("m.relates_to") or {}
if rel.get("rel_type") == "m.thread" and rel.get("event_id") == root:
await redact_card(ev.event_id)
token = getattr(resp, "end", None)
scanned += len(chunk)
if not token or scanned > 1000:
break
except Exception as exc:
print(f"matrix-intake: thread reply cleanup failed for {root}: {exc}", flush=True)
async def handle_email_reply(room_id, root, text):
"""An in-thread reply to a CRM-drafted email-proposal card: yes commits, no dismisses, and
@@ -190,8 +219,9 @@ async def main():
email_threads[root] = item # restore for retry
await say(room_id, email_proposals.frame(f"⚠️ couldn't add it ({str(exc)[:200]}). Reply **yes** to retry, **no** to dismiss."), root)
return
await say(room_id, email_proposals.frame(f"✅ Added to the grid for **{item.get('investor_name') or 'the investor'}**."), root)
await redact_card(root)
# Success → clear the whole thread (card + replies). No confirmation: the thread
# vanishing is the acknowledgment, and a confirmation reply would keep it alive.
await redact_thread(root)
elif decision == "reject":
email_threads.pop(root, None)
try:
@@ -200,8 +230,7 @@ async def main():
email_threads[root] = item
await say(room_id, email_proposals.frame(f"⚠️ couldn't dismiss it ({str(exc)[:200]}). Try again."), root)
return
await say(room_id, email_proposals.frame("🗑️ Dismissed — nothing added to the grid."), root)
await redact_card(root)
await redact_thread(root)
else:
try:
new_note = await asyncio.to_thread(email_proposals.revise_note, item.get("note") or "", text)
@@ -244,12 +273,12 @@ async def main():
"note": it.get("proposed_note") or ""}
except Exception as exc:
print(f"matrix-intake: failed to post email proposal {it.get('id')}: {exc}", flush=True)
for it in lists["to_close"]: # decided on the web → remove the card, then close
for it in lists["to_close"]: # decided on the web → clear the thread, then close
ev = it.get("event_id")
if not ev:
continue
try:
await redact_card(ev)
await redact_thread(ev)
await asyncio.to_thread(crm_client.mark_email_proposal_closed, it["id"])
email_threads.pop(ev, None)
except Exception as exc:
+17 -12
View File
@@ -39,7 +39,8 @@ async def main(apply):
sync = await client.sync(timeout=10000, full_state=False)
token = sync.next_batch
cards = {} # event_id -> snippet (dedup across pages)
cards = {} # root event_id -> snippet (still-identifiable card bodies)
replies = {} # reply event_id -> (thread_root, snippet)
for _ in range(MAX_PAGES):
resp = await client.room_messages(review_room, start=token,
direction=MessageDirection.back, limit=100)
@@ -47,24 +48,28 @@ async def main(apply):
if not chunk:
break
for ev in chunk:
if getattr(ev, "sender", None) != mx["user_id"]:
continue
body = getattr(ev, "body", "") or ""
if CARD_MARKER in body:
cards[ev.event_id] = body.replace("\n", " ")[:70]
body = (getattr(ev, "body", "") or "").replace("\n", " ")
rel = ((getattr(ev, "source", None) or {}).get("content", {}) or {}).get("m.relates_to") or {}
if rel.get("rel_type") == "m.thread" and rel.get("event_id"):
replies[ev.event_id] = (rel["event_id"], body[:50]) # a threaded reply (card already redacted)
elif getattr(ev, "sender", None) == mx["user_id"] and CARD_MARKER in body:
cards[ev.event_id] = body[:70] # an un-redacted card root
token = getattr(resp, "end", None)
if not token:
break
to_redact = [(eid, snip) for eid, snip in cards.items() if eid not in open_ids]
print(f"bot cards found: {len(cards)}; resolved (to redact): {len(to_redact)}")
for eid, snip in to_redact:
print(("APPLY redact " if apply else "WOULD redact ") + eid + " :: " + snip)
# Redact card roots that aren't still pending, AND any reply whose thread isn't still pending.
targets = [(eid, "card :: " + snip) for eid, snip in cards.items() if eid not in open_ids]
targets += [(eid, "reply :: " + snip) for eid, (root, snip) in replies.items() if root not in open_ids]
print(f"resolved cards: {sum(1 for e,_ in cards.items() if e not in open_ids)}; "
f"thread replies to clear: {sum(1 for _,(r,_) in replies.items() if r not in open_ids)}")
for eid, label in targets:
print(("APPLY redact " if apply else "WOULD redact ") + eid + " :: " + label)
if apply:
r = await client.room_redact(review_room, eid, reason="retroactive cleanup of resolved cards")
r = await client.room_redact(review_room, eid, reason="retroactive cleanup of resolved review threads")
if not hasattr(r, "event_id"):
print(f" ! redact failed: {r}")
print(("done — redacted " if apply else "dry run — would redact ") + f"{len(to_redact)} card(s).")
print(("done — redacted " if apply else "dry run — would redact ") + f"{len(targets)} event(s).")
finally:
await client.close()