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

Configure Feed

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

initial

Winter 6bdfa564

+839
+61
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "block-reposters", 7 + "dependencies": { 8 + "@atcute/identity-resolver": "^1.2.2", 9 + "@atcute/identity-resolver-node": "^1.0.3", 10 + "@atcute/oauth-node-client": "^1.1.0", 11 + "@clack/core": "^0.4.1", 12 + "@clack/prompts": "^0.10.0", 13 + }, 14 + }, 15 + }, 16 + "packages": { 17 + "@atcute/client": ["@atcute/client@4.2.1", "", { "dependencies": { "@atcute/identity": "^1.1.3", "@atcute/lexicons": "^1.2.6" } }, "sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw=="], 18 + 19 + "@atcute/identity": ["@atcute/identity@1.1.4", "", { "dependencies": { "@atcute/lexicons": "^1.2.9", "@badrap/valita": "^0.4.6" } }, "sha512-RCw1IqflfuSYCxK5m0lZCm0UnvIzcUnuhngiBhJEJb9a9Mc2SEf1xP3H8N5r8pvEH1LoAYd6/zrvCNU+uy9esw=="], 20 + 21 + "@atcute/identity-resolver": ["@atcute/identity-resolver@1.2.2", "", { "dependencies": { "@atcute/lexicons": "^1.2.6", "@atcute/util-fetch": "^1.0.5", "@badrap/valita": "^0.4.6" }, "peerDependencies": { "@atcute/identity": "^1.0.0" } }, "sha512-eUh/UH4bFvuXS0X7epYCeJC/kj4rbBXfSRumLEH4smMVwNOgTo7cL/0Srty+P/qVPoZEyXdfEbS0PHJyzoXmHw=="], 22 + 23 + "@atcute/identity-resolver-node": ["@atcute/identity-resolver-node@1.0.3", "", { "dependencies": { "@atcute/lexicons": "^1.2.2" }, "peerDependencies": { "@atcute/identity": "^1.0.0", "@atcute/identity-resolver": "^1.0.0" } }, "sha512-RPH5M4ZRayKRcGnJWUOPVhN5WSYURXXZxKzgVT9lj/WZCH6ij2Vg3P3Eva7GGs0SG1ytnX1XVBTMoIk8nF/SLQ=="], 24 + 25 + "@atcute/lexicons": ["@atcute/lexicons@1.3.0", "", { "dependencies": { "@atcute/uint8array": "^1.1.1", "@atcute/util-text": "^1.2.0", "@standard-schema/spec": "^1.1.0", "esm-env": "^1.2.2" } }, "sha512-Eq5y+9onnCXNVUlNiMf31beSXHKqptB7lUo/68YbhlmxdaR7ooywHmahya9goP5AsmlYEA1z+dRPXIDAa9O7cg=="], 26 + 27 + "@atcute/multibase": ["@atcute/multibase@1.2.0", "", { "dependencies": { "@atcute/uint8array": "^1.1.1" } }, "sha512-ZK2GRra+qIYq9nNuQB52m2ul0hOmCQEtPobGfTSUxm7pF0OGEkWGkWHugFhNEDVzHzTwPxHp6VGotdZFue4lYQ=="], 28 + 29 + "@atcute/oauth-crypto": ["@atcute/oauth-crypto@0.1.0", "", { "dependencies": { "@atcute/multibase": "^1.1.7", "@atcute/uint8array": "^1.1.0", "@badrap/valita": "^0.4.6", "nanoid": "^5.1.6" } }, "sha512-qZYDCNLF/4B6AndYT1rsQelN8621AC5u/sL5PHvlr/qqAbmmUwCBGjEgRSyZtHE1AqD60VNiSMlOgAuEQTSl3w=="], 30 + 31 + "@atcute/oauth-keyset": ["@atcute/oauth-keyset@0.1.0", "", { "dependencies": { "@atcute/oauth-crypto": "^0.1.0" } }, "sha512-+wqT/+I5Lg9VzKnKY3g88+N45xbq+wsdT6bHDGqCVa2u57gRvolFF4dY+weMfc/OX641BIZO6/o+zFtKBsMQnQ=="], 32 + 33 + "@atcute/oauth-node-client": ["@atcute/oauth-node-client@1.1.0", "", { "dependencies": { "@atcute/client": "^4.2.1", "@atcute/identity": "^1.1.3", "@atcute/identity-resolver": "^1.2.2", "@atcute/lexicons": "^1.2.7", "@atcute/oauth-crypto": "^0.1.0", "@atcute/oauth-keyset": "^0.1.0", "@atcute/oauth-types": "^0.1.1", "@atcute/util-fetch": "^1.0.5", "@badrap/valita": "^0.4.6", "nanoid": "^5.1.6" } }, "sha512-xCp/VfjtvTeKscKR/oI2hdMTp1/DaF/7ll8b6yZOCgbKlVDDfhCn5mmKNVARGTNaoywxrXG3XffbWCIx3/E87w=="], 34 + 35 + "@atcute/oauth-types": ["@atcute/oauth-types@0.1.1", "", { "dependencies": { "@atcute/identity": "^1.1.3", "@atcute/lexicons": "^1.2.7", "@atcute/oauth-keyset": "^0.1.0", "@badrap/valita": "^0.4.6" } }, "sha512-u+3KMjse3Uc/9hDyilu1QVN7IpcnjVXgRzhddzBB8Uh6wePHNVBDdi9wQvFTVVA3zmxtMJVptXRyLLg6Ou9bqg=="], 36 + 37 + "@atcute/uint8array": ["@atcute/uint8array@1.1.1", "", {}, "sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g=="], 38 + 39 + "@atcute/util-fetch": ["@atcute/util-fetch@1.0.5", "", { "dependencies": { "@badrap/valita": "^0.4.6" } }, "sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig=="], 40 + 41 + "@atcute/util-text": ["@atcute/util-text@1.2.0", "", { "dependencies": { "unicode-segmenter": "^0.14.5" } }, "sha512-b8WSh+Z7K601eUFFmTFj8QPKDO8Ic0VDDj63sdKzpkm+ySQKsYT5nXekViGqFVKbyKj1V5FyvZvgXad6/aI4QQ=="], 42 + 43 + "@badrap/valita": ["@badrap/valita@0.4.6", "", {}, "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="], 44 + 45 + "@clack/core": ["@clack/core@0.4.2", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg=="], 46 + 47 + "@clack/prompts": ["@clack/prompts@0.10.1", "", { "dependencies": { "@clack/core": "0.4.2", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw=="], 48 + 49 + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], 50 + 51 + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], 52 + 53 + "nanoid": ["nanoid@5.1.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ=="], 54 + 55 + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 56 + 57 + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], 58 + 59 + "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], 60 + } 61 + }
+763
index.js
··· 1 + import { OAuthClient, MemoryStore } from "@atcute/oauth-node-client"; 2 + import { 3 + LocalActorResolver, 4 + CompositeDidDocumentResolver, 5 + PlcDidDocumentResolver, 6 + WebDidDocumentResolver, 7 + } from "@atcute/identity-resolver"; 8 + import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node"; 9 + import { Prompt } from "@clack/core"; 10 + import * as p from "@clack/prompts"; 11 + import color from "picocolors"; 12 + 13 + // ── runtime detection ─────────────────────────────────────────────── 14 + const IS_BUN = typeof Bun !== "undefined"; 15 + 16 + // ── config ────────────────────────────────────────────────────────── 17 + const CONSTELLATION_BASE = "https://constellation.microcosm.blue"; 18 + const PORT = 22891; 19 + const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`; 20 + const BLOCK_DELAY_MS = 150; 21 + const BATCH_SIZE = 10; 22 + const PAGE_SIZE = 100; 23 + const PROFILE_BATCH_SIZE = 25; 24 + const SCOPE = "atproto repo:app.bsky.graph.block?action=create"; 25 + 26 + const CATEGORIES = /** @type {const} */ (["reposts", "replies", "likes"]); 27 + const DUPLICATE_PATTERNS = ["duplicate", "already exists"]; 28 + 29 + // ── CLI parsing ───────────────────────────────────────────────────── 30 + function getArgv() { 31 + if (IS_BUN) { 32 + const scriptIdx = Bun.argv.findIndex((a) => 33 + a.endsWith(".js") || a.endsWith(".ts") 34 + ); 35 + if (scriptIdx >= 0) return Bun.argv.slice(scriptIdx + 1); 36 + } 37 + return process.argv.slice(2); 38 + } 39 + 40 + function parseArgs() { 41 + const args = getArgv(); 42 + const flags = {}; 43 + for (let i = 0; i < args.length; i++) { 44 + if (args[i] === "--delay" && args[i + 1]) { 45 + flags.delay = parseInt(args[++i], 10); 46 + } else if (args[i] === "--batch" && args[i + 1]) { 47 + flags.batch = parseInt(args[++i], 10); 48 + } else if (args[i] === "--help" || args[i] === "-h") { 49 + flags.help = true; 50 + } 51 + } 52 + return { flags }; 53 + } 54 + 55 + function printUsage() { 56 + console.log(`block-reposters: interactively block engagement on an atproto post 57 + 58 + usage: 59 + bun index.js [options] 60 + node index.js [options] 61 + 62 + options: 63 + --delay <ms> delay between batches in ms (default: 150) 64 + --batch <n> writes per applyWrites call (default: 10) 65 + -h, --help show this help`); 66 + } 67 + 68 + // ── shared helpers ────────────────────────────────────────────────── 69 + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 70 + 71 + function exitIfCancelled(value) { 72 + if (p.isCancel(value)) { p.cancel("cancelled."); process.exit(0); } 73 + return value; 74 + } 75 + 76 + async function resolveHandle(handle) { 77 + const res = await fetch( 78 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` 79 + ); 80 + if (!res.ok) throw new Error(`failed to resolve handle ${handle}: ${res.status}`); 81 + return (await res.json()).did; 82 + } 83 + 84 + function makeBlockRecord(targetDid, createdAt) { 85 + return { $type: "app.bsky.graph.block", subject: targetDid, createdAt }; 86 + } 87 + 88 + function isDuplicateError(msg) { 89 + return DUPLICATE_PATTERNS.some((pat) => msg.includes(pat)); 90 + } 91 + 92 + async function parseApiError(res) { 93 + const body = await res.json().catch(() => ({})); 94 + return body?.message ?? body?.error ?? `status ${res.status}`; 95 + } 96 + 97 + // ── TID generation ────────────────────────────────────────────────── 98 + const TID_CHARS = "234567abcdefghijklmnopqrstuvwxyz"; 99 + const tidClockId = BigInt(Math.floor(Math.random() * 1024)); 100 + let tidLast = 0n; 101 + 102 + function generateTid() { 103 + let now = BigInt(Date.now()) * 1000n; 104 + if (now <= tidLast) now = tidLast + 1n; 105 + tidLast = now; 106 + let v = (now << 10n) | (tidClockId & 0x3ffn); 107 + let out = ""; 108 + for (let i = 0; i < 13; i++) { 109 + out = TID_CHARS[Number(v & 31n)] + out; 110 + v >>= 5n; 111 + } 112 + return out; 113 + } 114 + 115 + // ── URL/URI parsing ───────────────────────────────────────────────── 116 + async function resolvePostUri(input) { 117 + if (input.startsWith("at://")) return input; 118 + 119 + const urlMatch = input.match(/\/profile\/([^/]+)\/post\/([^/?#]+)/); 120 + if (!urlMatch) { 121 + throw new Error( 122 + `can't parse post URL: ${input}\nexpected format: https://<domain>/profile/<handle>/post/<rkey>` 123 + ); 124 + } 125 + 126 + const [, handleOrDid, rkey] = urlMatch; 127 + let did = handleOrDid; 128 + if (!handleOrDid.startsWith("did:")) { 129 + did = await resolveHandle(handleOrDid); 130 + } 131 + 132 + return `at://${did}/app.bsky.feed.post/${rkey}`; 133 + } 134 + 135 + // ── constellation ─────────────────────────────────────────────────── 136 + async function fetchConstellationDids(target, collection, path) { 137 + const allDids = []; 138 + let cursor; 139 + 140 + while (true) { 141 + const url = new URL(`${CONSTELLATION_BASE}/links/distinct-dids`); 142 + url.searchParams.set("target", target); 143 + url.searchParams.set("collection", collection); 144 + url.searchParams.set("path", path); 145 + url.searchParams.set("limit", String(PAGE_SIZE)); 146 + if (cursor) url.searchParams.set("cursor", cursor); 147 + 148 + const res = await fetch(url); 149 + if (!res.ok) throw new Error(`constellation error: ${res.status}`); 150 + const data = await res.json(); 151 + 152 + allDids.push(...data.linking_dids); 153 + if (!data.cursor || data.linking_dids.length === 0) break; 154 + cursor = data.cursor; 155 + } 156 + 157 + return allDids; 158 + } 159 + 160 + const fetchReposters = (atUri) => 161 + fetchConstellationDids(atUri, "app.bsky.feed.repost", ".subject.uri"); 162 + 163 + const fetchLikers = (atUri) => 164 + fetchConstellationDids(atUri, "app.bsky.feed.like", ".subject.uri"); 165 + 166 + const fetchRepliers = (atUri) => 167 + fetchConstellationDids(atUri, "app.bsky.feed.post", ".reply.parent.uri"); 168 + 169 + const fetchQuotePosters = (atUri) => 170 + fetchConstellationDids(atUri, "app.bsky.feed.post", ".embed.record.uri"); 171 + 172 + function extractAuthorDid(atUri) { 173 + return atUri.replace("at://", "").split("/")[0]; 174 + } 175 + 176 + // ── social graph ──────────────────────────────────────────────────── 177 + async function resolvePds(did) { 178 + const res = await fetch( 179 + `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(did)}` 180 + ); 181 + if (!res.ok) throw new Error(`slingshot error: ${res.status}`); 182 + return (await res.json()).pds; 183 + } 184 + 185 + async function fetchFollowing(did, pdsUrl) { 186 + const allDids = []; 187 + let cursor; 188 + 189 + while (true) { 190 + const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`); 191 + url.searchParams.set("repo", did); 192 + url.searchParams.set("collection", "app.bsky.graph.follow"); 193 + url.searchParams.set("limit", "100"); 194 + if (cursor) url.searchParams.set("cursor", cursor); 195 + 196 + const res = await fetch(url); 197 + if (!res.ok) throw new Error(`listRecords error: ${res.status}`); 198 + const data = await res.json(); 199 + 200 + for (const rec of data.records) allDids.push(rec.value.subject); 201 + if (!data.cursor || data.records.length === 0) break; 202 + cursor = data.cursor; 203 + } 204 + 205 + return new Set(allDids); 206 + } 207 + 208 + async function fetchFollowers(did) { 209 + const dids = await fetchConstellationDids(did, "app.bsky.graph.follow", ".subject"); 210 + return new Set(dids); 211 + } 212 + 213 + async function fetchExistingBlocks(did, pdsUrl) { 214 + const allDids = []; 215 + let cursor; 216 + 217 + while (true) { 218 + const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`); 219 + url.searchParams.set("repo", did); 220 + url.searchParams.set("collection", "app.bsky.graph.block"); 221 + url.searchParams.set("limit", "100"); 222 + if (cursor) url.searchParams.set("cursor", cursor); 223 + 224 + const res = await fetch(url); 225 + if (!res.ok) throw new Error(`listRecords error: ${res.status}`); 226 + const data = await res.json(); 227 + 228 + for (const rec of data.records) allDids.push(rec.value.subject); 229 + if (!data.cursor || data.records.length === 0) break; 230 + cursor = data.cursor; 231 + } 232 + 233 + return new Set(allDids); 234 + } 235 + 236 + async function fetchSocialGraph(did) { 237 + const pdsUrl = await resolvePds(did); 238 + const [follows, followers, existingBlocks] = await Promise.all([ 239 + fetchFollowing(did, pdsUrl), 240 + fetchFollowers(did), 241 + fetchExistingBlocks(did, pdsUrl), 242 + ]); 243 + return { follows, followers, existingBlocks }; 244 + } 245 + 246 + // ── profile resolution ────────────────────────────────────────────── 247 + async function resolveProfiles(dids) { 248 + const profiles = []; 249 + for (let i = 0; i < dids.length; i += PROFILE_BATCH_SIZE) { 250 + const batch = dids.slice(i, i + PROFILE_BATCH_SIZE); 251 + const params = batch.map((d) => `actors=${encodeURIComponent(d)}`).join("&"); 252 + const res = await fetch( 253 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${params}` 254 + ); 255 + if (!res.ok) throw new Error(`getProfiles error: ${res.status}`); 256 + const data = await res.json(); 257 + profiles.push(...data.profiles); 258 + } 259 + return profiles; 260 + } 261 + 262 + // ── callback server ───────────────────────────────────────────────── 263 + async function startCallbackServer() { 264 + let resolveSession, rejectSession; 265 + const sessionPromise = new Promise((resolve, reject) => { 266 + resolveSession = resolve; 267 + rejectSession = reject; 268 + }); 269 + 270 + const ctx = { oauthClient: null }; 271 + 272 + const handler = async (req) => { 273 + const url = new URL(req.url); 274 + if (url.pathname !== "/callback") { 275 + return new Response("not found", { status: 404 }); 276 + } 277 + try { 278 + const { session } = await ctx.oauthClient.callback(url.searchParams); 279 + resolveSession(session); 280 + return new Response("<h2>authenticated! you can close this tab.</h2>", { 281 + headers: { "content-type": "text/html" }, 282 + }); 283 + } catch (err) { 284 + rejectSession(err); 285 + return new Response(`oauth error: ${err.message}`, { status: 500 }); 286 + } 287 + }; 288 + 289 + let server; 290 + 291 + if (IS_BUN) { 292 + server = Bun.serve({ port: PORT, hostname: "127.0.0.1", fetch: handler }); 293 + } else { 294 + const { createServer } = await import("node:http"); 295 + server = createServer(async (req, res) => { 296 + const fakeReq = new Request(`http://127.0.0.1:${PORT}${req.url}`); 297 + const response = await handler(fakeReq); 298 + res.writeHead(response.status, { 299 + "content-type": response.headers.get("content-type") || "text/plain", 300 + }); 301 + res.end(await response.text()); 302 + }); 303 + await new Promise((r) => server.listen(PORT, "127.0.0.1", r)); 304 + } 305 + 306 + const close = () => { 307 + if (IS_BUN) server.stop(); 308 + else server.close(); 309 + }; 310 + 311 + return { sessionPromise, close, ctx }; 312 + } 313 + 314 + // ── oauth ─────────────────────────────────────────────────────────── 315 + async function authenticate(handle) { 316 + const oauthClient = new OAuthClient({ 317 + metadata: { 318 + redirect_uris: [REDIRECT_URI], 319 + scope: SCOPE, 320 + }, 321 + actorResolver: new LocalActorResolver({ 322 + handleResolver: new NodeDnsHandleResolver(), 323 + didDocumentResolver: new CompositeDidDocumentResolver({ 324 + methods: { 325 + plc: new PlcDidDocumentResolver(), 326 + web: new WebDidDocumentResolver(), 327 + }, 328 + }), 329 + }), 330 + stores: { 331 + sessions: new MemoryStore(), 332 + states: new MemoryStore({ ttl: 600_000 }), 333 + }, 334 + }); 335 + 336 + const { sessionPromise, close, ctx } = await startCallbackServer(); 337 + ctx.oauthClient = oauthClient; 338 + 339 + try { 340 + const { url } = await oauthClient.authorize({ 341 + target: { type: "account", identifier: handle }, 342 + scope: SCOPE, 343 + }); 344 + 345 + const { execFile } = await import("node:child_process"); 346 + const cmd = 347 + process.platform === "darwin" ? "open" : 348 + process.platform === "win32" ? "start" : 349 + "xdg-open"; 350 + execFile(cmd, [url.toString()]); 351 + } catch (err) { 352 + close(); 353 + throw new Error(`failed to start oauth flow: ${err.message}`); 354 + } 355 + 356 + const session = await sessionPromise; 357 + close(); 358 + return session; 359 + } 360 + 361 + // ── custom inline multi-select ────────────────────────────────────── 362 + function inlineMultiSelect({ message, options }) { 363 + let cursor = 0; 364 + const selected = new Set(); 365 + 366 + const prompt = new Prompt({ 367 + validate(value) { 368 + if (value.size === 0) return "select at least one option"; 369 + }, 370 + render() { 371 + const prefix = color.gray("│"); 372 + 373 + const items = options 374 + .map((opt, i) => { 375 + const check = selected.has(opt.value) 376 + ? color.green("◼") 377 + : color.dim("◻"); 378 + const label = 379 + i === cursor 380 + ? color.cyan(`${check} ${color.underline(opt.label)}`) 381 + : `${check} ${opt.label}`; 382 + return label; 383 + }) 384 + .join(" "); 385 + 386 + switch (this.state) { 387 + case "submit": 388 + return `${color.gray("◇")} ${message}\n${prefix} ${color.dim([...selected].join(", "))}`; 389 + case "cancel": 390 + return `${color.gray("◇")} ${message}\n${prefix} ${color.strikethrough(color.dim("cancelled"))}`; 391 + case "error": 392 + return `${color.yellow("▲")} ${message}\n${prefix} ${items}\n${prefix} ${color.yellow(this.error)}`; 393 + default: 394 + return `${color.cyan("◆")} ${message}\n${prefix} ${items}\n${prefix} ${color.dim("← → move · space toggle · enter confirm")}`; 395 + } 396 + }, 397 + }); 398 + 399 + prompt.on("cursor", (key) => { 400 + if (key === "right") { 401 + cursor = (cursor + 1) % options.length; 402 + } else if (key === "left") { 403 + cursor = (cursor - 1 + options.length) % options.length; 404 + } else if (key === "space") { 405 + const val = options[cursor].value; 406 + if (selected.has(val)) selected.delete(val); 407 + else selected.add(val); 408 + } 409 + }); 410 + 411 + prompt.on("submit", () => { 412 + prompt.value = selected; 413 + }); 414 + 415 + return prompt.prompt(); 416 + } 417 + 418 + // ── interactive flow ──────────────────────────────────────────────── 419 + async function runInteractiveFlow() { 420 + p.intro(color.inverse(" bluesky post blocker ")); 421 + 422 + const postUrl = exitIfCancelled(await p.text({ 423 + message: "paste the post URL or at:// URI", 424 + placeholder: "https://bsky.app/profile/someone.bsky.social/post/abc123", 425 + validate: (v) => { 426 + if (!v) return "url is required"; 427 + if (!v.includes("/post/") && !v.startsWith("at://")) 428 + return "doesn't look like a post url"; 429 + }, 430 + })); 431 + 432 + const categories = exitIfCancelled(await inlineMultiSelect({ 433 + message: "what do you want to block?", 434 + options: CATEGORIES.map((c) => ({ value: c, label: c })), 435 + })); 436 + 437 + const blockAuthorFollowers = exitIfCancelled(await p.confirm({ 438 + message: "also block the post author's followers?", 439 + initialValue: false, 440 + })); 441 + 442 + const blockFollowing = exitIfCancelled(await p.confirm({ 443 + message: "include people you're following in blocks?", 444 + initialValue: false, 445 + })); 446 + 447 + const handle = exitIfCancelled(await p.text({ 448 + message: "enter your bluesky handle", 449 + placeholder: "you.bsky.social", 450 + validate: (v) => { if (!v) return "handle is required"; }, 451 + })); 452 + 453 + return { postUrl, categories, blockAuthorFollowers, blockFollowing, handle }; 454 + } 455 + 456 + // ── data fetching ─────────────────────────────────────────────────── 457 + async function fetchEngagementData(atUri, categories, blockAuthorFollowers) { 458 + const results = { reposts: [], likes: [], replies: [], authorFollowers: [], quotePosters: [] }; 459 + 460 + const fetchers = []; 461 + if (categories.has("reposts")) { 462 + fetchers.push(fetchReposters(atUri).then((d) => { results.reposts = d; })); 463 + } 464 + if (categories.has("likes")) { 465 + fetchers.push(fetchLikers(atUri).then((d) => { results.likes = d; })); 466 + } 467 + if (categories.has("replies")) { 468 + fetchers.push(fetchRepliers(atUri).then((d) => { results.replies = d; })); 469 + } 470 + if (blockAuthorFollowers) { 471 + const authorDid = extractAuthorDid(atUri); 472 + fetchers.push(fetchFollowers(authorDid).then((d) => { results.authorFollowers = [...d]; })); 473 + } 474 + fetchers.push(fetchQuotePosters(atUri).then((d) => { results.quotePosters = d; })); 475 + 476 + await Promise.all(fetchers); 477 + return results; 478 + } 479 + 480 + // ── filtering ─────────────────────────────────────────────────────── 481 + function filterCandidates({ results, did, follows, followers, existingBlocks, blockFollowing }) { 482 + const allCandidates = new Set(); 483 + for (const src of [results.reposts, results.likes, results.replies, results.authorFollowers]) { 484 + for (const d of src) allCandidates.add(d); 485 + } 486 + 487 + const quotePosters = new Set(results.quotePosters); 488 + const followedInBlockList = []; 489 + const toBlock = []; 490 + let skippedSelf = 0, skippedQuote = 0, skippedFollow = 0, skippedFollower = 0, skippedAlreadyBlocked = 0; 491 + 492 + for (const d of allCandidates) { 493 + if (d === did) { skippedSelf++; continue; } 494 + if (existingBlocks.has(d)) { skippedAlreadyBlocked++; continue; } 495 + if (quotePosters.has(d)) { skippedQuote++; continue; } 496 + if (follows.has(d)) { 497 + if (!blockFollowing) { 498 + skippedFollow++; 499 + continue; 500 + } else { 501 + followedInBlockList.push(d); 502 + } 503 + } 504 + if (followers.has(d)) { skippedFollower++; continue; } 505 + toBlock.push(d); 506 + } 507 + 508 + return { toBlock, followedInBlockList, skippedSelf, skippedQuote, skippedFollow, skippedFollower, skippedAlreadyBlocked, total: allCandidates.size }; 509 + } 510 + 511 + // ── summary display ───────────────────────────────────────────────── 512 + async function showSummary(results, filterResult, categories, blockAuthorFollowers) { 513 + const lines = []; 514 + if (categories.has("reposts")) lines.push(` reposters: ${results.reposts.length}`); 515 + if (categories.has("likes")) lines.push(` likers: ${results.likes.length}`); 516 + if (categories.has("replies")) lines.push(` repliers: ${results.replies.length}`); 517 + if (blockAuthorFollowers) lines.push(` author followers: ${results.authorFollowers.length}`); 518 + lines.push(` quote posters (excluded): ${results.quotePosters.length}`); 519 + lines.push(""); 520 + lines.push(` unique candidates: ${filterResult.total}`); 521 + if (filterResult.skippedSelf) lines.push(` - ${filterResult.skippedSelf} (self)`); 522 + if (filterResult.skippedQuote) lines.push(` - ${filterResult.skippedQuote} (quote posters)`); 523 + if (filterResult.skippedFollow) lines.push(` - ${filterResult.skippedFollow} (people you follow)`); 524 + if (filterResult.skippedFollower) lines.push(` - ${filterResult.skippedFollower} (your followers)`); 525 + if (filterResult.skippedAlreadyBlocked) lines.push(` - ${filterResult.skippedAlreadyBlocked} (already blocked)`); 526 + lines.push(` = ${color.bold(String(filterResult.toBlock.length))} to block`); 527 + 528 + p.note(lines.join("\n"), "summary"); 529 + 530 + if (filterResult.followedInBlockList.length > 0) { 531 + const s = p.spinner(); 532 + s.start("resolving profiles of followed users..."); 533 + const profiles = await resolveProfiles(filterResult.followedInBlockList); 534 + s.stop("profiles resolved"); 535 + 536 + const warningLines = profiles.map((pr) => 537 + ` ${color.cyan(`@${pr.handle}`)} ${color.dim(`(${pr.displayName || "no display name"})`)}` 538 + ); 539 + p.log.warn( 540 + `${color.yellow("you follow these accounts and they will be blocked:")}\n${warningLines.join("\n")}` 541 + ); 542 + } 543 + } 544 + 545 + // ── rate limit handling ────────────────────────────────────────────── 546 + function getBackoffDelay(res, defaultDelay) { 547 + if (!res?.headers) return defaultDelay; 548 + 549 + const remaining = parseInt(res.headers.get("ratelimit-remaining"), 10); 550 + const reset = parseInt(res.headers.get("ratelimit-reset"), 10); 551 + 552 + if (isNaN(remaining) || isNaN(reset)) return defaultDelay; 553 + 554 + if (remaining <= 0) { 555 + const waitMs = Math.max(0, (reset * 1000) - Date.now()) + 1000; 556 + return waitMs; 557 + } 558 + 559 + const limit = parseInt(res.headers.get("ratelimit-limit"), 10); 560 + if (!isNaN(limit) && limit > 0) { 561 + const ratio = remaining / limit; 562 + if (ratio < 0.2) { 563 + const scale = 1 + (5 * (1 - ratio / 0.2)); 564 + return Math.ceil(defaultDelay * scale); 565 + } 566 + } 567 + 568 + return defaultDelay; 569 + } 570 + 571 + async function sleepForRateLimit(res, spinner) { 572 + if (res?.status === 429) { 573 + const wait = getBackoffDelay(res, 60_000); 574 + spinner.message(`rate limited, waiting ${Math.ceil(wait / 1000)}s...`); 575 + await sleep(wait); 576 + return true; 577 + } 578 + return false; 579 + } 580 + 581 + // ── blocking ──────────────────────────────────────────────────────── 582 + async function createSingleBlock(session, did, targetDid) { 583 + return session.handle("/xrpc/com.atproto.repo.createRecord", { 584 + method: "POST", 585 + headers: { "content-type": "application/json" }, 586 + body: JSON.stringify({ 587 + repo: did, 588 + collection: "app.bsky.graph.block", 589 + record: makeBlockRecord(targetDid, new Date().toISOString()), 590 + }), 591 + }); 592 + } 593 + 594 + async function confirmAndBlock({ toBlock, handle, did, delayMs, batchSize }) { 595 + const proceed = exitIfCancelled(await p.confirm({ 596 + message: `block ${toBlock.length} accounts?`, 597 + initialValue: false, 598 + })); 599 + if (!proceed) { 600 + p.cancel("cancelled."); 601 + process.exit(0); 602 + } 603 + 604 + const authSpinner = p.spinner(); 605 + authSpinner.start("authenticating via oauth..."); 606 + const session = await authenticate(handle); 607 + authSpinner.stop(`authenticated as ${session.did}`); 608 + 609 + const blockSpinner = p.spinner(); 610 + blockSpinner.start(`blocking 0/${toBlock.length} (batch size ${batchSize})...`); 611 + 612 + let blocked = 0, alreadyBlocked = 0, errors = 0; 613 + let errorCount = 0; 614 + 615 + function logError(msg) { 616 + errorCount++; 617 + if (errorCount <= 5) p.log.error(msg); 618 + } 619 + 620 + for (let i = 0; i < toBlock.length; i += batchSize) { 621 + const batch = toBlock.slice(i, i + batchSize); 622 + const now = new Date().toISOString(); 623 + 624 + const writes = batch.map((targetDid) => ({ 625 + $type: "com.atproto.repo.applyWrites#create", 626 + collection: "app.bsky.graph.block", 627 + rkey: generateTid(), 628 + value: makeBlockRecord(targetDid, now), 629 + })); 630 + 631 + let lastRes = null; 632 + 633 + try { 634 + const res = await session.handle("/xrpc/com.atproto.repo.applyWrites", { 635 + method: "POST", 636 + headers: { "content-type": "application/json" }, 637 + body: JSON.stringify({ repo: did, writes }), 638 + }); 639 + lastRes = res; 640 + 641 + if (await sleepForRateLimit(res, blockSpinner)) { 642 + i -= batchSize; 643 + continue; 644 + } 645 + 646 + if (!res.ok) { 647 + const msg = await parseApiError(res); 648 + if (isDuplicateError(msg)) { 649 + // batch rejected for duplicates — fall back to individual creates 650 + const fallbackResults = await Promise.allSettled( 651 + batch.map((targetDid) => createSingleBlock(session, did, targetDid)) 652 + ); 653 + for (let j = 0; j < fallbackResults.length; j++) { 654 + const result = fallbackResults[j]; 655 + if (result.status === "rejected") { 656 + errors++; 657 + logError(`error blocking ${batch[j]}: ${result.reason?.message ?? result.reason}`); 658 + continue; 659 + } 660 + const r = result.value; 661 + lastRes = r; 662 + if (r.status === 429) { 663 + // 429 on individual — retry this one 664 + await sleepForRateLimit(r, blockSpinner); 665 + try { 666 + const retry = await createSingleBlock(session, did, batch[j]); 667 + if (retry.ok) blocked++; 668 + else { 669 + const m = await parseApiError(retry); 670 + if (isDuplicateError(m)) alreadyBlocked++; 671 + else { errors++; logError(`error blocking ${batch[j]}: ${m}`); } 672 + } 673 + } catch (err) { 674 + errors++; 675 + logError(`error blocking ${batch[j]}: ${err?.message ?? err}`); 676 + } 677 + } else if (!r.ok) { 678 + const m = await parseApiError(r); 679 + if (isDuplicateError(m)) alreadyBlocked++; 680 + else { errors++; logError(`error blocking ${batch[j]}: ${m}`); } 681 + } else { 682 + blocked++; 683 + } 684 + } 685 + } else { 686 + errors += batch.length; 687 + logError(`batch error: ${msg}`); 688 + } 689 + } else { 690 + blocked += batch.length; 691 + } 692 + } catch (err) { 693 + errors += batch.length; 694 + logError(`batch error: ${err?.message ?? err}`); 695 + } 696 + 697 + const total = blocked + alreadyBlocked + errors; 698 + const adaptiveDelay = getBackoffDelay(lastRes, delayMs); 699 + blockSpinner.message(`blocking ${total}/${toBlock.length}...`); 700 + await sleep(adaptiveDelay); 701 + } 702 + 703 + blockSpinner.stop("blocking complete"); 704 + 705 + p.note( 706 + ` blocked: ${blocked}\n already blocked: ${alreadyBlocked}\n errors: ${errors}`, 707 + "results" 708 + ); 709 + p.outro("done!"); 710 + } 711 + 712 + // ── main ──────────────────────────────────────────────────────────── 713 + async function main() { 714 + const { flags } = parseArgs(); 715 + if (flags.help) { printUsage(); process.exit(0); } 716 + const delayMs = flags.delay ?? BLOCK_DELAY_MS; 717 + const batchSize = flags.batch ?? BATCH_SIZE; 718 + 719 + const config = await runInteractiveFlow(); 720 + 721 + const s1 = p.spinner(); 722 + s1.start("resolving post..."); 723 + const atUri = await resolvePostUri(config.postUrl); 724 + s1.stop(`target: ${atUri}`); 725 + 726 + // resolve identity and fetch engagement data in parallel 727 + const s2 = p.spinner(); 728 + s2.start("fetching engagement data & resolving identity..."); 729 + const [results, did] = await Promise.all([ 730 + fetchEngagementData(atUri, config.categories, config.blockAuthorFollowers), 731 + resolveHandle(config.handle), 732 + ]); 733 + s2.stop("engagement data fetched"); 734 + 735 + const s3 = p.spinner(); 736 + s3.start("fetching your social graph & existing blocks..."); 737 + const { follows, followers, existingBlocks } = await fetchSocialGraph(did); 738 + s3.stop(`following: ${follows.size}, followers: ${followers.size}, existing blocks: ${existingBlocks.size}`); 739 + 740 + const filterResult = filterCandidates({ 741 + results, did, follows, followers, existingBlocks, blockFollowing: config.blockFollowing, 742 + }); 743 + 744 + if (filterResult.toBlock.length === 0) { 745 + p.outro("nothing to block after filtering."); 746 + process.exit(0); 747 + } 748 + 749 + await showSummary(results, filterResult, config.categories, config.blockAuthorFollowers); 750 + 751 + await confirmAndBlock({ 752 + toBlock: filterResult.toBlock, 753 + handle: config.handle, 754 + did, 755 + delayMs, 756 + batchSize, 757 + }); 758 + } 759 + 760 + main().catch((err) => { 761 + p.cancel(`fatal: ${err.message}`); 762 + process.exit(1); 763 + });
+15
package.json
··· 1 + { 2 + "name": "block-reposters", 3 + "version": "1.0.0", 4 + "type": "module", 5 + "scripts": { 6 + "start": "node index.js" 7 + }, 8 + "dependencies": { 9 + "@atcute/identity-resolver": "^1.2.2", 10 + "@atcute/identity-resolver-node": "^1.0.3", 11 + "@atcute/oauth-node-client": "^1.1.0", 12 + "@clack/core": "^0.4.1", 13 + "@clack/prompts": "^0.10.0" 14 + } 15 + }