Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/bluesky

src/client.ts

417 lines · 14.2 KB · TypeScript
import { packageStorage } from 'kody:runtime'
import { buildLinkFacets, normalizeExternalHttpUrl } from './facets.ts'
import { prepareExternalCardThumbnail, thumbnailUploadPreview } from './thumbnail.ts'
import {
	APP_PASSWORD_SECRET,
	DEFAULT_AUTH_SERVICE,
	DEFAULT_PUBLIC_APPVIEW,
	HANDLE_STORAGE_KEY,
	missingAppPasswordMessage,
	missingHandleMessage,
} from './setup.ts'

export { buildLinkFacets } from './facets.ts'

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 {
		// Leave json null and surface the status below.
	}
	if (!response.ok) {
		let host = ''
		try {
			host = new URL(response.url).host
		} catch {
			// Ignore unparsable response URLs.
		}
		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 hint =
			response.status === 401 || response.status === 403
				? ' ' + missingAppPasswordMessage()
				: ''
		const error = new Error(
			(where ? `${detail} (${response.status} ${json?.error || 'error'} ${where})` : detail) + hint,
		) 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 tryDefaultHandle() {
	try {
		return readStoredHandle(await packageStorage().get(HANDLE_STORAGE_KEY))
	} catch {
		// Static imports of the official listing have no packageStorage grant.
		return ''
	}
}

export async function getDefaultHandle() {
	const stored = await tryDefaultHandle()
	if (stored) return stored
	throw new Error(missingHandleMessage())
}

export async function setDefaultHandle(params: { handle?: string } = {}) {
	const handle = String(params.handle || '').trim()
	if (!handle) throw new Error('handle is required.')
	await packageStorage().set(HANDLE_STORAGE_KEY, handle)
	return { handle }
}

async function resolveIdentifier(params: AnyRecord = {}) {
	return (
		String(params.identifier || params.handle || params.actor || '').trim() ||
		(await tryDefaultHandle())
	)
}

async function createSession(params: AnyRecord = {}) {
	const identifier = await resolveIdentifier(params)
	if (!identifier) throw new Error(missingHandleMessage())
	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:${APP_PASSWORD_SECRET}|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(params: AnyRecord = {}) {
	const identifier = await resolveIdentifier(params)
	const session = await createSession({ ...params, identifier })
	const profile = await getProfile({ actor: session.handle, authenticated: true, service: params.service })
	return {
		ok: true,
		live: 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')
	return await xrpcRequest({
		nsid: 'app.bsky.feed.searchPosts',
		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,
		}),
		service: params.service,
		authenticated: true,
		identifier: params.identifier,
	})
}

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,
		identifier: params.identifier,
	})
}

async function uploadExternalThumbnail(thumbnailUrl: string, params: AnyRecord, session: AnyRecord) {
	const url = normalizeExternalHttpUrl(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 = normalizeExternalHttpUrl(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 = {}) {
	if (!params.text) throw new Error('params.text is required')
	if (params.embed && params.externalCard) {
		throw new Error('Provide either params.embed or params.externalCard, not both')
	}
	const automaticFacets = params.autoLinkFacets === false ? [] : buildLinkFacets(params.text)
	const facets = params.facets ?? (automaticFacets.length > 0 ? automaticFacets : undefined)
	const previewEmbed = params.externalCard ? buildExternalEmbed(params.externalCard) : params.embed
	const record = cleanObject({
		$type: 'app.bsky.feed.post',
		text: params.text,
		createdAt: params.createdAt || new Date().toISOString(),
		langs: params.langs,
		facets,
		reply: params.reply,
		embed: previewEmbed,
	})
	const repo = (await resolveIdentifier(params)) || '<blueskyHandle>'
	const payload = { repo, collection: 'app.bsky.feed.post', record }
	const dryRun = requireConfirmed(params, 'create-post', payload)
	if (dryRun) {
		const thumbnailUrl = params.externalCard?.thumbnailUrl
			? normalizeExternalHttpUrl(params.externalCard.thumbnailUrl, 'externalCard.thumbnailUrl')
			: null
		const thumbnail = thumbnailUrl
			? thumbnailUploadPreview(await prepareExternalCardThumbnail(thumbnailUrl))
			: null
		return {
			...dryRun,
			thumbnailPreparation: thumbnail,
			uploads: thumbnail ? [thumbnail] : [],
		}
	}
	const session = await createSession(params)
	payload.repo = session.did
	if (params.externalCard) {
		const thumb = params.externalCard.thumbnailUrl
			? await uploadExternalThumbnail(params.externalCard.thumbnailUrl, params, session)
			: undefined
		record.embed = buildExternalEmbed(params.externalCard, thumb)
	}
	return await authedRequest('com.atproto.repo.createRecord', payload, params, session)
}

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 parsed = params.uri
		? parseAtUri(params.uri)
		: { repo: params.repo, collection: params.collection || 'app.bsky.feed.post', rkey: params.rkey }
	if (!parsed.repo || !parsed.collection || !parsed.rkey) {
		throw new Error('Provide params.uri or params.repo, params.collection, and params.rkey')
	}
	const dryRun = requireConfirmed(params, 'delete-post', parsed)
	if (dryRun) return dryRun
	return await authedRequest('com.atproto.repo.deleteRecord', parsed, params)
}

async function createSubjectRecord(params: AnyRecord, collection: string, action: string) {
	if (!params.uri || !params.cid) throw new Error('params.uri and params.cid are required')
	const repo = (await resolveIdentifier(params)) || '<blueskyHandle>'
	const payload = {
		repo,
		collection,
		record: {
			subject: { uri: params.uri, cid: params.cid },
			createdAt: params.createdAt || new Date().toISOString(),
		},
	}
	const dryRun = requireConfirmed(params, action, payload)
	if (dryRun) return dryRun
	const session = await createSession(params)
	payload.repo = session.did
	return await authedRequest('com.atproto.repo.createRecord', payload, params, session)
}

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 = {}) {
	if (!params.actor) throw new Error('params.actor is required')
	const profile = await getProfile({ actor: params.actor, authenticated: false, service: params.service })
	const repo = (await resolveIdentifier(params)) || '<blueskyHandle>'
	const payload = {
		repo,
		collection: 'app.bsky.graph.follow',
		record: { subject: profile.did, createdAt: params.createdAt || new Date().toISOString() },
	}
	const dryRun = requireConfirmed(params, 'follow', payload)
	if (dryRun) return dryRun
	const session = await createSession(params)
	payload.repo = session.did
	return await authedRequest('com.atproto.repo.createRecord', payload, params, session)
}

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