import { addApiFeatures, requestApi } from './api'; import { TwitterAuth } from './auth'; import { getUserIdByScreenName } from './profile'; import { QueryTweetsResponse } from './timeline-v1'; import { parseTimelineTweetsV2, TimelineV2, TimelineEntryItemContentRaw, parseTimelineEntryItemContentRaw, ThreadedConversation, parseThreadedConversation, } from './timeline-v2'; import { getTweetTimeline } from './timeline-async'; import { apiRequestFactory } from './api-data'; import { ListTimeline, parseListTimelineTweets } from './timeline-list'; import { updateCookieJar } from './requests'; import { ApiV2Includes, MediaObjectV2, PlaceV2, PollV2, TTweetv2Expansion, TTweetv2MediaField, TTweetv2PlaceField, TTweetv2PollField, TTweetv2TweetField, TTweetv2UserField, TweetV2, UserV2, } from 'twitter-api-v2'; export const defaultOptions = { expansions: [ 'attachments.poll_ids', 'attachments.media_keys', 'author_id', 'referenced_tweets.id', 'in_reply_to_user_id', 'edit_history_tweet_ids', 'geo.place_id', 'entities.mentions.username', 'referenced_tweets.id.author_id', ] as TTweetv2Expansion[], tweetFields: [ 'attachments', 'author_id', 'context_annotations', 'conversation_id', 'created_at', 'entities', 'geo', 'id', 'in_reply_to_user_id', 'lang', 'public_metrics', 'edit_controls', 'possibly_sensitive', 'referenced_tweets', 'reply_settings', 'source', 'text', 'withheld', 'note_tweet', ] as TTweetv2TweetField[], pollFields: [ 'duration_minutes', 'end_datetime', 'id', 'options', 'voting_status', ] as TTweetv2PollField[], mediaFields: [ 'duration_ms', 'height', 'media_key', 'preview_image_url', 'type', 'url', 'width', 'public_metrics', 'alt_text', 'variants', ] as TTweetv2MediaField[], userFields: [ 'created_at', 'description', 'entities', 'id', 'location', 'name', 'profile_image_url', 'protected', 'public_metrics', 'url', 'username', 'verified', 'withheld', ] as TTweetv2UserField[], placeFields: [ 'contained_within', 'country', 'country_code', 'full_name', 'geo', 'id', 'name', 'place_type', ] as TTweetv2PlaceField[], }; export interface Mention { id: string; username?: string; name?: string; } export interface Photo { id: string; url: string; alt_text: string | undefined; } export interface Video { id: string; preview: string; url?: string; } export interface PlaceRaw { id?: string; place_type?: string; name?: string; full_name?: string; country_code?: string; country?: string; bounding_box?: { type?: string; coordinates?: number[][][]; }; } export interface PollData { id?: string; end_datetime?: string; voting_status?: string; duration_minutes: number; options: PollOption[]; } export interface PollOption { position?: number; label: string; votes?: number; } /** * A parsed Tweet object. */ export interface Tweet { bookmarkCount?: number; conversationId?: string; hashtags: string[]; html?: string; id?: string; inReplyToStatus?: Tweet; inReplyToStatusId?: string; isQuoted?: boolean; isPin?: boolean; isReply?: boolean; isRetweet?: boolean; isSelfThread?: boolean; likes?: number; name?: string; mentions: Mention[]; permanentUrl?: string; photos: Photo[]; place?: PlaceRaw; quotedStatus?: Tweet; quotedStatusId?: string; replies?: number; retweets?: number; retweetedStatus?: Tweet; retweetedStatusId?: string; text?: string; thread: Tweet[]; timeParsed?: Date; timestamp?: number; urls: string[]; userId?: string; username?: string; videos: Video[]; views?: number; sensitiveContent?: boolean; poll?: PollV2 | null; } export type TweetQuery = | Partial | ((tweet: Tweet) => boolean | Promise); export const features = addApiFeatures({ interactive_text_enabled: true, longform_notetweets_inline_media_enabled: false, responsive_web_text_conversations_enabled: false, tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: false, vibe_api_enabled: false, }); export async function fetchTweets( userId: string, maxTweets: number, cursor: string | undefined, auth: TwitterAuth, ): Promise { if (maxTweets > 200) { maxTweets = 200; } const userTweetsRequest = apiRequestFactory.createUserTweetsRequest(); userTweetsRequest.variables.userId = userId; userTweetsRequest.variables.count = maxTweets; userTweetsRequest.variables.includePromotedContent = false; // true on the website if (cursor != null && cursor != '') { userTweetsRequest.variables['cursor'] = cursor; } const res = await requestApi( userTweetsRequest.toRequestUrl(), auth, ); if (!res.success) { throw res.err; } return parseTimelineTweetsV2(res.value); } export async function fetchTweetsAndReplies( userId: string, maxTweets: number, cursor: string | undefined, auth: TwitterAuth, ): Promise { if (maxTweets > 40) { maxTweets = 40; } const userTweetsRequest = apiRequestFactory.createUserTweetsAndRepliesRequest(); userTweetsRequest.variables.userId = userId; userTweetsRequest.variables.count = maxTweets; userTweetsRequest.variables.includePromotedContent = false; // true on the website if (cursor != null && cursor != '') { userTweetsRequest.variables['cursor'] = cursor; } const res = await requestApi( userTweetsRequest.toRequestUrl(), auth, ); if (!res.success) { throw res.err; } return parseTimelineTweetsV2(res.value); } export async function createCreateTweetRequestV2( text: string, auth: TwitterAuth, tweetId?: string, options?: { poll?: PollData; }, ) { const v2client = auth.getV2Client(); if (v2client == null) { throw new Error('V2 client is not initialized'); } const { poll } = options || {}; let tweetConfig; if (poll) { tweetConfig = { text, poll: { options: poll?.options.map((option) => option.label) ?? [], duration_minutes: poll?.duration_minutes ?? 60, }, }; } else if (tweetId) { tweetConfig = { text, reply: { in_reply_to_tweet_id: tweetId, }, }; } else { tweetConfig = { text, }; } const tweetResponse = await v2client.v2.tweet(tweetConfig); let optionsConfig = {}; if (options?.poll) { optionsConfig = { expansions: ['attachments.poll_ids'], pollFields: [ 'options', 'duration_minutes', 'end_datetime', 'voting_status', ], }; } return await getTweetV2(tweetResponse.data.id, auth, optionsConfig); } export function parseTweetV2ToV1( tweetV2: TweetV2, includes?: ApiV2Includes, defaultTweetData?: Tweet | null, ): Tweet { let parsedTweet: Tweet; if (defaultTweetData != null) { parsedTweet = defaultTweetData; } parsedTweet = { id: tweetV2.id, text: tweetV2.text ?? defaultTweetData?.text ?? '', hashtags: tweetV2.entities?.hashtags?.map((tag) => tag.tag) ?? defaultTweetData?.hashtags ?? [], mentions: tweetV2.entities?.mentions?.map((mention) => ({ id: mention.id, username: mention.username, })) ?? defaultTweetData?.mentions ?? [], urls: tweetV2.entities?.urls?.map((url) => url.url) ?? defaultTweetData?.urls ?? [], likes: tweetV2.public_metrics?.like_count ?? defaultTweetData?.likes ?? 0, retweets: tweetV2.public_metrics?.retweet_count ?? defaultTweetData?.retweets ?? 0, replies: tweetV2.public_metrics?.reply_count ?? defaultTweetData?.replies ?? 0, views: tweetV2.public_metrics?.impression_count ?? defaultTweetData?.views ?? 0, userId: tweetV2.author_id ?? defaultTweetData?.userId, conversationId: tweetV2.conversation_id ?? defaultTweetData?.conversationId, photos: defaultTweetData?.photos ?? [], videos: defaultTweetData?.videos ?? [], poll: defaultTweetData?.poll ?? null, username: defaultTweetData?.username ?? '', name: defaultTweetData?.name ?? '', place: defaultTweetData?.place, thread: defaultTweetData?.thread ?? [], }; // Process Polls if (includes?.polls?.length) { const poll = includes.polls[0]; parsedTweet.poll = { id: poll.id, end_datetime: poll.end_datetime ? poll.end_datetime : defaultTweetData?.poll?.end_datetime ? defaultTweetData?.poll?.end_datetime : undefined, options: poll.options.map((option) => ({ position: option.position, label: option.label, votes: option.votes, })), voting_status: poll.voting_status ?? defaultTweetData?.poll?.voting_status, }; } // Process Media (photos and videos) if (includes?.media?.length) { includes.media.forEach((media: MediaObjectV2) => { if (media.type === 'photo') { parsedTweet.photos.push({ id: media.media_key, url: media.url ?? '', alt_text: media.alt_text ?? '', }); } else if (media.type === 'video' || media.type === 'animated_gif') { parsedTweet.videos.push({ id: media.media_key, preview: media.preview_image_url ?? '', url: media.variants?.find( (variant) => variant.content_type === 'video/mp4', )?.url ?? '', }); } }); } // Process User (for author info) if (includes?.users?.length) { const user = includes.users.find( (user: UserV2) => user.id === tweetV2.author_id, ); if (user) { parsedTweet.username = user.username ?? defaultTweetData?.username ?? ''; parsedTweet.name = user.name ?? defaultTweetData?.name ?? ''; } } // Process Place (if any) if (tweetV2?.geo?.place_id && includes?.places?.length) { const place = includes.places.find( (place: PlaceV2) => place.id === tweetV2?.geo?.place_id, ); if (place) { parsedTweet.place = { id: place.id, full_name: place.full_name ?? defaultTweetData?.place?.full_name ?? '', country: place.country ?? defaultTweetData?.place?.country ?? '', country_code: place.country_code ?? defaultTweetData?.place?.country_code ?? '', name: place.name ?? defaultTweetData?.place?.name ?? '', place_type: place.place_type ?? defaultTweetData?.place?.place_type, }; } } // TODO: Process Thread (referenced tweets) and remove reference to v1 return parsedTweet; } export async function createCreateTweetRequest( text: string, auth: TwitterAuth, tweetId?: string, mediaData?: { data: Buffer; mediaType: string }[], ) { const onboardingTaskUrl = 'https://api.twitter.com/1.1/onboarding/task.json'; const cookies = await auth.cookieJar().getCookies(onboardingTaskUrl); const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0'); //@ ts-expect-error - This is a private API. const headers = new Headers({ authorization: `Bearer ${(auth as any).bearerToken}`, cookie: await auth.cookieJar().getCookieString(onboardingTaskUrl), 'content-type': 'application/json', 'User-Agent': 'Mozilla/5.0 (Linux; Android 11; Nokia G20) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.88 Mobile Safari/537.36', 'x-guest-token': (auth as any).guestToken, 'x-twitter-auth-type': 'OAuth2Client', 'x-twitter-active-user': 'yes', 'x-twitter-client-language': 'en', 'x-csrf-token': xCsrfToken?.value as string, }); const variables: Record = { tweet_text: text, dark_request: false, media: { media_entities: [], possibly_sensitive: false, }, semantic_annotation_ids: [], }; if (mediaData && mediaData.length > 0) { const mediaIds = await Promise.all( mediaData.map(({ data, mediaType }) => uploadMedia(data, auth, mediaType), ), ); variables.media.media_entities = mediaIds.map((id) => ({ media_id: id, tagged_users: [], })); } if (tweetId) { variables.reply = { in_reply_to_tweet_id: tweetId }; } const response = await fetch( 'https://twitter.com/i/api/graphql/a1p9RWpkYKBjWv_I3WzS-A/CreateTweet', { headers, body: JSON.stringify({ variables, features: { interactive_text_enabled: true, longform_notetweets_inline_media_enabled: false, responsive_web_text_conversations_enabled: false, tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: false, vibe_api_enabled: false, rweb_lists_timeline_redesign_enabled: true, responsive_web_graphql_exclude_directive_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, tweetypie_unmention_optimization_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, tweet_awards_web_tipping_enabled: false, freedom_of_speech_not_reach_fetch_enabled: true, standardized_nudges_misinfo: true, longform_notetweets_rich_text_read_enabled: true, responsive_web_enhance_cards_enabled: false, subscriptions_verification_info_enabled: true, subscriptions_verification_info_reason_enabled: true, subscriptions_verification_info_verified_since_enabled: true, super_follow_badge_privacy_enabled: false, super_follow_exclusive_tweet_notifications_enabled: false, super_follow_tweet_api_enabled: false, super_follow_user_api_enabled: false, android_graphql_skip_api_media_color_palette: false, creator_subscriptions_subscription_count_enabled: false, blue_business_profile_image_shape_enabled: false, unified_cards_ad_metadata_container_dynamic_card_content_query_enabled: false, rweb_video_timestamps_enabled: false, c9s_tweet_anatomy_moderator_badge_enabled: false, responsive_web_twitter_article_tweet_consumption_enabled: false, }, fieldToggles: {}, }), method: 'POST', }, ); await updateCookieJar(auth.cookieJar(), response.headers); // check for errors if (!response.ok) { throw new Error(await response.text()); } return response; } export async function createCreateNoteTweetRequest( text: string, auth: TwitterAuth, tweetId?: string, mediaData?: { data: Buffer; mediaType: string }[], ) { const onboardingTaskUrl = 'https://api.twitter.com/1.1/onboarding/task.json'; const cookies = await auth.cookieJar().getCookies(onboardingTaskUrl); const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0'); const headers = new Headers({ authorization: `Bearer ${(auth as any).bearerToken}`, cookie: await auth.cookieJar().getCookieString(onboardingTaskUrl), 'content-type': 'application/json', 'User-Agent': 'Mozilla/5.0 (Linux; Android 11; Nokia G20) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.88 Mobile Safari/537.36', 'x-guest-token': (auth as any).guestToken, 'x-twitter-auth-type': 'OAuth2Client', 'x-twitter-active-user': 'yes', 'x-twitter-client-language': 'en', 'x-csrf-token': xCsrfToken?.value as string, }); const variables: Record = { tweet_text: text, dark_request: false, media: { media_entities: [], possibly_sensitive: false, }, semantic_annotation_ids: [], }; if (mediaData && mediaData.length > 0) { const mediaIds = await Promise.all( mediaData.map(({ data, mediaType }) => uploadMedia(data, auth, mediaType), ), ); variables.media.media_entities = mediaIds.map((id) => ({ media_id: id, tagged_users: [], })); } if (tweetId) { variables.reply = { in_reply_to_tweet_id: tweetId }; } const response = await fetch( 'https://twitter.com/i/api/graphql/0aWhJJmFlxkxv9TAUJPanA/CreateNoteTweet', { headers, body: JSON.stringify({ variables, features: { interactive_text_enabled: true, longform_notetweets_inline_media_enabled: false, responsive_web_text_conversations_enabled: false, tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: false, vibe_api_enabled: false, rweb_lists_timeline_redesign_enabled: true, responsive_web_graphql_exclude_directive_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, tweetypie_unmention_optimization_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, longform_notetweets_creation_enabled: true, tweet_awards_web_tipping_enabled: false, freedom_of_speech_not_reach_fetch_enabled: true, standardized_nudges_misinfo: true, longform_notetweets_rich_text_read_enabled: true, responsive_web_enhance_cards_enabled: false, subscriptions_verification_info_enabled: true, subscriptions_verification_info_reason_enabled: true, subscriptions_verification_info_verified_since_enabled: true, super_follow_badge_privacy_enabled: false, super_follow_exclusive_tweet_notifications_enabled: false, super_follow_tweet_api_enabled: false, super_follow_user_api_enabled: false, android_graphql_skip_api_media_color_palette: false, creator_subscriptions_subscription_count_enabled: false, blue_business_profile_image_shape_enabled: false, unified_cards_ad_metadata_container_dynamic_card_content_query_enabled: false, rweb_video_timestamps_enabled: false, c9s_tweet_anatomy_moderator_badge_enabled: false, responsive_web_twitter_article_tweet_consumption_enabled: false, communities_web_enable_tweet_community_results_fetch: false, articles_preview_enabled: false, rweb_tipjar_consumption_enabled: false, creator_subscriptions_quote_tweet_preview_enabled: false, }, fieldToggles: {}, }), method: 'POST', }, ); await updateCookieJar(auth.cookieJar(), response.headers); // Check for errors and log the error response if (!response.ok) { const errorText = await response.text(); console.error('Error response:', errorText); throw new Error(`Failed to create long tweet: ${errorText}`); } // Parse and return the response const data = await response.json(); return data; } export async function fetchListTweets( listId: string, maxTweets: number, cursor: string | undefined, auth: TwitterAuth, ): Promise { if (maxTweets > 200) { maxTweets = 200; } const listTweetsRequest = apiRequestFactory.createListTweetsRequest(); listTweetsRequest.variables.listId = listId; listTweetsRequest.variables.count = maxTweets; if (cursor != null && cursor != '') { listTweetsRequest.variables['cursor'] = cursor; } const res = await requestApi( listTweetsRequest.toRequestUrl(), auth, ); if (!res.success) { throw res.err; } return parseListTimelineTweets(res.value); } export function getTweets( user: string, maxTweets: number, auth: TwitterAuth, ): AsyncGenerator { return getTweetTimeline(user, maxTweets, async (q, mt, c) => { const userIdRes = await getUserIdByScreenName(q, auth); if (!userIdRes.success) { throw userIdRes.err; } const { value: userId } = userIdRes; return fetchTweets(userId, mt, c, auth); }); } export function getTweetsByUserId( userId: string, maxTweets: number, auth: TwitterAuth, ): AsyncGenerator { return getTweetTimeline(userId, maxTweets, (q, mt, c) => { return fetchTweets(q, mt, c, auth); }); } export function getTweetsAndReplies( user: string, maxTweets: number, auth: TwitterAuth, ): AsyncGenerator { return getTweetTimeline(user, maxTweets, async (q, mt, c) => { const userIdRes = await getUserIdByScreenName(q, auth); if (!userIdRes.success) { throw userIdRes.err; } const { value: userId } = userIdRes; return fetchTweetsAndReplies(userId, mt, c, auth); }); } export function getTweetsAndRepliesByUserId( userId: string, maxTweets: number, auth: TwitterAuth, ): AsyncGenerator { return getTweetTimeline(userId, maxTweets, (q, mt, c) => { return fetchTweetsAndReplies(q, mt, c, auth); }); } export async function fetchLikedTweets( userId: string, maxTweets: number, cursor: string | undefined, auth: TwitterAuth, ): Promise { if (!auth.isLoggedIn()) { throw new Error('Scraper is not logged-in for fetching liked tweets.'); } if (maxTweets > 200) { maxTweets = 200; } const userTweetsRequest = apiRequestFactory.createUserLikedTweetsRequest(); userTweetsRequest.variables.userId = userId; userTweetsRequest.variables.count = maxTweets; userTweetsRequest.variables.includePromotedContent = false; // true on the website if (cursor != null && cursor != '') { userTweetsRequest.variables['cursor'] = cursor; } const res = await requestApi( userTweetsRequest.toRequestUrl(), auth, ); if (!res.success) { throw res.err; } return parseTimelineTweetsV2(res.value); } export async function getTweetWhere( tweets: AsyncIterable, query: TweetQuery, ): Promise { const isCallback = typeof query === 'function'; for await (const tweet of tweets) { const matches = isCallback ? await query(tweet) : checkTweetMatches(tweet, query); if (matches) { return tweet; } } return null; } export async function getTweetsWhere( tweets: AsyncIterable, query: TweetQuery, ): Promise { const isCallback = typeof query === 'function'; const filtered = []; for await (const tweet of tweets) { const matches = isCallback ? query(tweet) : checkTweetMatches(tweet, query); if (!matches) continue; filtered.push(tweet); } return filtered; } function checkTweetMatches(tweet: Tweet, options: Partial): boolean { return Object.keys(options).every((k) => { const key = k as keyof Tweet; return tweet[key] === options[key]; }); } export async function getLatestTweet( user: string, includeRetweets: boolean, max: number, auth: TwitterAuth, ): Promise { const timeline = getTweets(user, max, auth); // No point looping if max is 1, just use first entry. return max === 1 ? (await timeline.next()).value : await getTweetWhere(timeline, { isRetweet: includeRetweets }); } export interface TweetResultByRestId { data?: TimelineEntryItemContentRaw; } export async function getTweet( id: string, auth: TwitterAuth, ): Promise { const tweetDetailRequest = apiRequestFactory.createTweetDetailRequest(); tweetDetailRequest.variables.focalTweetId = id; const res = await requestApi( tweetDetailRequest.toRequestUrl(), auth, ); if (!res.success) { throw res.err; } if (!res.value) { return null; } const tweets = parseThreadedConversation(res.value); return tweets.find((tweet) => tweet.id === id) ?? null; } export async function getTweetV2( id: string, auth: TwitterAuth, options: { expansions?: TTweetv2Expansion[]; tweetFields?: TTweetv2TweetField[]; pollFields?: TTweetv2PollField[]; mediaFields?: TTweetv2MediaField[]; userFields?: TTweetv2UserField[]; placeFields?: TTweetv2PlaceField[]; } = defaultOptions, ): Promise { const v2client = auth.getV2Client(); if (!v2client) { throw new Error('V2 client is not initialized'); } try { const tweetData = await v2client.v2.singleTweet(id, { expansions: options?.expansions, 'tweet.fields': options?.tweetFields, 'poll.fields': options?.pollFields, 'media.fields': options?.mediaFields, 'user.fields': options?.userFields, 'place.fields': options?.placeFields, }); if (!tweetData?.data) { console.warn(`Tweet data not found for ID: ${id}`); return null; } const defaultTweetData = await getTweet(tweetData.data.id, auth); // Extract primary tweet data const parsedTweet = parseTweetV2ToV1( tweetData.data, tweetData?.includes, defaultTweetData, ); return parsedTweet; } catch (error) { console.error(`Error fetching tweet ${id}:`, error); return null; } } export async function getTweetsV2( ids: string[], auth: TwitterAuth, options: { expansions?: TTweetv2Expansion[]; tweetFields?: TTweetv2TweetField[]; pollFields?: TTweetv2PollField[]; mediaFields?: TTweetv2MediaField[]; userFields?: TTweetv2UserField[]; placeFields?: TTweetv2PlaceField[]; } = defaultOptions, ): Promise { const v2client = auth.getV2Client(); if (!v2client) { return []; } try { const tweetData = await v2client.v2.tweets(ids, { expansions: options?.expansions, 'tweet.fields': options?.tweetFields, 'poll.fields': options?.pollFields, 'media.fields': options?.mediaFields, 'user.fields': options?.userFields, 'place.fields': options?.placeFields, }); const tweetsV2 = tweetData.data; if (tweetsV2.length === 0) { console.warn(`No tweet data found for IDs: ${ids.join(', ')}`); return []; } return ( await Promise.all( tweetsV2.map( async (tweet) => await getTweetV2(tweet.id, auth, options), ), ) ).filter((tweet): tweet is Tweet => tweet !== null); } catch (error) { console.error(`Error fetching tweets for IDs: ${ids.join(', ')}`, error); return []; } } export async function getTweetAnonymous( id: string, auth: TwitterAuth, ): Promise { const tweetResultByRestIdRequest = apiRequestFactory.createTweetResultByRestIdRequest(); tweetResultByRestIdRequest.variables.tweetId = id; const res = await requestApi( tweetResultByRestIdRequest.toRequestUrl(), auth, ); if (!res.success) { throw res.err; } if (!res.value.data) { return null; } return parseTimelineEntryItemContentRaw(res.value.data, id); } interface MediaUploadResponse { media_id_string: string; size: number; expires_after_secs: number; image: { image_type: string; w: number; h: number; }; } async function uploadMedia( mediaData: Buffer, auth: TwitterAuth, mediaType: string, ): Promise { const uploadUrl = 'https://upload.twitter.com/1.1/media/upload.json'; // Get authentication headers const cookies = await auth.cookieJar().getCookies(uploadUrl); const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0'); const headers = new Headers({ authorization: `Bearer ${(auth as any).bearerToken}`, cookie: await auth.cookieJar().getCookieString(uploadUrl), 'x-csrf-token': xCsrfToken?.value as string, }); // Detect if media is a video based on mediaType const isVideo = mediaType.startsWith('video/'); if (isVideo) { // Handle video upload using chunked media upload const mediaId = await uploadVideoInChunks(mediaData, mediaType); return mediaId; } else { // Handle image upload const form = new FormData(); form.append('media', new Blob([mediaData])); const response = await fetch(uploadUrl, { method: 'POST', headers, body: form, }); await updateCookieJar(auth.cookieJar(), response.headers); if (!response.ok) { throw new Error(await response.text()); } const data: MediaUploadResponse = await response.json(); return data.media_id_string; } // Function to upload video in chunks async function uploadVideoInChunks( mediaData: Buffer, mediaType: string, ): Promise { // Initialize upload const initParams = new URLSearchParams(); initParams.append('command', 'INIT'); initParams.append('media_type', mediaType); initParams.append('total_bytes', mediaData.length.toString()); const initResponse = await fetch(uploadUrl, { method: 'POST', headers, body: initParams, }); if (!initResponse.ok) { throw new Error(await initResponse.text()); } const initData = await initResponse.json(); const mediaId = initData.media_id_string; // Append upload in chunks const segmentSize = 5 * 1024 * 1024; // 5 MB per chunk let segmentIndex = 0; for (let offset = 0; offset < mediaData.length; offset += segmentSize) { const chunk = mediaData.slice(offset, offset + segmentSize); const appendForm = new FormData(); appendForm.append('command', 'APPEND'); appendForm.append('media_id', mediaId); appendForm.append('segment_index', segmentIndex.toString()); appendForm.append('media', new Blob([chunk])); const appendResponse = await fetch(uploadUrl, { method: 'POST', headers, body: appendForm, }); if (!appendResponse.ok) { throw new Error(await appendResponse.text()); } segmentIndex++; } // Finalize upload const finalizeParams = new URLSearchParams(); finalizeParams.append('command', 'FINALIZE'); finalizeParams.append('media_id', mediaId); const finalizeResponse = await fetch(uploadUrl, { method: 'POST', headers, body: finalizeParams, }); if (!finalizeResponse.ok) { throw new Error(await finalizeResponse.text()); } const finalizeData = await finalizeResponse.json(); // Check processing status for videos if (finalizeData.processing_info) { await checkUploadStatus(mediaId); } return mediaId; } // Function to check upload status async function checkUploadStatus(mediaId: string): Promise { let processing = true; while (processing) { await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait 5 seconds const statusParams = new URLSearchParams(); statusParams.append('command', 'STATUS'); statusParams.append('media_id', mediaId); const statusResponse = await fetch( `${uploadUrl}?${statusParams.toString()}`, { method: 'GET', headers, }, ); if (!statusResponse.ok) { throw new Error(await statusResponse.text()); } const statusData = await statusResponse.json(); const state = statusData.processing_info.state; if (state === 'succeeded') { processing = false; } else if (state === 'failed') { throw new Error('Video processing failed'); } } } } // Function to create a quote tweet export async function createQuoteTweetRequest( text: string, quotedTweetId: string, auth: TwitterAuth, mediaData?: { data: Buffer; mediaType: string }[], ) { const onboardingTaskUrl = 'https://api.twitter.com/1.1/onboarding/task.json'; // Retrieve necessary cookies and tokens const cookies = await auth.cookieJar().getCookies(onboardingTaskUrl); const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0'); const headers = new Headers({ authorization: `Bearer ${(auth as any).bearerToken}`, cookie: await auth.cookieJar().getCookieString(onboardingTaskUrl), 'content-type': 'application/json', 'User-Agent': 'Mozilla/5.0 (Linux; Android 11; Nokia G20) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.88 Mobile Safari/537.36', 'x-guest-token': (auth as any).guestToken, 'x-twitter-auth-type': 'OAuth2Client', 'x-twitter-active-user': 'yes', 'x-csrf-token': xCsrfToken?.value as string, }); // Construct variables for the GraphQL request const variables: Record = { tweet_text: text, dark_request: false, attachment_url: `https://twitter.com/twitter/status/${quotedTweetId}`, media: { media_entities: [], possibly_sensitive: false, }, semantic_annotation_ids: [], }; // Handle media uploads if any media data is provided if (mediaData && mediaData.length > 0) { const mediaIds = await Promise.all( mediaData.map(({ data, mediaType }) => uploadMedia(data, auth, mediaType), ), ); variables.media.media_entities = mediaIds.map((id) => ({ media_id: id, tagged_users: [], })); } // Send the GraphQL request to create a quote tweet const response = await fetch( 'https://twitter.com/i/api/graphql/a1p9RWpkYKBjWv_I3WzS-A/CreateTweet', { headers, body: JSON.stringify({ variables, features: { interactive_text_enabled: true, longform_notetweets_inline_media_enabled: false, responsive_web_text_conversations_enabled: false, tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: false, vibe_api_enabled: false, rweb_lists_timeline_redesign_enabled: true, responsive_web_graphql_exclude_directive_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, tweetypie_unmention_optimization_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, tweet_awards_web_tipping_enabled: false, freedom_of_speech_not_reach_fetch_enabled: true, standardized_nudges_misinfo: true, longform_notetweets_rich_text_read_enabled: true, responsive_web_enhance_cards_enabled: false, subscriptions_verification_info_enabled: true, subscriptions_verification_info_reason_enabled: true, subscriptions_verification_info_verified_since_enabled: true, super_follow_badge_privacy_enabled: false, super_follow_exclusive_tweet_notifications_enabled: false, super_follow_tweet_api_enabled: false, super_follow_user_api_enabled: false, android_graphql_skip_api_media_color_palette: false, creator_subscriptions_subscription_count_enabled: false, blue_business_profile_image_shape_enabled: false, unified_cards_ad_metadata_container_dynamic_card_content_query_enabled: false, rweb_video_timestamps_enabled: true, c9s_tweet_anatomy_moderator_badge_enabled: true, responsive_web_twitter_article_tweet_consumption_enabled: false, }, fieldToggles: {}, }), method: 'POST', }, ); // Update the cookie jar with any new cookies from the response await updateCookieJar(auth.cookieJar(), response.headers); // Check for errors in the response if (!response.ok) { throw new Error(await response.text()); } return response; } /** * Likes a tweet with the given tweet ID. * @param tweetId The ID of the tweet to like. * @param auth The authentication object. * @returns A promise that resolves when the tweet is liked. */ export async function likeTweet( tweetId: string, auth: TwitterAuth, ): Promise { // Prepare the GraphQL endpoint and payload const likeTweetUrl = 'https://twitter.com/i/api/graphql/lI07N6Otwv1PhnEgXILM7A/FavoriteTweet'; // Retrieve necessary cookies and tokens const cookies = await auth.cookieJar().getCookies(likeTweetUrl); const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0'); const headers = new Headers({ authorization: `Bearer ${(auth as any).bearerToken}`, cookie: await auth.cookieJar().getCookieString(likeTweetUrl), 'content-type': 'application/json', 'x-guest-token': (auth as any).guestToken, 'x-twitter-auth-type': 'OAuth2Client', 'x-twitter-active-user': 'yes', 'x-csrf-token': xCsrfToken?.value as string, }); const payload = { variables: { tweet_id: tweetId, }, }; // Send the POST request to like the tweet const response = await fetch(likeTweetUrl, { method: 'POST', headers, body: JSON.stringify(payload), }); // Update the cookie jar with any new cookies from the response await updateCookieJar(auth.cookieJar(), response.headers); // Check for errors in the response if (!response.ok) { throw new Error(await response.text()); } } /** * Retweets a tweet with the given tweet ID. * @param tweetId The ID of the tweet to retweet. * @param auth The authentication object. * @returns A promise that resolves when the tweet is retweeted. */ export async function retweet( tweetId: string, auth: TwitterAuth, ): Promise { // Prepare the GraphQL endpoint and payload const retweetUrl = 'https://twitter.com/i/api/graphql/ojPdsZsimiJrUGLR1sjUtA/CreateRetweet'; // Retrieve necessary cookies and tokens const cookies = await auth.cookieJar().getCookies(retweetUrl); const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0'); const headers = new Headers({ authorization: `Bearer ${(auth as any).bearerToken}`, cookie: await auth.cookieJar().getCookieString(retweetUrl), 'content-type': 'application/json', 'x-guest-token': (auth as any).guestToken, 'x-twitter-auth-type': 'OAuth2Client', 'x-twitter-active-user': 'yes', 'x-csrf-token': xCsrfToken?.value as string, }); const payload = { variables: { tweet_id: tweetId, dark_request: false, }, }; // Send the POST request to retweet the tweet const response = await fetch(retweetUrl, { method: 'POST', headers, body: JSON.stringify(payload), }); // Update the cookie jar with any new cookies from the response await updateCookieJar(auth.cookieJar(), response.headers); // Check for errors in the response if (!response.ok) { throw new Error(await response.text()); } } export async function createCreateLongTweetRequest( text: string, auth: TwitterAuth, tweetId?: string, mediaData?: { data: Buffer; mediaType: string }[], ) { // URL for the long tweet endpoint const url = 'https://x.com/i/api/graphql/YNXM2DGuE2Sff6a2JD3Ztw/CreateNoteTweet'; const onboardingTaskUrl = 'https://api.twitter.com/1.1/onboarding/task.json'; const cookies = await auth.cookieJar().getCookies(onboardingTaskUrl); const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0'); //@ ts-expect-error - This is a private API. const headers = new Headers({ authorization: `Bearer ${(auth as any).bearerToken}`, cookie: await auth.cookieJar().getCookieString(onboardingTaskUrl), 'content-type': 'application/json', 'User-Agent': 'Mozilla/5.0 (Linux; Android 11; Nokia G20) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.88 Mobile Safari/537.36', 'x-guest-token': (auth as any).guestToken, 'x-twitter-auth-type': 'OAuth2Client', 'x-twitter-active-user': 'yes', 'x-twitter-client-language': 'en', 'x-csrf-token': xCsrfToken?.value as string, }); const variables: Record = { tweet_text: text, dark_request: false, media: { media_entities: [], possibly_sensitive: false, }, semantic_annotation_ids: [], }; if (mediaData && mediaData.length > 0) { const mediaIds = await Promise.all( mediaData.map(({ data, mediaType }) => uploadMedia(data, auth, mediaType), ), ); variables.media.media_entities = mediaIds.map((id) => ({ media_id: id, tagged_users: [], })); } if (tweetId) { variables.reply = { in_reply_to_tweet_id: tweetId }; } const features = { 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: 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, 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: false, rweb_tipjar_consumption_enabled: true, responsive_web_graphql_exclude_directive_enabled: true, verified_phone_label_enabled: false, articles_preview_enabled: true, rweb_video_timestamps_enabled: true, 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_graphql_timeline_navigation_enabled: true, responsive_web_enhance_cards_enabled: false, }; const response = await fetch(url, { headers, body: JSON.stringify({ variables, features, queryId: 'YNXM2DGuE2Sff6a2JD3Ztw', }), method: 'POST', }); await updateCookieJar(auth.cookieJar(), response.headers); // check for errors if (!response.ok) { throw new Error(await response.text()); } return response; }