your personal website on atproto - mirror
blento.app
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}