your personal website on atproto - mirror blento.app
at improve-oauth-permissions 580 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 return JSON.parse(JSON.stringify(record.data)); 251} 252 253/** 254 * Creates or updates a record in the current user's repository. 255 * Only accepts collections that are configured in permissions. 256 * @param collection - The collection to write to (must be in permissions.collections) 257 * @param rkey - The record key (defaults to "self") 258 * @param record - The record data to write 259 * @returns The response from the PDS 260 * @throws If the user is not logged in 261 */ 262export async function putRecord({ 263 collection, 264 rkey = 'self', 265 record 266}: { 267 collection: AllowedCollection; 268 rkey?: string; 269 record: Record<string, unknown>; 270}) { 271 if (!user.client || !user.did) throw new Error('No rpc or did'); 272 273 const response = await user.client.post('com.atproto.repo.putRecord', { 274 input: { 275 collection, 276 repo: user.did, 277 rkey, 278 record: { 279 ...record 280 } 281 } 282 }); 283 284 return response; 285} 286 287/** 288 * Deletes a record from the current user's repository. 289 * Only accepts collections that are configured in permissions. 290 * @param collection - The collection the record belongs to (must be in permissions.collections) 291 * @param rkey - The record key (defaults to "self") 292 * @returns True if the deletion was successful 293 * @throws If the user is not logged in 294 */ 295export async function deleteRecord({ 296 collection, 297 rkey = 'self' 298}: { 299 collection: AllowedCollection; 300 rkey: string; 301}) { 302 if (!user.client || !user.did) throw new Error('No profile or rpc or did'); 303 304 const response = await user.client.post('com.atproto.repo.deleteRecord', { 305 input: { 306 collection, 307 repo: user.did, 308 rkey 309 } 310 }); 311 312 return response.ok; 313} 314 315/** 316 * Uploads a blob to the current user's PDS. 317 * @param blob - The blob data to upload 318 * @returns The blob metadata including ref, mimeType, and size, or undefined on failure 319 * @throws If the user is not logged in 320 */ 321export async function uploadBlob({ blob }: { blob: Blob }) { 322 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in"); 323 324 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', { 325 params: { 326 repo: user.did 327 }, 328 input: blob 329 }); 330 331 if (!blobResponse?.ok) return; 332 333 const blobInfo = blobResponse?.data.blob as { 334 $type: 'blob'; 335 ref: { 336 $link: string; 337 }; 338 mimeType: string; 339 size: number; 340 }; 341 342 return blobInfo; 343} 344 345/** 346 * Gets metadata about a repository. 347 * @param client - The client to use 348 * @param did - The DID of the repository (defaults to current user) 349 * @returns Repository metadata or undefined on failure 350 */ 351export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 352 did ??= user.did; 353 if (!did) { 354 throw new Error('Error describeRepo: No did'); 355 } 356 client ??= await getClient({ did }); 357 358 const repo = await client.get('com.atproto.repo.describeRepo', { 359 params: { 360 repo: did 361 } 362 }); 363 if (!repo.ok) return; 364 365 return repo.data; 366} 367 368/** 369 * Constructs a URL to fetch a blob directly from a user's PDS. 370 * @param did - The DID of the user who owns the blob 371 * @param blob - The blob reference object 372 * @returns The URL to fetch the blob 373 */ 374export async function getBlobURL({ 375 did, 376 blob 377}: { 378 did: Did; 379 blob: { 380 $type: 'blob'; 381 ref: { 382 $link: string; 383 }; 384 }; 385}) { 386 const pds = await getPDS(did); 387 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; 388} 389 390/** 391 * Constructs a Bluesky CDN URL for an image blob. 392 * @param did - The DID of the user who owns the blob (defaults to current user) 393 * @param blob - The blob reference object 394 * @returns The CDN URL for the image in webp format 395 */ 396export function getCDNImageBlobUrl({ 397 did, 398 blob, 399 type = 'webp' 400}: { 401 did?: string; 402 blob: { 403 $type: 'blob'; 404 ref: { 405 $link: string; 406 }; 407 }; 408 type?: 'webp' | 'jpeg'; 409}) { 410 if (!blob || !did) return; 411 did ??= user.did; 412 413 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@${type}`; 414} 415 416/** 417 * Searches for actors with typeahead/autocomplete functionality. 418 * @param q - The search query 419 * @param limit - Maximum number of results (default 10) 420 * @param host - The API host to use (defaults to public Bluesky API) 421 * @returns An object containing matching actors and the original query 422 */ 423export async function searchActorsTypeahead( 424 q: string, 425 limit: number = 10, 426 host?: string 427): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> { 428 host ??= 'https://public.api.bsky.app'; 429 430 const client = new Client({ 431 handler: simpleFetchHandler({ service: host }) 432 }); 433 434 const response = await client.get('app.bsky.actor.searchActorsTypeahead', { 435 params: { 436 q, 437 limit 438 } 439 }); 440 441 if (!response.ok) return { actors: [], q }; 442 443 return { actors: response.data.actors, q }; 444} 445 446/** 447 * Return a TID based on current time 448 * 449 * @returns TID for current time 450 */ 451export function createTID() { 452 return TID.now(); 453} 454 455export async function getAuthorFeed(data?: { 456 did?: Did; 457 client?: Client; 458 filter?: string; 459 limit?: number; 460 cursor?: string; 461}) { 462 data ??= {}; 463 data.did ??= user.did; 464 465 if (!data.did) throw new Error('Error getting detailed profile: no did'); 466 467 data.client ??= new Client({ 468 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 469 }); 470 471 const response = await data.client.get('app.bsky.feed.getAuthorFeed', { 472 params: { 473 actor: data.did, 474 filter: data.filter ?? 'posts_with_media', 475 limit: data.limit || 100, 476 cursor: data.cursor 477 } 478 }); 479 480 if (!response.ok) return; 481 482 return response.data; 483} 484 485/** 486 * Fetches posts by their AT URIs. 487 * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 488 * @param client - The client to use (defaults to public Bluesky API) 489 * @returns Array of posts or undefined on failure 490 */ 491export async function getPosts(data: { uris: string[]; client?: Client }) { 492 data.client ??= new Client({ 493 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 494 }); 495 496 const response = await data.client.get('app.bsky.feed.getPosts', { 497 params: { uris: data.uris as ResourceUri[] } 498 }); 499 500 if (!response.ok) return; 501 502 return response.data.posts; 503} 504 505export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier { 506 if (profile.handle && profile.handle !== 'handle.invalid') { 507 return profile.handle; 508 } else { 509 return profile.did; 510 } 511} 512 513/** 514 * Fetches a post's thread including replies. 515 * @param uri - The AT URI of the post 516 * @param depth - How many levels of replies to fetch (default 1) 517 * @param client - The client to use (defaults to public Bluesky API) 518 * @returns The thread data or undefined on failure 519 */ 520export async function getPostThread({ 521 uri, 522 depth = 1, 523 client 524}: { 525 uri: string; 526 depth?: number; 527 client?: Client; 528}) { 529 client ??= new Client({ 530 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 531 }); 532 533 const response = await client.get('app.bsky.feed.getPostThread', { 534 params: { uri: uri as ResourceUri, depth } 535 }); 536 537 if (!response.ok) return; 538 539 return response.data.thread; 540} 541 542/** 543 * Creates a Bluesky post on the authenticated user's account. 544 * @param text - The post text 545 * @param facets - Optional rich text facets (links, mentions, etc.) 546 * @returns The response containing the post's URI and CID 547 * @throws If the user is not logged in 548 */ 549export async function createPost({ 550 text, 551 facets 552}: { 553 text: string; 554 facets?: Array<{ 555 index: { byteStart: number; byteEnd: number }; 556 features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>; 557 }>; 558}) { 559 if (!user.client || !user.did) throw new Error('No client or did'); 560 561 const record: Record<string, unknown> = { 562 $type: 'app.bsky.feed.post', 563 text, 564 createdAt: new Date().toISOString() 565 }; 566 567 if (facets) { 568 record.facets = facets; 569 } 570 571 const response = await user.client.post('com.atproto.repo.createRecord', { 572 input: { 573 collection: 'app.bsky.feed.post', 574 repo: user.did, 575 record 576 } 577 }); 578 579 return response; 580}