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