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

444 lines · 12.3 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'

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 }
}