import { mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs as nodeParseArgs } from "node:util"; import { OAuthClient, MemoryStore } from "@atcute/oauth-node-client"; import { Prompt } from "@clack/core"; import * as p from "@clack/prompts"; import color from "picocolors"; // ── runtime detection ─────────────────────────────────────────────── const IS_BUN = typeof Bun !== "undefined"; // ── config ────────────────────────────────────────────────────────── const AUTHOR_CONTACT = "did:plc:7exy3k53z33dvghn6edyayxt; winter@madoka.systems"; let USER_AGENT = "mbr/1.0"; const CONSTELLATION_BASE = process.env.CONSTELLATION_BASE ?? "https://constellation.microcosm.blue"; const SLINGSHOT_BASE = process.env.SLINGSHOT_BASE ?? "https://slingshot.microcosm.blue"; const PORT = 22891; const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`; const BLOCK_DELAY_MS = 150; const BATCH_SIZE = 200; const PAGE_SIZE = 100; const PROFILE_BATCH_SIZE = 25; const POST_CATEGORIES = /** @type {const} */ (["reposts", "replies", "likes", "quotes", "followers"]); const PROFILE_CATEGORIES = /** @type {const} */ (["followers", "following"]); const DUPLICATE_PATTERNS = ["duplicate", "already exists"]; // ── CLI parsing ───────────────────────────────────────────────────── function parseArgs() { const argv = IS_BUN ? (() => { const i = Bun.argv.findIndex((a) => a.endsWith(".js") || a.endsWith(".ts")); return i >= 0 ? Bun.argv.slice(i + 1) : Bun.argv.slice(2); })() : process.argv.slice(2); const { values } = nodeParseArgs({ args: argv, options: { delay: { type: "string" }, batch: { type: "string" }, "dry-run": { type: "boolean" }, "no-block-target": { type: "boolean" }, output: { type: "string" }, unblock: { type: "boolean" }, help: { type: "boolean", short: "h" }, }, strict: false, }); return { flags: { delay: values.delay != null ? parseInt(values.delay, 10) : undefined, batch: values.batch != null ? parseInt(values.batch, 10) : undefined, dryRun: values["dry-run"], noBlockTarget: values["no-block-target"], output: values.output, unblock: values.unblock, help: values.help, }, }; } function printUsage() { console.log(`mbr: interactively block engagement on an atproto post usage: bun index.js [options] node index.js [options] options: --delay delay between batches in ms (default: ${BLOCK_DELAY_MS}) --batch writes per applyWrites call (default: ${BATCH_SIZE}) --dry-run show what would be blocked without actually blocking --no-block-target don't block the target account itself --output write blocked DIDs to file (one per line) --unblock remove blocks instead of adding them -h, --help show this help`); } // ── shared helpers ────────────────────────────────────────────────── const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function exitIfCancelled(value) { if (p.isCancel(value)) { p.cancel("cancelled."); process.exit(0); } return value; } async function writeOutput(path, dids) { const { writeFile } = await import("node:fs/promises"); await writeFile(path, dids.join("\n") + "\n"); } async function resolveMiniDoc(identifier) { const res = await fetch( `${SLINGSHOT_BASE}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`, { headers: { "user-agent": USER_AGENT } } ); if (!res.ok) throw new Error(`slingshot identity error for ${identifier}: ${res.status}`); const doc = await res.json(); if (typeof doc?.did !== "string") throw new Error(`unexpected slingshot response for ${identifier}`); return doc; } async function resolveHandle(handle) { return (await resolveMiniDoc(handle)).did; } function makeBlockRecord(targetDid, createdAt) { return { $type: "app.bsky.graph.block", subject: targetDid, createdAt }; } function isDuplicateError(msg) { return DUPLICATE_PATTERNS.some((pat) => msg.includes(pat)); } async function parseApiError(res) { const body = await res.json().catch(() => ({})); return body?.message ?? body?.error ?? `status ${res.status}`; } // ── TID generation ────────────────────────────────────────────────── const TID_CHARS = "234567abcdefghijklmnopqrstuvwxyz"; const tidClockId = BigInt(Math.floor(Math.random() * 1024)); let tidLast = 0n; function generateTid() { let now = BigInt(Date.now()) * 1000n; if (now <= tidLast) now = tidLast + 1n; tidLast = now; let v = (now << 10n) | (tidClockId & 0x3ffn); let out = ""; for (let i = 0; i < 13; i++) { out = TID_CHARS[Number(v & 31n)] + out; v >>= 5n; } return out; } // ── CAR / CBOR (inline, no deps) ──────────────────────────────────── const TEXT_DECODER = new TextDecoder(); function readVarint(bytes, pos) { let val = 0, mul = 1; for (;;) { const b = bytes[pos++]; val += (b & 0x7f) * mul; if (!(b & 0x80)) break; mul *= 128; } return [val, pos]; } // Minimal dag-cbor decoder — handles the subset used in ATProto records. function decodeCbor(bytes, pos) { const initial = bytes[pos++]; const major = initial >> 5; const info = initial & 0x1f; let arg = info; if (info === 24) { arg = bytes[pos++]; } else if (info === 25) { arg = (bytes[pos] << 8) | bytes[pos + 1]; pos += 2; } else if (info === 26) { arg = ((bytes[pos] << 24) | (bytes[pos+1] << 16) | (bytes[pos+2] << 8) | bytes[pos+3]) >>> 0; pos += 4; } else if (info === 27) { pos += 8; arg = 0; } // 8-byte ints won't appear in block records switch (major) { case 0: return [arg, pos]; case 1: return [-(arg + 1), pos]; case 2: { const end = pos + arg; return [bytes.slice(pos, end), end]; } case 3: { const end = pos + arg; return [TEXT_DECODER.decode(bytes.slice(pos, end)), end]; } case 4: { const arr = []; for (let i = 0; i < arg; i++) { let v; [v, pos] = decodeCbor(bytes, pos); arr.push(v); } return [arr, pos]; } case 5: { const obj = Object.create(null); for (let i = 0; i < arg; i++) { let k, v; [k, pos] = decodeCbor(bytes, pos); [v, pos] = decodeCbor(bytes, pos); obj[k] = v; } return [obj, pos]; } case 6: return decodeCbor(bytes, pos); // tag — unwrap (tag 42 = CID link, we don't need the value) case 7: if (info === 20) return [false, pos]; if (info === 21) return [true, pos]; if (info === 22) return [null, pos]; return [undefined, pos]; // floats: bytes already consumed above default: return [undefined, pos]; } } // ── URL type detection ────────────────────────────────────────────── function isProfileUrl(input) { if (input.startsWith("at://")) return false; return input.includes("/profile/") && !input.includes("/post/"); } async function resolveToDid(handleOrDid) { if (handleOrDid.startsWith("did:")) return handleOrDid; return resolveHandle(handleOrDid); } async function resolveProfileTarget(input) { const match = input.match(/\/profile\/([^/?#]+)/); if (!match) throw new Error(`can't parse profile URL: ${input}`); return resolveToDid(match[1]); } // ── URL/URI parsing ───────────────────────────────────────────────── async function resolvePostUri(input) { if (input.startsWith("at://")) return input; const urlMatch = input.match(/\/profile\/([^/]+)\/post\/([^/?#]+)/); if (!urlMatch) { throw new Error( `can't parse post URL: ${input}\nexpected format: https:///profile//post/` ); } const [, handleOrDid, rkey] = urlMatch; const did = await resolveToDid(handleOrDid); return `at://${did}/app.bsky.feed.post/${rkey}`; } // ── constellation ─────────────────────────────────────────────────── async function fetchConstellationDids(target, collection, path, onProgress) { const allDids = []; let cursor; while (true) { const url = new URL(`${CONSTELLATION_BASE}/links/distinct-dids`); url.searchParams.set("target", target); url.searchParams.set("collection", collection); url.searchParams.set("path", path); url.searchParams.set("limit", String(PAGE_SIZE)); if (cursor) url.searchParams.set("cursor", cursor); const res = await fetch(url, { headers: { "user-agent": USER_AGENT } }); if (!res.ok) throw new Error(`constellation error: ${res.status}`); const data = await res.json(); allDids.push(...data.linking_dids); if (onProgress) onProgress(data.linking_dids.length); if (!data.cursor || data.linking_dids.length === 0) break; cursor = data.cursor; } return allDids; } const fetchReposters = (atUri, onProgress) => fetchConstellationDids(atUri, "app.bsky.feed.repost", ".subject.uri", onProgress); const fetchLikers = (atUri, onProgress) => fetchConstellationDids(atUri, "app.bsky.feed.like", ".subject.uri", onProgress); const fetchRepliers = (atUri, onProgress) => fetchConstellationDids(atUri, "app.bsky.feed.post", ".reply.parent.uri", onProgress); const fetchQuotePosters = (atUri, onProgress) => fetchConstellationDids(atUri, "app.bsky.feed.post", ".embed.record.uri", onProgress); function extractAuthorDid(atUri) { return atUri.replace("at://", "").split("/")[0]; } // ── social graph ──────────────────────────────────────────────────── async function resolvePds(did) { const doc = await resolveMiniDoc(did); if (typeof doc?.pds !== "string") throw new Error(`no pds in slingshot response for ${did}`); return doc.pds; } async function fetchFollowing(did, pdsUrl, onProgress) { const allDids = []; let cursor; while (true) { const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`); url.searchParams.set("repo", did); url.searchParams.set("collection", "app.bsky.graph.follow"); url.searchParams.set("limit", "100"); if (cursor) url.searchParams.set("cursor", cursor); const res = await fetch(url); if (!res.ok) throw new Error(`listRecords error: ${res.status}`); const data = await res.json(); for (const rec of data.records) allDids.push(rec.value.subject); if (onProgress) onProgress(data.records.length); if (!data.cursor || data.records.length === 0) break; cursor = data.cursor; } return new Set(allDids); } async function fetchFollowers(did, onProgress) { const dids = await fetchConstellationDids(did, "app.bsky.graph.follow", ".subject", onProgress); return new Set(dids); } // Fast-path extractor for app.bsky.graph.block records from raw dag-cbor bytes. // dag-cbor uses deterministic key ordering (length-first, then lex), so a block // record's keys always appear in order: "$type"(5) < "subject"(7) < "createdAt"(9). // That gives a fixed byte prefix we can check cheaply before touching the decoder. // // Layout from pos: // a3 map(3) // 65 tstr(5) // 24 74 79 70 65 "$type" // 74 tstr(20) // [20 bytes] "app.bsky.graph.block" // 67 tstr(7) // [7 bytes] "subject" // [tstr hdr] subject DID string const BLOCK_TYPE_BYTES = new TextEncoder().encode("app.bsky.graph.block"); const SUBJECT_KEY_BYTES = new TextEncoder().encode("subject"); function extractBlockSubject(bytes, pos) { // map(3), tstr(5), "$type" if (bytes[pos] !== 0xa3 || bytes[pos+1] !== 0x65) return null; if (bytes[pos+2] !== 0x24 || bytes[pos+3] !== 0x74 || bytes[pos+4] !== 0x79 || bytes[pos+5] !== 0x70 || bytes[pos+6] !== 0x65) return null; // tstr(20) + "app.bsky.graph.block" if (bytes[pos+7] !== 0x74) return null; for (let i = 0; i < 20; i++) { if (bytes[pos + 8 + i] !== BLOCK_TYPE_BYTES[i]) return null; } // tstr(7) + "subject" let p = pos + 28; if (bytes[p] !== 0x67) return null; p++; for (let i = 0; i < 7; i++) { if (bytes[p + i] !== SUBJECT_KEY_BYTES[i]) return null; } p += 7; // read the subject DID string const hdr = bytes[p++]; if ((hdr >> 5) !== 3) return null; // not a tstr const info = hdr & 0x1f; let len; if (info <= 23) { len = info; } else if (info === 24) { len = bytes[p++]; } else if (info === 25) { len = (bytes[p] << 8) | bytes[p + 1]; p += 2; } else return null; return TEXT_DECODER.decode(bytes.slice(p, p + len)); } async function fetchExistingBlocks(did, pdsUrl) { const res = await fetch( `${pdsUrl}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`, { headers: { "user-agent": USER_AGENT } } ); if (!res.ok) throw new Error(`getRepo error: ${res.status}`); const bytes = new Uint8Array(await res.arrayBuffer()); // Parse CARv1: skip header, then scan every dag-cbor block for block records let pos = 0; let headerLen; [headerLen, pos] = readVarint(bytes, pos); pos += headerLen; const blocked = new Set(); while (pos < bytes.length) { let sectionLen; [sectionLen, pos] = readVarint(bytes, pos); const sectionEnd = pos + sectionLen; // CIDv1: version + codec + multihash (fn + digestLen + digest) let codec, digestLen; [, pos] = readVarint(bytes, pos); // version [codec, pos] = readVarint(bytes, pos); [, pos] = readVarint(bytes, pos); // hash fn [digestLen, pos] = readVarint(bytes, pos); pos += digestLen; if (codec === 0x71) { // dag-cbor const subject = extractBlockSubject(bytes, pos); if (subject !== null) blocked.add(subject); } pos = sectionEnd; } return blocked; } async function fetchBlockMap(did, pdsUrl, onProgress) { const map = new Map(); let cursor; while (true) { const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`); url.searchParams.set("repo", did); url.searchParams.set("collection", "app.bsky.graph.block"); url.searchParams.set("limit", "100"); if (cursor) url.searchParams.set("cursor", cursor); const res = await fetch(url); if (!res.ok) throw new Error(`listRecords error: ${res.status}`); const data = await res.json(); for (const rec of data.records) { const rkey = rec.uri.split("/").pop(); map.set(rec.value.subject, rkey); } if (onProgress) onProgress(data.records.length); if (!data.cursor || data.records.length === 0) break; cursor = data.cursor; } return map; } async function fetchSocialGraph(did, onProgress) { const pdsUrl = await resolvePds(did); let total = 0; const tick = onProgress ? (delta) => { total += delta; onProgress(total); } : undefined; const [follows, followers, existingBlocks] = await Promise.all([ fetchFollowing(did, pdsUrl, tick), fetchFollowers(did, tick), fetchExistingBlocks(did, pdsUrl), ]); return { follows, followers, existingBlocks }; } // ── count prefetch ────────────────────────────────────────────────── async function fetchProfile(actor) { const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`); if (!res.ok) return null; return res.json(); } async function fetchPostCounts(atUri) { const [postRes, profile] = await Promise.all([ fetch(`https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=${encodeURIComponent(atUri)}`), fetchProfile(extractAuthorDid(atUri)), ]); const counts = {}; if (postRes.ok) { const data = await postRes.json(); const post = data.posts?.[0]; if (post) { counts.reposts = post.repostCount ?? 0; counts.likes = post.likeCount ?? 0; counts.replies = post.replyCount ?? 0; } } if (profile) counts.followers = profile.followersCount ?? 0; return counts; } async function fetchProfileCounts(did) { const profile = await fetchProfile(did); if (!profile) return {}; return { followers: profile.followersCount ?? 0, following: profile.followsCount ?? 0, }; } // ── profile resolution ────────────────────────────────────────────── async function resolveProfiles(dids) { const batches = []; for (let i = 0; i < dids.length; i += PROFILE_BATCH_SIZE) { const params = dids.slice(i, i + PROFILE_BATCH_SIZE).map((d) => `actors=${encodeURIComponent(d)}`).join("&"); batches.push( fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${params}`) .then((r) => r.ok ? r.json() : null) .then((d) => d?.profiles ?? []) .catch(() => []) ); } return (await Promise.all(batches)).flat(); } // ── oauth scope ───────────────────────────────────────────────────── function getOAuthScope(unblock) { const action = unblock ? "delete" : "create"; return `atproto repo:app.bsky.graph.block?action=${action}`; } function scopeCovers(granted, required) { if (!granted) return false; const g = new Set(granted.trim().split(/\s+/)); return required.trim().split(/\s+/).every((tok) => g.has(tok)); } // ── persistent session store ──────────────────────────────────────── function getSessionStorePath() { const base = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"); return join(base, "mbr", "sessions.json"); } class FileStore { #path; #cache; constructor(path) { this.#path = path; } async #load() { if (this.#cache) return this.#cache; try { const data = await readFile(this.#path, "utf8"); this.#cache = new Map(Object.entries(JSON.parse(data))); } catch (err) { if (err.code !== "ENOENT") throw err; this.#cache = new Map(); } return this.#cache; } async #flush() { await mkdir(dirname(this.#path), { recursive: true }); await writeFile(this.#path, JSON.stringify(Object.fromEntries(this.#cache)), { mode: 0o600 }); } async get(key) { return (await this.#load()).get(key); } async set(key, value) { (await this.#load()).set(key, value); await this.#flush(); } async delete(key) { (await this.#load()).delete(key); await this.#flush(); } async clear() { this.#cache = new Map(); await this.#flush(); } } const sessionStore = new FileStore(getSessionStorePath()); // ── callback server ───────────────────────────────────────────────── async function startCallbackServer() { let resolveSession, rejectSession; const sessionPromise = new Promise((resolve, reject) => { resolveSession = resolve; rejectSession = reject; }); const ctx = { oauthClient: null }; const handler = async (req) => { const url = new URL(req.url); if (url.pathname !== "/callback") { return new Response("not found", { status: 404 }); } try { const { session } = await ctx.oauthClient.callback(url.searchParams); resolveSession(session); return new Response("

authenticated! you can close this tab.

", { headers: { "content-type": "text/html" }, }); } catch (err) { rejectSession(err); return new Response(`oauth error: ${err.message}`, { status: 500 }); } }; let server; if (IS_BUN) { server = Bun.serve({ port: PORT, hostname: "127.0.0.1", fetch: handler }); } else { const { createServer } = await import("node:http"); server = createServer(async (req, res) => { const fakeReq = new Request(`http://127.0.0.1:${PORT}${req.url}`); const response = await handler(fakeReq); res.writeHead(response.status, { "content-type": response.headers.get("content-type") || "text/plain", }); res.end(await response.text()); }); await new Promise((r) => server.listen(PORT, "127.0.0.1", r)); } const close = () => { if (IS_BUN) server.stop(); else server.close(); }; return { sessionPromise, close, ctx }; } // ── oauth ─────────────────────────────────────────────────────────── async function authenticate({ handle, did, scope }) { const oauthClient = new OAuthClient({ metadata: { redirect_uris: [REDIRECT_URI], scope, }, actorResolver: { resolve: async (actor) => { const doc = await resolveMiniDoc(actor); return { did: doc.did, handle: doc.handle, pds: doc.pds }; }, }, stores: { sessions: sessionStore, states: new MemoryStore({ ttl: 600_000 }), }, }); if (did) { const stored = await sessionStore.get(did); if (stored && scopeCovers(stored.tokenSet?.scope, scope)) { try { const session = await oauthClient.restore(did); p.log.info(`restored cached session for ${session.did}`); return session; } catch { // cached session unusable (expired/revoked) — fall through to full flow } } } const { sessionPromise, close, ctx } = await startCallbackServer(); ctx.oauthClient = oauthClient; let authUrl; try { const { url } = await oauthClient.authorize({ target: { type: "account", identifier: handle }, scope, }); authUrl = url.toString(); // Fire-and-forget: errors suppressed (fails silently in SSH/WSL) const { execFile } = await import("node:child_process"); const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; execFile(cmd, [authUrl], () => {}); } catch (err) { close(); throw new Error(`failed to start oauth flow: ${err.message}`); } p.log.info(`open this URL in your browser:\n ${color.cyan(authUrl)}`); p.log.info(`SSH/WSL: if the redirect fails, copy the full ${color.dim("http://127.0.0.1:" + PORT + "/callback?code=...")} URL from your browser and paste it below`); // Race: server callback vs manual paste let serverWon = false; let serverError = null; // When server callback arrives, dismiss the paste prompt via a synthetic keypress. // On server-side OAuth failure, surface the error and cancel the prompt. sessionPromise.then(() => { serverWon = true; process.stdin.emit("keypress", "\r", { name: "return", ctrl: false, meta: false, shift: false }); }).catch((err) => { serverError = err; p.log.error(`browser callback failed: ${err.message}`); process.stdin.emit("keypress", "\x03", { name: "c", ctrl: true, meta: false, shift: false }); }); const pastePrompt = new Prompt({ validate(value) { if (serverWon) return; // server resolved -- accept empty input to dismiss if (!value) return "waiting for browser redirect... or paste the full callback URL"; try { const u = new URL(value); if (!u.searchParams.has("code")) return "paste the full redirect URL from your browser (must include ?code=...)"; } catch { return "not a valid URL"; } }, render() { const prefix = color.gray("│"); switch (this.state) { case "submit": return `${color.gray("◇")} ${serverWon ? "authenticated via browser" : "callback URL received"}`; case "error": return `${color.yellow("▲")} waiting for OAuth\n${prefix} ${this.value || ""}\n${prefix} ${color.yellow(this.error)}`; default: return `${color.cyan("◆")} waiting for OAuth (paste callback URL if redirect failed)\n${prefix} ${this.value || color.dim("http://127.0.0.1:" + PORT + "/callback?code=...")}`; } }, }); const pastedInput = await pastePrompt.prompt(); if (p.isCancel(pastedInput)) { close(); if (serverError) throw serverError; // propagate server-side OAuth failure p.cancel("cancelled."); process.exit(0); } if (serverWon || !pastedInput) { // Browser callback completed const session = await sessionPromise; close(); return session; } // User pasted the callback URL manually close(); const params = new URL(pastedInput).searchParams; const { session } = await oauthClient.callback(params); return session; } // ── custom inline multi-select ────────────────────────────────────── function inlineMultiSelect({ message, options }) { let cursor = 0; const selected = new Set(); const prompt = new Prompt({ validate(value) { if (!value || value.size === 0) return "select at least one option"; }, render() { const prefix = color.gray("│"); const items = options .map((opt, i) => { const check = selected.has(opt.value) ? color.green("◼") : color.dim("◻"); const label = i === cursor ? color.cyan(`${check} ${color.underline(opt.label)}`) : `${check} ${opt.label}`; return label; }) .join(" "); switch (this.state) { case "submit": return `${color.gray("◇")} ${message}\n${prefix} ${color.dim([...selected].join(", "))}`; case "cancel": return `${color.gray("◇")} ${message}\n${prefix} ${color.strikethrough(color.dim("cancelled"))}`; case "error": return `${color.yellow("▲")} ${message}\n${prefix} ${items}\n${prefix} ${color.yellow(this.error)}`; default: return `${color.cyan("◆")} ${message}\n${prefix} ${items}\n${prefix} ${color.dim("← → move · space toggle · enter confirm")}`; } }, }); prompt.on("cursor", (key) => { if (key === "right") { cursor = (cursor + 1) % options.length; } else if (key === "left") { cursor = (cursor - 1 + options.length) % options.length; } else if (key === "space") { const val = options[cursor].value; if (selected.has(val)) selected.delete(val); else selected.add(val); } }); prompt.value = selected; return prompt.prompt(); } // ── interactive flow ──────────────────────────────────────────────── const PROFILE_LABELS = { followers: "their followers", following: "their following" }; async function runInteractiveFlow() { p.intro(color.inverse(" bluesky blocker ")); const url = exitIfCancelled(await p.text({ message: "paste a post URL, profile URL, or at:// URI", placeholder: "https://bsky.app/profile/someone.bsky.social/post/abc123", validate: (v) => { if (!v) return "url is required"; if (v.startsWith("at://")) return; if (v.includes("/post/")) return; if (v.includes("/profile/")) return; return "paste a post URL (…/post/…), a profile URL (…/profile/…), or an at:// URI"; }, })); const mode = isProfileUrl(url) ? "profile" : "post"; // Resolve target and prefetch counts before showing category selector const s = p.spinner(); s.start(mode === "post" ? "resolving post..." : "resolving profile..."); let target, counts; try { target = mode === "post" ? await resolvePostUri(url) : await resolveProfileTarget(url); counts = mode === "post" ? await fetchPostCounts(target) : await fetchProfileCounts(target); s.stop(`target: ${target}`); } catch (err) { s.stop("failed to resolve"); throw err; } const categoryList = mode === "post" ? POST_CATEGORIES : PROFILE_CATEGORIES; const categories = exitIfCancelled(await inlineMultiSelect({ message: "what do you want to block?", options: categoryList.map((c) => { const base = mode === "profile" ? (PROFILE_LABELS[c] ?? c) : c; const n = counts?.[c]; return { value: c, label: n !== undefined ? `${base} (${n.toLocaleString()})` : base }; }), })); const blockFollowing = exitIfCancelled(await p.confirm({ message: "include people you're following in blocks?", initialValue: false, })); const handle = exitIfCancelled(await p.text({ message: "enter your bluesky handle", placeholder: "you.bsky.social", validate: (v) => { if (!v) return "handle is required"; }, })); return { url, mode, target, categories, blockFollowing, handle }; } // ── data fetching ─────────────────────────────────────────────────── async function fetchEngagementData(target, categories, mode, onProgress) { const results = { reposts: [], likes: [], replies: [], quotes: [], followers: [], following: [], quotePosters: [] }; const fetchers = []; let total = 0; const tick = onProgress ? (delta) => { total += delta; onProgress(total); } : undefined; if (mode === "profile") { // target is a DID if (categories.has("followers")) { fetchers.push(fetchFollowers(target, tick).then((d) => { results.followers = [...d]; })); } if (categories.has("following")) { fetchers.push( resolvePds(target).then((pdsUrl) => fetchFollowing(target, pdsUrl, tick).then((d) => { results.following = [...d]; }) ) ); } } else { // post mode: target is an at:// URI if (categories.has("reposts")) { fetchers.push(fetchReposters(target, tick).then((d) => { results.reposts = d; })); } if (categories.has("likes")) { fetchers.push(fetchLikers(target, tick).then((d) => { results.likes = d; })); } if (categories.has("replies")) { fetchers.push(fetchRepliers(target, tick).then((d) => { results.replies = d; })); } if (categories.has("followers")) { const authorDid = extractAuthorDid(target); fetchers.push(fetchFollowers(authorDid, tick).then((d) => { results.followers = [...d]; })); } fetchers.push(fetchQuotePosters(target, tick).then((d) => { results.quotePosters = d; if (categories.has("quotes")) results.quotes = d; })); } await Promise.all(fetchers); return results; } // ── filtering ─────────────────────────────────────────────────────── function filterCandidates({ results, did, targetDid, follows, followers, existingBlocks, blockFollowing, blockTarget = true, blockQuotes = false }) { const allCandidates = new Set(); for (const src of [results.reposts, results.likes, results.replies, results.quotes, results.followers, results.following]) { for (const d of src) allCandidates.add(d); } const quotePosters = blockQuotes ? new Set() : new Set(results.quotePosters); const followedInBlockList = []; const toBlock = []; let skippedSelf = 0, skippedQuote = 0, skippedFollow = 0, skippedFollower = 0, skippedAlreadyBlocked = 0; // Block the target themselves first, unless skipped or already blocked if (blockTarget && targetDid && targetDid !== did && !existingBlocks.has(targetDid)) { toBlock.push(targetDid); } for (const d of allCandidates) { if (d === did) { skippedSelf++; continue; } if (d === targetDid) continue; // already added above if (existingBlocks.has(d)) { skippedAlreadyBlocked++; continue; } if (quotePosters.has(d)) { skippedQuote++; continue; } if (follows.has(d)) { if (!blockFollowing) { skippedFollow++; continue; } else { followedInBlockList.push(d); } } if (followers.has(d)) { skippedFollower++; continue; } toBlock.push(d); } return { toBlock, followedInBlockList, skippedSelf, skippedQuote, skippedFollow, skippedFollower, skippedAlreadyBlocked, total: allCandidates.size }; } // ── summary display ───────────────────────────────────────────────── async function showSummary(results, filterResult, categories, mode) { const lines = []; if (mode === "post") { if (categories.has("reposts")) lines.push(` reposters: ${results.reposts.length}`); if (categories.has("likes")) lines.push(` likers: ${results.likes.length}`); if (categories.has("replies")) lines.push(` repliers: ${results.replies.length}`); if (categories.has("followers")) lines.push(` author followers: ${results.followers.length}`); if (categories.has("quotes")) lines.push(` quote posters: ${results.quotes.length}`); else lines.push(` quote posters (excluded): ${results.quotePosters.length}`); } else { if (categories.has("followers")) lines.push(` followers: ${results.followers.length}`); if (categories.has("following")) lines.push(` following: ${results.following.length}`); } lines.push(""); lines.push(` unique candidates: ${filterResult.total}`); if (filterResult.skippedSelf) lines.push(` - ${filterResult.skippedSelf} (self)`); if (filterResult.skippedQuote) lines.push(` - ${filterResult.skippedQuote} (quote posters)`); if (filterResult.skippedFollow) lines.push(` - ${filterResult.skippedFollow} (people you follow)`); if (filterResult.skippedFollower) lines.push(` - ${filterResult.skippedFollower} (your followers)`); if (filterResult.skippedAlreadyBlocked) lines.push(` - ${filterResult.skippedAlreadyBlocked} (already blocked)`); lines.push(` = ${color.bold(String(filterResult.toBlock.length))} to block`); p.note(lines.join("\n"), "summary"); if (filterResult.followedInBlockList.length > 0) { const s = p.spinner(); s.start("resolving profiles of followed users..."); const profiles = await resolveProfiles(filterResult.followedInBlockList); s.stop("profiles resolved"); if (profiles.length === 0) { p.log.warn(`${color.yellow("you follow")} ${filterResult.followedInBlockList.length} ${color.yellow("accounts that will be blocked")} ${color.dim("(profile resolution unavailable)")}`); } else { const warningLines = profiles.map((pr) => ` ${color.cyan(`@${pr.handle}`)} ${color.dim(`(${pr.displayName || "no display name"})`)}` ); p.log.warn( `${color.yellow("you follow these accounts and they will be blocked:")}\n${warningLines.join("\n")}` ); } } } // ── rate limit handling ────────────────────────────────────────────── function getBackoffDelay(res, defaultDelay) { if (!res?.headers) return defaultDelay; const remaining = parseInt(res.headers.get("ratelimit-remaining"), 10); const reset = parseInt(res.headers.get("ratelimit-reset"), 10); if (isNaN(remaining) || isNaN(reset)) return defaultDelay; if (remaining <= 0) { const waitMs = Math.max(0, (reset * 1000) - Date.now()) + 1000; return waitMs; } const limit = parseInt(res.headers.get("ratelimit-limit"), 10); if (!isNaN(limit) && limit > 0) { const ratio = remaining / limit; if (ratio < 0.2) { const scale = 1 + (5 * (1 - ratio / 0.2)); return Math.ceil(defaultDelay * scale); } } return defaultDelay; } async function sleepForRateLimit(res, spinner) { if (res?.status === 429) { const wait = getBackoffDelay(res, 60_000); spinner.message(`rate limited, waiting ${Math.ceil(wait / 1000)}s...`); await sleep(wait); return true; } return false; } // ── blocking ──────────────────────────────────────────────────────── async function createSingleBlock(session, did, targetDid) { return session.handle("/xrpc/com.atproto.repo.createRecord", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ repo: did, collection: "app.bsky.graph.block", record: makeBlockRecord(targetDid, new Date().toISOString()), }), }); } async function confirmAndBlock({ toBlock, handle, did, delayMs, batchSize }) { const proceed = exitIfCancelled(await p.confirm({ message: `block ${toBlock.length} accounts?`, initialValue: false, })); if (!proceed) { p.cancel("cancelled."); process.exit(0); } const authSpinner = p.spinner(); authSpinner.start("authenticating via oauth..."); const session = await authenticate({ handle, did, scope: getOAuthScope(false) }); authSpinner.stop(`authenticated as ${session.did}`); const confirmAuth = exitIfCancelled(await p.confirm({ message: `proceed as ${session.did}?`, initialValue: true, })); if (!confirmAuth) { p.cancel("cancelled."); process.exit(0); } const blockSpinner = p.spinner(); blockSpinner.start(`blocking 0/${toBlock.length} (batch size ${batchSize})...`); let blocked = 0, alreadyBlocked = 0, errors = 0; let errorCount = 0; function logError(msg) { errorCount++; if (errorCount <= 5) p.log.error(msg); } for (let i = 0; i < toBlock.length; i += batchSize) { const batch = toBlock.slice(i, i + batchSize); const now = new Date().toISOString(); const writes = batch.map((targetDid) => ({ $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.graph.block", rkey: generateTid(), value: makeBlockRecord(targetDid, now), })); let lastRes = null; try { const res = await session.handle("/xrpc/com.atproto.repo.applyWrites", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ repo: did, writes }), }); lastRes = res; if (await sleepForRateLimit(res, blockSpinner)) { i -= batchSize; continue; } if (!res.ok) { const msg = await parseApiError(res); if (isDuplicateError(msg)) { // batch rejected for duplicates — fall back to individual creates const fallbackResults = await Promise.allSettled( batch.map((targetDid) => createSingleBlock(session, did, targetDid)) ); for (let j = 0; j < fallbackResults.length; j++) { const result = fallbackResults[j]; if (result.status === "rejected") { errors++; logError(`error blocking ${batch[j]}: ${result.reason?.message ?? result.reason}`); continue; } const r = result.value; lastRes = r; if (r.status === 429) { // 429 on individual — retry this one await sleepForRateLimit(r, blockSpinner); try { const retry = await createSingleBlock(session, did, batch[j]); if (retry.ok) blocked++; else { const m = await parseApiError(retry); if (isDuplicateError(m)) alreadyBlocked++; else { errors++; logError(`error blocking ${batch[j]}: ${m}`); } } } catch (err) { errors++; logError(`error blocking ${batch[j]}: ${err?.message ?? err}`); } } else if (!r.ok) { const m = await parseApiError(r); if (isDuplicateError(m)) alreadyBlocked++; else { errors++; logError(`error blocking ${batch[j]}: ${m}`); } } else { blocked++; } } } else { errors += batch.length; logError(`batch error: ${msg}`); } } else { blocked += batch.length; } } catch (err) { errors += batch.length; logError(`batch error: ${err?.message ?? err}`); } const total = blocked + alreadyBlocked + errors; blockSpinner.message(`blocking ${total}/${toBlock.length}...`); if (i + batchSize < toBlock.length) await sleep(getBackoffDelay(lastRes, delayMs)); } blockSpinner.stop("blocking complete"); p.note( ` blocked: ${blocked}\n already blocked: ${alreadyBlocked}\n errors: ${errors}`, "results" ); p.outro("done!"); } async function confirmAndUnblock({ toUnblock, blockMap, handle, did, delayMs, batchSize }) { const proceed = exitIfCancelled(await p.confirm({ message: `unblock ${toUnblock.length} accounts?`, initialValue: false, })); if (!proceed) { p.cancel("cancelled."); process.exit(0); } const authSpinner = p.spinner(); authSpinner.start("authenticating via oauth..."); const session = await authenticate({ handle, did, scope: getOAuthScope(true) }); authSpinner.stop(`authenticated as ${session.did}`); const confirmAuth = exitIfCancelled(await p.confirm({ message: `proceed as ${session.did}?`, initialValue: true, })); if (!confirmAuth) { p.cancel("cancelled."); process.exit(0); } const unblockSpinner = p.spinner(); unblockSpinner.start(`unblocking 0/${toUnblock.length}...`); let unblocked = 0, notBlocked = 0, errors = 0; let errorCount = 0; function logError(msg) { errorCount++; if (errorCount <= 5) p.log.error(msg); } for (let i = 0; i < toUnblock.length; i += batchSize) { const batch = toUnblock.slice(i, i + batchSize); const writes = batch.flatMap((targetDid) => { const rkey = blockMap.get(targetDid); if (!rkey) { notBlocked++; return []; } return [{ $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.block", rkey }]; }); if (writes.length === 0) continue; let lastRes = null; try { const res = await session.handle("/xrpc/com.atproto.repo.applyWrites", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ repo: did, writes }), }); lastRes = res; if (await sleepForRateLimit(res, unblockSpinner)) { i -= batchSize; continue; } if (!res.ok) { const msg = await parseApiError(res); errors += writes.length; logError(`batch error: ${msg}`); } else { unblocked += writes.length; } } catch (err) { errors += writes.length; logError(`batch error: ${err?.message ?? err}`); } unblockSpinner.message(`unblocking ${unblocked + notBlocked + errors}/${toUnblock.length}...`); if (i + batchSize < toUnblock.length) await sleep(getBackoffDelay(lastRes, delayMs)); } unblockSpinner.stop("unblocking complete"); p.note(` unblocked: ${unblocked}\n not blocked: ${notBlocked}\n errors: ${errors}`, "results"); p.outro("done!"); } // ── main ──────────────────────────────────────────────────────────── async function main() { const { flags } = parseArgs(); if (flags.help) { printUsage(); process.exit(0); } const delayMs = flags.delay ?? BLOCK_DELAY_MS; const batchSize = flags.batch ?? BATCH_SIZE; const config = await runInteractiveFlow(); USER_AGENT = `mbr/1.0 (@${config.handle}; ${AUTHOR_CONTACT})`; const { target } = config; const s2 = p.spinner(); s2.start("fetching data & resolving identity..."); const [results, did] = await Promise.all([ fetchEngagementData(target, config.categories, config.mode, (n) => { s2.message(`fetching data & resolving identity... (${n.toLocaleString()} records)`); }), resolveHandle(config.handle), ]); s2.stop("data fetched"); let filterResult, blockMap; if (flags.unblock) { const pdsUrl = await resolvePds(did); const s3 = p.spinner(); s3.start("fetching your existing blocks..."); let blockCount = 0; blockMap = await fetchBlockMap(did, pdsUrl, (n) => { blockCount += n; s3.message(`fetching your existing blocks... (${blockCount.toLocaleString()} records)`); }); s3.stop(`existing blocks: ${blockMap.size}`); const targetDid = config.mode === "post" ? extractAuthorDid(target) : target; const allEngaged = new Set([ ...results.reposts, ...results.likes, ...results.replies, ...results.quotes, ...results.followers, ...results.following, ]); if (!flags.noBlockTarget && targetDid !== did) allEngaged.add(targetDid); const toUnblock = [...allEngaged].filter((d) => blockMap.has(d)); filterResult = { toBlock: toUnblock, followedInBlockList: [], skippedSelf: 0, skippedQuote: 0, skippedFollow: 0, skippedFollower: 0, skippedAlreadyBlocked: 0, total: allEngaged.size }; } else { const s3 = p.spinner(); s3.start("fetching your social graph & existing blocks..."); const { follows, followers, existingBlocks } = await fetchSocialGraph(did, (n) => { s3.message(`fetching your social graph & existing blocks... (${n.toLocaleString()} records)`); }); s3.stop(`following: ${follows.size}, followers: ${followers.size}, existing blocks: ${existingBlocks.size}`); const targetDid = config.mode === "post" ? extractAuthorDid(target) : target; filterResult = filterCandidates({ results, did, targetDid, follows, followers, existingBlocks, blockFollowing: config.blockFollowing, blockTarget: !flags.noBlockTarget, blockQuotes: config.categories.has("quotes"), }); } if (flags.unblock) { p.note(` to unblock: ${color.bold(String(filterResult.toBlock.length))}`, "summary"); } else { await showSummary(results, filterResult, config.categories, config.mode); } if (flags.output) { await writeOutput(flags.output, filterResult.toBlock); p.log.info(`wrote ${filterResult.toBlock.length} DIDs to ${flags.output}`); } if (flags.dryRun) { p.outro(flags.unblock ? "dry run — nothing unblocked." : "dry run — nothing blocked."); process.exit(0); } if (filterResult.toBlock.length === 0) { p.outro(flags.unblock ? "nothing to unblock." : "nothing to block after filtering."); process.exit(0); } if (flags.unblock) { await confirmAndUnblock({ toUnblock: filterResult.toBlock, blockMap, handle: config.handle, did, delayMs, batchSize }); } else { await confirmAndBlock({ toBlock: filterResult.toBlock, handle: config.handle, did, delayMs, batchSize }); } process.exit(0); } // Only run when executed directly (not imported by tests) const __isMain = IS_BUN ? import.meta.main : fileURLToPath(import.meta.url) === process.argv[1]; if (__isMain) { main().catch((err) => { p.cancel(`fatal: ${err.message}`); process.exit(1); }); } export { isProfileUrl, resolveMiniDoc, filterCandidates, writeOutput };