diff --git a/command.ts b/command.ts index 5353520..2dba35e 100644 --- a/command.ts +++ b/command.ts @@ -282,6 +282,23 @@ async function executeCommand(commandLine: string) { break; } + case 'send-long-tweet': { + await ensureAuthenticated(); + + // First argument is the tweet text + const tweetText = args[0]; + // Remaining arguments are media file paths + const mediaFiles = args.slice(1); + + if (!tweetText) { + console.log('Please provide text for the long tweet.'); + } else { + // Call the sendLongTweetCommand with optional media files + await sendLongTweetCommand(tweetText, mediaFiles); + } + break; + } + case 'get-tweets': await ensureAuthenticated(); const username = args[0]; @@ -368,6 +385,7 @@ async function executeCommand(commandLine: string) { console.log('Available commands:'); console.log(' login - Login to Twitter and save cookies'); console.log(' send-tweet [mediaFiles...] - Send a tweet with optional media attachments'); + console.log(' send-long-tweet [mediaFiles...] - Send a long tweet (Note Tweet) with optional media attachments'); console.log(' get-tweets - Get recent tweets from the specified user'); console.log(' get-replies - Get replies to the specified tweet ID'); console.log(' reply-to-tweet - Reply to a tweet with the specified text'); @@ -474,6 +492,49 @@ async function executeCommand(commandLine: string) { } } +// Function to send a long tweet (Note Tweet) with optional media files +async function sendLongTweetCommand( + text: string, + mediaFiles?: string[], + replyToTweetId?: string +): Promise { + try { + let mediaData; + + if (mediaFiles && mediaFiles.length > 0) { + // Prepare media data by reading files and determining media types + mediaData = await Promise.all( + mediaFiles.map(async (filePath) => { + const absolutePath = path.resolve(__dirname, filePath); + const buffer = await fs.promises.readFile(absolutePath); + const ext = path.extname(filePath).toLowerCase(); + const mediaType = getMediaType(ext); + return { data: buffer, mediaType }; + }) + ); + } + + // Send the long tweet using the sendLongTweet function + const response = await scraper.sendLongTweet(text, replyToTweetId, mediaData); + + // Parse the response to extract the tweet ID + const responseData = await response.json(); + const tweetId = + responseData?.data?.notetweet_create?.tweet_results?.result?.rest_id; + + if (tweetId) { + console.log(`Long tweet sent: "${text.substring(0, 50)}..." (ID: ${tweetId})`); + return tweetId; + } else { + console.error('Tweet ID not found in response.'); + return null; + } + } catch (error) { + console.error('Error sending long tweet:', error); + return null; + } +} + // Main function to start the CLI (async () => { console.log('Welcome to the Twitter CLI Interface!'); diff --git a/src/scraper.ts b/src/scraper.ts index 3f2dceb..bc3f2cd 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -51,6 +51,7 @@ import { createQuoteTweetRequest, likeTweet, retweet, + createCreateLongTweetRequest, } from './tweets'; import { parseTimelineTweetsV2, TimelineV2 } from './timeline-v2'; import { fetchHomeTimeline } from './timeline-home'; @@ -450,6 +451,21 @@ export class Scraper { return await createCreateTweetRequest(text, this.auth, replyToTweetId, mediaData); } + /** + * Send a long tweet (Note Tweet) + * @param text The text of the tweet + * @param tweetId The id of the tweet to reply to + * @param mediaData Optional media data + * @returns + */ + async sendLongTweet( + text: string, + replyToTweetId?: string, + mediaData?: { data: Buffer; mediaType: string }[], + ) { + return await createCreateLongTweetRequest(text, this.auth, replyToTweetId, mediaData); + } + /** * Send a tweet * @param text The text of the tweet diff --git a/src/tweets.ts b/src/tweets.ts index 02e2b40..3d181ce 100644 --- a/src/tweets.ts +++ b/src/tweets.ts @@ -1238,4 +1238,104 @@ export async function retweet( 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; } \ No newline at end of file