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.
This commit is contained in:
@@ -10,9 +10,12 @@ import path from 'node:path';
|
|||||||
const TARGET_OPERATIONS = [
|
const TARGET_OPERATIONS = [
|
||||||
'CreateTweet',
|
'CreateTweet',
|
||||||
'CreateRetweet',
|
'CreateRetweet',
|
||||||
|
'DeleteRetweet',
|
||||||
'CreateFriendship',
|
'CreateFriendship',
|
||||||
'DestroyFriendship',
|
'DestroyFriendship',
|
||||||
'FavoriteTweet',
|
'FavoriteTweet',
|
||||||
|
'UnfavoriteTweet',
|
||||||
|
'CreateBookmark',
|
||||||
'DeleteBookmark',
|
'DeleteBookmark',
|
||||||
'TweetDetail',
|
'TweetDetail',
|
||||||
'SearchTimeline',
|
'SearchTimeline',
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ export const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/updat
|
|||||||
export const FALLBACK_QUERY_IDS = {
|
export const FALLBACK_QUERY_IDS = {
|
||||||
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
|
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
|
||||||
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
||||||
|
DeleteRetweet: 'iQtK4dl5hBmXewYZuEOKVw',
|
||||||
CreateFriendship: '8h9JVdV8dlSyqyRDJEPCsA',
|
CreateFriendship: '8h9JVdV8dlSyqyRDJEPCsA',
|
||||||
DestroyFriendship: 'ppXWuagMNXgvzx6WoXBW0Q',
|
DestroyFriendship: 'ppXWuagMNXgvzx6WoXBW0Q',
|
||||||
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
||||||
|
UnfavoriteTweet: 'ZYKSe-w7KEslx3JhSIk5LA',
|
||||||
|
CreateBookmark: 'aoDbu3RHznuiSkQ9aNM67Q',
|
||||||
DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ',
|
DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ',
|
||||||
TweetDetail: '97JF30KziU00483E_8elBA',
|
TweetDetail: '97JF30KziU00483E_8elBA',
|
||||||
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
||||||
|
|||||||
@@ -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<BookmarkMutationResult>;
|
||||||
|
/** Remove a like from a tweet. */
|
||||||
|
unlike(tweetId: string): Promise<BookmarkMutationResult>;
|
||||||
|
/** Retweet a tweet. */
|
||||||
|
retweet(tweetId: string): Promise<BookmarkMutationResult>;
|
||||||
|
/** Remove a retweet. */
|
||||||
|
unretweet(tweetId: string): Promise<BookmarkMutationResult>;
|
||||||
|
/** Bookmark a tweet. */
|
||||||
|
bookmark(tweetId: string): Promise<BookmarkMutationResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withEngagement<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||||
|
Base: TBase,
|
||||||
|
): Mixin<TBase, TwitterClientEngagementMethods> {
|
||||||
|
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<BookmarkMutationResult> {
|
||||||
|
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<BookmarkMutationResult> => {
|
||||||
|
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<BookmarkMutationResult> {
|
||||||
|
return this.performEngagementMutation('FavoriteTweet', tweetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a like from a tweet. */
|
||||||
|
async unlike(tweetId: string): Promise<BookmarkMutationResult> {
|
||||||
|
return this.performEngagementMutation('UnfavoriteTweet', tweetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retweet a tweet. */
|
||||||
|
async retweet(tweetId: string): Promise<BookmarkMutationResult> {
|
||||||
|
return this.performEngagementMutation('CreateRetweet', tweetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a retweet. */
|
||||||
|
async unretweet(tweetId: string): Promise<BookmarkMutationResult> {
|
||||||
|
return this.performEngagementMutation('DeleteRetweet', tweetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bookmark a tweet. */
|
||||||
|
async bookmark(tweetId: string): Promise<BookmarkMutationResult> {
|
||||||
|
return this.performEngagementMutation('CreateBookmark', tweetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TwitterClientEngagement;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AbstractConstructor } from './twitter-client-base.js';
|
import type { AbstractConstructor } from './twitter-client-base.js';
|
||||||
import { TwitterClientBase } from './twitter-client-base.js';
|
import { TwitterClientBase } from './twitter-client-base.js';
|
||||||
import { type TwitterClientBookmarkMethods, withBookmarks } from './twitter-client-bookmarks.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 TwitterClientFollowMethods, withFollow } from './twitter-client-follow.js';
|
||||||
import { type TwitterClientHomeMethods, withHome } from './twitter-client-home.js';
|
import { type TwitterClientHomeMethods, withHome } from './twitter-client-home.js';
|
||||||
import { type TwitterClientListMethods, withLists } from './twitter-client-lists.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 &
|
type TwitterClientInstance = TwitterClientBase &
|
||||||
TwitterClientBookmarkMethods &
|
TwitterClientBookmarkMethods &
|
||||||
|
TwitterClientEngagementMethods &
|
||||||
TwitterClientFollowMethods &
|
TwitterClientFollowMethods &
|
||||||
TwitterClientHomeMethods &
|
TwitterClientHomeMethods &
|
||||||
TwitterClientListMethods &
|
TwitterClientListMethods &
|
||||||
@@ -30,6 +32,7 @@ type TwitterClientInstance = TwitterClientBase &
|
|||||||
TwitterClientUserTweetsMethods;
|
TwitterClientUserTweetsMethods;
|
||||||
|
|
||||||
// News mixin wraps search because it depends on the search() method
|
// News mixin wraps search because it depends on the search() method
|
||||||
|
// Engagement mixin adds like/unlike/retweet/unretweet/bookmark methods
|
||||||
const MixedTwitterClient = withNews(
|
const MixedTwitterClient = withNews(
|
||||||
withUserTweets(
|
withUserTweets(
|
||||||
withUserLookup(
|
withUserLookup(
|
||||||
@@ -37,7 +40,9 @@ const MixedTwitterClient = withNews(
|
|||||||
withLists(
|
withLists(
|
||||||
withHome(
|
withHome(
|
||||||
withTimelines(
|
withTimelines(
|
||||||
withSearch(withTweetDetails(withPosting(withFollow(withBookmarks(withMedia(TwitterClientBase)))))),
|
withSearch(
|
||||||
|
withTweetDetails(withPosting(withEngagement(withFollow(withBookmarks(withMedia(TwitterClientBase)))))),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user