pds dash for shimaenaga.veryroundbird.house (based off of pds.witchcraft.systems)
at main 12 kB view raw
1import { simpleFetchHandler, XRPC } from "@atcute/client"; 2import "@atcute/bluesky/lexicons"; 3import type { 4 AppBskyActorDefs, 5 AppBskyActorProfile, 6 AppBskyFeedPost, 7 At, 8 ComAtprotoRepoListRecords, 9} from "@atcute/client/lexicons"; 10import { 11 CompositeDidDocumentResolver, 12 PlcDidDocumentResolver, 13 WebDidDocumentResolver, 14} from "@atcute/identity-resolver"; 15import { Config } from "../../config"; 16import { Mutex } from "mutex-ts" 17// import { ComAtprotoRepoListRecords.Record } from "@atcute/client/lexicons"; 18// import { AppBskyFeedPost } from "@atcute/client/lexicons"; 19// import { AppBskyActorDefs } from "@atcute/client/lexicons"; 20 21interface AccountMetadata { 22 did: At.Did; 23 displayName: string; 24 handle: string; 25 avatarCid: string | null; 26 currentCursor?: string; 27} 28 29let accountsMetadata: AccountMetadata[] = []; 30 31interface atUriObject { 32 repo: string; 33 collection: string; 34 rkey: string; 35} 36class Post { 37 authorDid: string; 38 authorAvatarCid: string | null; 39 postCid: string; 40 recordName: string; 41 authorHandle: string; 42 displayName: string; 43 text: string; 44 timestamp: number; 45 timenotstamp: string; 46 quotingUri: atUriObject | null; 47 replyingUri: atUriObject | null; 48 imagesCid: string[] | null; 49 videosLinkCid: string | null; 50 gifLink: string | null; 51 52 constructor( 53 record: ComAtprotoRepoListRecords.Record, 54 account: AccountMetadata, 55 ) { 56 this.postCid = record.cid; 57 this.recordName = processAtUri(record.uri).rkey; 58 this.authorDid = account.did; 59 this.authorAvatarCid = account.avatarCid; 60 this.authorHandle = account.handle; 61 this.displayName = account.displayName; 62 const post = record.value as AppBskyFeedPost.Record; 63 this.timenotstamp = post.createdAt; 64 this.text = post.text; 65 this.timestamp = Date.parse(post.createdAt); 66 if (post.reply) { 67 this.replyingUri = processAtUri(post.reply.parent.uri); 68 } else { 69 this.replyingUri = null; 70 } 71 this.quotingUri = null; 72 this.imagesCid = null; 73 this.videosLinkCid = null; 74 this.gifLink = null; 75 switch (post.embed?.$type) { 76 case "app.bsky.embed.images": 77 this.imagesCid = post.embed.images.map( 78 (imageRecord: any) => imageRecord.image.ref.$link, 79 ); 80 break; 81 case "app.bsky.embed.video": 82 this.videosLinkCid = post.embed.video.ref.$link; 83 break; 84 case "app.bsky.embed.record": 85 this.quotingUri = processAtUri(post.embed.record.uri); 86 break; 87 case "app.bsky.embed.recordWithMedia": 88 this.quotingUri = processAtUri(post.embed.record.record.uri); 89 switch (post.embed.media.$type) { 90 case "app.bsky.embed.images": 91 this.imagesCid = post.embed.media.images.map( 92 (imageRecord) => imageRecord.image.ref.$link, 93 ); 94 95 break; 96 case "app.bsky.embed.video": 97 this.videosLinkCid = post.embed.media.video.ref.$link; 98 99 break; 100 } 101 break; 102 case "app.bsky.embed.external": // assuming that external embeds are gifs for now 103 if (post.embed.external.uri.includes(".gif")) { 104 this.gifLink = post.embed.external.uri; 105 } 106 break; 107 } 108 } 109} 110 111const processAtUri = (aturi: string): atUriObject => { 112 const parts = aturi.split("/"); 113 return { 114 repo: parts[2], 115 collection: parts[3], 116 rkey: parts[4], 117 }; 118}; 119 120const rpc = new XRPC({ 121 handler: simpleFetchHandler({ 122 service: Config.PDS_URL, 123 }), 124}); 125 126const getDidsFromPDS = async (): Promise<At.Did[]> => { 127 const { data } = await rpc.get("com.atproto.sync.listRepos", { 128 params: {}, 129 }); 130 return data.repos.map((repo: any) => repo.did) as At.Did[]; 131}; 132const getAccountMetadata = async ( 133 did: `did:${string}:${string}`, 134) => { 135 const account: AccountMetadata = { 136 did: did, 137 handle: "", // Guaranteed to be filled out later 138 displayName: "", 139 avatarCid: null, 140 }; 141 142 try { 143 const { data } = await rpc.get("com.atproto.repo.getRecord", { 144 params: { 145 repo: did, 146 collection: "app.bsky.actor.profile", 147 rkey: "self", 148 }, 149 }); 150 const value = data.value as AppBskyActorProfile.Record; 151 account.displayName = value.displayName || ""; 152 account.description = value.description || ""; 153 if (value.avatar) { 154 account.avatarCid = value.avatar.ref["$link"]; 155 } 156 if (value.banner) { 157 account.bannerCid = value.banner.ref["$link"]; 158 } 159 } catch (e) { 160 console.warn(`Error fetching profile for ${did}:`, e); 161 } 162 163 try { 164 account.handle = await blueskyHandleFromDid(did); 165 } catch (e) { 166 console.error(`Error fetching handle for ${did}:`, e); 167 return null; 168 } 169 170 return account; 171}; 172 173const getAllMetadataFromPds = async (): Promise<AccountMetadata[]> => { 174 const dids = await getDidsFromPDS(); 175 const metadata = await Promise.all( 176 dids.map(async (repo: `did:${string}:${string}`) => { 177 return await getAccountMetadata(repo); 178 }), 179 ); 180 return metadata.filter((account) => account !== null) as AccountMetadata[]; 181}; 182 183const identityResolve = async (did: At.Did) => { 184 const resolver = new CompositeDidDocumentResolver({ 185 methods: { 186 plc: new PlcDidDocumentResolver(), 187 web: new WebDidDocumentResolver(), 188 }, 189 }); 190 191 if (did.startsWith("did:plc:") || did.startsWith("did:web:")) { 192 const doc = await resolver.resolve( 193 did as `did:plc:${string}` | `did:web:${string}`, 194 ); 195 return doc; 196 } else { 197 throw new Error(`Unsupported DID type: ${did}`); 198 } 199}; 200 201const blueskyHandleFromDid = async (did: At.Did) => { 202 const doc = await identityResolve(did); 203 if (doc.alsoKnownAs) { 204 const handleAtUri = doc.alsoKnownAs.find((url) => url.startsWith("at://")); 205 const handle = handleAtUri?.split("/")[2]; 206 if (!handle) { 207 return "Handle not found"; 208 } else { 209 return handle; 210 } 211 } else { 212 return "Handle not found"; 213 } 214}; 215 216interface PostsAcc { 217 posts: ComAtprotoRepoListRecords.Record[]; 218 account: AccountMetadata; 219} 220const getCutoffDate = (postAccounts: PostsAcc[]) => { 221 const now = Date.now(); 222 let cutoffDate: Date | null = null; 223 postAccounts.forEach((postAcc) => { 224 const latestPost = new Date( 225 (postAcc.posts[postAcc.posts.length - 1].value as AppBskyFeedPost.Record) 226 .createdAt, 227 ); 228 if (!cutoffDate) { 229 cutoffDate = latestPost; 230 } else { 231 if (latestPost > cutoffDate) { 232 cutoffDate = latestPost; 233 } 234 } 235 }); 236 if (cutoffDate) { 237 return cutoffDate; 238 } else { 239 return new Date(now); 240 } 241}; 242 243const filterPostsByDate = (posts: PostsAcc[], cutoffDate: Date) => { 244 // filter posts for each account that are older than the cutoff date and save the cursor of the last post included 245 const filteredPosts: PostsAcc[] = posts.map((postAcc) => { 246 const filtered = postAcc.posts.filter((post) => { 247 const postDate = new Date( 248 (post.value as AppBskyFeedPost.Record).createdAt, 249 ); 250 return postDate >= cutoffDate; 251 }); 252 if (filtered.length > 0) { 253 postAcc.account.currentCursor = processAtUri(filtered[filtered.length - 1].uri).rkey; 254 } 255 return { 256 posts: filtered, 257 account: postAcc.account, 258 }; 259 }); 260 return filteredPosts; 261}; 262 263const postsMutex = new Mutex(); 264// nightmare function. However it works so I am not touching it 265const getNextPosts = async () => { 266 const release = await postsMutex.obtain(); 267 if (!accountsMetadata.length) { 268 accountsMetadata = await getAllMetadataFromPds(); 269 } 270 271 const postsAcc: PostsAcc[] = await Promise.all( 272 accountsMetadata.map(async (account) => { 273 const posts = await fetchPostsForUser( 274 account.did, 275 account.currentCursor || null, 276 ); 277 if (posts) { 278 return { 279 posts: posts, 280 account: account, 281 }; 282 } else { 283 return { 284 posts: [], 285 account: account, 286 }; 287 } 288 }), 289 ); 290 const recordsFiltered = postsAcc.filter((postAcc) => 291 postAcc.posts.length > 0 292 ); 293 const cutoffDate = getCutoffDate(recordsFiltered); 294 const recordsCutoff = filterPostsByDate(recordsFiltered, cutoffDate); 295 // update the accountMetadata with the new cursor 296 accountsMetadata = accountsMetadata.map((account) => { 297 const postAcc = recordsCutoff.find( 298 (postAcc) => postAcc.account.did == account.did, 299 ); 300 if (postAcc) { 301 account.currentCursor = postAcc.account.currentCursor; 302 } 303 return account; 304 } 305 ); 306 // throw the records in a big single array 307 let records = recordsCutoff.flatMap((postAcc) => postAcc.posts); 308 // sort the records by timestamp 309 records = records.sort((a, b) => { 310 const aDate = new Date( 311 (a.value as AppBskyFeedPost.Record).createdAt, 312 ).getTime(); 313 const bDate = new Date( 314 (b.value as AppBskyFeedPost.Record).createdAt, 315 ).getTime(); 316 return bDate - aDate; 317 }); 318 // filter out posts that are in the future 319 if (!Config.SHOW_FUTURE_POSTS) { 320 const now = Date.now(); 321 records = records.filter((post) => { 322 const postDate = new Date( 323 (post.value as AppBskyFeedPost.Record).createdAt, 324 ).getTime(); 325 return postDate <= now; 326 }); 327 } 328 329 const newPosts = records.map((record) => { 330 const account = accountsMetadata.find( 331 (account) => account.did == processAtUri(record.uri).repo, 332 ); 333 if (!account) { 334 throw new Error( 335 `Account with DID ${processAtUri(record.uri).repo} not found`, 336 ); 337 } 338 return new Post(record, account); 339 }); 340 // release the mutex 341 release(); 342 return newPosts; 343}; 344 345const fetchPostsForUser = async (did: At.Did, cursor: string | null) => { 346 try { 347 const { data } = await rpc.get("com.atproto.repo.listRecords", { 348 params: { 349 repo: did as At.Identifier, 350 collection: "app.bsky.feed.post", 351 limit: Config.MAX_POSTS, 352 cursor: cursor || undefined, 353 }, 354 }); 355 return data.records as ComAtprotoRepoListRecords.Record[]; 356 } catch (e) { 357 console.error(`Error fetching posts for ${did}:`, e); 358 return null; 359 } 360}; 361 362const convertUri = (uri: string) => { 363 if (uri.startsWith("at://")) { 364 return uri; 365 } 366 367 if (uri.includes("bsky.app/profile/")) { 368 const match = uri.match(/profile\/([\w.]+)\/post\/([\w]+)/); 369 if (match) { 370 const [, did, postId] = match; 371 try { 372 return `at://${did}/app.bsky.feed.post/${postId}`; 373 } catch (e) { 374 console.error("Invalid Bluesky post URL format", e); 375 return null; 376 } 377 } 378 } 379} 380 381const fetchGuestbookPosts = async () => { 382 const params = new URLSearchParams({ uri: convertUri(Config.GUESTBOOK_POST) }); 383 const url = `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?${params.toString()}`; 384 385 try { 386 const response = await fetch(url, { 387 method: "GET", 388 headers: { 389 Accept: "application/json", 390 }, 391 cache: "no-store", 392 }); 393 394 if (!response.ok) { 395 const errorText = await response.text(); 396 console.error("Fetch Error: ", errorText); 397 throw new Error(`Failed to fetch thread: ${response.statusText}`); 398 } 399 400 const data = await response.json(); 401 402 if (!data.thread || !data.thread.replies) { 403 throw new Error("Invalid thread data: Missing expected properties."); 404 } 405 406 return data.thread.replies; 407 } catch (e) { 408 console.error("Is this the wrong kind of Bluesky object?", e); 409 } 410} 411 412export { getAllMetadataFromPds, getNextPosts, Post, fetchGuestbookPosts }; 413export type { AccountMetadata };