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