From df449ea736e147a90eb99236b5e38c50391847b8 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Fri, 16 Jan 2026 06:48:10 -0500 Subject: [PATCH] feat: add follow/unfollow commands - bird follow : follow a user by handle or ID - bird unfollow : unfollow a user by handle or ID Uses REST API /1.1/friendships/create.json and /destroy.json with GraphQL fallback. Accepts @handles, bare usernames, or numeric user IDs. --- package.json | 2 +- src/cli/program.ts | 4 + src/commands/follow.ts | 110 ++++++++++++++++ src/lib/twitter-client-follow.ts | 209 +++++++++++++++++++++++++++++++ src/lib/twitter-client-types.ts | 11 ++ src/lib/twitter-client.ts | 5 +- 6 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 src/commands/follow.ts create mode 100644 src/lib/twitter-client-follow.ts diff --git a/package.json b/package.json index bdaff87..189ed86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yuta/bird", - "version": "0.7.0", + "version": "0.8.0", "description": "CLI tool for tweeting and replying via Twitter/X GraphQL API", "type": "module", "main": "dist/index.js", diff --git a/src/cli/program.ts b/src/cli/program.ts index e60c601..cac36ec 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { registerBookmarksCommand } from '../commands/bookmarks.js'; import { registerCheckCommand } from '../commands/check.js'; +import { registerFollowCommands } from '../commands/follow.js'; import { registerHelpCommand } from '../commands/help.js'; import { registerHomeCommand } from '../commands/home.js'; import { registerListsCommand } from '../commands/lists.js'; @@ -26,6 +27,8 @@ export const KNOWN_COMMANDS = new Set([ 'mentions', 'bookmarks', 'unbookmark', + 'follow', + 'unfollow', 'following', 'followers', 'likes', @@ -133,6 +136,7 @@ export function createProgram(ctx: CliContext): Command { registerSearchCommands(program, ctx); registerBookmarksCommand(program, ctx); registerUnbookmarkCommand(program, ctx); + registerFollowCommands(program, ctx); registerListsCommand(program, ctx); registerHomeCommand(program, ctx); registerUserCommands(program, ctx); diff --git a/src/commands/follow.ts b/src/commands/follow.ts new file mode 100644 index 0000000..23e3d46 --- /dev/null +++ b/src/commands/follow.ts @@ -0,0 +1,110 @@ +import type { Command } from 'commander'; +import type { CliContext } from '../cli/shared.js'; +import { normalizeHandle } from '../lib/normalize-handle.js'; +import { TwitterClient } from '../lib/twitter-client.js'; + +async function resolveUserId( + client: TwitterClient, + usernameOrId: string, + ctx: CliContext, +): Promise<{ userId: string; username?: string } | null> { + // If it looks like a numeric ID, use it directly + if (/^\d+$/.test(usernameOrId)) { + return { userId: usernameOrId }; + } + + // Otherwise, treat as username and look up + const handle = normalizeHandle(usernameOrId); + if (!handle) { + console.error(`${ctx.p('err')}Invalid username: ${usernameOrId}`); + return null; + } + + const lookup = await client.getUserIdByUsername(handle); + if (!lookup.success || !lookup.userId) { + console.error(`${ctx.p('err')}Failed to find user @${handle}: ${lookup.error ?? 'Unknown error'}`); + return null; + } + + return { userId: lookup.userId, username: lookup.username }; +} + +export function registerFollowCommands(program: Command, ctx: CliContext): void { + program + .command('follow') + .description('Follow a user') + .argument('', 'Username (with or without @) or user ID to follow') + .action(async (usernameOrId: string) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + + 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 resolved = await resolveUserId(client, usernameOrId, ctx); + if (!resolved) { + process.exit(1); + } + + const { userId, username } = resolved; + const displayName = username ? `@${username}` : userId; + + const result = await client.follow(userId); + if (result.success) { + const finalName = result.username ? `@${result.username}` : displayName; + console.log(`${ctx.p('ok')}Now following ${finalName}`); + } else { + console.error(`${ctx.p('err')}Failed to follow ${displayName}: ${result.error}`); + process.exit(1); + } + }); + + program + .command('unfollow') + .description('Unfollow a user') + .argument('', 'Username (with or without @) or user ID to unfollow') + .action(async (usernameOrId: string) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + + 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 resolved = await resolveUserId(client, usernameOrId, ctx); + if (!resolved) { + process.exit(1); + } + + const { userId, username } = resolved; + const displayName = username ? `@${username}` : userId; + + const result = await client.unfollow(userId); + if (result.success) { + const finalName = result.username ? `@${result.username}` : displayName; + console.log(`${ctx.p('ok')}Unfollowed ${finalName}`); + } else { + console.error(`${ctx.p('err')}Failed to unfollow ${displayName}: ${result.error}`); + process.exit(1); + } + }); +} diff --git a/src/lib/twitter-client-follow.ts b/src/lib/twitter-client-follow.ts new file mode 100644 index 0000000..800ed63 --- /dev/null +++ b/src/lib/twitter-client-follow.ts @@ -0,0 +1,209 @@ +import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; +import { TWITTER_API_BASE } from './twitter-client-constants.js'; +import type { FollowMutationResult } from './twitter-client-types.js'; + +export interface TwitterClientFollowMethods { + follow(userId: string): Promise; + unfollow(userId: string): Promise; +} + +export function withFollow>( + Base: TBase, +): Mixin { + abstract class TwitterClientFollow extends Base { + // biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement. + constructor(...args: any[]) { + super(...args); + } + + /** + * Follow a user by their user ID + */ + async follow(userId: string): Promise { + await this.ensureClientUserId(); + + // Try REST API first (more reliable for follow/unfollow) + const restResult = await this.followViaRest(userId, 'create'); + if (restResult.success) { + return restResult; + } + + // Fallback to GraphQL + return this.followViaGraphQL(userId, true); + } + + /** + * Unfollow a user by their user ID + */ + async unfollow(userId: string): Promise { + await this.ensureClientUserId(); + + // Try REST API first (more reliable for follow/unfollow) + const restResult = await this.followViaRest(userId, 'destroy'); + if (restResult.success) { + return restResult; + } + + // Fallback to GraphQL + return this.followViaGraphQL(userId, false); + } + + private async followViaRest( + userId: string, + action: 'create' | 'destroy', + ): Promise { + const urls = [ + `https://x.com/i/api/1.1/friendships/${action}.json`, + `https://api.twitter.com/1.1/friendships/${action}.json`, + ]; + + const params = new URLSearchParams({ + user_id: userId, + skip_status: 'true', + }); + + let lastError: string | undefined; + + for (const url of urls) { + try { + const response = await this.fetchWithTimeout(url, { + method: 'POST', + headers: { + ...this.getBaseHeaders(), + 'content-type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!response.ok) { + const text = await response.text(); + + // Parse error response + try { + const errorData = JSON.parse(text) as { errors?: Array<{ code: number; message: string }> }; + if (errorData.errors && errorData.errors.length > 0) { + const error = errorData.errors[0]; + // Code 160: Already following / already not following + if (error.code === 160) { + return { success: true }; + } + // Code 162: Blocked from following + if (error.code === 162) { + return { success: false, error: 'You have been blocked from following this account' }; + } + // Code 108: Cannot find specified user + if (error.code === 108) { + return { success: false, error: 'User not found' }; + } + lastError = `${error.message} (code ${error.code})`; + continue; + } + } catch { + // Not JSON, continue with generic error + } + + lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; + continue; + } + + const data = (await response.json()) as { + id_str?: string; + screen_name?: string; + following?: boolean; + errors?: Array<{ message: string; code?: number }>; + }; + + if (data.errors && data.errors.length > 0) { + lastError = data.errors.map((e) => e.message).join(', '); + continue; + } + + // Success - user data returned + if (data.id_str || data.screen_name) { + return { + success: true, + userId: data.id_str, + username: data.screen_name, + }; + } + + return { success: true }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false, error: lastError ?? `Unknown error during ${action}` }; + } + + private async followViaGraphQL(userId: string, follow: boolean): Promise { + const operationName = follow ? 'CreateFriendship' : 'DestroyFriendship'; + + // Known query IDs for friendship operations + const queryIds = follow + ? ['8h9JVdV8dlSyqyRDJEPCsA', 'OPwKc1HXnBT_bWXfAlo-9g'] + : ['8h9JVdV8dlSyqyRDJEPCsA', 'ppXWuagMNXgvzx6WoXBW0Q']; + + const variables = { + user_id: userId, + }; + + let lastError: string | undefined; + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/${operationName}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ variables, queryId }), + }); + + if (response.status === 404) { + lastError = `HTTP 404`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; + continue; + } + + const data = (await response.json()) as { + data?: { + user?: { + result?: { + rest_id?: string; + legacy?: { + screen_name?: string; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + lastError = data.errors.map((e) => e.message).join(', '); + continue; + } + + const result = data.data?.user?.result; + return { + success: true, + userId: result?.rest_id, + username: result?.legacy?.screen_name, + }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false, error: lastError ?? `Unknown error during ${operationName}` }; + } + } + + return TwitterClientFollow; +} diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index 5d176c3..e14cc04 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -228,6 +228,17 @@ export type BookmarkMutationResult = error: string; }; +export type FollowMutationResult = + | { + success: true; + userId?: string; + username?: string; + } + | { + success: false; + error: string; + }; + export interface UploadMediaResult { success: boolean; mediaId?: string; diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index c79a26b..531bdcb 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -1,6 +1,7 @@ import type { AbstractConstructor } from './twitter-client-base.js'; import { TwitterClientBase } from './twitter-client-base.js'; import { type TwitterClientBookmarkMethods, withBookmarks } from './twitter-client-bookmarks.js'; +import { type TwitterClientFollowMethods, withFollow } from './twitter-client-follow.js'; import { type TwitterClientHomeMethods, withHome } from './twitter-client-home.js'; import { type TwitterClientListMethods, withLists } from './twitter-client-lists.js'; import { type TwitterClientMediaMethods, withMedia } from './twitter-client-media.js'; @@ -15,6 +16,7 @@ import { type TwitterClientUserMethods, withUsers } from './twitter-client-users type TwitterClientInstance = TwitterClientBase & TwitterClientBookmarkMethods & + TwitterClientFollowMethods & TwitterClientHomeMethods & TwitterClientListMethods & TwitterClientMediaMethods & @@ -34,7 +36,7 @@ const MixedTwitterClient = withNews( withUsers( withLists( withHome( - withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase)))))), + withTimelines(withSearch(withTweetDetails(withPosting(withFollow(withBookmarks(withMedia(TwitterClientBase))))))), ), ), ), @@ -49,6 +51,7 @@ export type { BookmarkMutationResult, CurrentUserResult, FollowingResult, + FollowMutationResult, GetTweetResult, ListsResult, SearchResult,