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/client.ts

1055 lines · 33.5 KB · TypeScript
import { kody } from 'kody:runtime'
import { resolveIntegrationName, resolveOAuthAccount } from './accounts.ts'
import { withSendIdempotency } from './idempotency.ts'
import {
	type QueryInput,
	getpostsbyid,
	getusersbyusername,
	getusersposts,
	rawXRequest,
	searchpostsrecent,
} from './openapi-client.ts'
import {
	cleanObject,
	defaultDmEventFields,
	defaultDmExpansions,
	defaultDmUserFields,
	defaultGetPostExpansions,
	defaultGetPostUserFields,
	defaultPublicUserFields,
	defaultSearchExpansions,
	defaultSearchUserFields,
	defaultPostFields,
	defaultUserFields,
	defaultUserPostsExpansions,
	extractRateLimit,
	isOriginalShapedPost,
	oauthTokenStorageNotes,
	parsePostId,
	shapeDmEventsResponse,
	shapePost,
	shapePostsResponse,
	summarizeUser,
} from './domain.ts'
import { summarizePostMetricsFromPosts } from './metrics.ts'
import {
	parseCreatePostInput,
	stripLeadingReplyMentions,
	withPostModeResult,
} from './create-post-input.ts'
import {
	parseDeletePostInput,
	parseFollowUserInput,
	parsePostActionInput,
} from './post-action-input.ts'
import { parseSendDmInput, withSendTargetResult } from './send-input.ts'
import type {
	JsonRecord,
	ResolvedXOAuth,
	XAccountParams,
	XApiResponse,
	XCreatePostParams,
	XDeletePostParams,
	XDmEvent,
	XDryRunResult,
	XGetDmConversationParams,
	XGetMeParams,
	XGetPostParams,
	XGetPostThreadParams,
	XGetUserByUsernameParams,
	XGetUserPostsParams,
	XListDmEventsParams,
	XPostMetricsInput,
	XSummarizePostMetricsParams,
	XRefreshTokenParams,
	XRequestParams,
	XSearchRecentParams,
	XSendDmParams,
	XSmokeTestResult,
	XPost,
	XPostActionParams,
	XUser,
	XUserActionParams,
} from './types.ts'

export type { QueryInput } from './openapi-client.ts'
export type {
	JsonRecord,
	ResolvedXOAuth,
	XAccountParams,
	XAccountSummary,
	XApiResponse,
	XAuthMode,
	XCreatePostParams,
	XDeletePostParams,
	XDmEvent,
	XDmReadParams,
	XDmTargetParams,
	XDryRunResult,
	XGetChatConversationParams,
	XGetDmConversationParams,
	XGetMeParams,
	XGetPostParams,
	XGetPostThreadParams,
	XGetUserByUsernameParams,
	XGetUserPostsParams,
	XListChatConversationsParams,
	XSummarizePostMetricsParams,
	XSummarizePostMetricsSort,
	XPostMetricsInput,
	XListDmEventsParams,
	XRateLimitInfo,
	XRefreshTokenParams,
	XRequestParams,
	XSearchRecentParams,
	XSendChatParams,
	XSendDmParams,
	XSmokeTestResult,
	XPost,
	XPostActionParams,
	XUser,
	XUserActionParams,
	XUserSummary,
} from './types.ts'

export { listXAccounts, resolveIntegrationName, resolveOAuthAccount } from './accounts.ts'

export {
	createposts,
	createusersbookmark,
	deleteposts,
	deleteusersbookmark,
	followuser as followuserOp,
	getpostsbyid,
	getusersbyusername,
	getusersme,
	getusersposts,
	likepost,
	rawXRequest,
	repostpost,
	searchpostsrecent,
	unfollowuser as unfollowuserOp,
	unlikepost,
	unrepostpost,
} from './openapi-client.ts'

export {
	oauthTokenStorageNotes,
	parsePostId,
	postDisplayText,
	shapeDmEvent,
	shapeDmEventsResponse,
	shapePost,
	shapePostsResponse,
	summarizeUser,
} from './domain.ts'

export { metricsFromPost, summarizePostMetricsFromPosts } from './metrics.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
	}
}

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

export function requireRecord(value: unknown, utilityName: string): JsonRecord {
	if (value && typeof value === 'object' && !Array.isArray(value)) {
		return value as JsonRecord
	}
	throw new Error(utilityName + ' requires an object params value.')
}

export function requireString(record: JsonRecord, key: string, utilityName: string): string {
	const value = record[key]
	if (typeof value !== 'string' || value.length === 0) {
		throw new Error(utilityName + ' requires params.' + key + '.')
	}
	return value
}

async function oauthContext(params: XAccountParams = {}): Promise<ResolvedXOAuth> {
	return resolveOAuthAccount(params)
}

/**
 * OAuth GET with the same refresh-on-401 behavior as xRequest. X access
 * tokens expire after ~2 hours, so any user-context read that runs from a
 * scheduled job hits a stale token; without this retry, helpers like
 * likePost/repostPost died in their currentUserId lookup with a bare
 * "Unauthorized" (the 2026-07-22/23 morning release jobs).
 */
async function oauthGetWithRefresh(input: {
	path: string
	query?: QueryInput
	params: XAccountParams
}): Promise<Response> {
	const oauth = await oauthContext(input.params)
	const response = await rawXRequest(input.path, {
		authMode: 'oauth',
		integrationName: oauth.integrationName,
		query: input.query,
	})
	if (response.status !== 401) return response
	await refreshOauthToken({
		internal: true,
		account: input.params.account,
		integration: input.params.integration,
	})
	return rawXRequest(input.path, {
		authMode: 'oauth',
		integrationName: oauth.integrationName,
		query: input.query,
	})
}

/** Generic X API v2 helper with bearer or OAuth auth and dry-run for non-GET. */
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 = params.authMode || (method === 'GET' ? 'bearer' : 'oauth')
	if (authMode !== 'bearer' && authMode !== 'oauth') {
		throw new Error('params.authMode must be bearer or oauth')
	}

	const oauth =
		authMode === 'oauth' && !params.accessToken ? await oauthContext(params) : null

	const normalized = params.path.replace(/^\/+/, '').replace(/^2\//, '')
	const previewUrl = `https://api.x.com/2/${normalized}`
	if (method !== 'GET' && (params.dryRun || !params.confirm)) {
		return {
			dryRun: true,
			method,
			url: previewUrl,
			authMode,
			body: params.body ?? null,
			requiresConfirm: true,
			payload: oauth
				? { integration: oauth.integrationName, account: oauth.account }
				: undefined,
		} satisfies XDryRunResult
	}

	let response = await rawXRequest(params.path, {
		method,
		query: params.query,
		body: params.body,
		authMode,
		accessToken: params.accessToken,
		integrationName: oauth?.integrationName,
		accessTokenSecretName: oauth?.accessTokenSecretName,
	})

	if (response.status === 401 && authMode === 'oauth' && params.refreshOnUnauthorized !== false) {
		await refreshOauthToken({
			internal: true,
			account: params.account,
			integration: params.integration,
		})
		response = await rawXRequest(params.path, {
			method,
			query: params.query,
			body: params.body,
			authMode: 'oauth',
			integrationName: oauth?.integrationName,
			accessTokenSecretName: oauth?.accessTokenSecretName,
		})
	}

	return parseOkJson(response)
}

/**
 * X rotates refresh tokens: issuing a new one invalidates the old one server
 * side, immediately. Rotation is therefore delegated to the host
 * `integrationTokenRefresh` helper rather than reimplemented here, for two
 * reasons.
 *
 * The host persists the rotated refresh token *before* the access token, so if
 * the second write fails you keep a usable refresh token instead of a fresh
 * access token guarding a dead one — the difference between self-healing on
 * the next call and needing a manual reconnect.
 *
 * It is also the same code path `createAuthenticatedFetch` uses for automatic
 * 401 refresh. Two independent implementations rotating the same secret can
 * race, and the loser stores a token X has already invalidated.
 *
 * Host refresh returns metadata only. Callers retry with the integration's
 * access-token secret placeholder so the new token never enters the sandbox.
 */
export async function refreshOauthToken(params: XRefreshTokenParams = {}) {
	const oauth = await oauthContext(params)
	const refreshed = await kody.integrationTokenRefresh({ name: oauth.integrationName })
	if (params.internal) {
		return {
			integration: oauth.integrationName,
			account: oauth.account,
			accessTokenSecretName: oauth.accessTokenSecretName,
			refreshedAt: refreshed.refreshedAt,
			refreshTokenRotated: refreshed.refreshTokenRotated,
		}
	}
	return {
		ok: true,
		accessTokenUpdated: true,
		integration: oauth.integrationName,
		account: oauth.account,
		refreshedAt: refreshed.refreshedAt,
		refreshTokenRotated: refreshed.refreshTokenRotated,
		oauthNotes: oauthTokenStorageNotes(oauth),
	}
}

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

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

export async function searchRecent(params: XSearchRecentParams): Promise<JsonRecord> {
	if (!params.query) throw new Error('params.query is required')
	const authMode = params.authMode || 'bearer'
	const query = cleanObject({
		query: params.query,
		max_results: params.maxResults || params.max_results || 10,
		'tweet.fields': defaultPostFields(params.postFields),
		expansions: defaultSearchExpansions(params.expansions),
		'user.fields': defaultSearchUserFields(params.userFields),
	})
	const response =
		authMode === 'oauth'
			? await oauthGetWithRefresh({
					path: '/tweets/search/recent',
					query,
					params: accountSelection(params),
				})
			: await searchpostsrecent({ query })
	const payload = await parseOkJson<XApiResponse<XPost[]>>(response)
	return shapePostsResponse(payload)
}

export async function getUserPosts(params: XGetUserPostsParams): Promise<JsonRecord> {
	if (!params.id) throw new Error('params.id is required')
	const authMode = params.authMode || 'bearer'
	const originalsOnly = Boolean(params.originalsOnly)
	const exclude = originalsOnly ? 'retweets,replies' : params.exclude
	const query = cleanObject({
		max_results: params.maxResults || params.max_results || 10,
		pagination_token: params.paginationToken || params.pagination_token,
		'tweet.fields': defaultPostFields(params.postFields),
		expansions: defaultUserPostsExpansions(params.expansions),
		exclude,
	})
	const response =
		authMode === 'oauth'
			? await oauthGetWithRefresh({
					path: `/users/${encodeURIComponent(params.id)}/tweets`,
					query,
					params: accountSelection(params),
				})
			: await getusersposts({ params: { id: params.id }, query })
	const payload = await parseOkJson<XApiResponse<XPost[]>>(response)
	const shaped = shapePostsResponse(payload)
	if (!originalsOnly) return shaped
	const data = Array.isArray(shaped.data)
		? (shaped.data as JsonRecord[]).filter((post) => isOriginalShapedPost(post))
		: shaped.data
	return { ...shaped, data, originalsOnly: true }
}

function resolvePostIdInput(params: { id?: string; postId?: string; url?: string }): string {
	const raw = params.id || params.postId || params.url
	if (!raw) throw new Error('params.id, params.postId, or params.url is required')
	return parsePostId(String(raw))
}

/**
 * Read one X post by status id or x.com/twitter.com URL.
 * Expands author + referenced posts; shapes `quotedPost` / `repliedToPost` /
 * `retweetedPost` when includes are present. `text` prefers note_tweet.text.
 * X API path remains GET /2/tweets/:id.
 *
 * Optional `includeQuotePosts: true` also calls GET /2/tweets/:id/quote_tweets
 * (rate-limited — keep quoteMaxResults small; default 10, hard cap 25).
 */
export async function getPost(params: XGetPostParams = {}): Promise<JsonRecord> {
	const id = resolvePostIdInput(params)
	const authMode = params.authMode || 'bearer'
	const query = cleanObject({
		'tweet.fields': defaultPostFields(params.postFields),
		expansions: defaultGetPostExpansions(params.expansions),
		'user.fields': defaultGetPostUserFields(params.userFields),
	})
	const response =
		authMode === 'oauth'
			? await oauthGetWithRefresh({
					path: `/tweets/${encodeURIComponent(id)}`,
					query,
					params: accountSelection(params),
				})
			: await getpostsbyid({ params: { id }, query })
	const payload = await parseOkJson<XApiResponse<XPost>>(response)
	const shaped = shapePostsResponse(payload)
	const result: JsonRecord = {
		data: shaped.data,
		includes: shaped.includes,
		meta: shaped.meta,
		errors: shaped.errors,
		rateLimit: shaped.rateLimit,
	}

	if (params.includeQuotePosts) {
		const quoteMax = Math.min(Math.max(Number(params.quoteMaxResults) || 10, 1), 25)
		const quoteQuery = cleanObject({
			max_results: quoteMax,
			'tweet.fields': defaultPostFields(params.postFields),
			expansions: 'author_id',
			'user.fields': defaultGetPostUserFields(params.userFields),
		})
		const quotePayload = (await xRequest({
			path: `/tweets/${encodeURIComponent(id)}/quote_tweets`,
			method: 'GET',
			query: quoteQuery,
			authMode,
			...accountSelection(params),
		})) as XApiResponse<XPost[]>
		const quoteShaped = shapePostsResponse(quotePayload)
		result.quotePosts = Array.isArray(quoteShaped.data) ? quoteShaped.data : []
		result.quoteMeta = quoteShaped.meta
		result.quoteRateLimit = quoteShaped.rateLimit
		result.quotePostsNote =
			'Fetched via GET /2/tweets/:id/quote_tweets — rate-limited; prefer includeQuotePosts only when needed.'
	}

	return result
}

/**
 * Fetch a post and recent replies in its conversation via search-recent
 * `conversation_id:<id>` (default maxResults 20, hard cap 50).
 * Rate-limit caution: two API calls minimum (get-post + search-recent).
 */
export async function getPostThread(params: XGetPostThreadParams = {}): Promise<JsonRecord> {
	const seed = await getPost({
		id: params.id,
		postId: params.postId,
		url: params.url,
		postFields: params.postFields,
		expansions: params.expansions,
		userFields: params.userFields,
		authMode: params.authMode,
		account: params.account,
		integration: params.integration,
	})
	const seedData = seed.data as JsonRecord | undefined
	if (!seedData || typeof seedData !== 'object') {
		throw new Error('get-post-thread: seed post not found')
	}

	let root = seedData
	let conversationId =
		typeof seedData.conversation_id === 'string' ? seedData.conversation_id : undefined

	// Walk replied_to chain from expansions when conversation root differs
	const replied = seedData.repliedToPost as JsonRecord | undefined
	if (replied && typeof replied.id === 'string') {
		if (conversationId && replied.id === conversationId) {
			root = replied
		} else if (!conversationId && typeof replied.conversation_id === 'string') {
			conversationId = replied.conversation_id
		}
	}
	if (!conversationId && typeof seedData.id === 'string') conversationId = seedData.id
	if (!conversationId) throw new Error('get-post-thread: could not resolve conversation_id')

	if (typeof root.id === 'string' && root.id !== conversationId) {
		try {
			const rootFetch = await getPost({
				id: conversationId,
				postFields: params.postFields,
				expansions: params.expansions,
				userFields: params.userFields,
				authMode: params.authMode,
				account: params.account,
				integration: params.integration,
			})
			if (rootFetch.data && typeof rootFetch.data === 'object') {
				root = rootFetch.data as JsonRecord
			}
		} catch {
			// keep seed/replied root if conversation root fetch fails (deleted, etc.)
		}
	}

	const maxResults = Math.min(Math.max(Number(params.maxResults || params.max_results) || 20, 10), 50)
	const search = await searchRecent({
		query: `conversation_id:${conversationId}`,
		maxResults,
		postFields: params.postFields,
		expansions: params.expansions || defaultGetPostExpansions(),
		userFields: params.userFields,
		authMode: params.authMode,
		account: params.account,
		integration: params.integration,
	})

	const replies = Array.isArray(search.data)
		? (search.data as JsonRecord[]).filter((p) => p.id && p.id !== root.id)
		: []

	const rootMetrics = root.public_metrics as JsonRecord | undefined
	return {
		root,
		replies,
		metrics: rootMetrics
			? {
					root: rootMetrics,
					replyCount: replies.length,
				}
			: { replyCount: replies.length },
		conversationId,
		rateLimit: { seed: seed.rateLimit, search: search.rateLimit },
		note: 'Uses get-post + search-recent conversation_id query. Keep maxResults bounded (hard cap 50). Do not WebFetch x.com status pages (403).',
	}
}

/**
 * Engagement table from posts with public_metrics.
 * Pass `posts` for a pure (no-network) summary, or `ids`/`id`/`url` to thin-fetch via get-post.
 * Engagement = likes+replies+retweets+bookmarks+quotes. followsPer1k is always null.
 */
export async function summarizePostMetrics(
	params: XSummarizePostMetricsParams = {},
): Promise<JsonRecord> {
	const collected: XPostMetricsInput[] = []
	if (Array.isArray(params.posts)) collected.push(...params.posts)
	if (params.post) collected.push(params.post)

	const ids: string[] = []
	if (Array.isArray(params.ids)) ids.push(...params.ids.map(String))
	if (params.id || params.url) {
		ids.push(resolvePostIdInput({ id: params.id, url: params.url }))
	}

	for (const id of ids) {
		const fetched = await getPost({
			id,
			authMode: params.authMode,
			account: params.account,
			integration: params.integration,
		})
		if (fetched.data && typeof fetched.data === 'object') {
			collected.push(fetched.data as XPostMetricsInput)
		}
	}

	if (!collected.length) {
		throw new Error('summarize-post-metrics requires posts, post, ids, id, or url')
	}

	return summarizePostMetricsFromPosts(collected, {
		sort: params.sort,
		filter: params.filter,
	})
}

export async function createPost(params: XCreatePostParams | Record<string, unknown> = {}): Promise<unknown> {
	const input = parseCreatePostInput(params)
	const parentId = input.reply?.in_reply_to_tweet_id
	const excludeReplyUserIds = input.reply?.exclude_reply_user_ids
	const mentionStrip = parentId
		? stripLeadingReplyMentions(input.text)
		: { text: input.text, originalText: input.text, strippedHandles: [] as string[] }
	const body = cleanObject({
		text: mentionStrip.text,
		reply: input.reply,
		quote_tweet_id: input.quote_tweet_id,
		media: input.media,
		poll: input.poll,
		direct_message_deep_link: input.direct_message_deep_link,
		for_super_followers_only: input.for_super_followers_only,
	})
	const result = await xRequest({
		path: '/tweets',
		method: 'POST',
		body,
		authMode: 'oauth',
		account: input.account,
		integration: input.integration,
		dryRun: input.dryRun,
		confirm: input.confirm,
	})
	const withMode = withPostModeResult(result, parentId)
	if (!parentId) return withMode
	if (withMode && typeof withMode === 'object' && !Array.isArray(withMode)) {
		return {
			...(withMode as JsonRecord),
			originalText: mentionStrip.originalText,
			text: mentionStrip.text,
			strippedHandles: mentionStrip.strippedHandles,
			...(excludeReplyUserIds?.length
				? {
						excludeReplyUserIds,
						exclude_reply_user_ids: excludeReplyUserIds,
					}
				: {}),
		}
	}
	return withMode
}

export async function deletePost(params: XDeletePostParams | Record<string, unknown> = {}): Promise<unknown> {
	const input = parseDeletePostInput(params)
	return xRequest({
		path: `/tweets/${encodeURIComponent(input.id)}`,
		method: 'DELETE',
		authMode: 'oauth',
		account: input.account,
		integration: input.integration,
		dryRun: input.dryRun,
		confirm: input.confirm,
	})
}

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

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

async function resolveFollowTargetId(params: XUserActionParams): Promise<string> {
	if (params.targetUserId || params.id) return String(params.targetUserId || params.id)
	if (params.username) {
		const user = await getUserByUsername({
			username: params.username.replace(/^@/, ''),
			account: params.account,
			integration: params.integration,
			authMode: 'oauth',
		})
		const id = user?.data?.id
		if (!id) throw new Error(`Could not resolve X user id for username ${params.username}`)
		return String(id)
	}
	throw new Error('params.targetUserId, params.id, or params.username is required')
}

export async function likePost(params: XPostActionParams | Record<string, unknown> = {}): Promise<unknown> {
	const input = parsePostActionInput(params, 'like-post')
	const userId = await currentUserId(input)
	const payload = { tweet_id: String(input.postId) }
	const dryRun = requireConfirmed(input, 'like-post', { userId, ...payload })
	if (dryRun) return dryRun
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/likes`,
		method: 'POST',
		body: payload,
		authMode: 'oauth',
		...accountSelection(input),
		confirm: true,
	})
}

export async function unlikePost(params: XPostActionParams | Record<string, unknown> = {}): Promise<unknown> {
	const input = parsePostActionInput(params, 'unlike-post')
	const userId = await currentUserId(input)
	const postId = String(input.postId)
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/likes/${encodeURIComponent(postId)}`,
		method: 'DELETE',
		authMode: 'oauth',
		...accountSelection(input),
		dryRun: input.dryRun,
		confirm: input.confirm,
	})
}

export async function repostPost(params: XPostActionParams | Record<string, unknown> = {}): Promise<unknown> {
	const input = parsePostActionInput(params, 'repost-post')
	const userId = await currentUserId(input)
	const payload = { tweet_id: String(input.postId) }
	const dryRun = requireConfirmed(input, 'repost-post', { userId, ...payload })
	if (dryRun) return dryRun
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/retweets`,
		method: 'POST',
		body: payload,
		authMode: 'oauth',
		...accountSelection(input),
		confirm: true,
	})
}

export async function unrepostPost(params: XPostActionParams | Record<string, unknown> = {}): Promise<unknown> {
	const input = parsePostActionInput(params, 'unrepost-post')
	const userId = await currentUserId(input)
	const postId = String(input.postId)
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/retweets/${encodeURIComponent(postId)}`,
		method: 'DELETE',
		authMode: 'oauth',
		...accountSelection(input),
		dryRun: input.dryRun,
		confirm: input.confirm,
	})
}

export async function bookmarkPost(params: XPostActionParams | Record<string, unknown> = {}): Promise<unknown> {
	const input = parsePostActionInput(params, 'bookmark-post')
	const userId = await currentUserId(input)
	const payload = { tweet_id: String(input.postId) }
	const dryRun = requireConfirmed(input, 'bookmark-post', { userId, ...payload })
	if (dryRun) return dryRun
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/bookmarks`,
		method: 'POST',
		body: payload,
		authMode: 'oauth',
		...accountSelection(input),
		confirm: true,
	})
}

export async function removeBookmark(params: XPostActionParams | Record<string, unknown> = {}): Promise<unknown> {
	const input = parsePostActionInput(params, 'remove-bookmark')
	const userId = await currentUserId(input)
	const postId = String(input.postId)
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/bookmarks/${encodeURIComponent(postId)}`,
		method: 'DELETE',
		authMode: 'oauth',
		...accountSelection(input),
		dryRun: input.dryRun,
		confirm: input.confirm,
	})
}

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

export async function unfollowUser(params: XUserActionParams | Record<string, unknown> = {}): Promise<unknown> {
	const input = parseFollowUserInput(params, 'unfollow-user')
	const userId = await currentUserId(input)
	const targetUserId = await resolveFollowTargetId(input)
	return xRequest({
		path: `/users/${encodeURIComponent(userId)}/following/${encodeURIComponent(targetUserId)}`,
		method: 'DELETE',
		authMode: 'oauth',
		...accountSelection(input),
		dryRun: input.dryRun,
		confirm: input.confirm,
	})
}

/**
 * All DM endpoints are OAuth user-context only (scopes dm.read / dm.write —
 * no app-only bearer support), so every helper below rides the same
 * refresh-on-401 OAuth plumbing as getMe.
 *
 * Encrypted X Chat caveat (late 2025+): end-to-end encrypted conversations are
 * not readable via these API routes. Reads may return only pre-encryption
 * history (or nothing) while the X app shows a live thread; sends can still
 * succeed. See README "Encrypted X Chat limitation".
 */

async function resolveDmParticipantId(
	params: { participantId?: string; participant_id?: string; username?: string } & XAccountParams,
): Promise<string | null> {
	const participantId = params.participantId || params.participant_id
	if (participantId) return String(participantId)
	if (!params.username) return null
	const user = await getUserByUsername({ username: params.username })
	const id = user?.data?.id
	if (!id) throw new Error(`Could not resolve X user id for username "${params.username}"`)
	return id
}

function dmReadQuery(params: XListDmEventsParams): QueryInput {
	return cleanObject({
		max_results: params.maxResults || params.max_results,
		pagination_token: params.paginationToken || params.pagination_token,
		event_types: params.eventTypes || params.event_types,
		'dm_event.fields': defaultDmEventFields(params.dmEventFields),
		expansions: defaultDmExpansions(params.expansions),
		'user.fields': defaultDmUserFields(params.userFields),
	}) as QueryInput
}

/** List recent DM events across all of the account's conversations. */
export async function listDmEvents(params: XListDmEventsParams = {}): Promise<JsonRecord> {
	const payload = await parseOkJson<XApiResponse<XDmEvent[]>>(
		await oauthGetWithRefresh({
			path: '/dm_events',
			query: dmReadQuery(params),
			params: accountSelection(params),
		}),
	)
	return shapeDmEventsResponse(payload)
}

/**
 * Read DM events for one conversation, addressed by conversation id, or by
 * the other participant's user id or username (1-1 conversations).
 */
export async function getDmConversation(params: XGetDmConversationParams): Promise<JsonRecord> {
	const conversationId = params.conversationId || params.dm_conversation_id
	const participantId = await resolveDmParticipantId(params)
	if (!conversationId && !participantId) {
		throw new Error('params.conversationId, params.participantId, or params.username is required')
	}
	const path = conversationId
		? `/dm_conversations/${encodeURIComponent(conversationId)}/dm_events`
		: `/dm_conversations/with/${encodeURIComponent(String(participantId))}/dm_events`
	const payload = await parseOkJson<XApiResponse<XDmEvent[]>>(
		await oauthGetWithRefresh({
			path,
			query: dmReadQuery(params),
			params: accountSelection(params),
		}),
	)
	return shapeDmEventsResponse(payload)
}

/**
 * Send or dry-run an X direct message; sending requires confirm: true.
 *
 * Target selection (exactly one):
 * - conversationId — message an existing conversation (1-1 or group)
 * - participantId / username — 1-1 conversation (created on first message)
 * - participantIds (2+) — create a new group conversation with this message
 */
/**
 * Send or dry-run an X direct message; sending requires confirm: true.
 *
 * Pass optional `idempotencyKey` so retries / parallel workers reuse the prior
 * successful send for the same `(account, key)` within 7 days.
 */
export async function sendDm(params: XSendDmParams | Record<string, unknown> = {}): Promise<unknown> {
	const input = parseSendDmInput(params)
	const mediaId = input.mediaId
	const attachments = input.attachments || (mediaId ? [{ media_id: String(mediaId) }] : undefined)
	const message = cleanObject({ text: input.text, attachments })

	const conversationId = input.conversationId
	const participantIds = input.participantIds
	let path: string
	let body: JsonRecord
	if (input.targetMode === 'conversation' && conversationId) {
		path = `/dm_conversations/${encodeURIComponent(conversationId)}/messages`
		body = message
	} else if (input.targetMode === 'group' && participantIds && participantIds.length > 0) {
		path = '/dm_conversations'
		body = {
			conversation_type: 'Group',
			participant_ids: participantIds.map(String),
			message,
		}
	} else {
		const participantId =
			input.participantId ||
			(await resolveDmParticipantId({
				participantId: input.participantId,
				username: input.username,
				account: input.account,
				integration: input.integration,
			}))
		if (!participantId) {
			throw new Error(
				'send-dm requires conversationId, participantId, username, or participantIds after alias mapping.',
			)
		}
		path = `/dm_conversations/with/${encodeURIComponent(participantId)}/messages`
		body = message
	}

	const request = {
		path,
		method: 'POST' as const,
		body,
		authMode: 'oauth' as const,
		...accountSelection(input),
		dryRun: input.dryRun,
		confirm: input.confirm,
	}
	const targetFields = {
		targetMode: input.targetMode,
		conversationId: input.conversationId,
		participantId: input.participantId,
		username: input.username,
		participantIds: input.participantIds,
	}
	if (input.dryRun || !input.confirm) {
		return withSendTargetResult(await xRequest(request), targetFields)
	}
	const sent = await withSendIdempotency({
		action: 'sendDm',
		account: resolveIntegrationName(input),
		idempotencyKey: input.idempotencyKey,
		run: async () => xRequest(request),
	})
	return withSendTargetResult(sent, targetFields)
}
export async function smokeTest(): Promise<XSmokeTestResult> {
	try {
		const publicUser = await getUserByUsername({ username: 'kentcdodds' })
		return {
			ok: true,
			bearer: { user: summarizeUser(publicUser.data) },
			note: 'Single bearer GET /users/by/username/kentcdodds succeeded. OAuth /users/me skipped to conserve free-tier rate limit.',
		}
	} catch (error) {
		if (error instanceof XApiError && error.status === 429) {
			return {
				ok: true,
				rateLimited: true,
				status: 429,
				note: 'HTTP 429 from X free tier — auth plumbing reached the API; treat as verified. Do not retry aggressively.',
				bearer: { user: null },
			}
		}
		throw error
	}
}

export async function mediaUploadGuide(params: XAccountParams = {}) {
	const oauth = await oauthContext(params).catch(() => null)
	return {
		summary:
			'Use ./upload-media for X API v2 chunked upload on api.x.com (not upload.x.com). Requires OAuth scope media.write. Dry-run by default; confirm: true to upload. Pass the returned mediaId to create-post as media.media_ids.',
		host: 'api.x.com',
		export: 'kody:@kentcdodds/x/upload-media',
		steps: [
			'POST /2/media/upload/initialize with JSON { media_type, total_bytes, media_category }.',
			'POST /2/media/upload/{id}/append multipart (segment_index + media; ~4 MiB chunks; server max 8 MiB).',
			'POST /2/media/upload/{id}/finalize.',
			'Poll GET /2/media/upload?command=STATUS&media_id={id} until processing_info.state is succeeded (pending/in_progress + check_after_secs).',
			'Pass mediaId to create-post: { text, media: { media_ids: [mediaId] }, dryRun: true } then confirm: true to publish.',
		],
		uploadMediaShape: {
			account: 'kodykoala',
			url: 'https://example.com/image.jpg',
			mediaType: 'image/jpeg',
			mediaCategory: 'tweet_image',
			dryRun: true,
		},
		createPostShape: {
			text: 'Post text',
			media: { media_ids: ['1234567890'] },
			account: 'kodykoala',
			dryRun: true,
		},
		oauthNotes: oauthTokenStorageNotes(oauth ?? undefined),
		note: 'Do not use upload.x.com for this package helper. One contract: upload-media returns mediaId; create-post takes media.media_ids.',
	}
}