From 966599522ac779f8927b605cd7d492dcb6c4b949 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 19 Jan 2026 05:11:11 +0000 Subject: [PATCH] fix: follow/unfollow hardening (#54) (thanks @citizenlee) --- CHANGELOG.md | 2 +- docs/testing.md | 2 +- scripts/update-query-ids.ts | 2 + src/commands/follow.ts | 31 ++++--- src/lib/query-ids.json | 2 + src/lib/twitter-client-constants.ts | 2 + src/lib/twitter-client-follow.ts | 127 +++++++++++++++++----------- src/lib/twitter-client.ts | 4 +- tests/commands.follow.test.ts | 54 ++++++++++++ tests/live/live.test.ts | 20 +++-- 10 files changed, 172 insertions(+), 74 deletions(-) create mode 100644 tests/commands.follow.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0468384..a735361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala. - `replies` and `thread` now support pagination (`--all`, `--max-pages`, `--cursor`, `--delay`) (#35) — thanks @crcatala. - `search` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#42) — thanks @pjtf93. -- `about` command for account origin/location details (#51) — thanks @pjtf93. +- `follow`/`unfollow` commands to manage follows (#54) — thanks @citizenlee. - `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes. - `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli. - Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0. diff --git a/docs/testing.md b/docs/testing.md index 39e9a87..0bb6135 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -22,7 +22,7 @@ Run: Notes: - Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`). - Search query is configurable via `BIRD_LIVE_SEARCH_QUERY`. -- About account handle is configurable via `BIRD_LIVE_ABOUT_HANDLE`. +- Follow/unfollow handle is configurable via `BIRD_LIVE_FOLLOW_HANDLE` (opt-in). - Command timeout is configurable via `BIRD_LIVE_TIMEOUT_MS` (ms). - Spawned CLI `NODE_ENV` defaults to `production` (override with `BIRD_LIVE_NODE_ENV`). - If you don't tweet, set `BIRD_LIVE_TWEET_ID` to a known tweet ID to use for `read/replies/thread`. diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts index abf32a3..14feca4 100644 --- a/scripts/update-query-ids.ts +++ b/scripts/update-query-ids.ts @@ -10,6 +10,8 @@ import path from 'node:path'; const TARGET_OPERATIONS = [ 'CreateTweet', 'CreateRetweet', + 'CreateFriendship', + 'DestroyFriendship', 'FavoriteTweet', 'DeleteBookmark', 'TweetDetail', diff --git a/src/commands/follow.ts b/src/commands/follow.ts index 23e3d46..a5ae8b1 100644 --- a/src/commands/follow.ts +++ b/src/commands/follow.ts @@ -3,30 +3,35 @@ import type { CliContext } from '../cli/shared.js'; import { normalizeHandle } from '../lib/normalize-handle.js'; import { TwitterClient } from '../lib/twitter-client.js'; +const ONLY_DIGITS_REGEX = /^\d+$/; + async function resolveUserId( client: TwitterClient, usernameOrId: string, ctx: CliContext, ): Promise<{ userId: string; username?: string } | null> { - // If it looks like a numeric ID, use it directly - if (/^\d+$/.test(usernameOrId)) { - return { userId: usernameOrId }; - } + const raw = usernameOrId.trim(); + const isNumeric = ONLY_DIGITS_REGEX.test(raw); // Otherwise, treat as username and look up - const handle = normalizeHandle(usernameOrId); - if (!handle) { - console.error(`${ctx.p('err')}Invalid username: ${usernameOrId}`); - return null; + const handle = normalizeHandle(raw); + if (handle) { + const lookup = await client.getUserIdByUsername(handle); + if (lookup.success && lookup.userId) { + return { userId: lookup.userId, username: lookup.username }; + } + if (!isNumeric) { + console.error(`${ctx.p('err')}Failed to find user @${handle}: ${lookup.error ?? 'Unknown error'}`); + return null; + } } - const lookup = await client.getUserIdByUsername(handle); - if (!lookup.success || !lookup.userId) { - console.error(`${ctx.p('err')}Failed to find user @${handle}: ${lookup.error ?? 'Unknown error'}`); - return null; + if (isNumeric) { + return { userId: raw }; } - return { userId: lookup.userId, username: lookup.username }; + console.error(`${ctx.p('err')}Invalid username: ${usernameOrId}`); + return null; } export function registerFollowCommands(program: Command, ctx: CliContext): void { diff --git a/src/lib/query-ids.json b/src/lib/query-ids.json index b75ce7e..6033f31 100644 --- a/src/lib/query-ids.json +++ b/src/lib/query-ids.json @@ -1,6 +1,8 @@ { "CreateTweet": "nmdAQXJDxw6-0KKF2on7eA", "CreateRetweet": "LFho5rIi4xcKO90p9jwG7A", + "CreateFriendship": "8h9JVdV8dlSyqyRDJEPCsA", + "DestroyFriendship": "ppXWuagMNXgvzx6WoXBW0Q", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ", "TweetDetail": "_NvJCnIjOW__EP5-RF197A", diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts index 167b830..2e9f302 100644 --- a/src/lib/twitter-client-constants.ts +++ b/src/lib/twitter-client-constants.ts @@ -12,6 +12,8 @@ export const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/updat export const FALLBACK_QUERY_IDS = { CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg', CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', + CreateFriendship: '8h9JVdV8dlSyqyRDJEPCsA', + DestroyFriendship: 'ppXWuagMNXgvzx6WoXBW0Q', FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ', TweetDetail: '97JF30KziU00483E_8elBA', diff --git a/src/lib/twitter-client-follow.ts b/src/lib/twitter-client-follow.ts index 800ed63..3f1ad89 100644 --- a/src/lib/twitter-client-follow.ts +++ b/src/lib/twitter-client-follow.ts @@ -48,10 +48,7 @@ export function withFollow> return this.followViaGraphQL(userId, false); } - private async followViaRest( - userId: string, - action: 'create' | 'destroy', - ): Promise { + private async followViaRest(userId: string, action: 'create' | 'destroy'): Promise { const urls = [ `https://x.com/i/api/1.1/friendships/${action}.json`, `https://api.twitter.com/1.1/friendships/${action}.json`, @@ -77,7 +74,7 @@ export function withFollow> if (!response.ok) { const text = await response.text(); - + // Parse error response try { const errorData = JSON.parse(text) as { errors?: Array<{ code: number; message: string }> }; @@ -138,70 +135,98 @@ export function withFollow> private async followViaGraphQL(userId: string, follow: boolean): Promise { const operationName = follow ? 'CreateFriendship' : 'DestroyFriendship'; - - // Known query IDs for friendship operations - const queryIds = follow - ? ['8h9JVdV8dlSyqyRDJEPCsA', 'OPwKc1HXnBT_bWXfAlo-9g'] - : ['8h9JVdV8dlSyqyRDJEPCsA', 'ppXWuagMNXgvzx6WoXBW0Q']; - const variables = { user_id: userId, }; - let lastError: string | undefined; + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getFollowQueryIds(follow); - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/${operationName}`; + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/${operationName}`; - try { - const response = await this.fetchWithTimeout(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify({ variables, queryId }), - }); + try { + const response = await this.fetchWithTimeout(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ variables, queryId }), + }); - if (response.status === 404) { - lastError = `HTTP 404`; - continue; - } + if (response.status === 404) { + had404 = true; + lastError = 'HTTP 404'; + continue; + } - if (!response.ok) { - const text = await response.text(); - lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; - continue; - } + if (!response.ok) { + const text = await response.text(); + lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; + continue; + } - const data = (await response.json()) as { - data?: { - user?: { - result?: { - rest_id?: string; - legacy?: { - screen_name?: string; + const data = (await response.json()) as { + data?: { + user?: { + result?: { + rest_id?: string; + legacy?: { + screen_name?: string; + }; }; }; }; + errors?: Array<{ message: string }>; }; - errors?: Array<{ message: string }>; - }; - if (data.errors && data.errors.length > 0) { - lastError = data.errors.map((e) => e.message).join(', '); - continue; + if (data.errors && data.errors.length > 0) { + lastError = data.errors.map((e) => e.message).join(', '); + continue; + } + + const result = data.data?.user?.result; + return { + success: true as const, + userId: result?.rest_id, + username: result?.legacy?.screen_name, + had404, + }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); } - - const result = data.data?.user?.result; - return { - success: true, - userId: result?.rest_id, - username: result?.legacy?.screen_name, - }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); } + + return { + success: false as const, + error: lastError ?? `Unknown error during ${operationName}`, + had404, + }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, userId: firstAttempt.userId, username: firstAttempt.username }; } - return { success: false, error: lastError ?? `Unknown error during ${operationName}` }; + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, userId: secondAttempt.userId, username: secondAttempt.username }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } + + private async getFollowQueryIds(follow: boolean): Promise { + const primary = await this.getQueryId(follow ? 'CreateFriendship' : 'DestroyFriendship'); + const fallbacks = follow + ? ['8h9JVdV8dlSyqyRDJEPCsA', 'OPwKc1HXnBT_bWXfAlo-9g'] + : ['ppXWuagMNXgvzx6WoXBW0Q', '8h9JVdV8dlSyqyRDJEPCsA']; + return Array.from(new Set([primary, ...fallbacks])); } } diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 531bdcb..5f257dc 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -36,7 +36,9 @@ const MixedTwitterClient = withNews( withUsers( withLists( withHome( - withTimelines(withSearch(withTweetDetails(withPosting(withFollow(withBookmarks(withMedia(TwitterClientBase))))))), + withTimelines( + withSearch(withTweetDetails(withPosting(withFollow(withBookmarks(withMedia(TwitterClientBase)))))), + ), ), ), ), diff --git a/tests/commands.follow.test.ts b/tests/commands.follow.test.ts new file mode 100644 index 0000000..c19a505 --- /dev/null +++ b/tests/commands.follow.test.ts @@ -0,0 +1,54 @@ +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CliContext } from '../src/cli/shared.js'; +import { registerFollowCommands } from '../src/commands/follow.js'; +import { TwitterClient } from '../src/lib/twitter-client.js'; + +const baseCtx = { + resolveTimeoutFromOptions: () => undefined, + resolveCredentialsFromOptions: async () => ({ + cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' }, + warnings: [], + }), + p: () => '', +} as unknown as CliContext; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('follow commands', () => { + it('prefers username lookup for numeric handles', async () => { + const program = new Command(); + registerFollowCommands(program, baseCtx); + + const lookupSpy = vi.spyOn(TwitterClient.prototype, 'getUserIdByUsername').mockResolvedValue({ + success: true, + userId: '999', + username: '12345', + }); + const followSpy = vi.spyOn(TwitterClient.prototype, 'follow').mockResolvedValue({ success: true }); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await program.parseAsync(['node', 'bird', 'follow', '12345']); + + expect(lookupSpy).toHaveBeenCalledWith('12345'); + expect(followSpy).toHaveBeenCalledWith('999'); + }); + + it('falls back to numeric user IDs when lookup fails', async () => { + const program = new Command(); + registerFollowCommands(program, baseCtx); + + vi.spyOn(TwitterClient.prototype, 'getUserIdByUsername').mockResolvedValue({ + success: false, + error: 'User not found', + }); + const followSpy = vi.spyOn(TwitterClient.prototype, 'follow').mockResolvedValue({ success: true }); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await program.parseAsync(['node', 'bird', 'follow', '12345']); + + expect(followSpy).toHaveBeenCalledWith('12345'); + }); +}); diff --git a/tests/live/live.test.ts b/tests/live/live.test.ts index 24525ec..29a40a1 100644 --- a/tests/live/live.test.ts +++ b/tests/live/live.test.ts @@ -149,15 +149,21 @@ d('live CLI (Twitter/X)', () => { expect(whoamiStdout).toContain('credentials:'); }); - it('about returns account JSON', async () => { - const aboutHandle = (process.env.BIRD_LIVE_ABOUT_HANDLE ?? handle).trim() || handle; - const about = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'about', aboutHandle, '--json'], { + it('follow/unfollow works (opt-in)', async () => { + const followHandle = (process.env.BIRD_LIVE_FOLLOW_HANDLE ?? '').trim(); + if (!followHandle) { + return; + } + + const follow = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'follow', followHandle], { timeoutMs: 45_000, }); - expect(about.exitCode).toBe(0); - const payload = parseJson>(about.stdout); - expect(Array.isArray(payload)).toBe(false); - expect(Object.keys(payload).length).toBeGreaterThan(0); + expect(follow.exitCode).toBe(0); + + const unfollow = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'unfollow', followHandle], { + timeoutMs: 45_000, + }); + expect(unfollow.exitCode).toBe(0); }); it('read returns tweet JSON', async () => {