diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts index 19213ea..154353f 100644 --- a/scripts/update-query-ids.ts +++ b/scripts/update-query-ids.ts @@ -17,6 +17,7 @@ const TARGET_OPERATIONS = [ 'UnfavoriteTweet', 'CreateBookmark', 'DeleteBookmark', + 'bookmarkTweetToFolder', 'TweetDetail', 'SearchTimeline', 'Bookmarks', diff --git a/src/cli/program.ts b/src/cli/program.ts index 6c5c605..763cebf 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,4 +1,6 @@ import { Command } from 'commander'; +import { registerBookmarkCommand } from '../commands/bookmark.js'; +import { registerBookmarkToFolderCommand } from '../commands/bookmark-to-folder.js'; import { registerBookmarksCommand } from '../commands/bookmarks.js'; import { registerCheckCommand } from '../commands/check.js'; import { registerFollowCommands } from '../commands/follow.js'; @@ -25,6 +27,8 @@ export const KNOWN_COMMANDS = new Set([ 'thread', 'search', 'mentions', + 'bookmark', + 'bookmark-to-folder', 'bookmarks', 'unbookmark', 'follow', @@ -143,6 +147,8 @@ export function createProgram(ctx: CliContext): Command { registerPostCommands(program, ctx); registerReadCommands(program, ctx); registerSearchCommands(program, ctx); + registerBookmarkCommand(program, ctx); + registerBookmarkToFolderCommand(program, ctx); registerBookmarksCommand(program, ctx); registerUnbookmarkCommand(program, ctx); registerFollowCommands(program, ctx); diff --git a/src/commands/bookmark-to-folder.ts b/src/commands/bookmark-to-folder.ts new file mode 100644 index 0000000..c491580 --- /dev/null +++ b/src/commands/bookmark-to-folder.ts @@ -0,0 +1,37 @@ +import type { Command } from 'commander'; +import type { CliContext } from '../cli/shared.js'; +import { TwitterClient } from '../lib/twitter-client.js'; + +export function registerBookmarkToFolderCommand(program: Command, ctx: CliContext): void { + program + .command('bookmark-to-folder') + .description('Bookmark a tweet to a specific folder') + .argument('', 'Tweet ID or URL to bookmark') + .requiredOption('--folder-id ', 'Bookmark folder ID') + .action(async (tweetIdOrUrl: string, options: { folderId: string }) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + + const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); + + for (const warning of warnings) { + console.error(`${ctx.p('warn')}${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error(`${ctx.p('err')}Missing required credentials`); + process.exit(1); + } + + const client = new TwitterClient({ cookies, timeoutMs }); + const tweetId = ctx.extractTweetId(tweetIdOrUrl); + const result = await client.bookmarkToFolder(tweetId, options.folderId); + + if (result.success) { + console.log(`${ctx.p('ok')}Bookmarked ${tweetId} to folder ${options.folderId}`); + } else { + console.error(`${ctx.p('err')}Failed to bookmark ${tweetId} to folder: ${result.error}`); + process.exit(1); + } + }); +} diff --git a/src/commands/bookmark.ts b/src/commands/bookmark.ts new file mode 100644 index 0000000..bf8ae41 --- /dev/null +++ b/src/commands/bookmark.ts @@ -0,0 +1,43 @@ +import type { Command } from 'commander'; +import type { CliContext } from '../cli/shared.js'; +import { TwitterClient } from '../lib/twitter-client.js'; + +export function registerBookmarkCommand(program: Command, ctx: CliContext): void { + program + .command('bookmark') + .description('Bookmark tweets') + .argument('', 'Tweet IDs or URLs to bookmark') + .action(async (tweetIdOrUrls: string[]) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + + const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); + + for (const warning of warnings) { + console.error(`${ctx.p('warn')}${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error(`${ctx.p('err')}Missing required credentials`); + process.exit(1); + } + + const client = new TwitterClient({ cookies, timeoutMs }); + let failures = 0; + + for (const input of tweetIdOrUrls) { + const tweetId = ctx.extractTweetId(input); + const result = await client.bookmark(tweetId); + if (result.success) { + console.log(`${ctx.p('ok')}Bookmarked ${tweetId}`); + } else { + failures += 1; + console.error(`${ctx.p('err')}Failed to bookmark ${tweetId}: ${result.error}`); + } + } + + if (failures > 0) { + process.exit(1); + } + }); +} diff --git a/src/lib/query-ids.json b/src/lib/query-ids.json index 6033f31..442d0e4 100644 --- a/src/lib/query-ids.json +++ b/src/lib/query-ids.json @@ -4,7 +4,9 @@ "CreateFriendship": "8h9JVdV8dlSyqyRDJEPCsA", "DestroyFriendship": "ppXWuagMNXgvzx6WoXBW0Q", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", + "CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q", "DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ", + "bookmarkTweetToFolder": "4KHZvvNbHNf07bsgnL9gWA", "TweetDetail": "_NvJCnIjOW__EP5-RF197A", "SearchTimeline": "6AAys3t42mosm_yTI_QENg", "Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw", diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts index bcc70cc..7953b35 100644 --- a/src/lib/twitter-client-constants.ts +++ b/src/lib/twitter-client-constants.ts @@ -23,6 +23,7 @@ export const FALLBACK_QUERY_IDS = { UnfavoriteTweet: 'ZYKSe-w7KEslx3JhSIk5LA', CreateBookmark: 'aoDbu3RHznuiSkQ9aNM67Q', DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ', + bookmarkTweetToFolder: '4KHZvvNbHNf07bsgnL9gWA', TweetDetail: '97JF30KziU00483E_8elBA', SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', diff --git a/src/lib/twitter-client-engagement.ts b/src/lib/twitter-client-engagement.ts index 32435ac..e5dfc33 100644 --- a/src/lib/twitter-client-engagement.ts +++ b/src/lib/twitter-client-engagement.ts @@ -13,6 +13,8 @@ export interface TwitterClientEngagementMethods { unretweet(tweetId: string): Promise; /** Bookmark a tweet. */ bookmark(tweetId: string): Promise; + /** Bookmark a tweet to a specific folder. */ + bookmarkToFolder(tweetId: string, folderId: string): Promise; } export function withEngagement>( @@ -112,6 +114,67 @@ export function withEngagement { return this.performEngagementMutation('CreateBookmark', tweetId); } + + /** Bookmark a tweet to a specific folder. */ + async bookmarkToFolder(tweetId: string, folderId: string): Promise { + await this.ensureClientUserId(); + const variables = { tweet_id: tweetId, bookmark_collection_id: folderId }; + let queryId = await this.getQueryId('bookmarkTweetToFolder'); + let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/bookmarkTweetToFolder`; + + const buildBody = () => JSON.stringify({ variables, queryId }); + const buildHeaders = () => ({ ...this.getHeaders(), referer: `https://x.com/i/bookmarks` }); + let body = buildBody(); + + const parseResponse = async (response: Response): Promise => { + 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 { errors?: Array<{ message: string }> }; + if (data.errors && data.errors.length > 0) { + return { success: false, error: data.errors.map((e) => e.message).join(', ') }; + } + + return { success: true }; + }; + + try { + let response = await this.fetchWithTimeout(urlWithOperation, { + method: 'POST', + headers: buildHeaders(), + body, + }); + + if (response.status === 404) { + await this.refreshQueryIds(); + queryId = await this.getQueryId('bookmarkTweetToFolder'); + urlWithOperation = `${TWITTER_API_BASE}/${queryId}/bookmarkTweetToFolder`; + body = buildBody(); + + response = await this.fetchWithTimeout(urlWithOperation, { + method: 'POST', + headers: buildHeaders(), + body, + }); + + if (response.status === 404) { + const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, { + method: 'POST', + headers: buildHeaders(), + body, + }); + + return parseResponse(retry); + } + } + + return parseResponse(response); + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } } return TwitterClientEngagement;