import { packageSecrets, 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 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()) ) } // createSession needs the app password in its JSON body, and the fetch gateway // substitutes secrets only into headers. The `blueskyAppPassword` credential slot // is `raw`, so package code reads the value here (cloud runs only, not --local). async function readAppPassword() { let value: unknown try { value = await packageSecrets.get(APP_PASSWORD_SECRET) } catch (error) { const reason = error instanceof Error ? error.message : String(error) throw new Error(`${missingAppPasswordMessage()} (${reason})`) } if (typeof value !== 'string' || !value) throw new Error(missingAppPasswordMessage()) return value } async function createSession(params: AnyRecord = {}) { const identifier = await resolveIdentifier(params) if (!identifier) throw new Error(missingHandleMessage()) const password = await readAppPassword() const response = await fetch(buildUrl(normalizeService(params.service), 'com.atproto.server.createSession'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ identifier, password }), }) 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)) || '' 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)) || '' 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)) || '' 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) }