diff --git a/src/index.ts b/src/index.ts index 931064a..86fc042 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,6 +99,7 @@ type BirdConfig = { firefoxProfile?: string; cookieSource?: CookieSource | CookieSource[]; timeoutMs?: number; + quoteDepth?: number; }; function readConfigFile(path: string): Partial { @@ -180,6 +181,7 @@ program .option('--media ', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, []) .option('--alt ', 'Alt text for the corresponding --media (repeatable)', collect, []) .option('--timeout ', 'Request timeout in milliseconds') + .option('--quote-depth ', 'Max quoted tweet depth (default: 1; 0 disables)') .option('--plain', 'Plain output (stable, no emoji, no color)') .option('--no-emoji', 'Disable emoji output') .option('--no-color', 'Disable ANSI colors (or set NO_COLOR)'); @@ -224,6 +226,19 @@ function resolveTimeoutFromOptions(options: { timeout?: string | number }): numb return resolveTimeoutMs(options.timeout, config.timeoutMs, process.env.BIRD_TIMEOUT_MS); } +function resolveQuoteDepth(...values: Array): number | undefined { + for (const value of values) { + if (value === undefined || value === null || value === '') continue; + const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed >= 0) return Math.floor(parsed); + } + return undefined; +} + +function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }): number | undefined { + return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, process.env.BIRD_QUOTE_DEPTH); +} + function detectMime(path: string): string | null { const ext = path.toLowerCase(); if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg'; @@ -364,6 +379,7 @@ program .action(async (text: string) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); let media: MediaSpec[] = []; try { media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); @@ -387,7 +403,7 @@ program console.error(`${l('source')}${cookies.source}`); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); let mediaIds: string[] | undefined; if (media.length > 0) { const uploaded: string[] = []; @@ -422,6 +438,7 @@ program .action(async (tweetIdOrUrl: string, text: string) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); let media: MediaSpec[] = []; try { media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); @@ -448,7 +465,7 @@ program console.error(`${p('info')}Replying to tweet: ${tweetId}`); - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); let mediaIds: string[] | undefined; if (media.length > 0) { const uploaded: string[] = []; @@ -483,6 +500,7 @@ program .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const tweetId = extractTweetId(tweetIdOrUrl); @@ -497,7 +515,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getTweet(tweetId); if (result.success && result.tweet) { @@ -526,6 +544,7 @@ program .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const tweetId = extractTweetId(tweetIdOrUrl); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -539,7 +558,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getReplies(tweetId); if (result.success && result.tweets) { @@ -559,6 +578,7 @@ program .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const tweetId = extractTweetId(tweetIdOrUrl); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -572,7 +592,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getThread(tweetId); if (result.success && result.tweets) { @@ -593,6 +613,7 @@ program .action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '10', 10); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -606,7 +627,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.search(query, count); if (result.success && result.tweets) { @@ -627,6 +648,7 @@ program .action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '10', 10); const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user); @@ -648,7 +670,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); if (!query) { const who = await client.getCurrentUser(); @@ -682,6 +704,7 @@ program .action(async (cmdOpts: { count?: string; json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '20', 10); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -695,7 +718,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getBookmarks(count); if (result.success && result.tweets) { @@ -713,6 +736,7 @@ program .action(async () => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -729,7 +753,7 @@ program console.error(`${l('source')}${cookies.source}`); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getCurrentUser(); const credentialSource = cookies.source ?? 'env/auto-detected cookies'; diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 7a957b7..96c20e9 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -35,7 +35,14 @@ const QUERY_IDS: Record = { const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array; +function normalizeQuoteDepth(value?: number): number { + if (value === undefined || value === null) return 1; + if (!Number.isFinite(value)) return 1; + return Math.max(0, Math.floor(value)); +} + type GraphqlTweetResult = { + __typename?: string; rest_id?: string; legacy?: { full_text?: string; @@ -180,6 +187,7 @@ type GraphqlTweetResult = { }>; }>; }; + tweet?: GraphqlTweetResult; quoted_status_result?: { result?: GraphqlTweetResult; }; @@ -215,15 +223,8 @@ export interface TweetData { likeCount?: number; conversationId?: string; inReplyToStatusId?: string; - quotedTweet?: { - id: string; - text: string; - author: { - username: string; - name: string; - }; - authorId?: string; - }; + // Optional quoted tweet; depth controlled by quoteDepth (default: 1). + quotedTweet?: TweetData; } export interface GetTweetResult { @@ -252,6 +253,8 @@ export interface TwitterClientOptions { cookies: TwitterCookies; userAgent?: string; timeoutMs?: number; + // Max depth for quoted tweets (0 disables). Defaults to 1. + quoteDepth?: number; } interface CreateTweetResponse { @@ -276,6 +279,7 @@ export class TwitterClient { private cookieHeader: string; private userAgent: string; private timeoutMs?: number; + private quoteDepth: number; private clientUuid: string; private clientDeviceId: string; private clientUserId?: string; @@ -291,6 +295,7 @@ export class TwitterClient { options.userAgent || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; this.timeoutMs = options.timeoutMs; + this.quoteDepth = normalizeQuoteDepth(options.quoteDepth); this.clientUuid = randomUUID(); this.clientDeviceId = randomUUID(); } @@ -686,7 +691,13 @@ export class TwitterClient { ); } - private mapTweetResult(result: GraphqlTweetResult | undefined): TweetData | undefined { + private unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined { + if (!result) return undefined; + if (result.tweet) return result.tweet; + return result; + } + + private mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth = this.quoteDepth): TweetData | undefined { const userResult = result?.core?.user_results?.result; const userLegacy = userResult?.legacy; const userCore = userResult?.core; @@ -698,26 +709,11 @@ export class TwitterClient { const text = this.extractTweetText(result); if (!text) return undefined; - // Extract quoted tweet if present - let quotedTweet: TweetData['quotedTweet'] = undefined; - const quotedResult = result.quoted_status_result?.result; - if (quotedResult?.rest_id) { - const quotedUserResult = quotedResult?.core?.user_results?.result; - const quotedUserLegacy = quotedUserResult?.legacy; - const quotedUserCore = quotedUserResult?.core; - const quotedUsername = quotedUserLegacy?.screen_name ?? quotedUserCore?.screen_name; - const quotedName = quotedUserLegacy?.name ?? quotedUserCore?.name ?? quotedUsername; - const quotedText = this.extractTweetText(quotedResult); - if (quotedUsername && quotedText) { - quotedTweet = { - id: quotedResult.rest_id, - text: quotedText, - author: { - username: quotedUsername, - name: quotedName || quotedUsername, - }, - authorId: quotedUserResult?.rest_id, - }; + let quotedTweet: TweetData | undefined; + if (quoteDepth > 0) { + const quotedResult = this.unwrapTweetResult(result.quoted_status_result?.result); + if (quotedResult) { + quotedTweet = this.mapTweetResult(quotedResult, quoteDepth - 1); } }