From 7a4782ddc453b2f8de8a5f33e7eb5c6826597ff3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 13:58:08 +0100 Subject: [PATCH] refactor: split twitter client --- src/lib/index.ts | 1 + src/lib/twitter-client-base.ts | 139 + src/lib/twitter-client-constants.ts | 37 + src/lib/twitter-client-features.ts | 210 ++ src/lib/twitter-client-media.ts | 166 ++ src/lib/twitter-client-posting.ts | 277 ++ src/lib/twitter-client-search.ts | 160 ++ src/lib/twitter-client-timelines.ts | 337 +++ src/lib/twitter-client-tweet-detail.ts | 322 +++ src/lib/twitter-client-types.ts | 251 ++ src/lib/twitter-client-users.ts | 330 +++ src/lib/twitter-client-utils.ts | 455 +++ src/lib/twitter-client.ts | 2667 +----------------- tests/twitter-client-coverage.upload.test.ts | 10 +- tests/twitter-client.test.ts | 17 +- 15 files changed, 2716 insertions(+), 2663 deletions(-) create mode 100644 src/lib/twitter-client-base.ts create mode 100644 src/lib/twitter-client-constants.ts create mode 100644 src/lib/twitter-client-features.ts create mode 100644 src/lib/twitter-client-media.ts create mode 100644 src/lib/twitter-client-posting.ts create mode 100644 src/lib/twitter-client-search.ts create mode 100644 src/lib/twitter-client-timelines.ts create mode 100644 src/lib/twitter-client-tweet-detail.ts create mode 100644 src/lib/twitter-client-types.ts create mode 100644 src/lib/twitter-client-users.ts create mode 100644 src/lib/twitter-client-utils.ts diff --git a/src/lib/index.ts b/src/lib/index.ts index 9501f67..f455f2c 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -8,6 +8,7 @@ export { type TwitterCookies, } from './cookies.js'; export { runtimeQueryIds } from './runtime-query-ids.js'; +export type { TweetResult, UploadMediaResult } from './twitter-client-types.js'; export { type CurrentUserResult, type FollowingResult, diff --git a/src/lib/twitter-client-base.ts b/src/lib/twitter-client-base.ts new file mode 100644 index 0000000..adc877d --- /dev/null +++ b/src/lib/twitter-client-base.ts @@ -0,0 +1,139 @@ +import { randomBytes, randomUUID } from 'node:crypto'; +import { QUERY_IDS, TARGET_QUERY_ID_OPERATIONS, type OperationName } from './twitter-client-constants.js'; +import { runtimeQueryIds } from './runtime-query-ids.js'; +import { normalizeQuoteDepth } from './twitter-client-utils.js'; +import type { CurrentUserResult, TwitterClientOptions } from './twitter-client-types.js'; + +export type Constructor = new (...args: any[]) => T; +export type AbstractConstructor = abstract new (...args: any[]) => T; + +export abstract class TwitterClientBase { + protected authToken: string; + protected ct0: string; + protected cookieHeader: string; + protected userAgent: string; + protected timeoutMs?: number; + protected quoteDepth: number; + protected clientUuid: string; + protected clientDeviceId: string; + protected clientUserId?: string; + + constructor(options: TwitterClientOptions) { + if (!options.cookies.authToken || !options.cookies.ct0) { + throw new Error('Both authToken and ct0 cookies are required'); + } + this.authToken = options.cookies.authToken; + this.ct0 = options.cookies.ct0; + this.cookieHeader = options.cookies.cookieHeader || `auth_token=${this.authToken}; ct0=${this.ct0}`; + this.userAgent = + options.userAgent || + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + this.timeoutMs = options.timeoutMs; + this.quoteDepth = normalizeQuoteDepth(options.quoteDepth); + this.clientUuid = randomUUID(); + this.clientDeviceId = randomUUID(); + } + + protected abstract getCurrentUser(): Promise; + + protected async getQueryId(operationName: OperationName): Promise { + const cached = await runtimeQueryIds.getQueryId(operationName); + return cached ?? QUERY_IDS[operationName]; + } + + protected async refreshQueryIds(): Promise { + if (process.env.NODE_ENV === 'test') { + return; + } + try { + await runtimeQueryIds.refresh(TARGET_QUERY_ID_OPERATIONS, { force: true }); + } catch { + // ignore refresh failures; callers will fall back to baked-in IDs + } + } + + protected async getTweetDetailQueryIds(): Promise { + const primary = await this.getQueryId('TweetDetail'); + return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g'])); + } + + protected async getSearchTimelineQueryIds(): Promise { + const primary = await this.getQueryId('SearchTimeline'); + return Array.from( + new Set([primary, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ']), + ); + } + + protected async fetchWithTimeout(url: string, init: RequestInit): Promise { + if (!this.timeoutMs || this.timeoutMs <= 0) { + return fetch(url, init); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } + } + + protected getHeaders(): Record { + return this.getJsonHeaders(); + } + + protected createTransactionId(): string { + return randomBytes(16).toString('hex'); + } + + protected getBaseHeaders(): Record { + const headers: Record = { + accept: '*/*', + 'accept-language': 'en-US,en;q=0.9', + authorization: + 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA', + 'x-csrf-token': this.ct0, + 'x-twitter-auth-type': 'OAuth2Session', + 'x-twitter-active-user': 'yes', + 'x-twitter-client-language': 'en', + 'x-client-uuid': this.clientUuid, + 'x-twitter-client-deviceid': this.clientDeviceId, + 'x-client-transaction-id': this.createTransactionId(), + cookie: this.cookieHeader, + 'user-agent': this.userAgent, + origin: 'https://x.com', + referer: 'https://x.com/', + }; + + if (this.clientUserId) { + headers['x-twitter-client-user-id'] = this.clientUserId; + } + + return headers; + } + + protected getJsonHeaders(): Record { + return { + ...this.getBaseHeaders(), + 'content-type': 'application/json', + }; + } + + protected getUploadHeaders(): Record { + // Note: do not set content-type; URLSearchParams/FormData need to set it (incl boundary) themselves. + return this.getBaseHeaders(); + } + + protected async ensureClientUserId(): Promise { + if (process.env.NODE_ENV === 'test') { + return; + } + if (this.clientUserId) { + return; + } + const result = await this.getCurrentUser(); + if (result.success && result.user?.id) { + this.clientUserId = result.user.id; + } + } +} diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts new file mode 100644 index 0000000..cb3598d --- /dev/null +++ b/src/lib/twitter-client-constants.ts @@ -0,0 +1,37 @@ +// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension. +import queryIds from './query-ids.json' with { type: 'json' }; + +export const TWITTER_API_BASE = 'https://x.com/i/api/graphql'; +export const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql'; +export const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json'; +export const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json'; +export const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json'; +export const SETTINGS_SCREEN_NAME_REGEX = /"screen_name":"([^"]+)"/; +export const SETTINGS_USER_ID_REGEX = /"user_id"\s*:\s*"(\d+)"/; +export const SETTINGS_NAME_REGEX = /"name":"([^"\\]*(?:\\.[^"\\]*)*)"/; + +// Query IDs rotate frequently; the values in query-ids.json are refreshed by +// scripts/update-query-ids.ts. The fallback values keep the client usable if +// the file is missing or incomplete. +export const FALLBACK_QUERY_IDS = { + CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg', + CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', + FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', + TweetDetail: '97JF30KziU00483E_8elBA', + SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', + UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', + Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw', + Following: 'BEkNpEt5pNETESoqMsTEGA', + Followers: 'kuFUYP9eV1FPoEy4N-pi7w', + Likes: 'JR2gceKucIKcVNB_9JkhsA', + BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ', +} as const; + +export type OperationName = keyof typeof FALLBACK_QUERY_IDS; + +export const QUERY_IDS: Record = { + ...FALLBACK_QUERY_IDS, + ...(queryIds as Partial>), +}; + +export const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array; diff --git a/src/lib/twitter-client-features.ts b/src/lib/twitter-client-features.ts new file mode 100644 index 0000000..a6608f5 --- /dev/null +++ b/src/lib/twitter-client-features.ts @@ -0,0 +1,210 @@ +export function buildArticleFeatures(): Record { + return { + rweb_video_screen_enabled: true, + profile_label_improvements_pcf_label_in_post_enabled: true, + responsive_web_profile_redirect_enabled: true, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: false, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + articles_preview_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: true, + creator_subscriptions_quote_tweet_preview_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_imagine_annotation_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_enhance_cards_enabled: false, + }; +} + +export function buildTweetDetailFeatures(): Record { + return { + ...buildArticleFeatures(), + responsive_web_graphql_exclude_directive_enabled: true, + communities_web_enable_tweet_community_results_fetch: true, + responsive_web_twitter_article_plain_text_enabled: true, + responsive_web_twitter_article_seed_tweet_detail_enabled: true, + responsive_web_twitter_article_seed_tweet_summary_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + tweet_awards_web_tipping_enabled: false, + creator_subscriptions_quote_tweet_preview_enabled: false, + verified_phone_label_enabled: false, + }; +} + +export function buildArticleFieldToggles(): Record { + return { + withPayments: false, + withAuxiliaryUserLabels: false, + withArticleRichContentState: true, + withArticlePlainText: true, + withGrokAnalyze: false, + withDisallowedReplyControls: false, + }; +} + +export function buildSearchFeatures(): Record { + return { + rweb_video_screen_enabled: true, + profile_label_improvements_pcf_label_in_post_enabled: true, + responsive_web_profile_redirect_enabled: true, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_exclude_directive_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: false, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: true, + creator_subscriptions_quote_tweet_preview_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + rweb_video_timestamps_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_imagine_annotation_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + articles_preview_enabled: true, + responsive_web_enhance_cards_enabled: false, + }; +} + +export function buildTweetCreateFeatures(): Record { + return { + rweb_video_screen_enabled: true, + creator_subscriptions_tweet_preview_api_enabled: true, + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: false, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: true, + creator_subscriptions_quote_tweet_preview_enabled: false, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + profile_label_improvements_pcf_label_in_post_enabled: true, + responsive_web_profile_redirect_enabled: false, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + articles_preview_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_imagine_annotation_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_enhance_cards_enabled: false, + }; +} + +export function buildTimelineFeatures(): Record { + return { + ...buildSearchFeatures(), + blue_business_profile_image_shape_enabled: true, + responsive_web_text_conversations_enabled: false, + tweetypie_unmention_optimization_enabled: true, + vibe_api_enabled: true, + responsive_web_twitter_blue_verified_badge_is_enabled: true, + interactive_text_enabled: true, + longform_notetweets_richtext_consumption_enabled: true, + responsive_web_media_download_video_enabled: false, + }; +} + +export function buildBookmarksFeatures(): Record { + return { + ...buildTimelineFeatures(), + graphql_timeline_v2_bookmark_timeline: true, + }; +} + +export function buildLikesFeatures(): Record { + return buildTimelineFeatures(); +} + +export function buildFollowingFeatures(): Record { + return { + rweb_video_screen_enabled: true, + profile_label_improvements_pcf_label_in_post_enabled: false, + responsive_web_profile_redirect_enabled: true, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + premium_content_api_read_enabled: true, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: false, + responsive_web_jetfuel_frame: false, + responsive_web_grok_share_attachment_enabled: false, + articles_preview_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: true, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: false, + creator_subscriptions_quote_tweet_preview_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: false, + responsive_web_grok_imagine_annotation_enabled: false, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_enhance_cards_enabled: false, + }; +} diff --git a/src/lib/twitter-client-media.ts b/src/lib/twitter-client-media.ts new file mode 100644 index 0000000..a0f83a7 --- /dev/null +++ b/src/lib/twitter-client-media.ts @@ -0,0 +1,166 @@ +import { TWITTER_MEDIA_METADATA_URL, TWITTER_UPLOAD_URL } from './twitter-client-constants.js'; +import { type AbstractConstructor, type TwitterClientBase } from './twitter-client-base.js'; +import type { UploadMediaResult } from './twitter-client-types.js'; + +export function withMedia>(Base: TBase) { + return class extends Base { + private mediaCategoryForMime(mimeType: string): string | null { + if (mimeType.startsWith('image/')) { + if (mimeType === 'image/gif') { + return 'tweet_gif'; + } + return 'tweet_image'; + } + if (mimeType.startsWith('video/')) { + return 'tweet_video'; + } + return null; + } + + private async sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + + async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise { + const category = this.mediaCategoryForMime(input.mimeType); + if (!category) { + return { success: false, error: `Unsupported media type: ${input.mimeType}` }; + } + + try { + const initParams = new URLSearchParams({ + command: 'INIT', + total_bytes: String(input.data.byteLength), + media_type: input.mimeType, + media_category: category, + }); + + const initResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, { + method: 'POST', + headers: this.getUploadHeaders(), + body: initParams, + }); + + if (!initResp.ok) { + const text = await initResp.text(); + return { success: false, error: `HTTP ${initResp.status}: ${text.slice(0, 200)}` }; + } + + const initBody = (await initResp.json()) as { media_id_string?: string; media_id?: string | number }; + const mediaId = + typeof initBody.media_id_string === 'string' + ? initBody.media_id_string + : initBody.media_id !== undefined + ? String(initBody.media_id) + : undefined; + if (!mediaId) { + return { success: false, error: 'Media upload INIT did not return media_id' }; + } + + const chunkSize = 5 * 1024 * 1024; + let segmentIndex = 0; + for (let offset = 0; offset < input.data.byteLength; offset += chunkSize) { + const chunk = input.data.slice(offset, Math.min(input.data.byteLength, offset + chunkSize)); + const form = new FormData(); + form.set('command', 'APPEND'); + form.set('media_id', mediaId); + form.set('segment_index', String(segmentIndex)); + form.set('media', new Blob([chunk], { type: input.mimeType }), 'media'); + + const appendResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, { + method: 'POST', + headers: this.getUploadHeaders(), + body: form, + }); + + if (!appendResp.ok) { + const text = await appendResp.text(); + return { success: false, error: `HTTP ${appendResp.status}: ${text.slice(0, 200)}` }; + } + segmentIndex += 1; + } + + const finalizeParams = new URLSearchParams({ command: 'FINALIZE', media_id: mediaId }); + const finalizeResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, { + method: 'POST', + headers: this.getUploadHeaders(), + body: finalizeParams, + }); + + if (!finalizeResp.ok) { + const text = await finalizeResp.text(); + return { success: false, error: `HTTP ${finalizeResp.status}: ${text.slice(0, 200)}` }; + } + + const finalizeBody = (await finalizeResp.json()) as { + processing_info?: { + state?: string; + check_after_secs?: number; + error?: { message?: string; name?: string }; + }; + }; + + const info = finalizeBody.processing_info; + if (info?.state && info.state !== 'succeeded') { + let attempts = 0; + while (attempts < 20) { + if (info.state === 'failed') { + const msg = info.error?.message || info.error?.name || 'Media processing failed'; + return { success: false, error: msg }; + } + const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2; + await this.sleep(delaySecs * 1000); + + const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({ + command: 'STATUS', + media_id: mediaId, + }).toString()}`; + const statusResp = await this.fetchWithTimeout(statusUrl, { + method: 'GET', + headers: this.getUploadHeaders(), + }); + + if (!statusResp.ok) { + const text = await statusResp.text(); + return { success: false, error: `HTTP ${statusResp.status}: ${text.slice(0, 200)}` }; + } + + const statusBody = (await statusResp.json()) as { + processing_info?: { + state?: string; + check_after_secs?: number; + error?: { message?: string; name?: string }; + }; + }; + if (!statusBody.processing_info) { + break; + } + info.state = statusBody.processing_info.state; + info.check_after_secs = statusBody.processing_info.check_after_secs; + info.error = statusBody.processing_info.error; + if (info.state === 'succeeded') { + break; + } + attempts += 1; + } + } + + if (input.alt && input.mimeType.startsWith('image/')) { + const metaResp = await this.fetchWithTimeout(TWITTER_MEDIA_METADATA_URL, { + method: 'POST', + headers: this.getJsonHeaders(), + body: JSON.stringify({ media_id: mediaId, alt_text: { text: input.alt } }), + }); + if (!metaResp.ok) { + const text = await metaResp.text(); + return { success: false, error: `HTTP ${metaResp.status}: ${text.slice(0, 200)}` }; + } + } + + return { success: true, mediaId }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + }; +} diff --git a/src/lib/twitter-client-posting.ts b/src/lib/twitter-client-posting.ts new file mode 100644 index 0000000..4d5ad00 --- /dev/null +++ b/src/lib/twitter-client-posting.ts @@ -0,0 +1,277 @@ +import { + TWITTER_API_BASE, + TWITTER_GRAPHQL_POST_URL, + TWITTER_STATUS_UPDATE_URL, +} from './twitter-client-constants.js'; +import { buildTweetCreateFeatures } from './twitter-client-features.js'; +import { type AbstractConstructor, type TwitterClientBase } from './twitter-client-base.js'; +import type { CreateTweetResponse, TweetResult } from './twitter-client-types.js'; + +export function withPosting>(Base: TBase) { + return class extends Base { + /** + * Post a new tweet + */ + async tweet(text: string, mediaIds?: string[]): Promise { + const variables = { + tweet_text: text, + dark_request: false, + media: { + media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })), + possibly_sensitive: false, + }, + semantic_annotation_ids: [], + }; + + const features = buildTweetCreateFeatures(); + + return this.createTweet(variables, features); + } + + /** + * Reply to an existing tweet + */ + async reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise { + const variables = { + tweet_text: text, + reply: { + in_reply_to_tweet_id: replyToTweetId, + exclude_reply_user_ids: [], + }, + dark_request: false, + media: { + media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })), + possibly_sensitive: false, + }, + semantic_annotation_ids: [], + }; + + const features = buildTweetCreateFeatures(); + + return this.createTweet(variables, features); + } + + private async createTweet( + variables: Record, + features: Record, + ): Promise { + await this.ensureClientUserId(); + let queryId = await this.getQueryId('CreateTweet'); + let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`; + + const buildBody = () => JSON.stringify({ variables, features, queryId }); + let body = buildBody(); + + try { + const headers = { ...this.getHeaders(), referer: 'https://x.com/compose/post' }; + let response = await this.fetchWithTimeout(urlWithOperation, { + method: 'POST', + headers, + body, + }); + + // Twitter increasingly prefers POST to /i/api/graphql with queryId in the payload. + // If the operation URL 404s, retry the generic endpoint. + if (response.status === 404) { + await this.refreshQueryIds(); + queryId = await this.getQueryId('CreateTweet'); + urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`; + body = buildBody(); + + response = await this.fetchWithTimeout(urlWithOperation, { + method: 'POST', + headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' }, + body, + }); + + if (response.status === 404) { + const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, { + method: 'POST', + headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' }, + body, + }); + + if (!retry.ok) { + const text = await retry.text(); + return { success: false, error: `HTTP ${retry.status}: ${text.slice(0, 200)}` }; + } + + const data = (await retry.json()) as CreateTweetResponse; + + if (data.errors && data.errors.length > 0) { + const fallback = await this.tryStatusUpdateFallback(data.errors, variables); + if (fallback) { + return fallback; + } + return { success: false, error: this.formatErrors(data.errors) }; + } + + const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; + if (tweetId) { + return { success: true, tweetId }; + } + + return { success: false, error: 'Tweet created but no ID returned' }; + } + } + + if (!response.ok) { + const text = await response.text(); + return { + success: false, + error: `HTTP ${response.status}: ${text.slice(0, 200)}`, + }; + } + + const data = (await response.json()) as CreateTweetResponse; + + if (data.errors && data.errors.length > 0) { + const fallback = await this.tryStatusUpdateFallback(data.errors, variables); + if (fallback) { + return fallback; + } + return { + success: false, + error: this.formatErrors(data.errors), + }; + } + + const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; + if (tweetId) { + return { + success: true, + tweetId, + }; + } + + return { + success: false, + error: 'Tweet created but no ID returned', + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + private formatErrors(errors: Array<{ message: string; code?: number }>): string { + return errors + .map((error) => (typeof error.code === 'number' ? `${error.message} (${error.code})` : error.message)) + .join(', '); + } + + private statusUpdateInputFromCreateTweetVariables(variables: Record): { + text: string; + inReplyToTweetId?: string; + mediaIds?: string[]; + } | null { + const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null; + if (!text) { + return null; + } + + const reply = variables.reply; + const inReplyToTweetId = + reply && + typeof reply === 'object' && + typeof (reply as { in_reply_to_tweet_id?: unknown }).in_reply_to_tweet_id === 'string' + ? (reply as { in_reply_to_tweet_id: string }).in_reply_to_tweet_id + : undefined; + + const media = variables.media; + const mediaEntities = + media && typeof media === 'object' ? (media as { media_entities?: unknown }).media_entities : undefined; + + const mediaIds = Array.isArray(mediaEntities) + ? mediaEntities + .map((entity) => + entity && typeof entity === 'object' && 'media_id' in (entity as Record) + ? (entity as { media_id?: unknown }).media_id + : undefined, + ) + .filter((value): value is string | number => typeof value === 'string' || typeof value === 'number') + .map((value) => String(value)) + : undefined; + + return { text, inReplyToTweetId, mediaIds: mediaIds && mediaIds.length > 0 ? mediaIds : undefined }; + } + + private async postStatusUpdate(input: { + text: string; + inReplyToTweetId?: string; + mediaIds?: string[]; + }): Promise { + const params = new URLSearchParams(); + params.set('status', input.text); + if (input.inReplyToTweetId) { + params.set('in_reply_to_status_id', input.inReplyToTweetId); + params.set('auto_populate_reply_metadata', 'true'); + } + if (input.mediaIds && input.mediaIds.length > 0) { + params.set('media_ids', input.mediaIds.join(',')); + } + + try { + const response = await this.fetchWithTimeout(TWITTER_STATUS_UPDATE_URL, { + method: 'POST', + headers: { + ...this.getBaseHeaders(), + 'content-type': 'application/x-www-form-urlencoded', + referer: 'https://x.com/compose/post', + }, + body: params.toString(), + }); + + if (!response.ok) { + const text = await response.text(); + return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; + } + + const data = (await response.json()) as { + id_str?: string; + id?: string | number; + errors?: Array<{ message: string; code?: number }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false, error: this.formatErrors(data.errors) }; + } + + const tweetId = + typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined; + + if (tweetId) { + return { success: true, tweetId }; + } + return { success: false, error: 'Tweet created but no ID returned' }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + private async tryStatusUpdateFallback( + errors: Array<{ message: string; code?: number }>, + variables: Record, + ): Promise { + if (!errors.some((error) => error.code === 226)) { + return null; + } + const input = this.statusUpdateInputFromCreateTweetVariables(variables); + if (!input) { + return null; + } + + const fallback = await this.postStatusUpdate(input); + if (fallback.success) { + return fallback; + } + + return { + success: false, + error: `${this.formatErrors(errors)} | fallback: ${fallback.error ?? 'Unknown error'}`, + }; + } + }; +} diff --git a/src/lib/twitter-client-search.ts b/src/lib/twitter-client-search.ts new file mode 100644 index 0000000..2e8f4cd --- /dev/null +++ b/src/lib/twitter-client-search.ts @@ -0,0 +1,160 @@ +import { TWITTER_API_BASE } from './twitter-client-constants.js'; +import { buildSearchFeatures } from './twitter-client-features.js'; +import { type AbstractConstructor, type TwitterClientBase } from './twitter-client-base.js'; +import type { SearchResult, TweetData } from './twitter-client-types.js'; +import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js'; + +export function withSearch>(Base: TBase) { + return class extends Base { + /** + * Search for tweets matching a query + */ + async search(query: string, count = 20): Promise { + const features = buildSearchFeatures(); + const pageSize = 20; + const seen = new Set(); + const tweets: TweetData[] = []; + let cursor: string | undefined; + + const fetchPage = async (pageCount: number, pageCursor?: string) => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getSearchTimelineQueryIds(); + + for (const queryId of queryIds) { + const variables = { + rawQuery: query, + count: pageCount, + querySource: 'typed_query', + product: 'Latest', + ...(pageCursor ? { cursor: pageCursor } : {}), + }; + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + }); + + const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ features, queryId }), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + search_by_raw_query?: { + search_timeline?: { + timeline?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: { + rest_id?: string; + legacy?: { + full_text?: string; + created_at?: string; + reply_count?: number; + retweet_count?: number; + favorite_count?: number; + in_reply_to_status_id_str?: string; + }; + core?: { + user_results?: { + result?: { + legacy?: { + screen_name?: string; + name?: string; + }; + }; + }; + }; + }; + }; + }; + }; + }>; + }>; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions; + const pageTweets = parseTweetsFromInstructions(instructions, this.quoteDepth); + const nextCursor = extractCursorFromInstructions(instructions); + + return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching search results', had404 }; + }; + + const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => { + const firstAttempt = await fetchPage(pageCount, pageCursor); + if (firstAttempt.success) { + return firstAttempt; + } + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await fetchPage(pageCount, pageCursor); + if (secondAttempt.success) { + return secondAttempt; + } + return { success: false as const, error: secondAttempt.error }; + } + return { success: false as const, error: firstAttempt.error }; + }; + + while (tweets.length < count) { + const pageCount = Math.min(pageSize, count - tweets.length); + const page = await fetchWithRefresh(pageCount, cursor); + if (!page.success) { + return { success: false, error: page.error }; + } + + for (const tweet of page.tweets) { + if (seen.has(tweet.id)) { + continue; + } + seen.add(tweet.id); + tweets.push(tweet); + if (tweets.length >= count) { + break; + } + } + + if (!page.cursor || page.cursor === cursor || page.tweets.length === 0) { + break; + } + cursor = page.cursor; + } + + return { success: true, tweets }; + } + }; +} diff --git a/src/lib/twitter-client-timelines.ts b/src/lib/twitter-client-timelines.ts new file mode 100644 index 0000000..4319042 --- /dev/null +++ b/src/lib/twitter-client-timelines.ts @@ -0,0 +1,337 @@ +import { TWITTER_API_BASE } from './twitter-client-constants.js'; +import { buildBookmarksFeatures, buildLikesFeatures } from './twitter-client-features.js'; +import { type AbstractConstructor, type TwitterClientBase } from './twitter-client-base.js'; +import type { SearchResult } from './twitter-client-types.js'; +import { parseTweetsFromInstructions } from './twitter-client-utils.js'; + +export function withTimelines>(Base: TBase) { + return class extends Base { + private async getBookmarksQueryIds(): Promise { + const primary = await this.getQueryId('Bookmarks'); + return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw'])); + } + + private async getBookmarkFolderQueryIds(): Promise { + const primary = await this.getQueryId('BookmarkFolderTimeline'); + return Array.from(new Set([primary, 'KJIQpsvxrTfRIlbaRIySHQ'])); + } + + private async getLikesQueryIds(): Promise { + const primary = await this.getQueryId('Likes'); + return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA'])); + } + + /** + * Get the authenticated user's bookmarks + */ + async getBookmarks(count = 20): Promise { + const variables = { + count, + includePromotedContent: false, + withDownvotePerspective: false, + withReactionsMetadata: false, + withReactionsPerspective: false, + }; + + const features = buildBookmarksFeatures(); + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getBookmarksQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + bookmark_timeline_v2?: { + timeline?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: unknown; + }; + }; + }; + }>; + }>; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions; + const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth); + + return { success: true as const, tweets, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching bookmarks', had404 }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, tweets: firstAttempt.tweets }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, tweets: secondAttempt.tweets }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } + + /** + * Get the authenticated user's liked tweets + */ + async getLikes(count = 20): Promise { + const userResult = await this.getCurrentUser(); + if (!userResult.success || !userResult.user) { + return { success: false, error: userResult.error ?? 'Could not determine current user' }; + } + + const variables = { + userId: userResult.user.id, + count, + includePromotedContent: false, + withClientEventToken: false, + withBirdwatchNotes: false, + withVoice: true, + }; + + const features = buildLikesFeatures(); + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getLikesQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + user?: { + result?: { + timeline?: { + timeline?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: unknown; + }; + }; + }; + }>; + }>; + }; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; + const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth); + + return { success: true as const, tweets, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching likes', had404 }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, tweets: firstAttempt.tweets }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, tweets: secondAttempt.tweets }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } + + /** + * Get the authenticated user's bookmark folder timeline + */ + async getBookmarkFolderTimeline(folderId: string, count = 20): Promise { + const variablesWithCount = { + bookmark_collection_id: folderId, + includePromotedContent: true, + count, + }; + + const variablesWithoutCount = { + bookmark_collection_id: folderId, + includePromotedContent: true, + }; + + const features = buildBookmarksFeatures(); + + const tryOnce = async (variables: Record) => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getBookmarkFolderQueryIds(); + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + bookmark_collection_timeline?: { + timeline?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: unknown; + }; + }; + }; + }>; + }>; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions; + const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth); + + return { success: true as const, tweets, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 }; + }; + + let firstAttempt = await tryOnce(variablesWithCount); + if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) { + firstAttempt = await tryOnce(variablesWithoutCount); + } + if (firstAttempt.success) { + return { success: true, tweets: firstAttempt.tweets }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + let secondAttempt = await tryOnce(variablesWithCount); + if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) { + secondAttempt = await tryOnce(variablesWithoutCount); + } + if (secondAttempt.success) { + return { success: true, tweets: secondAttempt.tweets }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } + }; +} diff --git a/src/lib/twitter-client-tweet-detail.ts b/src/lib/twitter-client-tweet-detail.ts new file mode 100644 index 0000000..bb45e74 --- /dev/null +++ b/src/lib/twitter-client-tweet-detail.ts @@ -0,0 +1,322 @@ +import { TWITTER_API_BASE } from './twitter-client-constants.js'; +import { buildArticleFeatures, buildArticleFieldToggles, buildTweetDetailFeatures } from './twitter-client-features.js'; +import { type AbstractConstructor, type TwitterClientBase } from './twitter-client-base.js'; +import type { GetTweetResult, GraphqlTweetResult, SearchResult } from './twitter-client-types.js'; +import { + extractArticleText, + findTweetInInstructions, + firstText, + mapTweetResult, + parseTweetsFromInstructions, +} from './twitter-client-utils.js'; + +export function withTweetDetails>(Base: TBase) { + return class extends Base { + private async fetchUserArticlePlainText( + userId: string, + tweetId: string, + ): Promise<{ title?: string; plainText?: string }> { + const variables = { + userId, + count: 20, + includePromotedContent: true, + withVoice: true, + withQuickPromoteEligibilityTweetFields: true, + withBirdwatchNotes: true, + withCommunity: true, + withSafetyModeUserFields: true, + withSuperFollowsUserFields: true, + withDownvotePerspective: false, + withReactionsMetadata: false, + withReactionsPerspective: false, + withSuperFollowsTweetFields: true, + withSuperFollowsReplyCount: false, + withClientEventToken: false, + }; + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(buildArticleFeatures()), + fieldToggles: JSON.stringify(buildArticleFieldToggles()), + }); + + const queryId = await this.getQueryId('UserArticlesTweets'); + const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() }); + if (!response.ok) { + return {}; + } + + const data = (await response.json()) as { + data?: { + user?: { + result?: { + timeline?: { + timeline?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { result?: GraphqlTweetResult }; + }; + }; + }>; + }>; + }; + }; + }; + }; + }; + }; + + const instructions = data.data?.user?.result?.timeline?.timeline?.instructions ?? []; + for (const instruction of instructions) { + for (const entry of instruction.entries ?? []) { + const result = entry.content?.itemContent?.tweet_results?.result; + if (result?.rest_id !== tweetId) { + continue; + } + const articleResult = result.article?.article_results?.result; + const title = firstText(articleResult?.title, result.article?.title); + const plainText = firstText(articleResult?.plain_text, result.article?.plain_text); + return { title, plainText }; + } + } + } catch { + return {}; + } + + return {}; + } + + private async fetchTweetDetail(tweetId: string): Promise< + | { + success: true; + data: { + tweetResult?: { result?: GraphqlTweetResult }; + threaded_conversation_with_injections_v2?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }>; + }; + }; + } + | { success: false; error: string } + > { + const variables = { + focalTweetId: tweetId, + with_rux_injections: false, + rankingMode: 'Relevance', + includePromotedContent: true, + withCommunity: true, + withQuickPromoteEligibilityTweetFields: true, + withBirdwatchNotes: true, + withVoice: true, + }; + + const features = { + ...buildTweetDetailFeatures(), + articles_preview_enabled: true, + articles_rest_api_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + rweb_video_timestamps_enabled: true, + }; + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + try { + const parseResponse = async (response: Response) => { + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; + } + + const data = (await response.json()) as { + data?: { + tweetResult?: { result?: GraphqlTweetResult }; + threaded_conversation_with_injections_v2?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }>; + }; + }; + errors?: Array<{ message: string; code?: number }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', ') }; + } + + return { success: true as const, data: data.data ?? {} }; + }; + + let lastError: string | undefined; + let had404 = false; + + const tryOnce = async () => { + const queryIds = await this.getTweetDetailQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params.toString()}`; + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status !== 404) { + return await parseResponse(response); + } + + had404 = true; + + const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ variables, features, queryId }), + }); + + if (postResponse.status !== 404) { + return await parseResponse(postResponse); + } + + lastError = 'HTTP 404'; + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching tweet detail' }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return firstAttempt; + } + + if (had404) { + await this.refreshQueryIds(); + return await tryOnce(); + } + + return firstAttempt; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + /** + * Get tweet details by ID + */ + async getTweet(tweetId: string): Promise { + const response = await this.fetchTweetDetail(tweetId); + if (!response.success) { + return response; + } + + const tweetResult = + (response.data.tweetResult as { result?: GraphqlTweetResult } | undefined)?.result ?? + findTweetInInstructions( + response.data.threaded_conversation_with_injections_v2?.instructions as + | Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }> + | undefined, + tweetId, + ); + + const mapped = mapTweetResult(tweetResult, this.quoteDepth); + if (mapped) { + if (tweetResult?.article) { + const title = firstText(tweetResult.article.article_results?.result?.title, tweetResult.article.title); + const articleText = extractArticleText(tweetResult); + if (title && (!articleText || articleText.trim() === title.trim())) { + const userId = tweetResult.core?.user_results?.result?.rest_id; + if (userId) { + const fallback = await this.fetchUserArticlePlainText(userId, tweetId); + if (fallback.plainText) { + mapped.text = fallback.title ? `${fallback.title}\n\n${fallback.plainText}` : fallback.plainText; + } + } + } + } + return { success: true, tweet: mapped }; + } + return { success: false, error: 'Tweet not found in response' }; + } + + /** + * Get replies to a tweet by ID + */ + async getReplies(tweetId: string): Promise { + const response = await this.fetchTweetDetail(tweetId); + if (!response.success) { + return response; + } + + const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; + const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth); + const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId); + + return { success: true, tweets: replies }; + } + + /** + * Get full conversation thread for a tweet ID + */ + async getThread(tweetId: string): Promise { + const response = await this.fetchTweetDetail(tweetId); + if (!response.success) { + return response; + } + + const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; + const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth); + + const target = tweets.find((t) => t.id === tweetId); + const rootId = target?.conversationId || tweetId; + const thread = tweets.filter((tweet) => tweet.conversationId === rootId); + + thread.sort((a, b) => { + const aTime = a.createdAt ? Date.parse(a.createdAt) : 0; + const bTime = b.createdAt ? Date.parse(b.createdAt) : 0; + return aTime - bTime; + }); + + return { success: true, tweets: thread }; + } + }; +} diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts new file mode 100644 index 0000000..52fe7ed --- /dev/null +++ b/src/lib/twitter-client-types.ts @@ -0,0 +1,251 @@ +import type { TwitterCookies } from './cookies.js'; + +export type GraphqlTweetResult = { + __typename?: string; + rest_id?: string; + legacy?: { + full_text?: string; + created_at?: string; + reply_count?: number; + retweet_count?: number; + favorite_count?: number; + conversation_id_str?: string; + in_reply_to_status_id_str?: string | null; + }; + core?: { + user_results?: { + result?: { + rest_id?: string; + id?: string; + legacy?: { + screen_name?: string; + name?: string; + }; + core?: { + screen_name?: string; + name?: string; + }; + }; + }; + }; + note_tweet?: { + note_tweet_results?: { + result?: { + text?: string; + richtext?: { + text?: string; + }; + rich_text?: { + text?: string; + }; + content?: { + text?: string; + richtext?: { + text?: string; + }; + rich_text?: { + text?: string; + }; + }; + }; + }; + }; + article?: { + article_results?: { + result?: { + title?: string; + plain_text?: string; + text?: string; + richtext?: { + text?: string; + }; + rich_text?: { + text?: string; + }; + body?: { + text?: string; + richtext?: { + text?: string; + }; + rich_text?: { + text?: string; + }; + }; + content?: { + text?: string; + richtext?: { + text?: string; + }; + rich_text?: { + text?: string; + }; + items?: Array<{ + text?: string; + content?: { + text?: string; + richtext?: { text?: string }; + rich_text?: { text?: string }; + }; + }>; + }; + sections?: Array<{ + items?: Array<{ + text?: string; + content?: { + text?: string; + richtext?: { text?: string }; + rich_text?: { text?: string }; + }; + }>; + }>; + }; + }; + title?: string; + plain_text?: string; + text?: string; + richtext?: { + text?: string; + }; + rich_text?: { + text?: string; + }; + body?: { + text?: string; + richtext?: { + text?: string; + }; + rich_text?: { + text?: string; + }; + }; + content?: { + text?: string; + richtext?: { + text?: string; + }; + rich_text?: { + text?: string; + }; + items?: Array<{ + text?: string; + content?: { + text?: string; + richtext?: { text?: string }; + rich_text?: { text?: string }; + }; + }>; + }; + sections?: Array<{ + items?: Array<{ + text?: string; + content?: { + text?: string; + richtext?: { text?: string }; + rich_text?: { text?: string }; + }; + }>; + }>; + }; + tweet?: GraphqlTweetResult; + quoted_status_result?: { + result?: GraphqlTweetResult; + }; +}; + +export type TweetResult = + | { + success: true; + tweetId: string; + } + | { + success: false; + error: string; + }; + +export interface UploadMediaResult { + success: boolean; + mediaId?: string; + error?: string; +} + +export interface TweetData { + id: string; + text: string; + author: { + username: string; + name: string; + }; + authorId?: string; + createdAt?: string; + replyCount?: number; + retweetCount?: number; + likeCount?: number; + conversationId?: string; + inReplyToStatusId?: string; + // Optional quoted tweet; depth controlled by quoteDepth (default: 1). + quotedTweet?: TweetData; +} + +export interface GetTweetResult { + success: boolean; + tweet?: TweetData; + error?: string; +} + +export interface SearchResult { + success: boolean; + tweets?: TweetData[]; + error?: string; +} + +export interface CurrentUserResult { + success: boolean; + user?: { + id: string; + username: string; + name: string; + }; + error?: string; +} + +export interface TwitterUser { + id: string; + username: string; + name: string; + description?: string; + followersCount?: number; + followingCount?: number; + isBlueVerified?: boolean; + profileImageUrl?: string; + createdAt?: string; +} + +export interface FollowingResult { + success: boolean; + users?: TwitterUser[]; + error?: string; +} + +export interface TwitterClientOptions { + cookies: TwitterCookies; + userAgent?: string; + timeoutMs?: number; + // Max depth for quoted tweets (0 disables). Defaults to 1. + quoteDepth?: number; +} + +export interface CreateTweetResponse { + data?: { + create_tweet?: { + tweet_results?: { + result?: { + rest_id?: string; + legacy?: { + full_text?: string; + }; + }; + }; + }; + }; + errors?: Array<{ message: string; code?: number }>; +} diff --git a/src/lib/twitter-client-users.ts b/src/lib/twitter-client-users.ts new file mode 100644 index 0000000..c0568ab --- /dev/null +++ b/src/lib/twitter-client-users.ts @@ -0,0 +1,330 @@ +import { + SETTINGS_NAME_REGEX, + SETTINGS_SCREEN_NAME_REGEX, + SETTINGS_USER_ID_REGEX, + TWITTER_API_BASE, +} from './twitter-client-constants.js'; +import { buildFollowingFeatures } from './twitter-client-features.js'; +import { type AbstractConstructor, type TwitterClientBase } from './twitter-client-base.js'; +import type { CurrentUserResult, FollowingResult } from './twitter-client-types.js'; +import { parseUsersFromInstructions } from './twitter-client-utils.js'; + +export function withUsers>(Base: TBase) { + return class extends Base { + private async getFollowingQueryIds(): Promise { + const primary = await this.getQueryId('Following'); + return Array.from(new Set([primary, 'BEkNpEt5pNETESoqMsTEGA'])); + } + + private async getFollowersQueryIds(): Promise { + const primary = await this.getQueryId('Followers'); + return Array.from(new Set([primary, 'kuFUYP9eV1FPoEy4N-pi7w'])); + } + + /** + * Fetch the account associated with the current cookies + */ + async getCurrentUser(): Promise { + const candidateUrls = [ + 'https://x.com/i/api/account/settings.json', + 'https://api.twitter.com/1.1/account/settings.json', + 'https://x.com/i/api/account/verify_credentials.json?skip_status=true&include_entities=false', + 'https://api.twitter.com/1.1/account/verify_credentials.json?skip_status=true&include_entities=false', + ]; + + let lastError: string | undefined; + + for (const url of candidateUrls) { + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + const text = await response.text(); + lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; + continue; + } + + // biome-ignore lint/suspicious/noExplicitAny: Twitter API response is dynamic here + let data: any; + try { + data = await response.json(); + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + continue; + } + + const username = + typeof data?.screen_name === 'string' + ? data.screen_name + : typeof data?.user?.screen_name === 'string' + ? data.user.screen_name + : null; + + const name = + typeof data?.name === 'string' + ? data.name + : typeof data?.user?.name === 'string' + ? data.user.name + : (username ?? ''); + + const userId = + typeof data?.user_id === 'string' + ? data.user_id + : typeof data?.user_id_str === 'string' + ? data.user_id_str + : typeof data?.user?.id_str === 'string' + ? data.user.id_str + : typeof data?.user?.id === 'string' + ? data.user.id + : null; + + if (username && userId) { + this.clientUserId = userId; + return { + success: true, + user: { + id: userId, + username, + name: name || username, + }, + }; + } + + lastError = 'Could not determine current user from response'; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + // Fallback: scrape the authenticated settings page (HTML) for screen_name/user_id + const profilePages = ['https://x.com/settings/account', 'https://twitter.com/settings/account']; + for (const page of profilePages) { + try { + const response = await this.fetchWithTimeout(page, { + headers: { + cookie: this.cookieHeader, + 'user-agent': this.userAgent, + }, + }); + + if (!response.ok) { + lastError = `HTTP ${response.status} (settings page)`; + continue; + } + + const html = await response.text(); + const usernameMatch = SETTINGS_SCREEN_NAME_REGEX.exec(html); + const idMatch = SETTINGS_USER_ID_REGEX.exec(html); + const nameMatch = SETTINGS_NAME_REGEX.exec(html); + + const username = usernameMatch?.[1]; + const userId = idMatch?.[1]; + const name = nameMatch?.[1]?.replace(/\\"/g, '"'); + + if (username && userId) { + return { + success: true, + user: { + id: userId, + username, + name: name || username, + }, + }; + } + + lastError = 'Could not parse settings page for user info'; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { + success: false, + error: lastError ?? 'Unknown error fetching current user', + }; + } + + /** + * Get users that a user is following + */ + async getFollowing(userId: string, count = 20): Promise { + const variables = { + userId, + count, + includePromotedContent: false, + }; + + const features = buildFollowingFeatures(); + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getFollowingQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/Following?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + user?: { + result?: { + timeline?: { + timeline?: { + instructions?: Array<{ type?: string; entries?: Array }>; + }; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; + const users = parseUsersFromInstructions(instructions); + + return { success: true as const, users, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching following', had404 }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, users: firstAttempt.users }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, users: secondAttempt.users }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } + + /** + * Get users that follow a user + */ + async getFollowers(userId: string, count = 20): Promise { + const variables = { + userId, + count, + includePromotedContent: false, + }; + + const features = buildFollowingFeatures(); + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getFollowersQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/Followers?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + user?: { + result?: { + timeline?: { + timeline?: { + instructions?: Array<{ type?: string; entries?: Array }>; + }; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; + const users = parseUsersFromInstructions(instructions); + + return { success: true as const, users, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching followers', had404 }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, users: firstAttempt.users }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, users: secondAttempt.users }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } + }; +} diff --git a/src/lib/twitter-client-utils.ts b/src/lib/twitter-client-utils.ts new file mode 100644 index 0000000..4654538 --- /dev/null +++ b/src/lib/twitter-client-utils.ts @@ -0,0 +1,455 @@ +import type { GraphqlTweetResult, TweetData, TwitterUser } from './twitter-client-types.js'; + +export function normalizeQuoteDepth(value?: number): number { + if (value === undefined || value === null) { + return 1; + } + if (!Number.isFinite(value)) { + return 1; + } + return Math.max(0, Math.floor(value)); +} + +export function firstText(...values: Array): string | undefined { + for (const value of values) { + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed) { + return trimmed; + } + } + } + return undefined; +} + +export function collectTextFields(value: unknown, keys: Set, output: string[]): void { + if (!value) { + return; + } + if (typeof value === 'string') { + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + collectTextFields(item, keys, output); + } + return; + } + + if (typeof value === 'object') { + for (const [key, nested] of Object.entries(value as Record)) { + if (keys.has(key)) { + if (typeof nested === 'string') { + const trimmed = nested.trim(); + if (trimmed) { + output.push(trimmed); + } + continue; + } + } + collectTextFields(nested, keys, output); + } + } +} + +export function uniqueOrdered(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + if (seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + return result; +} + +export function extractArticleText(result: GraphqlTweetResult | undefined): string | undefined { + const article = result?.article; + if (!article) { + return undefined; + } + + const articleResult = article.article_results?.result ?? article; + if (process.env.BIRD_DEBUG_ARTICLE === '1') { + console.error( + '[bird][debug][article] payload:', + JSON.stringify( + { + rest_id: result?.rest_id, + article: articleResult, + note_tweet: result?.note_tweet?.note_tweet_results?.result ?? null, + }, + null, + 2, + ), + ); + } + const title = firstText(articleResult.title, article.title); + let body = firstText( + articleResult.plain_text, + article.plain_text, + articleResult.body?.text, + articleResult.body?.richtext?.text, + articleResult.body?.rich_text?.text, + articleResult.content?.text, + articleResult.content?.richtext?.text, + articleResult.content?.rich_text?.text, + articleResult.text, + articleResult.richtext?.text, + articleResult.rich_text?.text, + article.body?.text, + article.body?.richtext?.text, + article.body?.rich_text?.text, + article.content?.text, + article.content?.richtext?.text, + article.content?.rich_text?.text, + article.text, + article.richtext?.text, + article.rich_text?.text, + ); + + if (body && title && body.trim() === title.trim()) { + body = undefined; + } + + if (!body) { + const collected: string[] = []; + collectTextFields(articleResult, new Set(['text', 'title']), collected); + collectTextFields(article, new Set(['text', 'title']), collected); + const unique = uniqueOrdered(collected); + const filtered = title ? unique.filter((value) => value !== title) : unique; + if (filtered.length > 0) { + body = filtered.join('\n\n'); + } + } + + if (title && body && !body.startsWith(title)) { + return `${title}\n\n${body}`; + } + + return body ?? title; +} + +export function extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined { + const note = result?.note_tweet?.note_tweet_results?.result; + if (!note) { + return undefined; + } + + return firstText( + note.text, + note.richtext?.text, + note.rich_text?.text, + note.content?.text, + note.content?.richtext?.text, + note.content?.rich_text?.text, + ); +} + +export function extractTweetText(result: GraphqlTweetResult | undefined): string | undefined { + return extractArticleText(result) ?? extractNoteTweetText(result) ?? firstText(result?.legacy?.full_text); +} + +export function unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined { + if (!result) { + return undefined; + } + if (result.tweet) { + return result.tweet; + } + return result; +} + +export function mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth: number): TweetData | undefined { + const userResult = result?.core?.user_results?.result; + const userLegacy = userResult?.legacy; + const userCore = userResult?.core; + const username = userLegacy?.screen_name ?? userCore?.screen_name; + const name = userLegacy?.name ?? userCore?.name ?? username; + const userId = userResult?.rest_id; + if (!result?.rest_id || !username) { + return undefined; + } + + const text = extractTweetText(result); + if (!text) { + return undefined; + } + + let quotedTweet: TweetData | undefined; + if (quoteDepth > 0) { + const quotedResult = unwrapTweetResult(result.quoted_status_result?.result); + if (quotedResult) { + quotedTweet = mapTweetResult(quotedResult, quoteDepth - 1); + } + } + + return { + id: result.rest_id, + text, + createdAt: result.legacy?.created_at, + replyCount: result.legacy?.reply_count, + retweetCount: result.legacy?.retweet_count, + likeCount: result.legacy?.favorite_count, + conversationId: result.legacy?.conversation_id_str, + inReplyToStatusId: result.legacy?.in_reply_to_status_id_str ?? undefined, + author: { + username, + name: name || username, + }, + authorId: userId, + quotedTweet, + }; +} + +export function findTweetInInstructions( + instructions: + | Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }> + | undefined, + tweetId: string, +) { + if (!instructions) { + return undefined; + } + + for (const instruction of instructions) { + for (const entry of instruction.entries || []) { + const result = entry.content?.itemContent?.tweet_results?.result; + if (result?.rest_id === tweetId) { + return result; + } + } + } + + return undefined; +} + +export function collectTweetResultsFromEntry(entry: { + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + item?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + items?: Array<{ + item?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }; +}): GraphqlTweetResult[] { + const results: GraphqlTweetResult[] = []; + const pushResult = (result?: GraphqlTweetResult) => { + if (result?.rest_id) { + results.push(result); + } + }; + + const content = entry.content; + pushResult(content?.itemContent?.tweet_results?.result); + pushResult(content?.item?.itemContent?.tweet_results?.result); + + for (const item of content?.items ?? []) { + pushResult(item?.item?.itemContent?.tweet_results?.result); + pushResult(item?.itemContent?.tweet_results?.result); + pushResult(item?.content?.itemContent?.tweet_results?.result); + } + + return results; +} + +export function parseTweetsFromInstructions( + instructions: + | Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + item?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + items?: Array<{ + item?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }; + }>; + }> + | undefined, + quoteDepth: number, +): TweetData[] { + const tweets: TweetData[] = []; + const seen = new Set(); + + for (const instruction of instructions ?? []) { + for (const entry of instruction.entries ?? []) { + const results = collectTweetResultsFromEntry(entry); + for (const result of results) { + const mapped = mapTweetResult(result, quoteDepth); + if (!mapped || seen.has(mapped.id)) { + continue; + } + seen.add(mapped.id); + tweets.push(mapped); + } + } + } + + return tweets; +} + +export function extractCursorFromInstructions( + instructions: + | Array<{ + entries?: Array<{ + content?: unknown; + }>; + }> + | undefined, + cursorType = 'Bottom', +): string | undefined { + for (const instruction of instructions ?? []) { + for (const entry of instruction.entries ?? []) { + const content = entry.content as { cursorType?: unknown; value?: unknown } | undefined; + if (content?.cursorType === cursorType && typeof content.value === 'string' && content.value.length > 0) { + return content.value; + } + } + } + return undefined; +} + +export function parseUsersFromInstructions( + instructions: Array<{ type?: string; entries?: Array }> | undefined, +): TwitterUser[] { + if (!instructions) { + return []; + } + + const users: TwitterUser[] = []; + + for (const instruction of instructions) { + if (!instruction.entries) { + continue; + } + + for (const entry of instruction.entries) { + const content = (entry as { content?: { itemContent?: { user_results?: { result?: unknown } } } })?.content; + const rawUserResult = content?.itemContent?.user_results?.result as + | { + __typename?: string; + rest_id?: string; + is_blue_verified?: boolean; + user?: unknown; + legacy?: { + screen_name?: string; + name?: string; + description?: string; + followers_count?: number; + friends_count?: number; + profile_image_url_https?: string; + created_at?: string; + }; + core?: { + screen_name?: string; + name?: string; + created_at?: string; + }; + avatar?: { + image_url?: string; + }; + } + | undefined; + + const userResult = + rawUserResult?.__typename === 'UserWithVisibilityResults' && rawUserResult.user + ? (rawUserResult.user as typeof rawUserResult) + : rawUserResult; + + if (!userResult || userResult.__typename !== 'User') { + continue; + } + + const legacy = userResult.legacy; + const core = userResult.core; + const username = legacy?.screen_name ?? core?.screen_name; + if (!userResult.rest_id || !username) { + continue; + } + + users.push({ + id: userResult.rest_id, + username, + name: legacy?.name ?? core?.name ?? username, + description: legacy?.description, + followersCount: legacy?.followers_count, + followingCount: legacy?.friends_count, + isBlueVerified: userResult.is_blue_verified, + profileImageUrl: legacy?.profile_image_url_https ?? userResult.avatar?.image_url, + createdAt: legacy?.created_at ?? core?.created_at, + }); + } + } + + return users; +} diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 8134ec1..b38edca 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -1,2644 +1,23 @@ -/** - * Twitter GraphQL API client for posting tweets and replies - */ - -import { randomBytes, randomUUID } from 'node:crypto'; -import type { TwitterCookies } from './cookies.js'; -// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension. -import queryIds from './query-ids.json' with { type: 'json' }; -import { runtimeQueryIds } from './runtime-query-ids.js'; - -const TWITTER_API_BASE = 'https://x.com/i/api/graphql'; -const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql'; -const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json'; -const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json'; -const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json'; -const SETTINGS_SCREEN_NAME_REGEX = /"screen_name":"([^"]+)"/; -const SETTINGS_USER_ID_REGEX = /"user_id"\s*:\s*"(\d+)"/; -const SETTINGS_NAME_REGEX = /"name":"([^"\\]*(?:\\.[^"\\]*)*)"/; - -// Query IDs rotate frequently; the values in query-ids.json are refreshed by -// scripts/update-query-ids.ts. The fallback values keep the client usable if -// the file is missing or incomplete. -const FALLBACK_QUERY_IDS = { - CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg', - CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', - FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', - TweetDetail: '97JF30KziU00483E_8elBA', - SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', - UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', - Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw', - Following: 'BEkNpEt5pNETESoqMsTEGA', - Followers: 'kuFUYP9eV1FPoEy4N-pi7w', - Likes: 'JR2gceKucIKcVNB_9JkhsA', - BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ', -} as const; - -type OperationName = keyof typeof FALLBACK_QUERY_IDS; - -const QUERY_IDS: Record = { - ...FALLBACK_QUERY_IDS, - ...(queryIds as Partial>), -}; - -const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array; - -function normalizeQuoteDepth(value?: number): number { - if (value === undefined || value === null) { - return 1; - } - if (!Number.isFinite(value)) { - return 1; - } - return Math.max(0, Math.floor(value)); -} - -type GraphqlTweetResult = { - __typename?: string; - rest_id?: string; - legacy?: { - full_text?: string; - created_at?: string; - reply_count?: number; - retweet_count?: number; - favorite_count?: number; - conversation_id_str?: string; - in_reply_to_status_id_str?: string | null; - }; - core?: { - user_results?: { - result?: { - rest_id?: string; - id?: string; - legacy?: { - screen_name?: string; - name?: string; - }; - core?: { - screen_name?: string; - name?: string; - }; - }; - }; - }; - note_tweet?: { - note_tweet_results?: { - result?: { - text?: string; - richtext?: { - text?: string; - }; - rich_text?: { - text?: string; - }; - content?: { - text?: string; - richtext?: { - text?: string; - }; - rich_text?: { - text?: string; - }; - }; - }; - }; - }; - article?: { - article_results?: { - result?: { - title?: string; - plain_text?: string; - text?: string; - richtext?: { - text?: string; - }; - rich_text?: { - text?: string; - }; - body?: { - text?: string; - richtext?: { - text?: string; - }; - rich_text?: { - text?: string; - }; - }; - content?: { - text?: string; - richtext?: { - text?: string; - }; - rich_text?: { - text?: string; - }; - items?: Array<{ - text?: string; - content?: { - text?: string; - richtext?: { text?: string }; - rich_text?: { text?: string }; - }; - }>; - }; - sections?: Array<{ - items?: Array<{ - text?: string; - content?: { - text?: string; - richtext?: { text?: string }; - rich_text?: { text?: string }; - }; - }>; - }>; - }; - }; - title?: string; - plain_text?: string; - text?: string; - richtext?: { - text?: string; - }; - rich_text?: { - text?: string; - }; - body?: { - text?: string; - richtext?: { - text?: string; - }; - rich_text?: { - text?: string; - }; - }; - content?: { - text?: string; - richtext?: { - text?: string; - }; - rich_text?: { - text?: string; - }; - items?: Array<{ - text?: string; - content?: { - text?: string; - richtext?: { text?: string }; - rich_text?: { text?: string }; - }; - }>; - }; - sections?: Array<{ - items?: Array<{ - text?: string; - content?: { - text?: string; - richtext?: { text?: string }; - rich_text?: { text?: string }; - }; - }>; - }>; - }; - tweet?: GraphqlTweetResult; - quoted_status_result?: { - result?: GraphqlTweetResult; - }; -}; - -export type TweetResult = - | { - success: true; - tweetId: string; - } - | { - success: false; - error: string; - }; - -export interface UploadMediaResult { - success: boolean; - mediaId?: string; - error?: string; -} - -export interface TweetData { - id: string; - text: string; - author: { - username: string; - name: string; - }; - authorId?: string; - createdAt?: string; - replyCount?: number; - retweetCount?: number; - likeCount?: number; - conversationId?: string; - inReplyToStatusId?: string; - // Optional quoted tweet; depth controlled by quoteDepth (default: 1). - quotedTweet?: TweetData; -} - -export interface GetTweetResult { - success: boolean; - tweet?: TweetData; - error?: string; -} - -export interface SearchResult { - success: boolean; - tweets?: TweetData[]; - error?: string; -} - -export interface CurrentUserResult { - success: boolean; - user?: { - id: string; - username: string; - name: string; - }; - error?: string; -} - -export interface TwitterUser { - id: string; - username: string; - name: string; - description?: string; - followersCount?: number; - followingCount?: number; - isBlueVerified?: boolean; - profileImageUrl?: string; - createdAt?: string; -} - -export interface FollowingResult { - success: boolean; - users?: TwitterUser[]; - error?: string; -} - -export interface TwitterClientOptions { - cookies: TwitterCookies; - userAgent?: string; - timeoutMs?: number; - // Max depth for quoted tweets (0 disables). Defaults to 1. - quoteDepth?: number; -} - -interface CreateTweetResponse { - data?: { - create_tweet?: { - tweet_results?: { - result?: { - rest_id?: string; - legacy?: { - full_text?: string; - }; - }; - }; - }; - }; - errors?: Array<{ message: string; code?: number }>; -} - -export class TwitterClient { - private authToken: string; - private ct0: string; - private cookieHeader: string; - private userAgent: string; - private timeoutMs?: number; - private quoteDepth: number; - private clientUuid: string; - private clientDeviceId: string; - private clientUserId?: string; - - constructor(options: TwitterClientOptions) { - if (!options.cookies.authToken || !options.cookies.ct0) { - throw new Error('Both authToken and ct0 cookies are required'); - } - this.authToken = options.cookies.authToken; - this.ct0 = options.cookies.ct0; - this.cookieHeader = options.cookies.cookieHeader || `auth_token=${this.authToken}; ct0=${this.ct0}`; - this.userAgent = - options.userAgent || - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; - this.timeoutMs = options.timeoutMs; - this.quoteDepth = normalizeQuoteDepth(options.quoteDepth); - this.clientUuid = randomUUID(); - this.clientDeviceId = randomUUID(); - } - - private async getQueryId(operationName: OperationName): Promise { - const cached = await runtimeQueryIds.getQueryId(operationName); - return cached ?? QUERY_IDS[operationName]; - } - - private async refreshQueryIds(): Promise { - if (process.env.NODE_ENV === 'test') { - return; - } - try { - await runtimeQueryIds.refresh(TARGET_QUERY_ID_OPERATIONS, { force: true }); - } catch { - // ignore refresh failures; callers will fall back to baked-in IDs - } - } - - private async getTweetDetailQueryIds(): Promise { - const primary = await this.getQueryId('TweetDetail'); - return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g'])); - } - - private async getSearchTimelineQueryIds(): Promise { - const primary = await this.getQueryId('SearchTimeline'); - return Array.from(new Set([primary, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ'])); - } - - private async fetchWithTimeout(url: string, init: RequestInit): Promise { - if (!this.timeoutMs || this.timeoutMs <= 0) { - return fetch(url, init); - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); - try { - return await fetch(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timeoutId); - } - } - - private findTweetInInstructions( - instructions: - | Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - }>; - }> - | undefined, - tweetId: string, - ) { - if (!instructions) { - return undefined; - } - - for (const instruction of instructions) { - for (const entry of instruction.entries || []) { - const result = entry.content?.itemContent?.tweet_results?.result; - if (result?.rest_id === tweetId) { - return result; - } - } - } - - return undefined; - } - - private getHeaders(): Record { - return this.getJsonHeaders(); - } - - private createTransactionId(): string { - return randomBytes(16).toString('hex'); - } - - private getBaseHeaders(): Record { - const headers: Record = { - accept: '*/*', - 'accept-language': 'en-US,en;q=0.9', - authorization: - 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA', - 'x-csrf-token': this.ct0, - 'x-twitter-auth-type': 'OAuth2Session', - 'x-twitter-active-user': 'yes', - 'x-twitter-client-language': 'en', - 'x-client-uuid': this.clientUuid, - 'x-twitter-client-deviceid': this.clientDeviceId, - 'x-client-transaction-id': this.createTransactionId(), - cookie: this.cookieHeader, - 'user-agent': this.userAgent, - origin: 'https://x.com', - referer: 'https://x.com/', - }; - - if (this.clientUserId) { - headers['x-twitter-client-user-id'] = this.clientUserId; - } - - return headers; - } - - private getJsonHeaders(): Record { - return { - ...this.getBaseHeaders(), - 'content-type': 'application/json', - }; - } - - private getUploadHeaders(): Record { - // Note: do not set content-type; URLSearchParams/FormData need to set it (incl boundary) themselves. - return this.getBaseHeaders(); - } - - private mediaCategoryForMime(mimeType: string): string | null { - if (mimeType.startsWith('image/')) { - if (mimeType === 'image/gif') { - return 'tweet_gif'; - } - return 'tweet_image'; - } - if (mimeType.startsWith('video/')) { - return 'tweet_video'; - } - return null; - } - - private async sleep(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); - } - - async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise { - const category = this.mediaCategoryForMime(input.mimeType); - if (!category) { - return { success: false, error: `Unsupported media type: ${input.mimeType}` }; - } - - try { - const initParams = new URLSearchParams({ - command: 'INIT', - total_bytes: String(input.data.byteLength), - media_type: input.mimeType, - media_category: category, - }); - - const initResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, { - method: 'POST', - headers: this.getUploadHeaders(), - body: initParams, - }); - - if (!initResp.ok) { - const text = await initResp.text(); - return { success: false, error: `HTTP ${initResp.status}: ${text.slice(0, 200)}` }; - } - - const initBody = (await initResp.json()) as { media_id_string?: string; media_id?: string | number }; - const mediaId = - typeof initBody.media_id_string === 'string' - ? initBody.media_id_string - : initBody.media_id !== undefined - ? String(initBody.media_id) - : undefined; - if (!mediaId) { - return { success: false, error: 'Media upload INIT did not return media_id' }; - } - - const chunkSize = 5 * 1024 * 1024; - let segmentIndex = 0; - for (let offset = 0; offset < input.data.byteLength; offset += chunkSize) { - const chunk = input.data.slice(offset, Math.min(input.data.byteLength, offset + chunkSize)); - const form = new FormData(); - form.set('command', 'APPEND'); - form.set('media_id', mediaId); - form.set('segment_index', String(segmentIndex)); - form.set('media', new Blob([chunk], { type: input.mimeType }), 'media'); - - const appendResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, { - method: 'POST', - headers: this.getUploadHeaders(), - body: form, - }); - - if (!appendResp.ok) { - const text = await appendResp.text(); - return { success: false, error: `HTTP ${appendResp.status}: ${text.slice(0, 200)}` }; - } - segmentIndex += 1; - } - - const finalizeParams = new URLSearchParams({ command: 'FINALIZE', media_id: mediaId }); - const finalizeResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, { - method: 'POST', - headers: this.getUploadHeaders(), - body: finalizeParams, - }); - - if (!finalizeResp.ok) { - const text = await finalizeResp.text(); - return { success: false, error: `HTTP ${finalizeResp.status}: ${text.slice(0, 200)}` }; - } - - const finalizeBody = (await finalizeResp.json()) as { - processing_info?: { - state?: string; - check_after_secs?: number; - error?: { message?: string; name?: string }; - }; - }; - - const info = finalizeBody.processing_info; - if (info?.state && info.state !== 'succeeded') { - let attempts = 0; - while (attempts < 20) { - if (info.state === 'failed') { - const msg = info.error?.message || info.error?.name || 'Media processing failed'; - return { success: false, error: msg }; - } - const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2; - await this.sleep(delaySecs * 1000); - - const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({ - command: 'STATUS', - media_id: mediaId, - }).toString()}`; - const statusResp = await this.fetchWithTimeout(statusUrl, { - method: 'GET', - headers: this.getUploadHeaders(), - }); - - if (!statusResp.ok) { - const text = await statusResp.text(); - return { success: false, error: `HTTP ${statusResp.status}: ${text.slice(0, 200)}` }; - } - - const statusBody = (await statusResp.json()) as { - processing_info?: { - state?: string; - check_after_secs?: number; - error?: { message?: string; name?: string }; - }; - }; - if (!statusBody.processing_info) { - break; - } - info.state = statusBody.processing_info.state; - info.check_after_secs = statusBody.processing_info.check_after_secs; - info.error = statusBody.processing_info.error; - if (info.state === 'succeeded') { - break; - } - attempts += 1; - } - } - - if (input.alt && input.mimeType.startsWith('image/')) { - const metaResp = await this.fetchWithTimeout(TWITTER_MEDIA_METADATA_URL, { - method: 'POST', - headers: this.getJsonHeaders(), - body: JSON.stringify({ media_id: mediaId, alt_text: { text: input.alt } }), - }); - if (!metaResp.ok) { - const text = await metaResp.text(); - return { success: false, error: `HTTP ${metaResp.status}: ${text.slice(0, 200)}` }; - } - } - - return { success: true, mediaId }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - } - - private firstText(...values: Array): string | undefined { - for (const value of values) { - if (typeof value === 'string') { - const trimmed = value.trim(); - if (trimmed) { - return trimmed; - } - } - } - return undefined; - } - - private collectTextFields(value: unknown, keys: Set, output: string[]): void { - if (!value) { - return; - } - if (typeof value === 'string') { - return; - } - - if (Array.isArray(value)) { - for (const item of value) { - this.collectTextFields(item, keys, output); - } - return; - } - - if (typeof value === 'object') { - for (const [key, nested] of Object.entries(value as Record)) { - if (keys.has(key)) { - if (typeof nested === 'string') { - const trimmed = nested.trim(); - if (trimmed) { - output.push(trimmed); - } - continue; - } - } - this.collectTextFields(nested, keys, output); - } - } - } - - private uniqueOrdered(values: string[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const value of values) { - if (seen.has(value)) { - continue; - } - seen.add(value); - result.push(value); - } - return result; - } - - private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined { - const article = result?.article; - if (!article) { - return undefined; - } - - const articleResult = article.article_results?.result ?? article; - if (process.env.BIRD_DEBUG_ARTICLE === '1') { - console.error( - '[bird][debug][article] payload:', - JSON.stringify( - { - rest_id: result?.rest_id, - article: articleResult, - note_tweet: result?.note_tweet?.note_tweet_results?.result ?? null, - }, - null, - 2, - ), - ); - } - const title = this.firstText(articleResult.title, article.title); - let body = this.firstText( - articleResult.plain_text, - article.plain_text, - articleResult.body?.text, - articleResult.body?.richtext?.text, - articleResult.body?.rich_text?.text, - articleResult.content?.text, - articleResult.content?.richtext?.text, - articleResult.content?.rich_text?.text, - articleResult.text, - articleResult.richtext?.text, - articleResult.rich_text?.text, - article.body?.text, - article.body?.richtext?.text, - article.body?.rich_text?.text, - article.content?.text, - article.content?.richtext?.text, - article.content?.rich_text?.text, - article.text, - article.richtext?.text, - article.rich_text?.text, - ); - - if (body && title && body.trim() === title.trim()) { - body = undefined; - } - - if (!body) { - const collected: string[] = []; - this.collectTextFields(articleResult, new Set(['text', 'title']), collected); - this.collectTextFields(article, new Set(['text', 'title']), collected); - const unique = this.uniqueOrdered(collected); - const filtered = title ? unique.filter((value) => value !== title) : unique; - if (filtered.length > 0) { - body = filtered.join('\n\n'); - } - } - - if (title && body && !body.startsWith(title)) { - return `${title}\n\n${body}`; - } - - return body ?? title; - } - - private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined { - const note = result?.note_tweet?.note_tweet_results?.result; - if (!note) { - return undefined; - } - - return this.firstText( - note.text, - note.richtext?.text, - note.rich_text?.text, - note.content?.text, - note.content?.richtext?.text, - note.content?.rich_text?.text, - ); - } - - private extractTweetText(result: GraphqlTweetResult | undefined): string | undefined { - return ( - this.extractArticleText(result) ?? this.extractNoteTweetText(result) ?? this.firstText(result?.legacy?.full_text) - ); - } - - private unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined { - if (!result) { - return undefined; - } - if (result.tweet) { - return result.tweet; - } - return result; - } - - private mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth = this.quoteDepth): TweetData | undefined { - const userResult = result?.core?.user_results?.result; - const userLegacy = userResult?.legacy; - const userCore = userResult?.core; - const username = userLegacy?.screen_name ?? userCore?.screen_name; - const name = userLegacy?.name ?? userCore?.name ?? username; - const userId = userResult?.rest_id; - if (!result?.rest_id || !username) { - return undefined; - } - - const text = this.extractTweetText(result); - if (!text) { - return undefined; - } - - let quotedTweet: TweetData | undefined; - if (quoteDepth > 0) { - const quotedResult = this.unwrapTweetResult(result.quoted_status_result?.result); - if (quotedResult) { - quotedTweet = this.mapTweetResult(quotedResult, quoteDepth - 1); - } - } - - return { - id: result.rest_id, - text, - createdAt: result.legacy?.created_at, - replyCount: result.legacy?.reply_count, - retweetCount: result.legacy?.retweet_count, - likeCount: result.legacy?.favorite_count, - conversationId: result.legacy?.conversation_id_str, - inReplyToStatusId: result.legacy?.in_reply_to_status_id_str ?? undefined, - author: { - username, - name: name || username, - }, - authorId: userId, - quotedTweet, - }; - } - - private collectTweetResultsFromEntry(entry: { - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - item?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - items?: Array<{ - item?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - }>; - }; - }): GraphqlTweetResult[] { - const results: GraphqlTweetResult[] = []; - const pushResult = (result?: GraphqlTweetResult) => { - if (result?.rest_id) { - results.push(result); - } - }; - - const content = entry.content; - pushResult(content?.itemContent?.tweet_results?.result); - pushResult(content?.item?.itemContent?.tweet_results?.result); - - for (const item of content?.items ?? []) { - pushResult(item?.item?.itemContent?.tweet_results?.result); - pushResult(item?.itemContent?.tweet_results?.result); - pushResult(item?.content?.itemContent?.tweet_results?.result); - } - - return results; - } - - private parseTweetsFromInstructions( - instructions: - | Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - item?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - items?: Array<{ - item?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - }>; - }; - }>; - }> - | undefined, - ): TweetData[] { - const tweets: TweetData[] = []; - const seen = new Set(); - - for (const instruction of instructions ?? []) { - for (const entry of instruction.entries ?? []) { - const results = this.collectTweetResultsFromEntry(entry); - for (const result of results) { - const mapped = this.mapTweetResult(result); - if (!mapped || seen.has(mapped.id)) { - continue; - } - seen.add(mapped.id); - tweets.push(mapped); - } - } - } - - return tweets; - } - - private extractCursorFromInstructions( - instructions: - | Array<{ - entries?: Array<{ - content?: unknown; - }>; - }> - | undefined, - cursorType = 'Bottom', - ): string | undefined { - for (const instruction of instructions ?? []) { - for (const entry of instruction.entries ?? []) { - const content = entry.content as { cursorType?: unknown; value?: unknown } | undefined; - if (content?.cursorType === cursorType && typeof content.value === 'string' && content.value.length > 0) { - return content.value; - } - } - } - return undefined; - } - - private buildArticleFeatures(): Record { - return { - rweb_video_screen_enabled: true, - profile_label_improvements_pcf_label_in_post_enabled: true, - responsive_web_profile_redirect_enabled: true, - rweb_tipjar_consumption_enabled: true, - verified_phone_label_enabled: false, - creator_subscriptions_tweet_preview_api_enabled: true, - responsive_web_graphql_timeline_navigation_enabled: true, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - premium_content_api_read_enabled: false, - communities_web_enable_tweet_community_results_fetch: true, - c9s_tweet_anatomy_moderator_badge_enabled: true, - responsive_web_grok_analyze_button_fetch_trends_enabled: false, - responsive_web_grok_analyze_post_followups_enabled: false, - responsive_web_jetfuel_frame: true, - responsive_web_grok_share_attachment_enabled: true, - articles_preview_enabled: true, - responsive_web_edit_tweet_api_enabled: true, - graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, - view_counts_everywhere_api_enabled: true, - longform_notetweets_consumption_enabled: true, - responsive_web_twitter_article_tweet_consumption_enabled: true, - tweet_awards_web_tipping_enabled: false, - responsive_web_grok_show_grok_translated_post: false, - responsive_web_grok_analysis_button_from_backend: true, - creator_subscriptions_quote_tweet_preview_enabled: false, - freedom_of_speech_not_reach_fetch_enabled: true, - standardized_nudges_misinfo: true, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, - longform_notetweets_rich_text_read_enabled: true, - longform_notetweets_inline_media_enabled: true, - responsive_web_grok_image_annotation_enabled: true, - responsive_web_grok_imagine_annotation_enabled: true, - responsive_web_grok_community_note_auto_translation_is_enabled: false, - responsive_web_enhance_cards_enabled: false, - }; - } - - private buildTweetDetailFeatures(): Record { - return { - ...this.buildArticleFeatures(), - responsive_web_graphql_exclude_directive_enabled: true, - communities_web_enable_tweet_community_results_fetch: true, - responsive_web_twitter_article_plain_text_enabled: true, - responsive_web_twitter_article_seed_tweet_detail_enabled: true, - responsive_web_twitter_article_seed_tweet_summary_enabled: true, - longform_notetweets_rich_text_read_enabled: true, - longform_notetweets_inline_media_enabled: true, - responsive_web_edit_tweet_api_enabled: true, - tweet_awards_web_tipping_enabled: false, - creator_subscriptions_quote_tweet_preview_enabled: false, - verified_phone_label_enabled: false, - }; - } - - private buildArticleFieldToggles(): Record { - return { - withPayments: false, - withAuxiliaryUserLabels: false, - withArticleRichContentState: true, - withArticlePlainText: true, - withGrokAnalyze: false, - withDisallowedReplyControls: false, - }; - } - - private buildSearchFeatures(): Record { - return { - rweb_video_screen_enabled: true, - profile_label_improvements_pcf_label_in_post_enabled: true, - responsive_web_profile_redirect_enabled: true, - rweb_tipjar_consumption_enabled: true, - verified_phone_label_enabled: false, - creator_subscriptions_tweet_preview_api_enabled: true, - responsive_web_graphql_timeline_navigation_enabled: true, - responsive_web_graphql_exclude_directive_enabled: true, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - premium_content_api_read_enabled: false, - communities_web_enable_tweet_community_results_fetch: true, - c9s_tweet_anatomy_moderator_badge_enabled: true, - responsive_web_grok_analyze_button_fetch_trends_enabled: false, - responsive_web_grok_analyze_post_followups_enabled: false, - responsive_web_jetfuel_frame: true, - responsive_web_grok_share_attachment_enabled: true, - responsive_web_edit_tweet_api_enabled: true, - graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, - view_counts_everywhere_api_enabled: true, - longform_notetweets_consumption_enabled: true, - responsive_web_twitter_article_tweet_consumption_enabled: true, - tweet_awards_web_tipping_enabled: false, - responsive_web_grok_show_grok_translated_post: false, - responsive_web_grok_analysis_button_from_backend: true, - creator_subscriptions_quote_tweet_preview_enabled: false, - freedom_of_speech_not_reach_fetch_enabled: true, - standardized_nudges_misinfo: true, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, - rweb_video_timestamps_enabled: true, - longform_notetweets_rich_text_read_enabled: true, - longform_notetweets_inline_media_enabled: true, - responsive_web_grok_image_annotation_enabled: true, - responsive_web_grok_imagine_annotation_enabled: true, - responsive_web_grok_community_note_auto_translation_is_enabled: false, - articles_preview_enabled: true, - responsive_web_enhance_cards_enabled: false, - }; - } - - private async fetchUserArticlePlainText( - userId: string, - tweetId: string, - ): Promise<{ title?: string; plainText?: string }> { - const variables = { - userId, - count: 20, - includePromotedContent: true, - withVoice: true, - withQuickPromoteEligibilityTweetFields: true, - withBirdwatchNotes: true, - withCommunity: true, - withSafetyModeUserFields: true, - withSuperFollowsUserFields: true, - withDownvotePerspective: false, - withReactionsMetadata: false, - withReactionsPerspective: false, - withSuperFollowsTweetFields: true, - withSuperFollowsReplyCount: false, - withClientEventToken: false, - }; - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(this.buildArticleFeatures()), - fieldToggles: JSON.stringify(this.buildArticleFieldToggles()), - }); - - const queryId = await this.getQueryId('UserArticlesTweets'); - const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params.toString()}`; - - try { - const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() }); - if (!response.ok) { - return {}; - } - - const data = (await response.json()) as { - data?: { - user?: { - result?: { - timeline?: { - timeline?: { - instructions?: Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { result?: GraphqlTweetResult }; - }; - }; - }>; - }>; - }; - }; - }; - }; - }; - }; - - const instructions = data.data?.user?.result?.timeline?.timeline?.instructions ?? []; - for (const instruction of instructions) { - for (const entry of instruction.entries ?? []) { - const result = entry.content?.itemContent?.tweet_results?.result; - if (result?.rest_id !== tweetId) { - continue; - } - const articleResult = result.article?.article_results?.result; - const title = this.firstText(articleResult?.title, result.article?.title); - const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text); - return { title, plainText }; - } - } - } catch { - return {}; - } - - return {}; - } - - private async fetchTweetDetail(tweetId: string): Promise< - | { - success: true; - data: { - tweetResult?: { result?: GraphqlTweetResult }; - threaded_conversation_with_injections_v2?: { - instructions?: Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - }>; - }>; - }; - }; - } - | { success: false; error: string } - > { - const variables = { - focalTweetId: tweetId, - with_rux_injections: false, - rankingMode: 'Relevance', - includePromotedContent: true, - withCommunity: true, - withQuickPromoteEligibilityTweetFields: true, - withBirdwatchNotes: true, - withVoice: true, - }; - - const features = { - ...this.buildTweetDetailFeatures(), - articles_preview_enabled: true, - articles_rest_api_enabled: true, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - creator_subscriptions_tweet_preview_api_enabled: true, - graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, - view_counts_everywhere_api_enabled: true, - longform_notetweets_consumption_enabled: true, - responsive_web_twitter_article_tweet_consumption_enabled: true, - freedom_of_speech_not_reach_fetch_enabled: true, - standardized_nudges_misinfo: true, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, - rweb_video_timestamps_enabled: true, - }; - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - }); - - try { - const parseResponse = async (response: Response) => { - if (!response.ok) { - const text = await response.text(); - return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; - } - - const data = (await response.json()) as { - data?: { - tweetResult?: { result?: GraphqlTweetResult }; - threaded_conversation_with_injections_v2?: { - instructions?: Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - }>; - }>; - }; - }; - errors?: Array<{ message: string; code?: number }>; - }; - - if (data.errors && data.errors.length > 0) { - return { success: false as const, error: data.errors.map((e) => e.message).join(', ') }; - } - - return { success: true as const, data: data.data ?? {} }; - }; - - let lastError: string | undefined; - let had404 = false; - - const tryOnce = async () => { - const queryIds = await this.getTweetDetailQueryIds(); - - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params.toString()}`; - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (response.status !== 404) { - return await parseResponse(response); - } - - had404 = true; - - const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify({ variables, features, queryId }), - }); - - if (postResponse.status !== 404) { - return await parseResponse(postResponse); - } - - lastError = 'HTTP 404'; - } - - return { success: false as const, error: lastError ?? 'Unknown error fetching tweet detail' }; - }; - - const firstAttempt = await tryOnce(); - if (firstAttempt.success) { - return firstAttempt; - } - - if (had404) { - await this.refreshQueryIds(); - return await tryOnce(); - } - - return firstAttempt; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - } - - /** - * Get tweet details by ID - */ - async getTweet(tweetId: string): Promise { - const response = await this.fetchTweetDetail(tweetId); - if (!response.success) { - return response; - } - - const tweetResult = - (response.data.tweetResult as { result?: GraphqlTweetResult } | undefined)?.result ?? - this.findTweetInInstructions( - response.data.threaded_conversation_with_injections_v2?.instructions as - | Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - }>; - }> - | undefined, - tweetId, - ); - - const mapped = this.mapTweetResult(tweetResult); - if (mapped) { - if (tweetResult?.article) { - const title = this.firstText(tweetResult.article.article_results?.result?.title, tweetResult.article.title); - const articleText = this.extractArticleText(tweetResult); - if (title && (!articleText || articleText.trim() === title.trim())) { - const userId = tweetResult.core?.user_results?.result?.rest_id; - if (userId) { - const fallback = await this.fetchUserArticlePlainText(userId, tweetId); - if (fallback.plainText) { - mapped.text = fallback.title ? `${fallback.title}\n\n${fallback.plainText}` : fallback.plainText; - } - } - } - } - return { success: true, tweet: mapped }; - } - return { success: false, error: 'Tweet not found in response' }; - } - - /** - * Post a new tweet - */ - async tweet(text: string, mediaIds?: string[]): Promise { - const variables = { - tweet_text: text, - dark_request: false, - media: { - media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })), - possibly_sensitive: false, - }, - semantic_annotation_ids: [], - }; - - const features = { - rweb_video_screen_enabled: true, - creator_subscriptions_tweet_preview_api_enabled: true, - premium_content_api_read_enabled: false, - communities_web_enable_tweet_community_results_fetch: true, - c9s_tweet_anatomy_moderator_badge_enabled: true, - responsive_web_grok_analyze_button_fetch_trends_enabled: false, - responsive_web_grok_analyze_post_followups_enabled: false, - responsive_web_jetfuel_frame: true, - responsive_web_grok_share_attachment_enabled: true, - responsive_web_edit_tweet_api_enabled: true, - graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, - view_counts_everywhere_api_enabled: true, - longform_notetweets_consumption_enabled: true, - responsive_web_twitter_article_tweet_consumption_enabled: true, - tweet_awards_web_tipping_enabled: false, - responsive_web_grok_show_grok_translated_post: false, - responsive_web_grok_analysis_button_from_backend: true, - creator_subscriptions_quote_tweet_preview_enabled: false, - longform_notetweets_rich_text_read_enabled: true, - longform_notetweets_inline_media_enabled: true, - profile_label_improvements_pcf_label_in_post_enabled: true, - responsive_web_profile_redirect_enabled: false, - rweb_tipjar_consumption_enabled: true, - verified_phone_label_enabled: false, - articles_preview_enabled: true, - responsive_web_grok_community_note_auto_translation_is_enabled: false, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - freedom_of_speech_not_reach_fetch_enabled: true, - standardized_nudges_misinfo: true, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, - responsive_web_grok_image_annotation_enabled: true, - responsive_web_grok_imagine_annotation_enabled: true, - responsive_web_graphql_timeline_navigation_enabled: true, - responsive_web_enhance_cards_enabled: false, - }; - - return this.createTweet(variables, features); - } - - /** - * Reply to an existing tweet - */ - async reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise { - const variables = { - tweet_text: text, - reply: { - in_reply_to_tweet_id: replyToTweetId, - exclude_reply_user_ids: [], - }, - dark_request: false, - media: { - media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })), - possibly_sensitive: false, - }, - semantic_annotation_ids: [], - }; - - const features = { - rweb_video_screen_enabled: true, - creator_subscriptions_tweet_preview_api_enabled: true, - premium_content_api_read_enabled: false, - communities_web_enable_tweet_community_results_fetch: true, - c9s_tweet_anatomy_moderator_badge_enabled: true, - responsive_web_grok_analyze_button_fetch_trends_enabled: false, - responsive_web_grok_analyze_post_followups_enabled: false, - responsive_web_jetfuel_frame: true, - responsive_web_grok_share_attachment_enabled: true, - responsive_web_edit_tweet_api_enabled: true, - graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, - view_counts_everywhere_api_enabled: true, - longform_notetweets_consumption_enabled: true, - responsive_web_twitter_article_tweet_consumption_enabled: true, - tweet_awards_web_tipping_enabled: false, - responsive_web_grok_show_grok_translated_post: false, - responsive_web_grok_analysis_button_from_backend: true, - creator_subscriptions_quote_tweet_preview_enabled: false, - longform_notetweets_rich_text_read_enabled: true, - longform_notetweets_inline_media_enabled: true, - profile_label_improvements_pcf_label_in_post_enabled: true, - responsive_web_profile_redirect_enabled: false, - rweb_tipjar_consumption_enabled: true, - verified_phone_label_enabled: false, - articles_preview_enabled: true, - responsive_web_grok_community_note_auto_translation_is_enabled: false, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - freedom_of_speech_not_reach_fetch_enabled: true, - standardized_nudges_misinfo: true, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, - responsive_web_grok_image_annotation_enabled: true, - responsive_web_grok_imagine_annotation_enabled: true, - responsive_web_graphql_timeline_navigation_enabled: true, - responsive_web_enhance_cards_enabled: false, - }; - - return this.createTweet(variables, features); - } - - private async createTweet( - variables: Record, - features: Record, - ): Promise { - await this.ensureClientUserId(); - let queryId = await this.getQueryId('CreateTweet'); - let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`; - - const buildBody = () => JSON.stringify({ variables, features, queryId }); - let body = buildBody(); - - try { - const headers = { ...this.getHeaders(), referer: 'https://x.com/compose/post' }; - let response = await this.fetchWithTimeout(urlWithOperation, { - method: 'POST', - headers, - body, - }); - - // Twitter increasingly prefers POST to /i/api/graphql with queryId in the payload. - // If the operation URL 404s, retry the generic endpoint. - if (response.status === 404) { - await this.refreshQueryIds(); - queryId = await this.getQueryId('CreateTweet'); - urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`; - body = buildBody(); - - response = await this.fetchWithTimeout(urlWithOperation, { - method: 'POST', - headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' }, - body, - }); - - if (response.status === 404) { - const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, { - method: 'POST', - headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' }, - body, - }); - - if (!retry.ok) { - const text = await retry.text(); - return { success: false, error: `HTTP ${retry.status}: ${text.slice(0, 200)}` }; - } - - const data = (await retry.json()) as CreateTweetResponse; - - if (data.errors && data.errors.length > 0) { - const fallback = await this.tryStatusUpdateFallback(data.errors, variables); - if (fallback) { - return fallback; - } - return { success: false, error: this.formatErrors(data.errors) }; - } - - const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; - if (tweetId) { - return { success: true, tweetId }; - } - - return { success: false, error: 'Tweet created but no ID returned' }; - } - } - - if (!response.ok) { - const text = await response.text(); - return { - success: false, - error: `HTTP ${response.status}: ${text.slice(0, 200)}`, - }; - } - - const data = (await response.json()) as CreateTweetResponse; - - if (data.errors && data.errors.length > 0) { - const fallback = await this.tryStatusUpdateFallback(data.errors, variables); - if (fallback) { - return fallback; - } - return { - success: false, - error: this.formatErrors(data.errors), - }; - } - - const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; - if (tweetId) { - return { - success: true, - tweetId, - }; - } - - return { - success: false, - error: 'Tweet created but no ID returned', - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - private formatErrors(errors: Array<{ message: string; code?: number }>): string { - return errors - .map((error) => (typeof error.code === 'number' ? `${error.message} (${error.code})` : error.message)) - .join(', '); - } - - private statusUpdateInputFromCreateTweetVariables(variables: Record): { - text: string; - inReplyToTweetId?: string; - mediaIds?: string[]; - } | null { - const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null; - if (!text) { - return null; - } - - const reply = variables.reply; - const inReplyToTweetId = - reply && - typeof reply === 'object' && - typeof (reply as { in_reply_to_tweet_id?: unknown }).in_reply_to_tweet_id === 'string' - ? (reply as { in_reply_to_tweet_id: string }).in_reply_to_tweet_id - : undefined; - - const media = variables.media; - const mediaEntities = - media && typeof media === 'object' ? (media as { media_entities?: unknown }).media_entities : undefined; - - const mediaIds = Array.isArray(mediaEntities) - ? mediaEntities - .map((entity) => - entity && typeof entity === 'object' && 'media_id' in (entity as Record) - ? (entity as { media_id?: unknown }).media_id - : undefined, - ) - .filter((value): value is string | number => typeof value === 'string' || typeof value === 'number') - .map((value) => String(value)) - : undefined; - - return { text, inReplyToTweetId, mediaIds: mediaIds && mediaIds.length > 0 ? mediaIds : undefined }; - } - - private async postStatusUpdate(input: { - text: string; - inReplyToTweetId?: string; - mediaIds?: string[]; - }): Promise { - const params = new URLSearchParams(); - params.set('status', input.text); - if (input.inReplyToTweetId) { - params.set('in_reply_to_status_id', input.inReplyToTweetId); - params.set('auto_populate_reply_metadata', 'true'); - } - if (input.mediaIds && input.mediaIds.length > 0) { - params.set('media_ids', input.mediaIds.join(',')); - } - - try { - const response = await this.fetchWithTimeout(TWITTER_STATUS_UPDATE_URL, { - method: 'POST', - headers: { - ...this.getBaseHeaders(), - 'content-type': 'application/x-www-form-urlencoded', - referer: 'https://x.com/compose/post', - }, - body: params.toString(), - }); - - if (!response.ok) { - const text = await response.text(); - return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; - } - - const data = (await response.json()) as { - id_str?: string; - id?: string | number; - errors?: Array<{ message: string; code?: number }>; - }; - - if (data.errors && data.errors.length > 0) { - return { success: false, error: this.formatErrors(data.errors) }; - } - - const tweetId = - typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined; - - if (tweetId) { - return { success: true, tweetId }; - } - return { success: false, error: 'Tweet created but no ID returned' }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - } - - private async tryStatusUpdateFallback( - errors: Array<{ message: string; code?: number }>, - variables: Record, - ): Promise { - if (!errors.some((error) => error.code === 226)) { - return null; - } - const input = this.statusUpdateInputFromCreateTweetVariables(variables); - if (!input) { - return null; - } - - const fallback = await this.postStatusUpdate(input); - if (fallback.success) { - return fallback; - } - - return { - success: false, - error: `${this.formatErrors(errors)} | fallback: ${fallback.error ?? 'Unknown error'}`, - }; - } - - private async ensureClientUserId(): Promise { - if (process.env.NODE_ENV === 'test') { - return; - } - if (this.clientUserId) { - return; - } - const result = await this.getCurrentUser(); - if (result.success && result.user?.id) { - this.clientUserId = result.user.id; - } - } - - /** - * Search for tweets matching a query - */ - async search(query: string, count = 20): Promise { - const features = this.buildSearchFeatures(); - const pageSize = 20; - const seen = new Set(); - const tweets: TweetData[] = []; - let cursor: string | undefined; - - const fetchPage = async (pageCount: number, pageCursor?: string) => { - let lastError: string | undefined; - let had404 = false; - const queryIds = await this.getSearchTimelineQueryIds(); - - for (const queryId of queryIds) { - const variables = { - rawQuery: query, - count: pageCount, - querySource: 'typed_query', - product: 'Latest', - ...(pageCursor ? { cursor: pageCursor } : {}), - }; - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - }); - - const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params.toString()}`; - - try { - const response = await this.fetchWithTimeout(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify({ features, queryId }), - }); - - if (response.status === 404) { - had404 = true; - lastError = `HTTP ${response.status}`; - continue; - } - - if (!response.ok) { - const text = await response.text(); - return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; - } - - const data = (await response.json()) as { - data?: { - search_by_raw_query?: { - search_timeline?: { - timeline?: { - instructions?: Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: { - rest_id?: string; - legacy?: { - full_text?: string; - created_at?: string; - reply_count?: number; - retweet_count?: number; - favorite_count?: number; - in_reply_to_status_id_str?: string; - }; - core?: { - user_results?: { - result?: { - legacy?: { - screen_name?: string; - name?: string; - }; - }; - }; - }; - }; - }; - }; - }; - }>; - }>; - }; - }; - }; - }; - errors?: Array<{ message: string }>; - }; - - if (data.errors && data.errors.length > 0) { - return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; - } - - const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions; - const pageTweets = this.parseTweetsFromInstructions(instructions); - const nextCursor = this.extractCursorFromInstructions(instructions); - - return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false as const, error: lastError ?? 'Unknown error fetching search results', had404 }; - }; - - const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => { - const firstAttempt = await fetchPage(pageCount, pageCursor); - if (firstAttempt.success) { - return firstAttempt; - } - if (firstAttempt.had404) { - await this.refreshQueryIds(); - const secondAttempt = await fetchPage(pageCount, pageCursor); - if (secondAttempt.success) { - return secondAttempt; - } - return { success: false as const, error: secondAttempt.error }; - } - return { success: false as const, error: firstAttempt.error }; - }; - - while (tweets.length < count) { - const pageCount = Math.min(pageSize, count - tweets.length); - const page = await fetchWithRefresh(pageCount, cursor); - if (!page.success) { - return { success: false, error: page.error }; - } - - for (const tweet of page.tweets) { - if (seen.has(tweet.id)) { - continue; - } - seen.add(tweet.id); - tweets.push(tweet); - if (tweets.length >= count) { - break; - } - } - - if (!page.cursor || page.cursor === cursor || page.tweets.length === 0) { - break; - } - cursor = page.cursor; - } - - return { success: true, tweets }; - } - - /** - * Fetch the account associated with the current cookies - */ - async getCurrentUser(): Promise { - const candidateUrls = [ - 'https://x.com/i/api/account/settings.json', - 'https://api.twitter.com/1.1/account/settings.json', - 'https://x.com/i/api/account/verify_credentials.json?skip_status=true&include_entities=false', - 'https://api.twitter.com/1.1/account/verify_credentials.json?skip_status=true&include_entities=false', - ]; - - let lastError: string | undefined; - - for (const url of candidateUrls) { - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (!response.ok) { - const text = await response.text(); - lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; - continue; - } - - // biome-ignore lint/suspicious/noExplicitAny: Twitter API response is dynamic here - let data: any; - try { - data = await response.json(); - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - continue; - } - - const username = - typeof data?.screen_name === 'string' - ? data.screen_name - : typeof data?.user?.screen_name === 'string' - ? data.user.screen_name - : null; - - const name = - typeof data?.name === 'string' - ? data.name - : typeof data?.user?.name === 'string' - ? data.user.name - : (username ?? ''); - - const userId = - typeof data?.user_id === 'string' - ? data.user_id - : typeof data?.user_id_str === 'string' - ? data.user_id_str - : typeof data?.user?.id_str === 'string' - ? data.user.id_str - : typeof data?.user?.id === 'string' - ? data.user.id - : null; - - if (username && userId) { - this.clientUserId = userId; - return { - success: true, - user: { - id: userId, - username, - name: name || username, - }, - }; - } - - lastError = 'Could not determine current user from response'; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - // Fallback: scrape the authenticated settings page (HTML) for screen_name/user_id - const profilePages = ['https://x.com/settings/account', 'https://twitter.com/settings/account']; - for (const page of profilePages) { - try { - const response = await this.fetchWithTimeout(page, { - headers: { - cookie: this.cookieHeader, - 'user-agent': this.userAgent, - }, - }); - - if (!response.ok) { - lastError = `HTTP ${response.status} (settings page)`; - continue; - } - - const html = await response.text(); - const usernameMatch = SETTINGS_SCREEN_NAME_REGEX.exec(html); - const idMatch = SETTINGS_USER_ID_REGEX.exec(html); - const nameMatch = SETTINGS_NAME_REGEX.exec(html); - - const username = usernameMatch?.[1]; - const userId = idMatch?.[1]; - const name = nameMatch?.[1]?.replace(/\\"/g, '"'); - - if (username && userId) { - return { - success: true, - user: { - id: userId, - username, - name: name || username, - }, - }; - } - - lastError = 'Could not parse settings page for user info'; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { - success: false, - error: lastError ?? 'Unknown error fetching current user', - }; - } - - /** - * Get replies to a tweet by ID - */ - async getReplies(tweetId: string): Promise { - const response = await this.fetchTweetDetail(tweetId); - if (!response.success) { - return response; - } - - const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; - const tweets = this.parseTweetsFromInstructions(instructions); - const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId); - - return { success: true, tweets: replies }; - } - - /** - * Get full conversation thread for a tweet ID - */ - async getThread(tweetId: string): Promise { - const response = await this.fetchTweetDetail(tweetId); - if (!response.success) { - return response; - } - - const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; - const tweets = this.parseTweetsFromInstructions(instructions); - - const target = tweets.find((t) => t.id === tweetId); - const rootId = target?.conversationId || tweetId; - const thread = tweets.filter((tweet) => tweet.conversationId === rootId); - - thread.sort((a, b) => { - const aTime = a.createdAt ? Date.parse(a.createdAt) : 0; - const bTime = b.createdAt ? Date.parse(b.createdAt) : 0; - return aTime - bTime; - }); - - return { success: true, tweets: thread }; - } - - // Shared timeline features for likes/bookmarks-style timelines. - private buildTimelineFeatures(): Record { - return { - ...this.buildSearchFeatures(), - blue_business_profile_image_shape_enabled: true, - responsive_web_text_conversations_enabled: false, - tweetypie_unmention_optimization_enabled: true, - vibe_api_enabled: true, - responsive_web_twitter_blue_verified_badge_is_enabled: true, - interactive_text_enabled: true, - longform_notetweets_richtext_consumption_enabled: true, - responsive_web_media_download_video_enabled: false, - }; - } - - private buildBookmarksFeatures(): Record { - return { - ...this.buildTimelineFeatures(), - graphql_timeline_v2_bookmark_timeline: true, - }; - } - - private buildLikesFeatures(): Record { - return this.buildTimelineFeatures(); - } - - private async getBookmarksQueryIds(): Promise { - const primary = await this.getQueryId('Bookmarks'); - return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw'])); - } - - private async getBookmarkFolderQueryIds(): Promise { - const primary = await this.getQueryId('BookmarkFolderTimeline'); - return Array.from(new Set([primary, 'KJIQpsvxrTfRIlbaRIySHQ'])); - } - - /** - * Get the authenticated user's bookmarks - */ - async getBookmarks(count = 20): Promise { - const variables = { - count, - includePromotedContent: false, - withDownvotePerspective: false, - withReactionsMetadata: false, - withReactionsPerspective: false, - }; - - const features = this.buildBookmarksFeatures(); - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - }); - - const tryOnce = async () => { - let lastError: string | undefined; - let had404 = false; - const queryIds = await this.getBookmarksQueryIds(); - - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`; - - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (response.status === 404) { - had404 = true; - lastError = `HTTP ${response.status}`; - continue; - } - - if (!response.ok) { - const text = await response.text(); - return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; - } - - const data = (await response.json()) as { - data?: { - bookmark_timeline_v2?: { - timeline?: { - instructions?: Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - }>; - }>; - }; - }; - }; - errors?: Array<{ message: string }>; - }; - - if (data.errors && data.errors.length > 0) { - return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; - } - - const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions; - const tweets = this.parseTweetsFromInstructions(instructions); - - return { success: true as const, tweets, had404 }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false as const, error: lastError ?? 'Unknown error fetching bookmarks', had404 }; - }; - - const firstAttempt = await tryOnce(); - if (firstAttempt.success) { - return { success: true, tweets: firstAttempt.tweets }; - } - - if (firstAttempt.had404) { - await this.refreshQueryIds(); - const secondAttempt = await tryOnce(); - if (secondAttempt.success) { - return { success: true, tweets: secondAttempt.tweets }; - } - return { success: false, error: secondAttempt.error }; - } - - return { success: false, error: firstAttempt.error }; - } - - private buildFollowingFeatures(): Record { - return { - rweb_video_screen_enabled: true, - profile_label_improvements_pcf_label_in_post_enabled: false, - responsive_web_profile_redirect_enabled: true, - rweb_tipjar_consumption_enabled: true, - verified_phone_label_enabled: false, - creator_subscriptions_tweet_preview_api_enabled: true, - responsive_web_graphql_timeline_navigation_enabled: true, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - premium_content_api_read_enabled: true, - communities_web_enable_tweet_community_results_fetch: true, - c9s_tweet_anatomy_moderator_badge_enabled: true, - responsive_web_grok_analyze_button_fetch_trends_enabled: false, - responsive_web_grok_analyze_post_followups_enabled: false, - responsive_web_jetfuel_frame: false, - responsive_web_grok_share_attachment_enabled: false, - articles_preview_enabled: true, - responsive_web_edit_tweet_api_enabled: true, - graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, - view_counts_everywhere_api_enabled: true, - longform_notetweets_consumption_enabled: true, - responsive_web_twitter_article_tweet_consumption_enabled: true, - tweet_awards_web_tipping_enabled: true, - responsive_web_grok_show_grok_translated_post: false, - responsive_web_grok_analysis_button_from_backend: false, - creator_subscriptions_quote_tweet_preview_enabled: false, - freedom_of_speech_not_reach_fetch_enabled: true, - standardized_nudges_misinfo: true, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, - longform_notetweets_rich_text_read_enabled: true, - longform_notetweets_inline_media_enabled: true, - responsive_web_grok_image_annotation_enabled: false, - responsive_web_grok_imagine_annotation_enabled: false, - responsive_web_grok_community_note_auto_translation_is_enabled: false, - responsive_web_enhance_cards_enabled: false, - }; - } - - private async getFollowingQueryIds(): Promise { - const primary = await this.getQueryId('Following'); - return Array.from(new Set([primary, 'BEkNpEt5pNETESoqMsTEGA'])); - } - - private async getFollowersQueryIds(): Promise { - const primary = await this.getQueryId('Followers'); - return Array.from(new Set([primary, 'kuFUYP9eV1FPoEy4N-pi7w'])); - } - - private parseUsersFromInstructions( - instructions: Array<{ type?: string; entries?: Array }> | undefined, - ): TwitterUser[] { - if (!instructions) { - return []; - } - - const users: TwitterUser[] = []; - - for (const instruction of instructions) { - if (!instruction.entries) { - continue; - } - - for (const entry of instruction.entries) { - const content = (entry as { content?: { itemContent?: { user_results?: { result?: unknown } } } })?.content; - const rawUserResult = content?.itemContent?.user_results?.result as - | { - __typename?: string; - rest_id?: string; - is_blue_verified?: boolean; - user?: unknown; - legacy?: { - screen_name?: string; - name?: string; - description?: string; - followers_count?: number; - friends_count?: number; - profile_image_url_https?: string; - created_at?: string; - }; - core?: { - screen_name?: string; - name?: string; - created_at?: string; - }; - avatar?: { - image_url?: string; - }; - } - | undefined; - - const userResult = - rawUserResult?.__typename === 'UserWithVisibilityResults' && rawUserResult.user - ? (rawUserResult.user as typeof rawUserResult) - : rawUserResult; - - if (!userResult || userResult.__typename !== 'User') { - continue; - } - - const legacy = userResult.legacy; - const core = userResult.core; - const username = legacy?.screen_name ?? core?.screen_name; - if (!userResult.rest_id || !username) { - continue; - } - - users.push({ - id: userResult.rest_id, - username, - name: legacy?.name ?? core?.name ?? username, - description: legacy?.description, - followersCount: legacy?.followers_count, - followingCount: legacy?.friends_count, - isBlueVerified: userResult.is_blue_verified, - profileImageUrl: legacy?.profile_image_url_https ?? userResult.avatar?.image_url, - createdAt: legacy?.created_at ?? core?.created_at, - }); - } - } - - return users; - } - - /** - * Get users that a user is following - */ - async getFollowing(userId: string, count = 20): Promise { - const variables = { - userId, - count, - includePromotedContent: false, - }; - - const features = this.buildFollowingFeatures(); - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - }); - - const tryOnce = async () => { - let lastError: string | undefined; - let had404 = false; - const queryIds = await this.getFollowingQueryIds(); - - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/Following?${params.toString()}`; - - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (response.status === 404) { - had404 = true; - lastError = `HTTP ${response.status}`; - continue; - } - - if (!response.ok) { - const text = await response.text(); - return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; - } - - const data = (await response.json()) as { - data?: { - user?: { - result?: { - timeline?: { - timeline?: { - instructions?: Array<{ type?: string; entries?: Array }>; - }; - }; - }; - }; - }; - errors?: Array<{ message: string }>; - }; - - if (data.errors && data.errors.length > 0) { - return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; - } - - const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; - const users = this.parseUsersFromInstructions(instructions); - - return { success: true as const, users, had404 }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false as const, error: lastError ?? 'Unknown error fetching following', had404 }; - }; - - const firstAttempt = await tryOnce(); - if (firstAttempt.success) { - return { success: true, users: firstAttempt.users }; - } - - if (firstAttempt.had404) { - await this.refreshQueryIds(); - const secondAttempt = await tryOnce(); - if (secondAttempt.success) { - return { success: true, users: secondAttempt.users }; - } - return { success: false, error: secondAttempt.error }; - } - - return { success: false, error: firstAttempt.error }; - } - - /** - * Get users that follow a user - */ - async getFollowers(userId: string, count = 20): Promise { - const variables = { - userId, - count, - includePromotedContent: false, - }; - - const features = this.buildFollowingFeatures(); - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - }); - - const tryOnce = async () => { - let lastError: string | undefined; - let had404 = false; - const queryIds = await this.getFollowersQueryIds(); - - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/Followers?${params.toString()}`; - - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (response.status === 404) { - had404 = true; - lastError = `HTTP ${response.status}`; - continue; - } - - if (!response.ok) { - const text = await response.text(); - return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; - } - - const data = (await response.json()) as { - data?: { - user?: { - result?: { - timeline?: { - timeline?: { - instructions?: Array<{ type?: string; entries?: Array }>; - }; - }; - }; - }; - }; - errors?: Array<{ message: string }>; - }; - - if (data.errors && data.errors.length > 0) { - return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; - } - - const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; - const users = this.parseUsersFromInstructions(instructions); - - return { success: true as const, users, had404 }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false as const, error: lastError ?? 'Unknown error fetching followers', had404 }; - }; - - const firstAttempt = await tryOnce(); - if (firstAttempt.success) { - return { success: true, users: firstAttempt.users }; - } - - if (firstAttempt.had404) { - await this.refreshQueryIds(); - const secondAttempt = await tryOnce(); - if (secondAttempt.success) { - return { success: true, users: secondAttempt.users }; - } - return { success: false, error: secondAttempt.error }; - } - - return { success: false, error: firstAttempt.error }; - } - private async getLikesQueryIds(): Promise { - const primary = await this.getQueryId('Likes'); - return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA'])); - } - - /** - * Get the authenticated user's liked tweets - */ - async getLikes(count = 20): Promise { - const userResult = await this.getCurrentUser(); - if (!userResult.success || !userResult.user) { - return { success: false, error: userResult.error ?? 'Could not determine current user' }; - } - - const variables = { - userId: userResult.user.id, - count, - includePromotedContent: false, - withClientEventToken: false, - withBirdwatchNotes: false, - withVoice: true, - }; - - const features = this.buildLikesFeatures(); - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - }); - - const tryOnce = async () => { - let lastError: string | undefined; - let had404 = false; - const queryIds = await this.getLikesQueryIds(); - - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`; - - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (response.status === 404) { - had404 = true; - lastError = `HTTP ${response.status}`; - continue; - } - - if (!response.ok) { - const text = await response.text(); - return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; - } - - const data = (await response.json()) as { - data?: { - user?: { - result?: { - timeline?: { - timeline?: { - instructions?: Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - }>; - }>; - }; - }; - }; - }; - }; - errors?: Array<{ message: string }>; - }; - - if (data.errors && data.errors.length > 0) { - return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; - } - - const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; - const tweets = this.parseTweetsFromInstructions(instructions); - - return { success: true as const, tweets, had404 }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false as const, error: lastError ?? 'Unknown error fetching likes', had404 }; - }; - - const firstAttempt = await tryOnce(); - if (firstAttempt.success) { - return { success: true, tweets: firstAttempt.tweets }; - } - - if (firstAttempt.had404) { - await this.refreshQueryIds(); - const secondAttempt = await tryOnce(); - if (secondAttempt.success) { - return { success: true, tweets: secondAttempt.tweets }; - } - return { success: false, error: secondAttempt.error }; - } - - return { success: false, error: firstAttempt.error }; - } - - /** - * Get the authenticated user's bookmark folder timeline - */ - async getBookmarkFolderTimeline(folderId: string, count = 20): Promise { - const variablesWithCount = { - bookmark_collection_id: folderId, - includePromotedContent: true, - count, - }; - - const variablesWithoutCount = { - bookmark_collection_id: folderId, - includePromotedContent: true, - }; - - const features = this.buildBookmarksFeatures(); - - const tryOnce = async (variables: Record) => { - let lastError: string | undefined; - let had404 = false; - const queryIds = await this.getBookmarkFolderQueryIds(); - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - }); - - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`; - - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (response.status === 404) { - had404 = true; - lastError = `HTTP ${response.status}`; - continue; - } - - if (!response.ok) { - const text = await response.text(); - return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; - } - - const data = (await response.json()) as { - data?: { - bookmark_collection_timeline?: { - timeline?: { - instructions?: Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - }>; - }>; - }; - }; - }; - errors?: Array<{ message: string }>; - }; - - if (data.errors && data.errors.length > 0) { - return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; - } - - const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions; - const tweets = this.parseTweetsFromInstructions(instructions); - - return { success: true as const, tweets, had404 }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 }; - }; - - let firstAttempt = await tryOnce(variablesWithCount); - if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) { - firstAttempt = await tryOnce(variablesWithoutCount); - } - if (firstAttempt.success) { - return { success: true, tweets: firstAttempt.tweets }; - } - - if (firstAttempt.had404) { - await this.refreshQueryIds(); - let secondAttempt = await tryOnce(variablesWithCount); - if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) { - secondAttempt = await tryOnce(variablesWithoutCount); - } - if (secondAttempt.success) { - return { success: true, tweets: secondAttempt.tweets }; - } - return { success: false, error: secondAttempt.error }; - } - - return { success: false, error: firstAttempt.error }; - } -} +import { TwitterClientBase } from './twitter-client-base.js'; +import { withMedia } from './twitter-client-media.js'; +import { withPosting } from './twitter-client-posting.js'; +import { withSearch } from './twitter-client-search.js'; +import { withTimelines } from './twitter-client-timelines.js'; +import { withTweetDetails } from './twitter-client-tweet-detail.js'; +import { withUsers } from './twitter-client-users.js'; + +const MixedTwitterClient = withUsers(withTimelines(withSearch(withTweetDetails(withPosting(withMedia(TwitterClientBase)))))); + +export class TwitterClient extends MixedTwitterClient {} + +export type { + CurrentUserResult, + FollowingResult, + GetTweetResult, + SearchResult, + TweetData, + TwitterClientOptions, + TweetResult, + TwitterUser, + UploadMediaResult, +} from './twitter-client-types.js'; diff --git a/tests/twitter-client-coverage.upload.test.ts b/tests/twitter-client-coverage.upload.test.ts index 00c2555..10091e5 100644 --- a/tests/twitter-client-coverage.upload.test.ts +++ b/tests/twitter-client-coverage.upload.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { TwitterClient } from '../src/lib/twitter-client.js'; +import { extractArticleText } from '../src/lib/twitter-client-utils.js'; const validCookies = { authToken: 'test_auth_token', @@ -17,7 +18,6 @@ type ResponseLike = { type TwitterClientUploadPrivate = TwitterClient & { sleep: (ms: number) => Promise; - extractArticleText: (result: unknown) => string | undefined; }; const makeResponse = (overrides: Partial = {}): ResponseLike => ({ @@ -188,12 +188,10 @@ describe('TwitterClient upload coverage', () => { describe('article extraction edge cases', () => { it('logs article payloads when debug flag is set', () => { - const client = new TwitterClient({ cookies: validCookies }); process.env.BIRD_DEBUG_ARTICLE = '1'; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const clientPrivate = client as unknown as TwitterClientUploadPrivate; - const result = clientPrivate.extractArticleText({ + const result = extractArticleText({ rest_id: '1', article: { title: 'Title', @@ -207,9 +205,7 @@ describe('TwitterClient upload coverage', () => { }); it('drops duplicate body when it matches the title', () => { - const client = new TwitterClient({ cookies: validCookies }); - const clientPrivate = client as unknown as TwitterClientUploadPrivate; - const result = clientPrivate.extractArticleText({ + const result = extractArticleText({ rest_id: '1', article: { title: 'Same', diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index 7696487..5225a7e 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type TweetData, TwitterClient } from '../src/lib/twitter-client.js'; +import { mapTweetResult } from '../src/lib/twitter-client-utils.js'; describe('TwitterClient', () => { const originalFetch = global.fetch; @@ -992,9 +993,7 @@ describe('TwitterClient', () => { root.quoted_status_result = { result: quoted }; const client = new TwitterClient({ cookies: validCookies }); - const mapped = ( - client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined } - ).mapTweetResult(root); + const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth); expect(mapped?.quotedTweet?.id).toBe('2'); expect(mapped?.quotedTweet?.quotedTweet).toBeUndefined(); @@ -1006,9 +1005,7 @@ describe('TwitterClient', () => { root.quoted_status_result = { result: quoted }; const client = new TwitterClient({ cookies: validCookies, quoteDepth: 0 }); - const mapped = ( - client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined } - ).mapTweetResult(root); + const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth); expect(mapped?.quotedTweet).toBeUndefined(); }); @@ -1021,9 +1018,7 @@ describe('TwitterClient', () => { root.quoted_status_result = { result: quoted1 }; const client = new TwitterClient({ cookies: validCookies, quoteDepth: 2 }); - const mapped = ( - client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined } - ).mapTweetResult(root); + const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth); expect(mapped?.quotedTweet?.id).toBe('2'); expect(mapped?.quotedTweet?.quotedTweet?.id).toBe('3'); @@ -1041,9 +1036,7 @@ describe('TwitterClient', () => { }; const client = new TwitterClient({ cookies: validCookies }); - const mapped = ( - client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined } - ).mapTweetResult(root); + const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth); expect(mapped?.quotedTweet?.id).toBe('2'); });