Mass Block [bsky] Reposts [and more]
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1305 lines 48 kB view raw
1import { mkdir, readFile, writeFile } from "node:fs/promises"; 2import { homedir } from "node:os"; 3import { dirname, join } from "node:path"; 4import { fileURLToPath } from "node:url"; 5import { parseArgs as nodeParseArgs } from "node:util"; 6import { OAuthClient, MemoryStore } from "@atcute/oauth-node-client"; 7import { Prompt } from "@clack/core"; 8import * as p from "@clack/prompts"; 9import color from "picocolors"; 10 11// ── runtime detection ─────────────────────────────────────────────── 12const IS_BUN = typeof Bun !== "undefined"; 13 14// ── config ────────────────────────────────────────────────────────── 15const AUTHOR_CONTACT = "did:plc:7exy3k53z33dvghn6edyayxt; winter@madoka.systems"; 16let USER_AGENT = "mbr/1.0"; 17const CONSTELLATION_BASE = process.env.CONSTELLATION_BASE ?? "https://constellation.microcosm.blue"; 18const SLINGSHOT_BASE = process.env.SLINGSHOT_BASE ?? "https://slingshot.microcosm.blue"; 19const PORT = 22891; 20const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`; 21const BLOCK_DELAY_MS = 150; 22const BATCH_SIZE = 200; 23const PAGE_SIZE = 100; 24const PROFILE_BATCH_SIZE = 25; 25 26const POST_CATEGORIES = /** @type {const} */ (["reposts", "replies", "likes", "quotes", "followers"]); 27const PROFILE_CATEGORIES = /** @type {const} */ (["followers", "following"]); 28const DUPLICATE_PATTERNS = ["duplicate", "already exists"]; 29 30// ── CLI parsing ───────────────────────────────────────────────────── 31function parseArgs() { 32 const argv = IS_BUN 33 ? (() => { const i = Bun.argv.findIndex((a) => a.endsWith(".js") || a.endsWith(".ts")); return i >= 0 ? Bun.argv.slice(i + 1) : Bun.argv.slice(2); })() 34 : process.argv.slice(2); 35 36 const { values } = nodeParseArgs({ 37 args: argv, 38 options: { 39 delay: { type: "string" }, 40 batch: { type: "string" }, 41 "dry-run": { type: "boolean" }, 42 "no-block-target": { type: "boolean" }, 43 output: { type: "string" }, 44 unblock: { type: "boolean" }, 45 help: { type: "boolean", short: "h" }, 46 }, 47 strict: false, 48 }); 49 50 return { 51 flags: { 52 delay: values.delay != null ? parseInt(values.delay, 10) : undefined, 53 batch: values.batch != null ? parseInt(values.batch, 10) : undefined, 54 dryRun: values["dry-run"], 55 noBlockTarget: values["no-block-target"], 56 output: values.output, 57 unblock: values.unblock, 58 help: values.help, 59 }, 60 }; 61} 62 63function printUsage() { 64 console.log(`mbr: interactively block engagement on an atproto post 65 66usage: 67 bun index.js [options] 68 node index.js [options] 69 70options: 71 --delay <ms> delay between batches in ms (default: ${BLOCK_DELAY_MS}) 72 --batch <n> writes per applyWrites call (default: ${BATCH_SIZE}) 73 --dry-run show what would be blocked without actually blocking 74 --no-block-target don't block the target account itself 75 --output <file> write blocked DIDs to file (one per line) 76 --unblock remove blocks instead of adding them 77 -h, --help show this help`); 78} 79 80// ── shared helpers ────────────────────────────────────────────────── 81const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 82 83function exitIfCancelled(value) { 84 if (p.isCancel(value)) { p.cancel("cancelled."); process.exit(0); } 85 return value; 86} 87 88async function writeOutput(path, dids) { 89 const { writeFile } = await import("node:fs/promises"); 90 await writeFile(path, dids.join("\n") + "\n"); 91} 92 93async function resolveMiniDoc(identifier) { 94 const res = await fetch( 95 `${SLINGSHOT_BASE}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`, 96 { headers: { "user-agent": USER_AGENT } } 97 ); 98 if (!res.ok) throw new Error(`slingshot identity error for ${identifier}: ${res.status}`); 99 const doc = await res.json(); 100 if (typeof doc?.did !== "string") throw new Error(`unexpected slingshot response for ${identifier}`); 101 return doc; 102} 103 104async function resolveHandle(handle) { 105 return (await resolveMiniDoc(handle)).did; 106} 107 108function makeBlockRecord(targetDid, createdAt) { 109 return { $type: "app.bsky.graph.block", subject: targetDid, createdAt }; 110} 111 112function isDuplicateError(msg) { 113 return DUPLICATE_PATTERNS.some((pat) => msg.includes(pat)); 114} 115 116async function parseApiError(res) { 117 const body = await res.json().catch(() => ({})); 118 return body?.message ?? body?.error ?? `status ${res.status}`; 119} 120 121// ── TID generation ────────────────────────────────────────────────── 122const TID_CHARS = "234567abcdefghijklmnopqrstuvwxyz"; 123const tidClockId = BigInt(Math.floor(Math.random() * 1024)); 124let tidLast = 0n; 125 126function generateTid() { 127 let now = BigInt(Date.now()) * 1000n; 128 if (now <= tidLast) now = tidLast + 1n; 129 tidLast = now; 130 let v = (now << 10n) | (tidClockId & 0x3ffn); 131 let out = ""; 132 for (let i = 0; i < 13; i++) { 133 out = TID_CHARS[Number(v & 31n)] + out; 134 v >>= 5n; 135 } 136 return out; 137} 138 139// ── CAR / CBOR (inline, no deps) ──────────────────────────────────── 140const TEXT_DECODER = new TextDecoder(); 141 142function readVarint(bytes, pos) { 143 let val = 0, mul = 1; 144 for (;;) { 145 const b = bytes[pos++]; 146 val += (b & 0x7f) * mul; 147 if (!(b & 0x80)) break; 148 mul *= 128; 149 } 150 return [val, pos]; 151} 152 153// Minimal dag-cbor decoder — handles the subset used in ATProto records. 154function decodeCbor(bytes, pos) { 155 const initial = bytes[pos++]; 156 const major = initial >> 5; 157 const info = initial & 0x1f; 158 159 let arg = info; 160 if (info === 24) { arg = bytes[pos++]; } 161 else if (info === 25) { arg = (bytes[pos] << 8) | bytes[pos + 1]; pos += 2; } 162 else if (info === 26) { arg = ((bytes[pos] << 24) | (bytes[pos+1] << 16) | (bytes[pos+2] << 8) | bytes[pos+3]) >>> 0; pos += 4; } 163 else if (info === 27) { pos += 8; arg = 0; } // 8-byte ints won't appear in block records 164 165 switch (major) { 166 case 0: return [arg, pos]; 167 case 1: return [-(arg + 1), pos]; 168 case 2: { const end = pos + arg; return [bytes.slice(pos, end), end]; } 169 case 3: { const end = pos + arg; return [TEXT_DECODER.decode(bytes.slice(pos, end)), end]; } 170 case 4: { 171 const arr = []; 172 for (let i = 0; i < arg; i++) { let v; [v, pos] = decodeCbor(bytes, pos); arr.push(v); } 173 return [arr, pos]; 174 } 175 case 5: { 176 const obj = Object.create(null); 177 for (let i = 0; i < arg; i++) { 178 let k, v; 179 [k, pos] = decodeCbor(bytes, pos); 180 [v, pos] = decodeCbor(bytes, pos); 181 obj[k] = v; 182 } 183 return [obj, pos]; 184 } 185 case 6: return decodeCbor(bytes, pos); // tag — unwrap (tag 42 = CID link, we don't need the value) 186 case 7: 187 if (info === 20) return [false, pos]; 188 if (info === 21) return [true, pos]; 189 if (info === 22) return [null, pos]; 190 return [undefined, pos]; // floats: bytes already consumed above 191 default: return [undefined, pos]; 192 } 193} 194 195// ── URL type detection ────────────────────────────────────────────── 196function isProfileUrl(input) { 197 if (input.startsWith("at://")) return false; 198 return input.includes("/profile/") && !input.includes("/post/"); 199} 200 201async function resolveToDid(handleOrDid) { 202 if (handleOrDid.startsWith("did:")) return handleOrDid; 203 return resolveHandle(handleOrDid); 204} 205 206async function resolveProfileTarget(input) { 207 const match = input.match(/\/profile\/([^/?#]+)/); 208 if (!match) throw new Error(`can't parse profile URL: ${input}`); 209 return resolveToDid(match[1]); 210} 211 212// ── URL/URI parsing ───────────────────────────────────────────────── 213async function resolvePostUri(input) { 214 if (input.startsWith("at://")) return input; 215 216 const urlMatch = input.match(/\/profile\/([^/]+)\/post\/([^/?#]+)/); 217 if (!urlMatch) { 218 throw new Error( 219 `can't parse post URL: ${input}\nexpected format: https://<domain>/profile/<handle>/post/<rkey>` 220 ); 221 } 222 223 const [, handleOrDid, rkey] = urlMatch; 224 const did = await resolveToDid(handleOrDid); 225 return `at://${did}/app.bsky.feed.post/${rkey}`; 226} 227 228// ── constellation ─────────────────────────────────────────────────── 229async function fetchConstellationDids(target, collection, path, onProgress) { 230 const allDids = []; 231 let cursor; 232 233 while (true) { 234 const url = new URL(`${CONSTELLATION_BASE}/links/distinct-dids`); 235 url.searchParams.set("target", target); 236 url.searchParams.set("collection", collection); 237 url.searchParams.set("path", path); 238 url.searchParams.set("limit", String(PAGE_SIZE)); 239 if (cursor) url.searchParams.set("cursor", cursor); 240 241 const res = await fetch(url, { headers: { "user-agent": USER_AGENT } }); 242 if (!res.ok) throw new Error(`constellation error: ${res.status}`); 243 const data = await res.json(); 244 245 allDids.push(...data.linking_dids); 246 if (onProgress) onProgress(data.linking_dids.length); 247 if (!data.cursor || data.linking_dids.length === 0) break; 248 cursor = data.cursor; 249 } 250 251 return allDids; 252} 253 254const fetchReposters = (atUri, onProgress) => 255 fetchConstellationDids(atUri, "app.bsky.feed.repost", ".subject.uri", onProgress); 256 257const fetchLikers = (atUri, onProgress) => 258 fetchConstellationDids(atUri, "app.bsky.feed.like", ".subject.uri", onProgress); 259 260const fetchRepliers = (atUri, onProgress) => 261 fetchConstellationDids(atUri, "app.bsky.feed.post", ".reply.parent.uri", onProgress); 262 263const fetchQuotePosters = (atUri, onProgress) => 264 fetchConstellationDids(atUri, "app.bsky.feed.post", ".embed.record.uri", onProgress); 265 266function extractAuthorDid(atUri) { 267 return atUri.replace("at://", "").split("/")[0]; 268} 269 270// ── social graph ──────────────────────────────────────────────────── 271async function resolvePds(did) { 272 const doc = await resolveMiniDoc(did); 273 if (typeof doc?.pds !== "string") throw new Error(`no pds in slingshot response for ${did}`); 274 return doc.pds; 275} 276 277async function fetchFollowing(did, pdsUrl, onProgress) { 278 const allDids = []; 279 let cursor; 280 281 while (true) { 282 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`); 283 url.searchParams.set("repo", did); 284 url.searchParams.set("collection", "app.bsky.graph.follow"); 285 url.searchParams.set("limit", "100"); 286 if (cursor) url.searchParams.set("cursor", cursor); 287 288 const res = await fetch(url); 289 if (!res.ok) throw new Error(`listRecords error: ${res.status}`); 290 const data = await res.json(); 291 292 for (const rec of data.records) allDids.push(rec.value.subject); 293 if (onProgress) onProgress(data.records.length); 294 if (!data.cursor || data.records.length === 0) break; 295 cursor = data.cursor; 296 } 297 298 return new Set(allDids); 299} 300 301async function fetchFollowers(did, onProgress) { 302 const dids = await fetchConstellationDids(did, "app.bsky.graph.follow", ".subject", onProgress); 303 return new Set(dids); 304} 305 306// Fast-path extractor for app.bsky.graph.block records from raw dag-cbor bytes. 307// dag-cbor uses deterministic key ordering (length-first, then lex), so a block 308// record's keys always appear in order: "$type"(5) < "subject"(7) < "createdAt"(9). 309// That gives a fixed byte prefix we can check cheaply before touching the decoder. 310// 311// Layout from pos: 312// a3 map(3) 313// 65 tstr(5) 314// 24 74 79 70 65 "$type" 315// 74 tstr(20) 316// [20 bytes] "app.bsky.graph.block" 317// 67 tstr(7) 318// [7 bytes] "subject" 319// [tstr hdr] subject DID string 320const BLOCK_TYPE_BYTES = new TextEncoder().encode("app.bsky.graph.block"); 321const SUBJECT_KEY_BYTES = new TextEncoder().encode("subject"); 322 323function extractBlockSubject(bytes, pos) { 324 // map(3), tstr(5), "$type" 325 if (bytes[pos] !== 0xa3 || bytes[pos+1] !== 0x65) return null; 326 if (bytes[pos+2] !== 0x24 || bytes[pos+3] !== 0x74 || 327 bytes[pos+4] !== 0x79 || bytes[pos+5] !== 0x70 || bytes[pos+6] !== 0x65) return null; 328 // tstr(20) + "app.bsky.graph.block" 329 if (bytes[pos+7] !== 0x74) return null; 330 for (let i = 0; i < 20; i++) { 331 if (bytes[pos + 8 + i] !== BLOCK_TYPE_BYTES[i]) return null; 332 } 333 // tstr(7) + "subject" 334 let p = pos + 28; 335 if (bytes[p] !== 0x67) return null; 336 p++; 337 for (let i = 0; i < 7; i++) { 338 if (bytes[p + i] !== SUBJECT_KEY_BYTES[i]) return null; 339 } 340 p += 7; 341 // read the subject DID string 342 const hdr = bytes[p++]; 343 if ((hdr >> 5) !== 3) return null; // not a tstr 344 const info = hdr & 0x1f; 345 let len; 346 if (info <= 23) { len = info; } 347 else if (info === 24) { len = bytes[p++]; } 348 else if (info === 25) { len = (bytes[p] << 8) | bytes[p + 1]; p += 2; } 349 else return null; 350 return TEXT_DECODER.decode(bytes.slice(p, p + len)); 351} 352 353async function fetchExistingBlocks(did, pdsUrl) { 354 const res = await fetch( 355 `${pdsUrl}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`, 356 { headers: { "user-agent": USER_AGENT } } 357 ); 358 if (!res.ok) throw new Error(`getRepo error: ${res.status}`); 359 const bytes = new Uint8Array(await res.arrayBuffer()); 360 361 // Parse CARv1: skip header, then scan every dag-cbor block for block records 362 let pos = 0; 363 let headerLen; [headerLen, pos] = readVarint(bytes, pos); 364 pos += headerLen; 365 366 const blocked = new Set(); 367 while (pos < bytes.length) { 368 let sectionLen; [sectionLen, pos] = readVarint(bytes, pos); 369 const sectionEnd = pos + sectionLen; 370 371 // CIDv1: version + codec + multihash (fn + digestLen + digest) 372 let codec, digestLen; 373 [, pos] = readVarint(bytes, pos); // version 374 [codec, pos] = readVarint(bytes, pos); 375 [, pos] = readVarint(bytes, pos); // hash fn 376 [digestLen, pos] = readVarint(bytes, pos); 377 pos += digestLen; 378 379 if (codec === 0x71) { // dag-cbor 380 const subject = extractBlockSubject(bytes, pos); 381 if (subject !== null) blocked.add(subject); 382 } 383 384 pos = sectionEnd; 385 } 386 387 return blocked; 388} 389 390async function fetchBlockMap(did, pdsUrl, onProgress) { 391 const map = new Map(); 392 let cursor; 393 394 while (true) { 395 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`); 396 url.searchParams.set("repo", did); 397 url.searchParams.set("collection", "app.bsky.graph.block"); 398 url.searchParams.set("limit", "100"); 399 if (cursor) url.searchParams.set("cursor", cursor); 400 401 const res = await fetch(url); 402 if (!res.ok) throw new Error(`listRecords error: ${res.status}`); 403 const data = await res.json(); 404 405 for (const rec of data.records) { 406 const rkey = rec.uri.split("/").pop(); 407 map.set(rec.value.subject, rkey); 408 } 409 if (onProgress) onProgress(data.records.length); 410 if (!data.cursor || data.records.length === 0) break; 411 cursor = data.cursor; 412 } 413 414 return map; 415} 416 417async function fetchSocialGraph(did, onProgress) { 418 const pdsUrl = await resolvePds(did); 419 let total = 0; 420 const tick = onProgress ? (delta) => { total += delta; onProgress(total); } : undefined; 421 const [follows, followers, existingBlocks] = await Promise.all([ 422 fetchFollowing(did, pdsUrl, tick), 423 fetchFollowers(did, tick), 424 fetchExistingBlocks(did, pdsUrl), 425 ]); 426 return { follows, followers, existingBlocks }; 427} 428 429// ── count prefetch ────────────────────────────────────────────────── 430async function fetchProfile(actor) { 431 const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`); 432 if (!res.ok) return null; 433 return res.json(); 434} 435 436async function fetchPostCounts(atUri) { 437 const [postRes, profile] = await Promise.all([ 438 fetch(`https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=${encodeURIComponent(atUri)}`), 439 fetchProfile(extractAuthorDid(atUri)), 440 ]); 441 const counts = {}; 442 if (postRes.ok) { 443 const data = await postRes.json(); 444 const post = data.posts?.[0]; 445 if (post) { 446 counts.reposts = post.repostCount ?? 0; 447 counts.likes = post.likeCount ?? 0; 448 counts.replies = post.replyCount ?? 0; 449 } 450 } 451 if (profile) counts.followers = profile.followersCount ?? 0; 452 return counts; 453} 454 455async function fetchProfileCounts(did) { 456 const profile = await fetchProfile(did); 457 if (!profile) return {}; 458 return { 459 followers: profile.followersCount ?? 0, 460 following: profile.followsCount ?? 0, 461 }; 462} 463 464// ── profile resolution ────────────────────────────────────────────── 465async function resolveProfiles(dids) { 466 const batches = []; 467 for (let i = 0; i < dids.length; i += PROFILE_BATCH_SIZE) { 468 const params = dids.slice(i, i + PROFILE_BATCH_SIZE).map((d) => `actors=${encodeURIComponent(d)}`).join("&"); 469 batches.push( 470 fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${params}`) 471 .then((r) => r.ok ? r.json() : null) 472 .then((d) => d?.profiles ?? []) 473 .catch(() => []) 474 ); 475 } 476 return (await Promise.all(batches)).flat(); 477} 478 479// ── oauth scope ───────────────────────────────────────────────────── 480function getOAuthScope(unblock) { 481 const action = unblock ? "delete" : "create"; 482 return `atproto repo:app.bsky.graph.block?action=${action}`; 483} 484 485function scopeCovers(granted, required) { 486 if (!granted) return false; 487 const g = new Set(granted.trim().split(/\s+/)); 488 return required.trim().split(/\s+/).every((tok) => g.has(tok)); 489} 490 491// ── persistent session store ──────────────────────────────────────── 492function getSessionStorePath() { 493 const base = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"); 494 return join(base, "mbr", "sessions.json"); 495} 496 497class FileStore { 498 #path; 499 #cache; 500 501 constructor(path) { 502 this.#path = path; 503 } 504 505 async #load() { 506 if (this.#cache) return this.#cache; 507 try { 508 const data = await readFile(this.#path, "utf8"); 509 this.#cache = new Map(Object.entries(JSON.parse(data))); 510 } catch (err) { 511 if (err.code !== "ENOENT") throw err; 512 this.#cache = new Map(); 513 } 514 return this.#cache; 515 } 516 517 async #flush() { 518 await mkdir(dirname(this.#path), { recursive: true }); 519 await writeFile(this.#path, JSON.stringify(Object.fromEntries(this.#cache)), { mode: 0o600 }); 520 } 521 522 async get(key) { 523 return (await this.#load()).get(key); 524 } 525 526 async set(key, value) { 527 (await this.#load()).set(key, value); 528 await this.#flush(); 529 } 530 531 async delete(key) { 532 (await this.#load()).delete(key); 533 await this.#flush(); 534 } 535 536 async clear() { 537 this.#cache = new Map(); 538 await this.#flush(); 539 } 540} 541 542const sessionStore = new FileStore(getSessionStorePath()); 543 544// ── callback server ───────────────────────────────────────────────── 545async function startCallbackServer() { 546 let resolveSession, rejectSession; 547 const sessionPromise = new Promise((resolve, reject) => { 548 resolveSession = resolve; 549 rejectSession = reject; 550 }); 551 552 const ctx = { oauthClient: null }; 553 554 const handler = async (req) => { 555 const url = new URL(req.url); 556 if (url.pathname !== "/callback") { 557 return new Response("not found", { status: 404 }); 558 } 559 try { 560 const { session } = await ctx.oauthClient.callback(url.searchParams); 561 resolveSession(session); 562 return new Response("<h2>authenticated! you can close this tab.</h2>", { 563 headers: { "content-type": "text/html" }, 564 }); 565 } catch (err) { 566 rejectSession(err); 567 return new Response(`oauth error: ${err.message}`, { status: 500 }); 568 } 569 }; 570 571 let server; 572 573 if (IS_BUN) { 574 server = Bun.serve({ port: PORT, hostname: "127.0.0.1", fetch: handler }); 575 } else { 576 const { createServer } = await import("node:http"); 577 server = createServer(async (req, res) => { 578 const fakeReq = new Request(`http://127.0.0.1:${PORT}${req.url}`); 579 const response = await handler(fakeReq); 580 res.writeHead(response.status, { 581 "content-type": response.headers.get("content-type") || "text/plain", 582 }); 583 res.end(await response.text()); 584 }); 585 await new Promise((r) => server.listen(PORT, "127.0.0.1", r)); 586 } 587 588 const close = () => { 589 if (IS_BUN) server.stop(); 590 else server.close(); 591 }; 592 593 return { sessionPromise, close, ctx }; 594} 595 596// ── oauth ─────────────────────────────────────────────────────────── 597async function authenticate({ handle, did, scope }) { 598 const oauthClient = new OAuthClient({ 599 metadata: { 600 redirect_uris: [REDIRECT_URI], 601 scope, 602 }, 603 actorResolver: { 604 resolve: async (actor) => { 605 const doc = await resolveMiniDoc(actor); 606 return { did: doc.did, handle: doc.handle, pds: doc.pds }; 607 }, 608 }, 609 stores: { 610 sessions: sessionStore, 611 states: new MemoryStore({ ttl: 600_000 }), 612 }, 613 }); 614 615 if (did) { 616 const stored = await sessionStore.get(did); 617 if (stored && scopeCovers(stored.tokenSet?.scope, scope)) { 618 try { 619 const session = await oauthClient.restore(did); 620 p.log.info(`restored cached session for ${session.did}`); 621 return session; 622 } catch { 623 // cached session unusable (expired/revoked) — fall through to full flow 624 } 625 } 626 } 627 628 const { sessionPromise, close, ctx } = await startCallbackServer(); 629 ctx.oauthClient = oauthClient; 630 631 let authUrl; 632 try { 633 const { url } = await oauthClient.authorize({ 634 target: { type: "account", identifier: handle }, 635 scope, 636 }); 637 authUrl = url.toString(); 638 639 // Fire-and-forget: errors suppressed (fails silently in SSH/WSL) 640 const { execFile } = await import("node:child_process"); 641 const cmd = 642 process.platform === "darwin" ? "open" : 643 process.platform === "win32" ? "start" : 644 "xdg-open"; 645 execFile(cmd, [authUrl], () => {}); 646 } catch (err) { 647 close(); 648 throw new Error(`failed to start oauth flow: ${err.message}`); 649 } 650 651 p.log.info(`open this URL in your browser:\n ${color.cyan(authUrl)}`); 652 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`); 653 654 // Race: server callback vs manual paste 655 let serverWon = false; 656 let serverError = null; 657 658 // When server callback arrives, dismiss the paste prompt via a synthetic keypress. 659 // On server-side OAuth failure, surface the error and cancel the prompt. 660 sessionPromise.then(() => { 661 serverWon = true; 662 process.stdin.emit("keypress", "\r", { name: "return", ctrl: false, meta: false, shift: false }); 663 }).catch((err) => { 664 serverError = err; 665 p.log.error(`browser callback failed: ${err.message}`); 666 process.stdin.emit("keypress", "\x03", { name: "c", ctrl: true, meta: false, shift: false }); 667 }); 668 669 const pastePrompt = new Prompt({ 670 validate(value) { 671 if (serverWon) return; // server resolved -- accept empty input to dismiss 672 if (!value) return "waiting for browser redirect... or paste the full callback URL"; 673 try { 674 const u = new URL(value); 675 if (!u.searchParams.has("code")) return "paste the full redirect URL from your browser (must include ?code=...)"; 676 } catch { 677 return "not a valid URL"; 678 } 679 }, 680 render() { 681 const prefix = color.gray("│"); 682 switch (this.state) { 683 case "submit": 684 return `${color.gray("◇")} ${serverWon ? "authenticated via browser" : "callback URL received"}`; 685 case "error": 686 return `${color.yellow("▲")} waiting for OAuth\n${prefix} ${this.value || ""}\n${prefix} ${color.yellow(this.error)}`; 687 default: 688 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=...")}`; 689 } 690 }, 691 }); 692 693 const pastedInput = await pastePrompt.prompt(); 694 695 if (p.isCancel(pastedInput)) { 696 close(); 697 if (serverError) throw serverError; // propagate server-side OAuth failure 698 p.cancel("cancelled."); 699 process.exit(0); 700 } 701 702 if (serverWon || !pastedInput) { 703 // Browser callback completed 704 const session = await sessionPromise; 705 close(); 706 return session; 707 } 708 709 // User pasted the callback URL manually 710 close(); 711 const params = new URL(pastedInput).searchParams; 712 const { session } = await oauthClient.callback(params); 713 return session; 714} 715 716// ── custom inline multi-select ────────────────────────────────────── 717function inlineMultiSelect({ message, options }) { 718 let cursor = 0; 719 const selected = new Set(); 720 721 const prompt = new Prompt({ 722 validate(value) { 723 if (!value || value.size === 0) return "select at least one option"; 724 }, 725 render() { 726 const prefix = color.gray("│"); 727 728 const items = options 729 .map((opt, i) => { 730 const check = selected.has(opt.value) 731 ? color.green("◼") 732 : color.dim("◻"); 733 const label = 734 i === cursor 735 ? color.cyan(`${check} ${color.underline(opt.label)}`) 736 : `${check} ${opt.label}`; 737 return label; 738 }) 739 .join(" "); 740 741 switch (this.state) { 742 case "submit": 743 return `${color.gray("◇")} ${message}\n${prefix} ${color.dim([...selected].join(", "))}`; 744 case "cancel": 745 return `${color.gray("◇")} ${message}\n${prefix} ${color.strikethrough(color.dim("cancelled"))}`; 746 case "error": 747 return `${color.yellow("▲")} ${message}\n${prefix} ${items}\n${prefix} ${color.yellow(this.error)}`; 748 default: 749 return `${color.cyan("◆")} ${message}\n${prefix} ${items}\n${prefix} ${color.dim("← → move · space toggle · enter confirm")}`; 750 } 751 }, 752 }); 753 754 prompt.on("cursor", (key) => { 755 if (key === "right") { 756 cursor = (cursor + 1) % options.length; 757 } else if (key === "left") { 758 cursor = (cursor - 1 + options.length) % options.length; 759 } else if (key === "space") { 760 const val = options[cursor].value; 761 if (selected.has(val)) selected.delete(val); 762 else selected.add(val); 763 } 764 }); 765 766 prompt.value = selected; 767 768 return prompt.prompt(); 769} 770 771// ── interactive flow ──────────────────────────────────────────────── 772const PROFILE_LABELS = { followers: "their followers", following: "their following" }; 773 774async function runInteractiveFlow() { 775 p.intro(color.inverse(" bluesky blocker ")); 776 777 const url = exitIfCancelled(await p.text({ 778 message: "paste a post URL, profile URL, or at:// URI", 779 placeholder: "https://bsky.app/profile/someone.bsky.social/post/abc123", 780 validate: (v) => { 781 if (!v) return "url is required"; 782 if (v.startsWith("at://")) return; 783 if (v.includes("/post/")) return; 784 if (v.includes("/profile/")) return; 785 return "paste a post URL (…/post/…), a profile URL (…/profile/…), or an at:// URI"; 786 }, 787 })); 788 789 const mode = isProfileUrl(url) ? "profile" : "post"; 790 791 // Resolve target and prefetch counts before showing category selector 792 const s = p.spinner(); 793 s.start(mode === "post" ? "resolving post..." : "resolving profile..."); 794 let target, counts; 795 try { 796 target = mode === "post" ? await resolvePostUri(url) : await resolveProfileTarget(url); 797 counts = mode === "post" ? await fetchPostCounts(target) : await fetchProfileCounts(target); 798 s.stop(`target: ${target}`); 799 } catch (err) { 800 s.stop("failed to resolve"); 801 throw err; 802 } 803 804 const categoryList = mode === "post" ? POST_CATEGORIES : PROFILE_CATEGORIES; 805 const categories = exitIfCancelled(await inlineMultiSelect({ 806 message: "what do you want to block?", 807 options: categoryList.map((c) => { 808 const base = mode === "profile" ? (PROFILE_LABELS[c] ?? c) : c; 809 const n = counts?.[c]; 810 return { value: c, label: n !== undefined ? `${base} (${n.toLocaleString()})` : base }; 811 }), 812 })); 813 814 const blockFollowing = exitIfCancelled(await p.confirm({ 815 message: "include people you're following in blocks?", 816 initialValue: false, 817 })); 818 819 const handle = exitIfCancelled(await p.text({ 820 message: "enter your bluesky handle", 821 placeholder: "you.bsky.social", 822 validate: (v) => { if (!v) return "handle is required"; }, 823 })); 824 825 return { url, mode, target, categories, blockFollowing, handle }; 826} 827 828// ── data fetching ─────────────────────────────────────────────────── 829async function fetchEngagementData(target, categories, mode, onProgress) { 830 const results = { reposts: [], likes: [], replies: [], quotes: [], followers: [], following: [], quotePosters: [] }; 831 const fetchers = []; 832 let total = 0; 833 const tick = onProgress ? (delta) => { total += delta; onProgress(total); } : undefined; 834 835 if (mode === "profile") { 836 // target is a DID 837 if (categories.has("followers")) { 838 fetchers.push(fetchFollowers(target, tick).then((d) => { results.followers = [...d]; })); 839 } 840 if (categories.has("following")) { 841 fetchers.push( 842 resolvePds(target).then((pdsUrl) => 843 fetchFollowing(target, pdsUrl, tick).then((d) => { results.following = [...d]; }) 844 ) 845 ); 846 } 847 } else { 848 // post mode: target is an at:// URI 849 if (categories.has("reposts")) { 850 fetchers.push(fetchReposters(target, tick).then((d) => { results.reposts = d; })); 851 } 852 if (categories.has("likes")) { 853 fetchers.push(fetchLikers(target, tick).then((d) => { results.likes = d; })); 854 } 855 if (categories.has("replies")) { 856 fetchers.push(fetchRepliers(target, tick).then((d) => { results.replies = d; })); 857 } 858 if (categories.has("followers")) { 859 const authorDid = extractAuthorDid(target); 860 fetchers.push(fetchFollowers(authorDid, tick).then((d) => { results.followers = [...d]; })); 861 } 862 fetchers.push(fetchQuotePosters(target, tick).then((d) => { 863 results.quotePosters = d; 864 if (categories.has("quotes")) results.quotes = d; 865 })); 866 } 867 868 await Promise.all(fetchers); 869 return results; 870} 871 872// ── filtering ─────────────────────────────────────────────────────── 873function filterCandidates({ results, did, targetDid, follows, followers, existingBlocks, blockFollowing, blockTarget = true, blockQuotes = false }) { 874 const allCandidates = new Set(); 875 for (const src of [results.reposts, results.likes, results.replies, results.quotes, results.followers, results.following]) { 876 for (const d of src) allCandidates.add(d); 877 } 878 879 const quotePosters = blockQuotes ? new Set() : new Set(results.quotePosters); 880 const followedInBlockList = []; 881 const toBlock = []; 882 let skippedSelf = 0, skippedQuote = 0, skippedFollow = 0, skippedFollower = 0, skippedAlreadyBlocked = 0; 883 884 // Block the target themselves first, unless skipped or already blocked 885 if (blockTarget && targetDid && targetDid !== did && !existingBlocks.has(targetDid)) { 886 toBlock.push(targetDid); 887 } 888 889 for (const d of allCandidates) { 890 if (d === did) { skippedSelf++; continue; } 891 if (d === targetDid) continue; // already added above 892 if (existingBlocks.has(d)) { skippedAlreadyBlocked++; continue; } 893 if (quotePosters.has(d)) { skippedQuote++; continue; } 894 if (follows.has(d)) { 895 if (!blockFollowing) { 896 skippedFollow++; 897 continue; 898 } else { 899 followedInBlockList.push(d); 900 } 901 } 902 if (followers.has(d)) { skippedFollower++; continue; } 903 toBlock.push(d); 904 } 905 906 return { toBlock, followedInBlockList, skippedSelf, skippedQuote, skippedFollow, skippedFollower, skippedAlreadyBlocked, total: allCandidates.size }; 907} 908 909// ── summary display ───────────────────────────────────────────────── 910async function showSummary(results, filterResult, categories, mode) { 911 const lines = []; 912 913 if (mode === "post") { 914 if (categories.has("reposts")) lines.push(` reposters: ${results.reposts.length}`); 915 if (categories.has("likes")) lines.push(` likers: ${results.likes.length}`); 916 if (categories.has("replies")) lines.push(` repliers: ${results.replies.length}`); 917 if (categories.has("followers")) lines.push(` author followers: ${results.followers.length}`); 918 if (categories.has("quotes")) lines.push(` quote posters: ${results.quotes.length}`); 919 else lines.push(` quote posters (excluded): ${results.quotePosters.length}`); 920 } else { 921 if (categories.has("followers")) lines.push(` followers: ${results.followers.length}`); 922 if (categories.has("following")) lines.push(` following: ${results.following.length}`); 923 } 924 925 lines.push(""); 926 lines.push(` unique candidates: ${filterResult.total}`); 927 if (filterResult.skippedSelf) lines.push(` - ${filterResult.skippedSelf} (self)`); 928 if (filterResult.skippedQuote) lines.push(` - ${filterResult.skippedQuote} (quote posters)`); 929 if (filterResult.skippedFollow) lines.push(` - ${filterResult.skippedFollow} (people you follow)`); 930 if (filterResult.skippedFollower) lines.push(` - ${filterResult.skippedFollower} (your followers)`); 931 if (filterResult.skippedAlreadyBlocked) lines.push(` - ${filterResult.skippedAlreadyBlocked} (already blocked)`); 932 lines.push(` = ${color.bold(String(filterResult.toBlock.length))} to block`); 933 934 p.note(lines.join("\n"), "summary"); 935 936 if (filterResult.followedInBlockList.length > 0) { 937 const s = p.spinner(); 938 s.start("resolving profiles of followed users..."); 939 const profiles = await resolveProfiles(filterResult.followedInBlockList); 940 s.stop("profiles resolved"); 941 942 if (profiles.length === 0) { 943 p.log.warn(`${color.yellow("you follow")} ${filterResult.followedInBlockList.length} ${color.yellow("accounts that will be blocked")} ${color.dim("(profile resolution unavailable)")}`); 944 } else { 945 const warningLines = profiles.map((pr) => 946 ` ${color.cyan(`@${pr.handle}`)} ${color.dim(`(${pr.displayName || "no display name"})`)}` 947 ); 948 p.log.warn( 949 `${color.yellow("you follow these accounts and they will be blocked:")}\n${warningLines.join("\n")}` 950 ); 951 } 952 } 953} 954 955// ── rate limit handling ────────────────────────────────────────────── 956function getBackoffDelay(res, defaultDelay) { 957 if (!res?.headers) return defaultDelay; 958 959 const remaining = parseInt(res.headers.get("ratelimit-remaining"), 10); 960 const reset = parseInt(res.headers.get("ratelimit-reset"), 10); 961 962 if (isNaN(remaining) || isNaN(reset)) return defaultDelay; 963 964 if (remaining <= 0) { 965 const waitMs = Math.max(0, (reset * 1000) - Date.now()) + 1000; 966 return waitMs; 967 } 968 969 const limit = parseInt(res.headers.get("ratelimit-limit"), 10); 970 if (!isNaN(limit) && limit > 0) { 971 const ratio = remaining / limit; 972 if (ratio < 0.2) { 973 const scale = 1 + (5 * (1 - ratio / 0.2)); 974 return Math.ceil(defaultDelay * scale); 975 } 976 } 977 978 return defaultDelay; 979} 980 981async function sleepForRateLimit(res, spinner) { 982 if (res?.status === 429) { 983 const wait = getBackoffDelay(res, 60_000); 984 spinner.message(`rate limited, waiting ${Math.ceil(wait / 1000)}s...`); 985 await sleep(wait); 986 return true; 987 } 988 return false; 989} 990 991// ── blocking ──────────────────────────────────────────────────────── 992async function createSingleBlock(session, did, targetDid) { 993 return session.handle("/xrpc/com.atproto.repo.createRecord", { 994 method: "POST", 995 headers: { "content-type": "application/json" }, 996 body: JSON.stringify({ 997 repo: did, 998 collection: "app.bsky.graph.block", 999 record: makeBlockRecord(targetDid, new Date().toISOString()), 1000 }), 1001 }); 1002} 1003 1004async function confirmAndBlock({ toBlock, handle, did, delayMs, batchSize }) { 1005 const proceed = exitIfCancelled(await p.confirm({ 1006 message: `block ${toBlock.length} accounts?`, 1007 initialValue: false, 1008 })); 1009 if (!proceed) { 1010 p.cancel("cancelled."); 1011 process.exit(0); 1012 } 1013 1014 const authSpinner = p.spinner(); 1015 authSpinner.start("authenticating via oauth..."); 1016 const session = await authenticate({ handle, did, scope: getOAuthScope(false) }); 1017 authSpinner.stop(`authenticated as ${session.did}`); 1018 1019 const confirmAuth = exitIfCancelled(await p.confirm({ 1020 message: `proceed as ${session.did}?`, 1021 initialValue: true, 1022 })); 1023 if (!confirmAuth) { 1024 p.cancel("cancelled."); 1025 process.exit(0); 1026 } 1027 1028 const blockSpinner = p.spinner(); 1029 blockSpinner.start(`blocking 0/${toBlock.length} (batch size ${batchSize})...`); 1030 1031 let blocked = 0, alreadyBlocked = 0, errors = 0; 1032 let errorCount = 0; 1033 1034 function logError(msg) { 1035 errorCount++; 1036 if (errorCount <= 5) p.log.error(msg); 1037 } 1038 1039 for (let i = 0; i < toBlock.length; i += batchSize) { 1040 const batch = toBlock.slice(i, i + batchSize); 1041 const now = new Date().toISOString(); 1042 1043 const writes = batch.map((targetDid) => ({ 1044 $type: "com.atproto.repo.applyWrites#create", 1045 collection: "app.bsky.graph.block", 1046 rkey: generateTid(), 1047 value: makeBlockRecord(targetDid, now), 1048 })); 1049 1050 let lastRes = null; 1051 1052 try { 1053 const res = await session.handle("/xrpc/com.atproto.repo.applyWrites", { 1054 method: "POST", 1055 headers: { "content-type": "application/json" }, 1056 body: JSON.stringify({ repo: did, writes }), 1057 }); 1058 lastRes = res; 1059 1060 if (await sleepForRateLimit(res, blockSpinner)) { 1061 i -= batchSize; 1062 continue; 1063 } 1064 1065 if (!res.ok) { 1066 const msg = await parseApiError(res); 1067 if (isDuplicateError(msg)) { 1068 // batch rejected for duplicates — fall back to individual creates 1069 const fallbackResults = await Promise.allSettled( 1070 batch.map((targetDid) => createSingleBlock(session, did, targetDid)) 1071 ); 1072 for (let j = 0; j < fallbackResults.length; j++) { 1073 const result = fallbackResults[j]; 1074 if (result.status === "rejected") { 1075 errors++; 1076 logError(`error blocking ${batch[j]}: ${result.reason?.message ?? result.reason}`); 1077 continue; 1078 } 1079 const r = result.value; 1080 lastRes = r; 1081 if (r.status === 429) { 1082 // 429 on individual — retry this one 1083 await sleepForRateLimit(r, blockSpinner); 1084 try { 1085 const retry = await createSingleBlock(session, did, batch[j]); 1086 if (retry.ok) blocked++; 1087 else { 1088 const m = await parseApiError(retry); 1089 if (isDuplicateError(m)) alreadyBlocked++; 1090 else { errors++; logError(`error blocking ${batch[j]}: ${m}`); } 1091 } 1092 } catch (err) { 1093 errors++; 1094 logError(`error blocking ${batch[j]}: ${err?.message ?? err}`); 1095 } 1096 } else if (!r.ok) { 1097 const m = await parseApiError(r); 1098 if (isDuplicateError(m)) alreadyBlocked++; 1099 else { errors++; logError(`error blocking ${batch[j]}: ${m}`); } 1100 } else { 1101 blocked++; 1102 } 1103 } 1104 } else { 1105 errors += batch.length; 1106 logError(`batch error: ${msg}`); 1107 } 1108 } else { 1109 blocked += batch.length; 1110 } 1111 } catch (err) { 1112 errors += batch.length; 1113 logError(`batch error: ${err?.message ?? err}`); 1114 } 1115 1116 const total = blocked + alreadyBlocked + errors; 1117 blockSpinner.message(`blocking ${total}/${toBlock.length}...`); 1118 if (i + batchSize < toBlock.length) await sleep(getBackoffDelay(lastRes, delayMs)); 1119 } 1120 1121 blockSpinner.stop("blocking complete"); 1122 1123 p.note( 1124 ` blocked: ${blocked}\n already blocked: ${alreadyBlocked}\n errors: ${errors}`, 1125 "results" 1126 ); 1127 p.outro("done!"); 1128} 1129 1130async function confirmAndUnblock({ toUnblock, blockMap, handle, did, delayMs, batchSize }) { 1131 const proceed = exitIfCancelled(await p.confirm({ 1132 message: `unblock ${toUnblock.length} accounts?`, 1133 initialValue: false, 1134 })); 1135 if (!proceed) { 1136 p.cancel("cancelled."); 1137 process.exit(0); 1138 } 1139 1140 const authSpinner = p.spinner(); 1141 authSpinner.start("authenticating via oauth..."); 1142 const session = await authenticate({ handle, did, scope: getOAuthScope(true) }); 1143 authSpinner.stop(`authenticated as ${session.did}`); 1144 1145 const confirmAuth = exitIfCancelled(await p.confirm({ 1146 message: `proceed as ${session.did}?`, 1147 initialValue: true, 1148 })); 1149 if (!confirmAuth) { 1150 p.cancel("cancelled."); 1151 process.exit(0); 1152 } 1153 1154 const unblockSpinner = p.spinner(); 1155 unblockSpinner.start(`unblocking 0/${toUnblock.length}...`); 1156 1157 let unblocked = 0, notBlocked = 0, errors = 0; 1158 let errorCount = 0; 1159 function logError(msg) { errorCount++; if (errorCount <= 5) p.log.error(msg); } 1160 1161 for (let i = 0; i < toUnblock.length; i += batchSize) { 1162 const batch = toUnblock.slice(i, i + batchSize); 1163 1164 const writes = batch.flatMap((targetDid) => { 1165 const rkey = blockMap.get(targetDid); 1166 if (!rkey) { notBlocked++; return []; } 1167 return [{ $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.block", rkey }]; 1168 }); 1169 1170 if (writes.length === 0) continue; 1171 1172 let lastRes = null; 1173 try { 1174 const res = await session.handle("/xrpc/com.atproto.repo.applyWrites", { 1175 method: "POST", 1176 headers: { "content-type": "application/json" }, 1177 body: JSON.stringify({ repo: did, writes }), 1178 }); 1179 lastRes = res; 1180 1181 if (await sleepForRateLimit(res, unblockSpinner)) { i -= batchSize; continue; } 1182 1183 if (!res.ok) { 1184 const msg = await parseApiError(res); 1185 errors += writes.length; 1186 logError(`batch error: ${msg}`); 1187 } else { 1188 unblocked += writes.length; 1189 } 1190 } catch (err) { 1191 errors += writes.length; 1192 logError(`batch error: ${err?.message ?? err}`); 1193 } 1194 1195 unblockSpinner.message(`unblocking ${unblocked + notBlocked + errors}/${toUnblock.length}...`); 1196 if (i + batchSize < toUnblock.length) await sleep(getBackoffDelay(lastRes, delayMs)); 1197 } 1198 1199 unblockSpinner.stop("unblocking complete"); 1200 p.note(` unblocked: ${unblocked}\n not blocked: ${notBlocked}\n errors: ${errors}`, "results"); 1201 p.outro("done!"); 1202} 1203 1204// ── main ──────────────────────────────────────────────────────────── 1205async function main() { 1206 const { flags } = parseArgs(); 1207 if (flags.help) { printUsage(); process.exit(0); } 1208 const delayMs = flags.delay ?? BLOCK_DELAY_MS; 1209 const batchSize = flags.batch ?? BATCH_SIZE; 1210 1211 const config = await runInteractiveFlow(); 1212 USER_AGENT = `mbr/1.0 (@${config.handle}; ${AUTHOR_CONTACT})`; 1213 1214 const { target } = config; 1215 1216 const s2 = p.spinner(); 1217 s2.start("fetching data & resolving identity..."); 1218 const [results, did] = await Promise.all([ 1219 fetchEngagementData(target, config.categories, config.mode, (n) => { 1220 s2.message(`fetching data & resolving identity... (${n.toLocaleString()} records)`); 1221 }), 1222 resolveHandle(config.handle), 1223 ]); 1224 s2.stop("data fetched"); 1225 1226 let filterResult, blockMap; 1227 1228 if (flags.unblock) { 1229 const pdsUrl = await resolvePds(did); 1230 const s3 = p.spinner(); 1231 s3.start("fetching your existing blocks..."); 1232 let blockCount = 0; 1233 blockMap = await fetchBlockMap(did, pdsUrl, (n) => { 1234 blockCount += n; 1235 s3.message(`fetching your existing blocks... (${blockCount.toLocaleString()} records)`); 1236 }); 1237 s3.stop(`existing blocks: ${blockMap.size}`); 1238 1239 const targetDid = config.mode === "post" ? extractAuthorDid(target) : target; 1240 const allEngaged = new Set([ 1241 ...results.reposts, ...results.likes, ...results.replies, 1242 ...results.quotes, ...results.followers, ...results.following, 1243 ]); 1244 if (!flags.noBlockTarget && targetDid !== did) allEngaged.add(targetDid); 1245 const toUnblock = [...allEngaged].filter((d) => blockMap.has(d)); 1246 filterResult = { toBlock: toUnblock, followedInBlockList: [], skippedSelf: 0, skippedQuote: 0, skippedFollow: 0, skippedFollower: 0, skippedAlreadyBlocked: 0, total: allEngaged.size }; 1247 } else { 1248 const s3 = p.spinner(); 1249 s3.start("fetching your social graph & existing blocks..."); 1250 const { follows, followers, existingBlocks } = await fetchSocialGraph(did, (n) => { 1251 s3.message(`fetching your social graph & existing blocks... (${n.toLocaleString()} records)`); 1252 }); 1253 s3.stop(`following: ${follows.size}, followers: ${followers.size}, existing blocks: ${existingBlocks.size}`); 1254 1255 const targetDid = config.mode === "post" ? extractAuthorDid(target) : target; 1256 filterResult = filterCandidates({ 1257 results, did, targetDid, follows, followers, existingBlocks, 1258 blockFollowing: config.blockFollowing, 1259 blockTarget: !flags.noBlockTarget, 1260 blockQuotes: config.categories.has("quotes"), 1261 }); 1262 } 1263 1264 if (flags.unblock) { 1265 p.note(` to unblock: ${color.bold(String(filterResult.toBlock.length))}`, "summary"); 1266 } else { 1267 await showSummary(results, filterResult, config.categories, config.mode); 1268 } 1269 1270 if (flags.output) { 1271 await writeOutput(flags.output, filterResult.toBlock); 1272 p.log.info(`wrote ${filterResult.toBlock.length} DIDs to ${flags.output}`); 1273 } 1274 1275 if (flags.dryRun) { 1276 p.outro(flags.unblock ? "dry run — nothing unblocked." : "dry run — nothing blocked."); 1277 process.exit(0); 1278 } 1279 1280 if (filterResult.toBlock.length === 0) { 1281 p.outro(flags.unblock ? "nothing to unblock." : "nothing to block after filtering."); 1282 process.exit(0); 1283 } 1284 1285 if (flags.unblock) { 1286 await confirmAndUnblock({ toUnblock: filterResult.toBlock, blockMap, handle: config.handle, did, delayMs, batchSize }); 1287 } else { 1288 await confirmAndBlock({ toBlock: filterResult.toBlock, handle: config.handle, did, delayMs, batchSize }); 1289 } 1290 process.exit(0); 1291} 1292 1293// Only run when executed directly (not imported by tests) 1294const __isMain = IS_BUN 1295 ? import.meta.main 1296 : fileURLToPath(import.meta.url) === process.argv[1]; 1297 1298if (__isMain) { 1299 main().catch((err) => { 1300 p.cancel(`fatal: ${err.message}`); 1301 process.exit(1); 1302 }); 1303} 1304 1305export { isProfileUrl, resolveMiniDoc, filterCandidates, writeOutput };