Skip to content
← Public packages

@kentcdodds/bluesky

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

src/write-action-input.ts

257 lines · 6.1 KB · TypeScript
import {
	ValidationError,
	any,
	boolean,
	object,
	optional,
	parse,
	string,
} from 'remix/data-schema'
import { formatSchemaValidationError } from './schema-input-errors.ts'
import type {
	DeletePostInput,
	FollowInput,
	LikeRepostInput,
	UnfollowInput,
} from './types.ts'

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

function firstNonEmpty(...values: Array<unknown>): string | undefined {
	for (const value of values) {
		if (typeof value === 'string' && value.trim().length > 0) return value.trim()
	}
	return undefined
}

const SERVICE_CONFIRM = {
	service: optional(string()),
	dryRun: optional(boolean()),
	confirm: optional(boolean()),
} as const

const DELETE_KNOWN_KEYS = [
	'uri',
	'postUri',
	'atUri',
	'post',
	'repo',
	'collection',
	'rkey',
	'service',
	'dryRun',
	'confirm',
] as const

const LIKE_REPOST_KNOWN_KEYS = [
	'uri',
	'postUri',
	'atUri',
	'post',
	'cid',
	'postCid',
	'subject',
	'createdAt',
	'service',
	'dryRun',
	'confirm',
] as const

const FOLLOW_KNOWN_KEYS = [
	'actor',
	'handle',
	'did',
	'user',
	'username',
	'createdAt',
	'service',
	'dryRun',
	'confirm',
] as const

const UNFOLLOW_KNOWN_KEYS = [
	'uri',
	'followUri',
	'actor',
	'handle',
	'did',
	'user',
	'username',
	'service',
	'dryRun',
	'confirm',
] as const

const DeleteSchema = object(
	{
		uri: optional(nonEmptyString),
		postUri: optional(nonEmptyString),
		atUri: optional(nonEmptyString),
		post: optional(nonEmptyString),
		repo: optional(nonEmptyString),
		collection: optional(nonEmptyString),
		rkey: optional(nonEmptyString),
		...SERVICE_CONFIRM,
	},
	{ unknownKeys: 'error' },
).transform((raw): DeletePostInput => {
	const uri = firstNonEmpty(raw.uri, raw.postUri, raw.atUri, raw.post)
	return {
		uri,
		repo: raw.repo,
		collection: raw.collection,
		rkey: raw.rkey,
		service: raw.service,
		dryRun: raw.dryRun,
		confirm: raw.confirm,
	}
})

const LikeRepostSchema = object(
	{
		uri: optional(nonEmptyString),
		postUri: optional(nonEmptyString),
		atUri: optional(nonEmptyString),
		post: optional(nonEmptyString),
		cid: optional(nonEmptyString),
		postCid: optional(nonEmptyString),
		subject: optional(any()),
		createdAt: optional(string()),
		...SERVICE_CONFIRM,
	},
	{ unknownKeys: 'error' },
).transform((raw): LikeRepostInput => {
	let uri = firstNonEmpty(raw.uri, raw.postUri, raw.atUri, raw.post)
	let cid = firstNonEmpty(raw.cid, raw.postCid)
	if (raw.subject && typeof raw.subject === 'object' && !Array.isArray(raw.subject)) {
		const subject = raw.subject as Record<string, unknown>
		uri = uri ?? firstNonEmpty(subject.uri, subject.postUri, subject.atUri)
		cid = cid ?? firstNonEmpty(subject.cid, subject.postCid)
	}
	return {
		uri,
		cid,
		createdAt: raw.createdAt,
		service: raw.service,
		dryRun: raw.dryRun,
		confirm: raw.confirm,
	}
})

const FollowSchema = object(
	{
		actor: optional(nonEmptyString),
		handle: optional(nonEmptyString),
		did: optional(nonEmptyString),
		user: optional(nonEmptyString),
		username: optional(nonEmptyString),
		createdAt: optional(string()),
		...SERVICE_CONFIRM,
	},
	{ unknownKeys: 'error' },
).transform((raw): FollowInput => ({
	actor: firstNonEmpty(raw.actor, raw.handle, raw.did, raw.user, raw.username),
	createdAt: raw.createdAt,
	service: raw.service,
	dryRun: raw.dryRun,
	confirm: raw.confirm,
}))

const UnfollowSchema = object(
	{
		uri: optional(nonEmptyString),
		followUri: optional(nonEmptyString),
		actor: optional(nonEmptyString),
		handle: optional(nonEmptyString),
		did: optional(nonEmptyString),
		user: optional(nonEmptyString),
		username: optional(nonEmptyString),
		...SERVICE_CONFIRM,
	},
	{ unknownKeys: 'error' },
).transform((raw): UnfollowInput => ({
	uri: firstNonEmpty(raw.uri, raw.followUri),
	actor: firstNonEmpty(raw.actor, raw.handle, raw.did, raw.user, raw.username),
	service: raw.service,
	dryRun: raw.dryRun,
	confirm: raw.confirm,
}))

function wrapParse<T>(
	surface: string,
	knownKeys: readonly string[],
	hint: string,
	schema: { /* opaque */ },
	input: unknown,
): T {
	try {
		return parse(schema as never, input ?? {}) as T
	} catch (error) {
		if (error instanceof ValidationError) {
			throw formatSchemaValidationError(error, { surface, knownKeys, hint, input })
		}
		throw error
	}
}

export function parseDeletePostInput(input: unknown): DeletePostInput {
	const normalized = wrapParse<DeletePostInput>(
		'delete-post',
		DELETE_KNOWN_KEYS,
		`Pass uri (aliases: postUri, atUri, post) or repo+rkey (+ optional collection). Unknown keys are rejected.`,
		DeleteSchema,
		input,
	)
	if (!normalized.uri && !(normalized.repo && normalized.rkey)) {
		throw new Error(
			'delete-post requires uri (aliases: postUri, atUri, post) or repo + rkey (optional collection; defaults to app.bsky.feed.post).',
		)
	}
	return normalized
}

export function parseLikeRepostInput(input: unknown, surface: string): LikeRepostInput {
	const normalized = wrapParse<LikeRepostInput>(
		surface,
		LIKE_REPOST_KNOWN_KEYS,
		`Pass uri + cid (aliases: postUri/atUri/post, postCid; or subject: { uri, cid }). Unknown keys are rejected.`,
		LikeRepostSchema,
		input,
	)
	if (!normalized.uri || !normalized.cid) {
		throw new Error(
			`${surface} requires uri and cid (aliases: postUri/atUri/post + postCid, or subject: { uri, cid }).`,
		)
	}
	return normalized
}

export function parseFollowInput(input: unknown): FollowInput {
	const normalized = wrapParse<FollowInput>(
		'follow',
		FOLLOW_KNOWN_KEYS,
		`Pass actor (aliases: handle, did, user, username). Unknown keys are rejected.`,
		FollowSchema,
		input,
	)
	if (!normalized.actor) {
		throw new Error('follow requires actor (aliases: handle, did, user, username).')
	}
	return normalized
}

export function parseUnfollowInput(input: unknown): UnfollowInput {
	const normalized = wrapParse<UnfollowInput>(
		'unfollow',
		UNFOLLOW_KNOWN_KEYS,
		`Pass uri/followUri of the follow record, or actor (aliases: handle, did, user, username) already followed. Unknown keys are rejected.`,
		UnfollowSchema,
		input,
	)
	if (!normalized.uri && !normalized.actor) {
		throw new Error(
			'unfollow requires uri (alias: followUri) or actor (aliases: handle, did, user, username).',
		)
	}
	return normalized
}