fix: follow/unfollow hardening (#54) (thanks @citizenlee)
This commit is contained in:
+1
-1
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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`,
|
||||||
@@ -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]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)))))),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user