diff --git a/src/index.ts b/src/index.ts index 2bec8cd..4a4fb5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -150,6 +150,110 @@ program } }); +// Search command - find tweets +program + .command('search') + .description('Search for tweets') + .argument('', 'Search query (e.g., "@clawdbot" or "from:clawdbot")') + .option('-n, --count ', 'Number of tweets to fetch', '10') + .option('--json', 'Output as JSON') + .action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => { + const opts = program.opts(); + const { cookies, warnings } = await resolveCredentials({ + authToken: opts.authToken, + ct0: opts.ct0, + chromeProfile: opts.chromeProfile, + }); + + for (const warning of warnings) { + console.error(`⚠️ ${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error('❌ Missing required credentials'); + process.exit(1); + } + + const client = new TwitterClient({ cookies }); + const count = parseInt(cmdOpts.count || '10', 10); + const result = await client.search(query, count); + + if (result.success && result.tweets) { + if (cmdOpts.json) { + console.log(JSON.stringify(result.tweets, null, 2)); + } else { + if (result.tweets.length === 0) { + console.log('No tweets found.'); + } else { + for (const tweet of result.tweets) { + console.log(`\n@${tweet.author.username} (${tweet.author.name}):`); + console.log(tweet.text); + if (tweet.createdAt) { + console.log(`📅 ${tweet.createdAt}`); + } + console.log(`🔗 https://x.com/${tweet.author.username}/status/${tweet.id}`); + console.log('─'.repeat(50)); + } + } + } + } else { + console.error(`❌ Search failed: ${result.error}`); + process.exit(1); + } + }); + +// Mentions command - shortcut to search for @username mentions +program + .command('mentions') + .description('Find tweets mentioning @clawdbot') + .option('-n, --count ', 'Number of tweets to fetch', '10') + .option('--json', 'Output as JSON') + .action(async (cmdOpts: { count?: string; json?: boolean }) => { + const opts = program.opts(); + const { cookies, warnings } = await resolveCredentials({ + authToken: opts.authToken, + ct0: opts.ct0, + chromeProfile: opts.chromeProfile, + }); + + for (const warning of warnings) { + console.error(`⚠️ ${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error('❌ Missing required credentials'); + process.exit(1); + } + + const client = new TwitterClient({ cookies }); + const count = parseInt(cmdOpts.count || '10', 10); + const result = await client.search('@clawdbot', count); + + if (result.success && result.tweets) { + if (cmdOpts.json) { + console.log(JSON.stringify(result.tweets, null, 2)); + } else { + if (result.tweets.length === 0) { + console.log('No mentions found.'); + } else { + console.log(`Found ${result.tweets.length} mentions:\n`); + for (const tweet of result.tweets) { + console.log(`@${tweet.author.username} (${tweet.author.name}):`); + console.log(tweet.text); + if (tweet.createdAt) { + console.log(`📅 ${tweet.createdAt}`); + } + console.log(`🔗 https://x.com/${tweet.author.username}/status/${tweet.id}`); + console.log('─'.repeat(50)); + } + } + } + } else { + console.error(`❌ Failed to fetch mentions: ${result.error}`); + process.exit(1); + } + }); + // Check command - verify credentials program .command('check') diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index be1b5a7..e1bce8a 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -79,7 +79,15 @@ function decryptCookieValue(encryptedHex: string): string | null { let decrypted = decipher.update(encryptedData); decrypted = Buffer.concat([decrypted, decipher.final()]); - return decrypted.toString('utf8'); + // Chrome v10 cookies have key material prepended before the actual value + // Twitter cookies are hex strings, so extract the longest hex sequence + const decryptedStr = decrypted.toString('utf8'); + const hexMatch = decryptedStr.match(/[a-f0-9]{32,}/i); + if (hexMatch) { + return hexMatch[0]; + } + // Fallback: keep only printable ASCII characters + return decryptedStr.replace(/[^\x20-\x7E]/g, ''); } catch { return null; } diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 4aab513..41c391a 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -14,6 +14,7 @@ const QUERY_IDS = { CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ', + SearchTimeline: 'Tp1sewRU1AsZpBWhqCZicQ', }; export interface TweetResult { @@ -41,6 +42,12 @@ export interface GetTweetResult { error?: string; } +export interface SearchResult { + success: boolean; + tweets?: TweetData[]; + error?: string; +} + export interface TwitterClientOptions { cookies: TwitterCookies; userAgent?: string; @@ -480,4 +487,152 @@ export class TwitterClient { }; } } + + /** + * Search for tweets matching a query + */ + async search(query: string, count = 20): Promise { + const variables = { + rawQuery: query, + count, + querySource: 'typed_query', + product: 'Latest', + }; + + const features = { + rweb_tipjar_consumption_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, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_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, + 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_enhance_cards_enabled: false, + }; + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + const url = `${TWITTER_API_BASE}/${QUERY_IDS.SearchTimeline}/SearchTimeline?${params}`; + + try { + const response = await fetch(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + 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 { + 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, + error: data.errors.map((e) => e.message).join(', '), + }; + } + + const tweets: TweetData[] = []; + const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || []; + + for (const instruction of instructions) { + for (const entry of instruction.entries || []) { + const result = entry.content?.itemContent?.tweet_results?.result; + if (!result) continue; + + const legacy = result.legacy; + const userLegacy = result.core?.user_results?.result?.legacy; + + if (legacy?.full_text && userLegacy?.screen_name) { + tweets.push({ + id: result.rest_id || '', + text: legacy.full_text, + author: { + username: userLegacy.screen_name, + name: userLegacy.name || userLegacy.screen_name, + }, + createdAt: legacy.created_at, + replyCount: legacy.reply_count, + retweetCount: legacy.retweet_count, + likeCount: legacy.favorite_count, + }); + } + } + } + + return { + success: true, + tweets, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } }