From da013664c848334a364c6f743ea19bffbaa3c86a Mon Sep 17 00:00:00 2001 From: the-vampiire <25523682+the-vampiire@users.noreply.github.com> Date: Wed, 14 Jan 2026 02:06:32 -0500 Subject: [PATCH] feat: add engagement mixin for like/unlike/retweet/unretweet/bookmark Adds a new mixin with programmatic engagement methods: - like(tweetId) - FavoriteTweet - unlike(tweetId) - UnfavoriteTweet - retweet(tweetId) - CreateRetweet - unretweet(tweetId) - DeleteRetweet - bookmark(tweetId) - CreateBookmark Implementation follows existing mixin patterns with: - Shared performEngagementMutation() helper - 404 retry with query ID refresh - Returns BookmarkMutationResult for consistency Also adds query IDs for the new operations to constants and the update-query-ids script. Includes 9 tests covering success cases, error handling, and retry logic. --- scripts/update-query-ids.ts | 3 + src/lib/twitter-client-constants.ts | 3 + src/lib/twitter-client-engagement.ts | 116 +++++++++++++++ src/lib/twitter-client.ts | 7 +- tests/twitter-client.engagement.test.ts | 179 ++++++++++++++++++++++++ 5 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/lib/twitter-client-engagement.ts create mode 100644 tests/twitter-client.engagement.test.ts diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts index 14feca4..19213ea 100644 --- a/scripts/update-query-ids.ts +++ b/scripts/update-query-ids.ts @@ -10,9 +10,12 @@ import path from 'node:path'; const TARGET_OPERATIONS = [ 'CreateTweet', 'CreateRetweet', + 'DeleteRetweet', 'CreateFriendship', 'DestroyFriendship', 'FavoriteTweet', + 'UnfavoriteTweet', + 'CreateBookmark', 'DeleteBookmark', 'TweetDetail', 'SearchTimeline', diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts index 2e9f302..fbb382f 100644 --- a/src/lib/twitter-client-constants.ts +++ b/src/lib/twitter-client-constants.ts @@ -12,9 +12,12 @@ export const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/updat export const FALLBACK_QUERY_IDS = { CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg', CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', + DeleteRetweet: 'iQtK4dl5hBmXewYZuEOKVw', CreateFriendship: '8h9JVdV8dlSyqyRDJEPCsA', DestroyFriendship: 'ppXWuagMNXgvzx6WoXBW0Q', FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', + UnfavoriteTweet: 'ZYKSe-w7KEslx3JhSIk5LA', + CreateBookmark: 'aoDbu3RHznuiSkQ9aNM67Q', DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ', TweetDetail: '97JF30KziU00483E_8elBA', SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', diff --git a/src/lib/twitter-client-engagement.ts b/src/lib/twitter-client-engagement.ts new file mode 100644 index 0000000..ad599de --- /dev/null +++ b/src/lib/twitter-client-engagement.ts @@ -0,0 +1,116 @@ +import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; +import { type OperationName, TWITTER_API_BASE, TWITTER_GRAPHQL_POST_URL } from './twitter-client-constants.js'; +import type { BookmarkMutationResult } from './twitter-client-types.js'; + +export interface TwitterClientEngagementMethods { + /** Like a tweet. */ + like(tweetId: string): Promise; + /** Remove a like from a tweet. */ + unlike(tweetId: string): Promise; + /** Retweet a tweet. */ + retweet(tweetId: string): Promise; + /** Remove a retweet. */ + unretweet(tweetId: string): Promise; + /** Bookmark a tweet. */ + bookmark(tweetId: string): Promise; +} + +export function withEngagement>( + Base: TBase, +): Mixin { + abstract class TwitterClientEngagement extends Base { + // biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement. + constructor(...args: any[]) { + super(...args); + } + + private async performEngagementMutation( + operationName: OperationName, + tweetId: string, + ): Promise { + const variables = { tweet_id: tweetId }; + let queryId = await this.getQueryId(operationName); + let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/${operationName}`; + + const buildBody = () => JSON.stringify({ variables, queryId }); + const buildHeaders = () => ({ ...this.getHeaders(), referer: `https://x.com/i/status/${tweetId}` }); + let body = buildBody(); + + const parseResponse = async (response: Response): Promise => { + 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 { errors?: Array<{ message: string }> }; + if (data.errors && data.errors.length > 0) { + return { success: false, error: data.errors.map((e) => e.message).join(', ') }; + } + + return { success: true }; + }; + + try { + let response = await this.fetchWithTimeout(urlWithOperation, { + method: 'POST', + headers: buildHeaders(), + body, + }); + + if (response.status === 404) { + await this.refreshQueryIds(); + queryId = await this.getQueryId(operationName); + urlWithOperation = `${TWITTER_API_BASE}/${queryId}/${operationName}`; + body = buildBody(); + + response = await this.fetchWithTimeout(urlWithOperation, { + method: 'POST', + headers: buildHeaders(), + body, + }); + + if (response.status === 404) { + const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, { + method: 'POST', + headers: buildHeaders(), + body, + }); + + return parseResponse(retry); + } + } + + return parseResponse(response); + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + /** Like a tweet. */ + async like(tweetId: string): Promise { + return this.performEngagementMutation('FavoriteTweet', tweetId); + } + + /** Remove a like from a tweet. */ + async unlike(tweetId: string): Promise { + return this.performEngagementMutation('UnfavoriteTweet', tweetId); + } + + /** Retweet a tweet. */ + async retweet(tweetId: string): Promise { + return this.performEngagementMutation('CreateRetweet', tweetId); + } + + /** Remove a retweet. */ + async unretweet(tweetId: string): Promise { + return this.performEngagementMutation('DeleteRetweet', tweetId); + } + + /** Bookmark a tweet. */ + async bookmark(tweetId: string): Promise { + return this.performEngagementMutation('CreateBookmark', tweetId); + } + } + + return TwitterClientEngagement; +} diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 5f257dc..04894c9 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -1,6 +1,7 @@ import type { AbstractConstructor } from './twitter-client-base.js'; import { TwitterClientBase } from './twitter-client-base.js'; import { type TwitterClientBookmarkMethods, withBookmarks } from './twitter-client-bookmarks.js'; +import { type TwitterClientEngagementMethods, withEngagement } from './twitter-client-engagement.js'; import { type TwitterClientFollowMethods, withFollow } from './twitter-client-follow.js'; import { type TwitterClientHomeMethods, withHome } from './twitter-client-home.js'; import { type TwitterClientListMethods, withLists } from './twitter-client-lists.js'; @@ -16,6 +17,7 @@ import { type TwitterClientUserMethods, withUsers } from './twitter-client-users type TwitterClientInstance = TwitterClientBase & TwitterClientBookmarkMethods & + TwitterClientEngagementMethods & TwitterClientFollowMethods & TwitterClientHomeMethods & TwitterClientListMethods & @@ -30,6 +32,7 @@ type TwitterClientInstance = TwitterClientBase & TwitterClientUserTweetsMethods; // News mixin wraps search because it depends on the search() method +// Engagement mixin adds like/unlike/retweet/unretweet/bookmark methods const MixedTwitterClient = withNews( withUserTweets( withUserLookup( @@ -37,7 +40,9 @@ const MixedTwitterClient = withNews( withLists( withHome( withTimelines( - withSearch(withTweetDetails(withPosting(withFollow(withBookmarks(withMedia(TwitterClientBase)))))), + withSearch( + withTweetDetails(withPosting(withEngagement(withFollow(withBookmarks(withMedia(TwitterClientBase)))))), + ), ), ), ), diff --git a/tests/twitter-client.engagement.test.ts b/tests/twitter-client.engagement.test.ts new file mode 100644 index 0000000..e3cdd3c --- /dev/null +++ b/tests/twitter-client.engagement.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TwitterClient } from '../src/lib/twitter-client.js'; +import { validCookies } from './twitter-client-fixtures.js'; + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe('TwitterClient engagement', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + describe('like', () => { + it('successfully likes a tweet', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ data: { favorite_tweet: 'Done' } }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.like('1234567890'); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('POST'); + expect(String(url)).toContain('/FavoriteTweet'); + const body = JSON.parse(options.body); + expect(body.variables.tweet_id).toBe('1234567890'); + }); + + it('returns error on HTTP failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => 'Forbidden', + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.like('1234567890'); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 403'); + }); + + it('returns error on GraphQL errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + errors: [{ message: 'You have already favorited this Tweet.' }], + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.like('1234567890'); + + expect(result.success).toBe(false); + expect(result.error).toContain('already favorited'); + }); + + it('retries on 404 with refreshed query IDs', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'Not found' }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ data: { favorite_tweet: 'Done' } }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.like('1234567890'); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('unlike', () => { + it('successfully unlikes a tweet', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ data: { unfavorite_tweet: 'Done' } }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.unlike('1234567890'); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('POST'); + expect(String(url)).toContain('/UnfavoriteTweet'); + }); + }); + + describe('retweet', () => { + it('successfully retweets a tweet', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ data: { create_retweet: { retweet_results: { result: {} } } } }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.retweet('1234567890'); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('POST'); + expect(String(url)).toContain('/CreateRetweet'); + }); + }); + + describe('unretweet', () => { + it('successfully unrets a tweet', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ data: { unretweet: { source_tweet_results: {} } } }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.unretweet('1234567890'); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('POST'); + expect(String(url)).toContain('/DeleteRetweet'); + }); + }); + + describe('bookmark', () => { + it('successfully bookmarks a tweet', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ data: { tweet_bookmark_put: 'Done' } }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.bookmark('1234567890'); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('POST'); + expect(String(url)).toContain('/CreateBookmark'); + }); + }); + + describe('error handling', () => { + it('catches network errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.like('1234567890'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + }); +});