diff --git a/README.md b/README.md index 00659994b..206410d8d 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil | **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` | | **linkedin** | `connect` `inbox` `job-detail` `jobs-preferences` `post-analytics` `posts` `profile-experience` `profile-projects` `profile-read` `profile-analytics` `safe-send` `search` `services-read` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` | -| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` | +| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-create` `list-delete` `list-add` `list-add-batch` `list-remove` `list-remove-batch` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` | | **claude** | `ask` `send` `new` `status` `read` `history` `detail` | | **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | | **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` | diff --git a/README.zh-CN.md b/README.zh-CN.md index 891c9e28a..a5444f05f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -164,7 +164,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 | **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` | | **linkedin** | `connect` `inbox` `job-detail` `jobs-preferences` `post-analytics` `posts` `profile-experience` `profile-projects` `profile-read` `profile-analytics` `safe-send` `search` `people-search` `services-read` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | -| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | +| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-create` `list-delete` `list-add` `list-add-batch` `list-remove` `list-remove-batch` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | | **claude** | `ask` `send` `new` `status` `read` `history` `detail` | | **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | | **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` | diff --git a/cli-manifest.json b/cli-manifest.json index 944958737..6c0c391dc 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -25594,6 +25594,40 @@ "sourceFile": "twitter/follow.js", "navigateBefore": true }, + { + "site": "twitter", + "name": "follow-batch", + "description": "Follow multiple Twitter/X users from a comma-separated username list", + "access": "write", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "usernames", + "type": "string", + "required": true, + "positional": true, + "help": "Comma-separated Twitter/X screen names, with or without @" + }, + { + "name": "delay-ms", + "type": "int", + "default": 3000, + "required": false, + "help": "Delay between follow attempts in milliseconds" + } + ], + "columns": [ + "username", + "status", + "message" + ], + "type": "js", + "modulePath": "twitter/follow-batch.js", + "sourceFile": "twitter/follow-batch.js", + "navigateBefore": true + }, { "site": "twitter", "name": "followers", @@ -25799,6 +25833,56 @@ "sourceFile": "twitter/list-add.js", "navigateBefore": true }, + { + "site": "twitter", + "name": "list-add-batch", + "description": "Add multiple users to a Twitter/X list you own from a comma-separated username list", + "access": "write", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "listId", + "type": "string", + "required": true, + "positional": true, + "help": "Numeric ID of the list you own (e.g. from `opencli twitter lists`)" + }, + { + "name": "usernames", + "type": "string", + "required": true, + "positional": true, + "help": "Comma-separated Twitter/X handles to add (with or without @)" + }, + { + "name": "interval", + "type": "int", + "default": 5, + "required": false, + "help": "Seconds to wait between account additions (default: 5)" + }, + { + "name": "timeout", + "type": "int", + "default": 600, + "required": false, + "help": "Max seconds for the overall batch command (default: 600)" + } + ], + "columns": [ + "listId", + "username", + "userId", + "status", + "message" + ], + "type": "js", + "modulePath": "twitter/list-add-batch.js", + "sourceFile": "twitter/list-add-batch.js", + "navigateBefore": true + }, { "site": "twitter", "name": "list-create", @@ -25842,6 +25926,49 @@ "sourceFile": "twitter/list-create.js", "navigateBefore": "https://x.com" }, + { + "site": "twitter", + "name": "list-delete", + "description": "Delete a Twitter/X list you own after explicit confirmation", + "access": "write", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "listId", + "type": "string", + "required": true, + "positional": true, + "help": "Numeric ID of the list you own (e.g. from `opencli twitter lists`)" + }, + { + "name": "confirm", + "type": "boolean", + "default": false, + "required": false, + "help": "Required. Set --confirm true to delete the list." + }, + { + "name": "timeout", + "type": "int", + "default": 300, + "required": false, + "help": "Max seconds for the overall delete command (default: 300)" + } + ], + "columns": [ + "listId", + "name", + "members", + "status", + "message" + ], + "type": "js", + "modulePath": "twitter/list-delete.js", + "sourceFile": "twitter/list-delete.js", + "navigateBefore": true + }, { "site": "twitter", "name": "list-remove", @@ -25878,6 +26005,56 @@ "sourceFile": "twitter/list-remove.js", "navigateBefore": true }, + { + "site": "twitter", + "name": "list-remove-batch", + "description": "Remove multiple users from a Twitter/X list you own from a comma-separated username list", + "access": "write", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "listId", + "type": "string", + "required": true, + "positional": true, + "help": "Numeric ID of the list you own (e.g. from `opencli twitter lists`)" + }, + { + "name": "usernames", + "type": "string", + "required": true, + "positional": true, + "help": "Comma-separated Twitter/X handles to remove (with or without @)" + }, + { + "name": "interval", + "type": "int", + "default": 5, + "required": false, + "help": "Seconds to wait between account removals (default: 5)" + }, + { + "name": "timeout", + "type": "int", + "default": 600, + "required": false, + "help": "Max seconds for the overall batch command (default: 600)" + } + ], + "columns": [ + "listId", + "username", + "userId", + "status", + "message" + ], + "type": "js", + "modulePath": "twitter/list-remove-batch.js", + "sourceFile": "twitter/list-remove-batch.js", + "navigateBefore": true + }, { "site": "twitter", "name": "list-tweets", diff --git a/clis/twitter/follow-batch.js b/clis/twitter/follow-batch.js new file mode 100644 index 000000000..befa77ee3 --- /dev/null +++ b/clis/twitter/follow-batch.js @@ -0,0 +1,162 @@ +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { unwrapBrowserResult } from './shared.js'; + +const USERNAME_RE = /^[A-Za-z0-9_]{1,15}$/; +const DEFAULT_DELAY_MS = 3000; +const MAX_DELAY_MS = 60000; + +export function parseBatchUsernames(input) { + const raw = String(input || '').trim(); + if (!raw) { + throw new ArgumentError('At least one Twitter/X username is required'); + } + + const usernames = []; + const seen = new Set(); + for (const part of raw.split(',')) { + const username = part.trim().replace(/^@+/, ''); + if (!username) continue; + if (!USERNAME_RE.test(username)) { + throw new ArgumentError(`Invalid Twitter/X username: ${JSON.stringify(part.trim())}`); + } + const key = username.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + usernames.push(username); + } + + if (!usernames.length) { + throw new ArgumentError('At least one Twitter/X username is required'); + } + return usernames; +} + +async function readFollowState(page, username) { + return unwrapBrowserResult(await page.evaluate(`(async () => { + try { + let attempts = 0; + while (attempts < 20) { + const unfollowBtn = document.querySelector('[data-testid$="-unfollow"]'); + if (unfollowBtn) { + return { ok: true, status: 'noop', message: 'Already following @${username}.' }; + } + + const followBtn = document.querySelector('[data-testid$="-follow"]'); + if (followBtn) { + return { ok: false, followButtonVisible: true }; + } + + await new Promise(r => setTimeout(r, 500)); + attempts++; + } + + return { ok: false, followButtonVisible: false }; + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`)); +} + +async function clickFollowAndVerify(page, username) { + return unwrapBrowserResult(await page.evaluate(`(async () => { + try { + const followBtn = document.querySelector('[data-testid$="-follow"]'); + if (!followBtn) { + return { ok: false, retryAfterRefresh: true, message: 'Could not find Follow button after loading profile.' }; + } + + followBtn.click(); + for (let attempts = 0; attempts < 20; attempts++) { + await new Promise(r => setTimeout(r, 500)); + const verify = document.querySelector('[data-testid$="-unfollow"]'); + if (verify) { + return { ok: true, status: 'success', message: 'Successfully followed @${username}.' }; + } + } + + return { ok: false, retryAfterRefresh: true, message: 'Follow action initiated but UI did not update.' }; + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`)); +} + +export async function followOne(page, username) { + await page.goto(`https://x.com/${username}`); + await page.wait({ selector: '[data-testid="primaryColumn"]' }); + + let result = await readFollowState(page, username); + if (!result.ok && result.followButtonVisible) { + result = await clickFollowAndVerify(page, username); + } + if (!result.ok && result.retryAfterRefresh) { + await page.goto(`https://x.com/${username}`); + await page.wait({ selector: '[data-testid="primaryColumn"]' }); + const refreshed = await readFollowState(page, username); + if (refreshed.ok) { + result = { ...refreshed, status: 'success', message: `Successfully followed @${username}.` }; + } + } + if (!result.ok && !result.message) { + result = { ...result, message: 'Could not find Follow button. Are you logged in?' }; + } + + if (result.ok) { + await page.wait(1); + } + + return { + username, + status: result.ok ? result.status : 'failed', + message: result.message, + }; +} + +export function parseDelayMs(input) { + if (input === undefined || input === null || input === '') { + return DEFAULT_DELAY_MS; + } + const value = Number(input); + if (!Number.isInteger(value) || value < 0 || value > MAX_DELAY_MS) { + throw new ArgumentError(`delay-ms must be an integer between 0 and ${MAX_DELAY_MS}`); + } + return value; +} + +cli({ + site: 'twitter', + name: 'follow-batch', + access: 'write', + description: 'Follow multiple Twitter/X users from a comma-separated username list', + domain: 'x.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'usernames', type: 'string', positional: true, required: true, help: 'Comma-separated Twitter/X screen names, with or without @' }, + { name: 'delay-ms', type: 'int', default: DEFAULT_DELAY_MS, help: 'Delay between follow attempts in milliseconds' }, + ], + columns: ['username', 'status', 'message'], + func: async (page, kwargs) => { + if (!page) { + throw new CommandExecutionError('Browser session required for twitter follow-batch'); + } + + const usernames = parseBatchUsernames(kwargs.usernames); + const delayMs = parseDelayMs(kwargs['delay-ms']); + const cookies = await page.getCookies({ url: 'https://x.com' }); + const ct0 = cookies.find((cookie) => cookie.name === 'ct0')?.value || null; + if (!ct0) { + throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)'); + } + + const rows = []; + for (const [index, username] of usernames.entries()) { + if (index > 0 && delayMs > 0) { + await page.wait(delayMs / 1000); + } + rows.push(await followOne(page, username)); + } + return rows; + } +}); diff --git a/clis/twitter/follow-batch.test.js b/clis/twitter/follow-batch.test.js new file mode 100644 index 000000000..faf018a59 --- /dev/null +++ b/clis/twitter/follow-batch.test.js @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors'; +import { followOne, parseBatchUsernames, parseDelayMs } from './follow-batch.js'; +import './follow-batch.js'; + +describe('twitter follow-batch command', () => { + it('registers with the expected shape', () => { + const cmd = getRegistry().get('twitter/follow-batch'); + expect(cmd?.func).toBeTypeOf('function'); + expect(cmd?.columns).toEqual(['username', 'status', 'message']); + expect(cmd?.access).toBe('write'); + expect(cmd?.browser).toBe(true); + expect(cmd?.args?.[0]).toMatchObject({ + name: 'usernames', + positional: true, + required: true, + }); + expect(cmd?.args?.find((arg) => arg.name === 'delay-ms')?.default).toBe(3000); + }); + + it('parses comma-separated usernames, strips @, and deduplicates case-insensitively', () => { + expect(parseBatchUsernames(' @karpathy, swyx,Karpathy, rauchg ')).toEqual([ + 'karpathy', + 'swyx', + 'rauchg', + ]); + }); + + it('rejects empty and invalid usernames', () => { + expect(() => parseBatchUsernames(' , , ')).toThrow(ArgumentError); + expect(() => parseBatchUsernames('valid,not/valid')).toThrow(ArgumentError); + expect(() => parseBatchUsernames('x'.repeat(16))).toThrow(ArgumentError); + }); + + it('parses and validates delay-ms', () => { + expect(parseDelayMs(undefined)).toBe(3000); + expect(parseDelayMs(0)).toBe(0); + expect(parseDelayMs('2500')).toBe(2500); + expect(() => parseDelayMs(-1)).toThrow(ArgumentError); + expect(() => parseDelayMs(60001)).toThrow(ArgumentError); + expect(() => parseDelayMs('1.5')).toThrow(ArgumentError); + }); + + it('follows each parsed username sequentially and returns per-user rows', async () => { + const cmd = getRegistry().get('twitter/follow-batch'); + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]), + evaluate: vi.fn() + .mockResolvedValueOnce({ ok: false, followButtonVisible: true }) + .mockResolvedValueOnce({ ok: true, status: 'success', message: 'Successfully followed @karpathy.' }) + .mockResolvedValueOnce({ ok: true, status: 'noop', message: 'Already following @swyx.' }) + .mockResolvedValueOnce({ ok: false, followButtonVisible: false }), + }; + + const rows = await cmd.func(page, { usernames: '@karpathy,swyx,baduser', 'delay-ms': 2500 }); + + expect(page.goto).toHaveBeenNthCalledWith(1, 'https://x.com/karpathy'); + expect(page.goto).toHaveBeenNthCalledWith(2, 'https://x.com/swyx'); + expect(page.goto).toHaveBeenNthCalledWith(3, 'https://x.com/baduser'); + expect(page.wait).toHaveBeenCalledWith(2.5); + expect(rows).toEqual([ + { username: 'karpathy', status: 'success', message: 'Successfully followed @karpathy.' }, + { username: 'swyx', status: 'noop', message: 'Already following @swyx.' }, + { username: 'baduser', status: 'failed', message: 'Could not find Follow button. Are you logged in?' }, + ]); + }); + + it('unwraps Browser Bridge evaluate envelopes before interpreting follow state', async () => { + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn() + .mockResolvedValueOnce({ session: 's1', data: { ok: false, followButtonVisible: true } }) + .mockResolvedValueOnce({ session: 's1', data: { ok: true, status: 'success', message: 'Successfully followed @karpathy.' } }), + }; + + const row = await followOne(page, 'karpathy'); + + expect(row).toEqual({ + username: 'karpathy', + status: 'success', + message: 'Successfully followed @karpathy.', + }); + }); + + it('fails typed before batch execution when the browser session is not authenticated', async () => { + const cmd = getRegistry().get('twitter/follow-batch'); + const page = { + goto: vi.fn(), + wait: vi.fn(), + getCookies: vi.fn().mockResolvedValue([]), + evaluate: vi.fn(), + }; + + await expect(cmd.func(page, { usernames: '@karpathy,swyx' })).rejects.toBeInstanceOf(AuthRequiredError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('rejects invalid batch input before reading browser authentication state', async () => { + const cmd = getRegistry().get('twitter/follow-batch'); + const page = { + goto: vi.fn(), + wait: vi.fn(), + getCookies: vi.fn(), + evaluate: vi.fn(), + }; + + await expect(cmd.func(page, { usernames: 'valid,not/valid' })).rejects.toBeInstanceOf(ArgumentError); + expect(page.getCookies).not.toHaveBeenCalled(); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('refreshes the profile before reporting a failed post-click verification', async () => { + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn() + .mockResolvedValueOnce({ ok: false, followButtonVisible: true }) + .mockResolvedValueOnce({ ok: false, retryAfterRefresh: true, message: 'Follow action initiated but UI did not update.' }) + .mockResolvedValueOnce({ ok: true, status: 'noop', message: 'Already following @langchainai.' }), + }; + + const row = await followOne(page, 'LangChainAI'); + + expect(page.goto).toHaveBeenCalledTimes(2); + expect(page.goto).toHaveBeenNthCalledWith(1, 'https://x.com/LangChainAI'); + expect(page.goto).toHaveBeenNthCalledWith(2, 'https://x.com/LangChainAI'); + expect(row).toEqual({ + username: 'LangChainAI', + status: 'success', + message: 'Successfully followed @LangChainAI.', + }); + }); +}); diff --git a/clis/twitter/list-add-batch.js b/clis/twitter/list-add-batch.js new file mode 100644 index 000000000..805287f77 --- /dev/null +++ b/clis/twitter/list-add-batch.js @@ -0,0 +1,32 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { listAddUser } from './list-add-core.js'; +import { + parseBatchIntervalSeconds, + parseCommaSeparatedUsernames, + runListBatch, +} from './list-batch-utils.js'; + +const EXAMPLE = 'Example: opencli twitter list-add-batch 123456789 "@alice,@bob" --interval 5'; + +cli({ + site: 'twitter', + name: 'list-add-batch', + access: 'write', + description: 'Add multiple users to a Twitter/X list you own from a comma-separated username list', + domain: 'x.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of the list you own (e.g. from `opencli twitter lists`)' }, + { name: 'usernames', positional: true, type: 'string', required: true, help: 'Comma-separated Twitter/X handles to add (with or without @)' }, + { name: 'interval', type: 'int', default: 5, help: 'Seconds to wait between account additions (default: 5)' }, + { name: 'timeout', type: 'int', default: 600, help: 'Max seconds for the overall batch command (default: 600)' }, + ], + columns: ['listId', 'username', 'userId', 'status', 'message'], + func: async (page, kwargs) => { + const listId = String(kwargs.listId || '').trim(); + const usernames = parseCommaSeparatedUsernames(kwargs.usernames, EXAMPLE); + const interval = parseBatchIntervalSeconds(kwargs.interval); + return runListBatch({ page, listId, usernames, interval, operation: listAddUser }); + }, +}); diff --git a/clis/twitter/list-add-core.js b/clis/twitter/list-add-core.js new file mode 100644 index 000000000..d03f8df32 --- /dev/null +++ b/clis/twitter/list-add-core.js @@ -0,0 +1,245 @@ +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { resolveTwitterQueryId, unwrapBrowserResult } from './shared.js'; +import { parseListsManagement } from './lists.js'; +import { TWITTER_BEARER_TOKEN } from './utils.js'; + +const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ'; +const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g'; +// 2026-05 fallback — X rotates queryIds; resolveTwitterQueryId() does live lookup, +// this constant is just the default if live lookup fails. +const LIST_ADD_MEMBER_QUERY_ID = 'vWPi0CTMoPFsjsL6W4IynQ'; + +const LISTS_MANAGEMENT_FEATURES = { + rweb_video_screen_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + rweb_tipjar_consumption_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, + 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: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: false, + responsive_web_grok_share_attachment_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, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: 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, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_enhance_cards_enabled: false, +}; + +function buildUserByScreenNameUrl(queryId, screenName) { + const vars = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true }); + const feats = JSON.stringify({ + hidden_profile_subscriptions_enabled: true, + rweb_tipjar_consumption_enabled: true, + responsive_web_graphql_exclude_directive_enabled: true, + verified_phone_label_enabled: false, + subscriptions_verification_info_is_identity_verified_enabled: true, + subscriptions_verification_info_verified_since_enabled: true, + highlights_tweets_tab_ui_enabled: true, + responsive_web_twitter_article_notes_tab_enabled: true, + subscriptions_feature_can_gift_premium: true, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + responsive_web_graphql_timeline_navigation_enabled: true, + }); + return `/i/api/graphql/${queryId}/UserByScreenName` + + `?variables=${encodeURIComponent(vars)}` + + `&features=${encodeURIComponent(feats)}`; +} + +function fatalGraphqlErrors(errors) { + const list = Array.isArray(errors) ? errors : []; + return list.filter((e) => + !(e?.path || []).join('.').includes('default_banner_media_results') + && !/decode/i.test(e?.message || '') + ); +} + +export function buildListAddMemberRow({ addResult, memberCountBefore, listId, username, userId }) { + if (!addResult?.httpOk) { + throw new CommandExecutionError( + `Failed to add @${username} to list ${listId}: HTTP ${addResult?.status ?? 0}${addResult?.fetchError ? ' (' + addResult.fetchError + ')' : ''}${addResult?.raw ? ' — ' + addResult.raw : ''}` + ); + } + + // X often returns a partial GraphQL error on `default_banner_media_results` + // even on successful mutations. Treat only missing main data or non-decode + // GraphQL errors as command failures. + const hasMemberCount = addResult.mc !== null && addResult.mc !== undefined; + const fatalErrors = fatalGraphqlErrors(addResult.errors); + if (!hasMemberCount && fatalErrors.length) { + const msg = fatalErrors.map((e) => e.message || JSON.stringify(e)).join('; '); + throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: ${msg.slice(0, 300)}`); + } + if (!hasMemberCount) { + throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: no member_count in response`); + } + + const memberCountAfter = Number(addResult.mc); + if (!Number.isFinite(memberCountAfter)) { + throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: invalid member_count in response`); + } + + if (memberCountAfter < memberCountBefore) { + throw new CommandExecutionError( + `Failed to add @${username} to list ${listId}: member_count decreased unexpectedly (${memberCountBefore} → ${memberCountAfter})` + ); + } + + const countIncreased = memberCountAfter > memberCountBefore; + const noop = !countIncreased; + if (noop && addResult.isMember !== true) { + throw new CommandExecutionError( + `Failed to add @${username} to list ${listId}: member_count unchanged and membership was not confirmed` + ); + } + const verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`; + return { + listId, + username, + userId: String(userId), + status: noop ? 'noop' : 'success', + message: noop + ? `@${username} is already a member of list ${listId}` + : `Added @${username} to list ${listId} (verified via ${verifiedBy})`, + }; +} + +export async function listAddUser(page, kwargs) { + const listId = String(kwargs.listId || '').trim(); + const username = String(kwargs.username || '').replace(/^@/, '').trim(); + if (!listId || !/^\d+$/.test(listId)) { + throw new ArgumentError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected numeric ID.`, 'Example: opencli twitter list-add 123456789 alice'); + } + if (!username) { + throw new ArgumentError('twitter list-add username is required', 'Example: opencli twitter list-add 123456789 alice'); + } + // Strategy.UI does not get a domain URL pre-nav from the framework. + // This page context is load-bearing for pre-target GraphQL calls below. + await page.goto('https://x.com'); + await page.wait(3); + const cookies = await page.getCookies({ url: 'https://x.com' }); + const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null; + if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)'); + + const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID); + + const headers = JSON.stringify({ + 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`, + 'X-Csrf-Token': ct0, + 'X-Twitter-Auth-Type': 'OAuth2Session', + 'X-Twitter-Active-User': 'yes', + }); + + // opencli >=1.7.x wraps page.evaluate return values as { session, data }. + // Unwrap before use so JSON.stringify of nested values doesn't become "[object Object]". + const userLookupUrl = buildUserByScreenNameUrl(userByScreenNameQueryId, username); + const userIdRaw = await page.evaluate(`async () => { + const resp = await fetch(${JSON.stringify(userLookupUrl)}, { headers: ${headers}, credentials: 'include' }); + if (!resp.ok) return null; + const d = await resp.json(); + return d.data?.user?.result?.rest_id || null; + }`); + const userId = unwrapBrowserResult(userIdRaw); + if (!userId) { + throw new CommandExecutionError(`Could not resolve user @${username}`); + } + + // ListsManagementPageTimeline — used for list existence check + before/after member_count. + const listsQueryId = await resolveTwitterQueryId(page, 'ListsManagementPageTimeline', LISTS_MANAGEMENT_QUERY_ID); + const listsUrl = `/i/api/graphql/${listsQueryId}/ListsManagementPageTimeline?features=${encodeURIComponent(JSON.stringify(LISTS_MANAGEMENT_FEATURES))}`; + const listsDataRaw = await page.evaluate(`async () => { + const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' }); + if (!r.ok) return { __error: 'HTTP ' + r.status }; + return await r.json(); + }`); + // Don't unwrap listsData: opencli spreads GraphQL response to top-level + adds session; + // parseListsManagement reads `.data.viewer.*` from this shape directly. + const listsData = listsDataRaw; + const parsedLists = listsData && !listsData.__error + ? parseListsManagement(listsData, new Set()) + : []; + if (listsData && listsData.__error) { + throw new CommandExecutionError(`Could not fetch lists: ${listsData.__error}`); + } + const targetList = parsedLists.find((l) => l.id === listId); + if (!targetList) { + throw new CommandExecutionError(`List ${listId} not found among your lists (${parsedLists.length} lists fetched).`); + } + + // Direct GraphQL ListAddMember mutation. + // + // Previously this command opened the X profile, clicked "…" → "Add/remove from Lists", + // navigated the dialog and used nativeClick on the Save button. In 2026-05 X replaced + // the dialog with a full-page route (/i/lists/add_member), breaking that UI flow. + // + // The mutation is the same one the UI fires under the hood; calling it directly is + // both more reliable and ~10x faster (no goto-profile + scroll-dialog roundtrip). + const memberCountBefore = Number(targetList.members) || 0; + const listAddMemberQueryId = await resolveTwitterQueryId(page, 'ListAddMember', LIST_ADD_MEMBER_QUERY_ID); + const addUrl = `/i/api/graphql/${listAddMemberQueryId}/ListAddMember`; + const addBody = JSON.stringify({ + variables: { listId, userId: String(userId) }, + queryId: listAddMemberQueryId, + }); + const addResultJsonRaw = await page.evaluate(`async () => { + try { + const r = await fetch(${JSON.stringify(addUrl)}, { + method: 'POST', + headers: Object.assign({}, ${headers}, { 'Content-Type': 'application/json' }), + credentials: 'include', + body: ${JSON.stringify(addBody)}, + }); + const text = await r.text(); + let body; + let raw = null; + try { body = JSON.parse(text); } catch { body = null; raw = text.slice(0, 300); } + const list = body && body.data && body.data.list ? body.data.list : null; + return JSON.stringify([ + r.ok, + r.status, + list ? list.member_count : null, + list ? list.is_member : null, + body && body.errors ? body.errors : null, + raw, + null, + ]); + } catch (e) { + return JSON.stringify([false, 0, null, null, null, null, String(e)]); + } + }`); + const addResultJson = unwrapBrowserResult(addResultJsonRaw); + let addResultTuple; + try { + addResultTuple = JSON.parse(addResultJson); + } catch { + throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: malformed mutation response envelope`); + } + const addResult = Object.create(null); + addResult.httpOk = Boolean(addResultTuple?.[0]); + addResult.status = Number(addResultTuple?.[1]) || 0; + addResult.mc = addResultTuple?.[2]; + addResult.isMember = addResultTuple?.[3]; + addResult.errors = addResultTuple?.[4]; + addResult.raw = addResultTuple?.[5]; + addResult.fetchError = addResultTuple?.[6]; + + return [buildListAddMemberRow({ addResult, memberCountBefore, listId, username, userId })]; +} diff --git a/clis/twitter/list-add.js b/clis/twitter/list-add.js index 1d89a367e..4469910a6 100644 --- a/clis/twitter/list-add.js +++ b/clis/twitter/list-add.js @@ -1,128 +1,5 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; -import { resolveTwitterQueryId } from './shared.js'; -import { parseListsManagement } from './lists.js'; -import { TWITTER_BEARER_TOKEN } from './utils.js'; - -const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ'; -const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g'; -// 2026-05 fallback — X rotates queryIds; resolveTwitterQueryId() does live lookup, -// this constant is just the default if live lookup fails. -const LIST_ADD_MEMBER_QUERY_ID = 'vWPi0CTMoPFsjsL6W4IynQ'; - -const LISTS_MANAGEMENT_FEATURES = { - rweb_video_screen_enabled: false, - profile_label_improvements_pcf_label_in_post_enabled: true, - rweb_tipjar_consumption_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, - 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: false, - responsive_web_grok_analyze_post_followups_enabled: true, - responsive_web_jetfuel_frame: false, - responsive_web_grok_share_attachment_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, - responsive_web_grok_show_grok_translated_post: false, - responsive_web_grok_analysis_button_from_backend: 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, - longform_notetweets_rich_text_read_enabled: true, - longform_notetweets_inline_media_enabled: true, - responsive_web_grok_image_annotation_enabled: true, - responsive_web_enhance_cards_enabled: false, -}; - -function buildUserByScreenNameUrl(queryId, screenName) { - const vars = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true }); - const feats = JSON.stringify({ - hidden_profile_subscriptions_enabled: true, - rweb_tipjar_consumption_enabled: true, - responsive_web_graphql_exclude_directive_enabled: true, - verified_phone_label_enabled: false, - subscriptions_verification_info_is_identity_verified_enabled: true, - subscriptions_verification_info_verified_since_enabled: true, - highlights_tweets_tab_ui_enabled: true, - responsive_web_twitter_article_notes_tab_enabled: true, - subscriptions_feature_can_gift_premium: true, - creator_subscriptions_tweet_preview_api_enabled: true, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - responsive_web_graphql_timeline_navigation_enabled: true, - }); - return `/i/api/graphql/${queryId}/UserByScreenName` - + `?variables=${encodeURIComponent(vars)}` - + `&features=${encodeURIComponent(feats)}`; -} - -function fatalGraphqlErrors(errors) { - const list = Array.isArray(errors) ? errors : []; - return list.filter((e) => - !(e?.path || []).join('.').includes('default_banner_media_results') - && !/decode/i.test(e?.message || '') - ); -} - -export function buildListAddMemberRow({ addResult, memberCountBefore, listId, username, userId }) { - if (!addResult?.httpOk) { - throw new CommandExecutionError( - `Failed to add @${username} to list ${listId}: HTTP ${addResult?.status ?? 0}${addResult?.fetchError ? ' (' + addResult.fetchError + ')' : ''}${addResult?.raw ? ' — ' + addResult.raw : ''}` - ); - } - - // X often returns a partial GraphQL error on `default_banner_media_results` - // even on successful mutations. Treat only missing main data or non-decode - // GraphQL errors as command failures. - const hasMemberCount = addResult.mc !== null && addResult.mc !== undefined; - const fatalErrors = fatalGraphqlErrors(addResult.errors); - if (!hasMemberCount && fatalErrors.length) { - const msg = fatalErrors.map((e) => e.message || JSON.stringify(e)).join('; '); - throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: ${msg.slice(0, 300)}`); - } - if (!hasMemberCount) { - throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: no member_count in response`); - } - - const memberCountAfter = Number(addResult.mc); - if (!Number.isFinite(memberCountAfter)) { - throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: invalid member_count in response`); - } - - if (memberCountAfter < memberCountBefore) { - throw new CommandExecutionError( - `Failed to add @${username} to list ${listId}: member_count decreased unexpectedly (${memberCountBefore} → ${memberCountAfter})` - ); - } - - const countIncreased = memberCountAfter > memberCountBefore; - if (!countIncreased && addResult.isMember !== true) { - throw new CommandExecutionError( - `Failed to add @${username} to list ${listId}: member_count unchanged (${memberCountBefore} → ${memberCountAfter}) and response did not confirm membership` - ); - } - - const noop = !countIncreased; - const verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`; - return { - listId, - username, - userId: String(userId), - status: noop ? 'noop' : 'success', - message: noop - ? `@${username} is already a member of list ${listId}` - : `Added @${username} to list ${listId} (verified via ${verifiedBy})`, - }; -} +import { buildListAddMemberRow, listAddUser } from './list-add-core.js'; cli({ site: 'twitter', @@ -137,127 +14,7 @@ cli({ { name: 'username', positional: true, type: 'string', required: true, help: 'Twitter/X handle to add (with or without @)' }, ], columns: ['listId', 'username', 'userId', 'status', 'message'], - func: async (page, kwargs) => { - const listId = String(kwargs.listId || '').trim(); - const username = String(kwargs.username || '').replace(/^@/, '').trim(); - if (!listId || !/^\d+$/.test(listId)) { - throw new ArgumentError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected numeric ID.`, 'Example: opencli twitter list-add 123456789 alice'); - } - if (!username) { - throw new ArgumentError('twitter list-add username is required', 'Example: opencli twitter list-add 123456789 alice'); - } - // Strategy.UI does not get a domain URL pre-nav from the framework. - // This page context is load-bearing for pre-target GraphQL calls below. - await page.goto('https://x.com'); - await page.wait(3); - const cookies = await page.getCookies({ url: 'https://x.com' }); - const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null; - if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)'); - - const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID); - - const headers = JSON.stringify({ - 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`, - 'X-Csrf-Token': ct0, - 'X-Twitter-Auth-Type': 'OAuth2Session', - 'X-Twitter-Active-User': 'yes', - }); - - // opencli >=1.7.x wraps page.evaluate return values as { session, data }. - // Unwrap before use so JSON.stringify of nested values doesn't become "[object Object]". - const unwrap = (v) => (v && typeof v === 'object' && 'session' in v && 'data' in v ? v.data : v); - - const userLookupUrl = buildUserByScreenNameUrl(userByScreenNameQueryId, username); - const userIdRaw = await page.evaluate(`async () => { - const resp = await fetch(${JSON.stringify(userLookupUrl)}, { headers: ${headers}, credentials: 'include' }); - if (!resp.ok) return null; - const d = await resp.json(); - return d.data?.user?.result?.rest_id || null; - }`); - const userId = unwrap(userIdRaw); - if (!userId) { - throw new CommandExecutionError(`Could not resolve user @${username}`); - } - - // ListsManagementPageTimeline — used for list existence check + before/after member_count. - const listsQueryId = await resolveTwitterQueryId(page, 'ListsManagementPageTimeline', LISTS_MANAGEMENT_QUERY_ID); - const listsUrl = `/i/api/graphql/${listsQueryId}/ListsManagementPageTimeline?features=${encodeURIComponent(JSON.stringify(LISTS_MANAGEMENT_FEATURES))}`; - const listsDataRaw = await page.evaluate(`async () => { - const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' }); - if (!r.ok) return { __error: 'HTTP ' + r.status }; - return await r.json(); - }`); - // Don't unwrap listsData: opencli spreads GraphQL response to top-level + adds session; - // parseListsManagement reads `.data.viewer.*` from this shape directly. - const listsData = listsDataRaw; - const parsedLists = listsData && !listsData.__error - ? parseListsManagement(listsData, new Set()) - : []; - if (listsData && listsData.__error) { - throw new CommandExecutionError(`Could not fetch lists: ${listsData.__error}`); - } - const targetList = parsedLists.find((l) => l.id === listId); - if (!targetList) { - throw new CommandExecutionError(`List ${listId} not found among your lists (${parsedLists.length} lists fetched).`); - } - - // Direct GraphQL ListAddMember mutation. - // - // Previously this command opened the X profile, clicked "…" → "Add/remove from Lists", - // navigated the dialog and used nativeClick on the Save button. In 2026-05 X replaced - // the dialog with a full-page route (/i/lists/add_member), breaking that UI flow. - // - // The mutation is the same one the UI fires under the hood; calling it directly is - // both more reliable and ~10x faster (no goto-profile + scroll-dialog roundtrip). - const memberCountBefore = Number(targetList.members) || 0; - const listAddMemberQueryId = await resolveTwitterQueryId(page, 'ListAddMember', LIST_ADD_MEMBER_QUERY_ID); - const addUrl = `/i/api/graphql/${listAddMemberQueryId}/ListAddMember`; - const addBody = JSON.stringify({ - variables: { listId, userId: String(userId) }, - queryId: listAddMemberQueryId, - }); - const addResultJsonRaw = await page.evaluate(`async () => { - try { - const r = await fetch(${JSON.stringify(addUrl)}, { - method: 'POST', - headers: Object.assign({}, ${headers}, { 'Content-Type': 'application/json' }), - credentials: 'include', - body: ${JSON.stringify(addBody)}, - }); - const text = await r.text(); - let body; - let raw = null; - try { body = JSON.parse(text); } catch { body = null; raw = text.slice(0, 300); } - const list = body && body.data && body.data.list ? body.data.list : null; - return JSON.stringify([ - r.ok, - r.status, - list ? list.member_count : null, - list ? list.is_member : null, - body && body.errors ? body.errors : null, - raw, - null, - ]); - } catch (e) { - return JSON.stringify([false, 0, null, null, null, null, String(e)]); - } - }`); - const addResultJson = unwrap(addResultJsonRaw); - let addResultTuple; - try { - addResultTuple = JSON.parse(addResultJson); - } catch { - throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: malformed mutation response envelope`); - } - const addResult = Object.create(null); - addResult.httpOk = Boolean(addResultTuple?.[0]); - addResult.status = Number(addResultTuple?.[1]) || 0; - addResult.mc = addResultTuple?.[2]; - addResult.isMember = addResultTuple?.[3]; - addResult.errors = addResultTuple?.[4]; - addResult.raw = addResultTuple?.[5]; - addResult.fetchError = addResultTuple?.[6]; - - return [buildListAddMemberRow({ addResult, memberCountBefore, listId, username, userId })]; - }, + func: listAddUser, }); + +export { buildListAddMemberRow, listAddUser }; diff --git a/clis/twitter/list-add.test.js b/clis/twitter/list-add.test.js index 576d7d7d8..be9ae3caa 100644 --- a/clis/twitter/list-add.test.js +++ b/clis/twitter/list-add.test.js @@ -87,14 +87,14 @@ describe('twitter list-add registration', () => { expect(row.message).toBe('@alice is already a member of list 123'); }); - it('fails typed when unchanged member_count does not confirm membership', () => { + it('fails typed when member_count is unchanged but membership is not confirmed', () => { expect(() => buildListAddMemberRow({ addResult: { httpOk: true, status: 200, mc: 10, isMember: false, errors: null }, memberCountBefore: 10, listId: '123', username: 'alice', userId: '42', - })).toThrow(CommandExecutionError); + })).toThrow(/membership was not confirmed/); }); it('fails typed when member_count decreases unexpectedly', () => { diff --git a/clis/twitter/list-batch-utils.js b/clis/twitter/list-batch-utils.js new file mode 100644 index 000000000..cd6f7829c --- /dev/null +++ b/clis/twitter/list-batch-utils.js @@ -0,0 +1,95 @@ +import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors'; + +const USERNAME_RE = /^[A-Za-z0-9_]{1,15}$/; +const DEFAULT_INTERVAL_SECONDS = 5; +const MAX_INTERVAL_SECONDS = 600; + +export function parseCommaSeparatedUsernames(rawValue, example) { + const raw = String(rawValue || '').trim(); + if (!raw) { + throw new ArgumentError('At least one username is required', example); + } + + const values = raw + .split(',') + .map((part) => part.trim().replace(/^@/, '')) + .filter(Boolean); + + if (values.length === 0) { + throw new ArgumentError('At least one username is required', example); + } + + const seen = new Set(); + const usernames = []; + for (const username of values) { + if (!USERNAME_RE.test(username)) { + throw new ArgumentError(`Invalid Twitter/X username: ${JSON.stringify(username)}`, example); + } + const key = username.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + usernames.push(username); + } + + return usernames; +} + +export function parseBatchIntervalSeconds(rawValue) { + const value = rawValue === undefined || rawValue === null || rawValue === '' + ? DEFAULT_INTERVAL_SECONDS + : Number(rawValue); + if (!Number.isInteger(value) || value < 0 || value > MAX_INTERVAL_SECONDS) { + throw new ArgumentError(`Invalid interval: ${JSON.stringify(rawValue)}. Expected an integer from 0 to ${MAX_INTERVAL_SECONDS}.`); + } + return value; +} + +export function toBatchFailureRow({ listId, username, error }) { + return { + listId, + username, + userId: '', + status: 'failed', + message: error?.message || String(error), + }; +} + +function isGlobalBatchFailure(error) { + if (error instanceof ArgumentError || error instanceof AuthRequiredError) { + return true; + } + const message = error?.message || String(error); + return /Invalid listId|Could not fetch lists|List \d+ not found among your lists|Not logged into x\.com/i.test(message); +} + +export async function waitBetweenBatchItems(page, seconds) { + if (seconds <= 0) return; + if (page && typeof page.wait === 'function') { + await page.wait(seconds); + return; + } + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} + +export async function runListBatch({ page, listId, usernames, interval, operation }) { + const rows = []; + + for (let i = 0; i < usernames.length; i++) { + const username = usernames[i]; + try { + const result = await operation(page, { listId, username }); + rows.push(...result); + } catch (error) { + if (isGlobalBatchFailure(error)) { + throw error; + } + rows.push(toBatchFailureRow({ listId, username, error })); + } + + if (i < usernames.length - 1) { + await waitBetweenBatchItems(page, interval); + } + } + + return rows; +} diff --git a/clis/twitter/list-batch.test.js b/clis/twitter/list-batch.test.js new file mode 100644 index 000000000..d6434032b --- /dev/null +++ b/clis/twitter/list-batch.test.js @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import './list-add-batch.js'; +import './list-remove-batch.js'; +import { + parseBatchIntervalSeconds, + parseCommaSeparatedUsernames, + runListBatch, +} from './list-batch-utils.js'; + +describe('twitter list batch utilities', () => { + it('parses comma-separated usernames, strips @, and dedupes case-insensitively', () => { + expect(parseCommaSeparatedUsernames('@Alice, bob,alice, @ComfyUI', 'Example')) + .toEqual(['Alice', 'bob', 'ComfyUI']); + }); + + it('rejects empty and invalid usernames', () => { + expect(() => parseCommaSeparatedUsernames('', 'Example')).toThrow(ArgumentError); + expect(() => parseCommaSeparatedUsernames('@valid,bad-name', 'Example')).toThrow(ArgumentError); + }); + + it('parses interval seconds with bounds', () => { + expect(parseBatchIntervalSeconds(undefined)).toBe(5); + expect(parseBatchIntervalSeconds(0)).toBe(0); + expect(parseBatchIntervalSeconds('10')).toBe(10); + expect(() => parseBatchIntervalSeconds(-1)).toThrow(ArgumentError); + expect(() => parseBatchIntervalSeconds(601)).toThrow(ArgumentError); + }); + + it('runs an operation for each user, waits between items, and continues after failures', async () => { + const page = { wait: vi.fn() }; + const operation = vi.fn(async (_page, args) => { + if (args.username === 'bad') throw new Error('Could not resolve user @bad'); + return [{ + listId: args.listId, + username: args.username, + userId: `${args.username}-id`, + status: 'success', + message: `Processed @${args.username}`, + }]; + }); + + const rows = await runListBatch({ + page, + listId: '123', + usernames: ['good', 'bad', 'next'], + interval: 2, + operation, + }); + + expect(operation).toHaveBeenCalledTimes(3); + expect(page.wait).toHaveBeenCalledTimes(2); + expect(page.wait).toHaveBeenCalledWith(2); + expect(rows.map((row) => row.status)).toEqual(['success', 'failed', 'success']); + expect(rows[1]).toMatchObject({ listId: '123', username: 'bad', userId: '', status: 'failed' }); + }); + + it('does not convert global precondition failures into per-user failed rows', async () => { + const page = { wait: vi.fn() }; + await expect(runListBatch({ + page, + listId: 'bad', + usernames: ['alice', 'bob'], + interval: 0, + operation: async () => { + throw new ArgumentError('Invalid listId: "bad". Expected numeric ID.'); + }, + })).rejects.toBeInstanceOf(ArgumentError); + + await expect(runListBatch({ + page, + listId: '123', + usernames: ['alice'], + interval: 0, + operation: async () => { + throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)'); + }, + })).rejects.toBeInstanceOf(AuthRequiredError); + + await expect(runListBatch({ + page, + listId: '123', + usernames: ['alice'], + interval: 0, + operation: async () => { + throw new CommandExecutionError('List 123 not found among your lists.'); + }, + })).rejects.toThrow(/List 123 not found/); + }); +}); + +describe('twitter list-add-batch registration', () => { + it('registers the list-add-batch command with the expected shape', () => { + const cmd = getRegistry().get('twitter/list-add-batch'); + expect(cmd?.func).toBeTypeOf('function'); + expect(cmd?.columns).toEqual(['listId', 'username', 'userId', 'status', 'message']); + expect(cmd?.args?.find((a) => a.name === 'interval')?.default).toBe(5); + expect(cmd?.args?.find((a) => a.name === 'timeout')?.default).toBe(600); + }); + +}); + +describe('twitter list-remove-batch registration', () => { + it('registers the list-remove-batch command with the expected shape', () => { + const cmd = getRegistry().get('twitter/list-remove-batch'); + expect(cmd?.func).toBeTypeOf('function'); + expect(cmd?.columns).toEqual(['listId', 'username', 'userId', 'status', 'message']); + expect(cmd?.args?.find((a) => a.name === 'interval')?.default).toBe(5); + expect(cmd?.args?.find((a) => a.name === 'timeout')?.default).toBe(600); + }); + +}); diff --git a/clis/twitter/list-delete.js b/clis/twitter/list-delete.js new file mode 100644 index 000000000..21dc4868c --- /dev/null +++ b/clis/twitter/list-delete.js @@ -0,0 +1,158 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { resolveTwitterQueryId, unwrapBrowserResult } from './shared.js'; +import { parseListsManagement } from './lists.js'; +import { TWITTER_BEARER_TOKEN } from './utils.js'; + +const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g'; + +const LISTS_MANAGEMENT_FEATURES = { + rweb_video_screen_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + rweb_tipjar_consumption_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, + 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: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: false, + responsive_web_grok_share_attachment_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, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: 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, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_enhance_cards_enabled: false, +}; + +function normalizeConfirm(value) { + return value === true || value === 'true'; +} + +async function getManagedLists(page, headers) { + const listsQueryId = await resolveTwitterQueryId(page, 'ListsManagementPageTimeline', LISTS_MANAGEMENT_QUERY_ID); + const listsUrl = `/i/api/graphql/${listsQueryId}/ListsManagementPageTimeline?features=${encodeURIComponent(JSON.stringify(LISTS_MANAGEMENT_FEATURES))}`; + const listsData = unwrapBrowserResult(await page.evaluate(`async () => { + const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' }); + if (!r.ok) return { __error: 'HTTP ' + r.status }; + return await r.json(); + }`)); + if (listsData && listsData.__error) { + throw new CommandExecutionError(`Could not fetch lists: ${listsData.__error}`); + } + return parseListsManagement(listsData, new Set()); +} + +export function buildListDeleteRow({ listId, targetList }) { + return { + listId, + name: targetList.name, + members: String(targetList.members ?? '0'), + status: 'success', + message: `Deleted list ${targetList.name} (${targetList.members ?? '0'} members)`, + }; +} + +cli({ + site: 'twitter', + name: 'list-delete', + access: 'write', + description: 'Delete a Twitter/X list you own after explicit confirmation', + domain: 'x.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of the list you own (e.g. from `opencli twitter lists`)' }, + { name: 'confirm', type: 'boolean', default: false, help: 'Required. Set --confirm true to delete the list.' }, + { name: 'timeout', type: 'int', default: 300, help: 'Max seconds for the overall delete command (default: 300)' }, + ], + columns: ['listId', 'name', 'members', 'status', 'message'], + func: async (page, kwargs) => { + const listId = String(kwargs.listId || '').trim(); + if (!listId || !/^\d+$/.test(listId)) { + throw new ArgumentError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected numeric ID.`, 'Example: opencli twitter list-delete 123456789 --confirm true'); + } + if (!normalizeConfirm(kwargs.confirm)) { + throw new ArgumentError('Refusing to delete list without --confirm true', 'Example: opencli twitter list-delete 123456789 --confirm true'); + } + + await page.goto('https://x.com'); + await page.wait(3); + const cookies = await page.getCookies({ url: 'https://x.com' }); + const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null; + if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)'); + + const headers = JSON.stringify({ + 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`, + 'X-Csrf-Token': ct0, + 'X-Twitter-Auth-Type': 'OAuth2Session', + 'X-Twitter-Active-User': 'yes', + }); + + const listsBefore = await getManagedLists(page, headers); + const targetList = listsBefore.find((list) => list.id === listId); + if (!targetList) { + throw new CommandExecutionError(`List ${listId} not found among your lists (${listsBefore.length} lists fetched).`); + } + + await page.goto(`https://x.com/i/lists/${listId}`); + await page.wait({ selector: '[data-testid="primaryColumn"]' }); + const deleteResult = unwrapBrowserResult(await page.evaluate(`(async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const visible = (el) => !!el && el.offsetParent !== null; + const buttonText = (el) => (el.innerText || el.textContent || '').trim(); + const waitFor = async (fn, { timeoutMs = 10000, intervalMs = 200 } = {}) => { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const value = fn(); + if (value) return value; + await sleep(intervalMs); + } + return null; + }; + const findButton = (text) => Array.from(document.querySelectorAll('button, [role="button"]')) + .find((el) => visible(el) && buttonText(el).toLowerCase() === text.toLowerCase()); + const editLink = Array.from(document.querySelectorAll('a[href$="/info"]')) + .find((el) => visible(el) && /edit list/i.test(el.innerText || el.textContent || '')); + if (!editLink) return { ok: false, message: 'Edit List link not found' }; + editLink.click(); + const editDialog = await waitFor(() => document.querySelector('[role="dialog"]')); + if (!editDialog) return { ok: false, message: 'Edit List dialog did not open' }; + const deleteButton = findButton('Delete List'); + if (!deleteButton) return { ok: false, message: 'Delete List button not found' }; + deleteButton.click(); + await sleep(800); + const confirmButton = document.querySelector('[data-testid="confirmationSheetConfirm"]') + || findButton('Delete'); + if (!confirmButton) return { ok: false, message: 'Delete confirmation button not found' }; + confirmButton.click(); + await sleep(2500); + return { ok: true, url: location.href }; + })()`)); + if (!deleteResult?.ok) { + throw new CommandExecutionError(`Failed to delete list ${listId}: ${deleteResult?.message || 'unknown UI failure'}`); + } + + const listsAfter = await getManagedLists(page, headers); + if (listsAfter.some((list) => list.id === listId)) { + throw new CommandExecutionError(`Failed to delete list ${listId}: list still appears in managed lists.`); + } + + return [buildListDeleteRow({ listId, targetList })]; + }, +}); diff --git a/clis/twitter/list-delete.test.js b/clis/twitter/list-delete.test.js new file mode 100644 index 000000000..7eb0c3ff6 --- /dev/null +++ b/clis/twitter/list-delete.test.js @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError } from '@jackwener/opencli/errors'; +import { buildListDeleteRow } from './list-delete.js'; + +describe('twitter list-delete registration', () => { + it('registers the list-delete command with explicit confirmation', () => { + const cmd = getRegistry().get('twitter/list-delete'); + expect(cmd?.func).toBeTypeOf('function'); + expect(cmd?.columns).toEqual(['listId', 'name', 'members', 'status', 'message']); + expect(cmd?.args?.find((a) => a.name === 'confirm')?.default).toBe(false); + expect(cmd?.args?.find((a) => a.name === 'timeout')?.default).toBe(300); + }); + + it('rejects deletion when confirm is not true before navigation', async () => { + const cmd = getRegistry().get('twitter/list-delete'); + const page = { + goto: vi.fn(), + wait: vi.fn(), + getCookies: vi.fn(), + evaluate: vi.fn(), + }; + + await expect(cmd.func(page, { listId: '123', confirm: false })).rejects.toBeInstanceOf(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('builds the success row from the pre-delete list payload', () => { + const row = buildListDeleteRow({ + listId: '123', + targetList: { name: 'AI Builders', members: '50' }, + }); + + expect(row).toEqual({ + listId: '123', + name: 'AI Builders', + members: '50', + status: 'success', + message: 'Deleted list AI Builders (50 members)', + }); + }); + + it('unwraps Browser Bridge delete action envelopes before checking ok', async () => { + const cmd = getRegistry().get('twitter/list-delete'); + const listsPayload = { + data: { + viewer: { + list_management_timeline: { + timeline: { + instructions: [{ + entries: [{ + entryId: 'owned-subscribed-list-module-0', + content: { + items: [{ + item: { + itemContent: { + list: { + id_str: '123', + name: 'AI Builders', + member_count: 50, + subscriber_count: 0, + mode: 'Public', + }, + }, + }, + }], + }, + }], + }], + }, + }, + }, + }, + }; + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]), + evaluate: vi.fn() + .mockResolvedValueOnce(null) // ListsManagement queryId fallback + .mockResolvedValueOnce({ session: 'site:twitter', data: listsPayload }) + .mockResolvedValueOnce({ session: 'site:twitter', data: { ok: true, url: 'https://x.com/i/lists' } }) + .mockResolvedValueOnce({ session: 'site:twitter', data: { + data: { + viewer: { + list_management_timeline: { + timeline: { instructions: [{ entries: [] }] }, + }, + }, + }, + } }), + }; + + await expect(cmd.func(page, { listId: '123', confirm: true })).resolves.toEqual([{ + listId: '123', + name: 'AI Builders', + members: '50', + status: 'success', + message: 'Deleted list AI Builders (50 members)', + }]); + }); +}); diff --git a/clis/twitter/list-remove-batch.js b/clis/twitter/list-remove-batch.js new file mode 100644 index 000000000..5ef25e938 --- /dev/null +++ b/clis/twitter/list-remove-batch.js @@ -0,0 +1,32 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { listRemoveUser } from './list-remove-core.js'; +import { + parseBatchIntervalSeconds, + parseCommaSeparatedUsernames, + runListBatch, +} from './list-batch-utils.js'; + +const EXAMPLE = 'Example: opencli twitter list-remove-batch 123456789 "@alice,@bob" --interval 5'; + +cli({ + site: 'twitter', + name: 'list-remove-batch', + access: 'write', + description: 'Remove multiple users from a Twitter/X list you own from a comma-separated username list', + domain: 'x.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of the list you own (e.g. from `opencli twitter lists`)' }, + { name: 'usernames', positional: true, type: 'string', required: true, help: 'Comma-separated Twitter/X handles to remove (with or without @)' }, + { name: 'interval', type: 'int', default: 5, help: 'Seconds to wait between account removals (default: 5)' }, + { name: 'timeout', type: 'int', default: 600, help: 'Max seconds for the overall batch command (default: 600)' }, + ], + columns: ['listId', 'username', 'userId', 'status', 'message'], + func: async (page, kwargs) => { + const listId = String(kwargs.listId || '').trim(); + const usernames = parseCommaSeparatedUsernames(kwargs.usernames, EXAMPLE); + const interval = parseBatchIntervalSeconds(kwargs.interval); + return runListBatch({ page, listId, usernames, interval, operation: listRemoveUser }); + }, +}); diff --git a/clis/twitter/list-remove-core.js b/clis/twitter/list-remove-core.js new file mode 100644 index 000000000..f35b88e45 --- /dev/null +++ b/clis/twitter/list-remove-core.js @@ -0,0 +1,291 @@ +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { resolveTwitterQueryId, unwrapBrowserResult } from './shared.js'; +import { getListsManagementInstructions, parseListsManagement } from './lists.js'; +import { TWITTER_BEARER_TOKEN } from './utils.js'; + +const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ'; +const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g'; + +const LISTS_MANAGEMENT_FEATURES = { + rweb_video_screen_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + rweb_tipjar_consumption_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, + 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: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: false, + responsive_web_grok_share_attachment_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, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: 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, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_enhance_cards_enabled: false, +}; + +function buildUserByScreenNameUrl(queryId, screenName) { + const vars = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true }); + const feats = JSON.stringify({ + hidden_profile_subscriptions_enabled: true, + rweb_tipjar_consumption_enabled: true, + responsive_web_graphql_exclude_directive_enabled: true, + verified_phone_label_enabled: false, + subscriptions_verification_info_is_identity_verified_enabled: true, + subscriptions_verification_info_verified_since_enabled: true, + highlights_tweets_tab_ui_enabled: true, + responsive_web_twitter_article_notes_tab_enabled: true, + subscriptions_feature_can_gift_premium: true, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + responsive_web_graphql_timeline_navigation_enabled: true, + }); + return `/i/api/graphql/${queryId}/UserByScreenName` + + `?variables=${encodeURIComponent(vars)}` + + `&features=${encodeURIComponent(feats)}`; +} + +export function interpretRemoveResponse(status, json) { + if (status === 200 && json && (json.id_str || json.id || json.slug)) return { ok: true }; + if (json && Array.isArray(json.errors) && json.errors.length > 0) { + const err = json.errors[0]; + return { ok: false, error: `${err.code ? '[' + err.code + '] ' : ''}${err.message || 'Unknown error'}` }; + } + return { ok: false, error: `HTTP ${status}` }; +} + +export async function listRemoveUser(page, kwargs) { + const listId = String(kwargs.listId || '').trim(); + const username = String(kwargs.username || '').replace(/^@/, '').trim(); + if (!listId || !/^\d+$/.test(listId)) { + throw new ArgumentError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected numeric ID.`); + } + if (!username) throw new ArgumentError('twitter list-remove username is required'); + + // Strategy.UI does not get a domain URL pre-nav from the framework. + // This page context is load-bearing for pre-target GraphQL calls below. + await page.goto('https://x.com'); + await page.wait(3); + const cookies = await page.getCookies({ url: 'https://x.com' }); + const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null; + if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)'); + + const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID); + const headers = JSON.stringify({ + 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`, + 'X-Csrf-Token': ct0, + 'X-Twitter-Auth-Type': 'OAuth2Session', + 'X-Twitter-Active-User': 'yes', + }); + + const userLookupUrl = buildUserByScreenNameUrl(userByScreenNameQueryId, username); + const userId = unwrapBrowserResult(await page.evaluate(`async () => { + const resp = await fetch(${JSON.stringify(userLookupUrl)}, { headers: ${headers}, credentials: 'include' }); + if (!resp.ok) return null; + const d = await resp.json(); + return d.data?.user?.result?.rest_id || null; + }`)); + if (!userId) throw new CommandExecutionError(`Could not resolve user @${username}`); + + // Resolve listId → name so we can match the dialog row. + const listsQueryId = await resolveTwitterQueryId(page, 'ListsManagementPageTimeline', LISTS_MANAGEMENT_QUERY_ID); + const listsUrl = `/i/api/graphql/${listsQueryId}/ListsManagementPageTimeline?features=${encodeURIComponent(JSON.stringify(LISTS_MANAGEMENT_FEATURES))}`; + const listsData = unwrapBrowserResult(await page.evaluate(`async () => { + const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' }); + if (!r.ok) return { __error: 'HTTP ' + r.status }; + return await r.json(); + }`)); + if (listsData && listsData.__error) { + throw new CommandExecutionError(`Could not fetch lists: ${listsData.__error}`); + } + const parsedLists = parseListsManagement(listsData, new Set()); + const targetList = parsedLists.find((l) => l.id === listId); + if (!targetList) { + throw new CommandExecutionError(`List ${listId} not found among your lists.`); + } + const targetName = targetList.name; + + await page.goto(`https://x.com/${username}`); + await page.wait({ selector: '[data-testid="primaryColumn"]' }); + const uiResult = unwrapBrowserResult(await page.evaluate(`(async () => { + const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + const findOne = (sel, root = document) => root.querySelector(sel); + const waitFor = async (fn, { timeoutMs = 8000, intervalMs = 200 } = {}) => { + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { const v = fn(); if (v) return v; await sleep(intervalMs); } + return null; + }; + try { + if (!window.__opencliListMutations) { + window.__opencliListMutations = []; + const origFetch = window.fetch.bind(window); + window.fetch = async function(...args) { + const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || ''; + const method = (args[1] && args[1].method) || 'GET'; + let resp; + try { resp = await origFetch(...args); } catch (err) { + if (/ListAddMember|ListRemoveMember|lists\\/members\\/(create|destroy)/.test(url)) { + window.__opencliListMutations.push({ url, method, status: 0, error: String(err), ts: Date.now() }); + } + throw err; + } + if (/ListAddMember|ListRemoveMember|lists\\/members\\/(create|destroy)/.test(url)) { + window.__opencliListMutations.push({ url, method, status: resp.status, ts: Date.now() }); + } + return resp; + }; + } + window.__opencliListMutations.length = 0; + + const caret = await waitFor(() => findOne('[data-testid="userActions"]')); + if (!caret) return { ok: false, message: 'Could not find user actions (…) button' }; + caret.click(); + await sleep(600); + const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]')); + const addToListItem = menuItems.find(el => /add\\/remove|从列表|列表|add to list|add or remove/i.test(el.innerText)); + if (!addToListItem) return { ok: false, message: 'Could not find "Add/remove from Lists" menu item' }; + addToListItem.click(); + await sleep(1200); + const dialog = await waitFor(() => findOne('[role="dialog"]')); + if (!dialog) return { ok: false, message: 'List selection dialog did not open' }; + + const targetName = ${JSON.stringify(targetName)}; + const scrollCandidates = [ + dialog.querySelector('[data-viewportview="true"]'), + ...Array.from(dialog.querySelectorAll('div')).filter(d => d.scrollHeight > d.clientHeight + 10), + ].filter(Boolean); + let scrollEl = scrollCandidates[0] || dialog; + for (const se of scrollCandidates) { + if (se.scrollHeight > se.clientHeight + 10) { scrollEl = se; break; } + } + let row = null; + let lastScrollTop = -1; + for (let i = 0; i < 12; i++) { + const cells = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]')); + row = cells.find(c => (c.innerText || '').split('\\n')[0].trim() === targetName); + if (row) break; + const prev = scrollEl.scrollTop; + scrollEl.scrollTop = prev + Math.max(200, scrollEl.clientHeight - 100); + if (scrollEl.scrollTop === prev && scrollEl.scrollTop === lastScrollTop) break; + lastScrollTop = scrollEl.scrollTop; + await sleep(500); + } + if (!row) { + const names = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]')) + .map(c => (c.innerText || '').split('\\n')[0].trim()).filter(Boolean); + return { ok: false, message: 'List "' + targetName + '" not found in dialog. Saw: ' + names.join(' | ') }; + } + + // Determine current membership: row has a filled checkmark (svg inside a specific container) when member. + // Heuristic: look for an aria-checked attribute, or an svg with specific fill on the row's right side. + // The listCell itself carries aria-checked. Require a stable reading + // (same value twice ~500ms apart) to avoid the X dialog's occasional + // flash of stale state when re-opened shortly after a toggle. + const listCell = row.querySelector('[data-testid="listCell"]') || row.querySelector('[role="checkbox"]') || row; + const readChecked = () => { + const v = listCell.getAttribute('aria-checked'); + return v === 'true' || v === 'false' ? v : null; + }; + await sleep(600); + let ariaChecked = readChecked(); + for (let i = 0; i < 8; i++) { + await sleep(500); + const next = readChecked(); + if (next && next === ariaChecked) break; + ariaChecked = next || ariaChecked; + } + const isMember = ariaChecked === 'true'; + if (!isMember) { + const closeBtn = findOne('[data-testid="app-bar-close"]') || findOne('[aria-label="Close"]'); + if (closeBtn) closeBtn.click(); + return { ok: true, noop: true }; + } + try { listCell.scrollIntoView({ block: 'center' }); } catch {} + await sleep(400); + const rowRect = listCell.getBoundingClientRect(); + const saveButton = Array.from(dialog.querySelectorAll('[role="button"], button')).find(b => { + const txt = (b.innerText || '').trim(); + return /^(Save|Done|保存|完成|儲存)$/i.test(txt); + }); + const saveRect = saveButton ? saveButton.getBoundingClientRect() : null; + return { + ok: true, + needsNativeInteraction: true, + rowClickX: Math.round(rowRect.left + rowRect.width / 2), + rowClickY: Math.round(rowRect.top + rowRect.height / 2), + saveClickX: saveRect ? Math.round(saveRect.left + saveRect.width / 2) : null, + saveClickY: saveRect ? Math.round(saveRect.top + saveRect.height / 2) : null, + mutationsBefore: window.__opencliListMutations.length, + }; + } catch (e) { + return { ok: false, message: 'UI error: ' + (e?.message || String(e)) }; + } + })()`)); + + if (!uiResult.ok) { + throw new CommandExecutionError(`Failed to remove @${username} from list ${listId}: ${uiResult.message}`); + } + + let verifiedBy = null; + if (uiResult.needsNativeInteraction) { + if (typeof page.nativeClick !== 'function') { + throw new CommandExecutionError('Requires up-to-date Chrome extension (nativeClick).'); + } + if (!uiResult.saveClickX) { + throw new CommandExecutionError('Save button not found in dialog.'); + } + const memberCountBefore = Number(targetList.members) || 0; + await page.nativeClick(uiResult.rowClickX, uiResult.rowClickY); + await new Promise((r) => setTimeout(r, 800)); + await page.nativeClick(uiResult.saveClickX, uiResult.saveClickY); + await new Promise((r) => setTimeout(r, 3500)); + const listsAfter = unwrapBrowserResult(await page.evaluate(`async () => { + const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' }); + if (!r.ok) return { __error: 'HTTP ' + r.status }; + return await r.json(); + }`)); + if (listsAfter && listsAfter.__error) { + throw new CommandExecutionError(`Could not verify list removal: ${listsAfter.__error}`); + } + if (!getListsManagementInstructions(listsAfter)) { + throw new CommandExecutionError('Could not verify list removal: unexpected lists payload shape'); + } + const parsedAfter = parseListsManagement(listsAfter, new Set()); + const afterList = parsedAfter.find((l) => l.id === listId); + if (!afterList) { + throw new CommandExecutionError(`Could not verify list removal: list ${listId} missing from post-delete payload`); + } + const memberCountAfter = Number(afterList.members) || 0; + if (memberCountAfter < memberCountBefore) { + verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`; + } else { + throw new CommandExecutionError(`Failed to remove @${username} from list ${listId}: member_count unchanged (${memberCountBefore} → ${memberCountAfter}).`); + } + } + + return [{ + listId, + username, + userId: String(userId), + status: uiResult.noop ? 'noop' : 'success', + message: uiResult.noop + ? `@${username} was not a member of list ${listId}` + : `Removed @${username} from list ${listId} (verified via ${verifiedBy})`, + }]; +} diff --git a/clis/twitter/list-remove.js b/clis/twitter/list-remove.js index 306c0de1c..2cc9cdc05 100644 --- a/clis/twitter/list-remove.js +++ b/clis/twitter/list-remove.js @@ -1,75 +1,5 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; -import { resolveTwitterQueryId } from './shared.js'; -import { getListsManagementInstructions, parseListsManagement } from './lists.js'; -import { TWITTER_BEARER_TOKEN } from './utils.js'; - -const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ'; -const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g'; - -const LISTS_MANAGEMENT_FEATURES = { - rweb_video_screen_enabled: false, - profile_label_improvements_pcf_label_in_post_enabled: true, - rweb_tipjar_consumption_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, - 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: false, - responsive_web_grok_analyze_post_followups_enabled: true, - responsive_web_jetfuel_frame: false, - responsive_web_grok_share_attachment_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, - responsive_web_grok_show_grok_translated_post: false, - responsive_web_grok_analysis_button_from_backend: 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, - longform_notetweets_rich_text_read_enabled: true, - longform_notetweets_inline_media_enabled: true, - responsive_web_grok_image_annotation_enabled: true, - responsive_web_enhance_cards_enabled: false, -}; - -function buildUserByScreenNameUrl(queryId, screenName) { - const vars = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true }); - const feats = JSON.stringify({ - hidden_profile_subscriptions_enabled: true, - rweb_tipjar_consumption_enabled: true, - responsive_web_graphql_exclude_directive_enabled: true, - verified_phone_label_enabled: false, - subscriptions_verification_info_is_identity_verified_enabled: true, - subscriptions_verification_info_verified_since_enabled: true, - highlights_tweets_tab_ui_enabled: true, - responsive_web_twitter_article_notes_tab_enabled: true, - subscriptions_feature_can_gift_premium: true, - creator_subscriptions_tweet_preview_api_enabled: true, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - responsive_web_graphql_timeline_navigation_enabled: true, - }); - return `/i/api/graphql/${queryId}/UserByScreenName` - + `?variables=${encodeURIComponent(vars)}` - + `&features=${encodeURIComponent(feats)}`; -} - -export function interpretRemoveResponse(status, json) { - if (status === 200 && json && (json.id_str || json.id || json.slug)) return { ok: true }; - if (json && Array.isArray(json.errors) && json.errors.length > 0) { - const err = json.errors[0]; - return { ok: false, error: `${err.code ? '[' + err.code + '] ' : ''}${err.message || 'Unknown error'}` }; - } - return { ok: false, error: `HTTP ${status}` }; -} +import { interpretRemoveResponse, listRemoveUser } from './list-remove-core.js'; cli({ site: 'twitter', @@ -84,223 +14,7 @@ cli({ { name: 'username', positional: true, type: 'string', required: true, help: 'Twitter/X handle to remove (with or without @)' }, ], columns: ['listId', 'username', 'userId', 'status', 'message'], - func: async (page, kwargs) => { - const listId = String(kwargs.listId || '').trim(); - const username = String(kwargs.username || '').replace(/^@/, '').trim(); - if (!listId || !/^\d+$/.test(listId)) { - throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}`); - } - if (!username) throw new CommandExecutionError('Username is required'); - - // Strategy.UI does not get a domain URL pre-nav from the framework. - // This page context is load-bearing for pre-target GraphQL calls below. - await page.goto('https://x.com'); - await page.wait(3); - const cookies = await page.getCookies({ url: 'https://x.com' }); - const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null; - if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)'); - - const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID); - const headers = JSON.stringify({ - 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`, - 'X-Csrf-Token': ct0, - 'X-Twitter-Auth-Type': 'OAuth2Session', - 'X-Twitter-Active-User': 'yes', - }); - - const userLookupUrl = buildUserByScreenNameUrl(userByScreenNameQueryId, username); - const userId = await page.evaluate(`async () => { - const resp = await fetch(${JSON.stringify(userLookupUrl)}, { headers: ${headers}, credentials: 'include' }); - if (!resp.ok) return null; - const d = await resp.json(); - return d.data?.user?.result?.rest_id || null; - }`); - if (!userId) throw new CommandExecutionError(`Could not resolve user @${username}`); - - // Resolve listId → name so we can match the dialog row. - const listsQueryId = await resolveTwitterQueryId(page, 'ListsManagementPageTimeline', LISTS_MANAGEMENT_QUERY_ID); - const listsUrl = `/i/api/graphql/${listsQueryId}/ListsManagementPageTimeline?features=${encodeURIComponent(JSON.stringify(LISTS_MANAGEMENT_FEATURES))}`; - const listsData = await page.evaluate(`async () => { - const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' }); - if (!r.ok) return { __error: 'HTTP ' + r.status }; - return await r.json(); - }`); - if (listsData && listsData.__error) { - throw new CommandExecutionError(`Could not fetch lists: ${listsData.__error}`); - } - const parsedLists = parseListsManagement(listsData, new Set()); - const targetList = parsedLists.find((l) => l.id === listId); - if (!targetList) { - throw new CommandExecutionError(`List ${listId} not found among your lists.`); - } - const targetName = targetList.name; - - await page.goto(`https://x.com/${username}`); - await page.wait({ selector: '[data-testid="primaryColumn"]' }); - const uiResult = await page.evaluate(`(async () => { - const sleep = (ms) => new Promise(r => setTimeout(r, ms)); - const findOne = (sel, root = document) => root.querySelector(sel); - const waitFor = async (fn, { timeoutMs = 8000, intervalMs = 200 } = {}) => { - const t0 = Date.now(); - while (Date.now() - t0 < timeoutMs) { const v = fn(); if (v) return v; await sleep(intervalMs); } - return null; - }; - try { - if (!window.__opencliListMutations) { - window.__opencliListMutations = []; - const origFetch = window.fetch.bind(window); - window.fetch = async function(...args) { - const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || ''; - const method = (args[1] && args[1].method) || 'GET'; - let resp; - try { resp = await origFetch(...args); } catch (err) { - if (/ListAddMember|ListRemoveMember|lists\\/members\\/(create|destroy)/.test(url)) { - window.__opencliListMutations.push({ url, method, status: 0, error: String(err), ts: Date.now() }); - } - throw err; - } - if (/ListAddMember|ListRemoveMember|lists\\/members\\/(create|destroy)/.test(url)) { - window.__opencliListMutations.push({ url, method, status: resp.status, ts: Date.now() }); - } - return resp; - }; - } - window.__opencliListMutations.length = 0; - - const caret = await waitFor(() => findOne('[data-testid="userActions"]')); - if (!caret) return { ok: false, message: 'Could not find user actions (…) button' }; - caret.click(); - await sleep(600); - const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]')); - const addToListItem = menuItems.find(el => /add\\/remove|从列表|列表|add to list|add or remove/i.test(el.innerText)); - if (!addToListItem) return { ok: false, message: 'Could not find "Add/remove from Lists" menu item' }; - addToListItem.click(); - await sleep(1200); - const dialog = await waitFor(() => findOne('[role="dialog"]')); - if (!dialog) return { ok: false, message: 'List selection dialog did not open' }; - - const targetName = ${JSON.stringify(targetName)}; - const scrollCandidates = [ - dialog.querySelector('[data-viewportview="true"]'), - ...Array.from(dialog.querySelectorAll('div')).filter(d => d.scrollHeight > d.clientHeight + 10), - ].filter(Boolean); - let scrollEl = scrollCandidates[0] || dialog; - for (const se of scrollCandidates) { - if (se.scrollHeight > se.clientHeight + 10) { scrollEl = se; break; } - } - let row = null; - let lastScrollTop = -1; - for (let i = 0; i < 12; i++) { - const cells = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]')); - row = cells.find(c => (c.innerText || '').split('\\n')[0].trim() === targetName); - if (row) break; - const prev = scrollEl.scrollTop; - scrollEl.scrollTop = prev + Math.max(200, scrollEl.clientHeight - 100); - if (scrollEl.scrollTop === prev && scrollEl.scrollTop === lastScrollTop) break; - lastScrollTop = scrollEl.scrollTop; - await sleep(500); - } - if (!row) { - const names = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]')) - .map(c => (c.innerText || '').split('\\n')[0].trim()).filter(Boolean); - return { ok: false, message: 'List "' + targetName + '" not found in dialog. Saw: ' + names.join(' | ') }; - } - - // Determine current membership: row has a filled checkmark (svg inside a specific container) when member. - // Heuristic: look for an aria-checked attribute, or an svg with specific fill on the row's right side. - // The listCell itself carries aria-checked. Require a stable reading - // (same value twice ~500ms apart) to avoid the X dialog's occasional - // flash of stale state when re-opened shortly after a toggle. - const listCell = row.querySelector('[data-testid="listCell"]') || row.querySelector('[role="checkbox"]') || row; - const readChecked = () => { - const v = listCell.getAttribute('aria-checked'); - return v === 'true' || v === 'false' ? v : null; - }; - await sleep(600); - let ariaChecked = readChecked(); - for (let i = 0; i < 8; i++) { - await sleep(500); - const next = readChecked(); - if (next && next === ariaChecked) break; - ariaChecked = next || ariaChecked; - } - const isMember = ariaChecked === 'true'; - if (!isMember) { - const closeBtn = findOne('[data-testid="app-bar-close"]') || findOne('[aria-label="Close"]'); - if (closeBtn) closeBtn.click(); - return { ok: true, noop: true }; - } - try { listCell.scrollIntoView({ block: 'center' }); } catch {} - await sleep(400); - const rowRect = listCell.getBoundingClientRect(); - const saveButton = Array.from(dialog.querySelectorAll('[role="button"], button')).find(b => { - const txt = (b.innerText || '').trim(); - return /^(Save|Done|保存|完成|儲存)$/i.test(txt); - }); - const saveRect = saveButton ? saveButton.getBoundingClientRect() : null; - return { - ok: true, - needsNativeInteraction: true, - rowClickX: Math.round(rowRect.left + rowRect.width / 2), - rowClickY: Math.round(rowRect.top + rowRect.height / 2), - saveClickX: saveRect ? Math.round(saveRect.left + saveRect.width / 2) : null, - saveClickY: saveRect ? Math.round(saveRect.top + saveRect.height / 2) : null, - mutationsBefore: window.__opencliListMutations.length, - }; - } catch (e) { - return { ok: false, message: 'UI error: ' + (e?.message || String(e)) }; - } - })()`); - - if (!uiResult.ok) { - throw new CommandExecutionError(`Failed to remove @${username} from list ${listId}: ${uiResult.message}`); - } - - let verifiedBy = null; - if (uiResult.needsNativeInteraction) { - if (typeof page.nativeClick !== 'function') { - throw new CommandExecutionError('Requires up-to-date Chrome extension (nativeClick).'); - } - if (!uiResult.saveClickX) { - throw new CommandExecutionError('Save button not found in dialog.'); - } - const memberCountBefore = Number(targetList.members) || 0; - await page.nativeClick(uiResult.rowClickX, uiResult.rowClickY); - await new Promise((r) => setTimeout(r, 800)); - await page.nativeClick(uiResult.saveClickX, uiResult.saveClickY); - await new Promise((r) => setTimeout(r, 3500)); - const listsAfter = await page.evaluate(`async () => { - const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' }); - if (!r.ok) return { __error: 'HTTP ' + r.status }; - return await r.json(); - }`); - if (listsAfter && listsAfter.__error) { - throw new CommandExecutionError(`Could not verify list removal: ${listsAfter.__error}`); - } - if (!getListsManagementInstructions(listsAfter)) { - throw new CommandExecutionError('Could not verify list removal: unexpected lists payload shape'); - } - const parsedAfter = parseListsManagement(listsAfter, new Set()); - const afterList = parsedAfter.find((l) => l.id === listId); - if (!afterList) { - throw new CommandExecutionError(`Could not verify list removal: list ${listId} missing from post-delete payload`); - } - const memberCountAfter = Number(afterList.members) || 0; - if (memberCountAfter < memberCountBefore) { - verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`; - } else { - throw new CommandExecutionError(`Failed to remove @${username} from list ${listId}: member_count unchanged (${memberCountBefore} → ${memberCountAfter}).`); - } - } - - return [{ - listId, - username, - userId: String(userId), - status: uiResult.noop ? 'noop' : 'success', - message: uiResult.noop - ? `@${username} was not a member of list ${listId}` - : `Removed @${username} from list ${listId} (verified via ${verifiedBy})`, - }]; - }, + func: listRemoveUser, }); + +export { interpretRemoveResponse, listRemoveUser }; diff --git a/clis/twitter/list-remove.test.js b/clis/twitter/list-remove.test.js index 477e0c875..cd2c601bb 100644 --- a/clis/twitter/list-remove.test.js +++ b/clis/twitter/list-remove.test.js @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { getRegistry } from '@jackwener/opencli/registry'; -import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; import './list-remove.js'; function buildListsPayload(listId = '123', memberCount = 10) { @@ -84,6 +84,43 @@ describe('twitter list-remove registration', () => { expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' }); }); + it('rejects invalid user input before navigation', async () => { + const cmd = getRegistry().get('twitter/list-remove'); + const page = { + goto: vi.fn(), + wait: vi.fn(), + getCookies: vi.fn(), + evaluate: vi.fn(), + }; + + await expect(cmd.func(page, { listId: 'abc', username: 'alice' })).rejects.toBeInstanceOf(ArgumentError); + await expect(cmd.func(page, { listId: '123', username: '' })).rejects.toBeInstanceOf(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('unwraps Browser Bridge envelopes for user lookup and UI action results', async () => { + const cmd = getRegistry().get('twitter/list-remove'); + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]), + evaluate: vi.fn() + .mockResolvedValueOnce(null) // UserByScreenName queryId fallback + .mockResolvedValueOnce({ session: 'site:twitter', data: 'user-1' }) + .mockResolvedValueOnce(null) // ListsManagement queryId fallback + .mockResolvedValueOnce({ session: 'site:twitter', data: buildListsPayload('123', 10) }) + .mockResolvedValueOnce({ session: 'site:twitter', data: { ok: true, noop: true } }), + }; + + await expect(cmd.func(page, { listId: '123', username: 'alice' })).resolves.toEqual([{ + listId: '123', + username: 'alice', + userId: 'user-1', + status: 'noop', + message: '@alice was not a member of list 123', + }]); + }); + it('does not treat post-delete fetch failure as successful member_count decrease', async () => { const cmd = getRegistry().get('twitter/list-remove'); const page = buildRemovePage({ __error: 'HTTP 500' }); diff --git a/docs/adapters/browser/twitter.md b/docs/adapters/browser/twitter.md index deb1d60da..566d8c8dc 100644 --- a/docs/adapters/browser/twitter.md +++ b/docs/adapters/browser/twitter.md @@ -24,8 +24,11 @@ | `opencli twitter lists` | | | `opencli twitter list-tweets` | | | `opencli twitter list-create` | Create a Twitter/X list via GraphQL and return the created list id | +| `opencli twitter list-delete` | Delete a Twitter/X list you own after explicit confirmation | | `opencli twitter list-add` | | +| `opencli twitter list-add-batch` | Add multiple users to a Twitter/X list you own from a comma-separated username list | | `opencli twitter list-remove` | | +| `opencli twitter list-remove-batch` | Remove multiple users from a Twitter/X list you own from a comma-separated username list | | `opencli twitter article` | | | `opencli twitter follow` | | | `opencli twitter unfollow` | | @@ -66,8 +69,11 @@ opencli twitter download --tweet-url https://x.com/jack/status/20 --output ./twi # Create a list and then manage members (requires login) opencli twitter list-create "AI research" --description "Papers and labs" --mode private +opencli twitter list-delete 123456789 --confirm true opencli twitter list-add 123456789 alice +opencli twitter list-add-batch 123456789 "@alice,@bob" --interval 5 opencli twitter list-remove 123456789 alice +opencli twitter list-remove-batch 123456789 "@alice,@bob" --interval 5 # Write actions (require login). Idempotent — calling twice is safe. opencli twitter like https://x.com/jack/status/20