From 1007d051e44c416c89e4d5514b77947daf9a97a2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 26 Dec 2025 21:57:36 +0100 Subject: [PATCH] fix(tweet): fallback to statuses/update on 226 --- CHANGELOG.md | 3 + README.md | 11 ++- src/lib/cookies.ts | 98 +++++++++++++-------- src/lib/twitter-client.ts | 164 +++++++++++++++++++++++++++++++++-- tests/cookies.test.ts | 5 ++ tests/twitter-client.test.ts | 153 +++++++++++++++++++++++++++++++- 6 files changed, 385 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 125470f..44e845a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index bea4d77..f6ad167 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index 3274c39..bc03729 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -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 { + 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, + 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, 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, 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 = {}; 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 = {}; + // 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 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 = {}; + + 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 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 }; } diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index ff5e5ab..0cf39ab 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -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 { @@ -343,19 +352,34 @@ export class TwitterClient { return this.getJsonHeaders(); } + private createTransactionId(): string { + return randomBytes(16).toString('hex'); + } + private getBaseHeaders(): Record { - return { + const headers: Record = { + 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 { @@ -1278,6 +1302,7 @@ export class TwitterClient { variables: Record, features: Record, ): Promise { + 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): { + 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) + ? (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 { + 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, + ): Promise { + 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 { + 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, }, }); diff --git a/tests/cookies.test.ts b/tests/cookies.test.ts index 9637762..145c624 100644 --- a/tests/cookies.test.ts +++ b/tests/cookies.test.ts @@ -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'); }); diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index 2920d92..5a17e9f 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -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', () => {