Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/update-query-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const TARGET_OPERATIONS = [
'UnfavoriteTweet',
'CreateBookmark',
'DeleteBookmark',
'bookmarkTweetToFolder',
'TweetDetail',
'SearchTimeline',
'Bookmarks',
Expand Down
6 changes: 6 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,6 +27,8 @@ export const KNOWN_COMMANDS = new Set([
'thread',
'search',
'mentions',
'bookmark',
'bookmark-to-folder',
'bookmarks',
'unbookmark',
'follow',
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 37 additions & 0 deletions src/commands/bookmark-to-folder.ts
Original file line number Diff line number Diff line change
@@ -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>', 'Tweet ID or URL to bookmark')
.requiredOption('--folder-id <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);
}
});
}
43 changes: 43 additions & 0 deletions src/commands/bookmark.ts
Original file line number Diff line number Diff line change
@@ -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-id-or-url...>', '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);
}
});
}
2 changes: 2 additions & 0 deletions src/lib/query-ids.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"CreateFriendship": "8h9JVdV8dlSyqyRDJEPCsA",
"DestroyFriendship": "ppXWuagMNXgvzx6WoXBW0Q",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q",
"DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
"bookmarkTweetToFolder": "4KHZvvNbHNf07bsgnL9gWA",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add new operation to query-id refresh list

The new bookmarkTweetToFolder entry in query-ids.json will be dropped the next time scripts/update-query-ids.ts runs, because its TARGET_OPERATIONS list does not include this operation. That means the refresh process will never update this query ID and the CLI will eventually fail once the ID rotates (even if other IDs are kept current). Consider adding bookmarkTweetToFolder to TARGET_OPERATIONS so the refresh script preserves and updates it.

Useful? React with 👍 / 👎.

"TweetDetail": "_NvJCnIjOW__EP5-RF197A",
"SearchTimeline": "6AAys3t42mosm_yTI_QENg",
"Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw",
Expand Down
1 change: 1 addition & 0 deletions src/lib/twitter-client-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
63 changes: 63 additions & 0 deletions src/lib/twitter-client-engagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface TwitterClientEngagementMethods {
unretweet(tweetId: string): Promise<BookmarkMutationResult>;
/** Bookmark a tweet. */
bookmark(tweetId: string): Promise<BookmarkMutationResult>;
/** Bookmark a tweet to a specific folder. */
bookmarkToFolder(tweetId: string, folderId: string): Promise<BookmarkMutationResult>;
}

export function withEngagement<TBase extends AbstractConstructor<TwitterClientBase>>(
Expand Down Expand Up @@ -112,6 +114,67 @@ export function withEngagement<TBase extends AbstractConstructor<TwitterClientBa
async bookmark(tweetId: string): Promise<BookmarkMutationResult> {
return this.performEngagementMutation('CreateBookmark', tweetId);
}

/** Bookmark a tweet to a specific folder. */
async bookmarkToFolder(tweetId: string, folderId: string): Promise<BookmarkMutationResult> {
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<BookmarkMutationResult> => {
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;
Expand Down