Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/x

src/client.ts

629 lines · 17.9 KB · TypeScript
import { createAuthenticatedFetch, refreshAccessToken } from 'kody:runtime'
import { listXAccounts, resolveIntegrationName, resolveOAuthAccount } from './accounts.ts'
import {
	API_BASE_URL,
	BEARER_SECRET_NAME,
	cleanObject,
	connectUrls,
	defaultPublicUserFields,
	defaultSearchExpansions,
	defaultSearchUserFields,
	defaultTweetFields,
	defaultUserFields,
	extractRateLimit,
	oauthTokenStorageNotes,
	shapeTweetsResponse,
	summarizeUser,
} from './domain.ts'
import type {
	JsonRecord,
	QueryInput,
	XAccountParams,
	XApiResponse,
	XAuthMode,
	XCreateTweetParams,
	XDeleteTweetParams,
	XDryRunResult,
	XGetMeParams,
	XGetTweetParams,
	XGetUserByUsernameParams,
	XGetUserTweetsParams,
	XRefreshTokenParams,
	XRequestParams,
	XSearchRecentParams,
	XSmokeTestResult,
	XTweet,
	XTweetActionParams,
	XUser,
	XUserActionParams,
} from './types.ts'

export type {
	JsonRecord,
	QueryInput,
	ResolvedXOAuth,
	XAccountParams,
	XAccountSummary,
	XApiResponse,
	XAuthMode,
	XConnectUrls,
	XCreateTweetParams,
	XDeleteTweetParams,
	XDryRunResult,
	XGetMeParams,
	XGetTweetParams,
	XGetUserByUsernameParams,
	XGetUserTweetsParams,
	XRateLimitInfo,
	XRefreshTokenParams,
	XRequestParams,
	XSearchRecentParams,
	XSmokeTestResult,
	XTweet,
	XTweetActionParams,
	XUser,
	XUserActionParams,
	XUserSummary,
} from './types.ts'

export { listXAccounts, resolveIntegrationName, resolveOAuthAccount } from './accounts.ts'
export {
	connectUrls,
	oauthTokenStorageNotes,
	shapeTweet,
	shapeTweetsResponse,
	summarizeUser,
} from './domain.ts'

export class XApiError extends Error {
	readonly status: number
	readonly statusText: string
	readonly details: unknown
	readonly rateLimit: ReturnType<typeof extractRateLimit>

	constructor(
		message: string,
		options: {
			status: number
			statusText: string
			details: unknown
			rateLimit: ReturnType<typeof extractRateLimit>
		},
	) {
		super(message)
		this.name = 'XApiError'
		this.status = options.status
		this.statusText = options.statusText
		this.details = options.details
		this.rateLimit = options.rateLimit
	}
}

function appendQuery(url: string, query: QueryInput = {}): string {
	const search = new URLSearchParams()
	for (const [key, value] of Object.entries(query)) {
		if (value === undefined || value === null || value === '') continue
		if (Array.isArray(value)) {
			for (const item of value) {
				if (item === undefined || item === null || item === '') continue
				search.append(key, String(item))
			}
			continue
		}
		search.append(key, String(value))
	}
	const qs = search.toString()
	return qs ? `${url}?${qs}` : url
}

function hasHeader(headers: Record<string, string>, name: string): boolean {
	const lower = name.toLowerCase()
	return Object.keys(headers).some((key) => key.toLowerCase() === lower)
}

function normalizePath(path: string): string {
	return path.replace(/^\/+/, '').replace(/^2\//, '')
}

function previewUrl(path: string): string {
	return `${API_BASE_URL}/2/${normalizePath(path)}`
}

function accountSelection(params: XAccountParams): XAccountParams {
	return { account: params.account, integration: params.integration }
}

function requireConfirmed(
	params: { dryRun?: boolean; confirm?: boolean },
	action: string,
	payload: JsonRecord,
): XDryRunResult | null {
	if (params.dryRun || !params.confirm) {
		return { dryRun: true, action, requiresConfirm: true, payload }
	}
	return null
}

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

async function parseOkJson<T>(response: Response): Promise<T> {
	const body = await parseResponseBody(response)
	const rateLimit = extractRateLimit(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 API request failed: ${response.status} ${response.statusText}`
		throw new XApiError(message, {
			status: response.status,
			statusText: response.statusText,
			details: body,
			rateLimit,
		})
	}
	if (body && typeof body === 'object' && !Array.isArray(body)) {
		return { ...(body as object), rateLimit } as T
	}
	return body as T
}

function resolveAuthMode(mode: XAuthMode | undefined): XAuthMode {
	const resolved = mode ?? 'oauth'
	switch (resolved) {
		case 'oauth':
		case 'bearer':
			return resolved
		default: {
			const _exhaustive: never = resolved
			throw new Error(`params.authMode must be oauth or bearer, got ${String(_exhaustive)}`)
		}
	}
}

async function authenticatedFetch(
	authMode: XAuthMode,
	params: XAccountParams,
): Promise<typeof fetch> {
	switch (authMode) {
		case 'oauth': {
			const oauth = await resolveOAuthAccount(params)
			return createAuthenticatedFetch(oauth.integrationName)
		}
		case 'bearer':
			return fetch
		default: {
			const _exhaustive: never = authMode
			throw new Error(`Unsupported authMode: ${String(_exhaustive)}`)
		}
	}
}

function authHeaders(authMode: XAuthMode): Record<string, string> {
	switch (authMode) {
		case 'oauth':
			return {}
		case 'bearer':
			return { Authorization: `Bearer {{secret:${BEARER_SECRET_NAME}}}` }
		default: {
			const _exhaustive: never = authMode
			throw new Error(`Unsupported authMode: ${String(_exhaustive)}`)
		}
	}
}

export async function rawXRequest(
	path: string,
	options: {
		method?: string
		query?: QueryInput
		body?: unknown
		headers?: Record<string, string>
		authMode?: XAuthMode
		account?: string
		integration?: string
	} = {},
): Promise<Response> {
	const method = (options.method || (options.body !== undefined ? 'POST' : 'GET')).toUpperCase()
	const authMode = resolveAuthMode(options.authMode)
	const url = appendQuery(`${API_BASE_URL}/2/${normalizePath(path)}`, options.query)
	const headers: Record<string, string> = {
		accept: 'application/json',
		...(options.headers ?? {}),
		...authHeaders(authMode),
	}
	let body: string | undefined
	if (options.body !== undefined && options.body !== null) {
		body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body)
		if (!hasHeader(headers, 'content-type')) headers['content-type'] = 'application/json'
	}
	const fetchImpl = await authenticatedFetch(authMode, {
		account: options.account,
		integration: options.integration,
	})
	return fetchImpl(url, { method, headers, body })
}

/** Generic X API v2 helper. Writes dry-run unless confirm: true. */
export async function xRequest(params: XRequestParams): Promise<unknown> {
	if (!params.path) throw new Error('params.path is required')
	const method = String(params.method || (params.body ? 'POST' : 'GET')).toUpperCase()
	const authMode = resolveAuthMode(params.authMode)
	const oauth =
		authMode === 'oauth' ? await resolveOAuthAccount(params).catch(() => null) : null

	if (method !== 'GET' && (params.dryRun || !params.confirm)) {
		return {
			dryRun: true,
			method,
			url: previewUrl(params.path),
			authMode,
			body: params.body ?? null,
			requiresConfirm: true,
			payload: oauth
				? { integration: oauth.integrationName, account: oauth.account }
				: undefined,
		} satisfies XDryRunResult
	}

	const response = await rawXRequest(params.path, {
		method,
		query: params.query,
		body: params.body,
		authMode,
		account: params.account,
		integration: params.integration,
	})
	return parseOkJson(response)
}

export async function refreshOauthToken(params: XRefreshTokenParams = {}) {
	const oauth = await resolveOAuthAccount(params)
	await refreshAccessToken(oauth.integrationName)
	return {
		ok: true,
		accessTokenUpdated: true,
		integration: oauth.integrationName,
		account: oauth.account,
		oauthNotes: oauthTokenStorageNotes(oauth),
	}
}

export async function getMe(params: XGetMeParams = {}): Promise<XApiResponse<XUser>> {
	return parseOkJson(
		await rawXRequest('/users/me', {
			authMode: 'oauth',
			query: cleanObject({ 'user.fields': defaultUserFields(params.userFields) }),
			...accountSelection(params),
		}),
	)
}

export async function getUserByUsername(
	params: XGetUserByUsernameParams,
): Promise<XApiResponse<XUser>> {
	if (!params.username) throw new Error('params.username is required')
	const username = params.username.replace(/^@/, '')
	return parseOkJson(
		await rawXRequest(`/users/by/username/${encodeURIComponent(username)}`, {
			authMode: resolveAuthMode(params.authMode),
			query: cleanObject({ 'user.fields': defaultPublicUserFields(params.userFields) }),
			...accountSelection(params),
		}),
	)
}

export async function getTweet(params: XGetTweetParams): Promise<JsonRecord> {
	if (!params.id) throw new Error('params.id is required')
	const payload = await parseOkJson<XApiResponse<XTweet>>(
		await rawXRequest(`/tweets/${encodeURIComponent(params.id)}`, {
			authMode: resolveAuthMode(params.authMode),
			query: cleanObject({
				'tweet.fields': defaultTweetFields(params.tweetFields),
				expansions: params.expansions,
			}),
			...accountSelection(params),
		}),
	)
	return shapeTweetsResponse(payload)
}

export async function searchRecent(params: XSearchRecentParams): Promise<JsonRecord> {
	if (!params.query) throw new Error('params.query is required')
	const payload = await parseOkJson<XApiResponse<XTweet[]>>(
		await rawXRequest('/tweets/search/recent', {
			authMode: resolveAuthMode(params.authMode),
			query: cleanObject({
				query: params.query,
				max_results: params.maxResults || params.max_results || 10,
				'tweet.fields': defaultTweetFields(params.tweetFields),
				expansions: defaultSearchExpansions(params.expansions),
				'user.fields': defaultSearchUserFields(params.userFields),
			}),
			...accountSelection(params),
		}),
	)
	return shapeTweetsResponse(payload)
}

export async function getUserTweets(params: XGetUserTweetsParams): Promise<JsonRecord> {
	if (!params.id) throw new Error('params.id is required')
	const payload = await parseOkJson<XApiResponse<XTweet[]>>(
		await rawXRequest(`/users/${encodeURIComponent(params.id)}/tweets`, {
			authMode: resolveAuthMode(params.authMode),
			query: cleanObject({
				max_results: params.maxResults || params.max_results || 10,
				pagination_token: params.paginationToken || params.pagination_token,
				'tweet.fields': defaultTweetFields(params.tweetFields),
				expansions: params.expansions,
			}),
			...accountSelection(params),
		}),
	)
	return shapeTweetsResponse(payload)
}

export async function createTweet(params: XCreateTweetParams): Promise<unknown> {
	if (!params.text) throw new Error('params.text is required')
	return xRequest({
		path: '/tweets',
		method: 'POST',
		body: cleanObject({
			text: params.text,
			reply: params.reply,
			quote_tweet_id: params.quote_tweet_id || params.quoteTweetId,
			media: params.media,
			poll: params.poll,
			for_super_followers_only: params.for_super_followers_only || params.forSuperFollowersOnly,
		}),
		authMode: 'oauth',
		...accountSelection(params),
		dryRun: params.dryRun,
		confirm: params.confirm,
	})
}

export async function deleteTweet(params: XDeleteTweetParams): Promise<unknown> {
	if (!params.id) throw new Error('params.id is required')
	return xRequest({
		path: `/tweets/${encodeURIComponent(params.id)}`,
		method: 'DELETE',
		authMode: 'oauth',
		...accountSelection(params),
		dryRun: params.dryRun,
		confirm: params.confirm,
	})
}

async function currentUserId(params: XAccountParams & { userId?: string } = {}): Promise<string> {
	if (params.userId) return params.userId
	const me = await getMe(accountSelection(params))
	const id = me?.data?.id
	if (!id) throw new Error('Could not determine the connected X user id')
	return id
}

function tweetIdOf(params: XTweetActionParams): string {
	const id = params.tweetId || params.id
	if (!id) throw new Error('params.tweetId or params.id is required')
	return String(id)
}

async function resolveTargetUserId(params: XUserActionParams): Promise<string> {
	const direct = params.targetUserId || params.id
	if (direct) return String(direct)
	if (!params.username) {
		throw new Error('params.targetUserId, params.id, or params.username is required')
	}
	const user = await getUserByUsername({
		username: params.username,
		...accountSelection(params),
	})
	const id = user?.data?.id
	if (!id) throw new Error(`Could not resolve X user id for username "${params.username}"`)
	return id
}

export async function likeTweet(params: XTweetActionParams): Promise<unknown> {
	const tweetId = tweetIdOf(params)
	if (params.dryRun || !params.confirm) {
		return requireConfirmed(params, 'like-tweet', { tweet_id: tweetId })
	}
	const userId = await currentUserId(params)
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/likes`,
		method: 'POST',
		body: { tweet_id: tweetId },
		authMode: 'oauth',
		...accountSelection(params),
		confirm: true,
	})
}

export async function unlikeTweet(params: XTweetActionParams): Promise<unknown> {
	const tweetId = tweetIdOf(params)
	if (params.dryRun || !params.confirm) {
		return requireConfirmed(params, 'unlike-tweet', { tweet_id: tweetId })
	}
	const userId = await currentUserId(params)
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/likes/${encodeURIComponent(tweetId)}`,
		method: 'DELETE',
		authMode: 'oauth',
		...accountSelection(params),
		confirm: true,
	})
}

export async function repostTweet(params: XTweetActionParams): Promise<unknown> {
	const tweetId = tweetIdOf(params)
	if (params.dryRun || !params.confirm) {
		return requireConfirmed(params, 'repost-tweet', { tweet_id: tweetId })
	}
	const userId = await currentUserId(params)
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/retweets`,
		method: 'POST',
		body: { tweet_id: tweetId },
		authMode: 'oauth',
		...accountSelection(params),
		confirm: true,
	})
}

export async function unrepostTweet(params: XTweetActionParams): Promise<unknown> {
	const tweetId = tweetIdOf(params)
	if (params.dryRun || !params.confirm) {
		return requireConfirmed(params, 'unrepost-tweet', { tweet_id: tweetId })
	}
	const userId = await currentUserId(params)
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/retweets/${encodeURIComponent(tweetId)}`,
		method: 'DELETE',
		authMode: 'oauth',
		...accountSelection(params),
		confirm: true,
	})
}

export async function bookmarkTweet(params: XTweetActionParams): Promise<unknown> {
	const tweetId = tweetIdOf(params)
	if (params.dryRun || !params.confirm) {
		return requireConfirmed(params, 'bookmark-tweet', { tweet_id: tweetId })
	}
	const userId = await currentUserId(params)
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/bookmarks`,
		method: 'POST',
		body: { tweet_id: tweetId },
		authMode: 'oauth',
		...accountSelection(params),
		confirm: true,
	})
}

export async function removeBookmark(params: XTweetActionParams): Promise<unknown> {
	const tweetId = tweetIdOf(params)
	if (params.dryRun || !params.confirm) {
		return requireConfirmed(params, 'remove-bookmark', { tweet_id: tweetId })
	}
	const userId = await currentUserId(params)
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/bookmarks/${encodeURIComponent(tweetId)}`,
		method: 'DELETE',
		authMode: 'oauth',
		...accountSelection(params),
		confirm: true,
	})
}

export async function followUser(params: XUserActionParams): Promise<unknown> {
	if (params.dryRun || !params.confirm) {
		return requireConfirmed(params, 'follow-user', {
			username: params.username,
			target_user_id: params.targetUserId || params.id,
		})
	}
	const [userId, targetUserId] = await Promise.all([
		currentUserId(params),
		resolveTargetUserId(params),
	])
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/following`,
		method: 'POST',
		body: { target_user_id: targetUserId },
		authMode: 'oauth',
		...accountSelection(params),
		confirm: true,
	})
}

export async function unfollowUser(params: XUserActionParams): Promise<unknown> {
	if (params.dryRun || !params.confirm) {
		return requireConfirmed(params, 'unfollow-user', {
			username: params.username,
			target_user_id: params.targetUserId || params.id,
		})
	}
	const [userId, targetUserId] = await Promise.all([
		currentUserId(params),
		resolveTargetUserId(params),
	])
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/following/${encodeURIComponent(targetUserId)}`,
		method: 'DELETE',
		authMode: 'oauth',
		...accountSelection(params),
		confirm: true,
	})
}

/**
 * Read-only OAuth smoke. Looks up the connected user and never posts.
 * HTTP 429 counts as auth-plumbing-verified.
 */
export async function smokeTest(params: XAccountParams = {}): Promise<XSmokeTestResult> {
	const listed = await listXAccounts()
	const integration = resolveIntegrationName(params)
	const selected = listed.accounts.find((item) => item.integration === integration)
	if (!selected) {
		return {
			ok: false,
			integration,
			note: `No saved "${integration}" OAuth integration. Open the first-time connect URL, then retry.`,
			setup: connectUrls(integration),
		}
	}

	try {
		const me = await getMe(params)
		return {
			ok: true,
			integration,
			account: selected.account,
			user: summarizeUser(me.data),
			note: 'OAuth GET /users/me succeeded. Writes stay dry-run until confirm: true.',
		}
	} catch (error) {
		if (error instanceof XApiError && error.status === 429) {
			return {
				ok: true,
				rateLimited: true,
				status: 429,
				integration,
				account: selected.account,
				user: null,
				note: 'HTTP 429 from X — auth plumbing reached the API. Do not retry aggressively.',
			}
		}
		throw error
	}
}

export async function mediaUploadGuide(params: XAccountParams = {}) {
	const oauth = await resolveOAuthAccount(params).catch(() => null)
	return {
		summary:
			'X media upload is account-plan and host dependent. Approve upload.x.com, then attach media_ids on create-tweet.',
		steps: [
			'Initialize and upload media through the X media upload endpoints for the connected account plan.',
			'Pass the returned media_id as create-tweet params.media.media_ids.',
			'Keep create-tweet in dryRun until the user confirms the exact post.',
		],
		createTweetShape: {
			text: 'Post text',
			media: { media_ids: ['1234567890'] },
			account: 'work',
			dryRun: true,
		},
		oauthNotes: oauthTokenStorageNotes(oauth ?? undefined),
	}
}