Skip to content
← Public packages

@kentcdodds/bluesky

Bluesky and AT Protocol helpers using package-storage handle plus blueskyAppPassword.

src/client.ts

404 lines · 15.7 KB · TypeScript
import { parseCreatePostInput, withPostModeResult } from './create-post-input.ts'
import {
  parseDeletePostInput,
  parseFollowInput,
  parseLikeRepostInput,
  parseUnfollowInput,
} from './write-action-input.ts'
import { packageStorage } from 'kody:runtime'
import { prepareExternalCardThumbnail, thumbnailUploadPreview } from './thumbnail.ts'

const DEFAULT_AUTH_SERVICE = 'https://bsky.social'
const DEFAULT_PUBLIC_APPVIEW = 'https://public.api.bsky.app'
const DEFAULT_SEARCH_APPVIEW = 'https://api.bsky.app'

type AnyRecord = Record<string, any>

function normalizeService(service?: string) {
  return String(service || DEFAULT_AUTH_SERVICE).replace(/\/+$/, '')
}

function resolveRequestService(params: { service?: string; authenticated?: boolean } = {}) {
  if (params.service) return normalizeService(params.service)
  return params.authenticated ? DEFAULT_AUTH_SERVICE : DEFAULT_PUBLIC_APPVIEW
}

function cleanObject(input: AnyRecord) {
  return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined && value !== null && value !== ''))
}

function buildUrl(service: string, nsid: string, query?: AnyRecord) {
  const url = new URL('/xrpc/' + nsid.replace(/^\/+/, ''), normalizeService(service))
  for (const [key, value] of Object.entries(query || {})) {
    if (Array.isArray(value)) {
      for (const item of value) url.searchParams.append(key, String(item))
    } else if (value !== undefined && value !== null && value !== '') {
      url.searchParams.set(key, String(value))
    }
  }
  return url.toString()
}

async function readJson(response: Response, context: { nsid?: string } = {}) {
  const text = await response.text()
  let json: any = null
  try {
    json = text ? JSON.parse(text) : null
  } catch {}
  if (!response.ok) {
    let host = ''
    try {
      host = new URL(response.url).host
    } catch {}
    const detail = json?.message || json?.error || response.statusText || 'Bluesky request failed'
    const where = [host && `at ${host}`, context.nsid && `for ${context.nsid}`].filter(Boolean).join(' ')
    const error = new Error(where ? `${detail} (${response.status} ${json?.error || 'error'} ${where})` : detail) as Error & {
      status?: number
      details?: any
    }
    error.status = response.status
    error.details = json || text.slice(0, 1000)
    throw error
  }
  return json
}

function readStoredHandle(stored: unknown) {
  if (typeof stored === 'string' && stored.trim()) return stored.trim()
  if (stored && typeof stored === 'object') {
    const value = (stored as { value?: unknown }).value
    if (typeof value === 'string' && value.trim()) return value.trim()
  }
  return ''
}

export async function getDefaultHandle() {
  const stored = readStoredHandle(await packageStorage().get('blueskyHandle'))
  if (stored) return stored
  throw new Error(
    'Missing packageStorage blueskyHandle. Store the handle with packageStorage().set("blueskyHandle", handle), or pass identifier to authenticated exports.',
  )
}

async function createSession(params: AnyRecord = {}) {
  const identifier = params.identifier || await getDefaultHandle()
  const response = await fetch(buildUrl(normalizeService(params.service), 'com.atproto.server.createSession'), {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ identifier, password: '{{secret:blueskyAppPassword|scope=user}}' }),
  })
  return await readJson(response, { nsid: 'com.atproto.server.createSession' })
}

async function authedRequest(nsid: string, body: AnyRecord, params: AnyRecord = {}, existingSession?: AnyRecord) {
  const session = existingSession ?? await createSession(params)
  const response = await fetch(buildUrl(normalizeService(params.service), nsid), {
    method: 'POST',
    headers: { authorization: 'Bearer ' + session.accessJwt, 'content-type': 'application/json' },
    body: JSON.stringify(body),
  })
  return await readJson(response, { nsid })
}

function requireConfirmed(params: AnyRecord, action: string, payload: AnyRecord) {
  if (params.dryRun || !params.confirm) {
    return { dryRun: true, action, requiresConfirm: true, payload }
  }
  return null
}

export async function xrpcRequest(params: AnyRecord = {}) {
  if (!params.nsid) throw new Error('params.nsid is required')
  const method = String(params.method || (params.body ? 'POST' : 'GET')).toUpperCase()
  const authenticated = params.authenticated ?? method !== 'GET'
  const service = resolveRequestService({ service: params.service, authenticated })
  const url = buildUrl(service, params.nsid, params.query)
  const body = params.body ?? null
  if (method !== 'GET' && (params.dryRun || !params.confirm)) {
    return { dryRun: true, method, url, authenticated, body, requiresConfirm: true }
  }
  const headers: AnyRecord = { accept: 'application/json' }
  let session: any = null
  if (authenticated) {
    session = await createSession(params)
    headers.authorization = 'Bearer ' + session.accessJwt
  }
  if (body !== null && body !== undefined) headers['content-type'] = 'application/json'
  const response = await fetch(url, { method, headers, body: body === null || body === undefined ? undefined : JSON.stringify(body) })
  return await readJson(response, { nsid: params.nsid })
}

export async function smokeTest() {
  const session = await createSession()
  const profile = await getProfile({ actor: session.handle, authenticated: true })
  return { ok: true, did: session.did, handle: session.handle, emailConfirmed: session.emailConfirmed, profile: { did: profile.did, handle: profile.handle, displayName: profile.displayName, followersCount: profile.followersCount } }
}

export async function resolveHandle(params: AnyRecord = {}) {
  const handle = params.handle || await getDefaultHandle()
  return await xrpcRequest({ nsid: 'com.atproto.identity.resolveHandle', query: { handle }, service: params.service, authenticated: false })
}

export async function getProfile(params: AnyRecord = {}) {
  const actor = params.actor || await getDefaultHandle()
  return await xrpcRequest({ nsid: 'app.bsky.actor.getProfile', query: { actor }, service: params.service, authenticated: params.authenticated === true })
}

export async function searchPosts(params: AnyRecord = {}) {
  if (!params.q) throw new Error('params.q is required')
  const query = cleanObject({
    q: params.q,
    limit: params.limit || 25,
    sort: params.sort,
    since: params.since,
    until: params.until,
    mentions: params.mentions,
    author: params.author,
    domain: params.domain,
    url: params.url,
    cursor: params.cursor,
  })
  const authenticated = params.authenticated ?? true
  // Explicit service override keeps prior one-host behavior for callers that
  // already know which AppView/PDS they want.
  if (params.service) {
    return await xrpcRequest({
      nsid: 'app.bsky.feed.searchPosts',
      query,
      service: params.service,
      authenticated,
    })
  }
  if (!authenticated) {
    return await xrpcRequest({
      nsid: 'app.bsky.feed.searchPosts',
      query,
      service: DEFAULT_SEARCH_APPVIEW,
      authenticated: false,
    })
  }
  // Search is an AppView method. Unauthenticated AppView calls are blocked from
  // Kody workers (403). Creating a session against api.bsky.app fails because
  // blueskyAppPassword is not approved for that host. Create the session on the
  // approved auth host, then call the search AppView with the JWT only.
  const session = await createSession({
    identifier: params.identifier,
    service: params.authService || DEFAULT_AUTH_SERVICE,
  })
  const response = await fetch(buildUrl(DEFAULT_SEARCH_APPVIEW, 'app.bsky.feed.searchPosts', query), {
    method: 'GET',
    headers: {
      accept: 'application/json',
      authorization: 'Bearer ' + session.accessJwt,
    },
  })
  return await readJson(response, { nsid: 'app.bsky.feed.searchPosts' })
}

export async function getAuthorFeed(params: AnyRecord = {}) {
  const actor = params.actor || await getDefaultHandle()
  return await xrpcRequest({ nsid: 'app.bsky.feed.getAuthorFeed', query: cleanObject({ actor, limit: params.limit || 25, cursor: params.cursor, filter: params.filter }), service: params.service, authenticated: params.authenticated === true })
}

export async function getPostThread(params: AnyRecord = {}) {
  if (!params.uri) throw new Error('params.uri is required')
  return await xrpcRequest({ nsid: 'app.bsky.feed.getPostThread', query: cleanObject({ uri: params.uri, depth: params.depth || 6, parentHeight: params.parentHeight }), service: params.service, authenticated: params.authenticated === true })
}

export async function listNotifications(params: AnyRecord = {}) {
  return await xrpcRequest({ nsid: 'app.bsky.notification.listNotifications', query: cleanObject({ limit: params.limit || 25, cursor: params.cursor, seenAt: params.seenAt }), service: params.service, authenticated: true })
}

function normalizeHttpUrl(value: unknown, label: string) {
  let url: URL
  try {
    url = new URL(String(value || ''))
  } catch {
    throw new Error(`${label} must be a valid URL`)
  }
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
    throw new Error(`${label} must use http or https`)
  }
  return url.toString()
}

function trimTrailingUrlPunctuation(value: string) {
  return value.replace(/[.,!?;:)\]}]+$/u, '')
}

function buildLinkFacets(text: string) {
  const facets: AnyRecord[] = []
  const encoder = new TextEncoder()
  const matches = text.matchAll(/https?:\/\/[^\s]+/gu)
  for (const match of matches) {
    if (match.index === undefined) continue
    const matchedUrl = trimTrailingUrlPunctuation(match[0])
    if (!matchedUrl) continue
    let uri: string
    try {
      uri = normalizeHttpUrl(matchedUrl, 'Post link')
    } catch {
      continue
    }
    const byteStart = encoder.encode(text.slice(0, match.index)).byteLength
    const byteEnd = byteStart + encoder.encode(matchedUrl).byteLength
    facets.push({
      index: { byteStart, byteEnd },
      features: [{ $type: 'app.bsky.richtext.facet#link', uri }],
    })
  }
  return facets
}

async function uploadExternalThumbnail(thumbnailUrl: string, params: AnyRecord, session: AnyRecord) {
  const url = normalizeHttpUrl(thumbnailUrl, 'externalCard.thumbnailUrl')
  const prepared = await prepareExternalCardThumbnail(url)
  const uploadResponse = await fetch(
    buildUrl(normalizeService(params.service), 'com.atproto.repo.uploadBlob'),
    {
      method: 'POST',
      headers: {
        authorization: 'Bearer ' + session.accessJwt,
        'content-type': prepared.mimeType,
      },
      body: prepared.bytes,
    },
  )
  const result = await readJson(uploadResponse, { nsid: 'com.atproto.repo.uploadBlob' })
  if (!result?.blob) throw new Error('Bluesky thumbnail upload did not return a blob')
  return result.blob
}

function buildExternalEmbed(card: AnyRecord, thumb?: AnyRecord) {
  const uri = normalizeHttpUrl(card.uri, 'externalCard.uri')
  const title = String(card.title || '').trim()
  if (!title) throw new Error('externalCard.title is required')
  const description = String(card.description || '').trim()
  return {
    $type: 'app.bsky.embed.external',
    external: { uri, title, description, ...(thumb ? { thumb } : {}) },
  }
}

export async function createPost(params: AnyRecord = {}) {
  const input = parseCreatePostInput(params)
  if (input.embed && input.externalCard) {
    throw new Error('Provide either params.embed or params.externalCard, not both')
  }
  const session = await createSession(input)
  const automaticFacets =
    input.autoLinkFacets === false ? [] : buildLinkFacets(input.text)
  const facets =
    input.facets ??
    (automaticFacets.length > 0 ? automaticFacets : undefined)
  const previewEmbed = input.externalCard
    ? buildExternalEmbed(input.externalCard)
    : input.embed
  const record = cleanObject({
    $type: 'app.bsky.feed.post',
    text: input.text,
    createdAt: input.createdAt || new Date().toISOString(),
    langs: input.langs,
    facets,
    reply: input.reply,
    embed: previewEmbed,
  })
  const payload = { repo: session.did, collection: 'app.bsky.feed.post', record }
  const dryRun = requireConfirmed(input, 'create-post', payload)
  if (dryRun) {
    const thumbnailUrl = input.externalCard?.thumbnailUrl
      ? normalizeHttpUrl(input.externalCard.thumbnailUrl, 'externalCard.thumbnailUrl')
      : null
    const thumbnail = thumbnailUrl
      ? thumbnailUploadPreview(await prepareExternalCardThumbnail(thumbnailUrl))
      : null
    return withPostModeResult(
      {
        ...dryRun,
        thumbnailPreparation: thumbnail,
        uploads: thumbnail ? [thumbnail] : [],
      },
      input.reply,
    )
  }
  if (input.externalCard) {
    const thumb = input.externalCard.thumbnailUrl
      ? await uploadExternalThumbnail(input.externalCard.thumbnailUrl, input, session)
      : undefined
    record.embed = buildExternalEmbed(input.externalCard, thumb)
  }
  const created = await authedRequest('com.atproto.repo.createRecord', payload, input, session)
  return withPostModeResult(created, input.reply)
}
function parseAtUri(uri: string) {
  const match = String(uri || '').match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/)
  if (!match) throw new Error('Expected AT URI like at://did:plc:.../collection/rkey')
  return { repo: match[1], collection: match[2], rkey: match[3] }
}

export async function deletePost(params: AnyRecord = {}) {
  const input = parseDeletePostInput(params)
  const parsed = input.uri
    ? parseAtUri(input.uri)
    : { repo: input.repo, collection: input.collection || 'app.bsky.feed.post', rkey: input.rkey }
  if (!parsed.repo || !parsed.collection || !parsed.rkey) {
    throw new Error('Provide uri (aliases: postUri, atUri, post) or repo + rkey (+ optional collection)')
  }
  const dryRun = requireConfirmed(input, 'delete-post', parsed)
  if (dryRun) return dryRun
  return await authedRequest('com.atproto.repo.deleteRecord', parsed, input)
}

async function createSubjectRecord(params: AnyRecord, collection: string, action: string) {
  const input = parseLikeRepostInput(params, action)
  const session = await createSession(input)
  const payload = {
    repo: session.did,
    collection,
    record: {
      subject: { uri: input.uri, cid: input.cid },
      createdAt: input.createdAt || new Date().toISOString(),
    },
  }
  const dryRun = requireConfirmed(input, action, payload)
  if (dryRun) return dryRun
  return await authedRequest('com.atproto.repo.createRecord', payload, input)
}

export async function likePost(params: AnyRecord = {}) {
  return await createSubjectRecord(params, 'app.bsky.feed.like', 'like-post')
}

export async function repost(params: AnyRecord = {}) {
  return await createSubjectRecord(params, 'app.bsky.feed.repost', 'repost')
}

export async function follow(params: AnyRecord = {}) {
  const input = parseFollowInput(params)
  const session = await createSession(input)
  const profile = await getProfile({ actor: input.actor, authenticated: true, service: input.service })
  const payload = {
    repo: session.did,
    collection: 'app.bsky.graph.follow',
    record: { subject: profile.did, createdAt: input.createdAt || new Date().toISOString() },
  }
  const dryRun = requireConfirmed(input, 'follow', payload)
  if (dryRun) return dryRun
  return await authedRequest('com.atproto.repo.createRecord', payload, input)
}

export async function unfollow(params: AnyRecord = {}) {
  const input = parseUnfollowInput(params)
  let uri = input.uri
  if (!uri && input.actor) {
    const profile = await getProfile({ actor: input.actor, authenticated: true, service: input.service })
    uri = profile.viewer?.following
  }
  if (!uri) {
    throw new Error('Provide uri/followUri, or actor (handle/did/user) for a profile already followed by the current user')
  }
  const parsed = parseAtUri(uri)
  const dryRun = requireConfirmed(input, 'unfollow', parsed)
  if (dryRun) return dryRun
  return await authedRequest('com.atproto.repo.deleteRecord', parsed, input)
}