From 9044641b08177c1db9e766c67622bbfe679d66f6 Mon Sep 17 00:00:00 2001 From: Keysat Date: Thu, 18 Jun 2026 12:09:48 -0500 Subject: [PATCH] Add one-time tool to redact resolved review cards Cards decided before the auto-redact behavior shipped are already 'closed' in the CRM, so the bot's to_close sweep never redacts them. redact_resolved.py walks the review room, keeps cards still pending (CRM 'open' list), and redacts the rest. Dry-run by default; --apply to act. Run via docker compose on the Spark. --- backend/matrix_intake/redact_resolved.py | 73 ++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 backend/matrix_intake/redact_resolved.py diff --git a/backend/matrix_intake/redact_resolved.py b/backend/matrix_intake/redact_resolved.py new file mode 100644 index 0000000..851d95b --- /dev/null +++ b/backend/matrix_intake/redact_resolved.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""One-time maintenance: redact already-resolved email-proposal review cards. + +The bot redacts a card when it's decided going forward, but cards that were decided BEFORE that +behavior shipped (e.g. smoke-test remnants) are already `closed` in the CRM, so the normal +to_close sweep never touches them. This walks the review room's history, finds the bot's own +"proposed grid note" cards, and redacts every one that is NOT still pending (i.e. not in the CRM +`open` work-list) — leaving the room showing only what still needs handling. + +Safe by default: prints what it WOULD redact and does nothing. Pass --apply to actually redact. +Run on the Spark via the bot's own creds/image: + docker compose run --rm matrix-intake python -u backend/matrix_intake/redact_resolved.py + docker compose run --rm matrix-intake python -u backend/matrix_intake/redact_resolved.py --apply +""" +import asyncio +import sys + +from nio import AsyncClient, MessageDirection + +import crm_client +import settings + +CARD_MARKER = "📧 Proposed" # present in every review card (old and dash-framed) +MAX_PAGES = 30 # 30 * 100 events is far more history than this room holds + + +async def main(apply): + mx = settings.matrix_settings() + review_room = settings.email_review_room() + if not review_room: + print("MATRIX_EMAIL_REVIEW_ROOM is not set — nothing to do.") + return + client = AsyncClient(mx["homeserver"], mx["user_id"]) + client.restore_login(user_id=mx["user_id"], device_id=mx["device_id"], access_token=mx["token"]) + try: + # Cards still pending (must be KEPT) — their thread-root event id is the card event id. + open_ids = {it["event_id"] for it in crm_client.list_email_proposals().get("open", []) if it.get("event_id")} + print(f"pending cards to keep: {len(open_ids)}") + + sync = await client.sync(timeout=10000, full_state=False) + token = sync.next_batch + cards = {} # event_id -> snippet (dedup across pages) + for _ in range(MAX_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: + 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] + 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) + if apply: + r = await client.room_redact(review_room, eid, reason="retroactive cleanup of resolved cards") + 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).") + finally: + await client.close() + + +if __name__ == "__main__": + asyncio.run(main(apply="--apply" in sys.argv[1:]))