#!/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:]))