From 45657a171e7ddab9d74346abcd9a8ff931657eee Mon Sep 17 00:00:00 2001 From: yuta Date: Sun, 26 Jul 2026 12:34:53 +0900 Subject: [PATCH] fix lists queries and authenticated list reading --- CHANGELOG.md | 11 + README.md | 2 +- package.json | 2 +- scripts/update-query-ids.ts | 5 + src/commands/lists.ts | 4 +- src/commands/query-ids.ts | 16 +- src/lib/features.json | 14 +- src/lib/query-ids.json | 6 +- src/lib/runtime-query-ids.ts | 1 + src/lib/twitter-client-constants.ts | 8 +- src/lib/twitter-client-features.ts | 35 +- src/lib/twitter-client-lists.ts | 420 ++++++++---------- tests/commands.lists.test.ts | 35 ++ tests/runtime-features.test.ts | 35 +- tests/twitter-client-fixtures.ts | 1 + tests/twitter-client.lists.management.test.ts | 171 +++++++ .../twitter-client.lists.memberships.test.ts | 3 + tests/twitter-client.lists.owned.test.ts | 20 +- tests/twitter-client.lists.timeline.test.ts | 1 + z-ai/lessons.md | 3 + 20 files changed, 497 insertions(+), 296 deletions(-) create mode 100644 tests/commands.lists.test.ts create mode 100644 tests/twitter-client.lists.management.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fad203e..c887d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.10.1 — 2026-07-26 + +### Added + +- Add `getLists()` for the authenticated account's “Your Lists” timeline. + +### Fixed + +- Refresh Lists query IDs, variables, and feature flags. +- Keep usable list and tweet data when optional GraphQL fields return partial errors. + ## 0.10.0 — 2026-07-14 ### Added diff --git a/README.md b/README.md index d2da8ab..43f7a61 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Fields: - `bird likes [-n count] [--all] [--max-pages n] [--cursor string] [--json] [--json-full]` — list your liked tweets; `--max-pages` requires `--all` or `--cursor`. - `bird news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json]` — fetch news and trending topics from X's Explore tabs. - `bird trending` — alias for `news` command. -- `bird lists [--member-of] [-n count] [--json]` — list your lists (owned or memberships). +- `bird lists [--member-of] [-n count] [--json]` — list the authenticated account's “Your Lists”, or memberships with `--member-of`. - `bird list-timeline [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — get tweets from a list timeline; `--max-pages` implies `--all`. - `bird following [--user ] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that you (or another user) follow; `--max-pages` requires `--all`. - `bird followers [--user ] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that follow you (or another user); `--max-pages` requires `--all`. diff --git a/package.json b/package.json index 486f272..a75b48e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yuta/bird", - "version": "0.10.0", + "version": "0.10.1", "description": "CLI tool for tweeting and replying via Twitter/X GraphQL API", "type": "module", "main": "dist/index.js", diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts index 19213ea..7910321 100644 --- a/scripts/update-query-ids.ts +++ b/scripts/update-query-ids.ts @@ -29,6 +29,10 @@ const TARGET_OPERATIONS = [ 'GenericTimelineById', 'TrendHistory', 'AboutAccountQuery', + 'ListsManagementPageTimeline', + 'ListOwnerships', + 'ListMemberships', + 'ListLatestTweetsTimeline', ] as const; type OperationName = (typeof TARGET_OPERATIONS)[number]; @@ -36,6 +40,7 @@ type OperationName = (typeof TARGET_OPERATIONS)[number]; const DISCOVERY_PAGES = [ 'https://x.com/?lang=en', 'https://x.com/explore', + 'https://x.com/i/lists', 'https://x.com/notifications', 'https://x.com/settings/profile', ]; diff --git a/src/commands/lists.ts b/src/commands/lists.ts index 73215a6..a523266 100644 --- a/src/commands/lists.ts +++ b/src/commands/lists.ts @@ -47,13 +47,13 @@ export function registerListsCommand(program: Command, ctx: CliContext): void { const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs }); - const result = cmdOpts.memberOf ? await client.getListMemberships(count) : await client.getOwnedLists(count); + const result = cmdOpts.memberOf ? await client.getListMemberships(count) : await client.getLists(count); if (result.success && result.lists) { if (cmdOpts.json) { console.log(JSON.stringify(result.lists, null, 2)); } else { - const emptyMessage = cmdOpts.memberOf ? 'You are not a member of any lists.' : 'You do not own any lists.'; + const emptyMessage = cmdOpts.memberOf ? 'You are not a member of any lists.' : 'No lists found.'; if (result.lists.length === 0) { console.log(emptyMessage); } else { diff --git a/src/commands/query-ids.ts b/src/commands/query-ids.ts index 6181bf1..33be327 100644 --- a/src/commands/query-ids.ts +++ b/src/commands/query-ids.ts @@ -6,6 +6,7 @@ import { refreshFeatureOverridesCache, } from '../lib/runtime-features.js'; import { runtimeQueryIds } from '../lib/runtime-query-ids.js'; +import { TARGET_QUERY_ID_OPERATIONS } from '../lib/twitter-client-constants.js'; function countFeatureOverrides(overrides: FeatureOverrides): number { let count = 0; @@ -27,22 +28,9 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void .option('--json', 'Output as JSON') .option('--fresh', 'Force refresh (downloads X client bundles)', false) .action(async (cmdOpts: { json?: boolean; fresh?: boolean }) => { - const operations = [ - 'CreateTweet', - 'CreateRetweet', - 'FavoriteTweet', - 'TweetDetail', - 'SearchTimeline', - 'UserArticlesTweets', - 'Bookmarks', - 'Following', - 'Followers', - 'Likes', - ]; - if (cmdOpts.fresh) { console.error(`${ctx.p('info')}Refreshing GraphQL query IDs…`); - await runtimeQueryIds.refresh(operations, { force: true }); + await runtimeQueryIds.refresh(TARGET_QUERY_ID_OPERATIONS, { force: true }); console.error(`${ctx.p('info')}Refreshing feature overrides…`); await refreshFeatureOverridesCache(); } diff --git a/src/lib/features.json b/src/lib/features.json index c43e03c..fc4a1fa 100644 --- a/src/lib/features.json +++ b/src/lib/features.json @@ -1,17 +1,7 @@ { "global": { "responsive_web_grok_annotations_enabled": false, - "post_ctas_fetch_enabled": true, - "responsive_web_graphql_exclude_directive_enabled": true + "post_ctas_fetch_enabled": true }, - "sets": { - "lists": { - "blue_business_profile_image_shape_enabled": true, - "tweetypie_unmention_optimization_enabled": true, - "responsive_web_text_conversations_enabled": false, - "interactive_text_enabled": true, - "vibe_api_enabled": true, - "responsive_web_twitter_blue_verified_badge_is_enabled": true - } - } + "sets": {} } diff --git a/src/lib/query-ids.json b/src/lib/query-ids.json index 6033f31..eb7dcfc 100644 --- a/src/lib/query-ids.json +++ b/src/lib/query-ids.json @@ -16,5 +16,9 @@ "ExplorePage": "kheAINB_4pzRDqkzG3K-ng", "GenericTimelineById": "uGSr7alSjR9v6QJAIaqSKQ", "TrendHistory": "Sj4T-jSB9pr0Mxtsc1UKZQ", - "AboutAccountQuery": "zs_jFPFT78rBpXv9Z3U2YQ" + "AboutAccountQuery": "zs_jFPFT78rBpXv9Z3U2YQ", + "ListsManagementPageTimeline": "I96vj5eyw1D9g_NA0wWj3Q", + "ListOwnerships": "N_9HejNT9Amo1tDF7Gb_TQ", + "ListMemberships": "EPnwxyczkTMNNqPjlCBP9Q", + "ListLatestTweetsTimeline": "K77PSxWq_St4HLusAV9nVg" } diff --git a/src/lib/runtime-query-ids.ts b/src/lib/runtime-query-ids.ts index 4016d2b..a81f74d 100644 --- a/src/lib/runtime-query-ids.ts +++ b/src/lib/runtime-query-ids.ts @@ -8,6 +8,7 @@ const DEFAULT_TTL_MS: number = 24 * 60 * 60 * 1000; const DISCOVERY_PAGES: string[] = [ 'https://x.com/?lang=en', 'https://x.com/explore', + 'https://x.com/i/lists', 'https://x.com/notifications', 'https://x.com/settings/profile', ]; diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts index 0a5516c..7331166 100644 --- a/src/lib/twitter-client-constants.ts +++ b/src/lib/twitter-client-constants.ts @@ -28,10 +28,10 @@ export const FALLBACK_QUERY_IDS = { Followers: 'kuFUYP9eV1FPoEy4N-pi7w', Likes: 'JR2gceKucIKcVNB_9JkhsA', BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ', - ListOwnerships: 'wQcOSjSQ8NtgxIwvYl1lMg', - ListMemberships: 'BlEXXdARdSeL_0KyKHHvvg', - ListLatestTweetsTimeline: '2TemLyqrMpTeAmysdbnVqw', - ListByRestId: 'wXzyA5vM_aVkBL9G8Vp3kw', + ListsManagementPageTimeline: 'I96vj5eyw1D9g_NA0wWj3Q', + ListOwnerships: 'N_9HejNT9Amo1tDF7Gb_TQ', + ListMemberships: 'EPnwxyczkTMNNqPjlCBP9Q', + ListLatestTweetsTimeline: 'K77PSxWq_St4HLusAV9nVg', HomeTimeline: 'edseUwk9sP5Phz__9TIRnA', HomeLatestTimeline: 'iOEZpOdfekFsxSlPQCQtPg', ExploreSidebar: 'lpSN4M6qpimkF4nRFPE3nQ', diff --git a/src/lib/twitter-client-features.ts b/src/lib/twitter-client-features.ts index c8525c8..3b1cf4e 100644 --- a/src/lib/twitter-client-features.ts +++ b/src/lib/twitter-client-features.ts @@ -180,50 +180,47 @@ export function buildLikesFeatures(): Record { } export function buildListsFeatures(): Record { - return applyFeatureOverrides('lists', { - rweb_video_screen_enabled: true, + return { + rweb_video_screen_enabled: false, + rweb_cashtags_enabled: true, profile_label_improvements_pcf_label_in_post_enabled: true, - responsive_web_profile_redirect_enabled: true, - rweb_tipjar_consumption_enabled: true, + responsive_web_profile_redirect_enabled: false, + rweb_tipjar_consumption_enabled: false, verified_phone_label_enabled: false, creator_subscriptions_tweet_preview_api_enabled: true, responsive_web_graphql_timeline_navigation_enabled: true, - responsive_web_graphql_exclude_directive_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: false, - responsive_web_grok_annotations_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: true, + rweb_cashtags_composer_attachment_enabled: true, responsive_web_jetfuel_frame: true, - post_ctas_fetch_enabled: true, responsive_web_grok_share_attachment_enabled: true, + responsive_web_grok_annotations_enabled: true, articles_preview_enabled: true, responsive_web_edit_tweet_api_enabled: true, + rweb_conversational_replies_downvote_enabled: false, 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, + content_disclosure_indicator_enabled: true, + content_disclosure_ai_generated_indicator_enabled: true, + responsive_web_grok_show_grok_translated_post: true, responsive_web_grok_analysis_button_from_backend: true, - creator_subscriptions_quote_tweet_preview_enabled: false, + post_ctas_fetch_enabled: true, 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, + longform_notetweets_inline_media_enabled: false, responsive_web_grok_image_annotation_enabled: true, responsive_web_grok_imagine_annotation_enabled: true, - responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_grok_community_note_auto_translation_is_enabled: true, responsive_web_enhance_cards_enabled: false, - blue_business_profile_image_shape_enabled: false, - responsive_web_text_conversations_enabled: false, - tweetypie_unmention_optimization_enabled: true, - vibe_api_enabled: false, - interactive_text_enabled: false, - }); + }; } export function buildHomeTimelineFeatures(): Record { diff --git a/src/lib/twitter-client-lists.ts b/src/lib/twitter-client-lists.ts index 877d5a3..092fbc9 100644 --- a/src/lib/twitter-client-lists.ts +++ b/src/lib/twitter-client-lists.ts @@ -9,6 +9,7 @@ import type { GraphqlTweetResult, ListsResult, SearchResult, TweetData, TwitterL import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js'; export interface TwitterClientListMethods { + getLists(count?: number): Promise; getOwnedLists(count?: number): Promise; getListMemberships(count?: number): Promise; getListTimeline(listId: string, count?: number, options?: TimelineFetchOptions): Promise; @@ -22,7 +23,7 @@ interface GraphqlListResult { member_count?: number; subscriber_count?: number; mode?: string; - created_at?: string; + created_at?: string | number; user_results?: { result?: { rest_id?: string; @@ -30,16 +31,73 @@ interface GraphqlListResult { screen_name?: string; name?: string; }; + core?: { + screen_name?: string; + name?: string; + }; }; }; } +interface GraphqlListItem { + itemContent?: { + list?: GraphqlListResult; + }; +} + +interface GraphqlListInstruction { + entries?: Array<{ + content?: GraphqlListItem & { + clientEventInfo?: { + component?: string; + }; + items?: Array<{ + item?: GraphqlListItem; + }>; + }; + }>; +} + +interface GraphqlListsResponse { + data?: { + user?: { + result?: { + __typename?: string; + timeline?: { + timeline?: { + instructions?: GraphqlListInstruction[]; + }; + }; + }; + }; + viewer?: { + list_management_timeline?: { + timeline?: { + instructions?: GraphqlListInstruction[]; + }; + }; + }; + }; + errors?: Array<{ message: string }>; +} + function parseList(listResult: GraphqlListResult): TwitterList | null { if (!listResult.id_str || !listResult.name) { return null; } const owner = listResult.user_results?.result; + const ownerProfile = owner?.core ?? owner?.legacy; + const createdAtDate = + typeof listResult.created_at === 'number' && Number.isFinite(listResult.created_at) + ? new Date(listResult.created_at) + : null; + const createdAt = + createdAtDate && !Number.isNaN(createdAtDate.getTime()) + ? createdAtDate.toISOString() + : typeof listResult.created_at === 'string' + ? listResult.created_at + : undefined; return { id: listResult.id_str, name: listResult.name, @@ -47,51 +105,49 @@ function parseList(listResult: GraphqlListResult): TwitterList | null { memberCount: listResult.member_count, subscriberCount: listResult.subscriber_count, isPrivate: listResult.mode?.toLowerCase() === 'private', - createdAt: listResult.created_at, + createdAt, owner: owner ? { id: owner.rest_id ?? '', - username: owner.legacy?.screen_name ?? '', - name: owner.legacy?.name ?? '', + username: ownerProfile?.screen_name ?? '', + name: ownerProfile?.name ?? '', } : undefined, }; } function parseListsFromInstructions( - instructions: - | Array<{ - entries?: Array<{ - content?: { - itemContent?: { - list?: GraphqlListResult; - }; - }; - }>; - }> - | undefined, + instructions: GraphqlListInstruction[] | undefined, + component?: string, ): TwitterList[] { - const lists: TwitterList[] = []; + const lists = new Map(); if (!instructions) { - return lists; + return []; } - for (const instruction of instructions) { - if (!instruction.entries) { - continue; + const addList = (listResult: GraphqlListResult | undefined) => { + if (!listResult) { + return; } - for (const entry of instruction.entries) { - const listResult = entry.content?.itemContent?.list; - if (listResult) { - const parsed = parseList(listResult); - if (parsed) { - lists.push(parsed); - } + const parsed = parseList(listResult); + if (parsed) { + lists.set(parsed.id, parsed); + } + }; + + for (const instruction of instructions) { + for (const entry of instruction.entries ?? []) { + if (component && entry.content?.clientEventInfo?.component !== component) { + continue; + } + addList(entry.content?.itemContent?.list); + for (const item of entry.content?.items ?? []) { + addList(item.item?.itemContent?.list); } } } - return lists; + return Array.from(lists.values()); } export function withLists>( @@ -103,19 +159,108 @@ export function withLists>( super(...args); } + private async getListsManagementQueryIds(): Promise { + return [await this.getQueryId('ListsManagementPageTimeline')]; + } + private async getListOwnershipsQueryIds(): Promise { - const primary = await this.getQueryId('ListOwnerships'); - return Array.from(new Set([primary, 'wQcOSjSQ8NtgxIwvYl1lMg'])); + return [await this.getQueryId('ListOwnerships')]; } private async getListMembershipsQueryIds(): Promise { - const primary = await this.getQueryId('ListMemberships'); - return Array.from(new Set([primary, 'BlEXXdARdSeL_0KyKHHvvg'])); + return [await this.getQueryId('ListMemberships')]; } private async getListTimelineQueryIds(): Promise { - const primary = await this.getQueryId('ListLatestTweetsTimeline'); - return Array.from(new Set([primary, '2TemLyqrMpTeAmysdbnVqw'])); + return [await this.getQueryId('ListLatestTweetsTimeline')]; + } + + private async fetchListsOperation( + operationName: 'ListsManagementPageTimeline' | 'ListOwnerships' | 'ListMemberships', + variables: Record, + getQueryIds: () => Promise, + selectInstructions: (data: GraphqlListsResponse) => GraphqlListInstruction[] | undefined, + component?: string, + ): Promise { + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(buildListsFeatures()), + }); + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + + for (const queryId of await getQueryIds()) { + const url = `${TWITTER_API_BASE}/${queryId}/${operationName}?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + 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 GraphqlListsResponse; + const instructions = selectInstructions(data); + if (instructions) { + return { success: true as const, lists: parseListsFromInstructions(instructions, component), had404 }; + } + + if (data.data?.user?.result?.__typename === 'UserUnavailable') { + return { success: false as const, error: 'User unavailable', had404 }; + } + + if (data.errors?.length) { + return { success: false as const, error: data.errors.map((error) => error.message).join(', '), had404 }; + } + + return { success: false as const, error: `Missing ${operationName} timeline`, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? `Unknown error fetching ${operationName}`, had404 }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, lists: firstAttempt.lists }; + } + + if (!firstAttempt.had404) { + return { success: false, error: firstAttempt.error }; + } + + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + return secondAttempt.success + ? { success: true, lists: secondAttempt.lists } + : { success: false, error: secondAttempt.error }; + } + + /** + * Get the lists shown on the authenticated user's Lists page. + */ + async getLists(count = 100): Promise { + return this.fetchListsOperation( + 'ListsManagementPageTimeline', + { count }, + () => this.getListsManagementQueryIds(), + (data) => data.data?.viewer?.list_management_timeline?.timeline?.instructions, + 'suggest_owned_subscribed_list', + ); } /** @@ -127,99 +272,16 @@ export function withLists>( return { success: false, error: userResult.error ?? 'Could not determine current user' }; } - const variables = { - userId: userResult.user.id, - count, - isListMembershipShown: true, - isListMemberTargetUserId: userResult.user.id, - }; - - const features = buildListsFeatures(); - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - }); - - const tryOnce = async () => { - let lastError: string | undefined; - let had404 = false; - const queryIds = await this.getListOwnershipsQueryIds(); - - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/ListOwnerships?${params.toString()}`; - - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - 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<{ - entries?: Array<{ - content?: { - itemContent?: { - list?: GraphqlListResult; - }; - }; - }>; - }>; - }; - }; - }; - }; - }; - errors?: Array<{ message: string }>; - }; - - 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 lists = parseListsFromInstructions(instructions); - - return { success: true as const, lists, had404 }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false as const, error: lastError ?? 'Unknown error fetching owned lists', had404 }; - }; - - const firstAttempt = await tryOnce(); - if (firstAttempt.success) { - return { success: true, lists: firstAttempt.lists }; - } - - if (firstAttempt.had404) { - await this.refreshQueryIds(); - const secondAttempt = await tryOnce(); - if (secondAttempt.success) { - return { success: true, lists: secondAttempt.lists }; - } - return { success: false, error: secondAttempt.error }; - } - - return { success: false, error: firstAttempt.error }; + return this.fetchListsOperation( + 'ListOwnerships', + { + userId: userResult.user.id, + isListMemberTargetUserId: userResult.user.id, + count, + }, + () => this.getListOwnershipsQueryIds(), + (data) => data.data?.user?.result?.timeline?.timeline?.instructions, + ); } /** @@ -231,99 +293,12 @@ export function withLists>( return { success: false, error: userResult.error ?? 'Could not determine current user' }; } - const variables = { - userId: userResult.user.id, - count, - isListMembershipShown: true, - isListMemberTargetUserId: userResult.user.id, - }; - - const features = buildListsFeatures(); - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - }); - - const tryOnce = async () => { - let lastError: string | undefined; - let had404 = false; - const queryIds = await this.getListMembershipsQueryIds(); - - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/ListMemberships?${params.toString()}`; - - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - 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<{ - entries?: Array<{ - content?: { - itemContent?: { - list?: GraphqlListResult; - }; - }; - }>; - }>; - }; - }; - }; - }; - }; - errors?: Array<{ message: string }>; - }; - - 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 lists = parseListsFromInstructions(instructions); - - return { success: true as const, lists, had404 }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false as const, error: lastError ?? 'Unknown error fetching list memberships', had404 }; - }; - - const firstAttempt = await tryOnce(); - if (firstAttempt.success) { - return { success: true, lists: firstAttempt.lists }; - } - - if (firstAttempt.had404) { - await this.refreshQueryIds(); - const secondAttempt = await tryOnce(); - if (secondAttempt.success) { - return { success: true, lists: secondAttempt.lists }; - } - return { success: false, error: secondAttempt.error }; - } - - return { success: false, error: firstAttempt.error }; + return this.fetchListsOperation( + 'ListMemberships', + { userId: userResult.user.id, count }, + () => this.getListMembershipsQueryIds(), + (data) => data.data?.user?.result?.timeline?.timeline?.instructions, + ); } /** @@ -416,11 +391,10 @@ export function withLists>( errors?: Array<{ message: string }>; }; - if (data.errors && data.errors.length > 0) { + const instructions = data.data?.list?.tweets_timeline?.timeline?.instructions; + if (!instructions && data.errors?.length) { return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; } - - const instructions = data.data?.list?.tweets_timeline?.timeline?.instructions; const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw }); const nextCursor = extractCursorFromInstructions(instructions); diff --git a/tests/commands.lists.test.ts b/tests/commands.lists.test.ts new file mode 100644 index 0000000..df01dd7 --- /dev/null +++ b/tests/commands.lists.test.ts @@ -0,0 +1,35 @@ +import { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; +import type { CliContext } from '../src/cli/shared.js'; +import { registerListsCommand } from '../src/commands/lists.js'; +import { TwitterClient } from '../src/lib/twitter-client.js'; + +describe('lists command', () => { + it('uses the authenticated Lists page by default', async () => { + const program = new Command(); + const ctx = { + resolveTimeoutFromOptions: () => undefined, + resolveRelayBaseUrlFromOptions: () => 'https://relay.example', + resolveProfileNameFromOptions: () => undefined, + p: () => '', + colors: { muted: (value: string) => value, accent: (value: string) => value }, + getOutput: () => 'plain', + } as unknown as CliContext; + const listsSpy = vi.spyOn(TwitterClient.prototype, 'getLists').mockResolvedValue({ + success: true, + lists: [], + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + registerListsCommand(program, ctx); + await program.parseAsync(['node', 'bird', 'lists', '--count', '25']); + + expect(listsSpy).toHaveBeenCalledWith(25); + expect(logSpy).toHaveBeenCalledWith('No lists found.'); + } finally { + listsSpy.mockRestore(); + logSpy.mockRestore(); + } + }); +}); diff --git a/tests/runtime-features.test.ts b/tests/runtime-features.test.ts index 4237479..8add1b9 100644 --- a/tests/runtime-features.test.ts +++ b/tests/runtime-features.test.ts @@ -29,9 +29,6 @@ describe('runtime-features', () => { likes: { likes_only: true, }, - lists: { - responsive_web_text_conversations_enabled: true, - }, }, }); @@ -44,9 +41,6 @@ describe('runtime-features', () => { const likes = buildLikesFeatures(); expect(likes.global_flag).toBe(true); expect(likes.likes_only).toBe(true); - - const lists = buildListsFeatures(); - expect(lists.responsive_web_text_conversations_enabled).toBe(true); }); it('refresh merges defaults with existing cache', async () => { @@ -119,11 +113,28 @@ describe('runtime-features', () => { it('includes required lists API feature flags', () => { const features = buildListsFeatures(); - expect(features.responsive_web_graphql_exclude_directive_enabled).toBe(true); - expect(features.blue_business_profile_image_shape_enabled).toBe(true); - expect(features.responsive_web_text_conversations_enabled).toBe(false); - expect(features.tweetypie_unmention_optimization_enabled).toBe(true); - expect(features.vibe_api_enabled).toBe(true); - expect(features.interactive_text_enabled).toBe(true); + expect(features.responsive_web_graphql_exclude_directive_enabled).toBeUndefined(); + expect(features.responsive_web_grok_annotations_enabled).toBe(true); + expect(features.rweb_cashtags_enabled).toBe(true); + expect(features.responsive_web_grok_show_grok_translated_post).toBe(true); + expect(features.longform_notetweets_inline_media_enabled).toBe(false); + }); + + it('keeps catalog-locked list features independent from runtime overrides', () => { + process.env.BIRD_FEATURES_JSON = JSON.stringify({ + global: { + responsive_web_grok_annotations_enabled: false, + }, + sets: { + lists: { + rweb_cashtags_enabled: false, + }, + }, + }); + clearFeatureOverridesCache(); + + const features = buildListsFeatures(); + expect(features.responsive_web_grok_annotations_enabled).toBe(true); + expect(features.rweb_cashtags_enabled).toBe(true); }); }); diff --git a/tests/twitter-client-fixtures.ts b/tests/twitter-client-fixtures.ts index 736f4e6..1aa459a 100644 --- a/tests/twitter-client-fixtures.ts +++ b/tests/twitter-client-fixtures.ts @@ -9,6 +9,7 @@ export type TwitterClientPrivate = TwitterClient & { error?: string; }>; getLikesQueryIds: () => Promise; + getListsManagementQueryIds: () => Promise; getListTimelineQueryIds: () => Promise; getListOwnershipsQueryIds: () => Promise; getListMembershipsQueryIds: () => Promise; diff --git a/tests/twitter-client.lists.management.test.ts b/tests/twitter-client.lists.management.test.ts new file mode 100644 index 0000000..a9f165f --- /dev/null +++ b/tests/twitter-client.lists.management.test.ts @@ -0,0 +1,171 @@ +// ABOUTME: Tests for the authenticated user's Lists management timeline. +// ABOUTME: Covers nested list items, deduplication, and partial GraphQL errors. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TwitterClient } from '../src/lib/twitter-client.js'; +import { type TwitterClientPrivate, testRelayBaseUrl } from './twitter-client-fixtures.js'; + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe('TwitterClient lists management', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + it('gets and deduplicates the lists shown on the authenticated Lists page', async () => { + const list = { + id_str: '1234567890', + name: 'My List', + description: 'A useful list', + member_count: 10, + mode: 'Private', + created_at: 1_704_067_200_000, + user_results: { + result: { + rest_id: '12345', + core: { screen_name: 'testuser', name: 'Test User' }, + }, + }, + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + viewer: { + list_management_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + clientEventInfo: { component: 'suggest_list_to_follow' }, + items: [ + { + item: { + itemContent: { + list: { id_str: '999', name: 'Suggested List' }, + }, + }, + }, + ], + }, + }, + { + content: { + clientEventInfo: { component: 'suggest_owned_subscribed_list' }, + items: [{ item: { itemContent: { list } } }, { item: { itemContent: { list } } }], + }, + }, + ], + }, + ], + }, + }, + }, + }, + errors: [{ message: 'optional banner field failed' }], + }), + }); + + const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListsManagementQueryIds = async () => ['test']; + + const result = await client.getLists(100); + + expect(result).toEqual({ + success: true, + lists: [ + { + id: '1234567890', + name: 'My List', + description: 'A useful list', + memberCount: 10, + subscriberCount: undefined, + isPrivate: true, + createdAt: '2024-01-01T00:00:00.000Z', + owner: { + id: '12345', + username: 'testuser', + name: 'Test User', + }, + }, + ], + }); + const [url] = mockFetch.mock.calls[0]; + const requestUrl = new URL(url as string); + expect(requestUrl.pathname).toContain('/test/ListsManagementPageTimeline'); + expect(JSON.parse(requestUrl.searchParams.get('variables') as string)).toEqual({ count: 100 }); + }); + + it('returns an error when the management timeline is missing', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ errors: [{ message: 'Not authorized' }] }), + }); + + const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListsManagementQueryIds = async () => ['test']; + + await expect(client.getLists()).resolves.toEqual({ + success: false, + error: 'Not authorized', + }); + }); + + it('ignores an invalid numeric creation date', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + viewer: { + list_management_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + clientEventInfo: { component: 'suggest_owned_subscribed_list' }, + items: [ + { + item: { + itemContent: { + list: { id_str: '123', name: 'List', created_at: Number.NaN }, + }, + }, + }, + ], + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListsManagementQueryIds = async () => ['test']; + + const result = await client.getLists(); + expect(result.success && result.lists?.[0].createdAt).toBeUndefined(); + }); +}); diff --git a/tests/twitter-client.lists.memberships.test.ts b/tests/twitter-client.lists.memberships.test.ts index b302f6d..3bb3eb8 100644 --- a/tests/twitter-client.lists.memberships.test.ts +++ b/tests/twitter-client.lists.memberships.test.ts @@ -75,6 +75,9 @@ describe('TwitterClient lists memberships', () => { expect(result.lists?.[0].id).toBe('5555'); expect(result.lists?.[0].name).toBe('Member List'); expect(result.lists?.[0].owner?.username).toBe('otheruser'); + const [url] = mockFetch.mock.calls[0]; + const variables = JSON.parse(new URL(url as string).searchParams.get('variables') as string); + expect(variables).toEqual({ userId: '12345', count: 100 }); }); it('retries on 404 error after refreshing query IDs', async () => { diff --git a/tests/twitter-client.lists.owned.test.ts b/tests/twitter-client.lists.owned.test.ts index a255f64..6201a4c 100644 --- a/tests/twitter-client.lists.owned.test.ts +++ b/tests/twitter-client.lists.owned.test.ts @@ -116,13 +116,19 @@ describe('TwitterClient lists owned', () => { await client.getOwnedLists(1); const [url] = mockFetch.mock.calls[0]; - const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string); - expect(parsedFeatures.responsive_web_graphql_exclude_directive_enabled).toBe(true); - expect(parsedFeatures.blue_business_profile_image_shape_enabled).toBe(true); - expect(parsedFeatures.responsive_web_text_conversations_enabled).toBe(false); - expect(parsedFeatures.tweetypie_unmention_optimization_enabled).toBe(true); - expect(parsedFeatures.vibe_api_enabled).toBe(true); - expect(parsedFeatures.interactive_text_enabled).toBe(true); + const requestUrl = new URL(url as string); + const parsedVariables = JSON.parse(requestUrl.searchParams.get('variables') as string); + const parsedFeatures = JSON.parse(requestUrl.searchParams.get('features') as string); + expect(parsedVariables).toEqual({ + userId: '12345', + isListMemberTargetUserId: '12345', + count: 1, + }); + expect(parsedFeatures.responsive_web_graphql_exclude_directive_enabled).toBeUndefined(); + expect(parsedFeatures.responsive_web_grok_annotations_enabled).toBe(true); + expect(parsedFeatures.rweb_cashtags_enabled).toBe(true); + expect(parsedFeatures.responsive_web_grok_show_grok_translated_post).toBe(true); + expect(parsedFeatures.longform_notetweets_inline_media_enabled).toBe(false); }); it('handles private lists correctly', async () => { diff --git a/tests/twitter-client.lists.timeline.test.ts b/tests/twitter-client.lists.timeline.test.ts index f393893..03ad6a4 100644 --- a/tests/twitter-client.lists.timeline.test.ts +++ b/tests/twitter-client.lists.timeline.test.ts @@ -66,6 +66,7 @@ describe('TwitterClient lists timeline', () => { }, }, }, + errors: [{ message: 'optional media field failed' }], }), }); diff --git a/z-ai/lessons.md b/z-ai/lessons.md index af9c08c..bc16ca8 100644 --- a/z-ai/lessons.md +++ b/z-ai/lessons.md @@ -6,3 +6,6 @@ - When adding package registry publishing for this repository, use the `@yuta` package scope unless the user explicitly asks for another namespace. - Gitea Actions secret and variable names are case-insensitive, alphanumeric or underscore only, and cannot start with `GITEA_` or `GITHUB_`; use names like `PACKAGE_TOKEN`. - When adapting upstream tests to this fork, run tests with `TWITTER_RELAY_BASE_URL` unset; local relay environment variables can hide missing explicit `relayBaseUrl` fixtures. +- For authenticated account pages, use the viewer-scoped catalog operation that + matches the UI. Do not substitute a similarly named user-scoped operation; + verify the exact request against the relay before changing Bird.