+34
bun.lock
+34
bun.lock
···
1
+
{
2
+
"lockfileVersion": 1,
3
+
"configVersion": 1,
4
+
"workspaces": {
5
+
"": {
6
+
"dependencies": {
7
+
"@atcute/atproto": "^3.1.9",
8
+
"@atcute/bluesky": "^3.2.10",
9
+
"@atcute/client": "^4.0.5",
10
+
"@atcute/lexicons": "^1.2.3",
11
+
"dotenv": "^17.2.3",
12
+
},
13
+
},
14
+
},
15
+
"packages": {
16
+
"@atcute/atproto": ["@atcute/atproto@3.1.9", "", { "dependencies": { "@atcute/lexicons": "^1.2.2" } }, "sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w=="],
17
+
18
+
"@atcute/bluesky": ["@atcute/bluesky@3.2.10", "", { "dependencies": { "@atcute/atproto": "^3.1.9", "@atcute/lexicons": "^1.2.2" } }, "sha512-qwQWTzRf3umnh2u41gdU+xWYkbzGlKDupc3zeOB+YjmuP1N9wEaUhwS8H7vgrqr0xC9SGNDjeUVcjC4m5BPLBg=="],
19
+
20
+
"@atcute/client": ["@atcute/client@4.0.5", "", { "dependencies": { "@atcute/identity": "^1.1.1", "@atcute/lexicons": "^1.2.2" } }, "sha512-R8Qen8goGmEkynYGg2m6XFlVmz0GTDvQ+9w+4QqOob+XMk8/WDpF4aImev7WKEde/rV2gjcqW7zM8E6W9NShDA=="],
21
+
22
+
"@atcute/identity": ["@atcute/identity@1.1.2", "", { "dependencies": { "@atcute/lexicons": "^1.2.3", "@badrap/valita": "^0.4.6" } }, "sha512-vn0RN7SUF6N0sEPG9yyT6a0MzpfVS8BhsiLtB8OeS4qp2rLMQW33pelCpNitP1N+fq03MFlDGzs5p7K4qMs4cA=="],
23
+
24
+
"@atcute/lexicons": ["@atcute/lexicons@1.2.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "esm-env": "^1.2.2" } }, "sha512-ZNfNWS4jaR8VgWSSBaWRSSmwFeP134BmvpTt9JmM2x5vRoXeIFthxU9USY8ZV4vm0GPoxEMgkDin8HIlnFTg2w=="],
25
+
26
+
"@badrap/valita": ["@badrap/valita@0.4.6", "", {}, "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="],
27
+
28
+
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
29
+
30
+
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
31
+
32
+
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
33
+
}
34
+
}
+204
index.ts
+204
index.ts
···
1
+
import { Client, CredentialManager } from "@atcute/client";
2
+
import { Handle, Did, ActorIdentifier } from "@atcute/lexicons";
3
+
import * as dotenv from "dotenv";
4
+
5
+
dotenv.config();
6
+
7
+
const APPS: Record<string, string> = {
8
+
bsky: "app.bsky.graph.follow",
9
+
tangled: "sh.tangled.graph.follow",
10
+
// TODO: add more apps here. eg:
11
+
// whitewind: "com.whtwnd.graph.follow"
12
+
};
13
+
14
+
const BSKY_HANDLE = process.env.BSKY_HANDLE;
15
+
const BSKY_PASSWORD = process.env.BSKY_PASSWORD;
16
+
const SHOULD_DELETE = !process.argv.includes("--no-delete");
17
+
18
+
const sourceArg = process.argv.find((arg) => arg.startsWith("--source="));
19
+
const SOURCE_KEY = sourceArg ? sourceArg.split("=")[1] : "bsky";
20
+
21
+
if (!APPS[SOURCE_KEY]) {
22
+
console.error(`error: source '${SOURCE_KEY}' not found in APPS config`);
23
+
console.error(`available apps: ${Object.keys(APPS).join(", ")}`);
24
+
process.exit(1);
25
+
}
26
+
27
+
if (!BSKY_HANDLE || !BSKY_PASSWORD) {
28
+
process.exit(1);
29
+
}
30
+
31
+
let rpc: Client;
32
+
let manager: CredentialManager;
33
+
let agentDID: Did;
34
+
35
+
const resolveHandle = async (handle: string): Promise<Did> => {
36
+
const publicRpc = new Client({
37
+
handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
38
+
});
39
+
40
+
const res = await publicRpc.get("com.atproto.identity.resolveHandle", {
41
+
params: { handle: handle as Handle },
42
+
});
43
+
44
+
if (!res.ok) throw new Error(res.data.error);
45
+
return res.data.did;
46
+
};
47
+
48
+
const getPDS = async (did: string) => {
49
+
const res = await fetch(
50
+
did.startsWith("did:web")
51
+
? `https://${did.split(":")[2]}/.well-known/did.json`
52
+
: "https://plc.directory/" + did,
53
+
);
54
+
55
+
return res.json().then((doc: any) => {
56
+
for (const service of doc.service) {
57
+
if (service.id === "#atproto_pds") return service.serviceEndpoint;
58
+
}
59
+
throw new Error("no PDS endpoint found");
60
+
});
61
+
};
62
+
63
+
const fetchAllRecords = async (collection: string): Promise<Map<string, string>> => {
64
+
const records = new Map<string, string>();
65
+
let cursor: string | undefined;
66
+
67
+
process.stdout.write(`fetching records from ${collection}...`);
68
+
69
+
do {
70
+
const res = await rpc.get("com.atproto.repo.listRecords", {
71
+
params: {
72
+
repo: agentDID as ActorIdentifier,
73
+
collection: collection,
74
+
limit: 100,
75
+
cursor: cursor,
76
+
},
77
+
});
78
+
79
+
if (!res.ok) throw new Error(res.data.error);
80
+
81
+
res.data.records.forEach((record: any) => {
82
+
const rkey = record.uri.split("/").pop();
83
+
if (record.value.subject && rkey) {
84
+
records.set(record.value.subject, rkey);
85
+
}
86
+
});
87
+
88
+
cursor = res.data.cursor;
89
+
process.stdout.write(".");
90
+
} while (cursor);
91
+
92
+
console.log(` done (${records.size})`);
93
+
return records;
94
+
};
95
+
96
+
const createFollowRecord = async (collection: string, targetDid: Did) => {
97
+
const record = {
98
+
$type: collection,
99
+
subject: targetDid,
100
+
createdAt: new Date().toISOString(),
101
+
};
102
+
103
+
await rpc.post("com.atproto.repo.createRecord", {
104
+
input: {
105
+
repo: agentDID,
106
+
collection: collection,
107
+
record: record,
108
+
},
109
+
});
110
+
};
111
+
112
+
const deleteFollowRecord = async (collection: string, rkey: string) => {
113
+
await rpc.post("com.atproto.repo.deleteRecord", {
114
+
input: {
115
+
repo: agentDID,
116
+
collection: collection,
117
+
rkey: rkey,
118
+
},
119
+
});
120
+
};
121
+
122
+
const syncCollection = async (
123
+
targetAppName: string,
124
+
targetCollection: string,
125
+
sourceDids: Set<string>
126
+
) => {
127
+
console.log(`\ndownstream target is ${targetAppName} (${targetCollection})`);
128
+
129
+
const currentTargetRecords = await fetchAllRecords(targetCollection);
130
+
let addedCount = 0;
131
+
let deletedCount = 0;
132
+
133
+
for (const subjectDid of sourceDids) {
134
+
if (!currentTargetRecords.has(subjectDid)) {
135
+
process.stdout.write(`[+] following ${subjectDid}... `);
136
+
await createFollowRecord(targetCollection, subjectDid as Did);
137
+
console.log("done");
138
+
addedCount++;
139
+
await new Promise((resolve) => setTimeout(resolve, 1000));
140
+
} else {
141
+
currentTargetRecords.delete(subjectDid);
142
+
}
143
+
}
144
+
145
+
if (SHOULD_DELETE && currentTargetRecords.size > 0) {
146
+
console.log(`found ${currentTargetRecords.size} orphans in ${targetAppName}, pruning...`);
147
+
148
+
let progress = 0;
149
+
for (const [did, rkey] of currentTargetRecords) {
150
+
progress++;
151
+
process.stdout.write(`[-] [${progress}/${currentTargetRecords.size}] unfollowing ${did}... `);
152
+
await deleteFollowRecord(targetCollection, rkey);
153
+
console.log("done");
154
+
deletedCount++;
155
+
await new Promise((resolve) => setTimeout(resolve, 1000));
156
+
}
157
+
} else if (!SHOULD_DELETE && currentTargetRecords.size > 0) {
158
+
console.log(`skipping deletion of ${currentTargetRecords.size} orphans (--no-delete)`);
159
+
}
160
+
161
+
console.log(`sync complete for ${targetAppName}: +${addedCount} added, -${deletedCount} removed`);
162
+
};
163
+
164
+
const main = async () => {
165
+
try {
166
+
if (!SHOULD_DELETE) console.log("running in add-only mode (--no-delete detected)\ncoward!! :3");
167
+
168
+
agentDID = BSKY_HANDLE.startsWith("did:")
169
+
? (BSKY_HANDLE as Did)
170
+
: await resolveHandle(BSKY_HANDLE);
171
+
172
+
const pdsUrl = await getPDS(agentDID);
173
+
manager = new CredentialManager({ service: pdsUrl });
174
+
rpc = new Client({ handler: manager });
175
+
176
+
await manager.login({
177
+
identifier: agentDID,
178
+
password: BSKY_PASSWORD,
179
+
});
180
+
181
+
console.log(`\nSOURCE OF TRUTH: ${SOURCE_KEY} (${APPS[SOURCE_KEY]})`);
182
+
183
+
const sourceMap = await fetchAllRecords(APPS[SOURCE_KEY]);
184
+
const sourceDids = new Set(sourceMap.keys());
185
+
186
+
const targetApps = Object.entries(APPS).filter(([key]) => key !== SOURCE_KEY);
187
+
188
+
if (targetApps.length === 0) {
189
+
console.log("no target apps found to sync to");
190
+
return;
191
+
}
192
+
193
+
for (const [appName, collectionUri] of targetApps) {
194
+
await syncCollection(appName, collectionUri, sourceDids);
195
+
}
196
+
197
+
console.log("\nall syncs finished, nini");
198
+
199
+
} catch (error) {
200
+
console.error(error);
201
+
}
202
+
};
203
+
204
+
main();