fix(tweet): fallback to statuses/update on 226
This commit is contained in:
@@ -5,6 +5,9 @@
|
||||
### Added
|
||||
- Cookie source selection: `--cookie-source safari|chrome|firefox` (repeatable) + `cookieSource` config (string or array).
|
||||
|
||||
### Fixed
|
||||
- `tweet`/`reply`: fallback to `statuses/update.json` when GraphQL `CreateTweet` returns error 226 (“automated request”).
|
||||
|
||||
### Breaking
|
||||
- Remove `allowSafari`/`allowChrome`/`allowFirefox` config toggles in favor of `cookieSource` ordering.
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
npm install -g @steipete/bird
|
||||
# or
|
||||
pnpm add -g @steipete/bird
|
||||
# or
|
||||
bun add -g @steipete/bird
|
||||
|
||||
# one-shot (no install)
|
||||
bunx @steipete/bird whoami
|
||||
```
|
||||
|
||||
Homebrew (macOS, prebuilt Bun binary):
|
||||
@@ -69,6 +74,10 @@ Global options:
|
||||
GraphQL mode uses your existing X/Twitter web session (no password prompt). It sends requests to internal
|
||||
X endpoints and authenticates via cookies (`auth_token`, `ct0`).
|
||||
|
||||
Write operations:
|
||||
- `tweet`/`reply` primarily use GraphQL (`CreateTweet`).
|
||||
- If GraphQL returns error `226` (“automated request”), `bird` falls back to the legacy `statuses/update.json` endpoint.
|
||||
|
||||
`bird` resolves credentials in this order:
|
||||
|
||||
1. CLI flags: `--auth-token`, `--ct0`
|
||||
@@ -172,4 +181,4 @@ pnpm run lint
|
||||
## Notes
|
||||
|
||||
- GraphQL uses internal X endpoints and can be rate limited (429).
|
||||
- Query IDs rotate; refresh them with `pnpm run graphql:update`.
|
||||
- Query IDs rotate; refresh at runtime with `bird query-ids --fresh` (or update the baked baseline via `pnpm run graphql:update`).
|
||||
|
||||
+60
-38
@@ -12,6 +12,7 @@ import { join } from 'node:path';
|
||||
export interface TwitterCookies {
|
||||
authToken: string | null;
|
||||
ct0: string | null;
|
||||
cookieHeader: string | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
@@ -86,7 +87,6 @@ function getSafariCookiesPath(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const SAFARI_COOKIE_NAMES = new Set(['auth_token', 'ct0']);
|
||||
const SAFARI_COOKIE_DOMAINS = ['x.com', 'twitter.com'];
|
||||
const SAFARI_PAGE_SIGNATURE = Buffer.from([0x00, 0x00, 0x01, 0x00]);
|
||||
|
||||
@@ -104,7 +104,19 @@ function readSafariCString(buffer: Buffer, start: number, end: number): string |
|
||||
return buffer.toString('utf8', start, cursor);
|
||||
}
|
||||
|
||||
function parseSafariCookieRecord(page: Buffer, offset: number, cookies: TwitterCookies): void {
|
||||
function serializeCookieJar(jar: Record<string, string>): string {
|
||||
const entries = Object.entries(jar)
|
||||
.filter(([, value]) => typeof value === 'string' && value.length > 0)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
return entries.map(([name, value]) => `${name}=${value}`).join('; ');
|
||||
}
|
||||
|
||||
function parseSafariCookieRecord(
|
||||
page: Buffer,
|
||||
offset: number,
|
||||
jar: Record<string, string>,
|
||||
cookies: TwitterCookies,
|
||||
): void {
|
||||
if (offset < 0 || offset + 4 > page.length) return;
|
||||
const recordSize = page.readUInt32LE(offset);
|
||||
const recordEnd = offset + recordSize;
|
||||
@@ -123,11 +135,12 @@ function parseSafariCookieRecord(page: Buffer, offset: number, cookies: TwitterC
|
||||
const value = readSafariCString(page, offset + valueOffset, recordEnd);
|
||||
|
||||
if (!name || !value || !matchesSafariDomain(domain)) return;
|
||||
if (!SAFARI_COOKIE_NAMES.has(name)) return;
|
||||
|
||||
const normalizedValue = normalizeValue(value);
|
||||
if (!normalizedValue) return;
|
||||
|
||||
jar[name] = normalizedValue;
|
||||
|
||||
if (name === 'auth_token' && !cookies.authToken) {
|
||||
cookies.authToken = normalizedValue;
|
||||
} else if (name === 'ct0' && !cookies.ct0) {
|
||||
@@ -135,7 +148,7 @@ function parseSafariCookieRecord(page: Buffer, offset: number, cookies: TwitterC
|
||||
}
|
||||
}
|
||||
|
||||
function parseSafariCookiePage(page: Buffer, cookies: TwitterCookies): void {
|
||||
function parseSafariCookiePage(page: Buffer, jar: Record<string, string>, cookies: TwitterCookies): void {
|
||||
if (page.length < 12) return;
|
||||
if (!page.subarray(0, 4).equals(SAFARI_PAGE_SIGNATURE)) return;
|
||||
const cookieCount = page.readUInt32LE(4);
|
||||
@@ -150,12 +163,11 @@ function parseSafariCookiePage(page: Buffer, cookies: TwitterCookies): void {
|
||||
}
|
||||
|
||||
for (const offset of offsets) {
|
||||
parseSafariCookieRecord(page, offset, cookies);
|
||||
if (cookies.authToken && cookies.ct0) return;
|
||||
parseSafariCookieRecord(page, offset, jar, cookies);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSafariCookies(data: Buffer, cookies: TwitterCookies): void {
|
||||
function parseSafariCookies(data: Buffer, jar: Record<string, string>, cookies: TwitterCookies): void {
|
||||
if (data.length < 8) return;
|
||||
if (data.subarray(0, 4).toString('utf8') !== 'cook') return;
|
||||
const pageCount = data.readUInt32BE(4);
|
||||
@@ -170,8 +182,7 @@ function parseSafariCookies(data: Buffer, cookies: TwitterCookies): void {
|
||||
for (const pageSize of pageSizes) {
|
||||
if (cursor + pageSize > data.length) return;
|
||||
const page = data.subarray(cursor, cursor + pageSize);
|
||||
parseSafariCookiePage(page, cookies);
|
||||
if (cookies.authToken && cookies.ct0) return;
|
||||
parseSafariCookiePage(page, jar, cookies);
|
||||
cursor += pageSize;
|
||||
}
|
||||
}
|
||||
@@ -184,6 +195,7 @@ export async function extractCookiesFromSafari(): Promise<CookieExtractionResult
|
||||
const cookies: TwitterCookies = {
|
||||
authToken: null,
|
||||
ct0: null,
|
||||
cookieHeader: null,
|
||||
source: null,
|
||||
};
|
||||
|
||||
@@ -196,11 +208,15 @@ export async function extractCookiesFromSafari(): Promise<CookieExtractionResult
|
||||
let tempDir: string | null = null;
|
||||
|
||||
try {
|
||||
const jar: Record<string, string> = {};
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
|
||||
const tempCookiesPath = join(tempDir, 'Cookies.binarycookies');
|
||||
copyFileSync(cookiesPath, tempCookiesPath);
|
||||
const data = readFileSync(tempCookiesPath);
|
||||
parseSafariCookies(data, cookies);
|
||||
parseSafariCookies(data, jar, cookies);
|
||||
if (Object.keys(jar).length > 0) {
|
||||
cookies.cookieHeader = serializeCookieJar(jar);
|
||||
}
|
||||
|
||||
if (cookies.authToken || cookies.ct0) {
|
||||
cookies.source = 'Safari';
|
||||
@@ -293,6 +309,7 @@ export async function extractCookiesFromChrome(profile?: string): Promise<Cookie
|
||||
const cookies: TwitterCookies = {
|
||||
authToken: null,
|
||||
ct0: null,
|
||||
cookieHeader: null,
|
||||
source: null,
|
||||
};
|
||||
|
||||
@@ -321,8 +338,10 @@ export async function extractCookiesFromChrome(profile?: string): Promise<Cookie
|
||||
copyFileSync(shmPath, `${tempDbPath}-shm`);
|
||||
}
|
||||
|
||||
const jar: Record<string, string> = {};
|
||||
|
||||
// Use sqlite3 CLI to query cookies (no native deps required!)
|
||||
const query = `SELECT name, hex(encrypted_value) as encrypted_hex FROM cookies WHERE host_key IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`;
|
||||
const query = `SELECT name, hex(encrypted_value) as encrypted_hex FROM cookies WHERE host_key IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com');`;
|
||||
|
||||
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
|
||||
encoding: 'utf8',
|
||||
@@ -336,6 +355,7 @@ export async function extractCookiesFromChrome(profile?: string): Promise<Cookie
|
||||
|
||||
const decryptedValue = decryptCookieValue(encryptedHex);
|
||||
if (decryptedValue) {
|
||||
jar[name] = decryptedValue;
|
||||
if (name === 'auth_token' && !cookies.authToken) {
|
||||
cookies.authToken = decryptedValue;
|
||||
} else if (name === 'ct0' && !cookies.ct0) {
|
||||
@@ -345,6 +365,10 @@ export async function extractCookiesFromChrome(profile?: string): Promise<Cookie
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(jar).length > 0) {
|
||||
cookies.cookieHeader = serializeCookieJar(jar);
|
||||
}
|
||||
|
||||
if (cookies.authToken || cookies.ct0) {
|
||||
cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
|
||||
}
|
||||
@@ -378,6 +402,7 @@ export async function extractCookiesFromFirefox(profile?: string): Promise<Cooki
|
||||
const cookies: TwitterCookies = {
|
||||
authToken: null,
|
||||
ct0: null,
|
||||
cookieHeader: null,
|
||||
source: null,
|
||||
};
|
||||
|
||||
@@ -395,7 +420,9 @@ export async function extractCookiesFromFirefox(profile?: string): Promise<Cooki
|
||||
const tempDbPath = join(tempDir, 'cookies.sqlite');
|
||||
copyFileSync(cookiesPath, tempDbPath);
|
||||
|
||||
const query = `SELECT name, value FROM moz_cookies WHERE host IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`;
|
||||
const jar: Record<string, string> = {};
|
||||
|
||||
const query = `SELECT name, value FROM moz_cookies WHERE host IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com');`;
|
||||
|
||||
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
|
||||
encoding: 'utf8',
|
||||
@@ -406,6 +433,7 @@ export async function extractCookiesFromFirefox(profile?: string): Promise<Cooki
|
||||
for (const line of result.split('\n')) {
|
||||
const [name, value] = line.split('|');
|
||||
if (!name || !value) continue;
|
||||
jar[name] = value;
|
||||
if (name === 'auth_token' && !cookies.authToken) {
|
||||
cookies.authToken = value;
|
||||
} else if (name === 'ct0' && !cookies.ct0) {
|
||||
@@ -414,6 +442,10 @@ export async function extractCookiesFromFirefox(profile?: string): Promise<Cooki
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(jar).length > 0) {
|
||||
cookies.cookieHeader = serializeCookieJar(jar);
|
||||
}
|
||||
|
||||
if (cookies.authToken || cookies.ct0) {
|
||||
cookies.source = profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
|
||||
}
|
||||
@@ -454,6 +486,7 @@ export async function resolveCredentials(options: {
|
||||
const cookies: TwitterCookies = {
|
||||
authToken: null,
|
||||
ct0: null,
|
||||
cookieHeader: null,
|
||||
source: null,
|
||||
};
|
||||
|
||||
@@ -495,6 +528,11 @@ export async function resolveCredentials(options: {
|
||||
}
|
||||
}
|
||||
|
||||
if (cookies.authToken && cookies.ct0) {
|
||||
cookies.cookieHeader = `auth_token=${cookies.authToken}; ct0=${cookies.ct0}`;
|
||||
return { cookies, warnings };
|
||||
}
|
||||
|
||||
const sourcesToTry: CookieSource[] = Array.isArray(cookieSource)
|
||||
? cookieSource
|
||||
: cookieSource
|
||||
@@ -502,19 +540,11 @@ export async function resolveCredentials(options: {
|
||||
: ['safari', 'chrome', 'firefox'];
|
||||
|
||||
for (const source of sourcesToTry) {
|
||||
if (cookies.authToken && cookies.ct0) break;
|
||||
|
||||
if (source === 'safari') {
|
||||
const safariResult = await extractCookiesFromSafari();
|
||||
warnings.push(...safariResult.warnings);
|
||||
|
||||
if (!cookies.authToken && safariResult.cookies.authToken) {
|
||||
cookies.authToken = safariResult.cookies.authToken;
|
||||
cookies.source = safariResult.cookies.source;
|
||||
}
|
||||
if (!cookies.ct0 && safariResult.cookies.ct0) {
|
||||
cookies.ct0 = safariResult.cookies.ct0;
|
||||
if (!cookies.source) cookies.source = safariResult.cookies.source;
|
||||
if (safariResult.cookies.authToken && safariResult.cookies.ct0) {
|
||||
return { cookies: safariResult.cookies, warnings };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -522,14 +552,8 @@ export async function resolveCredentials(options: {
|
||||
if (source === 'chrome') {
|
||||
const chromeResult = await extractCookiesFromChrome(options.chromeProfile);
|
||||
warnings.push(...chromeResult.warnings);
|
||||
|
||||
if (!cookies.authToken && chromeResult.cookies.authToken) {
|
||||
cookies.authToken = chromeResult.cookies.authToken;
|
||||
cookies.source = chromeResult.cookies.source;
|
||||
}
|
||||
if (!cookies.ct0 && chromeResult.cookies.ct0) {
|
||||
cookies.ct0 = chromeResult.cookies.ct0;
|
||||
if (!cookies.source) cookies.source = chromeResult.cookies.source;
|
||||
if (chromeResult.cookies.authToken && chromeResult.cookies.ct0) {
|
||||
return { cookies: chromeResult.cookies, warnings };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -537,14 +561,8 @@ export async function resolveCredentials(options: {
|
||||
if (source === 'firefox') {
|
||||
const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile);
|
||||
warnings.push(...firefoxResult.warnings);
|
||||
|
||||
if (!cookies.authToken && firefoxResult.cookies.authToken) {
|
||||
cookies.authToken = firefoxResult.cookies.authToken;
|
||||
cookies.source = firefoxResult.cookies.source;
|
||||
}
|
||||
if (!cookies.ct0 && firefoxResult.cookies.ct0) {
|
||||
cookies.ct0 = firefoxResult.cookies.ct0;
|
||||
if (!cookies.source) cookies.source = firefoxResult.cookies.source;
|
||||
if (firefoxResult.cookies.authToken && firefoxResult.cookies.ct0) {
|
||||
return { cookies: firefoxResult.cookies, warnings };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -559,5 +577,9 @@ export async function resolveCredentials(options: {
|
||||
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox');
|
||||
}
|
||||
|
||||
if (cookies.authToken && cookies.ct0) {
|
||||
cookies.cookieHeader = `auth_token=${cookies.authToken}; ct0=${cookies.ct0}`;
|
||||
}
|
||||
|
||||
return { cookies, warnings };
|
||||
}
|
||||
|
||||
+156
-8
@@ -2,6 +2,7 @@
|
||||
* Twitter GraphQL API client for posting tweets and replies
|
||||
*/
|
||||
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import type { TwitterCookies } from './cookies.js';
|
||||
import queryIds from './query-ids.json' with { type: 'json' };
|
||||
import { runtimeQueryIds } from './runtime-query-ids.js';
|
||||
@@ -10,6 +11,7 @@ const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
|
||||
const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
|
||||
const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
|
||||
const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
|
||||
const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json';
|
||||
|
||||
// Query IDs rotate frequently; the values in query-ids.json are refreshed by
|
||||
// scripts/update-query-ids.ts. The fallback values keep the client usable if
|
||||
@@ -254,8 +256,12 @@ interface CreateTweetResponse {
|
||||
export class TwitterClient {
|
||||
private authToken: string;
|
||||
private ct0: string;
|
||||
private cookieHeader: string;
|
||||
private userAgent: string;
|
||||
private timeoutMs?: number;
|
||||
private clientUuid: string;
|
||||
private clientDeviceId: string;
|
||||
private clientUserId?: string;
|
||||
|
||||
constructor(options: TwitterClientOptions) {
|
||||
if (!options.cookies.authToken || !options.cookies.ct0) {
|
||||
@@ -263,10 +269,13 @@ export class TwitterClient {
|
||||
}
|
||||
this.authToken = options.cookies.authToken;
|
||||
this.ct0 = options.cookies.ct0;
|
||||
this.cookieHeader = options.cookies.cookieHeader || `auth_token=${this.authToken}; ct0=${this.ct0}`;
|
||||
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.clientUuid = randomUUID();
|
||||
this.clientDeviceId = randomUUID();
|
||||
}
|
||||
|
||||
private async getQueryId(operationName: OperationName): Promise<string> {
|
||||
@@ -343,19 +352,34 @@ export class TwitterClient {
|
||||
return this.getJsonHeaders();
|
||||
}
|
||||
|
||||
private createTransactionId(): string {
|
||||
return randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
private getBaseHeaders(): Record<string, string> {
|
||||
return {
|
||||
const headers: Record<string, string> = {
|
||||
accept: '*/*',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
authorization:
|
||||
'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
|
||||
'x-csrf-token': this.ct0,
|
||||
'x-twitter-auth-type': 'OAuth2Session',
|
||||
'x-twitter-active-user': 'yes',
|
||||
'x-twitter-client-language': 'en',
|
||||
cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`,
|
||||
'x-client-uuid': this.clientUuid,
|
||||
'x-twitter-client-deviceid': this.clientDeviceId,
|
||||
'x-client-transaction-id': this.createTransactionId(),
|
||||
cookie: this.cookieHeader,
|
||||
'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;
|
||||
}
|
||||
|
||||
private getJsonHeaders(): Record<string, string> {
|
||||
@@ -1278,6 +1302,7 @@ export class TwitterClient {
|
||||
variables: Record<string, unknown>,
|
||||
features: Record<string, boolean>,
|
||||
): Promise<TweetResult> {
|
||||
await this.ensureClientUserId();
|
||||
let queryId = await this.getQueryId('CreateTweet');
|
||||
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`;
|
||||
|
||||
@@ -1285,9 +1310,10 @@ export class TwitterClient {
|
||||
let body = buildBody();
|
||||
|
||||
try {
|
||||
const headers = { ...this.getHeaders(), referer: 'https://x.com/compose/post' };
|
||||
let response = await this.fetchWithTimeout(urlWithOperation, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
@@ -1301,14 +1327,14 @@ export class TwitterClient {
|
||||
|
||||
response = await this.fetchWithTimeout(urlWithOperation, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' },
|
||||
body,
|
||||
});
|
||||
|
||||
@@ -1320,7 +1346,9 @@ export class TwitterClient {
|
||||
const data = (await retry.json()) as CreateTweetResponse;
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
|
||||
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
|
||||
if (fallback) return fallback;
|
||||
return { success: false, error: this.formatErrors(data.errors) };
|
||||
}
|
||||
|
||||
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
|
||||
@@ -1341,9 +1369,11 @@ export class TwitterClient {
|
||||
const data = (await response.json()) as CreateTweetResponse;
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
|
||||
if (fallback) return fallback;
|
||||
return {
|
||||
success: false,
|
||||
error: data.errors.map((e) => e.message).join(', '),
|
||||
error: this.formatErrors(data.errors),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1367,6 +1397,123 @@ export class TwitterClient {
|
||||
}
|
||||
}
|
||||
|
||||
private formatErrors(errors: Array<{ message: string; code?: number }>): string {
|
||||
return errors
|
||||
.map((error) => (typeof error.code === 'number' ? `${error.message} (${error.code})` : error.message))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
private statusUpdateInputFromCreateTweetVariables(variables: Record<string, unknown>): {
|
||||
text: string;
|
||||
inReplyToTweetId?: string;
|
||||
mediaIds?: string[];
|
||||
} | null {
|
||||
const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null;
|
||||
if (!text) return null;
|
||||
|
||||
const reply = variables.reply;
|
||||
const inReplyToTweetId =
|
||||
reply &&
|
||||
typeof reply === 'object' &&
|
||||
typeof (reply as { in_reply_to_tweet_id?: unknown }).in_reply_to_tweet_id === 'string'
|
||||
? (reply as { in_reply_to_tweet_id: string }).in_reply_to_tweet_id
|
||||
: undefined;
|
||||
|
||||
const media = variables.media;
|
||||
const mediaEntities =
|
||||
media && typeof media === 'object' ? (media as { media_entities?: unknown }).media_entities : undefined;
|
||||
|
||||
const mediaIds = Array.isArray(mediaEntities)
|
||||
? mediaEntities
|
||||
.map((entity) =>
|
||||
entity && typeof entity === 'object' && 'media_id' in (entity as Record<string, unknown>)
|
||||
? (entity as { media_id?: unknown }).media_id
|
||||
: undefined,
|
||||
)
|
||||
.filter((value): value is string | number => typeof value === 'string' || typeof value === 'number')
|
||||
.map((value) => String(value))
|
||||
: undefined;
|
||||
|
||||
return { text, inReplyToTweetId, mediaIds: mediaIds && mediaIds.length > 0 ? mediaIds : undefined };
|
||||
}
|
||||
|
||||
private async postStatusUpdate(input: {
|
||||
text: string;
|
||||
inReplyToTweetId?: string;
|
||||
mediaIds?: string[];
|
||||
}): Promise<TweetResult> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('status', input.text);
|
||||
if (input.inReplyToTweetId) {
|
||||
params.set('in_reply_to_status_id', input.inReplyToTweetId);
|
||||
params.set('auto_populate_reply_metadata', 'true');
|
||||
}
|
||||
if (input.mediaIds && input.mediaIds.length > 0) {
|
||||
params.set('media_ids', input.mediaIds.join(','));
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(TWITTER_STATUS_UPDATE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...this.getBaseHeaders(),
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
referer: 'https://x.com/compose/post',
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
id_str?: string;
|
||||
id?: string | number;
|
||||
errors?: Array<{ message: string; code?: number }>;
|
||||
};
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return { success: false, error: this.formatErrors(data.errors) };
|
||||
}
|
||||
|
||||
const tweetId =
|
||||
typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined;
|
||||
|
||||
if (tweetId) return { success: true, tweetId };
|
||||
return { success: false, error: 'Tweet created but no ID returned' };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
private async tryStatusUpdateFallback(
|
||||
errors: Array<{ message: string; code?: number }>,
|
||||
variables: Record<string, unknown>,
|
||||
): Promise<TweetResult | null> {
|
||||
if (!errors.some((error) => error.code === 226)) return null;
|
||||
const input = this.statusUpdateInputFromCreateTweetVariables(variables);
|
||||
if (!input) return null;
|
||||
|
||||
const fallback = await this.postStatusUpdate(input);
|
||||
if (fallback.success) return fallback;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `${this.formatErrors(errors)} | fallback: ${fallback.error ?? 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureClientUserId(): Promise<void> {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
if (this.clientUserId) return;
|
||||
const result = await this.getCurrentUser();
|
||||
if (result.success && result.user?.id) {
|
||||
this.clientUserId = result.user.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for tweets matching a query
|
||||
*/
|
||||
@@ -1543,6 +1690,7 @@ export class TwitterClient {
|
||||
: null;
|
||||
|
||||
if (username && userId) {
|
||||
this.clientUserId = userId;
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
@@ -1565,7 +1713,7 @@ export class TwitterClient {
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(page, {
|
||||
headers: {
|
||||
cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`,
|
||||
cookie: this.cookieHeader,
|
||||
'user-agent': this.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -160,6 +160,8 @@ describe('cookies', () => {
|
||||
const result = await resolveCredentials({ cookieSource: 'safari' });
|
||||
expect(result.cookies.authToken).toBe('safari_auth');
|
||||
expect(result.cookies.ct0).toBe('safari_ct0');
|
||||
expect(result.cookies.cookieHeader).toContain('auth_token=safari_auth');
|
||||
expect(result.cookies.cookieHeader).toContain('ct0=safari_ct0');
|
||||
expect(result.cookies.source).toBe('Safari');
|
||||
});
|
||||
|
||||
@@ -188,6 +190,8 @@ describe('cookies', () => {
|
||||
|
||||
expect(result.cookies.authToken).toBe('firefox_auth');
|
||||
expect(result.cookies.ct0).toBe('firefox_ct0');
|
||||
expect(result.cookies.cookieHeader).toContain('auth_token=firefox_auth');
|
||||
expect(result.cookies.cookieHeader).toContain('ct0=firefox_ct0');
|
||||
expect(result.cookies.source).toContain('Firefox');
|
||||
});
|
||||
|
||||
@@ -203,6 +207,7 @@ describe('cookies', () => {
|
||||
|
||||
expect(result.cookies.authToken).toBe('cli_auth');
|
||||
expect(result.cookies.ct0).toBe('cli_ct0');
|
||||
expect(result.cookies.cookieHeader).toBe('auth_token=cli_auth; ct0=cli_ct0');
|
||||
expect(result.cookies.source).toBe('CLI argument');
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ describe('TwitterClient', () => {
|
||||
const validCookies = {
|
||||
authToken: 'test_auth_token',
|
||||
ct0: 'test_ct0_token',
|
||||
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
|
||||
source: 'test',
|
||||
};
|
||||
|
||||
@@ -19,7 +20,7 @@ describe('TwitterClient', () => {
|
||||
expect(
|
||||
() =>
|
||||
new TwitterClient({
|
||||
cookies: { authToken: null, ct0: 'test', source: null },
|
||||
cookies: { authToken: null, ct0: 'test', cookieHeader: null, source: null },
|
||||
}),
|
||||
).toThrow('Both authToken and ct0 cookies are required');
|
||||
});
|
||||
@@ -28,7 +29,7 @@ describe('TwitterClient', () => {
|
||||
expect(
|
||||
() =>
|
||||
new TwitterClient({
|
||||
cookies: { authToken: 'test', ct0: null, source: null },
|
||||
cookies: { authToken: 'test', ct0: null, cookieHeader: null, source: null },
|
||||
}),
|
||||
).toThrow('Both authToken and ct0 cookies are required');
|
||||
});
|
||||
@@ -169,6 +170,118 @@ describe('TwitterClient', () => {
|
||||
expect(result.error).toContain('Rate limit exceeded');
|
||||
});
|
||||
|
||||
it('falls back to statuses/update.json when CreateTweet returns code 226', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
errors: [
|
||||
{
|
||||
message: 'Authorization: This request looks like it might be automated.',
|
||||
code: 226,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id_str: '1234567890',
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.tweet('Hello world!');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tweetId).toBe('1234567890');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json');
|
||||
});
|
||||
|
||||
it('surfaces statuses/update.json failure when CreateTweet returns code 226', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
errors: [
|
||||
{
|
||||
message: 'Authorization: This request looks like it might be automated.',
|
||||
code: 226,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => 'Forbidden',
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.tweet('Hello world!');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('(226)');
|
||||
expect(result.error).toContain('fallback: HTTP 403');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json');
|
||||
});
|
||||
|
||||
it('surfaces statuses/update.json API errors when CreateTweet returns code 226', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
errors: [
|
||||
{
|
||||
message: 'Authorization: This request looks like it might be automated.',
|
||||
code: 226,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
errors: [{ message: 'Nope', code: 999 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.tweet('Hello world!');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('(226)');
|
||||
expect(result.error).toContain('fallback: Nope (999)');
|
||||
});
|
||||
|
||||
it('surfaces statuses/update.json missing id when CreateTweet returns code 226', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
errors: [
|
||||
{
|
||||
message: 'Authorization: This request looks like it might be automated.',
|
||||
code: 226,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.tweet('Hello world!');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('(226)');
|
||||
expect(result.error).toContain('fallback: Tweet created but no ID returned');
|
||||
});
|
||||
|
||||
it('should handle HTTP errors', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
@@ -348,6 +461,42 @@ describe('TwitterClient', () => {
|
||||
expect(body.features.rweb_video_screen_enabled).toBe(true);
|
||||
expect(body.features.creator_subscriptions_tweet_preview_api_enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to statuses/update.json for replies when CreateTweet returns code 226', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
errors: [
|
||||
{
|
||||
message: 'Authorization: This request looks like it might be automated.',
|
||||
code: 226,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id_str: '999',
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.reply('This is a reply', '1234567890', ['111', '222']);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tweetId).toBe('999');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [, options] = mockFetch.mock.calls[1];
|
||||
expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toContain('status=This+is+a+reply');
|
||||
expect(options.body).toContain('in_reply_to_status_id=1234567890');
|
||||
expect(options.body).toContain('auto_populate_reply_metadata=true');
|
||||
expect(options.body).toContain('media_ids=111%2C222');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTweet', () => {
|
||||
|
||||
Reference in New Issue
Block a user