From 34f45fece72ed55f1cfa9bd6bb93e09b68baee7f Mon Sep 17 00:00:00 2001 From: thelockmeister <87852832+lockmeister@users.noreply.github.com> Date: Thu, 1 Jan 2026 09:06:17 +1300 Subject: [PATCH 1/6] feat: add following and followers commands Add two new commands to list users: - `bird following` - list users that you (or another user) follow - `bird followers` - list users that follow you (or another user) Both commands support: - `--user ` to query another user's following/followers - `-n ` to limit results - `--json` for structured output Uses Twitter's GraphQL Following/Followers endpoints with proper feature flags and query ID rotation support. --- README.md | 10 ++ src/index.ts | 128 ++++++++++++++++++++ src/lib/query-ids.json | 4 +- src/lib/twitter-client.ts | 242 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 383 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e76627..6f4fb86 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,14 @@ bird mentions --user @steipete -n 5 # Bookmarks bird bookmarks -n 5 +# Following (who you follow) +bird following -n 20 +bird following --user 12345678 -n 10 # by user ID + +# Followers (who follows you) +bird followers -n 20 +bird followers --user 12345678 -n 10 # by user ID + # Refresh GraphQL query IDs cache (no rebuild) bird query-ids --fresh ``` @@ -68,6 +76,8 @@ bird query-ids --fresh - `bird search "" [-n count] [--json]` — search for tweets matching a query. - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). - `bird bookmarks [-n count] [--json]` — list your bookmarked tweets. +- `bird following [--user ] [-n count] [--json]` — list users that you (or another user) follow. +- `bird followers [--user ] [-n count] [--json]` — list users that follow you (or another user). - `bird whoami` — print which Twitter account your cookies belong to. - `bird check` — show which credentials are available and where they were sourced from. diff --git a/src/index.ts b/src/index.ts index 3e1a8fc..f457dc9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -150,6 +150,8 @@ const KNOWN_COMMANDS = new Set([ 'search', 'mentions', 'bookmarks', + 'following', + 'followers', 'help', 'whoami', 'check', @@ -741,6 +743,132 @@ program } }); +// Following command - get users that a user follows +program + .command('following') + .description('Get users that you (or another user) follow') + .option('--user ', 'User ID to get following for (defaults to current user)') + .option('-n, --count ', 'Number of users to fetch', '20') + .option('--json', 'Output as JSON') + .action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => { + const opts = program.opts(); + const timeoutMs = resolveTimeoutFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '20', 10); + + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); + + for (const warning of warnings) { + console.error(`${p('warn')}${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error(`${p('err')}Missing required credentials`); + process.exit(1); + } + + const client = new TwitterClient({ cookies, timeoutMs }); + + // Get user ID - either from option or current user + let userId = cmdOpts.user; + if (!userId) { + const currentUser = await client.getCurrentUser(); + if (!currentUser.success || !currentUser.user?.id) { + console.error(`${p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`); + process.exit(1); + } + userId = currentUser.user.id; + } + + const result = await client.getFollowing(userId, count); + + if (result.success && result.users) { + if (cmdOpts.json) { + console.log(JSON.stringify(result.users, null, 2)); + } else { + if (result.users.length === 0) { + console.log('No users found.'); + } else { + for (const user of result.users) { + console.log(`@${user.username} (${user.name})`); + if (user.description) { + console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`); + } + if (user.followers_count !== undefined) { + console.log(` ${p('info')}${user.followers_count.toLocaleString()} followers`); + } + console.log('──────────────────────────────────────────────────'); + } + } + } + } else { + console.error(`${p('err')}Failed to fetch following: ${result.error}`); + process.exit(1); + } + }); + +// Followers command - get users that follow a user +program + .command('followers') + .description('Get users that follow you (or another user)') + .option('--user ', 'User ID to get followers for (defaults to current user)') + .option('-n, --count ', 'Number of users to fetch', '20') + .option('--json', 'Output as JSON') + .action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => { + const opts = program.opts(); + const timeoutMs = resolveTimeoutFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '20', 10); + + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); + + for (const warning of warnings) { + console.error(`${p('warn')}${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error(`${p('err')}Missing required credentials`); + process.exit(1); + } + + const client = new TwitterClient({ cookies, timeoutMs }); + + // Get user ID - either from option or current user + let userId = cmdOpts.user; + if (!userId) { + const currentUser = await client.getCurrentUser(); + if (!currentUser.success || !currentUser.user?.id) { + console.error(`${p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`); + process.exit(1); + } + userId = currentUser.user.id; + } + + const result = await client.getFollowers(userId, count); + + if (result.success && result.users) { + if (cmdOpts.json) { + console.log(JSON.stringify(result.users, null, 2)); + } else { + if (result.users.length === 0) { + console.log('No users found.'); + } else { + for (const user of result.users) { + console.log(`@${user.username} (${user.name})`); + if (user.description) { + console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`); + } + if (user.followers_count !== undefined) { + console.log(` ${p('info')}${user.followers_count.toLocaleString()} followers`); + } + console.log('──────────────────────────────────────────────────'); + } + } + } + } else { + console.error(`${p('err')}Failed to fetch followers: ${result.error}`); + process.exit(1); + } + }); + // Whoami command - show the logged-in account program .command('whoami') diff --git a/src/lib/query-ids.json b/src/lib/query-ids.json index d981057..e9b40b0 100644 --- a/src/lib/query-ids.json +++ b/src/lib/query-ids.json @@ -3,5 +3,7 @@ "CreateRetweet": "LFho5rIi4xcKO90p9jwG7A", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "TweetDetail": "97JF30KziU00483E_8elBA", - "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ" + "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ", + "Following": "BEkNpEt5pNETESoqMsTEGA", + "Followers": "kuFUYP9eV1FPoEy4N-pi7w" } diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index db740a5..2906901 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -28,6 +28,8 @@ const FALLBACK_QUERY_IDS = { SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw', + Following: 'BEkNpEt5pNETESoqMsTEGA', + Followers: 'kuFUYP9eV1FPoEy4N-pi7w', } as const; type OperationName = keyof typeof FALLBACK_QUERY_IDS; @@ -240,6 +242,24 @@ export interface CurrentUserResult { error?: string; } +export interface TwitterUser { + id: string; + username: string; + name: string; + description?: string; + followers_count?: number; + following_count?: number; + is_blue_verified?: boolean; + profile_image_url?: string; + created_at?: string; +} + +export interface FollowingResult { + success: boolean; + users?: TwitterUser[]; + error?: string; +} + export interface TwitterClientOptions { cookies: TwitterCookies; userAgent?: string; @@ -1987,4 +2007,226 @@ export class TwitterClient { return { success: false, error: firstAttempt.error }; } + + private buildFollowingFeatures(): Record { + return { + rweb_video_screen_enabled: true, + profile_label_improvements_pcf_label_in_post_enabled: false, + responsive_web_profile_redirect_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: true, + 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: false, + responsive_web_jetfuel_frame: false, + responsive_web_grok_share_attachment_enabled: false, + 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: true, + 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: false, + responsive_web_grok_imagine_annotation_enabled: false, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_enhance_cards_enabled: false, + }; + } + + private parseUsersFromInstructions( + instructions: Array<{ type?: string; entries?: Array }> | undefined, + ): TwitterUser[] { + if (!instructions) { + return []; + } + + const users: TwitterUser[] = []; + + for (const instruction of instructions) { + if (instruction.type !== 'TimelineAddEntries' || !instruction.entries) { + continue; + } + + for (const entry of instruction.entries) { + const content = (entry as { content?: { itemContent?: { user_results?: { result?: unknown } } } })?.content; + const userResult = content?.itemContent?.user_results?.result as + | { + __typename?: string; + rest_id?: string; + is_blue_verified?: boolean; + legacy?: { + screen_name?: string; + name?: string; + description?: string; + followers_count?: number; + friends_count?: number; + profile_image_url_https?: string; + created_at?: string; + }; + core?: { + screen_name?: string; + name?: string; + created_at?: string; + }; + avatar?: { + image_url?: string; + }; + } + | undefined; + + if (!userResult || userResult.__typename !== 'User') { + continue; + } + + const legacy = userResult.legacy; + const core = userResult.core; + + users.push({ + id: userResult.rest_id ?? '', + username: legacy?.screen_name ?? core?.screen_name ?? '', + name: legacy?.name ?? core?.name ?? '', + description: legacy?.description, + followers_count: legacy?.followers_count, + following_count: legacy?.friends_count, + is_blue_verified: userResult.is_blue_verified, + profile_image_url: legacy?.profile_image_url_https ?? userResult.avatar?.image_url, + created_at: legacy?.created_at ?? core?.created_at, + }); + } + } + + return users; + } + + /** + * Get users that a user is following + */ + async getFollowing(userId: string, count = 20): Promise { + const variables = { + userId, + count, + includePromotedContent: false, + }; + + const features = this.buildFollowingFeatures(); + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + const queryId = await this.getQueryId('Following'); + const url = `${TWITTER_API_BASE}/${queryId}/Following?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + const text = await response.text(); + return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; + } + + const data = (await response.json()) as { + data?: { + user?: { + result?: { + timeline?: { + timeline?: { + instructions?: Array<{ type?: string; entries?: Array }>; + }; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false, error: data.errors.map((e) => e.message).join(', ') }; + } + + const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; + const users = this.parseUsersFromInstructions(instructions); + + return { success: true, users }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + /** + * Get users that follow a user + */ + async getFollowers(userId: string, count = 20): Promise { + const variables = { + userId, + count, + includePromotedContent: false, + }; + + const features = this.buildFollowingFeatures(); + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + const queryId = await this.getQueryId('Followers'); + const url = `${TWITTER_API_BASE}/${queryId}/Followers?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + const text = await response.text(); + return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; + } + + const data = (await response.json()) as { + data?: { + user?: { + result?: { + timeline?: { + timeline?: { + instructions?: Array<{ type?: string; entries?: Array }>; + }; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false, error: data.errors.map((e) => e.message).join(', ') }; + } + + const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; + const users = this.parseUsersFromInstructions(instructions); + + return { success: true, users }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } } From 9d715fb3a65fee5d705986abf9538a93f8c95741 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 11:25:21 +0100 Subject: [PATCH 2/6] feat: add following/followers query refresh + retry --- scripts/update-query-ids.ts | 2 + src/index.ts | 3 + src/lib/twitter-client.ts | 204 +++++++++++++++++++++++++----------- 3 files changed, 148 insertions(+), 61 deletions(-) diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts index 60b8648..0b8195e 100644 --- a/scripts/update-query-ids.ts +++ b/scripts/update-query-ids.ts @@ -14,6 +14,8 @@ const TARGET_OPERATIONS = [ 'TweetDetail', 'SearchTimeline', 'Bookmarks', + 'Following', + 'Followers', ] as const; type OperationName = (typeof TARGET_OPERATIONS)[number]; diff --git a/src/index.ts b/src/index.ts index f457dc9..0a1c491 100644 --- a/src/index.ts +++ b/src/index.ts @@ -349,6 +349,9 @@ program 'SearchTimeline', 'UserArticlesTweets', 'Bookmarks', + 'Following', + 'Followers', + 'Likes', ]; if (cmdOpts.fresh) { diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 2906901..cf519b2 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -2047,6 +2047,16 @@ export class TwitterClient { }; } + private async getFollowingQueryIds(): Promise { + const primary = await this.getQueryId('Following'); + return Array.from(new Set([primary, 'BEkNpEt5pNETESoqMsTEGA'])); + } + + private async getFollowersQueryIds(): Promise { + const primary = await this.getQueryId('Followers'); + return Array.from(new Set([primary, 'kuFUYP9eV1FPoEy4N-pi7w'])); + } + private parseUsersFromInstructions( instructions: Array<{ type?: string; entries?: Array }> | undefined, ): TwitterUser[] { @@ -2057,17 +2067,18 @@ export class TwitterClient { const users: TwitterUser[] = []; for (const instruction of instructions) { - if (instruction.type !== 'TimelineAddEntries' || !instruction.entries) { + if (!instruction.entries) { continue; } for (const entry of instruction.entries) { const content = (entry as { content?: { itemContent?: { user_results?: { result?: unknown } } } })?.content; - const userResult = content?.itemContent?.user_results?.result as + const rawUserResult = content?.itemContent?.user_results?.result as | { __typename?: string; rest_id?: string; is_blue_verified?: boolean; + user?: unknown; legacy?: { screen_name?: string; name?: string; @@ -2088,17 +2099,26 @@ export class TwitterClient { } | undefined; + const userResult = + rawUserResult?.__typename === 'UserWithVisibilityResults' && rawUserResult.user + ? (rawUserResult.user as typeof rawUserResult) + : rawUserResult; + if (!userResult || userResult.__typename !== 'User') { continue; } const legacy = userResult.legacy; const core = userResult.core; + const username = legacy?.screen_name ?? core?.screen_name; + if (!userResult.rest_id || !username) { + continue; + } users.push({ - id: userResult.rest_id ?? '', - username: legacy?.screen_name ?? core?.screen_name ?? '', - name: legacy?.name ?? core?.name ?? '', + id: userResult.rest_id, + username, + name: legacy?.name ?? core?.name ?? username, description: legacy?.description, followers_count: legacy?.followers_count, following_count: legacy?.friends_count, @@ -2129,46 +2149,77 @@ export class TwitterClient { features: JSON.stringify(features), }); - const queryId = await this.getQueryId('Following'); - const url = `${TWITTER_API_BASE}/${queryId}/Following?${params.toString()}`; + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getFollowingQueryIds(); - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/Following?${params.toString()}`; - if (!response.ok) { - const text = await response.text(); - return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; - } + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); - const data = (await response.json()) as { - data?: { - user?: { - result?: { - timeline?: { - timeline?: { - instructions?: Array<{ type?: string; entries?: Array }>; + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + user?: { + result?: { + timeline?: { + timeline?: { + instructions?: Array<{ type?: string; entries?: Array }>; + }; + }; }; }; }; + errors?: Array<{ message: string }>; }; - }; - errors?: Array<{ message: string }>; - }; - if (data.errors && data.errors.length > 0) { - return { success: false, error: data.errors.map((e) => e.message).join(', ') }; + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; + const users = this.parseUsersFromInstructions(instructions); + + return { success: true as const, users, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } } - const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; - const users = this.parseUsersFromInstructions(instructions); + return { success: false as const, error: lastError ?? 'Unknown error fetching following', had404 }; + }; - return { success: true, users }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, users: firstAttempt.users }; } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, users: secondAttempt.users }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; } /** @@ -2188,45 +2239,76 @@ export class TwitterClient { features: JSON.stringify(features), }); - const queryId = await this.getQueryId('Followers'); - const url = `${TWITTER_API_BASE}/${queryId}/Followers?${params.toString()}`; + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getFollowersQueryIds(); - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/Followers?${params.toString()}`; - if (!response.ok) { - const text = await response.text(); - return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; - } + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); - const data = (await response.json()) as { - data?: { - user?: { - result?: { - timeline?: { - timeline?: { - instructions?: Array<{ type?: string; entries?: Array }>; + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + user?: { + result?: { + timeline?: { + timeline?: { + instructions?: Array<{ type?: string; entries?: Array }>; + }; + }; }; }; }; + errors?: Array<{ message: string }>; }; - }; - errors?: Array<{ message: string }>; - }; - if (data.errors && data.errors.length > 0) { - return { success: false, error: data.errors.map((e) => e.message).join(', ') }; + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; + const users = this.parseUsersFromInstructions(instructions); + + return { success: true as const, users, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } } - const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; - const users = this.parseUsersFromInstructions(instructions); + return { success: false as const, error: lastError ?? 'Unknown error fetching followers', had404 }; + }; - return { success: true, users }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, users: firstAttempt.users }; } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, users: secondAttempt.users }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; } } From 97c73e939faffb2e4a17c6ca42c95a5c4ff6f036 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 11:25:32 +0100 Subject: [PATCH 3/6] test: cover following/followers --- tests/cli-args.test.ts | 4 + tests/twitter-client-coverage.api.test.ts | 63 ++++++++ tests/twitter-client.test.ts | 189 ++++++++++++++++++++++ 3 files changed, 256 insertions(+) diff --git a/tests/cli-args.test.ts b/tests/cli-args.test.ts index 6aeef7b..1927203 100644 --- a/tests/cli-args.test.ts +++ b/tests/cli-args.test.ts @@ -11,6 +11,10 @@ describe('cli-args', () => { 'thread', 'search', 'mentions', + 'bookmarks', + 'following', + 'followers', + 'likes', 'help', 'whoami', 'check', diff --git a/tests/twitter-client-coverage.api.test.ts b/tests/twitter-client-coverage.api.test.ts index 7083dc6..0938077 100644 --- a/tests/twitter-client-coverage.api.test.ts +++ b/tests/twitter-client-coverage.api.test.ts @@ -17,6 +17,8 @@ type ResponseLike = { type TwitterClientApiPrivate = TwitterClient & { getBookmarksQueryIds: () => Promise; + getFollowingQueryIds: () => Promise; + getFollowersQueryIds: () => Promise; }; const makeResponse = (overrides: Partial = {}): ResponseLike => ({ @@ -204,4 +206,65 @@ describe('TwitterClient API coverage', () => { expect(result.error).toBe('HTTP 404'); }); }); + + describe('following/followers error paths', () => { + it('returns an error for non-ok responses', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getFollowingQueryIds = async () => ['test']; + + const result = await client.getFollowing('123', 1); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 500'); + }); + + it('returns API errors from payloads', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + makeResponse({ + json: async () => ({ errors: [{ message: 'bad' }] }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getFollowersQueryIds = async () => ['test']; + + const result = await client.getFollowers('123', 1); + + expect(result.success).toBe(false); + expect(result.error).toBe('bad'); + }); + + it('returns unknown error when no query ids are available', async () => { + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getFollowingQueryIds = async () => []; + + const result = await client.getFollowing('123', 1); + + expect(result.success).toBe(false); + expect(result.error).toBe('Unknown error fetching following'); + }); + + it('returns the second attempt error after 404s', async () => { + const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getFollowersQueryIds = async () => ['test']; + + const result = await client.getFollowers('123', 1); + + expect(result.success).toBe(false); + expect(result.error).toBe('HTTP 404'); + }); + }); }); diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index f4f3fe9..78346e8 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -1144,6 +1144,195 @@ describe('TwitterClient', () => { }); }); + describe('following/followers', () => { + let mockFetch: ReturnType; + + const makeUserResult = (id: string, username: string, name = username) => ({ + __typename: 'User', + rest_id: id, + legacy: { + screen_name: username, + name, + description: `bio-${id}`, + followers_count: 10, + friends_count: 5, + profile_image_url_https: `https://example.com/${id}.jpg`, + created_at: '2024-01-01T00:00:00Z', + }, + }); + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + it('fetches following users and filters invalid entries', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + type: 'TimelineAddEntries', + entries: [ + { + content: { + itemContent: { + user_results: { + result: makeUserResult('1', 'alpha', 'Alpha'), + }, + }, + }, + }, + { + content: { + itemContent: { + user_results: { + result: { __typename: 'User', rest_id: '2' }, + }, + }, + }, + }, + { + content: { + itemContent: { + user_results: { + result: { __typename: 'TimelineUser' }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise }; + clientPrivate.getFollowingQueryIds = async () => ['test']; + + const result = await client.getFollowing('123', 2); + + expect(result.success).toBe(true); + expect(result.users?.length).toBe(1); + expect(result.users?.[0].username).toBe('alpha'); + const [url] = mockFetch.mock.calls[0]; + expect(String(url)).toContain('/Following?'); + }); + + it('fetches followers and unwraps visibility results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + user_results: { + result: { + __typename: 'UserWithVisibilityResults', + user: makeUserResult('9', 'vis', 'Visible'), + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise }; + clientPrivate.getFollowersQueryIds = async () => ['test']; + + const result = await client.getFollowers('123', 1); + + expect(result.success).toBe(true); + expect(result.users?.[0].username).toBe('vis'); + const [url] = mockFetch.mock.calls[0]; + expect(String(url)).toContain('/Followers?'); + }); + + it('refreshes query IDs after 404s', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + user_results: { + result: makeUserResult('1', 'alpha', 'Alpha'), + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClient & { + getFollowingQueryIds: () => Promise; + refreshQueryIds: () => Promise; + }; + clientPrivate.getFollowingQueryIds = async () => ['test']; + let refreshed = false; + clientPrivate.refreshQueryIds = async () => { + refreshed = true; + }; + + const result = await client.getFollowing('123', 1); + + expect(refreshed).toBe(true); + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + describe('conversation helpers', () => { let mockFetch: ReturnType; From 1235eda36853d098e91e2487bceb81ae6de7e096 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 11:25:38 +0100 Subject: [PATCH 4/6] docs: add following/followers changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0d3d0..ab0c307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 0.4.2 — Unreleased +### Added +- `following`/`followers` commands to list users (thanks @lockmeister). + +### Changed +- Query ID updater now tracks Following/Followers GraphQL operations. + ## 0.4.1 — 2025-12-31 ### Added From 578e38d427ed655ff14240eaaf438d485f7e6f8c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 11:31:55 +0100 Subject: [PATCH 5/6] build: refresh query ids --- src/lib/query-ids.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/query-ids.json b/src/lib/query-ids.json index bcac1e0..2b1c592 100644 --- a/src/lib/query-ids.json +++ b/src/lib/query-ids.json @@ -4,7 +4,7 @@ "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "TweetDetail": "97JF30KziU00483E_8elBA", "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ", - "Likes": "JR2gceKucIKcVNB_9JkhsA", "Following": "BEkNpEt5pNETESoqMsTEGA", - "Followers": "kuFUYP9eV1FPoEy4N-pi7w" + "Followers": "kuFUYP9eV1FPoEy4N-pi7w", + "Likes": "JR2gceKucIKcVNB_9JkhsA" } From f3977509dcbbf1ba63f1621046a3f4667b0da091 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 11:32:41 +0100 Subject: [PATCH 6/6] style: satisfy formatting in tests --- tests/twitter-client.test.ts | 48 +++++++++++++++++------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index 0456322..d1f2eab 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -1377,39 +1377,37 @@ describe('TwitterClient', () => { }); it('refreshes query IDs after 404s', async () => { - mockFetch - .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { + mockFetch.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' }).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { timeline: { - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - user_results: { - result: makeUserResult('1', 'alpha', 'Alpha'), - }, + instructions: [ + { + entries: [ + { + content: { + itemContent: { + user_results: { + result: makeUserResult('1', 'alpha', 'Alpha'), }, }, }, - ], - }, - ], - }, + }, + ], + }, + ], }, }, }, }, - }), - }); + }, + }), + }); const client = new TwitterClient({ cookies: validCookies }); const clientPrivate = client as unknown as TwitterClient & {