fix: follow/unfollow hardening (#54) (thanks @citizenlee)
This commit is contained in:
+18
-13
@@ -3,30 +3,35 @@ import type { CliContext } from '../cli/shared.js';
|
||||
import { normalizeHandle } from '../lib/normalize-handle.js';
|
||||
import { TwitterClient } from '../lib/twitter-client.js';
|
||||
|
||||
const ONLY_DIGITS_REGEX = /^\d+$/;
|
||||
|
||||
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 };
|
||||
}
|
||||
const raw = usernameOrId.trim();
|
||||
const isNumeric = ONLY_DIGITS_REGEX.test(raw);
|
||||
|
||||
// Otherwise, treat as username and look up
|
||||
const handle = normalizeHandle(usernameOrId);
|
||||
if (!handle) {
|
||||
console.error(`${ctx.p('err')}Invalid username: ${usernameOrId}`);
|
||||
return null;
|
||||
const handle = normalizeHandle(raw);
|
||||
if (handle) {
|
||||
const lookup = await client.getUserIdByUsername(handle);
|
||||
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 (!lookup.success || !lookup.userId) {
|
||||
console.error(`${ctx.p('err')}Failed to find user @${handle}: ${lookup.error ?? 'Unknown error'}`);
|
||||
return null;
|
||||
if (isNumeric) {
|
||||
return { userId: raw };
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"CreateTweet": "nmdAQXJDxw6-0KKF2on7eA",
|
||||
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
|
||||
"CreateFriendship": "8h9JVdV8dlSyqyRDJEPCsA",
|
||||
"DestroyFriendship": "ppXWuagMNXgvzx6WoXBW0Q",
|
||||
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
||||
"DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
|
||||
"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 = {
|
||||
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
|
||||
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
||||
CreateFriendship: '8h9JVdV8dlSyqyRDJEPCsA',
|
||||
DestroyFriendship: 'ppXWuagMNXgvzx6WoXBW0Q',
|
||||
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
||||
DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ',
|
||||
TweetDetail: '97JF30KziU00483E_8elBA',
|
||||
|
||||
@@ -48,10 +48,7 @@ export function withFollow<TBase extends AbstractConstructor<TwitterClientBase>>
|
||||
return this.followViaGraphQL(userId, false);
|
||||
}
|
||||
|
||||
private async followViaRest(
|
||||
userId: string,
|
||||
action: 'create' | 'destroy',
|
||||
): Promise<FollowMutationResult> {
|
||||
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`,
|
||||
@@ -77,7 +74,7 @@ export function withFollow<TBase extends AbstractConstructor<TwitterClientBase>>
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
|
||||
// Parse error response
|
||||
try {
|
||||
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> {
|
||||
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;
|
||||
const tryOnce = async () => {
|
||||
let lastError: string | undefined;
|
||||
let had404 = false;
|
||||
const queryIds = await this.getFollowQueryIds(follow);
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/${operationName}`;
|
||||
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 }),
|
||||
});
|
||||
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.status === 404) {
|
||||
had404 = true;
|
||||
lastError = 'HTTP 404';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
|
||||
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;
|
||||
const data = (await response.json()) as {
|
||||
data?: {
|
||||
user?: {
|
||||
result?: {
|
||||
rest_id?: string;
|
||||
legacy?: {
|
||||
screen_name?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
lastError = data.errors.map((e) => e.message).join(', ');
|
||||
continue;
|
||||
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 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(
|
||||
withLists(
|
||||
withHome(
|
||||
withTimelines(withSearch(withTweetDetails(withPosting(withFollow(withBookmarks(withMedia(TwitterClientBase))))))),
|
||||
withTimelines(
|
||||
withSearch(withTweetDetails(withPosting(withFollow(withBookmarks(withMedia(TwitterClientBase)))))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user