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: