@ewanc26/atproto 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fetch.ts ADDED
@@ -0,0 +1,355 @@
1
+ import { cache } from './cache.js';
2
+ import { withFallback, resolveIdentity } from './agents.js';
3
+ import { buildPdsBlobUrl } from './media.js';
4
+ import { findArtwork } from './musicbrainz.js';
5
+ import type {
6
+ ProfileData,
7
+ SiteInfoData,
8
+ LinkData,
9
+ MusicStatusData,
10
+ KibunStatusData,
11
+ TangledRepo,
12
+ TangledReposData
13
+ } from './types.js';
14
+
15
+ export async function fetchProfile(did: string, fetchFn?: typeof fetch): Promise<ProfileData> {
16
+ const cacheKey = `profile:${did}`;
17
+ const cached = cache.get<ProfileData>(cacheKey);
18
+ if (cached) return cached;
19
+
20
+ const profile = await withFallback(
21
+ did,
22
+ async (agent) => {
23
+ const response = await agent.getProfile({ actor: did });
24
+ return response.data;
25
+ },
26
+ false,
27
+ fetchFn
28
+ );
29
+
30
+ let pronouns: string | undefined;
31
+ try {
32
+ const recordResponse = await withFallback(
33
+ did,
34
+ async (agent) => {
35
+ const response = await agent.com.atproto.repo.getRecord({
36
+ repo: did,
37
+ collection: 'app.bsky.actor.profile',
38
+ rkey: 'self'
39
+ });
40
+ return response.data;
41
+ },
42
+ false,
43
+ fetchFn
44
+ );
45
+ pronouns = (recordResponse.value as any).pronouns;
46
+ } catch { /* pronouns optional */ }
47
+
48
+ const data: ProfileData = {
49
+ did: profile.did,
50
+ handle: profile.handle,
51
+ displayName: profile.displayName,
52
+ description: profile.description,
53
+ avatar: profile.avatar,
54
+ banner: profile.banner,
55
+ followersCount: profile.followersCount,
56
+ followsCount: profile.followsCount,
57
+ postsCount: profile.postsCount,
58
+ pronouns
59
+ };
60
+
61
+ cache.set(cacheKey, data);
62
+ return data;
63
+ }
64
+
65
+ export async function fetchSiteInfo(
66
+ did: string,
67
+ fetchFn?: typeof fetch
68
+ ): Promise<SiteInfoData | null> {
69
+ const cacheKey = `siteinfo:${did}`;
70
+ const cached = cache.get<SiteInfoData>(cacheKey);
71
+ if (cached) return cached;
72
+
73
+ try {
74
+ const result = await withFallback(
75
+ did,
76
+ async (agent) => {
77
+ try {
78
+ const response = await agent.com.atproto.repo.getRecord({
79
+ repo: did,
80
+ collection: 'uk.ewancroft.site.info',
81
+ rkey: 'self'
82
+ });
83
+ return response.data;
84
+ } catch (err: any) {
85
+ if (err.error === 'RecordNotFound') return null;
86
+ throw err;
87
+ }
88
+ },
89
+ true,
90
+ fetchFn
91
+ );
92
+
93
+ if (!result?.value) return null;
94
+ const data = result.value as SiteInfoData;
95
+ cache.set(cacheKey, data);
96
+ return data;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ export async function fetchLinks(
103
+ did: string,
104
+ fetchFn?: typeof fetch
105
+ ): Promise<LinkData | null> {
106
+ const cacheKey = `links:${did}`;
107
+ const cached = cache.get<LinkData>(cacheKey);
108
+ if (cached) return cached;
109
+
110
+ try {
111
+ const value = await withFallback(
112
+ did,
113
+ async (agent) => {
114
+ const response = await agent.com.atproto.repo.getRecord({
115
+ repo: did,
116
+ collection: 'blue.linkat.board',
117
+ rkey: 'self'
118
+ });
119
+ return response.data.value;
120
+ },
121
+ true,
122
+ fetchFn
123
+ );
124
+
125
+ if (!value || !Array.isArray((value as any).cards)) return null;
126
+ const data: LinkData = { cards: (value as any).cards };
127
+ cache.set(cacheKey, data);
128
+ return data;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ export async function fetchMusicStatus(
135
+ did: string,
136
+ fetchFn?: typeof fetch
137
+ ): Promise<MusicStatusData | null> {
138
+ const cacheKey = `music-status:${did}`;
139
+ const cached = cache.get<MusicStatusData>(cacheKey);
140
+ if (cached) return cached;
141
+
142
+ try {
143
+ // Try actor status first
144
+ try {
145
+ const statusRecords = await withFallback(
146
+ did,
147
+ async (agent) => {
148
+ const response = await agent.com.atproto.repo.listRecords({
149
+ repo: did,
150
+ collection: 'fm.teal.alpha.actor.status',
151
+ limit: 1
152
+ });
153
+ return response.data.records;
154
+ },
155
+ true,
156
+ fetchFn
157
+ );
158
+
159
+ if (statusRecords?.length) {
160
+ const record = statusRecords[0];
161
+ const value = record.value as any;
162
+ if (value.expiry) {
163
+ const expiryTime = parseInt(value.expiry) * 1000;
164
+ if (Date.now() <= expiryTime) {
165
+ const trackName = value.item?.trackName || value.trackName;
166
+ const artists = value.item?.artists || value.artists || [];
167
+ const releaseName = value.item?.releaseName || value.releaseName;
168
+ const artistName = artists[0]?.artistName;
169
+ const releaseMbId = value.item?.releaseMbId || value.releaseMbId;
170
+
171
+ let artworkUrl: string | undefined;
172
+ if (releaseName && artistName) {
173
+ artworkUrl = (await findArtwork(releaseName, artistName, releaseName, releaseMbId, fetchFn)) || undefined;
174
+ }
175
+ if (!artworkUrl && trackName && artistName) {
176
+ artworkUrl = (await findArtwork(trackName, artistName, releaseName, releaseMbId, fetchFn)) || undefined;
177
+ }
178
+ if (!artworkUrl) {
179
+ const artwork = value.item?.artwork || value.artwork;
180
+ if (artwork?.ref?.$link) {
181
+ const identity = await resolveIdentity(did, fetchFn);
182
+ artworkUrl = buildPdsBlobUrl(identity.pds, did, artwork.ref.$link);
183
+ }
184
+ }
185
+
186
+ const data: MusicStatusData = {
187
+ trackName,
188
+ artists,
189
+ releaseName,
190
+ playedTime: value.item?.playedTime || value.playedTime,
191
+ originUrl: value.item?.originUrl || value.originUrl,
192
+ recordingMbId: value.item?.recordingMbId || value.recordingMbId,
193
+ releaseMbId,
194
+ isrc: value.isrc,
195
+ duration: value.duration,
196
+ musicServiceBaseDomain: value.item?.musicServiceBaseDomain || value.musicServiceBaseDomain,
197
+ submissionClientAgent: value.item?.submissionClientAgent || value.submissionClientAgent,
198
+ $type: 'fm.teal.alpha.actor.status',
199
+ expiry: value.expiry,
200
+ artwork: value.item?.artwork || value.artwork,
201
+ artworkUrl
202
+ };
203
+ cache.set(cacheKey, data);
204
+ return data;
205
+ }
206
+ }
207
+ }
208
+ } catch { /* fall through to feed play */ }
209
+
210
+ // Fall back to feed play
211
+ const playRecords = await withFallback(
212
+ did,
213
+ async (agent) => {
214
+ const response = await agent.com.atproto.repo.listRecords({
215
+ repo: did,
216
+ collection: 'fm.teal.alpha.feed.play',
217
+ limit: 1
218
+ });
219
+ return response.data.records;
220
+ },
221
+ true,
222
+ fetchFn
223
+ );
224
+
225
+ if (playRecords?.length) {
226
+ const record = playRecords[0];
227
+ const value = record.value as any;
228
+ const artists = value.artists || [];
229
+ const artistName = artists[0]?.artistName;
230
+
231
+ let artworkUrl: string | undefined;
232
+ if (value.releaseName && artistName) {
233
+ artworkUrl = (await findArtwork(value.releaseName, artistName, value.releaseName, value.releaseMbId, fetchFn)) || undefined;
234
+ }
235
+ if (!artworkUrl && value.trackName && artistName) {
236
+ artworkUrl = (await findArtwork(value.trackName, artistName, value.releaseName, value.releaseMbId, fetchFn)) || undefined;
237
+ }
238
+ if (!artworkUrl && value.artwork?.ref?.$link) {
239
+ const identity = await resolveIdentity(did, fetchFn);
240
+ artworkUrl = buildPdsBlobUrl(identity.pds, did, value.artwork.ref.$link);
241
+ }
242
+
243
+ const data: MusicStatusData = {
244
+ trackName: value.trackName,
245
+ artists,
246
+ releaseName: value.releaseName,
247
+ playedTime: value.playedTime,
248
+ originUrl: value.originUrl,
249
+ recordingMbId: value.recordingMbId,
250
+ releaseMbId: value.releaseMbId,
251
+ isrc: value.isrc,
252
+ duration: value.duration,
253
+ musicServiceBaseDomain: value.musicServiceBaseDomain,
254
+ submissionClientAgent: value.submissionClientAgent,
255
+ $type: 'fm.teal.alpha.feed.play',
256
+ artwork: value.artwork,
257
+ artworkUrl
258
+ };
259
+ cache.set(cacheKey, data);
260
+ return data;
261
+ }
262
+
263
+ return null;
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ export async function fetchKibunStatus(
270
+ did: string,
271
+ fetchFn?: typeof fetch
272
+ ): Promise<KibunStatusData | null> {
273
+ const cacheKey = `kibun-status:${did}`;
274
+ const cached = cache.get<KibunStatusData>(cacheKey);
275
+ if (cached) return cached;
276
+
277
+ try {
278
+ const statusRecords = await withFallback(
279
+ did,
280
+ async (agent) => {
281
+ const response = await agent.com.atproto.repo.listRecords({
282
+ repo: did,
283
+ collection: 'social.kibun.status',
284
+ limit: 1
285
+ });
286
+ return response.data.records;
287
+ },
288
+ true,
289
+ fetchFn
290
+ );
291
+
292
+ if (statusRecords?.length) {
293
+ const value = statusRecords[0].value as any;
294
+ const data: KibunStatusData = {
295
+ text: value.text,
296
+ emoji: value.emoji,
297
+ createdAt: value.createdAt,
298
+ $type: 'social.kibun.status'
299
+ };
300
+ cache.set(cacheKey, data);
301
+ return data;
302
+ }
303
+ return null;
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ export async function fetchTangledRepos(
310
+ did: string,
311
+ fetchFn?: typeof fetch
312
+ ): Promise<TangledReposData | null> {
313
+ const cacheKey = `tangled:${did}`;
314
+ const cached = cache.get<TangledReposData>(cacheKey);
315
+ if (cached) return cached;
316
+
317
+ try {
318
+ const records = await withFallback(
319
+ did,
320
+ async (agent) => {
321
+ const response = await agent.com.atproto.repo.listRecords({
322
+ repo: did,
323
+ collection: 'sh.tangled.repo',
324
+ limit: 100
325
+ });
326
+ return response.data.records;
327
+ },
328
+ true,
329
+ fetchFn
330
+ );
331
+
332
+ if (!records.length) return null;
333
+
334
+ const repos: TangledRepo[] = records.map((record) => {
335
+ const value = record.value as any;
336
+ return {
337
+ uri: record.uri,
338
+ name: value.name,
339
+ description: value.description,
340
+ knot: value.knot,
341
+ createdAt: value.createdAt,
342
+ labels: value.labels,
343
+ source: value.source,
344
+ spindle: value.spindle
345
+ };
346
+ });
347
+
348
+ repos.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
349
+ const data: TangledReposData = { repos };
350
+ cache.set(cacheKey, data);
351
+ return data;
352
+ } catch {
353
+ return null;
354
+ }
355
+ }
package/src/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ // Re-export types and functions from the AT Protocol service layer.
2
+ // Key API difference from the app's src/lib/services/atproto:
3
+ // All functions that previously read PUBLIC_ATPROTO_DID from the environment
4
+ // now accept `did: string` as their first argument.
5
+
6
+ export type {
7
+ ProfileData,
8
+ SiteInfoData,
9
+ LinkData,
10
+ LinkCard,
11
+ BlueskyPost,
12
+ BlogPost,
13
+ PostAuthor,
14
+ ExternalLink,
15
+ Facet,
16
+ Technology,
17
+ License,
18
+ BasedOnItem,
19
+ RelatedService,
20
+ Repository,
21
+ Credit,
22
+ SectionLicense,
23
+ ResolvedIdentity,
24
+ CacheEntry,
25
+ MusicStatusData,
26
+ MusicArtist,
27
+ KibunStatusData,
28
+ TangledRepo,
29
+ TangledReposData,
30
+ StandardSitePublication,
31
+ StandardSitePublicationsData,
32
+ StandardSiteDocument,
33
+ StandardSiteDocumentsData,
34
+ StandardSiteBasicTheme,
35
+ StandardSiteThemeColor
36
+ } from './types';
37
+
38
+ export {
39
+ fetchProfile,
40
+ fetchSiteInfo,
41
+ fetchLinks,
42
+ fetchMusicStatus,
43
+ fetchKibunStatus,
44
+ fetchTangledRepos
45
+ } from './fetch';
46
+
47
+ export {
48
+ fetchPublications,
49
+ fetchDocuments,
50
+ fetchRecentDocuments,
51
+ fetchBlogPosts
52
+ } from './documents';
53
+
54
+ export { fetchLatestBlueskyPost, fetchPostFromUri } from './posts';
55
+
56
+ export { buildPdsBlobUrl, extractCidFromImageObject, extractImageUrlsFromValue } from './media';
57
+
58
+ export { createAgent, constellationAgent, defaultAgent, resolveIdentity, getPublicAgent, getPDSAgent, withFallback, resetAgents } from './agents';
59
+
60
+ export {
61
+ searchMusicBrainzRelease,
62
+ buildCoverArtUrl,
63
+ searchiTunesArtwork,
64
+ searchDeezerArtwork,
65
+ searchLastFmArtwork,
66
+ findArtwork
67
+ } from './musicbrainz';
68
+
69
+ export { fetchEngagementFromConstellation, fetchAllEngagement } from './engagement';
70
+
71
+ export { cache, ATProtoCache, CACHE_TTL } from './cache';
72
+
73
+ export { fetchAllRecords, fetchAllUserRecords } from './pagination';
74
+ export type { FetchRecordsConfig, AtProtoRecord } from './pagination';
package/src/media.ts ADDED
@@ -0,0 +1,106 @@
1
+ export function buildPdsBlobUrl(pds: string, did: string, cid: string): string {
2
+ return `${pds.replace(/\/$/, '')}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
3
+ }
4
+
5
+ export function extractCidFromImageObject(img: any): string | null {
6
+ if (!img) return null;
7
+ if (img.image && img.image.ref && img.image.ref.$link) return img.image.ref.$link as string;
8
+ if (img.ref && img.ref.$link) return img.ref.$link as string;
9
+ if (img.cid) return img.cid as string;
10
+ if (typeof img === 'string') return img;
11
+ return null;
12
+ }
13
+
14
+ export function extractImageUrlsFromValue(value: any, did: string, limit = 4): string[] {
15
+ const urls: string[] = [];
16
+
17
+ try {
18
+ const embed = (value as any)?.embed ?? null;
19
+
20
+ if (embed) {
21
+ if (embed.$type === 'app.bsky.embed.images#view' && Array.isArray(embed.images)) {
22
+ for (const img of embed.images) {
23
+ const imageUrl = img.fullsize || img.thumb;
24
+ if (imageUrl) urls.push(imageUrl);
25
+ else {
26
+ const cid = extractCidFromImageObject(img);
27
+ if (cid) urls.push(`https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`);
28
+ }
29
+ if (urls.length >= limit) return urls;
30
+ }
31
+ }
32
+
33
+ if (embed.$type === 'app.bsky.embed.video#view' || embed.$type === 'app.bsky.embed.video') {
34
+ const videoCid =
35
+ (embed as any)?.jobStatus?.blob ??
36
+ (embed as any)?.video?.ref?.$link ??
37
+ (embed as any)?.video?.cid ??
38
+ null;
39
+ if (videoCid) {
40
+ urls.push(`https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`);
41
+ if (urls.length >= limit) return urls;
42
+ }
43
+ }
44
+
45
+ if (embed.$type === 'app.bsky.embed.recordWithMedia#view') {
46
+ const media = embed.media;
47
+ if (
48
+ media &&
49
+ media.$type === 'app.bsky.embed.images#view' &&
50
+ Array.isArray(media.images)
51
+ ) {
52
+ for (const img of media.images) {
53
+ const imageUrl = img.fullsize || img.thumb;
54
+ if (imageUrl) urls.push(imageUrl);
55
+ else {
56
+ const cid = extractCidFromImageObject(img);
57
+ if (cid)
58
+ urls.push(`https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`);
59
+ }
60
+ if (urls.length >= limit) return urls;
61
+ }
62
+ }
63
+ const quotedRecord = embed.record;
64
+ if (quotedRecord) {
65
+ const quotedValue = quotedRecord.value ?? quotedRecord.record?.value ?? null;
66
+ if (quotedValue) {
67
+ const nested = extractImageUrlsFromValue(quotedValue, did, limit - urls.length);
68
+ urls.push(...nested);
69
+ if (urls.length >= limit) return urls;
70
+ }
71
+ }
72
+ }
73
+
74
+ if (embed.$type === 'app.bsky.embed.record#view' && embed.record) {
75
+ const quotedValue =
76
+ embed.record.value ?? embed.record.record?.value ?? null;
77
+ if (quotedValue) {
78
+ const nested = extractImageUrlsFromValue(quotedValue, did, limit - urls.length);
79
+ urls.push(...nested);
80
+ if (urls.length >= limit) return urls;
81
+ }
82
+ }
83
+ }
84
+
85
+ if (Array.isArray((value as any).embeds)) {
86
+ for (const e of (value as any).embeds) {
87
+ if (e.$type === 'app.bsky.embed.images#view' && Array.isArray(e.images)) {
88
+ for (const img of e.images) {
89
+ const imageUrl = img.fullsize || img.thumb;
90
+ if (imageUrl) urls.push(imageUrl);
91
+ else {
92
+ const cid = extractCidFromImageObject(img);
93
+ if (cid)
94
+ urls.push(`https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`);
95
+ }
96
+ if (urls.length >= limit) return urls;
97
+ }
98
+ }
99
+ }
100
+ }
101
+ } catch {
102
+ // conservative: return what we have
103
+ }
104
+
105
+ return urls.slice(0, limit);
106
+ }