diff --git a/src/cli/program.ts b/src/cli/program.ts index 0ca048c..46ba7b3 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { registerBookmarksCommand } from '../commands/bookmarks.js'; import { registerCheckCommand } from '../commands/check.js'; import { registerHelpCommand } from '../commands/help.js'; +import { registerListsCommand } from '../commands/lists.js'; import { registerPostCommands } from '../commands/post.js'; import { registerQueryIdsCommand } from '../commands/query-ids.js'; import { registerReadCommands } from '../commands/read.js'; @@ -23,6 +24,8 @@ export const KNOWN_COMMANDS = new Set([ 'following', 'followers', 'likes', + 'lists', + 'list-timeline', 'help', 'whoami', 'check', @@ -124,6 +127,7 @@ export function createProgram(ctx: CliContext): Command { registerReadCommands(program, ctx); registerSearchCommands(program, ctx); registerBookmarksCommand(program, ctx); + registerListsCommand(program, ctx); registerUserCommands(program, ctx); registerCheckCommand(program, ctx); diff --git a/src/commands/lists.ts b/src/commands/lists.ts new file mode 100644 index 0000000..bd3ade0 --- /dev/null +++ b/src/commands/lists.ts @@ -0,0 +1,138 @@ +// ABOUTME: CLI command for fetching Twitter Lists. +// ABOUTME: Supports listing owned lists, memberships, and list timelines. + +import type { Command } from 'commander'; +import type { CliContext } from '../cli/shared.js'; +import type { TwitterList } from '../lib/twitter-client.js'; +import { TwitterClient } from '../lib/twitter-client.js'; + +const NUMERIC_ID_REGEX = /^\d+$/; +const LIST_URL_REGEX = /(?:x\.com|twitter\.com)\/i\/lists\/(\d+)/i; + +function extractListId(listIdOrUrl: string): string | null { + const trimmed = listIdOrUrl.trim(); + + // Numeric ID + if (NUMERIC_ID_REGEX.test(trimmed)) { + return trimmed; + } + + // URL formats: + // https://x.com/i/lists/1234567890123456789 + // https://twitter.com/i/lists/1234567890123456789 + const urlMatch = trimmed.match(LIST_URL_REGEX); + if (urlMatch) { + return urlMatch[1]; + } + + return null; +} + +function printLists(lists: TwitterList[], ctx: CliContext): void { + if (lists.length === 0) { + console.log('No lists found.'); + return; + } + + for (const list of lists) { + const visibility = list.isPrivate ? '[private]' : '[public]'; + console.log(`${list.name} ${ctx.colors.muted(visibility)}`); + if (list.description) { + console.log(` ${list.description.slice(0, 100)}${list.description.length > 100 ? '...' : ''}`); + } + console.log(` ${ctx.p('info')}${list.memberCount?.toLocaleString() ?? 0} members`); + if (list.owner) { + console.log(` ${ctx.colors.muted(`Owner: @${list.owner.username}`)}`); + } + console.log(` ${ctx.colors.accent(`https://x.com/i/lists/${list.id}`)}`); + console.log('──────────────────────────────────────────────────'); + } +} + +export function registerListsCommand(program: Command, ctx: CliContext): void { + program + .command('lists') + .description('Get your Twitter lists') + .option('--member-of', 'Show lists you are a member of (instead of owned lists)') + .option('-n, --count ', 'Number of lists to fetch', '100') + .option('--json', 'Output as JSON') + .action(async (cmdOpts: { memberOf?: boolean; count?: string; json?: boolean }) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '100', 10); + + const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); + + for (const warning of warnings) { + console.error(`${ctx.p('warn')}${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error(`${ctx.p('err')}Missing required credentials`); + process.exit(1); + } + + const client = new TwitterClient({ cookies, timeoutMs }); + + const result = cmdOpts.memberOf ? await client.getListMemberships(count) : await client.getOwnedLists(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.'; + if (result.lists.length === 0) { + console.log(emptyMessage); + } else { + printLists(result.lists, ctx); + } + } + } else { + console.error(`${ctx.p('err')}Failed to fetch lists: ${result.error}`); + process.exit(1); + } + }); + + program + .command('list-timeline ') + .description('Get tweets from a list timeline') + .option('-n, --count ', 'Number of tweets to fetch', '20') + .option('--json', 'Output as JSON') + .option('--json-full', 'Output as JSON with full raw API response in _raw field') + .action(async (listIdOrUrl: string, cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean }) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '20', 10); + + const listId = extractListId(listIdOrUrl); + if (!listId) { + console.error(`${ctx.p('err')}Invalid list ID or URL. Expected numeric ID or https://x.com/i/lists/.`); + process.exit(2); + } + + const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); + + for (const warning of warnings) { + console.error(`${ctx.p('warn')}${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error(`${ctx.p('err')}Missing required credentials`); + process.exit(1); + } + + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); + const result = await client.getListTimeline(listId, count, { includeRaw: cmdOpts.jsonFull }); + + if (result.success && result.tweets) { + ctx.printTweets(result.tweets, { + json: cmdOpts.json || cmdOpts.jsonFull, + emptyMessage: 'No tweets found in this list.', + }); + } else { + console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`); + process.exit(1); + } + }); +} diff --git a/src/lib/query-ids.json b/src/lib/query-ids.json index a7645a4..0cf863e 100644 --- a/src/lib/query-ids.json +++ b/src/lib/query-ids.json @@ -8,5 +8,9 @@ "BookmarkFolderTimeline": "KJIQpsvxrTfRIlbaRIySHQ", "Following": "BEkNpEt5pNETESoqMsTEGA", "Followers": "kuFUYP9eV1FPoEy4N-pi7w", - "Likes": "JR2gceKucIKcVNB_9JkhsA" + "Likes": "JR2gceKucIKcVNB_9JkhsA", + "ListOwnerships": "wQcOSjSQ8NtgxIwvYl1lMg", + "ListMemberships": "BlEXXdARdSeL_0KyKHHvvg", + "ListLatestTweetsTimeline": "2TemLyqrMpTeAmysdbnVqw", + "ListByRestId": "wXzyA5vM_aVkBL9G8Vp3kw" } diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts index cb3598d..fdcdf3d 100644 --- a/src/lib/twitter-client-constants.ts +++ b/src/lib/twitter-client-constants.ts @@ -25,6 +25,10 @@ export const FALLBACK_QUERY_IDS = { Followers: 'kuFUYP9eV1FPoEy4N-pi7w', Likes: 'JR2gceKucIKcVNB_9JkhsA', BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ', + ListOwnerships: 'wQcOSjSQ8NtgxIwvYl1lMg', + ListMemberships: 'BlEXXdARdSeL_0KyKHHvvg', + ListLatestTweetsTimeline: '2TemLyqrMpTeAmysdbnVqw', + ListByRestId: 'wXzyA5vM_aVkBL9G8Vp3kw', } as const; export type OperationName = keyof typeof FALLBACK_QUERY_IDS; diff --git a/src/lib/twitter-client-features.ts b/src/lib/twitter-client-features.ts index a6608f5..24fed92 100644 --- a/src/lib/twitter-client-features.ts +++ b/src/lib/twitter-client-features.ts @@ -170,6 +170,46 @@ export function buildLikesFeatures(): Record { return buildTimelineFeatures(); } +export function buildListsFeatures(): Record { + return { + rweb_video_screen_enabled: true, + profile_label_improvements_pcf_label_in_post_enabled: true, + 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: 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_jetfuel_frame: true, + 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: true, + 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_grok_imagine_annotation_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_enhance_cards_enabled: false, + }; +} + export function buildFollowingFeatures(): Record { return { rweb_video_screen_enabled: true, diff --git a/src/lib/twitter-client-lists.ts b/src/lib/twitter-client-lists.ts new file mode 100644 index 0000000..9651201 --- /dev/null +++ b/src/lib/twitter-client-lists.ts @@ -0,0 +1,429 @@ +// ABOUTME: Mixin for Twitter Lists GraphQL operations. +// ABOUTME: Provides methods to fetch user's owned lists, memberships, and list timelines. + +import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; +import { TWITTER_API_BASE } from './twitter-client-constants.js'; +import { buildListsFeatures } from './twitter-client-features.js'; +import type { TimelineFetchOptions } from './twitter-client-timelines.js'; +import type { GraphqlTweetResult, ListsResult, SearchResult, TwitterList } from './twitter-client-types.js'; +import { parseTweetsFromInstructions } from './twitter-client-utils.js'; + +export interface TwitterClientListMethods { + getOwnedLists(count?: number): Promise; + getListMemberships(count?: number): Promise; + getListTimeline(listId: string, count?: number, options?: TimelineFetchOptions): Promise; +} + +interface GraphqlListResult { + id_str?: string; + name?: string; + description?: string; + member_count?: number; + subscriber_count?: number; + mode?: string; + created_at?: string; + user_results?: { + result?: { + rest_id?: string; + legacy?: { + screen_name?: string; + name?: string; + }; + }; + }; +} + +function parseList(listResult: GraphqlListResult): TwitterList | null { + if (!listResult.id_str || !listResult.name) { + return null; + } + + const owner = listResult.user_results?.result; + return { + id: listResult.id_str, + name: listResult.name, + description: listResult.description, + memberCount: listResult.member_count, + subscriberCount: listResult.subscriber_count, + isPrivate: listResult.mode === 'Private', + createdAt: listResult.created_at, + owner: owner + ? { + id: owner.rest_id ?? '', + username: owner.legacy?.screen_name ?? '', + name: owner.legacy?.name ?? '', + } + : undefined, + }; +} + +function parseListsFromInstructions( + instructions: + | Array<{ + entries?: Array<{ + content?: { + itemContent?: { + list?: GraphqlListResult; + }; + }; + }>; + }> + | undefined, +): TwitterList[] { + const lists: TwitterList[] = []; + if (!instructions) { + return lists; + } + + for (const instruction of instructions) { + if (!instruction.entries) { + continue; + } + for (const entry of instruction.entries) { + const listResult = entry.content?.itemContent?.list; + if (listResult) { + const parsed = parseList(listResult); + if (parsed) { + lists.push(parsed); + } + } + } + } + + return lists; +} + +export function withLists>( + Base: TBase, +): Mixin { + abstract class TwitterClientLists extends Base { + // biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement. + constructor(...args: any[]) { + super(...args); + } + + private async getListOwnershipsQueryIds(): Promise { + const primary = await this.getQueryId('ListOwnerships'); + return Array.from(new Set([primary, 'wQcOSjSQ8NtgxIwvYl1lMg'])); + } + + private async getListMembershipsQueryIds(): Promise { + const primary = await this.getQueryId('ListMemberships'); + return Array.from(new Set([primary, 'BlEXXdARdSeL_0KyKHHvvg'])); + } + + private async getListTimelineQueryIds(): Promise { + const primary = await this.getQueryId('ListLatestTweetsTimeline'); + return Array.from(new Set([primary, '2TemLyqrMpTeAmysdbnVqw'])); + } + + /** + * Get lists owned by the authenticated user + */ + async getOwnedLists(count = 100): Promise { + const userResult = await this.getCurrentUser(); + if (!userResult.success || !userResult.user) { + 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 }; + } + + /** + * Get lists the authenticated user is a member of + */ + async getListMemberships(count = 100): Promise { + const userResult = await this.getCurrentUser(); + if (!userResult.success || !userResult.user) { + 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 }; + } + + /** + * Get tweets from a list timeline + */ + async getListTimeline(listId: string, count = 20, options: TimelineFetchOptions = {}): Promise { + const { includeRaw = false } = options; + + const variables = { + listId, + count, + }; + + 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.getListTimelineQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/ListLatestTweetsTimeline?${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?: { + list?: { + tweets_timeline?: { + timeline?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }>; + }; + }; + }; + }; + 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?.list?.tweets_timeline?.timeline?.instructions; + const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw }); + + return { success: true as const, tweets, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching list timeline', had404 }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, tweets: firstAttempt.tweets }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, tweets: secondAttempt.tweets }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } + } + + return TwitterClientLists; +} diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index 8923475..630eb09 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -237,6 +237,27 @@ export interface TwitterClientOptions { quoteDepth?: number; } +export interface TwitterList { + id: string; + name: string; + description?: string; + memberCount?: number; + subscriberCount?: number; + isPrivate?: boolean; + createdAt?: string; + owner?: { + id: string; + username: string; + name: string; + }; +} + +export interface ListsResult { + success: boolean; + lists?: TwitterList[]; + error?: string; +} + export interface CreateTweetResponse { data?: { create_tweet?: { diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 70275fc..3178499 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -1,5 +1,6 @@ import type { AbstractConstructor } from './twitter-client-base.js'; import { TwitterClientBase } from './twitter-client-base.js'; +import { type TwitterClientListMethods, withLists } from './twitter-client-lists.js'; import { type TwitterClientMediaMethods, withMedia } from './twitter-client-media.js'; import { type TwitterClientPostingMethods, withPosting } from './twitter-client-posting.js'; import { type TwitterClientSearchMethods, withSearch } from './twitter-client-search.js'; @@ -8,6 +9,7 @@ import { type TwitterClientTweetDetailMethods, withTweetDetails } from './twitte import { type TwitterClientUserMethods, withUsers } from './twitter-client-users.js'; type TwitterClientInstance = TwitterClientBase & + TwitterClientListMethods & TwitterClientMediaMethods & TwitterClientPostingMethods & TwitterClientSearchMethods & @@ -16,7 +18,7 @@ type TwitterClientInstance = TwitterClientBase & TwitterClientUserMethods; const MixedTwitterClient = withUsers( - withTimelines(withSearch(withTweetDetails(withPosting(withMedia(TwitterClientBase))))), + withLists(withTimelines(withSearch(withTweetDetails(withPosting(withMedia(TwitterClientBase)))))), ) as AbstractConstructor; export class TwitterClient extends MixedTwitterClient {} @@ -25,10 +27,12 @@ export type { CurrentUserResult, FollowingResult, GetTweetResult, + ListsResult, SearchResult, TweetData, TweetResult, TwitterClientOptions, + TwitterList, TwitterUser, UploadMediaResult, } from './twitter-client-types.js'; diff --git a/tests/twitter-client.lists.test.ts b/tests/twitter-client.lists.test.ts new file mode 100644 index 0000000..7cd0b0a --- /dev/null +++ b/tests/twitter-client.lists.test.ts @@ -0,0 +1,495 @@ +// ABOUTME: Tests for TwitterClient list methods. +// ABOUTME: Tests getOwnedLists, getListMemberships, and getListTimeline. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TwitterClient } from '../src/lib/twitter-client.js'; +import { validCookies } from './twitter-client-fixtures.js'; + +const originalFetch = global.fetch; + +type TwitterClientPrivate = TwitterClient & { + getCurrentUser: () => Promise<{ + success: boolean; + user?: { id: string; username: string; name: string }; + error?: string; + }>; + getListOwnershipsQueryIds: () => Promise; + getListMembershipsQueryIds: () => Promise; + getListTimelineQueryIds: () => Promise; + refreshQueryIds: () => Promise; +}; + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe('TwitterClient lists', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + describe('getOwnedLists', () => { + it('fetches owned lists and parses list results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + list: { + id_str: '1234567890', + name: 'My Test List', + description: 'A test list for testing', + member_count: 10, + subscriber_count: 5, + mode: 'Public', + created_at: '2024-01-01T00:00:00Z', + user_results: { + result: { + rest_id: '12345', + legacy: { screen_name: 'testuser', name: 'Test User' }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ + success: true, + user: { id: '12345', username: 'testuser', name: 'Test User' }, + }); + clientPrivate.getListOwnershipsQueryIds = async () => ['test']; + + const result = await client.getOwnedLists(100); + + expect(result.success).toBe(true); + expect(result.lists).toHaveLength(1); + expect(result.lists?.[0].id).toBe('1234567890'); + expect(result.lists?.[0].name).toBe('My Test List'); + expect(result.lists?.[0].description).toBe('A test list for testing'); + expect(result.lists?.[0].memberCount).toBe(10); + expect(result.lists?.[0].subscriberCount).toBe(5); + expect(result.lists?.[0].isPrivate).toBe(false); + expect(result.lists?.[0].owner?.username).toBe('testuser'); + }); + + it('handles private lists correctly', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + list: { + id_str: '9999', + name: 'Secret List', + mode: 'Private', + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ + success: true, + user: { id: '12345', username: 'testuser', name: 'Test User' }, + }); + clientPrivate.getListOwnershipsQueryIds = async () => ['test']; + + const result = await client.getOwnedLists(100); + + expect(result.success).toBe(true); + expect(result.lists?.[0].isPrivate).toBe(true); + }); + + it('returns empty array when no lists exist', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ + success: true, + user: { id: '12345', username: 'testuser', name: 'Test User' }, + }); + clientPrivate.getListOwnershipsQueryIds = async () => ['test']; + + const result = await client.getOwnedLists(100); + + expect(result.success).toBe(true); + expect(result.lists).toEqual([]); + }); + + it('returns error on API failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ + success: true, + user: { id: '12345', username: 'testuser', name: 'Test User' }, + }); + clientPrivate.getListOwnershipsQueryIds = async () => ['test']; + + const result = await client.getOwnedLists(100); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 500'); + }); + + it('returns error when getCurrentUser fails', async () => { + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ + success: false, + error: 'Unauthorized', + }); + + const result = await client.getOwnedLists(100); + + expect(result.success).toBe(false); + // When getCurrentUser fails with an error, that error is used; otherwise fallback message is used + expect(result.error).toContain('Unauthorized'); + }); + + it('handles API errors in response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + errors: [{ message: 'Rate limit exceeded' }], + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ + success: true, + user: { id: '12345', username: 'testuser', name: 'Test User' }, + }); + clientPrivate.getListOwnershipsQueryIds = async () => ['test']; + + const result = await client.getOwnedLists(100); + + expect(result.success).toBe(false); + expect(result.error).toContain('Rate limit exceeded'); + }); + }); + + describe('getListMemberships', () => { + it('fetches list memberships and parses list results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + list: { + id_str: '5555', + name: 'Member List', + member_count: 100, + user_results: { + result: { + rest_id: '99999', + legacy: { screen_name: 'otheruser', name: 'Other User' }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ + success: true, + user: { id: '12345', username: 'testuser', name: 'Test User' }, + }); + clientPrivate.getListMembershipsQueryIds = async () => ['test']; + + const result = await client.getListMemberships(100); + + expect(result.success).toBe(true); + expect(result.lists?.[0].id).toBe('5555'); + expect(result.lists?.[0].name).toBe('Member List'); + expect(result.lists?.[0].owner?.username).toBe('otheruser'); + }); + }); + + describe('getListTimeline', () => { + it('fetches list timeline and parses tweet results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '111', + legacy: { + full_text: 'Tweet from list', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '111', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'listmember', name: 'List Member' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getListTimeline('1234567890', 20); + + expect(result.success).toBe(true); + expect(result.tweets?.[0].id).toBe('111'); + expect(result.tweets?.[0].text).toBe('Tweet from list'); + expect(result.tweets?.[0].author.username).toBe('listmember'); + }); + + it('returns empty array when list has no tweets', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getListTimeline('1234567890', 20); + + expect(result.success).toBe(true); + expect(result.tweets).toEqual([]); + }); + + it('returns error on API failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => 'Forbidden', + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getListTimeline('1234567890', 20); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 403'); + }); + + it('handles API errors in response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + errors: [{ message: 'List not found' }], + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getListTimeline('nonexistent', 20); + + expect(result.success).toBe(false); + expect(result.error).toContain('List not found'); + }); + + it('retries on 404 error after refreshing query IDs', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => 'Not Found', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '222', + legacy: { + full_text: 'Retry success', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '222', + }, + core: { + user_results: { + result: { + rest_id: 'u2', + legacy: { screen_name: 'user2', name: 'User Two' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + clientPrivate.refreshQueryIds = async () => {}; + + const result = await client.getListTimeline('1234567890', 20); + + expect(result.success).toBe(true); + expect(result.tweets?.[0].id).toBe('222'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); +});