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