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