← 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 · TypeScriptimport {
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 }
}