feat: add likes command

Add `bird likes` command to fetch the authenticated user's liked tweets,
similar to the existing bookmarks command.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
swairshah
2025-12-31 14:02:08 -08:00
co-authored by Claude Opus 4.5
parent f264e36cbe
commit 676bff71f3
2 changed files with 149 additions and 0 deletions
+35
View File
@@ -150,6 +150,7 @@ const KNOWN_COMMANDS = new Set([
'search', 'search',
'mentions', 'mentions',
'bookmarks', 'bookmarks',
'likes',
'help', 'help',
'whoami', 'whoami',
'check', 'check',
@@ -347,6 +348,7 @@ program
'SearchTimeline', 'SearchTimeline',
'UserArticlesTweets', 'UserArticlesTweets',
'Bookmarks', 'Bookmarks',
'Likes',
]; ];
if (cmdOpts.fresh) { if (cmdOpts.fresh) {
@@ -741,6 +743,39 @@ program
} }
}); });
// Likes command - get user's liked tweets
program
.command('likes')
.description('Get your liked tweets')
.option('-n, --count <number>', 'Number of likes to fetch', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.getLikes(count);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' });
} else {
console.error(`${p('err')}Failed to fetch likes: ${result.error}`);
process.exit(1);
}
});
// Whoami command - show the logged-in account // Whoami command - show the logged-in account
program program
.command('whoami') .command('whoami')
+114
View File
@@ -28,6 +28,7 @@ const FALLBACK_QUERY_IDS = {
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw', Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
Likes: 'JR2gceKucIKcVNB_9JkhsA',
} as const; } as const;
type OperationName = keyof typeof FALLBACK_QUERY_IDS; type OperationName = keyof typeof FALLBACK_QUERY_IDS;
@@ -1987,4 +1988,117 @@ export class TwitterClient {
return { success: false, error: firstAttempt.error }; return { success: false, error: firstAttempt.error };
} }
private async getLikesQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Likes');
return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA']));
}
/**
* Get the authenticated user's liked tweets
*/
async getLikes(count = 20): Promise<SearchResult> {
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,
includePromotedContent: false,
withClientEventToken: false,
withBirdwatchNotes: false,
withVoice: true,
};
const features = this.buildBookmarksFeatures();
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.getLikesQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Likes?${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?: {
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?.user?.result?.timeline?.timeline?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions);
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 likes', 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 };
}
} }