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