diff --git a/package.json b/package.json new file mode 100644 index 0000000..71a8649 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "@ewanc26/atproto", + "version": "0.1.0", + "description": "AT Protocol service layer extracted from ewancroft.uk", + "type": "module", + "exports": { + ".": { + "source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": ["dist", "src"], + "scripts": { + "build": "tsc --project tsconfig.json", + "dev": "tsc --project tsconfig.json --watch", + "check": "tsc --noEmit" + }, + "peerDependencies": { + "@atproto/api": ">=0.13.0" + }, + "devDependencies": { + "@atproto/api": "^0.18.1", + "typescript": "^5.9.3" + } +} diff --git a/src/agents.ts b/src/agents.ts new file mode 100644 index 0000000..98628c1 --- /dev/null +++ b/src/agents.ts @@ -0,0 +1,127 @@ +import { AtpAgent } from '@atproto/api'; +import type { ResolvedIdentity } from './types.js'; +import { cache } from './cache.js'; + +export function createAgent(service: string, fetchFn?: typeof fetch): AtpAgent { + const wrappedFetch = fetchFn + ? async (url: URL | RequestInfo, init?: RequestInit) => { + const urlStr = url instanceof URL ? url.toString() : url; + const response = await fetchFn(urlStr, init); + const headers = new Headers(response.headers); + if (!headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers + }); + } + : undefined; + + return new AtpAgent({ + service, + ...(wrappedFetch && { fetch: wrappedFetch }) + }); +} + +export const constellationAgent = createAgent('https://constellation.microcosm.blue'); +export const defaultAgent = createAgent('https://public.api.bsky.app'); + +let resolvedAgent: AtpAgent | null = null; +let pdsAgent: AtpAgent | null = null; + +export async function resolveIdentity( + did: string, + fetchFn?: typeof fetch +): Promise { + const cacheKey = `identity:${did}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + const _fetch = fetchFn ?? globalThis.fetch; + const response = await _fetch( + `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(did)}` + ); + + if (!response.ok) { + throw new Error( + `Failed to resolve identifier via Slingshot: ${response.status} ${response.statusText}` + ); + } + + const rawText = await response.text(); + let data: any; + try { + data = JSON.parse(rawText); + } catch (err) { + throw new Error('Failed to parse identity resolver response'); + } + + if (!data.did || !data.pds) { + throw new Error('Invalid response from identity resolver'); + } + + cache.set(cacheKey, data); + return data; +} + +export async function getPublicAgent(did: string, fetchFn?: typeof fetch): Promise { + if (resolvedAgent) return resolvedAgent; + + try { + try { + const response = await constellationAgent.getProfile({ actor: did }); + if (response.success) { + resolvedAgent = constellationAgent; + return resolvedAgent; + } + } catch { + // fall through + } + + const resolved = await resolveIdentity(did, fetchFn); + resolvedAgent = createAgent(resolved.pds, fetchFn); + return resolvedAgent; + } catch { + resolvedAgent = defaultAgent; + return resolvedAgent; + } +} + +export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise { + if (pdsAgent) return pdsAgent; + const resolved = await resolveIdentity(did, fetchFn); + pdsAgent = createAgent(resolved.pds, fetchFn); + return pdsAgent; +} + +export async function withFallback( + did: string, + operation: (agent: AtpAgent) => Promise, + usePDSFirst = false, + fetchFn?: typeof fetch +): Promise { + const defaultAgentFn = () => + fetchFn ? createAgent('https://public.api.bsky.app', fetchFn) : Promise.resolve(defaultAgent); + + const agents = usePDSFirst + ? [() => getPDSAgent(did, fetchFn), defaultAgentFn] + : [defaultAgentFn, () => getPDSAgent(did, fetchFn)]; + + let lastError: any; + for (const getAgent of agents) { + try { + const agent = await getAgent(); + return await operation(agent); + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +export function resetAgents(): void { + resolvedAgent = null; + pdsAgent = null; +} diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..f6151ea --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,63 @@ +import type { CacheEntry } from './types.js'; + +/** + * Cache TTL values in milliseconds (production defaults). + * All functions that previously read PUBLIC_ATPROTO_DID from the environment + * now accept `did: string` as their first argument. + */ +export const CACHE_TTL = { + PROFILE: 60 * 60 * 1000, + SITE_INFO: 120 * 60 * 1000, + LINKS: 60 * 60 * 1000, + MUSIC_STATUS: 10 * 60 * 1000, + KIBUN_STATUS: 15 * 60 * 1000, + TANGLED_REPOS: 60 * 60 * 1000, + BLOG_POSTS: 30 * 60 * 1000, + PUBLICATIONS: 60 * 60 * 1000, + INDIVIDUAL_POST: 60 * 60 * 1000, + IDENTITY: 24 * 60 * 60 * 1000 +} as const; + +export class ATProtoCache { + private cache = new Map>(); + + private getTTL(key: string): number { + if (key.startsWith('profile:')) return CACHE_TTL.PROFILE; + if (key.startsWith('siteinfo:')) return CACHE_TTL.SITE_INFO; + if (key.startsWith('links:')) return CACHE_TTL.LINKS; + if (key.startsWith('music-status:')) return CACHE_TTL.MUSIC_STATUS; + if (key.startsWith('kibun-status:')) return CACHE_TTL.KIBUN_STATUS; + if (key.startsWith('tangled:')) return CACHE_TTL.TANGLED_REPOS; + if (key.startsWith('blog-posts:') || key.startsWith('blogposts:')) return CACHE_TTL.BLOG_POSTS; + if (key.startsWith('publications:') || key.startsWith('standard-site:publications:')) + return CACHE_TTL.PUBLICATIONS; + if (key.startsWith('post:') || key.startsWith('blueskypost:')) return CACHE_TTL.INDIVIDUAL_POST; + if (key.startsWith('identity:')) return CACHE_TTL.IDENTITY; + return 30 * 60 * 1000; + } + + get(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) return null; + const ttl = this.getTTL(key); + if (Date.now() - entry.timestamp > ttl) { + this.cache.delete(key); + return null; + } + return entry.data; + } + + set(key: string, data: T): void { + this.cache.set(key, { data, timestamp: Date.now() }); + } + + delete(key: string): void { + this.cache.delete(key); + } + + clear(): void { + this.cache.clear(); + } +} + +export const cache = new ATProtoCache(); diff --git a/src/documents.ts b/src/documents.ts new file mode 100644 index 0000000..d46ccae --- /dev/null +++ b/src/documents.ts @@ -0,0 +1,247 @@ +import { cache } from './cache.js'; +import { withFallback, resolveIdentity } from './agents.js'; +import { buildPdsBlobUrl } from './media.js'; +import type { + StandardSitePublication, + StandardSitePublicationsData, + StandardSiteDocument, + StandardSiteDocumentsData, + StandardSiteBasicTheme, + StandardSiteThemeColor, + BlogPost +} from './types.js'; + +interface DocumentRecord { + site: string; + path?: string; + title: string; + description?: string; + coverImage?: unknown; + content?: unknown; + textContent?: string; + bskyPostRef?: { uri: string; cid: string }; + tags?: string[]; + publishedAt: string; + updatedAt?: string; +} + +interface PublicationRecord { + url: string; + name: string; + description?: string; + icon?: unknown; + basicTheme?: { + background: StandardSiteThemeColor; + foreground: StandardSiteThemeColor; + accent: StandardSiteThemeColor; + accentForeground: StandardSiteThemeColor; + }; + preferences?: { showInDiscover?: boolean }; +} + +async function getBlobUrl( + did: string, + blob: any, + fetchFn?: typeof fetch +): Promise { + try { + const cid = blob.ref?.$link || blob.cid; + if (!cid) return undefined; + const resolved = await resolveIdentity(did, fetchFn); + return buildPdsBlobUrl(resolved.pds, did, cid); + } catch { + return undefined; + } +} + +function resolveViewUrl( + site: string, + path: string | undefined, + publicationUrl: string | undefined, + rkey: string +): string { + const docPath = path || `/${rkey}`; + const normalizedPath = docPath.startsWith('/') ? docPath : `/${docPath}`; + + if (site.startsWith('at://')) { + if (!publicationUrl) return `${site}${normalizedPath}`; + const baseUrl = publicationUrl.startsWith('http') ? publicationUrl : `https://${publicationUrl}`; + return `${baseUrl.replace(/\/$/, '')}${normalizedPath}`; + } else { + const baseUrl = site.startsWith('http') ? site : `https://${site}`; + return `${baseUrl.replace(/\/$/, '')}${normalizedPath}`; + } +} + +export async function fetchPublications( + did: string, + fetchFn?: typeof fetch +): Promise { + const cacheKey = `standard-site:publications:${did}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + const publications: StandardSitePublication[] = []; + + try { + const publicationsRecords = await withFallback( + did, + async (agent) => { + const response = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: 'site.standard.publication', + limit: 100 + }); + return response.data.records; + }, + true, + fetchFn + ); + + for (const pubRecord of publicationsRecords) { + const pubValue = pubRecord.value as unknown as PublicationRecord; + const rkey = pubRecord.uri.split('/').pop() || ''; + + let basicTheme: StandardSiteBasicTheme | undefined; + if (pubValue.basicTheme) { + basicTheme = { + background: pubValue.basicTheme.background, + foreground: pubValue.basicTheme.foreground, + accent: pubValue.basicTheme.accent, + accentForeground: pubValue.basicTheme.accentForeground + }; + } + + publications.push({ + name: pubValue.name, + rkey, + uri: pubRecord.uri, + url: pubValue.url, + description: pubValue.description, + icon: pubValue.icon ? await getBlobUrl(did, pubValue.icon, fetchFn) : undefined, + basicTheme, + preferences: pubValue.preferences + }); + } + + const data: StandardSitePublicationsData = { publications }; + cache.set(cacheKey, data); + return data; + } catch { + return { publications: [] }; + } +} + +export async function fetchDocuments( + did: string, + fetchFn?: typeof fetch +): Promise { + const cacheKey = `standard-site:documents:${did}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + const documents: StandardSiteDocument[] = []; + + try { + const publicationsData = await fetchPublications(did, fetchFn); + const publicationsMap = new Map(); + for (const pub of publicationsData.publications) { + publicationsMap.set(pub.uri, pub); + } + + const documentsRecords = await withFallback( + did, + async (agent) => { + const response = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: 'site.standard.document', + limit: 100 + }); + return response.data.records; + }, + true, + fetchFn + ); + + for (const docRecord of documentsRecords) { + const docValue = docRecord.value as unknown as DocumentRecord; + const rkey = docRecord.uri.split('/').pop() || ''; + + const site = docValue.site; + let publication: StandardSitePublication | undefined; + let publicationRkey: string | undefined; + let pubUrl: string | undefined; + + if (site.startsWith('at://')) { + publication = publicationsMap.get(site); + publicationRkey = site.split('/').pop(); + pubUrl = publication?.url; + } else { + pubUrl = site; + } + + const url = resolveViewUrl(site, docValue.path, pubUrl, rkey); + const coverImage = docValue.coverImage + ? await getBlobUrl(did, docValue.coverImage, fetchFn) + : undefined; + + documents.push({ + title: docValue.title, + rkey, + uri: docRecord.uri, + url, + site, + path: docValue.path, + description: docValue.description, + coverImage, + content: docValue.content, + textContent: docValue.textContent, + bskyPostRef: docValue.bskyPostRef, + tags: docValue.tags, + publishedAt: docValue.publishedAt, + updatedAt: docValue.updatedAt, + publicationName: publication?.name, + publicationRkey + }); + } + + documents.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()); + + const data: StandardSiteDocumentsData = { documents }; + cache.set(cacheKey, data); + return data; + } catch { + return { documents: [] }; + } +} + +export async function fetchRecentDocuments( + did: string, + limit = 5, + fetchFn?: typeof fetch +): Promise { + const { documents } = await fetchDocuments(did, fetchFn); + return documents.slice(0, limit); +} + +export async function fetchBlogPosts( + did: string, + fetchFn?: typeof fetch +): Promise<{ posts: BlogPost[] }> { + const { documents } = await fetchDocuments(did, fetchFn); + const posts: BlogPost[] = documents.map((doc) => ({ + title: doc.title, + url: doc.url, + createdAt: doc.publishedAt, + platform: 'standard.site' as const, + description: doc.description, + rkey: doc.rkey, + publicationName: doc.publicationName, + publicationRkey: doc.publicationRkey, + tags: doc.tags, + coverImage: doc.coverImage, + textContent: doc.textContent, + updatedAt: doc.updatedAt + })); + return { posts }; +} diff --git a/src/engagement.ts b/src/engagement.ts new file mode 100644 index 0000000..95141f8 --- /dev/null +++ b/src/engagement.ts @@ -0,0 +1,56 @@ +import { cache } from './cache.js'; + +export type EngagementType = 'app.bsky.feed.like' | 'app.bsky.feed.repost'; + +interface EngagementResponse { + dids: string[]; + cursor?: string; +} + +export async function fetchEngagementFromConstellation( + uri: string, + type: EngagementType, + cursor?: string +): Promise { + const cacheKey = `engagement:${type}:${uri}:${cursor || 'initial'}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + const url = new URL('https://constellation.microcosm.blue/links/distinct-dids'); + url.searchParams.append('target', uri); + url.searchParams.append('collection', type); + url.searchParams.append('path', ''); + url.searchParams.append('limit', '100'); + if (cursor) url.searchParams.append('cursor', cursor); + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Constellation HTTP error! Status: ${response.status}`); + } + + const data = await response.json(); + const result: EngagementResponse = { + dids: data.dids || [], + cursor: data.cursor + }; + + cache.set(cacheKey, result); + return result; +} + +export async function fetchAllEngagement(uri: string, type: EngagementType): Promise { + const allDids: Set = new Set(); + let cursor: string | undefined = undefined; + + try { + do { + const response = await fetchEngagementFromConstellation(uri, type, cursor); + response.dids.forEach((did) => allDids.add(did)); + cursor = response.cursor; + } while (cursor); + } catch { + // return what we have + } + + return Array.from(allDids); +} diff --git a/src/fetch.ts b/src/fetch.ts new file mode 100644 index 0000000..8432150 --- /dev/null +++ b/src/fetch.ts @@ -0,0 +1,355 @@ +import { cache } from './cache.js'; +import { withFallback, resolveIdentity } from './agents.js'; +import { buildPdsBlobUrl } from './media.js'; +import { findArtwork } from './musicbrainz.js'; +import type { + ProfileData, + SiteInfoData, + LinkData, + MusicStatusData, + KibunStatusData, + TangledRepo, + TangledReposData +} from './types.js'; + +export async function fetchProfile(did: string, fetchFn?: typeof fetch): Promise { + const cacheKey = `profile:${did}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + const profile = await withFallback( + did, + async (agent) => { + const response = await agent.getProfile({ actor: did }); + return response.data; + }, + false, + fetchFn + ); + + let pronouns: string | undefined; + try { + const recordResponse = await withFallback( + did, + async (agent) => { + const response = await agent.com.atproto.repo.getRecord({ + repo: did, + collection: 'app.bsky.actor.profile', + rkey: 'self' + }); + return response.data; + }, + false, + fetchFn + ); + pronouns = (recordResponse.value as any).pronouns; + } catch { /* pronouns optional */ } + + const data: ProfileData = { + did: profile.did, + handle: profile.handle, + displayName: profile.displayName, + description: profile.description, + avatar: profile.avatar, + banner: profile.banner, + followersCount: profile.followersCount, + followsCount: profile.followsCount, + postsCount: profile.postsCount, + pronouns + }; + + cache.set(cacheKey, data); + return data; +} + +export async function fetchSiteInfo( + did: string, + fetchFn?: typeof fetch +): Promise { + const cacheKey = `siteinfo:${did}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + try { + const result = await withFallback( + did, + async (agent) => { + try { + const response = await agent.com.atproto.repo.getRecord({ + repo: did, + collection: 'uk.ewancroft.site.info', + rkey: 'self' + }); + return response.data; + } catch (err: any) { + if (err.error === 'RecordNotFound') return null; + throw err; + } + }, + true, + fetchFn + ); + + if (!result?.value) return null; + const data = result.value as SiteInfoData; + cache.set(cacheKey, data); + return data; + } catch { + return null; + } +} + +export async function fetchLinks( + did: string, + fetchFn?: typeof fetch +): Promise { + const cacheKey = `links:${did}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + try { + const value = await withFallback( + did, + async (agent) => { + const response = await agent.com.atproto.repo.getRecord({ + repo: did, + collection: 'blue.linkat.board', + rkey: 'self' + }); + return response.data.value; + }, + true, + fetchFn + ); + + if (!value || !Array.isArray((value as any).cards)) return null; + const data: LinkData = { cards: (value as any).cards }; + cache.set(cacheKey, data); + return data; + } catch { + return null; + } +} + +export async function fetchMusicStatus( + did: string, + fetchFn?: typeof fetch +): Promise { + const cacheKey = `music-status:${did}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + try { + // Try actor status first + try { + const statusRecords = await withFallback( + did, + async (agent) => { + const response = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: 'fm.teal.alpha.actor.status', + limit: 1 + }); + return response.data.records; + }, + true, + fetchFn + ); + + if (statusRecords?.length) { + const record = statusRecords[0]; + const value = record.value as any; + if (value.expiry) { + const expiryTime = parseInt(value.expiry) * 1000; + if (Date.now() <= expiryTime) { + const trackName = value.item?.trackName || value.trackName; + const artists = value.item?.artists || value.artists || []; + const releaseName = value.item?.releaseName || value.releaseName; + const artistName = artists[0]?.artistName; + const releaseMbId = value.item?.releaseMbId || value.releaseMbId; + + let artworkUrl: string | undefined; + if (releaseName && artistName) { + artworkUrl = (await findArtwork(releaseName, artistName, releaseName, releaseMbId, fetchFn)) || undefined; + } + if (!artworkUrl && trackName && artistName) { + artworkUrl = (await findArtwork(trackName, artistName, releaseName, releaseMbId, fetchFn)) || undefined; + } + if (!artworkUrl) { + const artwork = value.item?.artwork || value.artwork; + if (artwork?.ref?.$link) { + const identity = await resolveIdentity(did, fetchFn); + artworkUrl = buildPdsBlobUrl(identity.pds, did, artwork.ref.$link); + } + } + + const data: MusicStatusData = { + trackName, + artists, + releaseName, + playedTime: value.item?.playedTime || value.playedTime, + originUrl: value.item?.originUrl || value.originUrl, + recordingMbId: value.item?.recordingMbId || value.recordingMbId, + releaseMbId, + isrc: value.isrc, + duration: value.duration, + musicServiceBaseDomain: value.item?.musicServiceBaseDomain || value.musicServiceBaseDomain, + submissionClientAgent: value.item?.submissionClientAgent || value.submissionClientAgent, + $type: 'fm.teal.alpha.actor.status', + expiry: value.expiry, + artwork: value.item?.artwork || value.artwork, + artworkUrl + }; + cache.set(cacheKey, data); + return data; + } + } + } + } catch { /* fall through to feed play */ } + + // Fall back to feed play + const playRecords = await withFallback( + did, + async (agent) => { + const response = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: 'fm.teal.alpha.feed.play', + limit: 1 + }); + return response.data.records; + }, + true, + fetchFn + ); + + if (playRecords?.length) { + const record = playRecords[0]; + const value = record.value as any; + const artists = value.artists || []; + const artistName = artists[0]?.artistName; + + let artworkUrl: string | undefined; + if (value.releaseName && artistName) { + artworkUrl = (await findArtwork(value.releaseName, artistName, value.releaseName, value.releaseMbId, fetchFn)) || undefined; + } + if (!artworkUrl && value.trackName && artistName) { + artworkUrl = (await findArtwork(value.trackName, artistName, value.releaseName, value.releaseMbId, fetchFn)) || undefined; + } + if (!artworkUrl && value.artwork?.ref?.$link) { + const identity = await resolveIdentity(did, fetchFn); + artworkUrl = buildPdsBlobUrl(identity.pds, did, value.artwork.ref.$link); + } + + const data: MusicStatusData = { + trackName: value.trackName, + artists, + releaseName: value.releaseName, + playedTime: value.playedTime, + originUrl: value.originUrl, + recordingMbId: value.recordingMbId, + releaseMbId: value.releaseMbId, + isrc: value.isrc, + duration: value.duration, + musicServiceBaseDomain: value.musicServiceBaseDomain, + submissionClientAgent: value.submissionClientAgent, + $type: 'fm.teal.alpha.feed.play', + artwork: value.artwork, + artworkUrl + }; + cache.set(cacheKey, data); + return data; + } + + return null; + } catch { + return null; + } +} + +export async function fetchKibunStatus( + did: string, + fetchFn?: typeof fetch +): Promise { + const cacheKey = `kibun-status:${did}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + try { + const statusRecords = await withFallback( + did, + async (agent) => { + const response = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: 'social.kibun.status', + limit: 1 + }); + return response.data.records; + }, + true, + fetchFn + ); + + if (statusRecords?.length) { + const value = statusRecords[0].value as any; + const data: KibunStatusData = { + text: value.text, + emoji: value.emoji, + createdAt: value.createdAt, + $type: 'social.kibun.status' + }; + cache.set(cacheKey, data); + return data; + } + return null; + } catch { + return null; + } +} + +export async function fetchTangledRepos( + did: string, + fetchFn?: typeof fetch +): Promise { + const cacheKey = `tangled:${did}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + try { + const records = await withFallback( + did, + async (agent) => { + const response = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: 'sh.tangled.repo', + limit: 100 + }); + return response.data.records; + }, + true, + fetchFn + ); + + if (!records.length) return null; + + const repos: TangledRepo[] = records.map((record) => { + const value = record.value as any; + return { + uri: record.uri, + name: value.name, + description: value.description, + knot: value.knot, + createdAt: value.createdAt, + labels: value.labels, + source: value.source, + spindle: value.spindle + }; + }); + + repos.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + const data: TangledReposData = { repos }; + cache.set(cacheKey, data); + return data; + } catch { + return null; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9b768b3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,74 @@ +// Re-export types and functions from the AT Protocol service layer. +// Key API difference from the app's src/lib/services/atproto: +// All functions that previously read PUBLIC_ATPROTO_DID from the environment +// now accept `did: string` as their first argument. + +export type { + ProfileData, + SiteInfoData, + LinkData, + LinkCard, + BlueskyPost, + BlogPost, + PostAuthor, + ExternalLink, + Facet, + Technology, + License, + BasedOnItem, + RelatedService, + Repository, + Credit, + SectionLicense, + ResolvedIdentity, + CacheEntry, + MusicStatusData, + MusicArtist, + KibunStatusData, + TangledRepo, + TangledReposData, + StandardSitePublication, + StandardSitePublicationsData, + StandardSiteDocument, + StandardSiteDocumentsData, + StandardSiteBasicTheme, + StandardSiteThemeColor +} from './types'; + +export { + fetchProfile, + fetchSiteInfo, + fetchLinks, + fetchMusicStatus, + fetchKibunStatus, + fetchTangledRepos +} from './fetch'; + +export { + fetchPublications, + fetchDocuments, + fetchRecentDocuments, + fetchBlogPosts +} from './documents'; + +export { fetchLatestBlueskyPost, fetchPostFromUri } from './posts'; + +export { buildPdsBlobUrl, extractCidFromImageObject, extractImageUrlsFromValue } from './media'; + +export { createAgent, constellationAgent, defaultAgent, resolveIdentity, getPublicAgent, getPDSAgent, withFallback, resetAgents } from './agents'; + +export { + searchMusicBrainzRelease, + buildCoverArtUrl, + searchiTunesArtwork, + searchDeezerArtwork, + searchLastFmArtwork, + findArtwork +} from './musicbrainz'; + +export { fetchEngagementFromConstellation, fetchAllEngagement } from './engagement'; + +export { cache, ATProtoCache, CACHE_TTL } from './cache'; + +export { fetchAllRecords, fetchAllUserRecords } from './pagination'; +export type { FetchRecordsConfig, AtProtoRecord } from './pagination'; diff --git a/src/media.ts b/src/media.ts new file mode 100644 index 0000000..a925930 --- /dev/null +++ b/src/media.ts @@ -0,0 +1,106 @@ +export function buildPdsBlobUrl(pds: string, did: string, cid: string): string { + return `${pds.replace(/\/$/, '')}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; +} + +export function extractCidFromImageObject(img: any): string | null { + if (!img) return null; + if (img.image && img.image.ref && img.image.ref.$link) return img.image.ref.$link as string; + if (img.ref && img.ref.$link) return img.ref.$link as string; + if (img.cid) return img.cid as string; + if (typeof img === 'string') return img; + return null; +} + +export function extractImageUrlsFromValue(value: any, did: string, limit = 4): string[] { + const urls: string[] = []; + + try { + const embed = (value as any)?.embed ?? null; + + if (embed) { + if (embed.$type === 'app.bsky.embed.images#view' && Array.isArray(embed.images)) { + for (const img of embed.images) { + const imageUrl = img.fullsize || img.thumb; + if (imageUrl) urls.push(imageUrl); + else { + const cid = extractCidFromImageObject(img); + if (cid) urls.push(`https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`); + } + if (urls.length >= limit) return urls; + } + } + + if (embed.$type === 'app.bsky.embed.video#view' || embed.$type === 'app.bsky.embed.video') { + const videoCid = + (embed as any)?.jobStatus?.blob ?? + (embed as any)?.video?.ref?.$link ?? + (embed as any)?.video?.cid ?? + null; + if (videoCid) { + urls.push(`https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`); + if (urls.length >= limit) return urls; + } + } + + if (embed.$type === 'app.bsky.embed.recordWithMedia#view') { + const media = embed.media; + if ( + media && + media.$type === 'app.bsky.embed.images#view' && + Array.isArray(media.images) + ) { + for (const img of media.images) { + const imageUrl = img.fullsize || img.thumb; + if (imageUrl) urls.push(imageUrl); + else { + const cid = extractCidFromImageObject(img); + if (cid) + urls.push(`https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`); + } + if (urls.length >= limit) return urls; + } + } + const quotedRecord = embed.record; + if (quotedRecord) { + const quotedValue = quotedRecord.value ?? quotedRecord.record?.value ?? null; + if (quotedValue) { + const nested = extractImageUrlsFromValue(quotedValue, did, limit - urls.length); + urls.push(...nested); + if (urls.length >= limit) return urls; + } + } + } + + if (embed.$type === 'app.bsky.embed.record#view' && embed.record) { + const quotedValue = + embed.record.value ?? embed.record.record?.value ?? null; + if (quotedValue) { + const nested = extractImageUrlsFromValue(quotedValue, did, limit - urls.length); + urls.push(...nested); + if (urls.length >= limit) return urls; + } + } + } + + if (Array.isArray((value as any).embeds)) { + for (const e of (value as any).embeds) { + if (e.$type === 'app.bsky.embed.images#view' && Array.isArray(e.images)) { + for (const img of e.images) { + const imageUrl = img.fullsize || img.thumb; + if (imageUrl) urls.push(imageUrl); + else { + const cid = extractCidFromImageObject(img); + if (cid) + urls.push(`https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`); + } + if (urls.length >= limit) return urls; + } + } + } + } + } catch { + // conservative: return what we have + } + + return urls.slice(0, limit); +} diff --git a/src/musicbrainz.ts b/src/musicbrainz.ts new file mode 100644 index 0000000..e384da9 --- /dev/null +++ b/src/musicbrainz.ts @@ -0,0 +1,188 @@ +import { cache } from './cache.js'; + +interface MusicBrainzRelease { + id: string; + score: number; + title: string; +} + +interface MusicBrainzSearchResponse { + releases: MusicBrainzRelease[]; +} + +interface iTunesSearchResponse { + resultCount: number; + results: Array<{ artworkUrl100?: string }>; +} + +export async function searchMusicBrainzRelease( + trackName: string, + artistName: string, + releaseName?: string +): Promise { + const cacheKey = `mb:release:${trackName}:${artistName}:${releaseName || 'none'}`; + const cached = cache.get(cacheKey); + if (cached !== null) return cached; + + try { + if (releaseName) { + const result = await searchByReleaseName(releaseName, artistName); + if (result) { cache.set(cacheKey, result); return result; } + } + const result = await searchByTrackName(trackName, artistName); + if (result) { cache.set(cacheKey, result); return result; } + cache.set(cacheKey, null); + return null; + } catch { + return null; + } +} + +async function searchByReleaseName(releaseName: string, artistName: string): Promise { + try { + const query = `release:"${releaseName}" AND artist:"${artistName}"`; + const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; + const response = await fetch(url, { + headers: { 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', Accept: 'application/json' } + }); + if (!response.ok) return null; + const data: MusicBrainzSearchResponse = await response.json(); + if (!data.releases?.length) return null; + const best = data.releases[0]; + if (best.score < 80) return null; + return best.id; + } catch { return null; } +} + +async function searchByTrackName(trackName: string, artistName: string): Promise { + try { + const query = `recording:"${trackName}" AND artist:"${artistName}"`; + const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; + const response = await fetch(url, { + headers: { 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', Accept: 'application/json' } + }); + if (!response.ok) return null; + const data: MusicBrainzSearchResponse = await response.json(); + if (!data.releases?.length) return null; + const best = data.releases[0]; + if (best.score < 75) return null; + return best.id; + } catch { return null; } +} + +export function buildCoverArtUrl(releaseMbId: string, size: 250 | 500 | 1200 = 500): string { + return `https://coverartarchive.org/release/${releaseMbId}/front-${size}`; +} + +export async function searchiTunesArtwork( + trackName: string, + artistName: string, + releaseName?: string +): Promise { + const cacheKey = `itunes:artwork:${trackName}:${artistName}:${releaseName || 'none'}`; + const cached = cache.get(cacheKey); + if (cached !== null) return cached; + + try { + const searchTerm = releaseName ? `${releaseName} ${artistName}` : `${trackName} ${artistName}`; + const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; + const response = await fetch(url); + if (!response.ok) { cache.set(cacheKey, null); return null; } + const data: iTunesSearchResponse = await response.json(); + if (!data.results?.length) { cache.set(cacheKey, null); return null; } + let artworkUrl = data.results[0].artworkUrl100; + if (artworkUrl) { + artworkUrl = artworkUrl.replace('100x100', '600x600'); + cache.set(cacheKey, artworkUrl); + return artworkUrl; + } + cache.set(cacheKey, null); + return null; + } catch { + return null; + } +} + +export async function searchDeezerArtwork( + trackName: string, + artistName: string, + releaseName?: string +): Promise { + // Deezer has CORS restrictions in browsers — skip silently + return null; +} + +export async function searchLastFmArtwork( + trackName: string, + artistName: string, + releaseName?: string +): Promise { + if (!releaseName) return null; + + const cacheKey = `lastfm:artwork:${trackName}:${artistName}:${releaseName}`; + const cached = cache.get(cacheKey); + if (cached !== null) return cached; + + try { + const url = `https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=8de8b91ab0c3f8d08a35c33bf0e0e803&artist=${encodeURIComponent(artistName)}&album=${encodeURIComponent(releaseName)}&format=json`; + const response = await fetch(url); + if (!response.ok) { cache.set(cacheKey, null); return null; } + const data: any = await response.json(); + if (!data.album?.image) { cache.set(cacheKey, null); return null; } + const images = data.album.image; + const largeImage = + images.find((img: any) => img.size === 'extralarge') || + images.find((img: any) => img.size === 'large') || + images.find((img: any) => img.size === 'medium'); + if (largeImage?.['#text']) { + cache.set(cacheKey, largeImage['#text']); + return largeImage['#text']; + } + cache.set(cacheKey, null); + return null; + } catch { + return null; + } +} + +/** + * Cascading artwork search: Cover Art Archive → MusicBrainz+CAA → iTunes → Last.fm + */ +export async function findArtwork( + trackName: string, + artistName: string, + releaseName?: string, + releaseMbId?: string, + fetchFn?: typeof fetch +): Promise { + const _fetch = fetchFn || globalThis.fetch; + + // 1. Try Cover Art Archive with known MBID + if (releaseMbId) { + const caaUrl = buildCoverArtUrl(releaseMbId, 500); + try { + const res = await _fetch(caaUrl, { method: 'HEAD' }); + if (res.ok) return caaUrl; + } catch { /* continue */ } + } + + // 2. Search MusicBrainz for MBID, then try CAA + const mbId = await searchMusicBrainzRelease(trackName, artistName, releaseName); + if (mbId) { + const caaUrl = buildCoverArtUrl(mbId, 500); + try { + const res = await _fetch(caaUrl, { method: 'HEAD' }); + if (res.ok) return caaUrl; + } catch { /* continue */ } + } + + // 3. Try iTunes + const iTunesUrl = await searchiTunesArtwork(trackName, artistName, releaseName); + if (iTunesUrl) return iTunesUrl; + + // 4. Try Last.fm + const lastFmUrl = await searchLastFmArtwork(trackName, artistName, releaseName); + if (lastFmUrl) return lastFmUrl; + + return null; +} diff --git a/src/pagination/fetchAllRecords.ts b/src/pagination/fetchAllRecords.ts new file mode 100644 index 0000000..93b7277 --- /dev/null +++ b/src/pagination/fetchAllRecords.ts @@ -0,0 +1,57 @@ +import { withFallback } from '../agents.js'; + +export interface FetchRecordsConfig { + repo: string; + collection: string; + limit?: number; + fetchFn?: typeof fetch; +} + +export interface AtProtoRecord { + uri: string; + value: T; + cid?: string; +} + +export async function fetchAllRecords( + config: FetchRecordsConfig +): Promise[]> { + const { repo, collection, limit = 100, fetchFn } = config; + const allRecords: AtProtoRecord[] = []; + let cursor: string | undefined; + + try { + do { + const records = await withFallback( + repo, + async (agent) => { + const response = await agent.com.atproto.repo.listRecords({ + repo, + collection, + limit, + cursor + }); + cursor = response.data.cursor; + return response.data.records; + }, + true, + fetchFn + ); + allRecords.push(...(records as AtProtoRecord[])); + } while (cursor); + } catch (error) { + console.warn(`Failed to fetch records from ${collection}:`, error); + throw error; + } + + return allRecords; +} + +export async function fetchAllUserRecords( + did: string, + collection: string, + fetchFn?: typeof fetch, + limit?: number +): Promise[]> { + return fetchAllRecords({ repo: did, collection, limit, fetchFn }); +} diff --git a/src/pagination/index.ts b/src/pagination/index.ts new file mode 100644 index 0000000..c98aa60 --- /dev/null +++ b/src/pagination/index.ts @@ -0,0 +1,2 @@ +export { fetchAllRecords, fetchAllUserRecords } from './fetchAllRecords.js'; +export type { FetchRecordsConfig, AtProtoRecord } from './fetchAllRecords.js'; diff --git a/src/posts.ts b/src/posts.ts new file mode 100644 index 0000000..aba33dc --- /dev/null +++ b/src/posts.ts @@ -0,0 +1,197 @@ +import { cache } from './cache.js'; +import { withFallback } from './agents.js'; +import type { BlueskyPost, PostAuthor, ExternalLink } from './types.js'; + +export async function fetchLatestBlueskyPost( + did: string, + fetchFn?: typeof fetch +): Promise { + const cacheKey = `blueskypost:latest:${did}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + try { + const feedResponse = await withFallback( + did, + async (agent) => agent.getAuthorFeed({ actor: did, limit: 5 }), + false, + fetchFn + ); + + const feed = feedResponse.data.feed; + if (!feed.length) return null; + + const latestFeedItem = feed[0]; + const latestPostData = latestFeedItem.post; + + const isRepost = latestFeedItem.reason?.$type === 'app.bsky.feed.defs#reasonRepost'; + let repostAuthor: PostAuthor | undefined; + let repostCreatedAt: string | undefined; + + if (isRepost && latestFeedItem.reason) { + const reason = latestFeedItem.reason as any; + repostAuthor = { + did: reason.by.did, + handle: reason.by.handle, + displayName: reason.by.displayName, + avatar: reason.by.avatar + }; + repostCreatedAt = reason.indexedAt; + } + + const post = await fetchPostFromUri(did, latestPostData.uri, 0, fetchFn); + if (!post) return null; + + if (isRepost) { + post.isRepost = true; + post.repostAuthor = repostAuthor; + post.repostCreatedAt = repostCreatedAt; + post.originalPost = { ...post }; + } + + cache.set(cacheKey, post); + return post; + } catch { + return null; + } +} + +export async function fetchPostFromUri( + did: string, + uri: string, + depth: number, + fetchFn?: typeof fetch +): Promise { + if (depth >= 3) return null; + + try { + const threadResponse = await withFallback( + did, + async (agent) => agent.getPostThread({ uri, depth: 0 }), + false, + fetchFn + ); + + if (!threadResponse.data.thread || !('post' in threadResponse.data.thread)) return null; + + const postData = threadResponse.data.thread.post; + const value = postData.record as any; + const embed = (postData as any).embed ?? null; + + const author: PostAuthor = { + did: postData.author.did, + handle: postData.author.handle, + displayName: postData.author.displayName, + avatar: postData.author.avatar + }; + + let imageUrls: string[] | undefined; + let imageAlts: string[] | undefined; + let hasImages = false; + let hasVideo = false; + let videoUrl: string | undefined; + let videoThumbnail: string | undefined; + let quotedPost: BlueskyPost | undefined; + let quotedPostUri: string | undefined; + let externalLink: ExternalLink | undefined; + + const facets = value.facets; + + if (embed?.$type === 'app.bsky.embed.images#view' && Array.isArray(embed.images)) { + hasImages = true; + imageUrls = []; + imageAlts = []; + for (const img of embed.images) { + const imageUrl = img.fullsize || img.thumb; + if (imageUrl) { imageUrls.push(imageUrl); imageAlts.push(img.alt || ''); } + } + } + + if (embed?.$type === 'app.bsky.embed.video#view') { + if (embed.playlist) { hasVideo = true; videoUrl = embed.playlist; } + if (embed.thumbnail) videoThumbnail = embed.thumbnail; + } + + if (embed?.$type === 'app.bsky.embed.external#view' && embed.external) { + externalLink = { + uri: embed.external.uri, + title: embed.external.title, + description: embed.external.description, + thumb: embed.external.thumb + }; + } + + if (embed?.$type === 'app.bsky.embed.recordWithMedia#view') { + const media = embed.media; + if (media?.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) { + hasImages = true; + imageUrls = []; + imageAlts = []; + for (const img of media.images) { + const imageUrl = img.fullsize || img.thumb; + if (imageUrl) { imageUrls.push(imageUrl); imageAlts.push(img.alt || ''); } + } + } + if (media?.$type === 'app.bsky.embed.video#view') { + if (media.playlist) { hasVideo = true; videoUrl = media.playlist; } + if (media.thumbnail) videoThumbnail = media.thumbnail; + } + if (media?.$type === 'app.bsky.embed.external#view' && media.external) { + externalLink = { + uri: media.external.uri, + title: media.external.title, + description: media.external.description, + thumb: media.external.thumb + }; + } + const quotedRecord = embed.record?.record || embed.record; + if (quotedRecord?.uri) { + quotedPostUri = quotedRecord.uri as string; + quotedPost = (await fetchPostFromUri(did, quotedPostUri, depth + 1, fetchFn)) ?? undefined; + } + } + + if (embed?.$type === 'app.bsky.embed.record#view') { + const quotedRecord = embed.record?.record || embed.record; + if (quotedRecord?.uri) { + quotedPostUri = quotedRecord.uri as string; + quotedPost = (await fetchPostFromUri(did, quotedPostUri, depth + 1, fetchFn)) ?? undefined; + } + } + + let replyParent: BlueskyPost | undefined; + let replyRoot: BlueskyPost | undefined; + if (value.reply) { + if (value.reply.parent?.uri) { + replyParent = (await fetchPostFromUri(did, value.reply.parent.uri as string, depth + 1, fetchFn)) ?? undefined; + } + if (value.reply.root?.uri && value.reply.root.uri !== value.reply.parent?.uri) { + replyRoot = (await fetchPostFromUri(did, value.reply.root.uri as string, depth + 1, fetchFn)) ?? undefined; + } + } + + return { + text: value.text, + createdAt: value.createdAt, + uri: postData.uri, + author, + likeCount: postData.likeCount || 0, + repostCount: postData.repostCount || 0, + replyCount: postData.replyCount, + hasImages, + imageUrls, + imageAlts, + hasVideo, + videoUrl, + videoThumbnail, + quotedPostUri, + quotedPost, + facets, + externalLink, + replyParent, + replyRoot + }; + } catch { + return null; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6e6356a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,272 @@ +/** + * Type definitions for AT Protocol services + * Identical to src/lib/services/atproto/types.ts — no SvelteKit dependencies. + */ + +export interface ProfileData { + did: string; + handle: string; + displayName?: string; + description?: string; + avatar?: string; + banner?: string; + followersCount?: number; + followsCount?: number; + postsCount?: number; + pronouns?: string; +} + +export interface StatusData { + text: string; + createdAt: string; +} + +export interface Technology { + name: string; + url?: string; + description?: string; +} + +export interface License { + name: string; + url?: string; +} + +export interface BasedOnItem { + section?: string; + name?: string; + url?: string; + description?: string; + type?: string; +} + +export interface RelatedService { + section?: string; + name?: string; + url?: string; + description?: string; + relationship?: string; +} + +export interface Repository { + platform?: string; + url: string; + type?: string; + description?: string; +} + +export interface Credit { + section?: string; + name?: string; + type: string; + url?: string; + author?: string; + license?: License; + description?: string; +} + +export interface SectionLicense { + section?: string; + name?: string; + url?: string; +} + +export interface SiteInfoData { + technologyStack?: Technology[]; + privacyStatement?: string; + openSourceInfo?: { + description?: string; + license?: License; + basedOn?: BasedOnItem[]; + relatedServices?: RelatedService[]; + repositories?: Repository[]; + }; + credits?: Credit[]; + additionalInfo?: { + websiteBirthYear?: number; + purpose?: string; + sectionLicense?: SectionLicense[]; + }; +} + +export interface LinkCard { + url: string; + text: string; + emoji: string; +} + +export interface LinkData { + cards: LinkCard[]; +} + +export interface BlogPost { + title: string; + url: string; + createdAt: string; + platform: 'standard.site'; + description?: string; + rkey: string; + publicationName?: string; + publicationRkey?: string; + tags?: string[]; + coverImage?: string; + textContent?: string; + updatedAt?: string; +} + +export interface BlogPostsData { + posts: BlogPost[]; +} + +export interface Facet { + index: { byteStart: number; byteEnd: number }; + features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>; +} + +export interface ExternalLink { + uri: string; + title: string; + description?: string; + thumb?: string; +} + +export interface PostAuthor { + did: string; + handle: string; + displayName?: string; + avatar?: string; + pronouns?: string; +} + +export interface BlueskyPost { + text: string; + createdAt: string; + uri: string; + author: PostAuthor; + likeCount?: number; + repostCount?: number; + replyCount?: number; + hasImages: boolean; + imageUrls?: string[]; + imageAlts?: string[]; + hasVideo?: boolean; + videoUrl?: string; + videoThumbnail?: string; + quotedPostUri?: string; + quotedPost?: BlueskyPost; + facets?: Facet[]; + externalLink?: ExternalLink; + replyParent?: BlueskyPost; + replyRoot?: BlueskyPost; + isRepost?: boolean; + repostAuthor?: PostAuthor; + repostCreatedAt?: string; + originalPost?: BlueskyPost; +} + +export interface ResolvedIdentity { + did: string; + pds: string; +} + +export interface CacheEntry { + data: T; + timestamp: number; +} + +export interface MusicArtist { + artistName: string; + artistMbId?: string; +} + +export interface MusicStatusData { + trackName: string; + artists: MusicArtist[]; + releaseName?: string; + playedTime: string; + originUrl?: string; + recordingMbId?: string; + releaseMbId?: string; + isrc?: string; + duration?: number; + musicServiceBaseDomain?: string; + submissionClientAgent?: string; + $type: 'fm.teal.alpha.actor.status' | 'fm.teal.alpha.feed.play'; + expiry?: string; + artwork?: { ref?: { $link: string }; mimeType?: string; size?: number }; + artworkUrl?: string; +} + +export interface KibunStatusData { + text: string; + emoji: string; + createdAt: string; + $type: 'social.kibun.status'; +} + +export interface TangledRepo { + uri: string; + name: string; + description?: string; + knot: string; + createdAt: string; + labels?: string[]; + source?: string; + spindle?: string; +} + +export interface TangledReposData { + repos: TangledRepo[]; +} + +export interface StandardSiteThemeColor { + r: number; + g: number; + b: number; + a?: number; +} + +export interface StandardSiteBasicTheme { + background: StandardSiteThemeColor; + foreground: StandardSiteThemeColor; + accent: StandardSiteThemeColor; + accentForeground: StandardSiteThemeColor; +} + +export interface StandardSitePublication { + name: string; + rkey: string; + uri: string; + url: string; + description?: string; + icon?: string; + basicTheme?: StandardSiteBasicTheme; + preferences?: { showInDiscover?: boolean }; +} + +export interface StandardSitePublicationsData { + publications: StandardSitePublication[]; +} + +export interface StandardSiteDocument { + title: string; + rkey: string; + uri: string; + url: string; + site: string; + path?: string; + description?: string; + coverImage?: string; + content?: any; + textContent?: string; + bskyPostRef?: { uri: string; cid: string }; + tags?: string[]; + publishedAt: string; + updatedAt?: string; + publicationName?: string; + publicationRkey?: string; +} + +export interface StandardSiteDocumentsData { + documents: StandardSiteDocument[]; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..21e2579 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "moduleResolution": "bundler", + "module": "esnext", + "target": "es2022" + }, + "include": ["src"] +} -- 2.51.2 From 059d6c056b5dcc2029b5dd0b569bc09aa4a791e3 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Fri, 6 Mar 2026 23:18:31 +0000 Subject: [PATCH 2/3] chore: allow pkgs to be public --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 71a8649..5e19730 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "main": "./dist/index.js", "types": "./dist/index.d.ts", + "publishConfig": { "access": "public" }, "files": ["dist", "src"], "scripts": { "build": "tsc --project tsconfig.json", -- 2.51.2 From 0d6856c3f4196c9bbb6d0a3c0fb984ac90afd5b8 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Fri, 6 Mar 2026 23:32:34 +0000 Subject: [PATCH 3/3] chore: create READMEs for pkgs --- README.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5a9c10 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# @ewanc26/atproto + +AT Protocol service layer extracted from [ewancroft.uk](https://ewancroft.uk). Handles identity resolution, record fetching, Bluesky posts, Standard.site documents, music/mood status, and more — with a built-in in-memory cache. + +**Key difference from the app's internal service layer:** all functions accept `did: string` as their first argument rather than reading `PUBLIC_ATPROTO_DID` from the environment. + +## Installation + +```bash +pnpm add @ewanc26/atproto +``` + +Requires `@atproto/api >= 0.13.0` as a peer dependency. + +## What's Exported + +### Fetch Functions + +| Function | Description | +|----------|-------------| +| `fetchProfile(did)` | Bluesky profile including pronouns | +| `fetchSiteInfo(did)` | `uk.ewancroft.site.info` record | +| `fetchLinks(did)` | `blue.linkat.board` link cards | +| `fetchMusicStatus(did)` | teal.fm music status with cascading artwork lookup | +| `fetchKibunStatus(did)` | kibun.social mood status | +| `fetchTangledRepos(did)` | Tangled code repositories | +| `fetchPublications(did)` | Standard.site publications | +| `fetchDocuments(did, pubRkey)` | Standard.site documents for a publication | +| `fetchBlogPosts(did, pubRkey)` | Blog posts for a publication | +| `fetchRecentDocuments(did, limit)` | Most recent documents across all publications | +| `fetchLatestBlueskyPost(did)` | Latest non-reply Bluesky post with thread context | +| `fetchPostFromUri(did, uri)` | Single Bluesky post by AT URI | +| `fetchAllEngagement(uris)` | Like/repost counts via Constellation API | + +### Agents & Identity + +```typescript +import { resolveIdentity, getPublicAgent, getPDSAgent, createAgent, withFallback, resetAgents } from '@ewanc26/atproto'; +``` + +### Pagination + +```typescript +import { fetchAllRecords, fetchAllUserRecords } from '@ewanc26/atproto'; +``` + +### Cache + +```typescript +import { cache, ATProtoCache, CACHE_TTL } from '@ewanc26/atproto'; + +cache.clear(); +cache.delete('profile:did:plc:…'); +``` + +### Music Artwork + +```typescript +import { findArtwork, searchMusicBrainzRelease, buildCoverArtUrl } from '@ewanc26/atproto'; +// Cascading: MusicBrainz → iTunes → Deezer → Last.fm → PDS blob +``` + +### Media + +```typescript +import { buildPdsBlobUrl, extractCidFromImageObject, extractImageUrlsFromValue } from '@ewanc26/atproto'; +``` + +### Types + +All interfaces (`ProfileData`, `BlueskyPost`, `BlogPost`, `MusicStatusData`, `KibunStatusData`, `TangledRepo`, `StandardSiteDocument`, `StandardSitePublication`, `SiteInfoData`, `LinkData`, `ResolvedIdentity`, …) are exported from the package root. + +## Build + +```bash +pnpm build # tsc +pnpm dev # tsc --watch +pnpm check # tsc --noEmit +``` + +## Licence + +See the root [LICENSE](../../LICENSE).