feat: add follow/unfollow commands
- bird follow <username-or-id>: follow a user by handle or ID - bird unfollow <username-or-id>: 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.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@yuta/bird",
|
"name": "@yuta/bird",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
|
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { registerBookmarksCommand } from '../commands/bookmarks.js';
|
import { registerBookmarksCommand } from '../commands/bookmarks.js';
|
||||||
import { registerCheckCommand } from '../commands/check.js';
|
import { registerCheckCommand } from '../commands/check.js';
|
||||||
|
import { registerFollowCommands } from '../commands/follow.js';
|
||||||
import { registerHelpCommand } from '../commands/help.js';
|
import { registerHelpCommand } from '../commands/help.js';
|
||||||
import { registerHomeCommand } from '../commands/home.js';
|
import { registerHomeCommand } from '../commands/home.js';
|
||||||
import { registerListsCommand } from '../commands/lists.js';
|
import { registerListsCommand } from '../commands/lists.js';
|
||||||
@@ -26,6 +27,8 @@ export const KNOWN_COMMANDS = new Set([
|
|||||||
'mentions',
|
'mentions',
|
||||||
'bookmarks',
|
'bookmarks',
|
||||||
'unbookmark',
|
'unbookmark',
|
||||||
|
'follow',
|
||||||
|
'unfollow',
|
||||||
'following',
|
'following',
|
||||||
'followers',
|
'followers',
|
||||||
'likes',
|
'likes',
|
||||||
@@ -133,6 +136,7 @@ export function createProgram(ctx: CliContext): Command {
|
|||||||
registerSearchCommands(program, ctx);
|
registerSearchCommands(program, ctx);
|
||||||
registerBookmarksCommand(program, ctx);
|
registerBookmarksCommand(program, ctx);
|
||||||
registerUnbookmarkCommand(program, ctx);
|
registerUnbookmarkCommand(program, ctx);
|
||||||
|
registerFollowCommands(program, ctx);
|
||||||
registerListsCommand(program, ctx);
|
registerListsCommand(program, ctx);
|
||||||
registerHomeCommand(program, ctx);
|
registerHomeCommand(program, ctx);
|
||||||
registerUserCommands(program, ctx);
|
registerUserCommands(program, ctx);
|
||||||
|
|||||||
@@ -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-or-id>', '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-or-id>', '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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<FollowMutationResult>;
|
||||||
|
unfollow(userId: string): Promise<FollowMutationResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withFollow<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||||
|
Base: TBase,
|
||||||
|
): Mixin<TBase, TwitterClientFollowMethods> {
|
||||||
|
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<FollowMutationResult> {
|
||||||
|
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<FollowMutationResult> {
|
||||||
|
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<FollowMutationResult> {
|
||||||
|
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<FollowMutationResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -228,6 +228,17 @@ export type BookmarkMutationResult =
|
|||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FollowMutationResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
userId?: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: false;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface UploadMediaResult {
|
export interface UploadMediaResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
mediaId?: string;
|
mediaId?: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AbstractConstructor } from './twitter-client-base.js';
|
import type { AbstractConstructor } from './twitter-client-base.js';
|
||||||
import { TwitterClientBase } from './twitter-client-base.js';
|
import { TwitterClientBase } from './twitter-client-base.js';
|
||||||
import { type TwitterClientBookmarkMethods, withBookmarks } from './twitter-client-bookmarks.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 TwitterClientHomeMethods, withHome } from './twitter-client-home.js';
|
||||||
import { type TwitterClientListMethods, withLists } from './twitter-client-lists.js';
|
import { type TwitterClientListMethods, withLists } from './twitter-client-lists.js';
|
||||||
import { type TwitterClientMediaMethods, withMedia } from './twitter-client-media.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 &
|
type TwitterClientInstance = TwitterClientBase &
|
||||||
TwitterClientBookmarkMethods &
|
TwitterClientBookmarkMethods &
|
||||||
|
TwitterClientFollowMethods &
|
||||||
TwitterClientHomeMethods &
|
TwitterClientHomeMethods &
|
||||||
TwitterClientListMethods &
|
TwitterClientListMethods &
|
||||||
TwitterClientMediaMethods &
|
TwitterClientMediaMethods &
|
||||||
@@ -34,7 +36,7 @@ const MixedTwitterClient = withNews(
|
|||||||
withUsers(
|
withUsers(
|
||||||
withLists(
|
withLists(
|
||||||
withHome(
|
withHome(
|
||||||
withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase)))))),
|
withTimelines(withSearch(withTweetDetails(withPosting(withFollow(withBookmarks(withMedia(TwitterClientBase))))))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -49,6 +51,7 @@ export type {
|
|||||||
BookmarkMutationResult,
|
BookmarkMutationResult,
|
||||||
CurrentUserResult,
|
CurrentUserResult,
|
||||||
FollowingResult,
|
FollowingResult,
|
||||||
|
FollowMutationResult,
|
||||||
GetTweetResult,
|
GetTweetResult,
|
||||||
ListsResult,
|
ListsResult,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
|
|||||||
Reference in New Issue
Block a user