Syncs atproto following list from a source of truth of one's choosing

initial thingy

+2
.gitignore
··· 1 + .env 2 + node_modules
+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
··· 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();
+9
package.json
··· 1 + { 2 + "dependencies": { 3 + "@atcute/atproto": "^3.1.9", 4 + "@atcute/bluesky": "^3.2.10", 5 + "@atcute/client": "^4.0.5", 6 + "@atcute/lexicons": "^1.2.3", 7 + "dotenv": "^17.2.3" 8 + } 9 + }