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