Files
log/src/lib/api.ts
2026-01-16 18:33:48 +09:00

362 lines
10 KiB
TypeScript

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<string, AtpAgent> = 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, { plc: string; bsky: string; web?: string }>): 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<string> {
// 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<string> {
// 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<Profile> {
// 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<BlogPost[]> {
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<BlogPost | null> {
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<string[]> {
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<any[]> {
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<any | null> {
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<any | null> {
// 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<string, { domain: string; icon?: string }> = {
'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<any[]> {
// 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<string>()
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
}