#!/usr/bin/env python3 """One-time maintenance: clear the intake room's backlog of resolved/stale messages. Going forward the bot redacts each intake thread when it's approved/rejected (bot card + ack + nudge + the user's own note/photo). This clears the messages that piled up BEFORE that shipped. The intake room is single-purpose and the bot keeps **no durable pending state** (its proposal store is in-memory and is lost on every restart), so nothing in the room is "still live" after a restart — every message in it is safe to redact. This walks the room history and redacts every m.room.message event (text + business-card images), bot's and humans' alike. Redacting another user's message (the humans' notes/photos) needs the bot to hold a **redact / moderator power level** in the intake room — without it those just no-op and linger (the bot's own messages still clear). Make the bot a moderator of the intake room in Element first. 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_intake.py docker compose run --rm matrix-intake python -u backend/matrix_intake/redact_intake.py --apply """ import asyncio import sys from nio import AsyncClient, MessageDirection import settings MAX_PAGES = 50 # 50 * 100 events is far more history than this room holds async def main(apply): mx = settings.matrix_settings() intake_room = mx.get("intake_room") if not intake_room: print("MATRIX_INTAKE_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: sync = await client.sync(timeout=10000, full_state=False) token = sync.next_batch targets = [] # (event_id, label) seen = set() for _ in range(MAX_PAGES): resp = await client.room_messages(intake_room, start=token, direction=MessageDirection.back, limit=100) chunk = getattr(resp, "chunk", None) if not chunk: break for ev in chunk: src = getattr(ev, "source", None) or {} if src.get("type") != "m.room.message": continue # only chat messages + images; leave membership/state events alone eid = getattr(ev, "event_id", None) if not eid or eid in seen: continue seen.add(eid) content = src.get("content") or {} if not content: continue # already redacted (content stripped) — skip msgtype = content.get("msgtype") or "?" body = (content.get("body", "") or "").replace("\n", " ") who = "bot " if getattr(ev, "sender", None) == mx["user_id"] else "user" targets.append((eid, f"{who} [{msgtype}] {body[:60]}")) token = getattr(resp, "end", None) if not token: break print(f"messages to clear in the intake room: {len(targets)}") fails = 0 for eid, label in targets: print(("APPLY redact " if apply else "WOULD redact ") + eid + " :: " + label) if apply: r = await client.room_redact(intake_room, eid, reason="retroactive intake-room cleanup") if not hasattr(r, "event_id"): fails += 1 print(f" ! redact failed (need mod power for others' messages?): {r}") print(("done — redacted " if apply else "dry run — would redact ") + f"{len(targets) - (fails if apply else 0)}/{len(targets)} event(s)" + (f"; {fails} failed" if apply and fails else "") + ".") finally: await client.close() if __name__ == "__main__": asyncio.run(main(apply="--apply" in sys.argv[1:]))