import { AtpAgent } from '@atproto/api' import type { Profile, BlogPost, NetworkConfig } from '../types.js' import { FALLBACK_PLCS, FALLBACK_BSKY_ENDPOINTS, SEARCH_TIMEOUT_MS } from './constants.js' const agents: Map = new Map() let networkConfig: NetworkConfig | null = null export function setNetworkConfig(config: NetworkConfig): void { networkConfig = config } export function getPlc(): string { return networkConfig?.plc || 'https://plc.directory' } // Get PLC URL based on PDS endpoint export function getPlcForPds(pds: string, networks: Record): string { // Check if PDS matches any network for (const [_key, config] of Object.entries(networks)) { // Match by domain (e.g., "https://syu.is" or "https://bsky.syu.is") try { const pdsHost = new URL(pds).hostname const bskyHost = new URL(config.bsky).hostname // Check if PDS host matches network's bsky host if (pdsHost === bskyHost || pdsHost.endsWith('.' + bskyHost)) { return config.plc } // Also check web host if available if (config.web) { const webHost = new URL(config.web).hostname if (pdsHost === webHost || pdsHost.endsWith('.' + webHost)) { return config.plc } } } catch { continue } } // Default to plc.directory return 'https://plc.directory' } function getBsky(): string { return networkConfig?.bsky || 'https://public.api.bsky.app' } export function getAgent(service: string): AtpAgent { if (!agents.has(service)) { agents.set(service, new AtpAgent({ service })) } return agents.get(service)! } export async function resolvePds(did: string): Promise { // Try current PLC first, then fallbacks const plcs = [getPlc(), ...FALLBACK_PLCS.filter(p => p !== getPlc())] for (const plc of plcs) { try { const res = await fetch(`${plc}/${did}`) if (!res.ok) continue const doc = await res.json() const service = doc.service?.find((s: any) => s.type === 'AtprotoPersonalDataServer') if (service?.serviceEndpoint) { return service.serviceEndpoint } } catch { continue } } return getBsky() } export async function resolveHandle(handle: string): Promise { // Try current network first try { const agent = getAgent(getBsky()) const res = await agent.resolveHandle({ handle }) return res.data.did } catch { // Try fallback endpoints for (const endpoint of FALLBACK_BSKY_ENDPOINTS) { if (endpoint === getBsky()) continue // Skip if same as current try { const agent = getAgent(endpoint) const res = await agent.resolveHandle({ handle }) return res.data.did } catch { continue } } throw new Error(`Could not resolve handle: ${handle}`) } } export async function getProfile(actor: string): Promise { // Try current network first const endpoints = [getBsky(), ...FALLBACK_BSKY_ENDPOINTS.filter(e => e !== getBsky())] for (const endpoint of endpoints) { try { const agent = getAgent(endpoint) const res = await agent.getProfile({ actor }) return { did: res.data.did, handle: res.data.handle, displayName: res.data.displayName, description: res.data.description, avatar: res.data.avatar, banner: res.data.banner, } } catch { continue } } throw new Error(`Could not get profile: ${actor}`) } export async function listRecords( did: string, collection: string, limit = 50 ): Promise { const pds = await resolvePds(did) const agent = getAgent(pds) const res = await agent.com.atproto.repo.listRecords({ repo: did, collection, limit, }) return res.data.records.map((record: any) => ({ uri: record.uri, cid: record.cid, title: record.value.title || '', content: record.value.content || '', createdAt: record.value.createdAt || '', })) } export async function getRecord( did: string, collection: string, rkey: string ): Promise { const pds = await resolvePds(did) const agent = getAgent(pds) try { const res = await agent.com.atproto.repo.getRecord({ repo: did, collection, rkey, }) return { uri: res.data.uri, cid: res.data.cid || '', title: (res.data.value as any).title || '', content: (res.data.value as any).content || '', createdAt: (res.data.value as any).createdAt || '', } } catch { return null } } export async function describeRepo(did: string): Promise { const pds = await resolvePds(did) const agent = getAgent(pds) const res = await agent.com.atproto.repo.describeRepo({ repo: did }) return res.data.collections || [] } export async function listRecordsRaw( did: string, collection: string, limit = 100 ): Promise { const pds = await resolvePds(did) const agent = getAgent(pds) const res = await agent.com.atproto.repo.listRecords({ repo: did, collection, limit, }) return res.data.records } export async function getRecordRaw( did: string, collection: string, rkey: string ): Promise { const pds = await resolvePds(did) const agent = getAgent(pds) try { const res = await agent.com.atproto.repo.getRecord({ repo: did, collection, rkey, }) return res.data } catch { return null } } // Known lexicon prefixes that have schemas const KNOWN_LEXICON_PREFIXES = [ 'app.bsky.', 'chat.bsky.', 'com.atproto.', 'sh.tangled.', 'pub.leaflet.', 'blue.linkat.', 'fyi.unravel.frontpage.', 'com.whtwnd.', 'com.shinolabs.pinksea.', ] export function hasKnownSchema(nsid: string): boolean { return KNOWN_LEXICON_PREFIXES.some(prefix => nsid.startsWith(prefix)) } export async function fetchLexicon(nsid: string): Promise { // Check if it's a known lexicon first if (hasKnownSchema(nsid)) { return { id: nsid, known: true } } // Extract authority from NSID (e.g., "ai.syui.log.post" -> "syui.ai") const parts = nsid.split('.') if (parts.length < 3) return null const authority = parts.slice(0, 2).reverse().join('.') const url = `https://${authority}/.well-known/lexicon/${nsid}.json` try { const res = await fetch(url) if (!res.ok) return null return await res.json() } catch { return null } } // Known service mappings for collections const SERVICE_MAP: Record = { 'app.bsky': { domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' }, 'chat.bsky': { domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' }, 'ai.syui': { domain: 'syui.ai' }, 'com.whtwnd': { domain: 'whtwnd.com' }, 'fyi.unravel.frontpage': { domain: 'frontpage.fyi' }, 'com.shinolabs.pinksea': { domain: 'pinksea.art' }, 'blue.linkat': { domain: 'linkat.blue' }, 'sh.tangled': { domain: 'tangled.sh' }, 'pub.leaflet': { domain: 'leaflet.pub' }, } // Search Bluesky posts mentioning a URL export async function searchPostsForUrl(url: string): Promise { // Only use current network's endpoint - don't cross-search other networks // This avoids CORS issues with public.api.bsky.app when using different PDS const currentBsky = getBsky() const endpoints = [currentBsky] // Extract search-friendly patterns from URL // e.g., "https://syui.ai/post/abc123/" -> ["syui.ai/post/abc123", "syui.ai/post"] const searchQueries: string[] = [] try { const urlObj = new URL(url) const pathWithDomain = urlObj.host + urlObj.pathname.replace(/\/$/, '') searchQueries.push(pathWithDomain) // syui.ai/post/abc123 // Also try shorter path for broader search const pathParts = urlObj.pathname.split('/').filter(Boolean) if (pathParts.length >= 1) { searchQueries.push(urlObj.host + '/' + pathParts[0]) // syui.ai/post } } catch { searchQueries.push(url) } // Search all endpoints in parallel and collect results const allPosts: any[] = [] const seenUris = new Set() const searchPromises = endpoints.flatMap(endpoint => searchQueries.map(async query => { try { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS) const res = await fetch( `${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`, { signal: controller.signal } ) clearTimeout(timeoutId) if (!res.ok) return [] const data = await res.json() // Filter posts that actually link to the target URL return (data.posts || []).filter((post: any) => { const embedUri = post.record?.embed?.external?.uri const text = post.record?.text || '' return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, '')) }) } catch { // Silently fail for CORS/network/timeout errors return [] } }) ) const results = await Promise.all(searchPromises) // Merge results, removing duplicates by URI for (const posts of results) { for (const post of posts) { if (!seenUris.has(post.uri)) { seenUris.add(post.uri) allPosts.push(post) } } } // Sort by date (newest first) allPosts.sort((a, b) => new Date(b.record?.createdAt || 0).getTime() - new Date(a.record?.createdAt || 0).getTime() ) return allPosts } export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string; faviconFallback: string } | null { // Try to find matching service prefix for (const [prefix, info] of Object.entries(SERVICE_MAP)) { if (collection.startsWith(prefix)) { return { name: info.domain, domain: info.domain, favicon: `/favicons/${info.domain}.png`, faviconFallback: info.icon || `https://www.google.com/s2/favicons?domain=${info.domain}&sz=32` } } } // Fallback: extract domain from first 2 parts of NSID const parts = collection.split('.') if (parts.length >= 2) { const domain = parts.slice(0, 2).reverse().join('.') return { name: domain, domain: domain, favicon: `/favicons/${domain}.png`, faviconFallback: `https://www.google.com/s2/favicons?domain=${domain}&sz=32` } } return null }