fix: follow/unfollow hardening (#54) (thanks @citizenlee)

This commit is contained in:
Peter Steinberger
2026-06-24 18:43:54 +09:00
committed by yuta
parent 38d5dfe96a
commit 966599522a
10 changed files with 172 additions and 74 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
- `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala. - `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala.
- `replies` and `thread` now support pagination (`--all`, `--max-pages`, `--cursor`, `--delay`) (#35) — thanks @crcatala. - `replies` and `thread` now support pagination (`--all`, `--max-pages`, `--cursor`, `--delay`) (#35) — thanks @crcatala.
- `search` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#42) — thanks @pjtf93. - `search` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#42) — thanks @pjtf93.
- `about` command for account origin/location details (#51) — thanks @pjtf93. - `follow`/`unfollow` commands to manage follows (#54) — thanks @citizenlee.
- `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes. - `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes.
- `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli. - `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli.
- Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0. - Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0.
+1 -1
View File
@@ -22,7 +22,7 @@ Run:
Notes: Notes:
- Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`). - Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`).
- Search query is configurable via `BIRD_LIVE_SEARCH_QUERY`. - Search query is configurable via `BIRD_LIVE_SEARCH_QUERY`.
- About account handle is configurable via `BIRD_LIVE_ABOUT_HANDLE`. - Follow/unfollow handle is configurable via `BIRD_LIVE_FOLLOW_HANDLE` (opt-in).
- Command timeout is configurable via `BIRD_LIVE_TIMEOUT_MS` (ms). - Command timeout is configurable via `BIRD_LIVE_TIMEOUT_MS` (ms).
- Spawned CLI `NODE_ENV` defaults to `production` (override with `BIRD_LIVE_NODE_ENV`). - Spawned CLI `NODE_ENV` defaults to `production` (override with `BIRD_LIVE_NODE_ENV`).
- If you don't tweet, set `BIRD_LIVE_TWEET_ID` to a known tweet ID to use for `read/replies/thread`. - If you don't tweet, set `BIRD_LIVE_TWEET_ID` to a known tweet ID to use for `read/replies/thread`.
+2
View File
@@ -10,6 +10,8 @@ import path from 'node:path';
const TARGET_OPERATIONS = [ const TARGET_OPERATIONS = [
'CreateTweet', 'CreateTweet',
'CreateRetweet', 'CreateRetweet',
'CreateFriendship',
'DestroyFriendship',
'FavoriteTweet', 'FavoriteTweet',
'DeleteBookmark', 'DeleteBookmark',
'TweetDetail', 'TweetDetail',
+18 -13
View File
@@ -3,30 +3,35 @@ import type { CliContext } from '../cli/shared.js';
import { normalizeHandle } from '../lib/normalize-handle.js'; import { normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js'; import { TwitterClient } from '../lib/twitter-client.js';
const ONLY_DIGITS_REGEX = /^\d+$/;
async function resolveUserId( async function resolveUserId(
client: TwitterClient, client: TwitterClient,
usernameOrId: string, usernameOrId: string,
ctx: CliContext, ctx: CliContext,
): Promise<{ userId: string; username?: string } | null> { ): Promise<{ userId: string; username?: string } | null> {
// If it looks like a numeric ID, use it directly const raw = usernameOrId.trim();
if (/^\d+$/.test(usernameOrId)) { const isNumeric = ONLY_DIGITS_REGEX.test(raw);
return { userId: usernameOrId };
}
// Otherwise, treat as username and look up // Otherwise, treat as username and look up
const handle = normalizeHandle(usernameOrId); const handle = normalizeHandle(raw);
if (!handle) { if (handle) {
console.error(`${ctx.p('err')}Invalid username: ${usernameOrId}`); const lookup = await client.getUserIdByUsername(handle);
return null; if (lookup.success && lookup.userId) {
return { userId: lookup.userId, username: lookup.username };
}
if (!isNumeric) {
console.error(`${ctx.p('err')}Failed to find user @${handle}: ${lookup.error ?? 'Unknown error'}`);
return null;
}
} }
const lookup = await client.getUserIdByUsername(handle); if (isNumeric) {
if (!lookup.success || !lookup.userId) { return { userId: raw };
console.error(`${ctx.p('err')}Failed to find user @${handle}: ${lookup.error ?? 'Unknown error'}`);
return null;
} }
return { userId: lookup.userId, username: lookup.username }; console.error(`${ctx.p('err')}Invalid username: ${usernameOrId}`);
return null;
} }
export function registerFollowCommands(program: Command, ctx: CliContext): void { export function registerFollowCommands(program: Command, ctx: CliContext): void {
+2
View File
@@ -1,6 +1,8 @@
{ {
"CreateTweet": "nmdAQXJDxw6-0KKF2on7eA", "CreateTweet": "nmdAQXJDxw6-0KKF2on7eA",
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A", "CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
"CreateFriendship": "8h9JVdV8dlSyqyRDJEPCsA",
"DestroyFriendship": "ppXWuagMNXgvzx6WoXBW0Q",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ", "DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
"TweetDetail": "_NvJCnIjOW__EP5-RF197A", "TweetDetail": "_NvJCnIjOW__EP5-RF197A",
+2
View File
@@ -12,6 +12,8 @@ export const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/updat
export const FALLBACK_QUERY_IDS = { export const FALLBACK_QUERY_IDS = {
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg', CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
CreateFriendship: '8h9JVdV8dlSyqyRDJEPCsA',
DestroyFriendship: 'ppXWuagMNXgvzx6WoXBW0Q',
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ', DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ',
TweetDetail: '97JF30KziU00483E_8elBA', TweetDetail: '97JF30KziU00483E_8elBA',
+76 -51
View File
@@ -48,10 +48,7 @@ export function withFollow<TBase extends AbstractConstructor<TwitterClientBase>>
return this.followViaGraphQL(userId, false); return this.followViaGraphQL(userId, false);
} }
private async followViaRest( private async followViaRest(userId: string, action: 'create' | 'destroy'): Promise<FollowMutationResult> {
userId: string,
action: 'create' | 'destroy',
): Promise<FollowMutationResult> {
const urls = [ const urls = [
`https://x.com/i/api/1.1/friendships/${action}.json`, `https://x.com/i/api/1.1/friendships/${action}.json`,
`https://api.twitter.com/1.1/friendships/${action}.json`, `https://api.twitter.com/1.1/friendships/${action}.json`,
@@ -77,7 +74,7 @@ export function withFollow<TBase extends AbstractConstructor<TwitterClientBase>>
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
// Parse error response // Parse error response
try { try {
const errorData = JSON.parse(text) as { errors?: Array<{ code: number; message: string }> }; const errorData = JSON.parse(text) as { errors?: Array<{ code: number; message: string }> };
@@ -138,70 +135,98 @@ export function withFollow<TBase extends AbstractConstructor<TwitterClientBase>>
private async followViaGraphQL(userId: string, follow: boolean): Promise<FollowMutationResult> { private async followViaGraphQL(userId: string, follow: boolean): Promise<FollowMutationResult> {
const operationName = follow ? 'CreateFriendship' : 'DestroyFriendship'; const operationName = follow ? 'CreateFriendship' : 'DestroyFriendship';
// Known query IDs for friendship operations
const queryIds = follow
? ['8h9JVdV8dlSyqyRDJEPCsA', 'OPwKc1HXnBT_bWXfAlo-9g']
: ['8h9JVdV8dlSyqyRDJEPCsA', 'ppXWuagMNXgvzx6WoXBW0Q'];
const variables = { const variables = {
user_id: userId, user_id: userId,
}; };
let lastError: string | undefined; const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getFollowQueryIds(follow);
for (const queryId of queryIds) { for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/${operationName}`; const url = `${TWITTER_API_BASE}/${queryId}/${operationName}`;
try { try {
const response = await this.fetchWithTimeout(url, { const response = await this.fetchWithTimeout(url, {
method: 'POST', method: 'POST',
headers: this.getHeaders(), headers: this.getHeaders(),
body: JSON.stringify({ variables, queryId }), body: JSON.stringify({ variables, queryId }),
}); });
if (response.status === 404) { if (response.status === 404) {
lastError = `HTTP 404`; had404 = true;
continue; lastError = 'HTTP 404';
} continue;
}
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue; continue;
} }
const data = (await response.json()) as { const data = (await response.json()) as {
data?: { data?: {
user?: { user?: {
result?: { result?: {
rest_id?: string; rest_id?: string;
legacy?: { legacy?: {
screen_name?: string; screen_name?: string;
};
}; };
}; };
}; };
errors?: Array<{ message: string }>;
}; };
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
lastError = data.errors.map((e) => e.message).join(', '); lastError = data.errors.map((e) => e.message).join(', ');
continue; continue;
}
const result = data.data?.user?.result;
return {
success: true as const,
userId: result?.rest_id,
username: result?.legacy?.screen_name,
had404,
};
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
} }
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 as const,
error: lastError ?? `Unknown error during ${operationName}`,
had404,
};
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, userId: firstAttempt.userId, username: firstAttempt.username };
} }
return { success: false, error: lastError ?? `Unknown error during ${operationName}` }; if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, userId: secondAttempt.userId, username: secondAttempt.username };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
private async getFollowQueryIds(follow: boolean): Promise<string[]> {
const primary = await this.getQueryId(follow ? 'CreateFriendship' : 'DestroyFriendship');
const fallbacks = follow
? ['8h9JVdV8dlSyqyRDJEPCsA', 'OPwKc1HXnBT_bWXfAlo-9g']
: ['ppXWuagMNXgvzx6WoXBW0Q', '8h9JVdV8dlSyqyRDJEPCsA'];
return Array.from(new Set([primary, ...fallbacks]));
} }
} }
+3 -1
View File
@@ -36,7 +36,9 @@ const MixedTwitterClient = withNews(
withUsers( withUsers(
withLists( withLists(
withHome( withHome(
withTimelines(withSearch(withTweetDetails(withPosting(withFollow(withBookmarks(withMedia(TwitterClientBase))))))), withTimelines(
withSearch(withTweetDetails(withPosting(withFollow(withBookmarks(withMedia(TwitterClientBase)))))),
),
), ),
), ),
), ),
+54
View File
@@ -0,0 +1,54 @@
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerFollowCommands } from '../src/commands/follow.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
const baseCtx = {
resolveTimeoutFromOptions: () => undefined,
resolveCredentialsFromOptions: async () => ({
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' },
warnings: [],
}),
p: () => '',
} as unknown as CliContext;
afterEach(() => {
vi.restoreAllMocks();
});
describe('follow commands', () => {
it('prefers username lookup for numeric handles', async () => {
const program = new Command();
registerFollowCommands(program, baseCtx);
const lookupSpy = vi.spyOn(TwitterClient.prototype, 'getUserIdByUsername').mockResolvedValue({
success: true,
userId: '999',
username: '12345',
});
const followSpy = vi.spyOn(TwitterClient.prototype, 'follow').mockResolvedValue({ success: true });
vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'follow', '12345']);
expect(lookupSpy).toHaveBeenCalledWith('12345');
expect(followSpy).toHaveBeenCalledWith('999');
});
it('falls back to numeric user IDs when lookup fails', async () => {
const program = new Command();
registerFollowCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getUserIdByUsername').mockResolvedValue({
success: false,
error: 'User not found',
});
const followSpy = vi.spyOn(TwitterClient.prototype, 'follow').mockResolvedValue({ success: true });
vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'follow', '12345']);
expect(followSpy).toHaveBeenCalledWith('12345');
});
});
+13 -7
View File
@@ -149,15 +149,21 @@ d('live CLI (Twitter/X)', () => {
expect(whoamiStdout).toContain('credentials:'); expect(whoamiStdout).toContain('credentials:');
}); });
it('about returns account JSON', async () => { it('follow/unfollow works (opt-in)', async () => {
const aboutHandle = (process.env.BIRD_LIVE_ABOUT_HANDLE ?? handle).trim() || handle; const followHandle = (process.env.BIRD_LIVE_FOLLOW_HANDLE ?? '').trim();
const about = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'about', aboutHandle, '--json'], { if (!followHandle) {
return;
}
const follow = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'follow', followHandle], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(about.exitCode).toBe(0); expect(follow.exitCode).toBe(0);
const payload = parseJson<Record<string, unknown>>(about.stdout);
expect(Array.isArray(payload)).toBe(false); const unfollow = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'unfollow', followHandle], {
expect(Object.keys(payload).length).toBeGreaterThan(0); timeoutMs: 45_000,
});
expect(unfollow.exitCode).toBe(0);
}); });
it('read returns tweet JSON', async () => { it('read returns tweet JSON', async () => {