Skip to content
← Public packages

@kentcdodds/x

X API v2 helpers for tweets, search, legacy DMs, and encrypted X Chat via a Fly XDK sidecar.

src/create-post-input.ts

320 lines · 10.1 KB · TypeScript
import {
	ValidationError,
	any,
	array,
	boolean,
	object,
	optional,
	parse,
	string,
} from 'remix/data-schema'
import { formatSchemaValidationError } from './schema-input-errors.ts'
import type { JsonRecord, XAccountParams } from './types.ts'

/** Canonical reply body for X API POST /2/tweets. */
export type XReplyBody = {
	in_reply_to_tweet_id: string
	/** X API: omit these user ids from auto reply-chain mentions. */
	exclude_reply_user_ids?: string[]
}

/** Normalized create-post input after alias mapping + unknown-key rejection. */
export type NormalizedCreatePostInput = XAccountParams & {
	text: string
	reply?: XReplyBody
	quote_tweet_id?: string
	media?: JsonRecord
	poll?: JsonRecord
	direct_message_deep_link?: string
	for_super_followers_only?: boolean
	dryRun?: boolean
	confirm?: boolean
}

export type PostPublishMode = 'reply' | 'standalone'

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

const ReplyBodySchema = object(
	{
		in_reply_to_tweet_id: nonEmptyString,
		exclude_reply_user_ids: optional(array(nonEmptyString)),
		excludeReplyUserIds: optional(array(nonEmptyString)),
	},
	{ unknownKeys: 'error' },
)

const CREATE_POST_KNOWN_KEYS = [
	'text',
	'reply',
	'replyTo',
	'inReplyTo',
	'inReplyToTweetId',
	'parentId',
	'parentTweetId',
	'exclude_reply_user_ids',
	'excludeReplyUserIds',
	'quote_tweet_id',
	'quotePostId',
	'media',
	'poll',
	'direct_message_deep_link',
	'directMessageDeepLink',
	'for_super_followers_only',
	'forSuperFollowersOnly',
	'account',
	'integration',
	'dryRun',
	'confirm',
] as const

const CREATE_POST_HINT =
	`Reply shape: reply: { in_reply_to_tweet_id: '<id>', exclude_reply_user_ids?: string[] } ` +
	`(parent aliases: replyTo, inReplyTo, inReplyToTweetId, parentId, parentTweetId; ` +
	`exclude aliases: excludeReplyUserIds, exclude_reply_user_ids). ` +
	`Unknown keys are rejected.`

/**
 * Accepts the X API reply shape plus common agent aliases, then maps to
 * `reply: { in_reply_to_tweet_id, exclude_reply_user_ids? }`. Top-level
 * `excludeReplyUserIds` / `exclude_reply_user_ids` map into that nested field.
 * Rejects any other unknown top-level keys (and unknown keys inside `reply`)
 * so reply intent cannot silently publish a standalone post.
 */
const CreatePostInputSchema = object(
	{
		text: nonEmptyString,
		reply: optional(ReplyBodySchema),
		replyTo: optional(nonEmptyString),
		inReplyTo: optional(nonEmptyString),
		inReplyToTweetId: optional(nonEmptyString),
		parentId: optional(nonEmptyString),
		parentTweetId: optional(nonEmptyString),
		exclude_reply_user_ids: optional(array(nonEmptyString)),
		excludeReplyUserIds: optional(array(nonEmptyString)),
		quote_tweet_id: optional(nonEmptyString),
		quotePostId: optional(nonEmptyString),
		media: optional(any()),
		poll: optional(any()),
		direct_message_deep_link: optional(string()),
		directMessageDeepLink: optional(string()),
		for_super_followers_only: optional(boolean()),
		forSuperFollowersOnly: optional(boolean()),
		account: optional(string()),
		integration: optional(string()),
		dryRun: optional(boolean()),
		confirm: optional(boolean()),
	},
	{ unknownKeys: 'error' },
)
	.refine((raw) => {
		const ids = collectReplyParentCandidates(raw).map((c) => c.id)
		return new Set(ids).size <= 1
	}, 'Conflicting reply parent ids — reply / replyTo / inReplyTo / inReplyToTweetId / parentId / parentTweetId must agree')
	.refine((raw) => {
		const sets = collectExcludeReplyUserIdCandidates(raw).map((c) => c.key)
		return new Set(sets).size <= 1
	}, 'Conflicting exclude_reply_user_ids — reply.exclude_reply_user_ids / excludeReplyUserIds / exclude_reply_user_ids must agree')
	.refine((raw) => {
		const parentId = collectReplyParentCandidates(raw)[0]?.id
		const excludes = collectExcludeReplyUserIdCandidates(raw)[0]?.ids
		return !excludes?.length || Boolean(parentId)
	}, 'exclude_reply_user_ids requires a reply parent id (reply.in_reply_to_tweet_id or replyTo / inReplyTo / inReplyToTweetId / parentId / parentTweetId)')
	.transform((raw): NormalizedCreatePostInput => {
		const parentId = collectReplyParentCandidates(raw)[0]?.id
		const excludeIds = collectExcludeReplyUserIdCandidates(raw)[0]?.ids
		const reply: XReplyBody | undefined = parentId
			? {
					in_reply_to_tweet_id: parentId,
					...(excludeIds?.length ? { exclude_reply_user_ids: excludeIds } : {}),
				}
			: undefined
		return {
			text: raw.text,
			reply,
			quote_tweet_id: raw.quote_tweet_id || raw.quotePostId,
			media: isPlainRecord(raw.media) ? raw.media : undefined,
			poll: isPlainRecord(raw.poll) ? raw.poll : undefined,
			direct_message_deep_link: raw.direct_message_deep_link || raw.directMessageDeepLink,
			for_super_followers_only: raw.for_super_followers_only ?? raw.forSuperFollowersOnly,
			account: raw.account,
			integration: raw.integration,
			dryRun: raw.dryRun,
			confirm: raw.confirm,
		}
	})

function isPlainRecord(value: unknown): value is JsonRecord {
	return !!value && typeof value === 'object' && !Array.isArray(value)
}

function collectReplyParentCandidates(raw: {
	reply?: { in_reply_to_tweet_id?: string }
	replyTo?: string
	inReplyTo?: string
	inReplyToTweetId?: string
	parentId?: string
	parentTweetId?: string
}): Array<{ source: string; id: string }> {
	const out: Array<{ source: string; id: string }> = []
	if (raw.reply?.in_reply_to_tweet_id) {
		out.push({ source: 'reply.in_reply_to_tweet_id', id: raw.reply.in_reply_to_tweet_id })
	}
	if (raw.replyTo) out.push({ source: 'replyTo', id: raw.replyTo })
	if (raw.inReplyTo) out.push({ source: 'inReplyTo', id: raw.inReplyTo })
	if (raw.inReplyToTweetId) out.push({ source: 'inReplyToTweetId', id: raw.inReplyToTweetId })
	if (raw.parentId) out.push({ source: 'parentId', id: raw.parentId })
	if (raw.parentTweetId) out.push({ source: 'parentTweetId', id: raw.parentTweetId })
	return out
}

function normalizeExcludeReplyUserIds(ids: string[] | undefined): string[] | undefined {
	if (!ids?.length) return undefined
	return ids.map(String)
}

function excludeReplyUserIdsKey(ids: string[]): string {
	return [...ids].sort().join('\0')
}

function collectExcludeReplyUserIdCandidates(raw: {
	reply?: {
		exclude_reply_user_ids?: string[]
		excludeReplyUserIds?: string[]
	}
	exclude_reply_user_ids?: string[]
	excludeReplyUserIds?: string[]
}): Array<{ source: string; ids: string[]; key: string }> {
	const out: Array<{ source: string; ids: string[]; key: string }> = []
	const nestedSnake = normalizeExcludeReplyUserIds(raw.reply?.exclude_reply_user_ids)
	if (nestedSnake) {
		out.push({
			source: 'reply.exclude_reply_user_ids',
			ids: nestedSnake,
			key: excludeReplyUserIdsKey(nestedSnake),
		})
	}
	const nestedCamel = normalizeExcludeReplyUserIds(raw.reply?.excludeReplyUserIds)
	if (nestedCamel) {
		out.push({
			source: 'reply.excludeReplyUserIds',
			ids: nestedCamel,
			key: excludeReplyUserIdsKey(nestedCamel),
		})
	}
	const topSnake = normalizeExcludeReplyUserIds(raw.exclude_reply_user_ids)
	if (topSnake) {
		out.push({
			source: 'exclude_reply_user_ids',
			ids: topSnake,
			key: excludeReplyUserIdsKey(topSnake),
		})
	}
	const topCamel = normalizeExcludeReplyUserIds(raw.excludeReplyUserIds)
	if (topCamel) {
		out.push({
			source: 'excludeReplyUserIds',
			ids: topCamel,
			key: excludeReplyUserIdsKey(topCamel),
		})
	}
	return out
}

function formatCreatePostValidationError(
	error: ValidationError,
	surface: string,
	input: unknown,
): Error {
	return formatSchemaValidationError(error, {
		surface,
		knownKeys: CREATE_POST_KNOWN_KEYS,
		hint: CREATE_POST_HINT,
		input,
	})
}

/** Parse create-post params: map reply aliases, then fail loud on unknown keys. */
export function parseCreatePostInput(input: unknown): NormalizedCreatePostInput {
	try {
		return parse(CreatePostInputSchema, input ?? {})
	} catch (error) {
		if (error instanceof ValidationError) {
			throw formatCreatePostValidationError(error, 'create-post', input)
		}
		throw error
	}
}

/** Like parseCreatePostInput, but requires a mapped parent id (for ./reply-post). */
export function parseReplyPostInput(input: unknown): NormalizedCreatePostInput & {
	reply: XReplyBody
} {
	let normalized: NormalizedCreatePostInput
	try {
		normalized = parse(CreatePostInputSchema, input ?? {})
	} catch (error) {
		if (error instanceof ValidationError) {
			throw formatCreatePostValidationError(error, 'reply-post', input)
		}
		throw error
	}
	if (!normalized.reply?.in_reply_to_tweet_id) {
		throw new Error(
			'reply-post requires a parent tweet id. Pass reply: { in_reply_to_tweet_id } ' +
				'or replyTo / inReplyTo / inReplyToTweetId / parentId / parentTweetId. ' +
				'Refusing to publish a standalone post.',
		)
	}
	return normalized as NormalizedCreatePostInput & { reply: XReplyBody }
}

export function postModeFields(parentId?: string): {
	mode: PostPublishMode
	inReplyToTweetId?: string
	parentId?: string
} {
	if (parentId) {
		return { mode: 'reply', inReplyToTweetId: parentId, parentId }
	}
	return { mode: 'standalone' }
}

/** Attach mode (+ parent id when reply) onto dry-run / confirm result objects. */
export function withPostModeResult(result: unknown, parentId?: string): unknown {
	const modeFields = postModeFields(parentId)
	if (result && typeof result === 'object' && !Array.isArray(result)) {
		return { ...(result as JsonRecord), ...modeFields }
	}
	return { result, ...modeFields }
}

/**
 * X auto-prefixes reply-chain @handles on replies. Strip a leading run of
 * `@name` tokens (and surrounding whitespace) so agents do not double-mention.
 * If stripping would leave empty text, keep the original (validation still applies).
 */
export function stripLeadingReplyMentions(text: string): {
	text: string
	originalText: string
	strippedHandles: string[]
} {
	const originalText = text
	const strippedHandles: string[] = []
	let rest = text
	const token = /^\s*@([A-Za-z0-9_]+)/
	while (true) {
		const match = rest.match(token)
		if (!match) break
		strippedHandles.push(match[1]!)
		rest = rest.slice(match[0].length)
	}
	const trimmed = rest.replace(/^\s+/, '')
	if (!strippedHandles.length) {
		return { text: originalText, originalText, strippedHandles: [] }
	}
	if (!trimmed) {
		return { text: originalText, originalText, strippedHandles: [] }
	}
	return { text: trimmed, originalText, strippedHandles }
}