Skip to content

Commit

Permalink
Merge pull request #18 from kingbootoshi/media-and-engagement
Browse files Browse the repository at this point in the history
Added sendLongTweet functionality to send Twitter Blue long form posts
  • Loading branch information
lalalune authored Dec 9, 2024
2 parents eb2ab3e + aaf9c7d commit 6210993
Show file tree
Hide file tree
Showing 3 changed files with 177 additions and 0 deletions.
61 changes: 61 additions & 0 deletions command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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 <text> [mediaFiles...] - Send a tweet with optional media attachments');
console.log(' send-long-tweet <text> [mediaFiles...] - Send a long tweet (Note Tweet) with optional media attachments');
console.log(' get-tweets <username> - Get recent tweets from the specified user');
console.log(' get-replies <tweetId> - Get replies to the specified tweet ID');
console.log(' reply-to-tweet <tweetId> <text> - Reply to a tweet with the specified text');
Expand Down Expand Up @@ -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<string | null> {
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!');
Expand Down
16 changes: 16 additions & 0 deletions src/scraper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
createQuoteTweetRequest,
likeTweet,
retweet,
createCreateLongTweetRequest,
} from './tweets';
import { parseTimelineTweetsV2, TimelineV2 } from './timeline-v2';
import { fetchHomeTimeline } from './timeline-home';
Expand Down Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions src/tweets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {
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;
}

0 comments on commit 6210993

Please sign in to comment.