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/upload-media.ts

436 lines · 13.3 KB · TypeScript
import { createAuthenticatedFetch } from 'kody:runtime'
import {
	ValidationError,
	boolean,
	object,
	optional,
	parse,
	string,
} from 'remix/data-schema'
import { resolveOAuthAccount } from './accounts.ts'
import { extractRateLimit, oauthTokenStorageNotes } from './domain.ts'
import type { JsonRecord, XAccountParams, XDryRunResult } from './types.ts'

const API_BASE = 'https://api.x.com/2'
/** ~4 MiB chunks; X server max is 8 MiB per append segment. */
const CHUNK_BYTES = 4 * 1024 * 1024
const MAX_STATUS_POLLS = 60

const nonEmptyString = string().refine((value) => value.trim().length > 0, 'Expected non-empty string')

export type XUploadMediaParams = XAccountParams & {
	/** Public HTTPS URL of the media bytes. Mutually exclusive with bytesBase64. */
	url?: string
	/** Base64-encoded media bytes (raw or data-URL). Mutually exclusive with url. */
	bytesBase64?: string
	/** MIME type, e.g. image/jpeg or video/mp4. */
	mediaType: string
	/** X media_category; defaults from mediaType (tweet_image / tweet_gif / tweet_video). */
	mediaCategory?: string
	dryRun?: boolean
	/** Required true to actually upload. */
	confirm?: boolean
}

export type XUploadMediaResult = {
	mediaId: string
	mediaKey?: string
	state: string
	mediaCategory: string
	mediaType: string
	totalBytes: number
	account: string | null
	integration: string
}

const UploadMediaInputSchema = object(
	{
		url: optional(nonEmptyString),
		bytesBase64: optional(nonEmptyString),
		mediaType: nonEmptyString,
		mediaCategory: optional(nonEmptyString),
		account: optional(string()),
		integration: optional(string()),
		dryRun: optional(boolean()),
		confirm: optional(boolean()),
	},
	{ unknownKeys: 'error' },
).refine((raw) => {
	const hasUrl = typeof raw.url === 'string' && raw.url.trim().length > 0
	const hasB64 = typeof raw.bytesBase64 === 'string' && raw.bytesBase64.trim().length > 0
	return hasUrl !== hasB64
}, 'Provide exactly one of url or bytesBase64')

function formatValidationError(error: ValidationError): Error {
	const details = (error.issues ?? [])
		.map((issue) => {
			const path = Array.isArray(issue.path) && issue.path.length ? issue.path.join('.') : '(root)'
			return `${path}: ${issue.message}`
		})
		.join('; ')
	return new Error(
		`upload-media input invalid: ${details || error.message}. ` +
			`Params: { account?, url | bytesBase64, mediaType, mediaCategory?, dryRun?, confirm? }. Unknown keys are rejected.`,
	)
}

function parseUploadMediaInput(input: unknown): XUploadMediaParams {
	try {
		return parse(UploadMediaInputSchema, input ?? {}) as XUploadMediaParams
	} catch (error) {
		if (error instanceof ValidationError) throw formatValidationError(error)
		throw error
	}
}

export function defaultMediaCategory(mediaType: string): string {
	const t = mediaType.trim().toLowerCase()
	if (t === 'image/gif') return 'tweet_gif'
	if (t.startsWith('video/')) return 'tweet_video'
	return 'tweet_image'
}

function sleep(ms: number): Promise<void> {
	return new Promise((resolve) => setTimeout(resolve, ms))
}

function decodeBase64Media(bytesBase64: string): Uint8Array {
	const raw = bytesBase64.trim()
	const comma = raw.indexOf(',')
	const b64 = raw.startsWith('data:') && comma >= 0 ? raw.slice(comma + 1) : raw
	const binary = atob(b64)
	const out = new Uint8Array(binary.length)
	for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
	return out
}

async function resolveMediaBytes(params: {
	url?: string
	bytesBase64?: string
}): Promise<{ bytes: Uint8Array; totalBytes: number; source: 'url' | 'bytesBase64' }> {
	if (params.bytesBase64) {
		const bytes = decodeBase64Media(params.bytesBase64)
		return { bytes, totalBytes: bytes.byteLength, source: 'bytesBase64' }
	}
	if (!params.url) throw new Error('url or bytesBase64 is required')
	const response = await fetch(params.url)
	if (!response.ok) {
		throw new Error(`Failed to fetch media url: ${response.status} ${response.statusText}`)
	}
	const buffer = new Uint8Array(await response.arrayBuffer())
	return { bytes: buffer, totalBytes: buffer.byteLength, source: 'url' }
}

/** Size only — prefer HEAD for url dry-runs so large bodies stay out of memory. */
async function resolveMediaSize(params: {
	url?: string
	bytesBase64?: string
}): Promise<{ totalBytes: number; source: 'url' | 'bytesBase64'; sizeVia: 'head' | 'base64' | 'get' }> {
	if (params.bytesBase64) {
		return {
			totalBytes: decodeBase64Media(params.bytesBase64).byteLength,
			source: 'bytesBase64',
			sizeVia: 'base64',
		}
	}
	if (!params.url) throw new Error('url or bytesBase64 is required')
	try {
		const head = await fetch(params.url, { method: 'HEAD' })
		const len = head.headers.get('content-length')
		if (head.ok && len && /^\d+$/.test(len)) {
			return { totalBytes: Number(len), source: 'url', sizeVia: 'head' }
		}
	} catch {
		// fall through
	}
	const response = await fetch(params.url)
	if (!response.ok) {
		throw new Error(`Failed to probe media url: ${response.status} ${response.statusText}`)
	}
	const len = response.headers.get('content-length')
	if (len && /^\d+$/.test(len)) {
		try {
			await response.body?.cancel()
		} catch {
			/* ignore */
		}
		return { totalBytes: Number(len), source: 'url', sizeVia: 'head' }
	}
	const buffer = new Uint8Array(await response.arrayBuffer())
	return { totalBytes: buffer.byteLength, source: 'url', sizeVia: 'get' }
}

class MediaUploadError extends Error {
	readonly status: number
	readonly details: unknown
	constructor(message: string, status: number, details: unknown) {
		super(message)
		this.name = 'MediaUploadError'
		this.status = status
		this.details = details
	}
}

async function parseJson(response: Response): Promise<unknown> {
	const text = await response.text()
	if (!text) return null
	try {
		return JSON.parse(text)
	} catch {
		return text
	}
}

async function oauthFetch(
	integrationName: string,
	url: string,
	init: RequestInit,
): Promise<Response> {
	const authenticatedFetch = await Promise.resolve(createAuthenticatedFetch(integrationName))
	return authenticatedFetch(url, init)
}

async function oauthJson(
	integrationName: string,
	url: string,
	init: RequestInit,
): Promise<{ body: unknown; response: Response }> {
	const response = await oauthFetch(integrationName, url, init)
	const body = await parseJson(response)
	if (!response.ok) {
		const record = body && typeof body === 'object' ? (body as JsonRecord) : null
		const message =
			(typeof record?.detail === 'string' && record.detail) ||
			(typeof record?.title === 'string' && record.title) ||
			`X media upload failed: ${response.status} ${response.statusText}`
		throw new MediaUploadError(message, response.status, {
			body,
			rateLimit: extractRateLimit(response),
			url,
		})
	}
	return { body, response }
}

function asRecord(value: unknown): JsonRecord | null {
	return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : null
}

function mediaData(body: unknown): JsonRecord {
	const root = asRecord(body) ?? {}
	const data = asRecord(root.data)
	return data ?? root
}

function readMediaKey(data: JsonRecord): string | undefined {
	if (typeof data.media_key === 'string') return data.media_key
	if (typeof data.mediaKey === 'string') return data.mediaKey
	return undefined
}

/**
 * Upload media via X API v2 chunked upload on api.x.com (not upload.x.com).
 *
 * Flow: initialize → append (~4 MiB segments) → finalize → poll STATUS until succeeded.
 * Dry-run by default; pass `confirm: true` to upload. Returns `{ mediaId, mediaKey, state }`
 * for `create-post` `media.media_ids`.
 *
 * Requires OAuth scope `media.write` on the account integration.
 *
 * @example
 * import uploadMedia from 'kody:@kentcdodds/x/upload-media'
 * const preview = await uploadMedia({
 *   account: 'kodykoala',
 *   url: 'https://example.com/tiny.jpg',
 *   mediaType: 'image/jpeg',
 *   dryRun: true,
 * })
 * // => { dryRun: true, action: 'upload-media', requiresConfirm: true, ... }
 */
export default async function uploadMediaEntrypoint(
	params: XUploadMediaParams | Record<string, unknown> = {},
): Promise<XUploadMediaResult | (XDryRunResult & JsonRecord)> {
	const input = parseUploadMediaInput(params)
	const mediaType = input.mediaType.trim()
	const mediaCategory = (input.mediaCategory?.trim() || defaultMediaCategory(mediaType)).trim()
	const oauth = await resolveOAuthAccount({
		account: input.account,
		integration: input.integration,
	})
	const size = await resolveMediaSize({
		url: input.url,
		bytesBase64: input.bytesBase64,
	})

	if (input.dryRun || !input.confirm) {
		const segmentCount = Math.max(1, Math.ceil(size.totalBytes / CHUNK_BYTES) || 1)
		return {
			dryRun: true,
			action: 'upload-media',
			requiresConfirm: true,
			host: 'api.x.com',
			steps: [
				{
					method: 'POST',
					url: `${API_BASE}/media/upload/initialize`,
					body: {
						media_type: mediaType,
						total_bytes: size.totalBytes,
						media_category: mediaCategory,
					},
				},
				{
					method: 'POST',
					url: `${API_BASE}/media/upload/{id}/append`,
					note: `multipart segment_index + media; ~${CHUNK_BYTES} byte chunks; ${segmentCount} segment(s)`,
				},
				{
					method: 'POST',
					url: `${API_BASE}/media/upload/{id}/finalize`,
				},
				{
					method: 'GET',
					url: `${API_BASE}/media/upload?command=STATUS&media_id={id}`,
					note: 'Poll until processing_info.state is succeeded (pending/in_progress + check_after_secs)',
				},
			],
			payload: {
				mediaType,
				mediaCategory,
				totalBytes: size.totalBytes,
				sizeVia: size.sizeVia,
				source: size.source,
				url: input.url,
				bytesBase64Provided: Boolean(input.bytesBase64),
				chunkBytes: CHUNK_BYTES,
				segmentCount,
				account: oauth.account,
				integration: oauth.integrationName,
				createPostShape: {
					text: 'Post text',
					media: { media_ids: ['<mediaId from confirm:true>'] },
					account: oauth.account ?? undefined,
					dryRun: true,
				},
			},
			oauthNotes: oauthTokenStorageNotes(oauth),
		}
	}

	if (size.totalBytes <= 0) {
		throw new Error('upload-media: media is empty (0 bytes)')
	}

	const { bytes } = await resolveMediaBytes({
		url: input.url,
		bytesBase64: input.bytesBase64,
	})
	const totalBytes = bytes.byteLength
	if (totalBytes <= 0) {
		throw new Error('upload-media: media is empty (0 bytes)')
	}

	const init = await oauthJson(oauth.integrationName, `${API_BASE}/media/upload/initialize`, {
		method: 'POST',
		headers: { accept: 'application/json', 'content-type': 'application/json' },
		body: JSON.stringify({
			media_type: mediaType,
			total_bytes: totalBytes,
			media_category: mediaCategory,
		}),
	})
	const initData = mediaData(init.body)
	const mediaId = String(initData.id ?? initData.media_id ?? '')
	if (!mediaId) {
		throw new Error('upload-media: initialize did not return media id')
	}
	let mediaKey = readMediaKey(initData)

	let segmentIndex = 0
	for (let offset = 0; offset < totalBytes; offset += CHUNK_BYTES) {
		const chunk = bytes.subarray(offset, Math.min(offset + CHUNK_BYTES, totalBytes))
		const form = new FormData()
		form.append('segment_index', String(segmentIndex))
		form.append('media', new Blob([chunk], { type: mediaType }), `chunk-${segmentIndex}`)
		const appendResponse = await oauthFetch(
			oauth.integrationName,
			`${API_BASE}/media/upload/${encodeURIComponent(mediaId)}/append`,
			{ method: 'POST', headers: { accept: 'application/json' }, body: form },
		)
		if (!appendResponse.ok) {
			const body = await parseJson(appendResponse)
			throw new MediaUploadError(
				`upload-media append segment ${segmentIndex} failed: ${appendResponse.status}`,
				appendResponse.status,
				{ body, rateLimit: extractRateLimit(appendResponse) },
			)
		}
		segmentIndex += 1
	}

	const finalized = await oauthJson(
		oauth.integrationName,
		`${API_BASE}/media/upload/${encodeURIComponent(mediaId)}/finalize`,
		{
			method: 'POST',
			headers: { accept: 'application/json' },
		},
	)
	const finalizedData = mediaData(finalized.body)
	mediaKey = readMediaKey(finalizedData) ?? mediaKey

	let state = 'succeeded'
	let checkAfterSecs = 1
	const processing = asRecord(finalizedData.processing_info)
	if (processing && typeof processing.state === 'string') {
		state = processing.state
		if (typeof processing.check_after_secs === 'number' && processing.check_after_secs > 0) {
			checkAfterSecs = processing.check_after_secs
		}
	}

	let polls = 0
	while ((state === 'pending' || state === 'in_progress') && polls < MAX_STATUS_POLLS) {
		await sleep(Math.min(Math.max(checkAfterSecs, 1), 30) * 1000)
		const status = await oauthJson(
			oauth.integrationName,
			`${API_BASE}/media/upload?command=STATUS&media_id=${encodeURIComponent(mediaId)}`,
			{ method: 'GET', headers: { accept: 'application/json' } },
		)
		const statusData = mediaData(status.body)
		mediaKey = readMediaKey(statusData) ?? mediaKey
		const info = asRecord(statusData.processing_info)
		if (!info || typeof info.state !== 'string') {
			state = 'succeeded'
			break
		}
		state = info.state
		if (typeof info.check_after_secs === 'number' && info.check_after_secs > 0) {
			checkAfterSecs = info.check_after_secs
		}
		if (state === 'failed') {
			throw new MediaUploadError('upload-media: processing failed', 422, {
				processing_info: info,
				mediaId,
			})
		}
		polls += 1
	}

	if (state === 'pending' || state === 'in_progress') {
		throw new Error(
			`upload-media: processing still ${state} after ${MAX_STATUS_POLLS} polls (mediaId=${mediaId})`,
		)
	}

	return {
		mediaId,
		mediaKey,
		state,
		mediaCategory,
		mediaType,
		totalBytes,
		account: oauth.account,
		integration: oauth.integrationName,
	}
}