Skip to content
← Public packages

@kentcdodds/bluesky

Bluesky and AT Protocol helpers using package-storage handle plus blueskyAppPassword.

src/create-post-input.ts

248 lines · 6.5 KB · TypeScript
import {
	ValidationError,
	any,
	array,
	boolean,
	object,
	optional,
	parse,
	string,
} from 'remix/data-schema'
import { formatSchemaValidationError } from './schema-input-errors.ts'

export type BlueskyStrongRef = { uri: string; cid: string }

export type BlueskyReplyBody = {
	root: BlueskyStrongRef
	parent: BlueskyStrongRef
}

export type NormalizedCreatePostInput = {
	text: string
	langs?: string[]
	facets?: unknown[]
	autoLinkFacets?: boolean
	reply?: BlueskyReplyBody
	embed?: unknown
	externalCard?: {
		uri: string
		title: string
		description: string
		thumbnailUrl?: string
	}
	createdAt?: string
	identifier?: string
	service?: string
	dryRun?: boolean
	confirm?: boolean
}

export type PostPublishMode = 'reply' | 'standalone'

const nonEmptyString = string().refine((value) => value.trim().length > 0, 'Expected non-empty string')

const StrongRefSchema = object(
	{
		uri: nonEmptyString,
		cid: nonEmptyString,
	},
	{ unknownKeys: 'error' },
)

const ReplyBodySchema = object(
	{
		root: StrongRefSchema,
		parent: StrongRefSchema,
	},
	{ unknownKeys: 'error' },
)

const ExternalCardSchema = object(
	{
		uri: nonEmptyString,
		title: nonEmptyString,
		description: nonEmptyString,
		thumbnailUrl: optional(nonEmptyString),
	},
	{ unknownKeys: 'error' },
)

const KNOWN_KEYS = [
	'text',
	'langs',
	'facets',
	'autoLinkFacets',
	'reply',
	'replyTo',
	'inReplyTo',
	'parent',
	'root',
	'parentUri',
	'parentCid',
	'rootUri',
	'rootCid',
	'replyToUri',
	'replyToCid',
	'embed',
	'externalCard',
	'createdAt',
	'identifier',
	'service',
	'dryRun',
	'confirm',
] as const

const HINT =
	`Reply shape: reply: { root: { uri, cid }, parent: { uri, cid } }. ` +
	`Aliases: replyTo / inReplyTo (strong ref or {uri,cid}), parent+root, ` +
	`parentUri/parentCid/rootUri/rootCid, replyToUri/replyToCid. ` +
	`Unknown keys are rejected.`

function asStrongRef(value: unknown, label: string): BlueskyStrongRef | undefined {
	if (!value) return undefined
	if (typeof value === 'string') {
		throw new Error(
			`${label} must be { uri, cid } (AT strong ref), not a bare URI string. ` +
				`Pass reply: { root: { uri, cid }, parent: { uri, cid } }.`,
		)
	}
	if (typeof value === 'object' && !Array.isArray(value)) {
		const rec = value as Record<string, unknown>
		const uri = typeof rec.uri === 'string' ? rec.uri.trim() : ''
		const cid = typeof rec.cid === 'string' ? rec.cid.trim() : ''
		if (uri && cid) return { uri, cid }
	}
	throw new Error(`${label} must be { uri: string, cid: string }`)
}

const CreatePostInputSchema = object(
	{
		text: nonEmptyString,
		langs: optional(array(string())),
		facets: optional(array(any())),
		autoLinkFacets: optional(boolean()),
		reply: optional(ReplyBodySchema),
		replyTo: optional(any()),
		inReplyTo: optional(any()),
		parent: optional(any()),
		root: optional(any()),
		parentUri: optional(nonEmptyString),
		parentCid: optional(nonEmptyString),
		rootUri: optional(nonEmptyString),
		rootCid: optional(nonEmptyString),
		replyToUri: optional(nonEmptyString),
		replyToCid: optional(nonEmptyString),
		embed: optional(any()),
		externalCard: optional(ExternalCardSchema),
		createdAt: optional(string()),
		identifier: optional(string()),
		service: optional(string()),
		dryRun: optional(boolean()),
		confirm: optional(boolean()),
	},
	{ unknownKeys: 'error' },
).transform((raw): NormalizedCreatePostInput => {
	const candidates: BlueskyReplyBody[] = []
	if (raw.reply) candidates.push(raw.reply as BlueskyReplyBody)

	const replyTo = asStrongRef(raw.replyTo, 'replyTo') || asStrongRef(raw.inReplyTo, 'inReplyTo')
	if (replyTo) candidates.push({ root: replyTo, parent: replyTo })

	const parent = asStrongRef(raw.parent, 'parent')
	const root = asStrongRef(raw.root, 'root')
	if (parent || root) {
		if (!parent || !root) {
			throw new Error('When using parent/root aliases, pass both parent and root strong refs ({ uri, cid }).')
		}
		candidates.push({ parent, root })
	}

	if (raw.parentUri || raw.parentCid || raw.rootUri || raw.rootCid) {
		if (!raw.parentUri || !raw.parentCid || !raw.rootUri || !raw.rootCid) {
			throw new Error(
				'parentUri/parentCid/rootUri/rootCid must all be set together (or use reply: { root, parent }).',
			)
		}
		candidates.push({
			parent: { uri: raw.parentUri, cid: raw.parentCid },
			root: { uri: raw.rootUri, cid: raw.rootCid },
		})
	}

	if (raw.replyToUri || raw.replyToCid) {
		if (!raw.replyToUri || !raw.replyToCid) {
			throw new Error('replyToUri and replyToCid must be set together (used as both root and parent).')
		}
		const ref = { uri: raw.replyToUri, cid: raw.replyToCid }
		candidates.push({ root: ref, parent: ref })
	}

	const serialized = new Set(candidates.map((c) => JSON.stringify(c)))
	if (serialized.size > 1) {
		throw new Error('Conflicting reply parent/root refs — reply aliases must agree.')
	}

	return {
		text: raw.text,
		langs: raw.langs,
		facets: raw.facets,
		autoLinkFacets: raw.autoLinkFacets,
		reply: candidates[0],
		embed: raw.embed,
		externalCard: raw.externalCard as NormalizedCreatePostInput['externalCard'],
		createdAt: raw.createdAt,
		identifier: raw.identifier,
		service: raw.service,
		dryRun: raw.dryRun,
		confirm: raw.confirm,
	}
})

export function parseCreatePostInput(input: unknown): NormalizedCreatePostInput {
	try {
		return parse(CreatePostInputSchema, input ?? {})
	} catch (error) {
		if (error instanceof ValidationError) {
			throw formatSchemaValidationError(error, {
				surface: 'create-post',
				knownKeys: KNOWN_KEYS,
				hint: HINT,
				input,
			})
		}
		throw error
	}
}

export function parseReplyPostInput(input: unknown): NormalizedCreatePostInput & {
	reply: BlueskyReplyBody
} {
	const normalized = parseCreatePostInput(input)
	if (!normalized.reply) {
		throw new Error(
			'reply-post requires a parent. Pass reply: { root: { uri, cid }, parent: { uri, cid } } ' +
				'or aliases replyTo / parent+root / parentUri+parentCid+rootUri+rootCid / replyToUri+replyToCid. ' +
				'Refusing to publish a standalone post.',
		)
	}
	return normalized as NormalizedCreatePostInput & { reply: BlueskyReplyBody }
}

export function postModeFields(reply?: BlueskyReplyBody): {
	mode: PostPublishMode
	parentUri?: string
	rootUri?: string
} {
	if (reply) {
		return { mode: 'reply', parentUri: reply.parent.uri, rootUri: reply.root.uri }
	}
	return { mode: 'standalone' }
}

export function withPostModeResult(result: unknown, reply?: BlueskyReplyBody): unknown {
	const modeFields = postModeFields(reply)
	if (result && typeof result === 'object' && !Array.isArray(result)) {
		return { ...(result as Record<string, unknown>), ...modeFields }
	}
	return { result, ...modeFields }
}