feat: add following and followers commands

Add two new commands to list users:
- `bird following` - list users that you (or another user) follow
- `bird followers` - list users that follow you (or another user)

Both commands support:
- `--user <userId>` to query another user's following/followers
- `-n <count>` to limit results
- `--json` for structured output

Uses Twitter's GraphQL Following/Followers endpoints with proper
feature flags and query ID rotation support.
This commit is contained in:
thelockmeister
2026-01-01 09:06:17 +13:00
parent f264e36cbe
commit 34f45fece7
4 changed files with 383 additions and 1 deletions
+3 -1
View File
@@ -3,5 +3,7 @@
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"TweetDetail": "97JF30KziU00483E_8elBA",
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ"
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
"Following": "BEkNpEt5pNETESoqMsTEGA",
"Followers": "kuFUYP9eV1FPoEy4N-pi7w"
}
+242
View File
@@ -28,6 +28,8 @@ const FALLBACK_QUERY_IDS = {
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
Following: 'BEkNpEt5pNETESoqMsTEGA',
Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
} as const;
type OperationName = keyof typeof FALLBACK_QUERY_IDS;
@@ -240,6 +242,24 @@ export interface CurrentUserResult {
error?: string;
}
export interface TwitterUser {
id: string;
username: string;
name: string;
description?: string;
followers_count?: number;
following_count?: number;
is_blue_verified?: boolean;
profile_image_url?: string;
created_at?: string;
}
export interface FollowingResult {
success: boolean;
users?: TwitterUser[];
error?: string;
}
export interface TwitterClientOptions {
cookies: TwitterCookies;
userAgent?: string;
@@ -1987,4 +2007,226 @@ export class TwitterClient {
return { success: false, error: firstAttempt.error };
}
private buildFollowingFeatures(): Record<string, boolean> {
return {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: false,
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: true,
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_jetfuel_frame: false,
responsive_web_grok_share_attachment_enabled: false,
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: true,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: false,
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: false,
responsive_web_grok_imagine_annotation_enabled: false,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
};
}
private parseUsersFromInstructions(
instructions: Array<{ type?: string; entries?: Array<unknown> }> | undefined,
): TwitterUser[] {
if (!instructions) {
return [];
}
const users: TwitterUser[] = [];
for (const instruction of instructions) {
if (instruction.type !== 'TimelineAddEntries' || !instruction.entries) {
continue;
}
for (const entry of instruction.entries) {
const content = (entry as { content?: { itemContent?: { user_results?: { result?: unknown } } } })?.content;
const userResult = content?.itemContent?.user_results?.result as
| {
__typename?: string;
rest_id?: string;
is_blue_verified?: boolean;
legacy?: {
screen_name?: string;
name?: string;
description?: string;
followers_count?: number;
friends_count?: number;
profile_image_url_https?: string;
created_at?: string;
};
core?: {
screen_name?: string;
name?: string;
created_at?: string;
};
avatar?: {
image_url?: string;
};
}
| undefined;
if (!userResult || userResult.__typename !== 'User') {
continue;
}
const legacy = userResult.legacy;
const core = userResult.core;
users.push({
id: userResult.rest_id ?? '',
username: legacy?.screen_name ?? core?.screen_name ?? '',
name: legacy?.name ?? core?.name ?? '',
description: legacy?.description,
followers_count: legacy?.followers_count,
following_count: legacy?.friends_count,
is_blue_verified: userResult.is_blue_verified,
profile_image_url: legacy?.profile_image_url_https ?? userResult.avatar?.image_url,
created_at: legacy?.created_at ?? core?.created_at,
});
}
}
return users;
}
/**
* Get users that a user is following
*/
async getFollowing(userId: string, count = 20): Promise<FollowingResult> {
const variables = {
userId,
count,
includePromotedContent: false,
};
const features = this.buildFollowingFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const queryId = await this.getQueryId('Following');
const url = `${TWITTER_API_BASE}/${queryId}/Following?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{ type?: string; entries?: Array<unknown> }>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const users = this.parseUsersFromInstructions(instructions);
return { success: true, users };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
/**
* Get users that follow a user
*/
async getFollowers(userId: string, count = 20): Promise<FollowingResult> {
const variables = {
userId,
count,
includePromotedContent: false,
};
const features = this.buildFollowingFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const queryId = await this.getQueryId('Followers');
const url = `${TWITTER_API_BASE}/${queryId}/Followers?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{ type?: string; entries?: Array<unknown> }>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const users = this.parseUsersFromInstructions(instructions);
return { success: true, users };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
}