your personal website on atproto - mirror blento.app
at custom-domains 575 lines 14 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 104export async function getBlentoOrBskyProfile(data: { did: Did; client?: Client }): Promise< 105 Awaited<ReturnType<typeof getDetailedProfile>> & { 106 hasBlento: boolean; 107 url?: string; 108 } 109> { 110 let blentoProfile; 111 try { 112 // try getting blento profile first 113 blentoProfile = await getRecord({ 114 collection: 'site.standard.publication', 115 did: data?.did, 116 rkey: 'blento.self', 117 client: data?.client 118 }); 119 } catch { 120 console.error('error getting blento profile, falling back to bsky profile'); 121 } 122 123 const response = await getDetailedProfile(data); 124 125 return { 126 did: data.did, 127 handle: response?.handle, 128 displayName: blentoProfile?.value?.name || response?.displayName || response?.handle, 129 avatar: (getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon }) || 130 response?.avatar) as `${string}:${string}`, 131 hasBlento: Boolean(blentoProfile.value), 132 url: blentoProfile?.value?.url as string | undefined 133 }; 134} 135 136/** 137 * Creates an AT Protocol client for a user's PDS. 138 * @param did - The DID of the user 139 * @returns A client configured for the user's PDS 140 * @throws If the PDS cannot be found 141 */ 142export async function getClient({ did }: { did: Did }) { 143 const pds = await getPDS(did); 144 if (!pds) throw new Error('PDS not found'); 145 146 const client = new Client({ 147 handler: simpleFetchHandler({ service: pds }) 148 }); 149 150 return client; 151} 152 153/** 154 * Lists records from a repository collection with pagination support. 155 * @param did - The DID of the repository (defaults to current user) 156 * @param collection - The collection to list records from 157 * @param cursor - Pagination cursor for continuing from a previous request 158 * @param limit - Maximum number of records to return (default 100, set to 0 for all records) 159 * @param client - The client to use (defaults to user's PDS client) 160 * @returns An array of records from the collection 161 */ 162export async function listRecords({ 163 did, 164 collection, 165 cursor, 166 limit = 100, 167 client 168}: { 169 did?: Did; 170 collection: `${string}.${string}.${string}`; 171 cursor?: string; 172 limit?: number; 173 client?: Client; 174}) { 175 did ??= user.did; 176 if (!collection) { 177 throw new Error('Missing parameters for listRecords'); 178 } 179 if (!did) { 180 throw new Error('Missing did for getRecord'); 181 } 182 183 client ??= await getClient({ did }); 184 185 const allRecords = []; 186 187 let currentCursor = cursor; 188 do { 189 const response = await client.get('com.atproto.repo.listRecords', { 190 params: { 191 repo: did, 192 collection, 193 limit: !limit || limit > 100 ? 100 : limit, 194 cursor: currentCursor 195 } 196 }); 197 198 if (!response.ok) { 199 return allRecords; 200 } 201 202 allRecords.push(...response.data.records); 203 currentCursor = response.data.cursor; 204 } while (currentCursor && (!limit || allRecords.length < limit)); 205 206 return allRecords; 207} 208 209/** 210 * Fetches a single record from a repository. 211 * @param did - The DID of the repository (defaults to current user) 212 * @param collection - The collection the record belongs to 213 * @param rkey - The record key (defaults to "self") 214 * @param client - The client to use (defaults to user's PDS client) 215 * @returns The record data 216 */ 217export async function getRecord({ 218 did, 219 collection, 220 rkey = 'self', 221 client 222}: { 223 did?: Did; 224 collection: Collection; 225 rkey?: string; 226 client?: Client; 227}) { 228 did ??= user.did; 229 230 if (!collection) { 231 throw new Error('Missing parameters for getRecord'); 232 } 233 if (!did) { 234 throw new Error('Missing did for getRecord'); 235 } 236 237 client ??= await getClient({ did }); 238 239 const record = await client.get('com.atproto.repo.getRecord', { 240 params: { 241 repo: did, 242 collection, 243 rkey 244 } 245 }); 246 247 return JSON.parse(JSON.stringify(record.data)); 248} 249 250/** 251 * Creates or updates a record in the current user's repository. 252 * Only accepts collections that are configured in permissions. 253 * @param collection - The collection to write to (must be in permissions.collections) 254 * @param rkey - The record key (defaults to "self") 255 * @param record - The record data to write 256 * @returns The response from the PDS 257 * @throws If the user is not logged in 258 */ 259export async function putRecord({ 260 collection, 261 rkey = 'self', 262 record 263}: { 264 collection: AllowedCollection; 265 rkey?: string; 266 record: Record<string, unknown>; 267}) { 268 if (!user.client || !user.did) throw new Error('No rpc or did'); 269 270 const response = await user.client.post('com.atproto.repo.putRecord', { 271 input: { 272 collection, 273 repo: user.did, 274 rkey, 275 record: { 276 ...record 277 } 278 } 279 }); 280 281 return response; 282} 283 284/** 285 * Deletes a record from the current user's repository. 286 * Only accepts collections that are configured in permissions. 287 * @param collection - The collection the record belongs to (must be in permissions.collections) 288 * @param rkey - The record key (defaults to "self") 289 * @returns True if the deletion was successful 290 * @throws If the user is not logged in 291 */ 292export async function deleteRecord({ 293 collection, 294 rkey = 'self' 295}: { 296 collection: AllowedCollection; 297 rkey: string; 298}) { 299 if (!user.client || !user.did) throw new Error('No profile or rpc or did'); 300 301 const response = await user.client.post('com.atproto.repo.deleteRecord', { 302 input: { 303 collection, 304 repo: user.did, 305 rkey 306 } 307 }); 308 309 return response.ok; 310} 311 312/** 313 * Uploads a blob to the current user's PDS. 314 * @param blob - The blob data to upload 315 * @returns The blob metadata including ref, mimeType, and size, or undefined on failure 316 * @throws If the user is not logged in 317 */ 318export async function uploadBlob({ blob }: { blob: Blob }) { 319 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in"); 320 321 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', { 322 params: { 323 repo: user.did 324 }, 325 input: blob 326 }); 327 328 if (!blobResponse?.ok) return; 329 330 const blobInfo = blobResponse?.data.blob as { 331 $type: 'blob'; 332 ref: { 333 $link: string; 334 }; 335 mimeType: string; 336 size: number; 337 }; 338 339 return blobInfo; 340} 341 342/** 343 * Gets metadata about a repository. 344 * @param client - The client to use 345 * @param did - The DID of the repository (defaults to current user) 346 * @returns Repository metadata or undefined on failure 347 */ 348export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 349 did ??= user.did; 350 if (!did) { 351 throw new Error('Error describeRepo: No did'); 352 } 353 client ??= await getClient({ did }); 354 355 const repo = await client.get('com.atproto.repo.describeRepo', { 356 params: { 357 repo: did 358 } 359 }); 360 if (!repo.ok) return; 361 362 return repo.data; 363} 364 365/** 366 * Constructs a URL to fetch a blob directly from a user's PDS. 367 * @param did - The DID of the user who owns the blob 368 * @param blob - The blob reference object 369 * @returns The URL to fetch the blob 370 */ 371export async function getBlobURL({ 372 did, 373 blob 374}: { 375 did: Did; 376 blob: { 377 $type: 'blob'; 378 ref: { 379 $link: string; 380 }; 381 }; 382}) { 383 const pds = await getPDS(did); 384 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; 385} 386 387/** 388 * Constructs a Bluesky CDN URL for an image blob. 389 * @param did - The DID of the user who owns the blob (defaults to current user) 390 * @param blob - The blob reference object 391 * @returns The CDN URL for the image in webp format 392 */ 393export function getCDNImageBlobUrl({ 394 did, 395 blob 396}: { 397 did?: string; 398 blob: { 399 $type: 'blob'; 400 ref: { 401 $link: string; 402 }; 403 }; 404}) { 405 if (!blob || !did) return; 406 did ??= user.did; 407 408 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 409} 410 411/** 412 * Searches for actors with typeahead/autocomplete functionality. 413 * @param q - The search query 414 * @param limit - Maximum number of results (default 10) 415 * @param host - The API host to use (defaults to public Bluesky API) 416 * @returns An object containing matching actors and the original query 417 */ 418export async function searchActorsTypeahead( 419 q: string, 420 limit: number = 10, 421 host?: string 422): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> { 423 host ??= 'https://public.api.bsky.app'; 424 425 const client = new Client({ 426 handler: simpleFetchHandler({ service: host }) 427 }); 428 429 const response = await client.get('app.bsky.actor.searchActorsTypeahead', { 430 params: { 431 q, 432 limit 433 } 434 }); 435 436 if (!response.ok) return { actors: [], q }; 437 438 return { actors: response.data.actors, q }; 439} 440 441/** 442 * Return a TID based on current time 443 * 444 * @returns TID for current time 445 */ 446export function createTID() { 447 return TID.now(); 448} 449 450export async function getAuthorFeed(data?: { 451 did?: Did; 452 client?: Client; 453 filter?: string; 454 limit?: number; 455 cursor?: string; 456}) { 457 data ??= {}; 458 data.did ??= user.did; 459 460 if (!data.did) throw new Error('Error getting detailed profile: no did'); 461 462 data.client ??= new Client({ 463 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 464 }); 465 466 const response = await data.client.get('app.bsky.feed.getAuthorFeed', { 467 params: { 468 actor: data.did, 469 filter: data.filter ?? 'posts_with_media', 470 limit: data.limit || 100, 471 cursor: data.cursor 472 } 473 }); 474 475 if (!response.ok) return; 476 477 return response.data; 478} 479 480/** 481 * Fetches posts by their AT URIs. 482 * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 483 * @param client - The client to use (defaults to public Bluesky API) 484 * @returns Array of posts or undefined on failure 485 */ 486export async function getPosts(data: { uris: string[]; client?: Client }) { 487 data.client ??= new Client({ 488 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 489 }); 490 491 const response = await data.client.get('app.bsky.feed.getPosts', { 492 params: { uris: data.uris as ResourceUri[] } 493 }); 494 495 if (!response.ok) return; 496 497 return response.data.posts; 498} 499 500export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier { 501 if (profile.handle && profile.handle !== 'handle.invalid') { 502 return profile.handle; 503 } else { 504 return profile.did; 505 } 506} 507 508/** 509 * Fetches a post's thread including replies. 510 * @param uri - The AT URI of the post 511 * @param depth - How many levels of replies to fetch (default 1) 512 * @param client - The client to use (defaults to public Bluesky API) 513 * @returns The thread data or undefined on failure 514 */ 515export async function getPostThread({ 516 uri, 517 depth = 1, 518 client 519}: { 520 uri: string; 521 depth?: number; 522 client?: Client; 523}) { 524 client ??= new Client({ 525 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 526 }); 527 528 const response = await client.get('app.bsky.feed.getPostThread', { 529 params: { uri: uri as ResourceUri, depth } 530 }); 531 532 if (!response.ok) return; 533 534 return response.data.thread; 535} 536 537/** 538 * Creates a Bluesky post on the authenticated user's account. 539 * @param text - The post text 540 * @param facets - Optional rich text facets (links, mentions, etc.) 541 * @returns The response containing the post's URI and CID 542 * @throws If the user is not logged in 543 */ 544export async function createPost({ 545 text, 546 facets 547}: { 548 text: string; 549 facets?: Array<{ 550 index: { byteStart: number; byteEnd: number }; 551 features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>; 552 }>; 553}) { 554 if (!user.client || !user.did) throw new Error('No client or did'); 555 556 const record: Record<string, unknown> = { 557 $type: 'app.bsky.feed.post', 558 text, 559 createdAt: new Date().toISOString() 560 }; 561 562 if (facets) { 563 record.facets = facets; 564 } 565 566 const response = await user.client.post('com.atproto.repo.createRecord', { 567 input: { 568 collection: 'app.bsky.feed.post', 569 repo: user.did, 570 record 571 } 572 }); 573 574 return response; 575}