feat: add quote depth option for quoted tweets

This commit is contained in:
Peter Steinberger
2026-01-01 10:45:15 +01:00
parent c90d3f9ab4
commit 071953e9f4
2 changed files with 59 additions and 39 deletions
+33 -9
View File
@@ -99,6 +99,7 @@ type BirdConfig = {
firefoxProfile?: string; firefoxProfile?: string;
cookieSource?: CookieSource | CookieSource[]; cookieSource?: CookieSource | CookieSource[];
timeoutMs?: number; timeoutMs?: number;
quoteDepth?: number;
}; };
function readConfigFile(path: string): Partial<BirdConfig> { function readConfigFile(path: string): Partial<BirdConfig> {
@@ -180,6 +181,7 @@ program
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, []) .option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, [])
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, []) .option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, [])
.option('--timeout <ms>', 'Request timeout in milliseconds') .option('--timeout <ms>', 'Request timeout in milliseconds')
.option('--quote-depth <depth>', 'Max quoted tweet depth (default: 1; 0 disables)')
.option('--plain', 'Plain output (stable, no emoji, no color)') .option('--plain', 'Plain output (stable, no emoji, no color)')
.option('--no-emoji', 'Disable emoji output') .option('--no-emoji', 'Disable emoji output')
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)'); .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); return resolveTimeoutMs(options.timeout, config.timeoutMs, process.env.BIRD_TIMEOUT_MS);
} }
function resolveQuoteDepth(...values: Array<string | number | undefined | null>): 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 { function detectMime(path: string): string | null {
const ext = path.toLowerCase(); const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg'; if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg';
@@ -364,6 +379,7 @@ program
.action(async (text: string) => { .action(async (text: string) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
let media: MediaSpec[] = []; let media: MediaSpec[] = [];
try { try {
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
@@ -387,7 +403,7 @@ program
console.error(`${l('source')}${cookies.source}`); console.error(`${l('source')}${cookies.source}`);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
let mediaIds: string[] | undefined; let mediaIds: string[] | undefined;
if (media.length > 0) { if (media.length > 0) {
const uploaded: string[] = []; const uploaded: string[] = [];
@@ -422,6 +438,7 @@ program
.action(async (tweetIdOrUrl: string, text: string) => { .action(async (tweetIdOrUrl: string, text: string) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
let media: MediaSpec[] = []; let media: MediaSpec[] = [];
try { try {
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
@@ -448,7 +465,7 @@ program
console.error(`${p('info')}Replying to tweet: ${tweetId}`); 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; let mediaIds: string[] | undefined;
if (media.length > 0) { if (media.length > 0) {
const uploaded: string[] = []; const uploaded: string[] = [];
@@ -483,6 +500,7 @@ program
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
@@ -497,7 +515,7 @@ program
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getTweet(tweetId); const result = await client.getTweet(tweetId);
if (result.success && result.tweet) { if (result.success && result.tweet) {
@@ -526,6 +544,7 @@ program
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts); const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
@@ -539,7 +558,7 @@ program
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getReplies(tweetId); const result = await client.getReplies(tweetId);
if (result.success && result.tweets) { if (result.success && result.tweets) {
@@ -559,6 +578,7 @@ program
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts); const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
@@ -572,7 +592,7 @@ program
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getThread(tweetId); const result = await client.getThread(tweetId);
if (result.success && result.tweets) { if (result.success && result.tweets) {
@@ -593,6 +613,7 @@ program
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => { .action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10); const count = Number.parseInt(cmdOpts.count || '10', 10);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts); const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
@@ -606,7 +627,7 @@ program
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.search(query, count); const result = await client.search(query, count);
if (result.success && result.tweets) { if (result.success && result.tweets) {
@@ -627,6 +648,7 @@ program
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => { .action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10); const count = Number.parseInt(cmdOpts.count || '10', 10);
const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user); const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user);
@@ -648,7 +670,7 @@ program
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
if (!query) { if (!query) {
const who = await client.getCurrentUser(); const who = await client.getCurrentUser();
@@ -682,6 +704,7 @@ program
.action(async (cmdOpts: { count?: string; json?: boolean }) => { .action(async (cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10); const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts); const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
@@ -695,7 +718,7 @@ program
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getBookmarks(count); const result = await client.getBookmarks(count);
if (result.success && result.tweets) { if (result.success && result.tweets) {
@@ -713,6 +736,7 @@ program
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts); const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
@@ -729,7 +753,7 @@ program
console.error(`${l('source')}${cookies.source}`); 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 result = await client.getCurrentUser();
const credentialSource = cookies.source ?? 'env/auto-detected cookies'; const credentialSource = cookies.source ?? 'env/auto-detected cookies';
+26 -30
View File
@@ -35,7 +35,14 @@ const QUERY_IDS: Record<OperationName, string> = {
const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array<OperationName>; const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array<OperationName>;
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 = { type GraphqlTweetResult = {
__typename?: string;
rest_id?: string; rest_id?: string;
legacy?: { legacy?: {
full_text?: string; full_text?: string;
@@ -180,6 +187,7 @@ type GraphqlTweetResult = {
}>; }>;
}>; }>;
}; };
tweet?: GraphqlTweetResult;
quoted_status_result?: { quoted_status_result?: {
result?: GraphqlTweetResult; result?: GraphqlTweetResult;
}; };
@@ -215,15 +223,8 @@ export interface TweetData {
likeCount?: number; likeCount?: number;
conversationId?: string; conversationId?: string;
inReplyToStatusId?: string; inReplyToStatusId?: string;
quotedTweet?: { // Optional quoted tweet; depth controlled by quoteDepth (default: 1).
id: string; quotedTweet?: TweetData;
text: string;
author: {
username: string;
name: string;
};
authorId?: string;
};
} }
export interface GetTweetResult { export interface GetTweetResult {
@@ -252,6 +253,8 @@ export interface TwitterClientOptions {
cookies: TwitterCookies; cookies: TwitterCookies;
userAgent?: string; userAgent?: string;
timeoutMs?: number; timeoutMs?: number;
// Max depth for quoted tweets (0 disables). Defaults to 1.
quoteDepth?: number;
} }
interface CreateTweetResponse { interface CreateTweetResponse {
@@ -276,6 +279,7 @@ export class TwitterClient {
private cookieHeader: string; private cookieHeader: string;
private userAgent: string; private userAgent: string;
private timeoutMs?: number; private timeoutMs?: number;
private quoteDepth: number;
private clientUuid: string; private clientUuid: string;
private clientDeviceId: string; private clientDeviceId: string;
private clientUserId?: string; private clientUserId?: string;
@@ -291,6 +295,7 @@ export class TwitterClient {
options.userAgent || 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'; '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.timeoutMs = options.timeoutMs;
this.quoteDepth = normalizeQuoteDepth(options.quoteDepth);
this.clientUuid = randomUUID(); this.clientUuid = randomUUID();
this.clientDeviceId = 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 userResult = result?.core?.user_results?.result;
const userLegacy = userResult?.legacy; const userLegacy = userResult?.legacy;
const userCore = userResult?.core; const userCore = userResult?.core;
@@ -698,26 +709,11 @@ export class TwitterClient {
const text = this.extractTweetText(result); const text = this.extractTweetText(result);
if (!text) return undefined; if (!text) return undefined;
// Extract quoted tweet if present let quotedTweet: TweetData | undefined;
let quotedTweet: TweetData['quotedTweet'] = undefined; if (quoteDepth > 0) {
const quotedResult = result.quoted_status_result?.result; const quotedResult = this.unwrapTweetResult(result.quoted_status_result?.result);
if (quotedResult?.rest_id) { if (quotedResult) {
const quotedUserResult = quotedResult?.core?.user_results?.result; quotedTweet = this.mapTweetResult(quotedResult, quoteDepth - 1);
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,
};
} }
} }