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:
the-vampiire
2026-06-24 18:44:38 +09:00
committed by yuta
parent 471cd9b26f
commit da013664c8
5 changed files with 307 additions and 1 deletions
+3
View File
@@ -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',
+116
View File
@@ -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;
}
+6 -1
View File
@@ -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)))))),
),
),
),
),