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

288 lines · 9.3 KB · TypeScript
import type {
	JsonRecord,
	ResolvedXOAuth,
	XApiResponse,
	XDmEvent,
	XPost,
	XRateLimitInfo,
	XUser,
	XUserSummary,
} from './types.ts'

export function cleanObject(input: JsonRecord): JsonRecord {
	return Object.fromEntries(
		Object.entries(input).filter(([, value]) => value !== undefined && value !== null && value !== ''),
	)
}

export function extractRateLimit(response: Response): XRateLimitInfo {
	return {
		limit: response.headers.get('x-rate-limit-limit'),
		remaining: response.headers.get('x-rate-limit-remaining'),
		reset: response.headers.get('x-rate-limit-reset'),
	}
}

export function summarizeUser(user: XUser | null | undefined): XUserSummary | null {
	if (!user || typeof user !== 'object') return null
	return {
		id: typeof user.id === 'string' ? user.id : undefined,
		username: typeof user.username === 'string' ? user.username : undefined,
		name: typeof user.name === 'string' ? user.name : undefined,
	}
}

/** Prefer long-form note_tweet.text when X truncates `text`. */
export function postDisplayText(post: XPost | JsonRecord | null | undefined): string | undefined {
	if (!post || typeof post !== 'object') return undefined
	const note = (post as JsonRecord).note_tweet
	if (note && typeof note === 'object' && !Array.isArray(note)) {
		const noteText = (note as JsonRecord).text
		if (typeof noteText === 'string' && noteText.length > 0) return noteText
	}
	const text = (post as JsonRecord).text
	return typeof text === 'string' ? text : undefined
}

export function shapePost(post: XPost): JsonRecord {
	return {
		id: post.id,
		text: postDisplayText(post) ?? post.text,
		author_id: post.author_id,
		created_at: post.created_at,
		conversation_id: post.conversation_id,
		public_metrics: post.public_metrics,
		referenced_tweets: post.referenced_tweets,
		entities: post.entities,
		note_tweet: post.note_tweet,
	}
}

/**
 * Parse a status id from a bare snowflake or an x.com / twitter.com status URL.
 * @example parsePostId('https://x.com/kentcdodds/status/123') // => '123'
 */
export function parsePostId(idOrUrl: string): string {
	const raw = String(idOrUrl || '').trim()
	if (!raw) throw new Error('Post id or url is required')
	if (/^\d+$/.test(raw)) return raw
	const match = raw.match(
		/(?:https?:\/\/)?(?:www\.)?(?:x|twitter)\.com\/[^/]+\/status(?:es)?\/(\d+)/i,
	)
	if (match?.[1]) return match[1]
	throw new Error(`Could not parse post id from: ${raw.slice(0, 120)}`)
}

/** Residual RT / leading-@ reply patterns the exclude= query sometimes leaves. */
export function isResidualRetweetOrReplyText(text: string | undefined | null): boolean {
	const trimmed = String(text || '').trim()
	if (!trimmed) return false
	if (/^RT\s+@/i.test(trimmed)) return true
	if (/^@[A-Za-z0-9_]+\b/.test(trimmed)) return true
	return false
}

export function isOriginalShapedPost(post: JsonRecord): boolean {
	if (isResidualRetweetOrReplyText(postDisplayText(post) ?? (post.text as string | undefined))) {
		return false
	}
	const refs = post.referenced_tweets
	if (Array.isArray(refs)) {
		for (const ref of refs) {
			if (!ref || typeof ref !== 'object') continue
			const type = String((ref as JsonRecord).type || '')
			if (type === 'retweeted' || type === 'replied_to') return false
		}
	}
	return true
}

export function usersById(includes?: JsonRecord | null): Map<string, XUser> {
	const map = new Map<string, XUser>()
	const users = includes && Array.isArray(includes.users) ? (includes.users as XUser[]) : []
	for (const user of users) {
		if (user && typeof user.id === 'string') map.set(user.id, user)
	}
	return map
}

export function postsById(includes?: JsonRecord | null): Map<string, XPost> {
	const map = new Map<string, XPost>()
	const posts = includes && Array.isArray(includes.tweets) ? (includes.tweets as XPost[]) : []
	for (const post of posts) {
		if (post && typeof post.id === 'string') map.set(post.id, post)
	}
	return map
}

export function attachAuthorFromIncludes(
	post: JsonRecord,
	includes?: JsonRecord | null,
): JsonRecord {
	const authorId = typeof post.author_id === 'string' ? post.author_id : undefined
	if (!authorId) return post
	const author = usersById(includes).get(authorId)
	if (!author) return post
	return { ...post, author: summarizeUser(author) }
}

/**
 * Attach quoted / replied_to / retweeted referenced posts from expansions includes.
 * Prioritizes `quotedPost`; also sets `repliedToPost` / `retweetedPost` when present.
 */
export function attachReferencedPosts(
	post: JsonRecord,
	includes?: JsonRecord | null,
): JsonRecord {
	const refs = post.referenced_tweets
	if (!Array.isArray(refs) || refs.length === 0) return attachAuthorFromIncludes(post, includes)

	const byId = postsById(includes)
	const users = usersById(includes)
	const out: JsonRecord = { ...attachAuthorFromIncludes(post, includes) }

	for (const ref of refs) {
		if (!ref || typeof ref !== 'object') continue
		const type = String((ref as JsonRecord).type || '')
		const refId = String((ref as JsonRecord).id || '')
		if (!refId) continue
		const raw = byId.get(refId)
		if (!raw) continue
		const shaped = attachAuthorFromIncludes(shapePost(raw), {
			users: raw.author_id ? [users.get(String(raw.author_id))].filter(Boolean) : [],
		})
		if (type === 'quoted') out.quotedPost = shaped
		else if (type === 'replied_to') out.repliedToPost = shaped
		else if (type === 'retweeted') out.retweetedPost = shaped
	}
	return out
}

export function shapePostsResponse(payload: XApiResponse<XPost[] | XPost>): JsonRecord {
	const data = payload.data
	const shaped = Array.isArray(data)
		? data.map((post) => attachReferencedPosts(shapePost(post), payload.includes))
		: data
			? attachReferencedPosts(shapePost(data), payload.includes)
			: data
	return {
		data: shaped,
		includes: payload.includes,
		meta: payload.meta,
		errors: payload.errors,
		rateLimit: payload.rateLimit,
	}
}

export function defaultUserFields(fields?: string): string {
	return fields || 'id,name,username,verified,public_metrics,description,created_at'
}

export function defaultPublicUserFields(fields?: string): string {
	return fields || 'id,name,username,verified,public_metrics,description,created_at,url,location'
}

/** Default `tweet.fields` for post reads — always includes public_metrics + note_tweet. */
export function defaultPostFields(fields?: string): string {
	return (
		fields ||
		'id,text,author_id,created_at,conversation_id,public_metrics,referenced_tweets,entities,note_tweet'
	)
}

export function defaultGetPostExpansions(expansions?: string): string {
	return expansions || 'author_id,referenced_tweets.id,referenced_tweets.id.author_id'
}

export function defaultGetPostUserFields(fields?: string): string {
	return fields || 'username,name,id'
}

export function defaultUserPostsExpansions(expansions?: string): string {
	return expansions || 'author_id,referenced_tweets.id'
}

export function defaultSearchExpansions(expansions?: string): string {
	return expansions || 'author_id,referenced_tweets.id'
}

export function defaultSearchUserFields(fields?: string): string {
	return fields || 'id,name,username,verified,public_metrics'
}

export function defaultDmEventFields(fields?: string): string {
	return (
		fields ||
		'id,event_type,text,sender_id,created_at,dm_conversation_id,attachments,referenced_tweets'
	)
}

export function defaultDmExpansions(expansions?: string): string {
	return expansions || 'sender_id,participant_ids'
}

export function defaultDmUserFields(fields?: string): string {
	return fields || 'id,name,username'
}

export function shapeDmEvent(event: XDmEvent): JsonRecord {
	return {
		id: event.id,
		event_type: event.event_type,
		text: event.text,
		sender_id: event.sender_id,
		created_at: event.created_at,
		dm_conversation_id: event.dm_conversation_id,
		attachments: event.attachments,
		referenced_tweets: event.referenced_tweets,
	}
}

export function shapeDmEventsResponse(payload: XApiResponse<XDmEvent[] | XDmEvent>): JsonRecord {
	const data = payload.data
	const shaped = Array.isArray(data)
		? data.map((event) => shapeDmEvent(event))
		: data
			? shapeDmEvent(data)
			: data
	return {
		data: shaped,
		includes: payload.includes,
		meta: payload.meta,
		errors: payload.errors,
		rateLimit: payload.rateLimit,
	}
}

/** OAuth token storage scaffolding notes for future Kody agents. */
export function oauthTokenStorageNotes(oauth?: Pick<
	ResolvedXOAuth,
	| 'integrationName'
	| 'account'
	| 'accessTokenSecretName'
	| 'refreshTokenSecretName'
	| 'clientId'
>) {
	const integrationName = oauth?.integrationName || 'x'
	const accessToken = oauth?.accessTokenSecretName || null
	const refreshToken = oauth?.refreshTokenSecretName || null
	const clientId = oauth?.clientId || null
	return {
		integration: integrationName,
		account: oauth?.account ?? null,
		secrets: {
			accessToken,
			refreshToken,
			bearerToken: 'xBearerToken',
		},
		clientId,
		refreshEndpoint: 'https://api.x.com/2/oauth2/token',
		notes: [
			'App-only reads use shared xBearerToken via the OpenAPI-scaffolded client.',
			`User-context writes and /users/me use createAuthenticatedFetch("${integrationName}") with connection-stored tokens.`,
			`Pass account: '<purpose>' for integration x-<purpose>, or integration: '${integrationName}'.`,
			'refresh-oauth-token calls host integrationTokenRefresh; do not require mirrored access/refresh secret names.',
			'Connect additional accounts at /connect/oauth?provider=x-<purpose> — no package code changes required.',
		],
	}
}