← Public packages
@kentcdodds/x
X API v2 helpers for tweets, search, legacy DMs, and encrypted X Chat via a Fly XDK sidecar.
src/send-input.ts
444 lines · 12.3 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'
const nonEmptyString = string().refine((value) => value.trim().length > 0, 'Expected non-empty string')
export type SendTargetMode =
| 'conversation'
| 'participant'
| 'username'
| 'group'
export type NormalizedSendDmInput = XAccountParams & {
text?: string
mediaId?: string
attachments?: JsonRecord[]
conversationId?: string
participantId?: string
username?: string
participantIds?: string[]
dryRun?: boolean
confirm?: boolean
idempotencyKey?: string
targetMode: SendTargetMode
}
export type NormalizedSendChatInput = XAccountParams & {
text: string
conversationId?: string
participantId?: string
username?: string
sidecarUrl?: string
dryRun?: boolean
confirm?: boolean
idempotencyKey?: string
targetMode: Exclude<SendTargetMode, 'group'>
}
const SEND_DM_KNOWN_KEYS = [
'text',
'message',
'body',
'content',
'mediaId',
'media_id',
'attachments',
'conversationId',
'dm_conversation_id',
'conversation_id',
'participantId',
'participant_id',
'userId',
'user_id',
'to',
'recipient',
'recipientId',
'username',
'participantIds',
'participant_ids',
'account',
'integration',
'dryRun',
'confirm',
'idempotencyKey',
] as const
const SEND_CHAT_KNOWN_KEYS = [
'text',
'message',
'body',
'content',
'conversationId',
'conversation_id',
'participantId',
'participant_id',
'userId',
'user_id',
'to',
'recipient',
'recipientId',
'username',
'sidecarUrl',
'account',
'integration',
'dryRun',
'confirm',
'idempotencyKey',
] as const
const SEND_DM_HINT =
`Target exactly one of: conversationId (aliases: dm_conversation_id, conversation_id), ` +
`participantId (aliases: participant_id, userId, to, recipient, recipientId), ` +
`username, or participantIds (group, 2+). ` +
`Text aliases: message, body, content. Unknown keys are rejected.`
const SEND_CHAT_HINT =
`Target exactly one of: conversationId (alias: conversation_id), ` +
`participantId (aliases: participant_id, userId, to, recipient, recipientId), or username. ` +
`Text aliases: message, body, content. Unknown keys are rejected.`
function isPlainRecord(value: unknown): value is JsonRecord {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
for (const value of values) {
if (typeof value === 'string' && value.trim()) return value.trim()
}
return undefined
}
function collectStringCandidates(
entries: Array<{ source: string; value?: string }>,
): Array<{ source: string; id: string }> {
const out: Array<{ source: string; id: string }> = []
for (const entry of entries) {
if (typeof entry.value === 'string' && entry.value.trim()) {
out.push({ source: entry.source, id: entry.value.trim() })
}
}
return out
}
function assertAgree(candidates: Array<{ source: string; id: string }>, label: string) {
const ids = new Set(candidates.map((c) => c.id))
if (ids.size > 1) {
throw new Error(
`Conflicting ${label}: ${candidates.map((c) => `${c.source}=${c.id}`).join(', ')}`,
)
}
}
const SendDmInputSchema = object(
{
text: optional(string()),
message: optional(string()),
body: optional(string()),
content: optional(string()),
mediaId: optional(nonEmptyString),
media_id: optional(nonEmptyString),
attachments: optional(array(any())),
conversationId: optional(nonEmptyString),
dm_conversation_id: optional(nonEmptyString),
conversation_id: optional(nonEmptyString),
participantId: optional(nonEmptyString),
participant_id: optional(nonEmptyString),
userId: optional(nonEmptyString),
user_id: optional(nonEmptyString),
to: optional(nonEmptyString),
recipient: optional(nonEmptyString),
recipientId: optional(nonEmptyString),
username: optional(nonEmptyString),
participantIds: optional(array(nonEmptyString)),
participant_ids: optional(array(nonEmptyString)),
account: optional(string()),
integration: optional(string()),
dryRun: optional(boolean()),
confirm: optional(boolean()),
idempotencyKey: optional(nonEmptyString),
},
{ unknownKeys: 'error' },
).transform((raw): Omit<NormalizedSendDmInput, 'targetMode'> & { targetMode?: SendTargetMode } => {
const text = firstNonEmpty(raw.text, raw.message, raw.body, raw.content)
const conversationId = firstNonEmpty(
raw.conversationId,
raw.dm_conversation_id,
raw.conversation_id,
)
let participantId = firstNonEmpty(
raw.participantId,
raw.participant_id,
raw.userId,
raw.user_id,
raw.to,
raw.recipient,
raw.recipientId,
)
let username = raw.username?.trim() || undefined
const participantIds = (raw.participantIds || raw.participant_ids || undefined)?.map(String)
const mediaId = firstNonEmpty(raw.mediaId, raw.media_id)
const attachments = Array.isArray(raw.attachments)
? (raw.attachments.filter(isPlainRecord) as JsonRecord[])
: undefined
assertAgree(
collectStringCandidates([
{ source: 'conversationId', value: raw.conversationId },
{ source: 'dm_conversation_id', value: raw.dm_conversation_id },
{ source: 'conversation_id', value: raw.conversation_id },
]),
'conversation ids',
)
assertAgree(
collectStringCandidates([
{ source: 'participantId', value: raw.participantId },
{ source: 'participant_id', value: raw.participant_id },
{ source: 'userId', value: raw.userId },
{ source: 'user_id', value: raw.user_id },
{ source: 'to', value: raw.to },
{ source: 'recipient', value: raw.recipient },
{ source: 'recipientId', value: raw.recipientId },
]),
'participant ids',
)
if (participantId && username && participantId === username) {
participantId = undefined
} else if (participantId && !/^\d+$/.test(participantId) && !username) {
username = participantId
participantId = undefined
}
const modes: SendTargetMode[] = []
if (conversationId) modes.push('conversation')
if (participantIds && participantIds.length) modes.push('group')
if (participantId) modes.push('participant')
if (username) modes.push('username')
if (modes.length > 1) {
throw new Error(
`send-dm target ambiguous (${modes.join(', ')}). Pass exactly one of conversationId, participantId/username aliases, or participantIds.`,
)
}
return {
text,
mediaId,
attachments,
conversationId,
participantId,
username,
participantIds,
account: raw.account,
integration: raw.integration,
dryRun: raw.dryRun,
confirm: raw.confirm,
idempotencyKey: raw.idempotencyKey,
targetMode: modes[0],
}
})
const SendChatInputSchema = object(
{
text: optional(string()),
message: optional(string()),
body: optional(string()),
content: optional(string()),
conversationId: optional(nonEmptyString),
conversation_id: optional(nonEmptyString),
participantId: optional(nonEmptyString),
participant_id: optional(nonEmptyString),
userId: optional(nonEmptyString),
user_id: optional(nonEmptyString),
to: optional(nonEmptyString),
recipient: optional(nonEmptyString),
recipientId: optional(nonEmptyString),
username: optional(nonEmptyString),
sidecarUrl: optional(nonEmptyString),
account: optional(string()),
integration: optional(string()),
dryRun: optional(boolean()),
confirm: optional(boolean()),
idempotencyKey: optional(nonEmptyString),
},
{ unknownKeys: 'error' },
).transform((raw): Omit<NormalizedSendChatInput, 'text' | 'targetMode'> & {
text?: string
targetMode?: Exclude<SendTargetMode, 'group'>
} => {
const text = firstNonEmpty(raw.text, raw.message, raw.body, raw.content)
const conversationId = firstNonEmpty(raw.conversationId, raw.conversation_id)
let participantId = firstNonEmpty(
raw.participantId,
raw.participant_id,
raw.userId,
raw.user_id,
raw.to,
raw.recipient,
raw.recipientId,
)
let username = raw.username?.trim() || undefined
assertAgree(
collectStringCandidates([
{ source: 'conversationId', value: raw.conversationId },
{ source: 'conversation_id', value: raw.conversation_id },
]),
'conversation ids',
)
assertAgree(
collectStringCandidates([
{ source: 'participantId', value: raw.participantId },
{ source: 'participant_id', value: raw.participant_id },
{ source: 'userId', value: raw.userId },
{ source: 'user_id', value: raw.user_id },
{ source: 'to', value: raw.to },
{ source: 'recipient', value: raw.recipient },
{ source: 'recipientId', value: raw.recipientId },
]),
'participant ids',
)
if (participantId && username && participantId === username) {
participantId = undefined
} else if (participantId && !/^\d+$/.test(participantId) && !username) {
username = participantId
participantId = undefined
}
const modes: Array<Exclude<SendTargetMode, 'group'>> = []
if (conversationId) modes.push('conversation')
if (participantId) modes.push('participant')
if (username) modes.push('username')
if (modes.length > 1) {
throw new Error(
`send-chat target ambiguous (${modes.join(', ')}). Pass exactly one of conversationId, participantId aliases, or username.`,
)
}
return {
text,
conversationId,
participantId,
username,
sidecarUrl: raw.sidecarUrl,
account: raw.account,
integration: raw.integration,
dryRun: raw.dryRun,
confirm: raw.confirm,
idempotencyKey: raw.idempotencyKey,
targetMode: modes[0],
}
})
export function parseSendDmInput(input: unknown): NormalizedSendDmInput {
let normalized: Omit<NormalizedSendDmInput, 'targetMode'> & { targetMode?: SendTargetMode }
try {
normalized = parse(SendDmInputSchema, input ?? {})
} catch (error) {
if (error instanceof ValidationError) {
throw formatSchemaValidationError(error, {
surface: 'send-dm',
knownKeys: SEND_DM_KNOWN_KEYS,
hint: SEND_DM_HINT,
input,
})
}
throw error
}
if (!normalized.text && !(normalized.attachments?.length || normalized.mediaId)) {
throw new Error(
'send-dm requires text (aliases: message, body, content) or an attachment (mediaId / attachments).',
)
}
if (!normalized.targetMode) {
throw new Error(
'send-dm requires a target. Pass conversationId, participantId (aliases: userId, to, recipient), username, or participantIds (group).',
)
}
if (normalized.targetMode === 'group' && (normalized.participantIds?.length ?? 0) < 2) {
throw new Error(
'send-dm participantIds needs 2+ user ids for a group DM; use participantId/username for 1-1.',
)
}
return normalized as NormalizedSendDmInput
}
export function parseSendChatInput(input: unknown): NormalizedSendChatInput {
let normalized: Omit<NormalizedSendChatInput, 'text' | 'targetMode'> & {
text?: string
targetMode?: Exclude<SendTargetMode, 'group'>
}
try {
normalized = parse(SendChatInputSchema, input ?? {})
} catch (error) {
if (error instanceof ValidationError) {
throw formatSchemaValidationError(error, {
surface: 'send-chat',
knownKeys: SEND_CHAT_KNOWN_KEYS,
hint: SEND_CHAT_HINT,
input,
})
}
throw error
}
if (!normalized.text?.trim()) {
throw new Error('send-chat requires text (aliases: message, body, content).')
}
if (!normalized.targetMode) {
throw new Error(
'send-chat requires a target. Pass conversationId, participantId (aliases: userId, to, recipient), or username.',
)
}
if (
normalized.targetMode === 'participant' &&
normalized.participantId &&
!/^\d+$/.test(normalized.participantId) &&
!normalized.username
) {
return {
...normalized,
text: normalized.text.trim(),
username: normalized.participantId,
participantId: undefined,
targetMode: 'username',
}
}
return {
...normalized,
text: normalized.text.trim(),
targetMode: normalized.targetMode,
} as NormalizedSendChatInput
}
export function withSendTargetResult(
result: unknown,
fields: {
targetMode: SendTargetMode
conversationId?: string
participantId?: string
username?: string
participantIds?: string[]
},
): unknown {
const target = {
targetMode: fields.targetMode,
...(fields.conversationId ? { conversationId: fields.conversationId } : {}),
...(fields.participantId ? { participantId: fields.participantId } : {}),
...(fields.username ? { username: fields.username } : {}),
...(fields.participantIds ? { participantIds: fields.participantIds } : {}),
}
if (result && typeof result === 'object' && !Array.isArray(result)) {
return { ...(result as JsonRecord), ...target }
}
return { result, ...target }
}