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/post-action-input.ts

232 lines · 5.6 KB · TypeScript
import {
	ValidationError,
	boolean,
	object,
	optional,
	parse,
	string,
} from 'remix/data-schema'
import { parsePostId } from './domain.ts'
import { formatSchemaValidationError } from './schema-input-errors.ts'
import type {
	XDeletePostParams,
	XPostActionParams,
	XUserActionParams,
} 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 ACCOUNT = {
	account: optional(string()),
	integration: optional(string()),
	dryRun: optional(boolean()),
	confirm: optional(boolean()),
} as const

const POST_ID_KNOWN_KEYS = [
	'postId',
	'tweetId',
	'statusId',
	'id',
	'url',
	'postUrl',
	'tweetUrl',
	'statusUrl',
	'userId',
	'account',
	'integration',
	'dryRun',
	'confirm',
] as const

const DELETE_KNOWN_KEYS = [
	'id',
	'postId',
	'tweetId',
	'statusId',
	'url',
	'postUrl',
	'tweetUrl',
	'statusUrl',
	'account',
	'integration',
	'dryRun',
	'confirm',
] as const

const FOLLOW_KNOWN_KEYS = [
	'targetUserId',
	'target_user_id',
	'userId',
	'user_id',
	'id',
	'username',
	'handle',
	'screenName',
	'screen_name',
	'account',
	'integration',
	'dryRun',
	'confirm',
] as const

function resolvePostId(raw: Record<string, unknown>, label: string): string {
	const candidate = firstNonEmpty(
		raw.postId,
		raw.tweetId,
		raw.statusId,
		raw.id,
		raw.url,
		raw.postUrl,
		raw.tweetUrl,
		raw.statusUrl,
	)
	if (!candidate) {
		throw new Error(
			`${label} requires postId (aliases: tweetId, statusId, id, url, postUrl, tweetUrl, statusUrl).`,
		)
	}
	return parsePostId(candidate)
}

const PostActionSchema = object(
	{
		postId: optional(nonEmptyString),
		tweetId: optional(nonEmptyString),
		statusId: optional(nonEmptyString),
		id: optional(nonEmptyString),
		url: optional(nonEmptyString),
		postUrl: optional(nonEmptyString),
		tweetUrl: optional(nonEmptyString),
		statusUrl: optional(nonEmptyString),
		userId: optional(nonEmptyString),
		...ACCOUNT,
	},
	{ unknownKeys: 'error' },
)

const DeleteSchema = object(
	{
		id: optional(nonEmptyString),
		postId: optional(nonEmptyString),
		tweetId: optional(nonEmptyString),
		statusId: optional(nonEmptyString),
		url: optional(nonEmptyString),
		postUrl: optional(nonEmptyString),
		tweetUrl: optional(nonEmptyString),
		statusUrl: optional(nonEmptyString),
		...ACCOUNT,
	},
	{ unknownKeys: 'error' },
)

const FollowSchema = object(
	{
		targetUserId: optional(nonEmptyString),
		target_user_id: optional(nonEmptyString),
		userId: optional(nonEmptyString),
		user_id: optional(nonEmptyString),
		id: optional(nonEmptyString),
		username: optional(nonEmptyString),
		handle: optional(nonEmptyString),
		screenName: optional(nonEmptyString),
		screen_name: optional(nonEmptyString),
		...ACCOUNT,
	},
	{ unknownKeys: 'error' },
)

function wrapParse<T>(
	surface: string,
	knownKeys: readonly string[],
	hint: string,
	schema: unknown,
	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 parsePostActionInput(input: unknown, surface: string): XPostActionParams {
	const raw = wrapParse<Record<string, unknown>>(
		surface,
		POST_ID_KNOWN_KEYS,
		`Pass postId (aliases: tweetId, statusId, id, url/postUrl/tweetUrl/statusUrl). Unknown keys are rejected.`,
		PostActionSchema,
		input,
	)
	const postId = resolvePostId(raw, surface)
	return {
		postId,
		id: postId,
		userId: typeof raw.userId === 'string' ? raw.userId : undefined,
		account: typeof raw.account === 'string' ? raw.account : undefined,
		integration: typeof raw.integration === 'string' ? raw.integration : undefined,
		dryRun: raw.dryRun as boolean | undefined,
		confirm: raw.confirm as boolean | undefined,
	}
}

export function parseDeletePostInput(input: unknown): XDeletePostParams {
	const raw = wrapParse<Record<string, unknown>>(
		'delete-post',
		DELETE_KNOWN_KEYS,
		`Pass id (aliases: postId, tweetId, statusId, url/postUrl/tweetUrl/statusUrl). Unknown keys are rejected.`,
		DeleteSchema,
		input,
	)
	const id = resolvePostId(raw, 'delete-post')
	return {
		id,
		account: typeof raw.account === 'string' ? raw.account : undefined,
		integration: typeof raw.integration === 'string' ? raw.integration : undefined,
		dryRun: raw.dryRun as boolean | undefined,
		confirm: raw.confirm as boolean | undefined,
	}
}

export function parseFollowUserInput(input: unknown, surface: string): XUserActionParams {
	const raw = wrapParse<Record<string, unknown>>(
		surface,
		FOLLOW_KNOWN_KEYS,
		`Pass targetUserId (aliases: target_user_id, userId, id) and/or username (aliases: handle, screenName). Unknown keys are rejected.`,
		FollowSchema,
		input,
	)
	// userId / id aliases mean the *target* here (not the acting account).
	const targetUserId = firstNonEmpty(
		raw.targetUserId,
		raw.target_user_id,
		raw.userId,
		raw.user_id,
		raw.id,
	)
	const username = firstNonEmpty(raw.username, raw.handle, raw.screenName, raw.screen_name)
	if (!targetUserId && !username) {
		throw new Error(
			`${surface} requires targetUserId (aliases: target_user_id, userId, id) or username (aliases: handle, screenName).`,
		)
	}
	return {
		targetUserId,
		id: targetUserId,
		username,
		account: typeof raw.account === 'string' ? raw.account : undefined,
		integration: typeof raw.integration === 'string' ? raw.integration : undefined,
		dryRun: raw.dryRun as boolean | undefined,
		confirm: raw.confirm as boolean | undefined,
	}
}