Experimental Bluesky client for agents
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355import { createHash } from 'node:crypto';import { stat, readFile } from 'node:fs/promises';import { dirname, resolve } from 'node:path';import type { Source } from './model.ts';
type JsonObject = Record<string, unknown>;
export interface StrongReference { uri: string; cid: string; $type?: string;}
export interface PublicationTargetInput { input: string; stored?: { ref: string; uri: string; cid: string; };}
export interface PublicationAttachment { id: string; postIndex: number; role: 'image' | 'external-thumb'; order: number; sourcePath: string; alt: string; mimeType: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; byteLength: number; sha256: string;}
export interface FrozenAttachment { metadata: PublicationAttachment; bytes: Uint8Array;}
export interface LoadedDraftPost { text: string; quote?: PublicationTargetInput; images?: Array<{ attachmentId: string; alt: string }>; external?: { uri: string; title: string; description: string; thumb?: { attachmentId: string; alt: string }; }; langs?: string[];}
export interface LoadedDraft { sourceFile: string; posts: LoadedDraftPost[]; replyTo?: PublicationTargetInput; attachments: FrozenAttachment[];}
export interface PreparedPublicationPost { index: number; rkey: string; plannedUri: string; text: string; facets?: unknown[]; quote?: StrongReference; images?: Array<{ attachmentId: string; alt: string }>; external?: { uri: string; title: string; description: string; thumb?: { attachmentId: string; alt: string }; }; langs?: string[]; createdAt: string;}
export interface PublicationPlan { version: 1; id: string; ownerDid: string; service: string; preparedAt: string; sourceFile: string; initialReply?: { parent: StrongReference; root: StrongReference; }; posts: PreparedPublicationPost[]; attachments: PublicationAttachment[];}
export interface PreparedPublication { plan: PublicationPlan; attachments: FrozenAttachment[];}
export type PublicationPostState = | 'prepared' | 'assembling' | 'dispatching' | 'uncertain' | 'confirmed';
export interface PublicationPostProgress { index: number; rkey: string; plannedUri: string; state: PublicationPostState; record?: JsonObject; uri?: string; cid?: string; attemptedAt?: string; confirmedAt?: string; observedAt?: string; lastError?: string;}
export interface PublicationReceipt { id: string; status: 'prepared' | 'publishing' | 'partial' | 'uncertain' | 'complete'; plan: PublicationPlan; posts: PublicationPostProgress[];}
export interface PublicationClaim { token: string; plan: PublicationPlan; post: PreparedPublicationPost; attachments: FrozenAttachment[]; confirmed: StrongReference[]; record?: JsonObject;}
export interface PublicationRecordInspection { state: 'matching' | 'absent' | 'mismatch'; observedAt: string; uri?: string; cid?: string; detail?: string;}
export interface SocialMutationReceipt { operation: 'follow' | 'unfollow' | 'block'; targetDid: string; targetHandle?: string; state: 'created' | 'already-present' | 'deleted' | 'absent'; recordUri?: string;}
export interface SocialClient { readonly source: Source & { mode: 'authenticated'; viewerDid: string }; preparePublication(draft: LoadedDraft): Promise<PreparedPublication>; publishPost( claim: PublicationClaim, beforeDispatch: (record: JsonObject) => void, ): Promise<StrongReference>; inspectPublicationRecord( rkey: string, plannedUri: string, record: JsonObject, ): Promise<PublicationRecordInspection>; follow(target: string): Promise<SocialMutationReceipt>; unfollow(target: string): Promise<SocialMutationReceipt>; block(target: string): Promise<SocialMutationReceipt>;}
function requireObject(value: unknown, location: string): JsonObject { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`${location} must be a JSON object.`); } return value as JsonObject;}
function rejectUnknownKeys(value: JsonObject, allowed: readonly string[], location: string): void { const allowedKeys = new Set(allowed); const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key)); if (unknown.length > 0) { throw new Error(`${location} contains unsupported field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}.`); }}
function requiredString(value: unknown, location: string): string { if (typeof value !== 'string') throw new Error(`${location} must be a string.`); return value;}
function targetInput(value: unknown, location: string): PublicationTargetInput { const input = requiredString(value, location); if (input.trim().length === 0) throw new Error(`${location} must not be empty.`); return { input };}
function imageMimeType(bytes: Uint8Array, location: string): PublicationAttachment['mimeType'] { if ( bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a ) return 'image/png'; if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { return 'image/jpeg'; } const prefix = Buffer.from(bytes.subarray(0, 12)).toString('ascii'); if (prefix.startsWith('GIF87a') || prefix.startsWith('GIF89a')) return 'image/gif'; if (bytes.length >= 12 && prefix.slice(0, 4) === 'RIFF' && prefix.slice(8, 12) === 'WEBP') { return 'image/webp'; } throw new Error(`${location} is not a supported PNG, JPEG, GIF, or WebP image.`);}
async function freezeAttachment( sourceFile: string, postIndex: number, role: PublicationAttachment['role'], order: number, raw: unknown, location: string,): Promise<FrozenAttachment> { const value = requireObject(raw, location); rejectUnknownKeys(value, ['path', 'alt'], location); const path = requiredString(value.path, `${location}.path`); const alt = requiredString(value.alt, `${location}.alt`); if (path.length === 0) throw new Error(`${location}.path must not be empty.`); const sourcePath = resolve(dirname(sourceFile), path); let details; try { details = await stat(sourcePath); } catch (error) { const message = error instanceof Error ? error.message.replace(/[\r\n\t]+/gu, ' ').slice(0, 180) : 'unknown error'; throw new Error(`Unable to inspect ${location} at ${sourcePath}: ${message}`); } if (!details.isFile()) throw new Error(`${location} at ${sourcePath} is not a regular file.`); if (details.size > 1_000_000) throw new Error(`${location} exceeds the 1,000,000-byte image limit.`);
let bytes: Uint8Array; try { bytes = new Uint8Array(await readFile(sourcePath)); } catch (error) { const message = error instanceof Error ? error.message.replace(/[\r\n\t]+/gu, ' ').slice(0, 180) : 'unknown error'; throw new Error(`Unable to read ${location} at ${sourcePath}: ${message}`); } if (bytes.length === 0) throw new Error(`${location} is empty.`); if (bytes.length > 1_000_000) throw new Error(`${location} exceeds the 1,000,000-byte image limit.`); const mimeType = imageMimeType(bytes, location); const id = `p${postIndex}-${role === 'image' ? `image-${order}` : 'external-thumb'}`; return { metadata: { id, postIndex, role, order, sourcePath, alt, mimeType, byteLength: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), }, bytes, };}
/** Parses a complete explicit post-array draft and freezes every attachment byte in memory. */export async function loadDraftFile(path: string): Promise<LoadedDraft> { const sourceFile = resolve(path); let parsed: unknown; try { parsed = JSON.parse(await readFile(sourceFile, 'utf8')); } catch (error) { const message = error instanceof Error ? error.message.replace(/[\r\n\t]+/gu, ' ').slice(0, 200) : 'unknown error'; throw new Error(`Unable to read draft JSON ${sourceFile}: ${message}`); } const root = requireObject(parsed, 'Draft'); rejectUnknownKeys(root, ['posts', 'replyTo'], 'Draft'); if (!Array.isArray(root.posts) || root.posts.length === 0) { throw new Error('Draft.posts must be a nonempty array.'); }
const attachments: FrozenAttachment[] = []; const posts: LoadedDraftPost[] = []; for (let offset = 0; offset < root.posts.length; offset += 1) { const postIndex = offset + 1; const location = `Draft.posts[${offset}]`; const rawPost = requireObject(root.posts[offset], location); rejectUnknownKeys(rawPost, ['text', 'quote', 'images', 'external', 'langs'], location); const post: LoadedDraftPost = { text: requiredString(rawPost.text, `${location}.text`) }; if (rawPost.quote !== undefined) post.quote = targetInput(rawPost.quote, `${location}.quote`);
if (rawPost.images !== undefined) { if (!Array.isArray(rawPost.images)) throw new Error(`${location}.images must be an array.`); if (rawPost.images.length > 4) throw new Error(`${location}.images may contain at most 4 images.`); const images: Array<{ attachmentId: string; alt: string }> = []; for (let imageOffset = 0; imageOffset < rawPost.images.length; imageOffset += 1) { const frozen = await freezeAttachment( sourceFile, postIndex, 'image', imageOffset + 1, rawPost.images[imageOffset], `${location}.images[${imageOffset}]`, ); attachments.push(frozen); images.push({ attachmentId: frozen.metadata.id, alt: frozen.metadata.alt }); } post.images = images; }
if (rawPost.external !== undefined) { if (post.images && post.images.length > 0) { throw new Error(`${location} cannot combine images with external metadata; no supplied embed data will be dropped.`); } const external = requireObject(rawPost.external, `${location}.external`); rejectUnknownKeys(external, ['uri', 'title', 'description', 'thumb'], `${location}.external`); post.external = { uri: requiredString(external.uri, `${location}.external.uri`), title: requiredString(external.title, `${location}.external.title`), description: requiredString(external.description, `${location}.external.description`), }; if (external.thumb !== undefined) { const frozen = await freezeAttachment( sourceFile, postIndex, 'external-thumb', 1, external.thumb, `${location}.external.thumb`, ); attachments.push(frozen); post.external.thumb = { attachmentId: frozen.metadata.id, alt: frozen.metadata.alt }; } }
if (rawPost.langs !== undefined) { if (!Array.isArray(rawPost.langs) || rawPost.langs.some((lang) => typeof lang !== 'string')) { throw new Error(`${location}.langs must be an array of language-tag strings.`); } if (rawPost.langs.length > 3) throw new Error(`${location}.langs may contain at most 3 language tags.`); post.langs = [...rawPost.langs] as string[]; } posts.push(post); }
return { sourceFile, posts, ...(root.replyTo !== undefined ? { replyTo: targetInput(root.replyTo, 'Draft.replyTo') } : {}), attachments, };}