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:]))