import { Agent, CredentialSession } from '@atproto/api'; import { BrowserOAuthClient, type OAuthSession, } from '@atproto/oauth-client-browser'; import { buildAtprotoLoopbackClientMetadata } from '@atproto/oauth-types'; import { isBlockedPost, isSuspendedPost, suspendedToThread, } from './types'; import type { SessionData, GetTimelineResponse, GetPostThreadResponse, GetFeedResponse, Cursor, AtUri, Cid, Did, PostView, BlobRef, UploadBlobResponse, SearchActorsTypeaheadResponse, SearchPostsResponse, SearchActorsResponse, FeedGeneratorInfo, SavedFeedEntry, ListNotificationsResponse, UnreadCountResponse, Handle, FeedGeneratorView, GetActorFeedsResponse, SearchFeedsResponse, ProfileViewDetailed, ProfileViewBasic, SuspendedPost, EmbedView, } from './types'; import { initBlobRewrite, rewriteBlobUrls } from './blobRewrite'; import { resolvePdsForDids, getPdsUrlForDid } from './pdsResolver'; declare const __OAUTH_DOMAIN__: string | undefined; const HANDLE_RESOLVERS = [ 'https://compressed.cubporn.zip/', 'https://bsky.social/', ]; const DEFAULT_PUBLIC_APPVIEW_PROXIES = [ 'did:web:api.bsky.app#bsky_appview', 'did:web:api.blacksky.community#bsky_appview', ]; function appViewUrlFromProxy(proxyValue: string): string { const match = proxyValue.match(/^did:web:([^#]+)/); if (match) return `https://${match[1]}`; return 'https://api.bsky.app'; } let _publicAppViewProxies: string[] = [...DEFAULT_PUBLIC_APPVIEW_PROXIES]; async function resolveWorkingHandleResolver(): Promise { try { const cached = sessionStorage.getItem('foxsky_handle_resolver'); if (cached) return cached; } catch {} for (const resolver of HANDLE_RESOLVERS) { try { const probeUrl = `${resolver}xrpc/com.atproto.identity.resolveHandle?handle=bsky.social`; const resp = await fetch(probeUrl, { method: 'GET', signal: AbortSignal.timeout(5000) }); if (resp.status >= 500 || resp.status === 0) { console.warn(`[HANDLE_RESOLVER] ${resolver} returned ${resp.status}, trying next...`); continue; } // Any non-5xx (including 4xx for bad handle) means the server is up console.info(`[HANDLE_RESOLVER] Using ${resolver}`); try { sessionStorage.setItem('foxsky_handle_resolver', resolver); } catch {} return resolver; } catch (err) { console.warn(`[HANDLE_RESOLVER] ${resolver} unreachable:`, err); } } // All failed — fall back to bsky.social as last resort console.warn('[HANDLE_RESOLVER] All resolvers failed, falling back to bsky.social'); return HANDLE_RESOLVERS[HANDLE_RESOLVERS.length - 1]; } export class AtprotoClient { private oauthClient: BrowserOAuthClient | null = null; private oauthSession: OAuthSession | null = null; private agent: Agent | null = null; get isReady(): boolean { return this.agent != null; } get pdsUrl(): string { if (!this.oauthSession) return ''; try { return this.oauthSession.serverMetadata.issuer; } catch { return ''; } } get session(): SessionData | null { if (!this.oauthSession) return null; return { did: this.oauthSession.did, handle: '', accessJwt: '', refreshJwt: '', }; } get api(): Agent | null { return this.agent; } get oauth(): OAuthSession | null { return this.oauthSession; } get client(): BrowserOAuthClient | null { return this.oauthClient; } async initialize(): Promise { const { origin, hostname, port, pathname, protocol } = window.location; const isLoopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'; const buildDomain: string | undefined = typeof __OAUTH_DOMAIN__ !== 'undefined' ? __OAUTH_DOMAIN__ : undefined; const handleResolver = await resolveWorkingHandleResolver(); if (isLoopback && !buildDomain) { const actualHostname = hostname === 'localhost' ? '127.0.0.1' : hostname; const redirectUri = `${protocol}//${actualHostname}${port ? `:${port}` : ''}${pathname}`; const clientMetadata = buildAtprotoLoopbackClientMetadata({ redirect_uris: [redirectUri], scope: 'atproto transition:generic', }); this.oauthClient = new BrowserOAuthClient({ clientMetadata, handleResolver, responseMode: 'fragment', }); } else { const domain = buildDomain ? `https://${buildDomain}` : origin; const clientId = `${domain}/client-metadata.json`; this.oauthClient = await BrowserOAuthClient.load({ clientId, handleResolver, }); } } async initSession(): Promise<{ session: SessionData | null; state?: string | null; redirectUrl?: string | null; error?: string; }> { if (!this.oauthClient) { return { session: null, error: 'OAuth client not initialized' }; } try { const result = await this.oauthClient.init(); if (!result) { return { session: null }; } this.oauthSession = result.session; this.agent = new Agent(result.session); // Configure atproto-proxy for app.bsky.* requests routed through the user's PDS. // The PDS uses this header to determine which AppView to forward to. // Default to the bsky.app AppView; if the user's AppView list starts with // blacksky, use that instead. this.configureAgentProxy(); if (this.pdsUrl) { initBlobRewrite(this.oauthSession.did, this.pdsUrl); } let redirectUrl: string | null = null; try { redirectUrl = sessionStorage.getItem('foxsky_oauth_redirect'); if (redirectUrl) sessionStorage.removeItem('foxsky_oauth_redirect'); } catch {} return { session: this.session!, state: result.state, redirectUrl, }; } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to initialize session'; return { session: null, error: message }; } } async restoreSession(did: string): Promise { if (!this.oauthClient) { throw new Error('OAuth client not initialized'); } const session = await this.oauthClient.restore(did); this.oauthSession = session; this.agent = new Agent(session); this._isPublicAgent = false; // Configure atproto-proxy for app.bsky.* requests routed through the user's PDS. this.configureAgentProxy(); if (this.pdsUrl) { initBlobRewrite(session.did, this.pdsUrl); } return this.session!; } async revokeSession(did: string): Promise { if (!this.oauthClient) return; await this.oauthClient.revoke(did); if (this.oauthSession && this.oauthSession.did === did) { this.oauthSession = null; this.agent = null; } } async signIn(handle: string): Promise { if (!this.oauthClient) { throw new Error('OAuth client not initialized'); } const currentPath = window.location.pathname + window.location.search + window.location.hash; if (currentPath !== '/' && currentPath !== '/login') { try { sessionStorage.setItem('foxsky_oauth_redirect', currentPath); } catch {} } await this.oauthClient.signInRedirect(handle); throw new Error('Redirect did not occur'); } async logout(): Promise { if (this.oauthSession) { try { await this.oauthSession.signOut(); } catch { } } this.oauthSession = null; this.agent = null; this._isPublicAgent = false; } /** Initialize an unauthenticated Agent pointing at bsky.social * for public API access (used by demo/test accounts). */ async initPublicAgent(): Promise { this.oauthSession = null; this.agent = new Agent('https://bsky.social'); this._isPublicAgent = true; } /** Whether this agent is an unauthenticated public agent (demo mode). */ private _isPublicAgent = false; setPublicAppViewProxies(proxies: string[]): void { _publicAppViewProxies = proxies; // Re-configure the agent proxy to match the new primary AppView this.configureAgentProxy(); } getPublicAppViewProxies(): string[] { return _publicAppViewProxies; } private configureAgentProxy(): void { if (!this.agent) return; const proxyValue = _publicAppViewProxies[0] ?? 'did:web:api.bsky.app#bsky_appview'; // @ts-ignore this.agent.configureProxy(proxyValue); console.info(`[AtprotoClient] Configured agent proxy: ${proxyValue}`); } private async fetchFromAppViews( xrpcPath: string, requireOk = true, ): Promise<{ data: T; appViewProxy: string } | null> { const userPdsUrl = this.pdsUrl; for (const appViewProxy of _publicAppViewProxies) { try { const appViewUrl = userPdsUrl || appViewUrlFromProxy(appViewProxy); const url = `${appViewUrl}/${xrpcPath}`; const resp = await fetch(url, { signal: AbortSignal.timeout(10000), headers: { 'atproto-proxy': appViewProxy }, }); if (requireOk && !resp.ok) { console.warn(`[AppView] ${appViewUrl} returned ${resp.status} for ${xrpcPath}, trying next...`); continue; } if (!resp.ok) { continue; } const data = await resp.json() as T; // Check that we got actual data (not an empty/error object) if (data && typeof data === 'object') { console.info(`[AppView] ${appViewUrl} succeeded for ${xrpcPath}`); return { data, appViewProxy }; } } catch (err) { const failedUrl = userPdsUrl || appViewUrlFromProxy(appViewProxy); console.warn(`[AppView] ${failedUrl} failed for ${xrpcPath}:`, err); } } return null; } /** Public "What's Hot" feed URI used for unauthenticated browsing. */ private static readonly DISCOVER_FEED_URI = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot' as AtUri; /** * Authenticate with an app password (bypasses OAuth). * Used for test/internal accounts that can't go through the OAuth redirect flow. */ async loginWithPassword( service: string, identifier: string, password: string, ): Promise { const session = new CredentialSession(new URL(service)); await session.login({ identifier, password }); this.agent = new Agent(session); this._isPublicAgent = false; // Configure atproto-proxy for app.bsky.* requests routed through the user's PDS. this.configureAgentProxy(); const did = this.agent.did!; const handle = identifier; this.oauthSession = null; return { did, handle, accessJwt: '', refreshJwt: '', }; } /** * Restore a session using app password credentials. */ async restoreWithPassword( service: string, identifier: string, password: string, ): Promise { return this.loginWithPassword(service, identifier, password); } async getTimeline(limit = 50, cursor?: Cursor): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); // Unauthenticated (demo) mode: getTimeline and getFeed both require auth. // Try a raw fetch to getFeed first (bsky.social may allow it for official // generators without auth). If that fails, fall back to searchPosts which // is a guaranteed-public endpoint that returns trending/popular content. if (this._isPublicAgent) { // Use the public Bluesky API (api.bsky.app) which serves the // "What's Hot" discover feed without authentication. const params = new URLSearchParams({ feed: AtprotoClient.DISCOVER_FEED_URI, limit: '30', }); if (cursor) params.set('cursor', cursor); const rawResp = await this.fetchFromAppViews( `xrpc/app.bsky.feed.getFeed?${params}`, ); if (!rawResp) { throw new Error('Public feed request failed: all AppViews unavailable'); } const data = rawResp.data as { cursor?: string; feed: unknown[] }; return await rewriteBlobUrls({ cursor: data.cursor, feed: data.feed as GetTimelineResponse['feed'], }); } const resp = await this.agent.getTimeline({ limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, feed: resp.data.feed as GetTimelineResponse['feed'], }); } async getFeed(feedUri: AtUri, limit = 50, cursor?: Cursor): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.feed.getFeed({ feed: feedUri, limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, feed: resp.data.feed as GetFeedResponse['feed'], }); } async getSavedFeeds(): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const prefs = await this.agent.getPreferences(); return (prefs.savedFeeds ?? []).map((f) => ({ id: f.id, type: f.type, value: f.value, pinned: f.pinned, })); } async getFeedGenerators(uris: AtUri[]): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); if (uris.length === 0) return []; const resp = await this.agent.app.bsky.feed.getFeedGenerators({ feeds: uris }); return await rewriteBlobUrls(resp.data.feeds.map((f) => ({ uri: f.uri, cid: f.cid, did: f.did, creator: { did: f.creator.did, handle: f.creator.handle, displayName: f.creator.displayName, avatar: f.creator.avatar, }, displayName: f.displayName, description: f.description, avatar: f.avatar, likeCount: f.likeCount, indexedAt: f.indexedAt, viewer: f.viewer ? { like: f.viewer.like } : undefined, }))); } async getPostThread(uri: AtUri, depth = 10, parentHeight = 40): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); // Try PDS direct first — fetches the post directly from the post author's PDS. // This is the authoritative source and avoids routing through the logged-in user's PDS. const pdsPost = await this.getRecordPost(uri); // If PDS direct succeeded, return it immediately. The post author's PDS is the // canonical source for the record. We only fall back to the AppView (which routes // through the logged-in user's PDS) when PDS direct has no data. if (pdsPost) { // Try to enrich with AppView thread (counts, viewer state, replies) — optional try { const resp = await this.agent.getPostThread({ uri, depth, parentHeight }); const threadNode = isSuspendedPost(resp.data.thread) ? suspendedToThread(resp.data.thread) : resp.data.thread; if (isBlockedPost(threadNode)) { console.log('[getPostThread] Authenticated fetch returned BlockedPost, falling back to public AppView'); return this.getPostThreadPublic(uri, depth, parentHeight); } return await rewriteBlobUrls({ thread: resp.data.thread as GetPostThreadResponse['thread'], }); } catch (appViewErr) { console.warn('[getPostThread] AppView enrichment failed (non-critical), using PDS data:', appViewErr); } return { thread: pdsPost }; } // PDS direct returned nothing — fall back to the AppView (routed through logged-in user's PDS) try { const resp = await this.agent.getPostThread({ uri, depth, parentHeight }); const threadNode = isSuspendedPost(resp.data.thread) ? suspendedToThread(resp.data.thread) : resp.data.thread; if (isBlockedPost(threadNode)) { console.log('[getPostThread] Authenticated fetch (fallback) returned BlockedPost, falling back to public AppView'); return this.getPostThreadPublic(uri, depth, parentHeight); } return await rewriteBlobUrls({ thread: resp.data.thread as GetPostThreadResponse['thread'], }); } catch (appViewErr) { console.warn('[getPostThread] AppView failed:', appViewErr); } throw new Error('Cannot fetch post thread: PDS returned no data and AppView is unavailable'); } async getPostThreadPublic(uri: AtUri, depth = 10, parentHeight = 40): Promise { // Try PDS direct first — fetches the post directly from the post author's PDS. // This is the authoritative source and avoids routing through any intermediate server. const pdsPost = await this.getRecordPost(uri); // If PDS direct succeeded, return it immediately. We only fall back to the public // AppView when PDS direct has no data. if (pdsPost) { // Try to enrich with public AppView thread (counts, replies) — optional try { const params = new URLSearchParams({ uri, depth: String(depth), parentHeight: String(parentHeight), }); const result = await this.fetchFromAppViews<{ thread: GetPostThreadResponse['thread'] }>( `xrpc/app.bsky.feed.getPostThread?${params}`, ); if (result) { const threadNode = isSuspendedPost(result.data.thread) ? suspendedToThread(result.data.thread) : result.data.thread; if (isBlockedPost(threadNode)) { // Even public AppView says it's blocked? This shouldn't happen for public view // unless the whole repository is taken down or similar. return { thread: pdsPost }; } return await rewriteBlobUrls({ thread: result.data.thread }); } } catch (appViewErr) { console.warn('[getPostThreadPublic] Public AppView enrichment failed (non-critical), using PDS data:', appViewErr); } return { thread: pdsPost }; } // PDS direct returned nothing — fall back to the public AppViews try { const params = new URLSearchParams({ uri, depth: String(depth), parentHeight: String(parentHeight), }); const result = await this.fetchFromAppViews<{ thread: GetPostThreadResponse['thread'] }>( `xrpc/app.bsky.feed.getPostThread?${params}`, ); if (result) { const threadNode = isSuspendedPost(result.data.thread) ? suspendedToThread(result.data.thread) : result.data.thread; if (isBlockedPost(threadNode)) { throw new Error('Post is blocked even in public AppView'); } return await rewriteBlobUrls({ thread: result.data.thread }); } } catch (appViewErr) { console.warn('[getPostThreadPublic] Public AppView failed:', appViewErr); } throw new Error('Cannot fetch post thread: PDS returned no data and public AppView is unavailable'); } async like(uri: AtUri, cid: Cid): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); return this.agent.like(uri, cid); } async unlike(likeUri: AtUri): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.deleteLike(likeUri); } async repost(uri: AtUri, cid: Cid): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); return this.agent.repost(uri, cid); } async unrepost(repostUri: AtUri): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.deleteRepost(repostUri); } async postReply( text: string, parentPost: PostView, rootRef?: { uri: AtUri; cid: Cid }, embed?: unknown, facets?: unknown[], ): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const root = rootRef ?? { uri: parentPost.uri, cid: parentPost.cid }; const reply = { root, parent: { uri: parentPost.uri, cid: parentPost.cid }, }; const record: Record = { text, reply, createdAt: new Date().toISOString(), }; if (facets && facets.length > 0) { record.facets = facets; } if (embed) { record.embed = embed; } return this.agent.post(record as Parameters[0]); } async postQuote( text: string, quotedPost: PostView, ): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); return this.agent.post({ text, embed: { $type: 'app.bsky.embed.record', record: { uri: quotedPost.uri, cid: quotedPost.cid }, }, createdAt: new Date().toISOString(), }); } async getProfile() { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.getProfile({ actor: this.agent.did! }); return await rewriteBlobUrls(resp.data); } /** * Fetch a profile directly from the user's PDS using com.atproto.repo.getRecord. * This works even when the appView is down or the account is suspended/taken down. * Uses repo.getRecord (not sync.getRecord) because the repo endpoint reads directly * from the data store and does not respect takedown/suspension status. * Returns a ProfileViewDetailed with `suspended: true` to indicate the data source. */ async getRecordProfile(actor: string): Promise { // Resolve actor to DID if it's a handle let did = actor; if (!actor.startsWith('did:')) { try { const resolved = await this.resolveHandlePublic(actor); did = resolved.did; } catch { // Try authenticated resolve as fallback try { if (this.agent) { const resolved = await this.resolveHandle(actor); did = resolved.did; } } catch { return null; } } } // Resolve PDS URL for the DID await resolvePdsForDids([did]); const pdsUrl = getPdsUrlForDid(did); if (!pdsUrl) return null; try { const resp = await fetch( `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, { signal: AbortSignal.timeout(10000) }, ); if (!resp.ok) return null; const data = await resp.json() as { uri: string; cid: string; value: Record; }; const value = data.value; // Extract avatar and banner blob refs for URL construction const avatar = this.buildBlobUrl(did, value.avatar as BlobRef | undefined, pdsUrl); const banner = this.buildBlobUrl(did, value.banner as BlobRef | undefined, pdsUrl); // Resolve handle from DID doc let handle = actor.startsWith('did:') ? did : actor; try { const didResp = await fetch( did.startsWith('did:plc:') ? `https://plc.directory/${encodeURIComponent(did)}` : `https://${did.slice('did:web:'.length)}/.well-known/did.json`, { signal: AbortSignal.timeout(5000) }, ); if (didResp.ok) { const didDoc = await didResp.json() as { alsoKnownAs?: string[] }; const akaHandle = didDoc.alsoKnownAs?.find((a) => a.startsWith('at://')); if (akaHandle) { handle = akaHandle.replace('at://', ''); } } } catch { /* best effort */ } return await rewriteBlobUrls({ did, handle, displayName: value.displayName as string | undefined, description: value.description as string | undefined, avatar, banner, followsCount: undefined, followersCount: undefined, postsCount: undefined, labels: undefined, pronouns: value.pronouns as string | undefined, website: undefined, indexedAt: undefined, suspended: true, pinnedPost: undefined, viewer: {}, } as ProfileViewDetailed); } catch { return null; } } /** * Fetch a post record directly from the author's PDS using com.atproto.repo.getRecord. * Uses repo.getRecord (not sync.getRecord) because the repo endpoint reads directly * from the data store and works even for suspended/taken-down accounts. */ async getRecordPost(postUri: AtUri): Promise { // Parse the AT URI: at://did:.../app.bsky.feed.post/rkey const uriParts = postUri.replace('at://', '').split('/'); const did = uriParts[0]; const collection = uriParts[1]; const rkey = uriParts.slice(2).join('/'); if (collection !== 'app.bsky.feed.post') return null; // Resolve PDS URL await resolvePdsForDids([did]); const pdsUrl = getPdsUrlForDid(did); if (!pdsUrl) return null; try { const resp = await fetch( `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`, { signal: AbortSignal.timeout(10000) }, ); if (!resp.ok) return null; const data = await resp.json() as { uri: string; cid: string; value: Record; }; const value = data.value; // Resolve author handle from DID doc const author = await this.buildProfileBasicFromDid(did, pdsUrl); // Build embed from record-level embed data const embed = await this.buildEmbedFromRecord(did, value.embed, pdsUrl); return await rewriteBlobUrls({ $type: 'foxsky.feed.defs#suspendedPost', uri: data.uri || postUri, cid: data.cid, author, record: { $type: 'app.bsky.feed.post', text: value.text as string, createdAt: value.createdAt as string, embed: value.embed, reply: value.reply as { root: { uri: AtUri; cid: Cid }; parent: { uri: AtUri; cid: Cid } } | undefined, langs: value.langs as string[] | undefined, facets: value.facets as unknown[], }, embed, replyCount: undefined, repostCount: undefined, likeCount: undefined, quoteCount: undefined, indexedAt: value.createdAt as string, labels: undefined, viewer: {}, } as SuspendedPost); } catch { return null; } } /** * Fetch multiple post records directly from their authors' PDSs. * Returns an array of SuspendedPost for successfully fetched posts. */ async getRecordsPosts(uris: AtUri[]): Promise { if (uris.length === 0) return []; const results = await Promise.allSettled( uris.map((uri) => this.getRecordPost(uri)), ); return results .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled' && r.value !== null, ) .map((r) => r.value); } /** * Build a blob URL from a blob ref, pointing at the given PDS. */ private buildBlobUrl(did: string, blobRef: BlobRef | undefined, pdsUrl: string): string | undefined { if (!blobRef || typeof blobRef !== 'object') return undefined; const ref = blobRef.ref; if (!ref || typeof ref !== 'object' || !('$link' in ref)) { // Could be a simple CID string or a different format const cid = (blobRef as unknown as Record).cid; if (typeof cid === 'string') { return `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; } return undefined; } return `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(ref.$link)}`; } /** * Construct a minimal ProfileViewDetailed from DID resolution alone. * Used as a last resort when both the appView and PDS repo are unavailable. * Resolves handle from DID doc but has no avatar, banner, description, etc. */ private async buildMinimalProfile(actor: string): Promise { // Resolve actor to DID if it's a handle let did = actor; let handle = actor; if (!actor.startsWith('did:')) { try { const resolved = await this.resolveHandlePublic(actor); did = resolved.did; } catch { try { if (this.agent) { const resolved = await this.resolveHandle(actor); did = resolved.did; } } catch { return null; } } } else { handle = did; } // Resolve handle from DID doc try { const didResp = await fetch( did.startsWith('did:plc:') ? `https://plc.directory/${encodeURIComponent(did)}` : `https://${did.slice('did:web:'.length)}/.well-known/did.json`, { signal: AbortSignal.timeout(5000) }, ); if (didResp.ok) { const didDoc = await didResp.json() as { alsoKnownAs?: string[] }; const akaHandle = didDoc.alsoKnownAs?.find((a) => a.startsWith('at://')); if (akaHandle) { handle = akaHandle.replace('at://', ''); } } } catch { /* best effort */ } // Try PDS to at least get the profile record (description, display name, etc.) // even if the full getRecordProfile failed upstream let displayName: string | undefined; let description: string | undefined; let avatar: string | undefined; let banner: string | undefined; try { await resolvePdsForDids([did]); const pdsUrl = getPdsUrlForDid(did); if (pdsUrl) { const profileResp = await fetch( `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, { signal: AbortSignal.timeout(8000) }, ); if (profileResp.ok) { const profileData = await profileResp.json() as { value: Record }; const value = profileData.value; displayName = value.displayName as string | undefined; description = value.description as string | undefined; avatar = this.buildBlobUrl(did, value.avatar as BlobRef | undefined, pdsUrl); banner = this.buildBlobUrl(did, value.banner as BlobRef | undefined, pdsUrl); } } } catch { /* best effort */ } return { did, handle, displayName, description, avatar, banner, followsCount: undefined, followersCount: undefined, postsCount: undefined, labels: undefined, indexedAt: undefined, suspended: true, viewer: {}, } as ProfileViewDetailed; } /** * Build a basic ProfileViewBasic from a DID by resolving the DID doc * and fetching the profile record from the PDS. */ private async buildProfileBasicFromDid(did: string, pdsUrl: string): Promise { let handle = did; try { // Try to get handle from DID doc via PLC directory const didResp = await fetch( did.startsWith('did:plc:') ? `https://plc.directory/${encodeURIComponent(did)}` : `https://${did.slice('did:web:'.length)}/.well-known/did.json`, { signal: AbortSignal.timeout(5000) }, ); if (didResp.ok) { const didDoc = await didResp.json() as { alsoKnownAs?: string[] }; const akaHandle = didDoc.alsoKnownAs?.find((a) => a.startsWith('at://')); if (akaHandle) { handle = akaHandle.replace('at://', ''); } } } catch { /* best effort */ } let displayName: string | undefined; let avatar: string | undefined; try { const profileResp = await fetch( `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, { signal: AbortSignal.timeout(5000) }, ); if (profileResp.ok) { const profileData = await profileResp.json() as { value: Record }; displayName = profileData.value.displayName as string | undefined; avatar = this.buildBlobUrl(did, profileData.value.avatar as BlobRef | undefined, pdsUrl); } } catch { /* best effort */ } return { did, handle, displayName, avatar, viewer: {}, }; } /** * Build an EmbedView from a raw record embed by resolving blob references * to actual URLs on the PDS. Best-effort: returns undefined if it can't. */ private async buildEmbedFromRecord( did: string, rawEmbed: unknown, pdsUrl: string, ): Promise { if (!rawEmbed || typeof rawEmbed !== 'object') return undefined; const embed = rawEmbed as Record; const embedType = embed.$type as string; // Handle images embed if (embedType === 'app.bsky.embed.images') { const images = embed.images as Array> | undefined; if (!images) return undefined; return { $type: 'app.bsky.embed.images#view', images: images.map((img) => { const blobRef = img.image as BlobRef | undefined; const thumb = this.buildBlobUrl(did, blobRef, pdsUrl); return { thumb: thumb || '', fullsize: thumb || '', alt: (img.alt as string) || '', aspectRatio: img.aspectRatio as { width: number; height: number } | undefined, }; }), }; } // Handle video embed if (embedType === 'app.bsky.embed.video') { const videoBlob = embed.video as BlobRef | undefined; const videoCid = videoBlob?.ref?.$link || (videoBlob as unknown as Record)?.cid as string | undefined; const thumbBlob = embed.thumbnail as BlobRef | undefined; if (!videoCid) return undefined; return { $type: 'app.bsky.embed.video#view', cid: videoCid, playlist: `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(videoCid)}`, thumbnail: this.buildBlobUrl(did, thumbBlob, pdsUrl), alt: embed.alt as string | undefined, aspectRatio: embed.aspectRatio as { width: number; height: number } | undefined, }; } // Handle external embed if (embedType === 'app.bsky.embed.external') { const external = embed.external as Record | undefined; if (!external) return undefined; const thumbBlob = external.thumb as BlobRef | undefined; return { $type: 'app.bsky.embed.external#view', external: { uri: (external.uri as string) || '', title: (external.title as string) || '', description: (external.description as string) || '', thumb: this.buildBlobUrl(did, thumbBlob, pdsUrl), }, }; } // Handle recordWithMedia embed — render the media part, skip the record // (record enrichment requires AppView, but media can be served from PDS blobs) if (embedType === 'app.bsky.embed.recordWithMedia') { const media = embed.media as Record | undefined; if (!media) return undefined; const mediaEmbed = await this.buildEmbedFromRecord(did, media, pdsUrl); // Return only the media portion; the record (quote) part can't be hydrated // without the AppView, so it's omitted. return mediaEmbed; } // For record embeds, the appView enriches these from just URI/CID refs // into full views. Without the appView we can't do this, so return undefined // and let the UI show the raw text only. return undefined; } /** * Check if an error from the AppView indicates an account takedown/suspension. * The bsky AppView returns: {"error":"AccountTakedown","message":"Account has been suspended"} */ private isAccountTakedownError(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const e = err as Record; // Check for XRPC error shape const error = e.error as string | undefined; if (error === 'AccountTakedown') return true; // Also check the message for "suspend" keyword as a fallback const message = (e.message as string) || String(err); if (typeof message === 'string' && message.toLowerCase().includes('suspend')) return true; return false; } async getActorProfile(actor: string): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); let isSuspended = false; // 1. Try authenticated appView (fastest, richest data for normal accounts) try { const resp = await this.agent.getProfile({ actor }); const profile = await rewriteBlobUrls(resp.data) as ProfileViewDetailed; return profile; } catch (appViewErr) { if (this.isAccountTakedownError(appViewErr)) { isSuspended = true; } console.warn('[getActorProfile] Authenticated appView failed:', appViewErr); } // 2. Try public AppViews (might still serve data for suspended/taken-down accounts) try { const result = await this.fetchFromAppViews( `xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`, ); if (result && result.data && result.data.did) { const profile = await rewriteBlobUrls(result.data) as ProfileViewDetailed; if (isSuspended) { profile.suspended = true; } return profile; } } catch (publicErr) { console.warn('[getActorProfile] Public AppViews failed:', publicErr); } // 3. Try PDS direct (fetches raw profile record from the user's PDS) const pdsProfile = await this.getRecordProfile(actor); if (pdsProfile) return pdsProfile; // 4. Construct minimal profile from DID resolution (last resort) const minimalProfile = await this.buildMinimalProfile(actor); if (minimalProfile) return minimalProfile; throw new Error(`Cannot fetch profile for ${actor}`); } async updateProfile(fields: { displayName?: string; description?: string; avatar?: string; banner?: string; pronouns?: string; }): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const current = await this.agent.com.atproto.repo.getRecord({ repo: this.agent.did!, collection: 'app.bsky.actor.profile', rkey: 'self', }); const existing = (current.data as { value?: Record }).value ?? {}; const updated: Record = { ...existing, $type: 'app.bsky.actor.profile', }; if (fields.displayName !== undefined) updated.displayName = fields.displayName; if (fields.description !== undefined) updated.description = fields.description; if (fields.pronouns !== undefined) { if (fields.pronouns === '') { delete updated.pronouns; } else { updated.pronouns = fields.pronouns; } } await this.agent.com.atproto.repo.putRecord({ repo: this.agent.did!, collection: 'app.bsky.actor.profile', rkey: 'self', record: updated, }); } async getPosts(uris: AtUri[]): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); if (uris.length === 0) return []; // Try PDS direct first — fetches posts directly from each post author's PDS. // This is the authoritative source and avoids routing through the logged-in user's PDS. const pdsPosts = await this.getRecordsPosts(uris); // If all posts were fetched via PDS direct, try to enrich with AppView for counts, // viewer state, etc. — but prefer the PDS data structure when AppView fails. // If PDS direct returned all requested posts, we still try AppView enrichment // because it provides richer data (like counts, viewer state). if (pdsPosts.length === uris.length) { // All posts fetched from author PDSes — try AppView for enrichment (optional) try { const BATCH_SIZE = 25; const allAppViewPosts: PostView[] = []; for (let i = 0; i < uris.length; i += BATCH_SIZE) { const batch = uris.slice(i, i + BATCH_SIZE); const resp = await this.agent.app.bsky.feed.getPosts({ uris: batch }); allAppViewPosts.push(...(resp.data.posts as PostView[])); } // If AppView also succeeded, use the richer AppView data (has counts, viewer state) if (allAppViewPosts.length > 0) { return await rewriteBlobUrls(allAppViewPosts); } } catch (appViewErr) { console.warn('[getPosts] AppView enrichment failed (non-critical), using PDS data:', appViewErr); } // AppView failed or returned nothing — use PDS data return pdsPosts as unknown as PostView[]; } // PDS direct returned partial or no results — try the AppView for full hydration try { const BATCH_SIZE = 25; if (uris.length <= BATCH_SIZE) { const resp = await this.agent.app.bsky.feed.getPosts({ uris }); return await rewriteBlobUrls(resp.data.posts as PostView[]); } const allPosts: PostView[] = []; for (let i = 0; i < uris.length; i += BATCH_SIZE) { const batch = uris.slice(i, i + BATCH_SIZE); const resp = await this.agent.app.bsky.feed.getPosts({ uris: batch }); allPosts.push(...(resp.data.posts as PostView[])); } return await rewriteBlobUrls(allPosts); } catch (appViewErr) { // AppView failed — that's OK if we have PDS data console.warn('[getPosts] AppView failed (non-critical), using PDS records:', appViewErr); } // Use PDS results if we have them if (pdsPosts.length > 0) return pdsPosts as unknown as PostView[]; throw new Error('Cannot fetch posts: PDS returned no data and AppView is unavailable'); } async getAuthorFeed( actor: string, limit = 30, cursor?: Cursor, filter?: 'posts_with_replies' | 'posts_no_replies' | 'posts_with_media' | 'posts_with_video', ) { if (!this.agent) throw new Error('AtprotoClient: not logged in'); // Try PDS direct first (works even when appView is down or account is suspended) let pdsResult: { cursor?: string; feed: GetTimelineResponse['feed'] } | null = null; try { pdsResult = await this.getAuthorFeedViaRecords(actor, limit, cursor, filter); } catch (pdsErr) { console.warn('[getAuthorFeed] PDS direct failed:', pdsErr); } // If PDS returned posts, try to enrich with AppView for full hydration // (counts, viewer state, etc.). Use AppView data when available because // it provides a richer experience. Fall back to PDS data only when AppView // fails — this ensures suspended/taken-down accounts still work. if (pdsResult && pdsResult.feed.length > 0) { // Try authenticated agent first, then fall through to public AppViews try { const resp = await this.agent.getAuthorFeed({ actor, limit, cursor, filter }); const appViewResult = await rewriteBlobUrls({ cursor: resp.data.cursor, feed: resp.data.feed as GetTimelineResponse['feed'], }); if (appViewResult.feed.length > 0) { return appViewResult; } } catch (agentErr) { console.warn('[getAuthorFeed] Agent enrichment failed, trying public AppViews:', agentErr); } // Agent enrichment failed or returned empty — try public AppViews (bsky then Blacksky) try { const params = new URLSearchParams({ actor, limit: String(limit), }); if (cursor) params.set('cursor', cursor); if (filter) params.set('filter', filter); const result = await this.fetchFromAppViews<{ cursor?: string; feed: GetTimelineResponse['feed'] }>( `xrpc/app.bsky.feed.getAuthorFeed?${params}`, ); if (result) { const appViewResult = await rewriteBlobUrls({ cursor: result.data.cursor, feed: result.data.feed as GetTimelineResponse['feed'], }); if (appViewResult.feed.length > 0) { return appViewResult; } } } catch (appViewErr) { console.warn('[getAuthorFeed] Public AppView enrichment failed, using PDS data:', appViewErr); } // All AppView enrichment failed or returned empty — use PDS data as fallback return pdsResult; } // PDS returned no results — try the authenticated agent first, then public AppViews try { const resp = await this.agent.getAuthorFeed({ actor, limit, cursor, filter }); const appViewResult = await rewriteBlobUrls({ cursor: resp.data.cursor, feed: resp.data.feed as GetTimelineResponse['feed'], }); if (appViewResult.feed.length > 0) { return appViewResult; } console.warn('[getAuthorFeed] Agent returned empty feed'); } catch (agentErr) { console.warn('[getAuthorFeed] Agent failed (non-critical), trying public AppViews:', agentErr); } // Agent failed or returned empty — try public AppViews try { const params = new URLSearchParams({ actor, limit: String(limit), }); if (cursor) params.set('cursor', cursor); if (filter) params.set('filter', filter); const result = await this.fetchFromAppViews<{ cursor?: string; feed: GetTimelineResponse['feed'] }>( `xrpc/app.bsky.feed.getAuthorFeed?${params}`, ); if (result) { const appViewResult = await rewriteBlobUrls({ cursor: result.data.cursor, feed: result.data.feed as GetTimelineResponse['feed'], }); if (appViewResult.feed.length > 0) { return appViewResult; } } console.warn('[getAuthorFeed] Public AppViews returned empty feed'); } catch (appViewErr) { console.warn('[getAuthorFeed] Public AppViews failed (non-critical):', appViewErr); } // Use PDS result even if empty (it may have a cursor for pagination) if (pdsResult) { return pdsResult; } throw new Error('Cannot fetch author feed: PDS returned no data and AppView is unavailable'); } async getAuthorFeedPublic( actor: string, limit = 30, cursor?: Cursor, filter?: 'posts_with_replies' | 'posts_no_replies' | 'posts_with_media' | 'posts_with_video', ) { // Try PDS direct first (works even when appView is down or account is suspended) let pdsResult: { cursor?: string; feed: GetTimelineResponse['feed'] } | null = null; try { pdsResult = await this.getAuthorFeedViaRecords(actor, limit, cursor, filter); } catch (pdsErr) { console.warn('[getAuthorFeedPublic] PDS direct failed:', pdsErr); } // If PDS returned posts, try to enrich with public AppViews for full hydration // (counts, viewer state, etc.). Use AppView data when available because // it provides a richer experience. Fall back to PDS data only when AppView fails. if (pdsResult && pdsResult.feed.length > 0) { try { const params = new URLSearchParams({ actor, limit: String(limit), }); if (cursor) params.set('cursor', cursor); if (filter) params.set('filter', filter); const result = await this.fetchFromAppViews<{ cursor?: string; feed: GetTimelineResponse['feed'] }>( `xrpc/app.bsky.feed.getAuthorFeed?${params}`, ); if (result) { const appViewResult = await rewriteBlobUrls({ cursor: result.data.cursor, feed: result.data.feed as GetTimelineResponse['feed'], }); if (appViewResult.feed.length > 0) { return appViewResult; } } } catch (appViewErr) { console.warn('[getAuthorFeedPublic] Public AppView enrichment failed, using PDS data:', appViewErr); } // AppView failed or returned empty — use PDS data as fallback return pdsResult; } // PDS returned no results — try the public AppViews for full hydration try { const params = new URLSearchParams({ actor, limit: String(limit), }); if (cursor) params.set('cursor', cursor); if (filter) params.set('filter', filter); const result = await this.fetchFromAppViews<{ cursor?: string; feed: GetTimelineResponse['feed'] }>( `xrpc/app.bsky.feed.getAuthorFeed?${params}`, ); if (result) { const appViewResult = await rewriteBlobUrls({ cursor: result.data.cursor, feed: result.data.feed as GetTimelineResponse['feed'], }); if (appViewResult.feed.length > 0) { return appViewResult; } } console.warn('[getAuthorFeedPublic] Public AppViews returned empty feed'); } catch (appViewErr) { // AppView failed — that's OK if we have PDS data console.warn('[getAuthorFeedPublic] Public AppViews failed (non-critical):', appViewErr); } // Use PDS result even if empty (it may have a cursor for pagination) if (pdsResult) { return pdsResult; } throw new Error('Cannot fetch author feed: PDS returned no data and public AppView is unavailable'); } /** * Check whether a raw post record matches the given filter. * Used by getAuthorFeedViaRecords to apply tab-level filtering locally, * since the PDS listRecords endpoint has no filter parameter. */ private postMatchesFilter(value: Record, filter: string | undefined): boolean { if (!filter) return true; // 'posts_with_replies' or no filter → show everything switch (filter) { case 'posts_no_replies': // Exclude posts that are replies (have a reply field) return !value.reply; case 'posts_with_media': { // Only posts with image or video embeds const embed = value.embed as Record | undefined; if (!embed) return false; const type = embed.$type as string; return type === 'app.bsky.embed.images' || type === 'app.bsky.embed.video' || type === 'app.bsky.embed.recordWithMedia'; } case 'posts_with_video': { // Only posts with video embeds const embed = value.embed as Record | undefined; if (!embed) return false; const type = embed.$type as string; return type === 'app.bsky.embed.video' || (type === 'app.bsky.embed.recordWithMedia' && !!(embed.media as Record | undefined)?.$type?.toString().includes('video')); } default: return true; } } /** * Fetch a user's feed directly from their PDS repo using listRecords. * This is a fallback when the appView is down or the account is suspended. * Note: Without the appView, we can only return the raw posts (no hydration * of likes, reposts, reply counts, etc.). * The filter parameter is applied locally on the fetched records. */ private async getAuthorFeedViaRecords( actor: string, limit = 30, cursor?: Cursor, filter?: string, ): Promise<{ cursor?: string; feed: GetTimelineResponse['feed'] }> { // Resolve actor to DID let did = actor; if (!actor.startsWith('did:')) { try { const resolved = await this.resolveHandlePublic(actor); did = resolved.did; } catch { try { const resolved = await this.resolveHandle(actor); did = resolved.did; } catch { throw new Error(`Cannot resolve handle: ${actor}`); } } } // Resolve PDS URL await resolvePdsForDids([did]); const pdsUrl = getPdsUrlForDid(did); if (!pdsUrl) throw new Error(`Cannot resolve PDS for DID: ${did}`); // Fetch more records than needed when filtering, since some will be excluded const fetchLimit = filter && filter !== 'posts_with_replies' ? Math.min(limit * 3, 100) : Math.min(limit, 50); const params = new URLSearchParams({ repo: did, collection: 'app.bsky.feed.post', limit: String(fetchLimit), }); if (cursor) params.set('cursor', cursor); const resp = await fetch( `${pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`, { signal: AbortSignal.timeout(15000) }, ); if (!resp.ok) { throw new Error(`listRecords (posts) failed: ${resp.status}`); } const data = await resp.json() as { cursor?: string; records: Array<{ uri: string; cid: string; value: Record; }>; }; // Build SuspendedPost objects from raw records, applying filter const author = await this.buildProfileBasicFromDid(did, pdsUrl); const feed: GetTimelineResponse['feed'] = []; for (const record of data.records) { if (feed.length >= limit) break; const value = record.value; // Apply local filter if (!this.postMatchesFilter(value, filter)) continue; const embed = await this.buildEmbedFromRecord(did, value.embed, pdsUrl); const suspendedPost: SuspendedPost = await rewriteBlobUrls({ $type: 'foxsky.feed.defs#suspendedPost', uri: record.uri, cid: record.cid, author, record: { $type: 'app.bsky.feed.post', text: value.text as string, createdAt: value.createdAt as string, embed: value.embed, reply: value.reply as { root: { uri: AtUri; cid: Cid }; parent: { uri: AtUri; cid: Cid } } | undefined, langs: value.langs as string[] | undefined, facets: value.facets as unknown[], }, embed, indexedAt: value.createdAt as string, viewer: {}, } as SuspendedPost); feed.push({ post: suspendedPost }); } return { cursor: data.cursor, feed }; } /** * Resolve a handle to a DID without requiring authentication. * Uses the PLC directory / handle DNS/DID web resolution. */ async resolveHandlePublic(handle: string): Promise<{ did: string }> { // Try com.atproto.identity.resolveHandle on a public resolver first try { const resp = await fetch( `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, { signal: AbortSignal.timeout(5000) }, ); if (resp.ok) { const data = await resp.json() as { did: string }; return { did: data.did }; } } catch { /* try next */ } // Try plc.directory via handle → DID resolution try { const resp = await fetch( `https://plc.directory/handle/${encodeURIComponent(handle)}`, { signal: AbortSignal.timeout(5000) }, ); if (resp.ok) { const data = await resp.json() as { did: string }; return { did: data.did }; } } catch { /* try next */ } // Try did:web resolution try { const resp = await fetch( `https://${handle}/.well-known/atproto-did`, { signal: AbortSignal.timeout(5000) }, ); if (resp.ok) { const text = await resp.text(); const didMatch = text.match(/did:plc:[a-zA-Z0-9]+|did:web:[a-zA-Z0-9.%-]+/); if (didMatch) return { did: didMatch[0] }; } } catch { /* give up */ } throw new Error(`Cannot resolve handle: ${handle}`); } async getPostsPublic(uris: AtUri[]): Promise { if (uris.length === 0) return []; // Try PDS direct first — fetches posts directly from each post author's PDS. // This is the authoritative source and avoids routing through any intermediate server. const pdsPosts = await this.getRecordsPosts(uris); // If all posts were fetched via PDS direct, try to enrich with public AppView // for counts, viewer state, etc. — but prefer the PDS data when AppView fails. if (pdsPosts.length === uris.length) { try { const BATCH_SIZE = 25; const allAppViewPosts: PostView[] = []; for (let i = 0; i < uris.length; i += BATCH_SIZE) { const batch = uris.slice(i, i + BATCH_SIZE); const params = new URLSearchParams(); for (const u of batch) params.append('uris', u); const result = await this.fetchFromAppViews<{ posts: PostView[] }>( `xrpc/app.bsky.feed.getPosts?${params}`, ); if (result) { allAppViewPosts.push(...(result.data.posts as PostView[])); } } if (allAppViewPosts.length > 0) { return await rewriteBlobUrls(allAppViewPosts); } } catch (appViewErr) { console.warn('[getPostsPublic] Public AppView enrichment failed (non-critical), using PDS records:', appViewErr); } return pdsPosts as unknown as PostView[]; } // PDS direct returned partial or no results — try the public AppViews for full hydration try { const BATCH_SIZE = 25; const allPosts: PostView[] = []; for (let i = 0; i < uris.length; i += BATCH_SIZE) { const batch = uris.slice(i, i + BATCH_SIZE); const params = new URLSearchParams(); for (const u of batch) params.append('uris', u); const result = await this.fetchFromAppViews<{ posts: PostView[] }>( `xrpc/app.bsky.feed.getPosts?${params}`, ); if (result) { allPosts.push(...(result.data.posts as PostView[])); } } return await rewriteBlobUrls(allPosts); } catch (appViewErr) { // AppView failed — that's OK if we have PDS data console.warn('[getPostsPublic] Public AppView failed (non-critical), using PDS records:', appViewErr); } // Use PDS results if we have them if (pdsPosts.length > 0) return pdsPosts as unknown as PostView[]; throw new Error('Cannot fetch posts: PDS returned no data and public AppView is unavailable'); } async getActorLikes(actor: string, limit = 30, cursor?: Cursor) { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.feed.getActorLikes({ actor, limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, feed: resp.data.feed as GetTimelineResponse['feed'], }); } /** * Fetch another user's liked posts using the public listRecords endpoint. * This works for any user (not just the authenticated user) by: * 1. Resolving the user's DID to their PDS URL * 2. Calling com.atproto.repo.listRecords with collection=app.bsky.feed.like * 3. Extracting the liked post URIs from each like record's subject.uri * 4. Batch-fetching the actual posts via app.bsky.feed.getPosts (25 per batch) * * Returns the same shape as getActorLikes for compatibility. */ async getActorLikesViaRecords( did: string, limit = 25, cursor?: Cursor, ): Promise<{ cursor?: string; feed: GetTimelineResponse['feed'] }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); // Resolve the DID → PDS URL await resolvePdsForDids([did]); const pdsUrl = getPdsUrlForDid(did); const baseUrl = pdsUrl || 'https://bsky.social'; // Fetch like records from the user's repo const params = new URLSearchParams({ repo: did, collection: 'app.bsky.feed.like', limit: String(limit), }); if (cursor) params.set('cursor', cursor); const resp = await fetch( `${baseUrl}/xrpc/com.atproto.repo.listRecords?${params}`, ); if (!resp.ok) { throw new Error(`listRecords failed: ${resp.status}`); } const data = await resp.json() as { cursor?: string; records: Array<{ uri: string; cid: string; value: { subject: { uri: string; cid: string }; createdAt: string; }; }>; }; // Extract post URIs from the like records const postUris: AtUri[] = []; for (const record of data.records) { const subjectUri = record.value?.subject?.uri; if (subjectUri) { postUris.push(subjectUri as AtUri); } } if (postUris.length === 0) { return { cursor: data.cursor, feed: [] }; } // Batch-fetch the actual posts (getPosts already tries PDS first, then AppView enrichment) let posts: (PostView | SuspendedPost)[] = []; try { posts = await this.getPosts(postUris); } catch { // AppView getPosts failed — try PDS direct for each post console.warn('[getActorLikesViaRecords] getPosts failed, trying PDS records'); posts = await this.getRecordsPosts(postUris); } // Build a URI→PostView map for ordering (preserve listRecords order) const postMap = new Map(posts.map((p) => [p.uri, p])); // Construct FeedViewPost items — no reply/reason context for likes const feed: GetTimelineResponse['feed'] = []; for (const uri of postUris) { const post = postMap.get(uri); if (post) { feed.push({ post }); } } return await rewriteBlobUrls({ cursor: data.cursor, feed, }); } async getFollowers(actor: string, limit = 50, cursor?: Cursor) { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.graph.getFollowers({ actor, limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, subject: resp.data.subject, followers: resp.data.followers, }); } async getFollows(actor: string, limit = 50, cursor?: Cursor) { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.graph.getFollows({ actor, limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, subject: resp.data.subject, follows: resp.data.follows, }); } async follow(subjectDid: Did): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); return this.agent.follow(subjectDid); } async unfollow(followUri: AtUri): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.deleteFollow(followUri); } async block(subjectDid: Did): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.com.atproto.repo.createRecord({ repo: this.agent.did!, collection: 'app.bsky.graph.block', record: { $type: 'app.bsky.graph.block', subject: subjectDid, createdAt: new Date().toISOString(), }, }); return { uri: resp.data.uri, cid: resp.data.cid }; } async unblock(blockUri: AtUri): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const uriParts = blockUri.replace('at://', '').split('/'); const rkey = uriParts.slice(2).join('/'); await this.agent.com.atproto.repo.deleteRecord({ repo: this.agent.did!, collection: 'app.bsky.graph.block', rkey, }); } async uploadBlob(data: Uint8Array, encoding: string): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.uploadBlob(data, { encoding }); return { blob: resp.data.blob as unknown as BlobRef }; } async createPost(opts: { text: string; facets?: unknown[]; embed?: unknown; langs?: string[]; }): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const record: Record = { $type: 'app.bsky.feed.post', text: opts.text, createdAt: new Date().toISOString(), }; if (opts.facets && opts.facets.length > 0) { record.facets = opts.facets; } if (opts.embed) { record.embed = opts.embed; } if (opts.langs) { record.langs = opts.langs; } return this.agent.post(record as Parameters[0]); } async editPost(opts: { uri: AtUri; text: string; facets?: unknown[]; embed?: unknown; originalRecord: Record; }): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const uriParts = opts.uri.replace('at://', '').split('/'); const repo = uriParts[0]; const collection = uriParts[1]; const rkey = uriParts.slice(2).join('/'); const record: Record = { ...opts.originalRecord, $type: 'app.bsky.feed.post', text: opts.text, }; if (opts.facets && opts.facets.length > 0) { record.facets = opts.facets; } else { delete record.facets; } if (opts.embed) { record.embed = opts.embed; } const resp = await this.agent.com.atproto.repo.putRecord({ repo, collection, rkey, record, }); return { uri: resp.data.uri, cid: resp.data.cid }; } async searchActorsTypeahead(query: string, limit = 8): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.searchActorsTypeahead({ q: query, limit }); return await rewriteBlobUrls({ actors: resp.data.actors as SearchActorsTypeaheadResponse['actors'] }); } async searchActors(query: string, limit = 25, cursor?: Cursor): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.searchActors({ q: query, limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, actors: resp.data.actors as SearchActorsResponse['actors'], }); } async searchPosts( query: string, limit = 25, cursor?: Cursor, sort?: 'top' | 'latest', ): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const params: Record = { q: query, limit }; if (cursor) params.cursor = cursor; if (sort) params.sort = sort; const resp = await this.agent.app.bsky.feed.searchPosts(params as Parameters[0]); return await rewriteBlobUrls({ cursor: resp.data.cursor, hitsTotal: resp.data.hitsTotal, posts: resp.data.posts as PostView[], }); } async searchGifs(query: string, limit = 20): Promise> { const GIPHY_KEY = 'GlVGYHkr3WSBnllca54iNt0yFbjz7L65'; const resp = await fetch( `https://api.giphy.com/v1/gifs/search?api_key=${GIPHY_KEY}&q=${encodeURIComponent(query)}&limit=${limit}&rating=pg-13`, ); if (!resp.ok) throw new Error('GIF search failed'); const json = await resp.json() as { data: Array<{ id: string; title: string; images: { fixed_height_small: { url: string; width: string; height: string }; original: { url: string }; }; }>; }; return json.data.map((gif) => ({ id: gif.id, url: gif.images.original.url, preview: gif.images.fixed_height_small.url, title: gif.title, width: parseInt(gif.images.fixed_height_small.width, 10) || 200, height: parseInt(gif.images.fixed_height_small.height, 10) || 200, })); } async resolveHandle(handle: string): Promise<{ did: string }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.com.atproto.identity.resolveHandle({ handle }); return { did: resp.data.did }; } async listNotifications(limit = 50, cursor?: Cursor): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.notification.listNotifications({ limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, notifications: resp.data.notifications as ListNotificationsResponse['notifications'], priority: resp.data.priority ?? false, }); } async getUnreadCount(): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.notification.getUnreadCount({}); return { count: resp.data.count }; } async updateSeen(): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.notification.updateSeen({ seenAt: new Date().toISOString(), }); } async getBlocks(limit = 50, cursor?: Cursor): Promise<{ cursor?: Cursor; blocks: Array<{ did: Did; handle: Handle; displayName?: string; avatar?: string; }>; }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.graph.getBlocks({ limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, blocks: resp.data.blocks.map((b) => ({ did: b.did, handle: b.handle, displayName: b.displayName, avatar: b.avatar, })), }); } async getMutes(limit = 50, cursor?: Cursor): Promise<{ cursor?: Cursor; mutes: Array<{ did: Did; handle: Handle; displayName?: string; avatar?: string; }>; }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.graph.getMutes({ limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, mutes: resp.data.mutes.map((m) => ({ did: m.did, handle: m.handle, displayName: m.displayName, avatar: m.avatar, })), }); } async muteActor(actor: Did | Handle): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.graph.muteActor({ actor }); } async unmuteActor(actor: Did | Handle): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.graph.unmuteActor({ actor }); } async muteThread(root: AtUri): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.graph.muteThread({ root }); } async unmuteThread(root: AtUri): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.graph.unmuteThread({ root }); } async createBookmark(postUri: AtUri, postCid: Cid): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.bookmark.createBookmark({ uri: postUri, cid: postCid, }); } async deleteBookmark(postUri: AtUri): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.bookmark.deleteBookmark({ uri: postUri, }); } async getBookmarks(limit = 50, cursor?: Cursor): Promise<{ cursor?: Cursor; bookmarks: Array<{ subject: { uri: AtUri; cid: Cid }; createdAt: string; item: unknown; }>; }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.bookmark.getBookmarks({ limit, cursor, }); return await rewriteBlobUrls({ cursor: resp.data.cursor, bookmarks: resp.data.bookmarks as Array<{ subject: { uri: AtUri; cid: Cid }; createdAt: string; item: unknown; }>, }); } async updateSavedFeeds(feeds: SavedFeedEntry[]): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.overwriteSavedFeeds( feeds.map((f) => ({ id: f.id, type: f.type, value: f.value, pinned: f.pinned, })), ); } async getActorFeeds(actor: string, limit = 50, cursor?: Cursor): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.feed.getActorFeeds({ actor, limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, feeds: resp.data.feeds.map((f: any) => ({ uri: f.uri, cid: f.cid, did: f.did, creator: { did: f.creator.did, handle: f.creator.handle, displayName: f.creator.displayName, avatar: f.creator.avatar, }, displayName: f.displayName, description: f.description, descriptionFacets: f.descriptionFacets, avatar: f.avatar, likeCount: f.likeCount, acceptsInteractions: f.acceptsInteractions, labels: f.labels, indexedAt: f.indexedAt, viewer: f.viewer ? { like: f.viewer.like } : undefined, })), }); } async searchFeeds(query: string, limit = 25, cursor?: Cursor): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const params: Record = { q: query, limit }; if (cursor) params.cursor = cursor; const resp = await this.agent.app.bsky.feed.searchPosts(params as any); return await rewriteBlobUrls({ cursor: (resp.data as any).cursor, feeds: (resp.data as any).feeds.map((f: any) => ({ uri: f.uri, cid: f.cid, did: f.did, creator: { did: f.creator.did, handle: f.creator.handle, displayName: f.creator.displayName, avatar: f.creator.avatar, }, displayName: f.displayName, description: f.description, descriptionFacets: f.descriptionFacets, avatar: f.avatar, likeCount: f.likeCount, acceptsInteractions: f.acceptsInteractions, labels: f.labels, indexedAt: f.indexedAt, viewer: f.viewer ? { like: f.viewer.like } : undefined, })), }); } async getFeedGenerator(uri: AtUri): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.feed.getFeedGenerator({ feed: uri }); const f = resp.data.view as any; return await rewriteBlobUrls({ uri: f.uri, cid: f.cid, did: f.did, creator: { did: f.creator.did, handle: f.creator.handle, displayName: f.creator.displayName, avatar: f.creator.avatar, }, displayName: f.displayName, description: f.description, descriptionFacets: f.descriptionFacets, avatar: f.avatar, likeCount: f.likeCount, acceptsInteractions: f.acceptsInteractions, labels: f.labels, indexedAt: f.indexedAt, viewer: f.viewer ? { like: f.viewer.like } : undefined, }); } async likeFeedGenerator(uri: AtUri, cid: Cid): Promise<{ uri: AtUri; cid: Cid }> { return this.like(uri, cid); } async unlikeFeedGenerator(likeUri: AtUri): Promise { return this.unlike(likeUri); } /** * Report a post or account for moderation. */ async createReport(opts: { reasonType: string; reason?: string; subject: { $type: string; uri: string; cid: string } | { $type: string; did: string }; }): Promise { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.com.atproto.moderation.createReport(opts); } } export const atprotoClient = new AtprotoClient();