From 8257d52532759e330145f2b58c5801ce1942fd32 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 26 Dec 2025 13:10:49 +0100 Subject: [PATCH] fix(twitter): improve createTweet posting --- src/lib/twitter-client.ts | 42 ++++++++++++++++++++++++++++++------ tests/twitter-client.test.ts | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 8c16124..8963049 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -6,6 +6,7 @@ import type { TwitterCookies } from './cookies.js'; import queryIds from './query-ids.json' with { type: 'json' }; const TWITTER_API_BASE = 'https://x.com/i/api/graphql'; +const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql'; // 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 @@ -968,6 +969,8 @@ export class TwitterClient { }; const features = { + rweb_video_screen_enabled: true, + creator_subscriptions_tweet_preview_api_enabled: true, premium_content_api_read_enabled: false, communities_web_enable_tweet_community_results_fetch: true, c9s_tweet_anatomy_moderator_badge_enabled: true, @@ -1024,6 +1027,8 @@ export class TwitterClient { }; const features = { + rweb_video_screen_enabled: true, + creator_subscriptions_tweet_preview_api_enabled: true, premium_content_api_read_enabled: false, communities_web_enable_tweet_community_results_fetch: true, c9s_tweet_anatomy_moderator_badge_enabled: true, @@ -1065,21 +1070,44 @@ export class TwitterClient { variables: Record, features: Record, ): Promise { - const url = `${TWITTER_API_BASE}/${QUERY_IDS.CreateTweet}/CreateTweet`; + const queryId = QUERY_IDS.CreateTweet; + const urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`; - const body = JSON.stringify({ - variables, - features, - queryId: QUERY_IDS.CreateTweet, - }); + const body = JSON.stringify({ variables, features, queryId }); try { - const response = await this.fetchWithTimeout(url, { + const response = await this.fetchWithTimeout(urlWithOperation, { method: 'POST', headers: this.getHeaders(), body, }); + // Twitter increasingly prefers POST to /i/api/graphql with queryId in the payload. + // If the operation URL 404s, retry the generic endpoint. + if (response.status === 404) { + const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, { + method: 'POST', + headers: this.getHeaders(), + body, + }); + + if (!retry.ok) { + const text = await retry.text(); + return { success: false, error: `HTTP ${retry.status}: ${text.slice(0, 200)}` }; + } + + 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 tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; + if (tweetId) return { success: true, tweetId }; + + return { success: false, error: 'Tweet created but no ID returned' }; + } + if (!response.ok) { const text = await response.text(); return { diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index a7e0a5e..4089149 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -79,6 +79,44 @@ describe('TwitterClient', () => { const body = JSON.parse(options.body); expect(body.variables.tweet_text).toBe('Hello world!'); + expect(body.features.rweb_video_screen_enabled).toBe(true); + expect(body.features.creator_subscriptions_tweet_preview_api_enabled).toBe(true); + }); + + it('retries CreateTweet via /i/api/graphql when operation URL 404s', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => '', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + create_tweet: { + tweet_results: { + result: { + rest_id: '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); + + const [firstUrl] = mockFetch.mock.calls[0]; + const [secondUrl] = mockFetch.mock.calls[1]; + expect(String(firstUrl)).toContain('/CreateTweet'); + expect(String(secondUrl)).toBe('https://x.com/i/api/graphql'); }); it('should handle API errors', async () => { @@ -178,6 +216,8 @@ describe('TwitterClient', () => { const body = JSON.parse(options.body); expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890'); expect(body.variables.tweet_text).toBe('This is a reply'); + expect(body.features.rweb_video_screen_enabled).toBe(true); + expect(body.features.creator_subscriptions_tweet_preview_api_enabled).toBe(true); }); });