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)
}