← 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 · TypeScriptimport { 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.',
}
}