Skip to content

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

Package listing

@kody/x

src/domain.ts

158 lines · 4.9 KB · TypeScript
import type {
	JsonRecord,
	ResolvedXOAuth,
	XApiResponse,
	XConnectUrls,
	XRateLimitInfo,
	XTweet,
	XUser,
	XUserSummary,
} from './types.ts'

export const API_BASE_URL = 'https://api.x.com'
export const DEFAULT_INTEGRATION = 'x'
export const BEARER_SECRET_NAME = 'xBearerToken'

export const DEFAULT_SCOPES = [
	'tweet.read',
	'tweet.write',
	'users.read',
	'offline.access',
	'like.read',
	'like.write',
	'follows.read',
	'follows.write',
	'bookmark.read',
	'bookmark.write',
] as const

export function connectUrls(integrationName = DEFAULT_INTEGRATION): XConnectUrls {
	const provider = encodeURIComponent(integrationName)
	const firstTime = new URL('https://kody.codes/connect/oauth')
	firstTime.searchParams.set('provider', integrationName)
	firstTime.searchParams.set('authorizeUrl', 'https://x.com/i/oauth2/authorize')
	firstTime.searchParams.set('tokenUrl', 'https://api.x.com/2/oauth2/token')
	firstTime.searchParams.set('apiBaseUrl', 'https://api.x.com/2')
	firstTime.searchParams.set('flow', 'pkce')
	firstTime.searchParams.set('allowedHosts', 'api.x.com,upload.x.com')
	firstTime.searchParams.set('dashboardUrl', 'https://developer.x.com/en/portal/dashboard')
	firstTime.searchParams.set('scopes', DEFAULT_SCOPES.join(' '))

	const bearer = new URL('https://kody.codes/account/secrets/new')
	bearer.searchParams.set('name', BEARER_SECRET_NAME)
	bearer.searchParams.set(
		'description',
		'X API v2 app-only bearer token for optional public reads',
	)
	bearer.searchParams.set('allowedHosts', 'api.x.com')
	bearer.searchParams.set('scope', 'user')

	return {
		reconnect: `https://kody.codes/connect/oauth?provider=${provider}`,
		firstTime: firstTime.toString(),
		bearerSecret: bearer.toString(),
	}
}

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

export function shapeTweet(tweet: XTweet): JsonRecord {
	return {
		id: tweet.id,
		text: tweet.text,
		author_id: tweet.author_id,
		created_at: tweet.created_at,
		conversation_id: tweet.conversation_id,
		public_metrics: tweet.public_metrics,
		referenced_tweets: tweet.referenced_tweets,
	}
}

export function shapeTweetsResponse(payload: XApiResponse<XTweet[] | XTweet>): JsonRecord {
	const data = payload.data
	const shaped = Array.isArray(data)
		? data.map((tweet) => shapeTweet(tweet))
		: data
			? shapeTweet(data)
			: 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'
}

export function defaultTweetFields(fields?: string): string {
	return (
		fields ||
		'id,text,author_id,created_at,conversation_id,public_metrics,referenced_tweets,entities'
	)
}

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 oauthTokenStorageNotes(
	oauth?: Pick<
		ResolvedXOAuth,
		'integrationName' | 'account' | 'accessTokenSecretName' | 'refreshTokenSecretName' | 'clientId'
	>,
) {
	const integrationName = oauth?.integrationName || DEFAULT_INTEGRATION
	const accessToken = oauth?.accessTokenSecretName || 'xAccessToken'
	const refreshToken = oauth?.refreshTokenSecretName || 'xRefreshToken'
	return {
		integration: integrationName,
		account: oauth?.account ?? null,
		secrets: {
			accessToken,
			refreshToken,
			optionalBearer: BEARER_SECRET_NAME,
		},
		clientId: oauth?.clientId ?? null,
		refreshEndpoint: 'https://api.x.com/2/oauth2/token',
		connect: connectUrls(integrationName),
		notes: [
			`User-context reads and writes use the saved "${integrationName}" OAuth integration.`,
			`Pass account: '<purpose>' for integration x-<purpose>, or integration: '${integrationName}'.`,
			`refresh-oauth-token rotates ${accessToken} through the host refresh helper.`,
			`Optional app-only public reads can use ${BEARER_SECRET_NAME} with authMode: 'bearer'.`,
		],
	}
}