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) return;
97
98 return response.data;
99}
100
101/**
102 * Creates an AT Protocol client for a user's PDS.
103 * @param did - The DID of the user
104 * @returns A client configured for the user's PDS
105 * @throws If the PDS cannot be found
106 */
107export async function getClient({ did }: { did: Did }) {
108 const pds = await getPDS(did);
109 if (!pds) throw new Error('PDS not found');
110
111 const client = new Client({
112 handler: simpleFetchHandler({ service: pds })
113 });
114
115 return client;
116}
117
118/**
119 * Lists records from a repository collection with pagination support.
120 * @param did - The DID of the repository (defaults to current user)
121 * @param collection - The collection to list records from
122 * @param cursor - Pagination cursor for continuing from a previous request
123 * @param limit - Maximum number of records to return (default 100, set to 0 for all records)
124 * @param client - The client to use (defaults to user's PDS client)
125 * @returns An array of records from the collection
126 */
127export async function listRecords({
128 did,
129 collection,
130 cursor,
131 limit = 100,
132 client
133}: {
134 did?: Did;
135 collection: `${string}.${string}.${string}`;
136 cursor?: string;
137 limit?: number;
138 client?: Client;
139}) {
140 did ??= user.did;
141 if (!collection) {
142 throw new Error('Missing parameters for listRecords');
143 }
144 if (!did) {
145 throw new Error('Missing did for getRecord');
146 }
147
148 client ??= await getClient({ did });
149
150 const allRecords = [];
151
152 let currentCursor = cursor;
153 do {
154 const response = await client.get('com.atproto.repo.listRecords', {
155 params: {
156 repo: did,
157 collection,
158 limit: !limit || limit > 100 ? 100 : limit,
159 cursor: currentCursor
160 }
161 });
162
163 if (!response.ok) {
164 return allRecords;
165 }
166
167 allRecords.push(...response.data.records);
168 currentCursor = response.data.cursor;
169 } while (currentCursor && (!limit || allRecords.length < limit));
170
171 return allRecords;
172}
173
174/**
175 * Fetches a single record from a repository.
176 * @param did - The DID of the repository (defaults to current user)
177 * @param collection - The collection the record belongs to
178 * @param rkey - The record key (defaults to "self")
179 * @param client - The client to use (defaults to user's PDS client)
180 * @returns The record data
181 */
182export async function getRecord({
183 did,
184 collection,
185 rkey = 'self',
186 client
187}: {
188 did?: Did;
189 collection: Collection;
190 rkey?: string;
191 client?: Client;
192}) {
193 did ??= user.did;
194
195 if (!collection) {
196 throw new Error('Missing parameters for getRecord');
197 }
198 if (!did) {
199 throw new Error('Missing did for getRecord');
200 }
201
202 client ??= await getClient({ did });
203
204 const record = await client.get('com.atproto.repo.getRecord', {
205 params: {
206 repo: did,
207 collection,
208 rkey
209 }
210 });
211
212 return JSON.parse(JSON.stringify(record.data));
213}
214
215/**
216 * Creates or updates a record in the current user's repository.
217 * Only accepts collections that are configured in permissions.
218 * @param collection - The collection to write to (must be in permissions.collections)
219 * @param rkey - The record key (defaults to "self")
220 * @param record - The record data to write
221 * @returns The response from the PDS
222 * @throws If the user is not logged in
223 */
224export async function putRecord({
225 collection,
226 rkey = 'self',
227 record
228}: {
229 collection: AllowedCollection;
230 rkey?: string;
231 record: Record<string, unknown>;
232}) {
233 if (!user.client || !user.did) throw new Error('No rpc or did');
234
235 const response = await user.client.post('com.atproto.repo.putRecord', {
236 input: {
237 collection,
238 repo: user.did,
239 rkey,
240 record: {
241 ...record
242 }
243 }
244 });
245
246 return response;
247}
248
249/**
250 * Deletes a record from the current user's repository.
251 * Only accepts collections that are configured in permissions.
252 * @param collection - The collection the record belongs to (must be in permissions.collections)
253 * @param rkey - The record key (defaults to "self")
254 * @returns True if the deletion was successful
255 * @throws If the user is not logged in
256 */
257export async function deleteRecord({
258 collection,
259 rkey = 'self'
260}: {
261 collection: AllowedCollection;
262 rkey: string;
263}) {
264 if (!user.client || !user.did) throw new Error('No profile or rpc or did');
265
266 const response = await user.client.post('com.atproto.repo.deleteRecord', {
267 input: {
268 collection,
269 repo: user.did,
270 rkey
271 }
272 });
273
274 return response.ok;
275}
276
277/**
278 * Uploads a blob to the current user's PDS.
279 * @param blob - The blob data to upload
280 * @returns The blob metadata including ref, mimeType, and size, or undefined on failure
281 * @throws If the user is not logged in
282 */
283export async function uploadBlob({ blob }: { blob: Blob }) {
284 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in");
285
286 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', {
287 params: {
288 repo: user.did
289 },
290 input: blob
291 });
292
293 if (!blobResponse?.ok) return;
294
295 const blobInfo = blobResponse?.data.blob as {
296 $type: 'blob';
297 ref: {
298 $link: string;
299 };
300 mimeType: string;
301 size: number;
302 };
303
304 return blobInfo;
305}
306
307/**
308 * Gets metadata about a repository.
309 * @param client - The client to use
310 * @param did - The DID of the repository (defaults to current user)
311 * @returns Repository metadata or undefined on failure
312 */
313export async function describeRepo({ client, did }: { client?: Client; did?: Did }) {
314 did ??= user.did;
315 if (!did) {
316 throw new Error('Error describeRepo: No did');
317 }
318 client ??= await getClient({ did });
319
320 const repo = await client.get('com.atproto.repo.describeRepo', {
321 params: {
322 repo: did
323 }
324 });
325 if (!repo.ok) return;
326
327 return repo.data;
328}
329
330/**
331 * Constructs a URL to fetch a blob directly from a user's PDS.
332 * @param did - The DID of the user who owns the blob
333 * @param blob - The blob reference object
334 * @returns The URL to fetch the blob
335 */
336export async function getBlobURL({
337 did,
338 blob
339}: {
340 did: Did;
341 blob: {
342 $type: 'blob';
343 ref: {
344 $link: string;
345 };
346 };
347}) {
348 const pds = await getPDS(did);
349 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`;
350}
351
352/**
353 * Constructs a Bluesky CDN URL for an image blob.
354 * @param did - The DID of the user who owns the blob (defaults to current user)
355 * @param blob - The blob reference object
356 * @returns The CDN URL for the image in webp format
357 */
358export function getCDNImageBlobUrl({
359 did,
360 blob
361}: {
362 did?: string;
363 blob: {
364 $type: 'blob';
365 ref: {
366 $link: string;
367 };
368 };
369}) {
370 did ??= user.did;
371
372 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
373}
374
375/**
376 * Searches for actors with typeahead/autocomplete functionality.
377 * @param q - The search query
378 * @param limit - Maximum number of results (default 10)
379 * @param host - The API host to use (defaults to public Bluesky API)
380 * @returns An object containing matching actors and the original query
381 */
382export async function searchActorsTypeahead(
383 q: string,
384 limit: number = 10,
385 host?: string
386): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> {
387 host ??= 'https://public.api.bsky.app';
388
389 const client = new Client({
390 handler: simpleFetchHandler({ service: host })
391 });
392
393 const response = await client.get('app.bsky.actor.searchActorsTypeahead', {
394 params: {
395 q,
396 limit
397 }
398 });
399
400 if (!response.ok) return { actors: [], q };
401
402 return { actors: response.data.actors, q };
403}
404
405/**
406 * Return a TID based on current time
407 *
408 * @returns TID for current time
409 */
410export function createTID() {
411 return TID.now();
412}
413
414export async function getAuthorFeed(data?: {
415 did?: Did;
416 client?: Client;
417 filter?: string;
418 limit?: number;
419}) {
420 data ??= {};
421 data.did ??= user.did;
422
423 if (!data.did) throw new Error('Error getting detailed profile: no did');
424
425 data.client ??= new Client({
426 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
427 });
428
429 const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
430 params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 }
431 });
432
433 if (!response.ok) return;
434
435 return response.data;
436}
437
438/**
439 * Fetches posts by their AT URIs.
440 * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123")
441 * @param client - The client to use (defaults to public Bluesky API)
442 * @returns Array of posts or undefined on failure
443 */
444export async function getPosts(data: { uris: string[]; client?: Client }) {
445 data.client ??= new Client({
446 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
447 });
448
449 const response = await data.client.get('app.bsky.feed.getPosts', {
450 params: { uris: data.uris as ResourceUri[] }
451 });
452
453 if (!response.ok) return;
454
455 return response.data.posts;
456}
457
458export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier {
459 if (profile.handle && profile.handle !== 'handle.invalid') {
460 return profile.handle;
461 } else {
462 return profile.did;
463 }
464}