← Public packages
@kentcdodds/bluesky
Bluesky and AT Protocol helpers using package-storage handle plus blueskyAppPassword.
src/client.ts
404 lines · 15.7 KB · TypeScriptimport { 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)
}