your personal website on atproto - mirror blento.app
at statusphere-fix 467 lines 12 kB view raw
1import { 2 parseResourceUri, 3 type ActorIdentifier, 4 type Did, 5 type Handle, 6 type ResourceUri 7} from '@atcute/lexicons'; 8import { user } from './auth.svelte'; 9import type { AllowedCollection } from './settings'; 10import { 11 CompositeDidDocumentResolver, 12 CompositeHandleResolver, 13 DohJsonHandleResolver, 14 PlcDidDocumentResolver, 15 WebDidDocumentResolver, 16 WellKnownHandleResolver 17} from '@atcute/identity-resolver'; 18import { Client, simpleFetchHandler } from '@atcute/client'; 19import { type AppBskyActorDefs } from '@atcute/bluesky'; 20 21export type Collection = `${string}.${string}.${string}`; 22import * as TID from '@atcute/tid'; 23 24/** 25 * Parses an AT Protocol URI into its components. 26 * @param uri - The AT URI to parse (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 27 * @returns An object containing the repo, collection, and rkey or undefined if not an AT uri 28 */ 29export function parseUri(uri: string) { 30 const parts = parseResourceUri(uri); 31 if (!parts.ok) return; 32 return parts.value; 33} 34 35/** 36 * Resolves a handle to a DID using DNS and HTTP methods. 37 * @param handle - The handle to resolve (e.g., "alice.bsky.social") 38 * @returns The DID associated with the handle 39 */ 40export async function resolveHandle({ handle }: { handle: Handle }) { 41 const handleResolver = new CompositeHandleResolver({ 42 methods: { 43 dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), 44 http: new WellKnownHandleResolver() 45 } 46 }); 47 48 const data = await handleResolver.resolve(handle); 49 return data; 50} 51 52const didResolver = new CompositeDidDocumentResolver({ 53 methods: { 54 plc: new PlcDidDocumentResolver(), 55 web: new WebDidDocumentResolver() 56 } 57}); 58 59/** 60 * Gets the PDS (Personal Data Server) URL for a given DID. 61 * @param did - The DID to look up 62 * @returns The PDS service endpoint URL 63 * @throws If no PDS is found in the DID document 64 */ 65export async function getPDS(did: Did) { 66 const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>); 67 if (!doc.service) throw new Error('No PDS found'); 68 for (const service of doc.service) { 69 if (service.id === '#atproto_pds') { 70 return service.serviceEndpoint.toString(); 71 } 72 } 73} 74 75/** 76 * Fetches a detailed Bluesky profile for a user. 77 * @param data - Optional object with did and client 78 * @param data.did - The DID to fetch the profile for (defaults to current user) 79 * @param data.client - The client to use (defaults to public Bluesky API) 80 * @returns The profile data or undefined if not found 81 */ 82export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { 83 data ??= {}; 84 data.did ??= user.did; 85 86 if (!data.did) throw new Error('Error getting detailed profile: no did'); 87 88 data.client ??= new Client({ 89 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 90 }); 91 92 const response = await data.client.get('app.bsky.actor.getProfile', { 93 params: { actor: data.did } 94 }); 95 96 if (!response.ok || response.data.handle === 'handle.invalid') { 97 const repo = await describeRepo({ did: data.did }); 98 return { handle: repo?.handle ?? 'handle.invalid', did: data.did }; 99 } 100 101 return response.data; 102} 103 104/** 105 * Creates an AT Protocol client for a user's PDS. 106 * @param did - The DID of the user 107 * @returns A client configured for the user's PDS 108 * @throws If the PDS cannot be found 109 */ 110export async function getClient({ did }: { did: Did }) { 111 const pds = await getPDS(did); 112 if (!pds) throw new Error('PDS not found'); 113 114 const client = new Client({ 115 handler: simpleFetchHandler({ service: pds }) 116 }); 117 118 return client; 119} 120 121/** 122 * Lists records from a repository collection with pagination support. 123 * @param did - The DID of the repository (defaults to current user) 124 * @param collection - The collection to list records from 125 * @param cursor - Pagination cursor for continuing from a previous request 126 * @param limit - Maximum number of records to return (default 100, set to 0 for all records) 127 * @param client - The client to use (defaults to user's PDS client) 128 * @returns An array of records from the collection 129 */ 130export async function listRecords({ 131 did, 132 collection, 133 cursor, 134 limit = 100, 135 client 136}: { 137 did?: Did; 138 collection: `${string}.${string}.${string}`; 139 cursor?: string; 140 limit?: number; 141 client?: Client; 142}) { 143 did ??= user.did; 144 if (!collection) { 145 throw new Error('Missing parameters for listRecords'); 146 } 147 if (!did) { 148 throw new Error('Missing did for getRecord'); 149 } 150 151 client ??= await getClient({ did }); 152 153 const allRecords = []; 154 155 let currentCursor = cursor; 156 do { 157 const response = await client.get('com.atproto.repo.listRecords', { 158 params: { 159 repo: did, 160 collection, 161 limit: !limit || limit > 100 ? 100 : limit, 162 cursor: currentCursor 163 } 164 }); 165 166 if (!response.ok) { 167 return allRecords; 168 } 169 170 allRecords.push(...response.data.records); 171 currentCursor = response.data.cursor; 172 } while (currentCursor && (!limit || allRecords.length < limit)); 173 174 return allRecords; 175} 176 177/** 178 * Fetches a single record from a repository. 179 * @param did - The DID of the repository (defaults to current user) 180 * @param collection - The collection the record belongs to 181 * @param rkey - The record key (defaults to "self") 182 * @param client - The client to use (defaults to user's PDS client) 183 * @returns The record data 184 */ 185export async function getRecord({ 186 did, 187 collection, 188 rkey = 'self', 189 client 190}: { 191 did?: Did; 192 collection: Collection; 193 rkey?: string; 194 client?: Client; 195}) { 196 did ??= user.did; 197 198 if (!collection) { 199 throw new Error('Missing parameters for getRecord'); 200 } 201 if (!did) { 202 throw new Error('Missing did for getRecord'); 203 } 204 205 client ??= await getClient({ did }); 206 207 const record = await client.get('com.atproto.repo.getRecord', { 208 params: { 209 repo: did, 210 collection, 211 rkey 212 } 213 }); 214 215 return JSON.parse(JSON.stringify(record.data)); 216} 217 218/** 219 * Creates or updates a record in the current user's repository. 220 * Only accepts collections that are configured in permissions. 221 * @param collection - The collection to write to (must be in permissions.collections) 222 * @param rkey - The record key (defaults to "self") 223 * @param record - The record data to write 224 * @returns The response from the PDS 225 * @throws If the user is not logged in 226 */ 227export async function putRecord({ 228 collection, 229 rkey = 'self', 230 record 231}: { 232 collection: AllowedCollection; 233 rkey?: string; 234 record: Record<string, unknown>; 235}) { 236 if (!user.client || !user.did) throw new Error('No rpc or did'); 237 238 const response = await user.client.post('com.atproto.repo.putRecord', { 239 input: { 240 collection, 241 repo: user.did, 242 rkey, 243 record: { 244 ...record 245 } 246 } 247 }); 248 249 return response; 250} 251 252/** 253 * Deletes a record from the current user's repository. 254 * Only accepts collections that are configured in permissions. 255 * @param collection - The collection the record belongs to (must be in permissions.collections) 256 * @param rkey - The record key (defaults to "self") 257 * @returns True if the deletion was successful 258 * @throws If the user is not logged in 259 */ 260export async function deleteRecord({ 261 collection, 262 rkey = 'self' 263}: { 264 collection: AllowedCollection; 265 rkey: string; 266}) { 267 if (!user.client || !user.did) throw new Error('No profile or rpc or did'); 268 269 const response = await user.client.post('com.atproto.repo.deleteRecord', { 270 input: { 271 collection, 272 repo: user.did, 273 rkey 274 } 275 }); 276 277 return response.ok; 278} 279 280/** 281 * Uploads a blob to the current user's PDS. 282 * @param blob - The blob data to upload 283 * @returns The blob metadata including ref, mimeType, and size, or undefined on failure 284 * @throws If the user is not logged in 285 */ 286export async function uploadBlob({ blob }: { blob: Blob }) { 287 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in"); 288 289 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', { 290 params: { 291 repo: user.did 292 }, 293 input: blob 294 }); 295 296 if (!blobResponse?.ok) return; 297 298 const blobInfo = blobResponse?.data.blob as { 299 $type: 'blob'; 300 ref: { 301 $link: string; 302 }; 303 mimeType: string; 304 size: number; 305 }; 306 307 return blobInfo; 308} 309 310/** 311 * Gets metadata about a repository. 312 * @param client - The client to use 313 * @param did - The DID of the repository (defaults to current user) 314 * @returns Repository metadata or undefined on failure 315 */ 316export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 317 did ??= user.did; 318 if (!did) { 319 throw new Error('Error describeRepo: No did'); 320 } 321 client ??= await getClient({ did }); 322 323 const repo = await client.get('com.atproto.repo.describeRepo', { 324 params: { 325 repo: did 326 } 327 }); 328 if (!repo.ok) return; 329 330 return repo.data; 331} 332 333/** 334 * Constructs a URL to fetch a blob directly from a user's PDS. 335 * @param did - The DID of the user who owns the blob 336 * @param blob - The blob reference object 337 * @returns The URL to fetch the blob 338 */ 339export async function getBlobURL({ 340 did, 341 blob 342}: { 343 did: Did; 344 blob: { 345 $type: 'blob'; 346 ref: { 347 $link: string; 348 }; 349 }; 350}) { 351 const pds = await getPDS(did); 352 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; 353} 354 355/** 356 * Constructs a Bluesky CDN URL for an image blob. 357 * @param did - The DID of the user who owns the blob (defaults to current user) 358 * @param blob - The blob reference object 359 * @returns The CDN URL for the image in webp format 360 */ 361export function getCDNImageBlobUrl({ 362 did, 363 blob 364}: { 365 did?: string; 366 blob: { 367 $type: 'blob'; 368 ref: { 369 $link: string; 370 }; 371 }; 372}) { 373 did ??= user.did; 374 375 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 376} 377 378/** 379 * Searches for actors with typeahead/autocomplete functionality. 380 * @param q - The search query 381 * @param limit - Maximum number of results (default 10) 382 * @param host - The API host to use (defaults to public Bluesky API) 383 * @returns An object containing matching actors and the original query 384 */ 385export async function searchActorsTypeahead( 386 q: string, 387 limit: number = 10, 388 host?: string 389): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> { 390 host ??= 'https://public.api.bsky.app'; 391 392 const client = new Client({ 393 handler: simpleFetchHandler({ service: host }) 394 }); 395 396 const response = await client.get('app.bsky.actor.searchActorsTypeahead', { 397 params: { 398 q, 399 limit 400 } 401 }); 402 403 if (!response.ok) return { actors: [], q }; 404 405 return { actors: response.data.actors, q }; 406} 407 408/** 409 * Return a TID based on current time 410 * 411 * @returns TID for current time 412 */ 413export function createTID() { 414 return TID.now(); 415} 416 417export async function getAuthorFeed(data?: { 418 did?: Did; 419 client?: Client; 420 filter?: string; 421 limit?: number; 422}) { 423 data ??= {}; 424 data.did ??= user.did; 425 426 if (!data.did) throw new Error('Error getting detailed profile: no did'); 427 428 data.client ??= new Client({ 429 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 430 }); 431 432 const response = await data.client.get('app.bsky.feed.getAuthorFeed', { 433 params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 } 434 }); 435 436 if (!response.ok) return; 437 438 return response.data; 439} 440 441/** 442 * Fetches posts by their AT URIs. 443 * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 444 * @param client - The client to use (defaults to public Bluesky API) 445 * @returns Array of posts or undefined on failure 446 */ 447export async function getPosts(data: { uris: string[]; client?: Client }) { 448 data.client ??= new Client({ 449 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 450 }); 451 452 const response = await data.client.get('app.bsky.feed.getPosts', { 453 params: { uris: data.uris as ResourceUri[] } 454 }); 455 456 if (!response.ok) return; 457 458 return response.data.posts; 459} 460 461export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier { 462 if (profile.handle && profile.handle !== 'handle.invalid') { 463 return profile.handle; 464 } else { 465 return profile.did; 466 } 467}