|
| 1 | +import {AtUri} from '@atproto/api' |
| 2 | +import type {AppBskyFeedPost, AppBskyActorProfile} from '@atproto/api' |
| 3 | + |
| 4 | +const SLINGSHOT_URL = 'https://slingshot.microcosm.blue' |
| 5 | +const CONSTELLATION_URL = 'https://constellation.microcosm.blue' |
| 6 | + |
| 7 | +export interface MicrocosmRecord { |
| 8 | + uri: string |
| 9 | + cid: string |
| 10 | + value: any |
| 11 | +} |
| 12 | + |
| 13 | +export interface ConstellationCounts { |
| 14 | + likeCount: number |
| 15 | + repostCount: number |
| 16 | + replyCount: number |
| 17 | + // might need a saves/bookmark counter |
| 18 | + // bookmarkCount: number |
| 19 | +} |
| 20 | + |
| 21 | +/** |
| 22 | + * Generic helper to fetch from Slingshot proxy |
| 23 | + */ |
| 24 | +async function fetchFromSlingshot<T>( |
| 25 | + endpoint: string, |
| 26 | + params: Record<string, string> |
| 27 | +): Promise<T | null> { |
| 28 | + try { |
| 29 | + const queryString = new URLSearchParams( |
| 30 | + Object.entries(params).map(([key, value]) => [key, encodeURIComponent(value)]) |
| 31 | + ).toString() |
| 32 | + |
| 33 | + const res = await fetch(`${SLINGSHOT_URL}/xrpc/${endpoint}?${queryString}`) |
| 34 | + if (!res.ok) return null |
| 35 | + return await res.json() |
| 36 | + } catch (e) { |
| 37 | + console.error(`Slingshot fetch failed (${endpoint}):`, e) |
| 38 | + return null |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +/** |
| 43 | + * Fetch a record directly from PDS via Slingshot proxy |
| 44 | + */ |
| 45 | +export async function fetchRecordViaSlingshot( |
| 46 | + atUri: string |
| 47 | +): Promise<MicrocosmRecord | null> { |
| 48 | + return fetchFromSlingshot<MicrocosmRecord>( |
| 49 | + 'com.bad-example.repo.getUriRecord', |
| 50 | + { at_uri: atUri } |
| 51 | + ) |
| 52 | +} |
| 53 | + |
| 54 | +/** |
| 55 | + * Resolve identity (DID/handle) via Slingshot |
| 56 | + */ |
| 57 | +export async function resolveIdentityViaSlingshot( |
| 58 | + identifier: string |
| 59 | +): Promise<{did: string; handle: string; pds: string} | null> { |
| 60 | + return fetchFromSlingshot( |
| 61 | + 'com.bad-example.identity.resolveMiniDoc', |
| 62 | + { identifier } |
| 63 | + ) |
| 64 | +} |
| 65 | + |
| 66 | +export async function fetchConstellationCounts( |
| 67 | + atUri: string |
| 68 | +): Promise<ConstellationCounts> { |
| 69 | + try { |
| 70 | + const res = await fetch( |
| 71 | + `${CONSTELLATION_URL}/links/all?target=${encodeURIComponent(atUri)}` |
| 72 | + ) |
| 73 | + if (!res.ok) throw new Error('Constellation fetch failed') |
| 74 | + |
| 75 | + const data = await res.json() |
| 76 | + const links = data.links || {} |
| 77 | + |
| 78 | + return { |
| 79 | + likeCount: links?.['app.bsky.feed.like']?.['.subject.uri']?.distinct_dids ?? 0, |
| 80 | + repostCount: links?.['app.bsky.feed.repost']?.['.subject.uri']?.distinct_dids ?? 0, |
| 81 | + replyCount: links?.['app.bsky.feed.post']?.['.reply.parent.uri']?.records ?? 0, |
| 82 | + } |
| 83 | + } catch (e) { |
| 84 | + console.error('Constellation fetch failed:', e) |
| 85 | + return {likeCount: 0, repostCount: 0, replyCount: 0} |
| 86 | + } |
| 87 | +} |
| 88 | + |
| 89 | +/** |
| 90 | + * Detect if error is AppView-related (suspended user, not found, etc.) |
| 91 | + */ |
| 92 | +export function isAppViewError(error: any): boolean { |
| 93 | + if (!error) return false |
| 94 | + |
| 95 | + // Check HTTP status codes |
| 96 | + if (error.status === 400 || error.status === 404) return true |
| 97 | + |
| 98 | + // Check error messages |
| 99 | + // TODO: see if there is an easy way to source error messages from the appview |
| 100 | + const msg = error.message?.toLowerCase() || '' |
| 101 | + if (msg.includes('not found')) return true |
| 102 | + if (msg.includes('suspended')) return true |
| 103 | + if (msg.includes('could not locate')) return true |
| 104 | + |
| 105 | + return false |
| 106 | +} |
| 107 | + |
| 108 | +/** |
| 109 | + * Build synthetic ProfileViewDetailed from PDS data |
| 110 | + */ |
| 111 | +export async function buildSyntheticProfileView( |
| 112 | + did: string, |
| 113 | + handle: string |
| 114 | +): Promise<any> { |
| 115 | + const profileUri = `at://${did}/app.bsky.actor.profile/self` |
| 116 | + const record = await fetchRecordViaSlingshot(profileUri) |
| 117 | + |
| 118 | + return { |
| 119 | + $type: 'app.bsky.actor.defs#profileViewDetailed', |
| 120 | + did, |
| 121 | + handle, |
| 122 | + displayName: record?.value?.displayName || handle, |
| 123 | + description: record?.value?.description || '', |
| 124 | + avatar: record?.value?.avatar |
| 125 | + ? `https://cdn.bsky.app/img/avatar/plain/${did}/${record.value.avatar.ref.$link}@jpeg` |
| 126 | + : undefined, |
| 127 | + banner: record?.value?.banner |
| 128 | + ? `https://cdn.bsky.app/img/banner/plain/${did}/${record.value.banner.ref.$link}@jpeg` |
| 129 | + : undefined, |
| 130 | + followersCount: undefined, // Not available from PDS |
| 131 | + followsCount: undefined, // Not available from PDS |
| 132 | + postsCount: undefined, // Not available from PDS |
| 133 | + indexedAt: new Date().toISOString(), |
| 134 | + viewer: {}, |
| 135 | + labels: [], |
| 136 | + __fallbackMode: true, // Mark as fallback data |
| 137 | + } |
| 138 | +} |
| 139 | + |
| 140 | +/** |
| 141 | + * Build synthetic PostView from PDS + Constellation data |
| 142 | + */ |
| 143 | +export async function buildSyntheticPostView( |
| 144 | + atUri: string, |
| 145 | + authorDid: string, |
| 146 | + authorHandle: string |
| 147 | +): Promise<any> { |
| 148 | + const record = await fetchRecordViaSlingshot(atUri) |
| 149 | + if (!record) return null |
| 150 | + |
| 151 | + const counts = await fetchConstellationCounts(atUri) |
| 152 | + const profileView = await buildSyntheticProfileView(authorDid, authorHandle) |
| 153 | + |
| 154 | + return { |
| 155 | + $type: 'app.bsky.feed.defs#postView', |
| 156 | + uri: atUri, |
| 157 | + cid: record.cid, |
| 158 | + author: profileView, |
| 159 | + record: record.value, |
| 160 | + indexedAt: new Date().toISOString(), |
| 161 | + likeCount: counts.likeCount, |
| 162 | + repostCount: counts.repostCount, |
| 163 | + replyCount: counts.replyCount, |
| 164 | + viewer: {}, |
| 165 | + labels: [], |
| 166 | + __fallbackMode: true, // Mark as fallback data |
| 167 | + } |
| 168 | +} |
| 169 | + |
| 170 | +/** |
| 171 | + * Build synthetic feed page from PDS data |
| 172 | + * This is used for infinite queries that need paginated results |
| 173 | + */ |
| 174 | +export async function buildSyntheticFeedPage( |
| 175 | + did: string, |
| 176 | + pdsUrl: string, |
| 177 | + cursor?: string |
| 178 | +): Promise<any> { |
| 179 | + try { |
| 180 | + const limit = 25 |
| 181 | + const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' |
| 182 | + |
| 183 | + // Fetch posts directly from PDS using com.atproto.repo.listRecords |
| 184 | + const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}` |
| 185 | + const res = await fetch(url) |
| 186 | + |
| 187 | + if (!res.ok) { |
| 188 | + console.error('[Fallback] Failed to fetch author feed from PDS:', res.statusText) |
| 189 | + return null |
| 190 | + } |
| 191 | + |
| 192 | + const data = await res.json() |
| 193 | + |
| 194 | + // Build FeedViewPost array from records |
| 195 | + const feed = await Promise.all( |
| 196 | + data.records.map(async (record: any) => { |
| 197 | + const postView = await buildSyntheticPostView( |
| 198 | + record.uri, |
| 199 | + did, |
| 200 | + '' // Handle will be resolved in buildSyntheticPostView |
| 201 | + ) |
| 202 | + |
| 203 | + if (!postView) return null |
| 204 | + |
| 205 | + // Wrap in FeedViewPost format |
| 206 | + return { |
| 207 | + $type: 'app.bsky.feed.defs#feedViewPost', |
| 208 | + post: postView, |
| 209 | + feedContext: undefined, |
| 210 | + } |
| 211 | + }) |
| 212 | + ) |
| 213 | + |
| 214 | + // Filter out null results |
| 215 | + const validFeed = feed.filter(item => item !== null) |
| 216 | + |
| 217 | + return { |
| 218 | + feed: validFeed, |
| 219 | + cursor: data.cursor, |
| 220 | + __fallbackMode: true, // Mark as fallback data |
| 221 | + } |
| 222 | + } catch (e) { |
| 223 | + console.error('[Fallback] Failed to build synthetic feed page:', e) |
| 224 | + return null |
| 225 | + } |
| 226 | +} |
0 commit comments