This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
import { randomBytes, randomUUID } from 'node:crypto';
|
|
||||||
import { runtimeQueryIds } from './runtime-query-ids.js';
|
import { runtimeQueryIds } from './runtime-query-ids.js';
|
||||||
import { type OperationName, QUERY_IDS, TARGET_QUERY_ID_OPERATIONS } from './twitter-client-constants.js';
|
import { type OperationName, QUERY_IDS, TARGET_QUERY_ID_OPERATIONS } from './twitter-client-constants.js';
|
||||||
import type { CurrentUserResult, TwitterClientOptions } from './twitter-client-types.js';
|
import type { CurrentUserResult, TwitterClientOptions } from './twitter-client-types.js';
|
||||||
@@ -16,22 +15,14 @@ export type Mixin<TBase extends AbstractConstructor<TwitterClientBase>, TAdded>
|
|||||||
|
|
||||||
export abstract class TwitterClientBase {
|
export abstract class TwitterClientBase {
|
||||||
protected relayBaseUrl: string;
|
protected relayBaseUrl: string;
|
||||||
protected userAgent: string;
|
|
||||||
protected timeoutMs?: number;
|
protected timeoutMs?: number;
|
||||||
protected quoteDepth: number;
|
protected quoteDepth: number;
|
||||||
protected clientUuid: string;
|
|
||||||
protected clientDeviceId: string;
|
|
||||||
protected clientUserId?: string;
|
protected clientUserId?: string;
|
||||||
|
|
||||||
constructor(options: TwitterClientOptions) {
|
constructor(options: TwitterClientOptions) {
|
||||||
this.relayBaseUrl = this.normalizeRelayBaseUrl(options.relayBaseUrl ?? process.env.TWITTER_RELAY_BASE_URL);
|
this.relayBaseUrl = this.normalizeRelayBaseUrl(options.relayBaseUrl ?? process.env.TWITTER_RELAY_BASE_URL);
|
||||||
this.userAgent =
|
|
||||||
options.userAgent ||
|
|
||||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
||||||
this.timeoutMs = options.timeoutMs;
|
this.timeoutMs = options.timeoutMs;
|
||||||
this.quoteDepth = normalizeQuoteDepth(options.quoteDepth);
|
this.quoteDepth = normalizeQuoteDepth(options.quoteDepth);
|
||||||
this.clientUuid = randomUUID();
|
|
||||||
this.clientDeviceId = randomUUID();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract getCurrentUser(): Promise<CurrentUserResult>;
|
protected abstract getCurrentUser(): Promise<CurrentUserResult>;
|
||||||
@@ -82,11 +73,7 @@ export abstract class TwitterClientBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected getHeaders(): Record<string, string> {
|
protected getHeaders(): Record<string, string> {
|
||||||
return this.getJsonHeaders();
|
return {};
|
||||||
}
|
|
||||||
|
|
||||||
protected createTransactionId(): string {
|
|
||||||
return randomBytes(16).toString('hex');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeRelayBaseUrl(value: string | undefined): string {
|
private normalizeRelayBaseUrl(value: string | undefined): string {
|
||||||
@@ -105,37 +92,12 @@ export abstract class TwitterClientBase {
|
|||||||
return `${this.relayBaseUrl}${parsed.pathname}${parsed.search}`;
|
return `${this.relayBaseUrl}${parsed.pathname}${parsed.search}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getBaseHeaders(): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
accept: '*/*',
|
|
||||||
'accept-language': 'en-US,en;q=0.9',
|
|
||||||
'x-twitter-active-user': 'yes',
|
|
||||||
'x-twitter-client-language': 'en',
|
|
||||||
'x-client-uuid': this.clientUuid,
|
|
||||||
'x-twitter-client-deviceid': this.clientDeviceId,
|
|
||||||
'x-client-transaction-id': this.createTransactionId(),
|
|
||||||
'user-agent': this.userAgent,
|
|
||||||
origin: 'https://x.com',
|
|
||||||
referer: 'https://x.com/',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.clientUserId) {
|
|
||||||
headers['x-twitter-client-user-id'] = this.clientUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getJsonHeaders(): Record<string, string> {
|
protected getJsonHeaders(): Record<string, string> {
|
||||||
return {
|
return { 'content-type': 'application/json' };
|
||||||
...this.getBaseHeaders(),
|
|
||||||
'content-type': 'application/json',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getUploadHeaders(): Record<string, string> {
|
protected getUploadHeaders(): Record<string, string> {
|
||||||
// Note: do not set content-type; URLSearchParams/FormData need to set it (incl boundary) themselves.
|
return {};
|
||||||
return this.getBaseHeaders();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async ensureClientUserId(): Promise<void> {
|
protected async ensureClientUserId(): Promise<void> {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export function withBookmarks<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteBookmark`;
|
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteBookmark`;
|
||||||
|
|
||||||
const buildBody = () => JSON.stringify({ variables, queryId });
|
const buildBody = () => JSON.stringify({ variables, queryId });
|
||||||
const buildHeaders = () => ({ ...this.getHeaders(), referer: `https://x.com/i/status/${tweetId}` });
|
|
||||||
let body = buildBody();
|
let body = buildBody();
|
||||||
|
|
||||||
const parseResponse = async (response: Response): Promise<BookmarkMutationResult> => {
|
const parseResponse = async (response: Response): Promise<BookmarkMutationResult> => {
|
||||||
@@ -42,7 +41,7 @@ export function withBookmarks<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
try {
|
try {
|
||||||
let response = await this.fetchWithTimeout(urlWithOperation, {
|
let response = await this.fetchWithTimeout(urlWithOperation, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: buildHeaders(),
|
headers: this.getJsonHeaders(),
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,14 +53,14 @@ export function withBookmarks<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
|
|
||||||
response = await this.fetchWithTimeout(urlWithOperation, {
|
response = await this.fetchWithTimeout(urlWithOperation, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: buildHeaders(),
|
headers: this.getJsonHeaders(),
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
|
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: buildHeaders(),
|
headers: this.getJsonHeaders(),
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function withPosting<TBase extends AbstractConstructor<TwitterClientBase>
|
|||||||
let body = buildBody();
|
let body = buildBody();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers = { ...this.getHeaders(), referer: 'https://x.com/compose/post' };
|
const headers = this.getJsonHeaders();
|
||||||
let response = await this.fetchWithTimeout(urlWithOperation, {
|
let response = await this.fetchWithTimeout(urlWithOperation, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -88,14 +88,14 @@ export function withPosting<TBase extends AbstractConstructor<TwitterClientBase>
|
|||||||
|
|
||||||
response = await this.fetchWithTimeout(urlWithOperation, {
|
response = await this.fetchWithTimeout(urlWithOperation, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' },
|
headers,
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
|
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' },
|
headers,
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,12 +224,8 @@ export function withPosting<TBase extends AbstractConstructor<TwitterClientBase>
|
|||||||
try {
|
try {
|
||||||
const response = await this.fetchWithTimeout(TWITTER_STATUS_UPDATE_URL, {
|
const response = await this.fetchWithTimeout(TWITTER_STATUS_UPDATE_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: this.getHeaders(),
|
||||||
...this.getBaseHeaders(),
|
body: params,
|
||||||
'content-type': 'application/x-www-form-urlencoded',
|
|
||||||
referer: 'https://x.com/compose/post',
|
|
||||||
},
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
|
|||||||
try {
|
try {
|
||||||
const response = await this.fetchWithTimeout(url, {
|
const response = await this.fetchWithTimeout(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.getHeaders(),
|
headers: this.getJsonHeaders(),
|
||||||
body: JSON.stringify({ features, queryId }),
|
body: JSON.stringify({ features, queryId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
|
|||||||
|
|
||||||
const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, {
|
const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.getHeaders(),
|
headers: this.getJsonHeaders(),
|
||||||
body: JSON.stringify({ variables, features, queryId }),
|
body: JSON.stringify({ variables, features, queryId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,6 @@ export interface FollowingResult {
|
|||||||
|
|
||||||
export interface TwitterClientOptions {
|
export interface TwitterClientOptions {
|
||||||
relayBaseUrl?: string;
|
relayBaseUrl?: string;
|
||||||
userAgent?: string;
|
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
// Max depth for quoted tweets (0 disables). Defaults to 1.
|
// Max depth for quoted tweets (0 disables). Defaults to 1.
|
||||||
quoteDepth?: number;
|
quoteDepth?: number;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type TwitterClientPrivate = TwitterClient & {
|
|||||||
refreshQueryIds: () => Promise<void>;
|
refreshQueryIds: () => Promise<void>;
|
||||||
fetchWithTimeout: (url: string, init: RequestInit) => Promise<ResponseLike>;
|
fetchWithTimeout: (url: string, init: RequestInit) => Promise<ResponseLike>;
|
||||||
ensureClientUserId: () => Promise<void>;
|
ensureClientUserId: () => Promise<void>;
|
||||||
getBaseHeaders: () => Record<string, string>;
|
getHeaders: () => Record<string, string>;
|
||||||
fetchUserArticlePlainText: (userId: string, tweetId: string) => Promise<{ title?: string; plainText?: string }>;
|
fetchUserArticlePlainText: (userId: string, tweetId: string) => Promise<{ title?: string; plainText?: string }>;
|
||||||
fetchTweetDetail: (tweetId: string) => Promise<{ success: true } | { success: false; error: string }>;
|
fetchTweetDetail: (tweetId: string) => Promise<{ success: true } | { success: false; error: string }>;
|
||||||
postStatusUpdate: (input: { text: string }) => Promise<{ success: boolean; error?: string }>;
|
postStatusUpdate: (input: { text: string }) => Promise<{ success: boolean; error?: string }>;
|
||||||
@@ -108,7 +108,7 @@ describe('TwitterClient core coverage', () => {
|
|||||||
expect(result.error).toBe('Tweet not found in response');
|
expect(result.error).toBe('Tweet not found in response');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds client user id header after ensureClientUserId', async () => {
|
it('does not add Twitter headers after ensureClientUserId', async () => {
|
||||||
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
|
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
@@ -119,9 +119,9 @@ describe('TwitterClient core coverage', () => {
|
|||||||
|
|
||||||
const clientPrivate = client as unknown as TwitterClientPrivate;
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
await clientPrivate.ensureClientUserId();
|
await clientPrivate.ensureClientUserId();
|
||||||
const headers = clientPrivate.getBaseHeaders();
|
const headers = clientPrivate.getHeaders();
|
||||||
|
|
||||||
expect(headers['x-twitter-client-user-id']).toBe('42');
|
expect(headers).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchUserArticlePlainText failures', () => {
|
describe('fetchUserArticlePlainText failures', () => {
|
||||||
|
|||||||
@@ -68,9 +68,7 @@ describe('TwitterClient tweet', () => {
|
|||||||
expect(String(url)).toContain(`${testRelayBaseUrl}/i/api/graphql/`);
|
expect(String(url)).toContain(`${testRelayBaseUrl}/i/api/graphql/`);
|
||||||
expect(String(url)).toContain('CreateTweet');
|
expect(String(url)).toContain('CreateTweet');
|
||||||
expect(options.method).toBe('POST');
|
expect(options.method).toBe('POST');
|
||||||
expect(options.headers).not.toHaveProperty('cookie');
|
expect(options.headers).toEqual({ 'content-type': 'application/json' });
|
||||||
expect(options.headers).not.toHaveProperty('authorization');
|
|
||||||
expect(options.headers).not.toHaveProperty('x-csrf-token');
|
|
||||||
|
|
||||||
const body = JSON.parse(options.body);
|
const body = JSON.parse(options.body);
|
||||||
expect(body.variables.tweet_text).toBe('Hello world!');
|
expect(body.variables.tweet_text).toBe('Hello world!');
|
||||||
@@ -392,9 +390,10 @@ describe('TwitterClient reply', () => {
|
|||||||
const [, options] = mockFetch.mock.calls[1];
|
const [, options] = mockFetch.mock.calls[1];
|
||||||
expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json');
|
expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json');
|
||||||
expect(options.method).toBe('POST');
|
expect(options.method).toBe('POST');
|
||||||
expect(options.body).toContain('status=This+is+a+reply');
|
const body = String(options.body);
|
||||||
expect(options.body).toContain('in_reply_to_status_id=1234567890');
|
expect(body).toContain('status=This+is+a+reply');
|
||||||
expect(options.body).toContain('auto_populate_reply_metadata=true');
|
expect(body).toContain('in_reply_to_status_id=1234567890');
|
||||||
expect(options.body).toContain('media_ids=111%2C222');
|
expect(body).toContain('auto_populate_reply_metadata=true');
|
||||||
|
expect(body).toContain('media_ids=111%2C222');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user