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