summaryrefslogtreecommitdiff
path: root/packages/tweetdeck/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'packages/tweetdeck/src/lib')
-rw-r--r--packages/tweetdeck/src/lib/client/twitterClient.ts75
-rw-r--r--packages/tweetdeck/src/lib/fetching/python.ts52
-rw-r--r--packages/tweetdeck/src/lib/fetching/twitter-api.ts1178
-rw-r--r--packages/tweetdeck/src/lib/fetching/types.ts596
-rw-r--r--packages/tweetdeck/src/lib/utils/id.ts4
-rw-r--r--packages/tweetdeck/src/lib/utils/time.ts18
6 files changed, 1923 insertions, 0 deletions
diff --git a/packages/tweetdeck/src/lib/client/twitterClient.ts b/packages/tweetdeck/src/lib/client/twitterClient.ts
new file mode 100644
index 0000000..b8914b5
--- /dev/null
+++ b/packages/tweetdeck/src/lib/client/twitterClient.ts
@@ -0,0 +1,75 @@
+import {
+ type TwitterUser,
+ type TweetList,
+ type TwitterList,
+ type TwitterNotification,
+} from "../fetching/types";
+
+const headers = { "Content-Type": "application/json" };
+
+async function postJson<T>(
+ url: string,
+ body: Record<string, unknown>,
+): Promise<T> {
+ const res = await fetch(url, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || `Request failed (${res.status})`);
+ }
+
+ return (await res.json()) as T;
+}
+
+export const twitterClient = {
+ own(payload: Record<string, unknown>) {
+ // return postJson<TwitterUser>(`/api/twitter/our`, payload);
+ },
+ timeline(mode: string, payload: Record<string, unknown>) {
+ console.log("fetching tweets", mode);
+ return postJson<TweetList>(`/api/twitter/timeline/${mode}`, payload);
+ },
+ lists(payload: Record<string, unknown>) {
+ return postJson<TwitterList[]>("/api/twitter/lists", payload);
+ },
+ notifications(payload: Record<string, unknown>) {
+ return postJson<TwitterNotification[]>(
+ "/api/twitter/notifications",
+ payload,
+ );
+ },
+ removeBookmark(payload: Record<string, unknown>) {
+ return postJson<{ status: string }>(
+ "/api/twitter/bookmarks/remove",
+ payload,
+ );
+ },
+ like(tweetId: string, payload: Record<string, unknown>) {
+ return postJson<{ status: string }>(
+ `/api/twitter/tweets/${tweetId}/like`,
+ payload,
+ );
+ },
+ retweet(tweetId: string, payload: Record<string, unknown>) {
+ return postJson<{ status: string }>(
+ `/api/twitter/tweets/${tweetId}/retweet`,
+ payload,
+ );
+ },
+ bookmark(tweetId: string, payload: Record<string, unknown>) {
+ return postJson<{ status: string }>(
+ `/api/twitter/tweets/${tweetId}/bookmark`,
+ payload,
+ );
+ },
+ reply(tweetId: string, payload: Record<string, unknown>) {
+ return postJson<{ status: string }>(
+ `/api/twitter/tweets/${tweetId}/reply`,
+ payload,
+ );
+ },
+};
diff --git a/packages/tweetdeck/src/lib/fetching/python.ts b/packages/tweetdeck/src/lib/fetching/python.ts
new file mode 100644
index 0000000..760cb2c
--- /dev/null
+++ b/packages/tweetdeck/src/lib/fetching/python.ts
@@ -0,0 +1,52 @@
+import python from "bun_python";
+
+export class TransactionIdGenerator {
+ private initialHtmlContent!: string;
+ private client_transaction: any;
+ private cookie: string;
+ private headers: any;
+
+ private BeautifulSoup: any;
+ private ClientTransaction: any;
+
+ constructor(cookie: string) {
+ this.cookie = cookie;
+ }
+ public async init() {
+ const genheaders = await python.import("x_client_transaction.utils")
+ .generate_headers;
+ const hs = genheaders();
+ this.headers = { ...hs, Cookie: this.cookie };
+ const currentUrl = "https://x.com";
+ const response = await fetch(currentUrl, { headers: this.headers });
+ const html = await response.text();
+ this.initialHtmlContent = html;
+ }
+
+ public async getTransactionId(method: string, path: string): Promise<string> {
+ if (!this.BeautifulSoup || !this.ClientTransaction) {
+ this.BeautifulSoup = await python.import("bs4").BeautifulSoup;
+ this.ClientTransaction = await python.import("x_client_transaction")
+ .ClientTransaction;
+ }
+
+ if (!this.client_transaction) {
+ const soup = this.BeautifulSoup(this.initialHtmlContent, "lxml");
+ const onDemand = await python.import("x_client_transaction.utils")
+ .get_ondemand_file_url;
+ const file = onDemand(soup);
+ const ondemand_res = await fetch(file, {
+ method: "GET",
+ headers: this.headers,
+ });
+ const ondemand_text = await ondemand_res.text();
+ this.client_transaction = this.ClientTransaction(soup, ondemand_text);
+ }
+
+ const transaction_id = this.client_transaction.generate_transaction_id(
+ method,
+ path,
+ );
+ return transaction_id;
+ }
+}
diff --git a/packages/tweetdeck/src/lib/fetching/twitter-api.ts b/packages/tweetdeck/src/lib/fetching/twitter-api.ts
new file mode 100644
index 0000000..8ea4709
--- /dev/null
+++ b/packages/tweetdeck/src/lib/fetching/twitter-api.ts
@@ -0,0 +1,1178 @@
+import type {
+ Tweet,
+ TweetList,
+ TweetResult,
+ TwitterBookmarkResponse,
+ TwitterList,
+ TwitterListTimelineResponse,
+ TwitterListsManagementResponse,
+ TwitterNotification,
+ TwitterNotificationsTimelineResponse,
+ TimelineEntry,
+ TwitterTimelineResponse,
+ TwitterTweetDetailResponse,
+ TwitterUserTweetsResponse,
+ APITwitterList,
+ TweetWithVisibilityResult,
+ TwitterUser,
+ RTMetadata,
+ TwitterProfilesResponse,
+ UserResult,
+} from "./types";
+import { TransactionIdGenerator } from "./python";
+
+const TWITTER_INTERNAL_API_KEY =
+ "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
+
+export class TwitterApiService {
+ cookie: string;
+ constructor(cookie: string) {
+ this.cookie = cookie;
+ }
+ // Read endpoints
+ private static readonly BOOKMARKS_URL = new URL(
+ "https://x.com/i/api/graphql/C7CReOA1R0PwKorWAxnNUQ/Bookmarks",
+ );
+ private static readonly FOLLOWING_URL = new URL(
+ "https://x.com/i/api/graphql/fhqL7Cgmvax9jOhRMOhWpA/HomeLatestTimeline",
+ );
+ private static readonly FORYOU_URL = new URL(
+ "https://x.com/i/api/graphql/sMNeM4wvNe4JnRUZ2jd2zw/HomeTimeline",
+ );
+ private static readonly LISTS_URL = new URL(
+ "https://x.com/i/api/graphql/wLXb5F6pIEOrYtTjXFLQsA/ListsManagementPageTimeline",
+ );
+ private static readonly LIST_URL = new URL(
+ "https://x.com/i/api/graphql/p-5fXSlJaR-aZ4UUBdPMAg/ListLatestTweetsTimeline",
+ );
+ private static readonly NOTIFICATIONS_URL = new URL(
+ "https://api.x.com/1.1/notifications/timeline.json",
+ );
+ private static readonly USERDATA_URL = new URL(
+ "https://x.com/i/api/graphql/2AtIgw7Kz26sV6sEBrQjSQ/UsersByRestIds",
+ );
+ private static readonly USER_URL = new URL(
+ "https://x.com/i/api/graphql/Le1DChzkS7ioJH_yEPMi3w/UserTweets",
+ );
+ private static readonly THREAD_URL = new URL(
+ "https://x.com/i/api/graphql/aTYmkYpjWyvUyrinVWSiYA/TweetDetail",
+ );
+
+ private static readonly HEADERS = {
+ "User-Agent":
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
+ Accept: "*/*",
+ Referer: "https://x.com/i/bookmarks",
+ "Content-Type": "application/json",
+ "X-Twitter-Auth-Type": "OAuth2Session",
+ "X-Twitter-Active-User": "yes",
+ "X-Twitter-Client-Language": "en",
+ };
+
+ private get csrfToken() {
+ return this.cookie.match(/ct0=([^;]+)/)?.[1];
+ }
+
+ private buildHeaders(extra?: Record<string, string>) {
+ const headers: Record<string, string> = {
+ ...TwitterApiService.HEADERS,
+ Authorization: TWITTER_INTERNAL_API_KEY,
+ Cookie: this.cookie,
+ ...(extra ?? {}),
+ };
+ const csrf = this.csrfToken;
+ if (csrf) headers["X-Csrf-Token"] = csrf;
+ return headers;
+ }
+
+ private async request(url: URL, init: RequestInit) {
+ const headers = this.buildHeaders(init.headers as Record<string, string>);
+
+ const xcs = new TransactionIdGenerator("");
+ await xcs.init();
+ const xclientid = await xcs.getTransactionId(init.method!, url.pathname);
+ headers["X-Client-Transaction-Id"] = xclientid;
+ const response = await fetch(url, { ...init, headers });
+ if (!response.ok) {
+ console.log(headers);
+ console.log(response);
+ throw new Error(
+ `Twitter API request failed: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ return await response.json();
+ }
+ async postCall(url: URL, payload: Record<string, unknown>) {
+ const body = JSON.stringify(payload);
+ return this.request(url, {
+ method: "POST",
+ body,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ async getCall(url: URL) {
+ return this.request(url, { method: "GET" });
+ }
+ async findOwn(): Promise<TwitterUser> {
+ const cookie = decodeURIComponent(this.cookie);
+ const match = cookie.match(/twid=u=([^;]+)/);
+ const id = match![1]!;
+ const profs = await this.fetchProfiles([id]);
+ return profs[0]!;
+ }
+ async fetchProfiles(userIds: string[]): Promise<TwitterUser[]> {
+ const variables = {
+ userIds,
+ };
+ const features = {
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ responsive_web_profile_redirect_enabled: false,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ };
+ const url = new URL(TwitterApiService.USERDATA_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+
+ const data = (await this.getCall(url)) as TwitterProfilesResponse;
+ const users = data?.data?.users;
+ if (!users) throw new Error("error parsing ids");
+ const parsed = users.map((u) =>
+ TwitterApiService.extractUserData(u.result),
+ );
+ return parsed;
+ }
+ async fetchForyou(cursor?: string): Promise<TweetList> {
+ const payload = {
+ variables: {
+ count: 50,
+ cursor: cursor || "DAABCgABGv-ytnDAJxEKAAIa_qVgidrhcwgAAwAAAAEAAA",
+ includePromotedContent: true,
+ latestControlAvailable: true,
+ withCommunity: true,
+ seenTweetIds: [],
+ },
+ features: {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ },
+ queryId: "sMNeM4wvNe4JnRUZ2jd2zw",
+ };
+ const data = (await this.postCall(
+ TwitterApiService.FORYOU_URL,
+ payload,
+ )) as TwitterTimelineResponse;
+ try {
+ return TwitterApiService.parseTimelineResponse(data, "foryou");
+ } catch (e) {
+ console.error(e);
+ console.dir(data, { depth: null });
+ throw new Error("wtf");
+ }
+ }
+
+ async fetchFollowing(cursor?: string) {
+ const payload = {
+ variables: {
+ count: 50,
+ cursor: cursor || "DAABCgABGv-ytnDAJxEKAAIa_qVgidrhcwgAAwAAAAEAAA",
+ includePromotedContent: true,
+ latestControlAvailable: true,
+ requestContext: "launch",
+ seenTweetIds: [],
+ },
+ features: {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ },
+ queryId: "fhqL7Cgmvax9jOhRMOhWpA",
+ };
+ const data = (await this.postCall(
+ TwitterApiService.FOLLOWING_URL,
+ payload,
+ )) as TwitterTimelineResponse;
+ try {
+ return TwitterApiService.parseTimelineResponse(data, "following");
+ } catch (e) {
+ console.error(e);
+ console.dir(data, { depth: null });
+ throw new Error("wtf");
+ }
+ }
+
+ async fetchList(listId: string, cursor?: string): Promise<TweetList> {
+ const variables = { listId, count: 20, cursor };
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const url = new URL(TwitterApiService.LIST_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ const data = (await this.getCall(url)) as TwitterListTimelineResponse;
+ return TwitterApiService.parseListTimelineResponse(data);
+ }
+ async fetchLists(): Promise<TwitterList[]> {
+ const variables = { count: 100 };
+
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const url = new URL(TwitterApiService.LISTS_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ const data = (await this.getCall(url)) as TwitterListsManagementResponse;
+ try {
+ return TwitterApiService.parseListsManagementResponse(data);
+ } catch (e) {
+ console.error(e);
+ // console.dir(data.data.viewer.list_management_timeline, { depth: null });
+ throw e;
+ }
+ }
+
+ async fetchNotifications(cursor?: string): Promise<TwitterNotification[]> {
+ const variables: Record<string, string | number> = {
+ include_profile_interstitial_type: 1,
+ include_blocking: 1,
+ include_blocked_by: 1,
+ include_followed_by: 1,
+ include_want_retweets: 1,
+ include_mute_edge: 1,
+ include_can_dm: 1,
+ include_can_media_tag: 1,
+ include_ext_has_nft_avatar: 1,
+ include_ext_is_blue_verified: 1,
+ include_ext_verified_type: 1,
+ include_ext_profile_image_shape: 1,
+ skip_status: 1,
+ cards_platform: "Web-12",
+ include_cards: 1,
+ include_composer_source: "true",
+ include_ext_alt_text: "true",
+ include_ext_limited_action_results: "false",
+ include_reply_count: 1,
+ tweet_mode: "extended",
+ include_entities: "true",
+ include_user_entities: "true",
+ include_ext_media_color: "true",
+ include_ext_media_availability: "true",
+ include_ext_sensitive_media_warning: "true",
+ include_ext_trusted_friends_metadata: "true",
+ send_error_codes: "true",
+ simple_quoted_tweet: "true",
+ count: 40,
+ ext: "mediaStats,highlightedLabel,hasNftAvatar,voiceInfo,enrichments,superFollowMetadata,unmentionInfo,editControl,vibe",
+ };
+ if (cursor) {
+ variables.cursor = cursor;
+ }
+ const url = new URL(TwitterApiService.NOTIFICATIONS_URL);
+ Object.keys(variables).forEach((key) =>
+ url.searchParams.set(key, variables[key]!.toString()),
+ );
+
+ const data = (await this.getCall(
+ url,
+ )) as TwitterNotificationsTimelineResponse;
+
+ return TwitterApiService.parseNotificationsResponse(data);
+ }
+ async fetchUserTweets(userId: string, cursor?: string): Promise<TweetList> {
+ const variables = {
+ userId,
+ count: 50,
+ includePromotedContent: true,
+ withQuickPromoteEligibilityTweetFields: true,
+ withVoice: true,
+ };
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const fieldToggles = { withArticlePlainText: true };
+
+ const url = new URL(TwitterApiService.USER_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ url.searchParams.set("fieldToggles", JSON.stringify(fieldToggles));
+ const data = (await this.getCall(url)) as TwitterUserTweetsResponse;
+
+ try {
+ return TwitterApiService.parseUserTweetsResponse(data);
+ } catch (e) {
+ console.error(e);
+ console.dir(data, { depth: null });
+ throw new Error("bad");
+ }
+ }
+ async fetchThread(tweetId: string, cursor?: string): Promise<TweetList> {
+ const variables = {
+ focalTweetId: tweetId,
+ referrer: "profile",
+ with_rux_injections: false,
+ rankingMode: "Relevance",
+ includePromotedContent: true,
+ withCommunity: true,
+ withQuickPromoteEligibilityTweetFields: true,
+ withBirdwatchNotes: true,
+ withVoice: true,
+ };
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const fieldToggles = {
+ withArticleRichContentState: true,
+ withArticlePlainText: true,
+ withGrokAnalyze: true,
+ withDisallowedReplyControls: false,
+ };
+
+ const url = new URL(TwitterApiService.THREAD_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ url.searchParams.set("fieldToggles", JSON.stringify(fieldToggles));
+ const data = (await this.getCall(url)) as TwitterTweetDetailResponse;
+
+ try {
+ return TwitterApiService.parseThreadResponse(data);
+ } catch (e) {
+ console.error(e);
+ console.dir(data, { depth: null });
+ throw new Error("Bad");
+ }
+ }
+ async fetchBookmarks(cursor?: string): Promise<TweetList> {
+ const variables = {
+ count: 40,
+ cursor: cursor || null,
+ includePromotedContent: true,
+ };
+
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const url = new URL(TwitterApiService.BOOKMARKS_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ const data = (await this.getCall(url)) as TwitterBookmarkResponse;
+
+ return TwitterApiService.parseBookmarkResponse(data);
+ }
+
+ private static parseUserTweetsResponse(
+ response: TwitterUserTweetsResponse,
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+ const instructions =
+ response.data?.user?.result?.timeline?.timeline?.instructions;
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing user feed");
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing user feed");
+ for (const entry of instruction.entries) {
+ if (entry.content?.entryType === "TimelineTimelineItem") {
+ const tweetData = entry.content.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ const tweet = this.extractTweetData(tweetData);
+ if (tweet) {
+ tweets.push(tweet);
+ }
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+
+ return { tweets, cursorBottom, cursorTop };
+ }
+
+ private static parseThreadResponse(
+ response: TwitterTweetDetailResponse,
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+
+ const instructions =
+ response.data?.threaded_conversation_with_injections_v2?.instructions;
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing thread ");
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing thread feed");
+ for (const entry of instruction.entries) {
+ if (entry.content?.entryType === "TimelineTimelineItem") {
+ const tweetData = entry.content.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ const tweet = this.extractTweetData(tweetData);
+ if (tweet) {
+ tweets.push(tweet);
+ }
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+ return { tweets, cursorBottom, cursorTop };
+ }
+
+ private static parseBookmarkResponse(
+ response: TwitterBookmarkResponse,
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+
+ const instructions =
+ response.data?.bookmark_timeline_v2?.timeline?.instructions || [];
+
+ for (const instruction of instructions) {
+ if (
+ instruction.type === "TimelineAddEntries" ||
+ instruction.type === "TimelineReplaceEntry" ||
+ instruction.type === "TimelineShowMoreEntries"
+ ) {
+ const entries = [
+ ...(instruction.entries || []),
+ ...(((instruction as { entry?: TimelineEntry }).entry
+ ? [(instruction as { entry?: TimelineEntry }).entry!]
+ : []) as TimelineEntry[]),
+ ];
+
+ for (const entry of entries) {
+ const content = entry.content;
+
+ if (content?.entryType === "TimelineTimelineItem") {
+ const tweetData = content.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ const bookmark = this.extractTweetData(tweetData);
+ if (bookmark) {
+ tweets.push(bookmark);
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+ }
+
+ return {
+ tweets,
+ cursorTop,
+ cursorBottom,
+ };
+ }
+
+ private static parseTimelineResponse(
+ response: TwitterTimelineResponse,
+ type: "foryou" | "following",
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+
+ const instructions = response.data?.home?.home_timeline_urt?.instructions;
+ response.data?.home_timeline_urt?.instructions;
+
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing thread ");
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing thread feed");
+
+ for (const entry of instruction.entries) {
+ // if (entry.content.entryType.includes("ursor")) console.log(entry);
+ // if (entry.content.entryType.includes("odule"))
+ // console.log("module", entry);
+
+ if (entry.content?.entryType === "TimelineTimelineItem") {
+ const tweetData = entry.content?.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ try {
+ const tweet = this.extractTweetData(tweetData);
+ tweets.push(tweet);
+ } catch (e) {
+ console.error(e);
+ // console.dir(entry, { depth: null });
+ }
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Gap" // TODO wtf???
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+
+ return { tweets, cursorTop, cursorBottom };
+ }
+
+ private static parseListTimelineResponse(
+ response: TwitterListTimelineResponse,
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+
+ const instructions =
+ response.data?.list?.tweets_timeline?.timeline?.instructions;
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing tw timeline res");
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing tw timeline res");
+ for (const entry of instruction.entries) {
+ if (entry.content?.entryType === "TimelineTimelineItem") {
+ const tweetData = entry.content.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ const tweet = this.extractTweetData(tweetData);
+ tweets.push(tweet);
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+
+ return { tweets, cursorBottom, cursorTop };
+ }
+
+ private static parseListsManagementResponse(
+ response: TwitterListsManagementResponse,
+ ): TwitterList[] {
+ const lists: TwitterList[] = [];
+ const instructions =
+ response.data?.viewer?.list_management_timeline?.timeline?.instructions;
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing tw lists res");
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction?.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing tw lists res 2");
+ for (const entry of instruction.entries) {
+ console.log("entry", entry.content.__typename);
+ // if (entry.content.__typename === "TimelineTimelineModule")
+ if (entry.content.__typename === "TimelineTimelineCursor") {
+ console.dir(entry, { depth: null });
+ continue;
+ }
+ // entry.content.entryType can be TimelineTimelineModule, TimelineTimelineCursor,
+ // entry.entryId can be list-to-follow-<bignumber> which si the recommended lists that show on top
+ // or owned-subscribed-list-module-<smolnum> which is what we want
+ const listList = entry?.content?.items;
+ if (!listList || !Array.isArray(listList))
+ throw new Error("error parsing tw lists res 3");
+ for (const list of listList) {
+ lists.push(this.parseListResponse(list.item.itemContent.list));
+ }
+ }
+ }
+ }
+ return lists;
+ }
+
+ private static parseListResponse(res: APITwitterList): TwitterList {
+ const { name, id_str, member_count, subscriber_count } = res;
+ const creator = res.user_results.result.core.name;
+ return { name, id: id_str, member_count, subscriber_count, creator };
+ }
+ private static parseNotificationsResponse(
+ response: TwitterNotificationsTimelineResponse,
+ ): TwitterNotification[] {
+ const notifications: TwitterNotification[] = [];
+ const timelineNotifs =
+ response.timeline.instructions[0]?.addEntries?.entries;
+ if (!timelineNotifs || !Array.isArray(timelineNotifs))
+ throw new Error("error parsing notifs");
+ for (const entry of timelineNotifs) {
+ const notificationId = entry.content.notification.id;
+ const notification = response.globalObjects.notifications[notificationId];
+ if (notification) {
+ notifications.push(notification);
+ }
+ }
+ return notifications;
+ }
+ private static extractUserData(userResults: UserResult): TwitterUser {
+ return {
+ id: userResults.rest_id,
+ avatar:
+ userResults.avatar?.image_url ||
+ userResults.legacy?.profile_image_url_https!,
+ name: userResults.legacy?.name || userResults.core?.name!,
+ username:
+ userResults.legacy?.screen_name || userResults.core?.screen_name!,
+ };
+ }
+
+ private static extractTweetData(
+ tweetRes: TweetResult | TweetWithVisibilityResult,
+ rter: RTMetadata | null = null,
+ ): Tweet {
+ const tweetData =
+ tweetRes.__typename === "Tweet" ? tweetRes : tweetRes.tweet;
+
+ console.log({ tweetData });
+ let quoting: Tweet | null = null;
+ let retweeted_by = rter;
+ const legacy = tweetData?.legacy;
+ const userResults = tweetData?.core?.user_results?.result;
+ // if (!legacy || !userResults) throw new Error("no legacy??");
+ if (!legacy) throw new Error("no legacy??");
+ if (!userResults) throw new Error("no userResults??");
+
+ const author = this.extractUserData(userResults);
+ const time = new Date(legacy.created_at).getTime();
+
+ // is_rt
+ if (legacy.retweeted_status_result) {
+ const d = legacy.retweeted_status_result.result;
+ if (!d) console.log("bad rt", tweetData);
+ return this.extractTweetData(legacy.retweeted_status_result.result, {
+ author,
+ time,
+ });
+ }
+ //
+
+ // quotes
+ if (
+ tweetData.quoted_status_result &&
+ tweetData.quoted_status_result.result
+ ) {
+ // const d = tweetData.quoted_status_result.result;
+ // if (!d) console.log("bad quote", tweetData);
+ quoting = this.extractTweetData(tweetData.quoted_status_result.result);
+ }
+ //
+ const mediaEntities = legacy.entities.media;
+ // if (!mediaEntities) {
+ // console.log("no media");
+ // console.dir(legacy.entities, { depth: null });
+ // }
+ const media = (mediaEntities || []).reduce(
+ (
+ acc: { pics: string[]; video: { thumb: string; url: string } },
+ item,
+ ) => {
+ if (item.type === "photo" && item.media_url_https) {
+ return {
+ pics: [...acc.pics, item.media_url_https],
+ video: acc.video,
+ };
+ }
+ if (item.type === "video" && item.video_info?.variants) {
+ const video = item.video_info.variants.reduce(
+ (
+ acc: { bitrate?: number; url: string },
+ vid: { bitrate?: number; url: string },
+ ) => {
+ if (!vid.bitrate) return acc;
+ if (!acc.bitrate || vid.bitrate > acc.bitrate) return vid;
+ return acc;
+ },
+ { url: "" },
+ );
+ return {
+ pics: acc.pics,
+ video: {
+ url: video.url!,
+ thumb: item.media_url_https!,
+ },
+ };
+ }
+ return acc;
+ },
+ { pics: [] as string[], video: { thumb: "", url: "" } },
+ );
+ if (legacy.full_text.includes("your computers"))
+ console.dir(tweetRes, { depth: null });
+ const replyingTo = legacy.entities?.user_mentions
+ ? legacy.entities.user_mentions.map((m) => ({
+ name: m.name,
+ username: m.screen_name,
+ id: m.id_str,
+ }))
+ : [];
+
+ return {
+ id: tweetData.rest_id,
+ text: legacy.display_text_range
+ ? legacy.full_text.slice(
+ legacy.display_text_range[0],
+ legacy.display_text_range[1] + 1,
+ )
+ : legacy.full_text!,
+ language: legacy.lang || "en",
+ author,
+ time,
+ urls:
+ legacy.entities?.urls?.map(
+ (url: { expanded_url: string; display_url: string }) => ({
+ expandedUrl: url.expanded_url,
+ displayUrl: url.display_url,
+ }),
+ ) || [],
+ media,
+ hashtags:
+ legacy.entities?.hashtags?.map((tag: { text: string }) => tag.text) ||
+ [],
+ quoting,
+ retweeted_by,
+ liked: legacy.favorited,
+ bookmarked: legacy.bookmarked,
+ rted: legacy.retweeted,
+ replyingTo,
+ };
+ }
+
+ async fetchAllBookmarks(): Promise<Tweet[]> {
+ const allBookmarks: Tweet[] = [];
+ let cursor: string | undefined;
+ let hasMore = true;
+
+ while (hasMore) {
+ try {
+ const result = await this.fetchBookmarks(cursor);
+ allBookmarks.push(...result.tweets);
+ cursor = result.cursorBottom;
+
+ // Rate limiting - be nice to Twitter's API
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ } catch (error) {
+ console.error("Error fetching bookmarks batch:", error);
+ break;
+ }
+ }
+
+ return allBookmarks;
+ }
+ // WRITE ENDPOINTS
+ // TODO Grok stuff
+ //
+ // TODO add images, polls etc.
+ // quote is the URL https;//x.com/{user}/status/{id} of the quoted tweet;
+ async createTweet(text: string, topts: { quote?: string; reply?: string }) {
+ const queryId = `-fU2A9SG7hdlzUdOh04POw`;
+ const url = `https://x.com/i/api/graphql/${queryId}/createTweet`;
+ let variables: any = {
+ tweet_text: text,
+ dark_request: false,
+ media: {
+ media_entities: [],
+ possibly_sensitive: false,
+ },
+ semantic_annotation_ids: [],
+ disallowed_reply_options: null,
+ };
+ const features = {
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ responsive_web_profile_redirect_enabled: false,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ articles_preview_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_imagine_annotation_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ if (topts.reply)
+ variables = {
+ ...variables,
+ reply: {
+ in_reply_to_tweet_id: topts.reply,
+ exclude_reply_user_ids: [],
+ },
+ };
+ if (topts.quote) variables = { ...variables, attachment_url: topts.quote };
+
+ const params = { ...features, variables, queryId };
+ const body = JSON.stringify(params);
+ const nheaders = { "Content-type": "application/json" };
+ const headers = {
+ ...TwitterApiService.HEADERS,
+ Authorization: TWITTER_INTERNAL_API_KEY,
+ Cookie: this.cookie,
+ ...nheaders,
+ };
+ const opts = {
+ method: "POST",
+ headers,
+ body,
+ };
+ const res = await fetch(url, opts);
+ console.log("like added", res);
+ }
+ async addLike(tweet_id: string) {
+ const queryId = `lI07N6Otwv1PhnEgXILM7A`;
+ const url = new URL(`https://x.com/i/api/graphql/${queryId}/FavoriteTweet`);
+ const body = { variables: { tweet_id }, queryId };
+ return await this.postCall(url, body);
+ }
+ async removeLike(tweet_id: string) {
+ const queryId = `ZYKSe-w7KEslx3JhSIk5LA`;
+ const url = new URL(
+ `https://x.com/i/api/graphql/${queryId}/UnfavoriteTweet`,
+ );
+ const body = { variables: { tweet_id }, queryId };
+ return await this.postCall(url, body);
+ }
+ async addRT(tweet_id: string) {
+ const queryId = `ZYKSe-w7KEslx3JhSIk5LA`;
+ const url = new URL(`https://x.com/i/api/graphql/${queryId}/CreateRetweet`);
+ // TODO wtf is dark_request bruh
+ const body = {
+ variables: { tweet_id, dark_request: false },
+ queryId,
+ };
+ return await this.postCall(url, body);
+ }
+ async removeRT(tweet_id: string) {
+ const queryId = `iQtK4dl5hBmXewYZuEOKVw`;
+ const url = new URL(`https://x.com/i/api/graphql/${queryId}/DeleteRetweet`);
+ const body = {
+ variables: { tweet_id, dark_request: false },
+ queryId,
+ };
+ return await this.postCall(url, body);
+ }
+ async removeTweet(tweet_id: string) {
+ const queryId = `VaenaVgh5q5ih7kvyVjgtg`;
+ const url = new URL(`https://x.com/i/api/graphql/${queryId}/DeleteTweet`);
+ const body = {
+ variables: { tweet_id, dark_request: false },
+ queryId,
+ };
+ return await this.postCall(url, body);
+ }
+
+ async addBookmark(tweet_id: string) {
+ const queryId = `aoDbu3RHznuiSkQ9aNM67Q`;
+ const url = new URL(
+ `https://x.com/i/api/graphql/${queryId}/CreateBookmark`,
+ );
+ const body = { variables: { tweet_id }, queryId };
+ try {
+ const res = await this.postCall(url, body);
+ return res?.data?.tweet_bookmark_put === "Done";
+ } catch (e) {
+ console.log("wtf man", e);
+ // return this.removeBookmark(tweet_id);
+ }
+ }
+ async removeBookmark(tweet_id: string) {
+ const queryId = `Wlmlj2-xzyS1GN3a6cj-mQ`;
+ const url = new URL(
+ `https://x.com/i/api/graphql/${queryId}/DeleteBookmark`,
+ );
+ const body = { variables: { tweet_id }, queryId };
+ const res = await this.postCall(url, body);
+ return res?.data?.tweet_bookmark_delete === "Done";
+ }
+}
diff --git a/packages/tweetdeck/src/lib/fetching/types.ts b/packages/tweetdeck/src/lib/fetching/types.ts
new file mode 100644
index 0000000..deb5418
--- /dev/null
+++ b/packages/tweetdeck/src/lib/fetching/types.ts
@@ -0,0 +1,596 @@
+export type TweetList = {
+ tweets: Tweet[];
+ cursorTop: string;
+ cursorBottom: string;
+};
+export interface UserResult {
+ __typename: "User";
+ id: string; // hash
+ rest_id: string; // number
+ affiliates_highlighted_label: {};
+ avatar: {
+ image_url: string;
+ };
+ core: {
+ created_at: string; // date string
+ name: string;
+ screen_name: string;
+ };
+ dm_permissions: {
+ can_dm: boolean;
+ };
+ has_graduated_access: boolean;
+ is_blue_verified: boolean;
+ legacy: {
+ profile_image_url_https?: string;
+ name?: string;
+ screen_name?: string;
+ default_profile: boolean;
+ default_profile_image: boolean;
+ description: string;
+ entities: {
+ description: {
+ urls: APITwitterURLEntity[];
+ };
+ url: {
+ urls: APITwitterURLEntity[];
+ };
+ };
+ fast_followers_count: number;
+ favourites_count: number;
+ followers_count: number;
+ friends_count: number;
+ has_custom_timelines: boolean;
+ is_translator: boolean;
+ listed_count: number;
+ media_count: number;
+ needs_phone_verification: boolean;
+ normal_followers_count: number;
+ pinned_tweet_ids_str: string[];
+ possibly_sensitive: boolean;
+ profile_interstitial_type: string;
+ statuses_count: number;
+ translator_type: string; // "none"
+ url: string;
+ want_retweets: boolean;
+ withheld_in_countries: string[];
+ };
+ location: {
+ location: string;
+ };
+ media_permissions: {
+ can_media_tag: boolean;
+ };
+ parody_commentary_fan_label: string;
+ profile_image_shape: string;
+ privacy: {
+ protected: boolean;
+ };
+ relationship_perspectives: {
+ following: boolean;
+ };
+ tipjar_settings:
+ | {}
+ | {
+ is_enabled: true;
+ bitcoin_handle: string;
+ ethereum_handle: string;
+ patreon_handle: string;
+ }; // TODO
+ super_follow_eligible?: boolean;
+ verification: {
+ verified: boolean;
+ };
+ quick_promote_eligibility?: {
+ eligibility: "IneligibleNotProfessional"; // TODO
+ };
+}
+export interface TweetWithVisibilityResult {
+ __typename: "TweetWithVisibilityResults";
+ tweet: TweetResult;
+ limitedActionResults: {
+ limited_actions: Array<{
+ action: "Reply"; // and?
+ prompts: {
+ __typename: "CtaLimitedActionPrompt"; // ?
+ cta_type: "SeeConversation";
+ headline: { text: string; entities: [] };
+ subtext: { text: string; entities: [] };
+ };
+ }>;
+ };
+}
+export interface TweetResult {
+ __typename: "Tweet";
+ rest_id: string;
+ post_video_description?: string;
+ has_birdwatch_notes?: boolean;
+ unmention_data: {};
+ edit_control: {
+ edit_tweet_ids: string[];
+ editable_until_msecs: string;
+ is_edit_eligible: boolean;
+ edits_remaining: number;
+ };
+ is_translatable: boolean;
+ views: {
+ count: string;
+ state: "EnabledWithCount"; // TODO
+ };
+ source: string; // "<a href=\"http://twitter.com/download/iphone\" rel=\"nofollow\">Twitter for iPhone</a>",
+ grok_analysis_button: boolean;
+ quoted_status_result?: { result: TweetResult };
+ is_quote_status: boolean;
+ legacy: {
+ retweeted_status_result?: { result: TweetResult };
+ quoted_status_id_str?: string;
+ quoted_status_permalink?: {
+ uri: string;
+ expanded: string;
+ display: string;
+ };
+ id_str: string;
+ user_id_str: string;
+ bookmark_count: number;
+ bookmarked: boolean;
+ favorite_count: number;
+ favorited: boolean;
+ quote_count: number;
+ reply_count: number;
+ retweet_count: number;
+ retweeted: boolean;
+ conversation_control: {
+ policy: "ByInvitation"; // TODO
+ conversation_owner_results: {
+ result: {
+ __typename: "User";
+ core: {
+ screen_name: string;
+ };
+ };
+ };
+ };
+ conversation_id_str: string;
+ display_text_range?: [number, number];
+ full_text: string;
+ lang: string;
+ created_at: string;
+ possibly_sensitive: boolean;
+ possibly_sensitive_Editable: boolean;
+ entities: {
+ hashtags?: Array<{ text: string }>;
+ media?: APITwitterMediaEntity[];
+ symbols: string[];
+ timestamps: string[];
+ urls: APITwitterURLEntity[]; // TODO
+ user_mentions: Array<{
+ id_str: string;
+ name: string;
+ screen_name: string;
+ indices: [number, number];
+ }>;
+ };
+ extended_entities: {
+ media: APITwitterMediaExtendedEntity[];
+ };
+ limitedActionResults: {
+ limited_actions: Array<{
+ actions: "Reply"; // TODO;
+ prompts: {
+ cta_type: string;
+ headline: {
+ text: string;
+ entities: APITwitterMediaEntity[]; // ?
+ };
+ subtext: {
+ text: string;
+ entities: APITwitterMediaEntity[];
+ };
+ };
+ }>;
+ };
+ };
+ core: {
+ user_results?: {
+ result: UserResult;
+ };
+ };
+}
+interface APITwitterURLEntity {
+ display_url: string;
+ expanded_url: string;
+ url: string; // minified
+ indices: [number, number];
+}
+type APITwitterMediaEntity = APITwitterPhotoEntity | APITwitterVideoEntity;
+interface APITwitterMediaBase {
+ additional_media_info?: {
+ monetizable: boolean;
+ };
+ display_url: string;
+ expanded_url: string;
+ id_str: string;
+ indices: [number, number];
+ media_key: string;
+ media_url_https: string;
+ url: string; // minified
+ ext_media_availability: {
+ status: "Available" | "Unavailable"; // ?
+ };
+ features: {
+ large: {
+ faces: [];
+ };
+ medium: {
+ faces: [];
+ };
+ small: {
+ faces: [];
+ };
+ orig: {
+ faces: [];
+ };
+ };
+ sizes: {
+ large: {
+ h: number;
+ w: number;
+ resize: "fit" | "crop";
+ };
+ medium: {
+ h: number;
+ w: number;
+ resize: "fit" | "crop";
+ };
+ small: {
+ h: number;
+ w: number;
+ resize: "fit" | "crop";
+ };
+ thumb: {
+ h: number;
+ w: number;
+ resize: "fit" | "crop";
+ };
+ };
+ original_info: {
+ height: number;
+ width: number;
+ focus_rects: [
+ {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ },
+ {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ },
+ {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ },
+ {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ },
+ {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ },
+ ];
+ };
+ media_results: {
+ result: {
+ media_key: string;
+ };
+ };
+}
+interface APITwitterPhotoEntity extends APITwitterMediaBase {
+ type: "photo";
+}
+interface APITwitterVideoEntity extends APITwitterMediaBase {
+ type: "video";
+
+ video_info: {
+ aspect_ratio: [number, number];
+ duration_millis: number;
+ variants: Array<
+ | {
+ content_type: "application/x-mpegURL";
+ url: string;
+ }
+ | {
+ content_type: "video/mp4";
+ bitrate: number;
+ url: string;
+ }
+ >;
+ };
+}
+
+type APITwitterMediaExtendedEntity = APITwitterMediaEntity;
+
+export interface TimelineEntry {
+ entryId: string;
+ sortIndex: string;
+ content: {
+ entryType: string;
+ __typename: string;
+ itemContent?: {
+ itemType: string;
+ __typename: string;
+ tweet_results?: {
+ result?: TweetResult | TweetWithVisibilityResult;
+ };
+ user_results?: {
+ result?: {
+ __typename: string;
+ id: string;
+ rest_id: string;
+ legacy: {
+ name?: string;
+ screen_name?: string;
+ profile_image_url_https?: string;
+ };
+ };
+ };
+ };
+ cursorType?: string;
+ value?: string;
+ stopOnEmptyResponse?: boolean;
+ };
+}
+
+export interface TimelineInstruction {
+ type: string;
+ entries?: TimelineEntry[];
+}
+
+export interface TwitterProfilesResponse {
+ data: { users: Array<{ result: UserResult }> };
+}
+export interface TwitterUserTweetsResponse {
+ data: {
+ user: {
+ result: {
+ __typename: "User";
+ timeline: {
+ timeline: {
+ instructions: TimelineInstruction[];
+ };
+ };
+ };
+ };
+ };
+}
+export interface TwitterTweetDetailResponse {
+ data: {
+ threaded_conversation_with_injections_v2: {
+ instructions: TimelineInstruction[];
+ };
+ };
+}
+export interface TwitterBookmarkResponse {
+ data: {
+ bookmark_timeline_v2: {
+ timeline: {
+ instructions: TimelineInstruction[];
+ };
+ };
+ };
+}
+
+export interface TwitterTimelineResponse {
+ data: {
+ home?: {
+ home_timeline_urt: {
+ instructions: TimelineInstruction[];
+ };
+ };
+ home_timeline_urt?: {
+ instructions: TimelineInstruction[];
+ };
+ };
+}
+
+export interface TwitterListTimelineResponse {
+ data: {
+ list: {
+ tweets_timeline: {
+ timeline: {
+ instructions: TimelineInstruction[];
+ };
+ };
+ };
+ };
+}
+
+export interface TwitterList {
+ id: string;
+ name: string;
+ member_count: number;
+ subscriber_count: number;
+ creator: string;
+}
+export interface APITwitterMediaInfo {
+ original_img_url: string;
+ original_img_width: number;
+ original_img_height: number;
+ salient_rect: {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+ };
+}
+export interface APITwitterList {
+ created_at: number;
+ default_banner_media: {
+ media_info: APITwitterMediaInfo;
+ };
+ default_banner_media_results: {
+ result: {
+ id: string;
+ media_key: string;
+ media_id: string;
+ media_info: APITwitterMediaInfo;
+ __typename: "ApiMedia";
+ };
+ };
+ description: string;
+ facepile_urls: string[];
+ following: boolean;
+ id: string; //hash
+ id_str: string; // timestamp
+ is_member: boolean;
+ member_count: number;
+ members_context: string;
+ mode: string; // "private or public"
+ muting: boolean;
+ name: string;
+ pinning: boolean;
+ subscriber_count: number;
+ user_results: {
+ result: UserResult;
+ };
+}
+
+export interface TwitterListsManagementResponse {
+ data: {
+ viewer: {
+ list_management_timeline: {
+ timeline: {
+ instructions: Array<{
+ type: string;
+ entries: Array<{
+ content: {
+ __typename: string;
+ items: Array<{
+ entryId: string;
+ item: {
+ clientEventInfo: any;
+ itemContent: {
+ itemType: "TimelineTwitterList";
+ displayType: "ListWithPin"; // ?
+ list: APITwitterList;
+ };
+ };
+ }>;
+ };
+ }>;
+ }>;
+ };
+ };
+ };
+ };
+}
+
+export interface TwitterNotification {
+ id: string;
+ timestampMs: string;
+ message: {
+ text: string;
+ entities: Array<{
+ fromIndex: number;
+ toIndex: number;
+ ref: {
+ type: string;
+ screenName?: string;
+ mentionResults?: {
+ result?: {
+ legacy?: {
+ name?: string;
+ screen_name?: string;
+ };
+ };
+ };
+ };
+ }>;
+ rtl: boolean;
+ };
+ icon: {
+ id: string;
+ };
+ users: {
+ [key: string]: {
+ id: string;
+ screen_name: string;
+ name: string;
+ profile_image_url_https: string;
+ };
+ };
+}
+
+export interface TwitterNotificationsTimelineResponse {
+ globalObjects: {
+ notifications: { [id: string]: TwitterNotification };
+ users: {
+ [id: string]: {
+ id: string;
+ screen_name: string;
+ name: string;
+ profile_image_url_https: string;
+ };
+ };
+ tweets: { [id: string]: Tweet };
+ };
+ timeline: {
+ id: string;
+ instructions: Array<{
+ addEntries?: {
+ entries: Array<{
+ entryId: string;
+ sortIndex: string;
+ content: {
+ notification: {
+ id: string;
+ urls: Array<{
+ url: string;
+ expandedUrl: string;
+ displayUrl: string;
+ }>;
+ };
+ };
+ }>;
+ };
+ }>;
+ };
+}
+
+export type TwitterUser = {
+ id: string;
+ avatar: string;
+ name: string;
+ username: string;
+};
+export interface Tweet {
+ id: string;
+ text: string;
+ language: string;
+ author: TwitterUser;
+ time: number;
+ urls: Array<{
+ expandedUrl: string;
+ displayUrl: string;
+ }>;
+ media: { pics: string[]; video: { thumb: string; url: string } };
+ hashtags: string[];
+ quoting: Tweet | null;
+ liked: boolean;
+ bookmarked: boolean;
+ retweeted_by: RTMetadata | null;
+ rted: boolean;
+ replyingTo: Array<{ username: string }>;
+}
+export type RTMetadata = { author: TwitterUser; time: number };
+export type TwitterBookmark = Tweet;
diff --git a/packages/tweetdeck/src/lib/utils/id.ts b/packages/tweetdeck/src/lib/utils/id.ts
new file mode 100644
index 0000000..3008587
--- /dev/null
+++ b/packages/tweetdeck/src/lib/utils/id.ts
@@ -0,0 +1,4 @@
+export const generateId = () =>
+ typeof crypto !== "undefined" && "randomUUID" in crypto
+ ? crypto.randomUUID()
+ : Math.random().toString(36).slice(2, 11);
diff --git a/packages/tweetdeck/src/lib/utils/time.ts b/packages/tweetdeck/src/lib/utils/time.ts
new file mode 100644
index 0000000..f2802bf
--- /dev/null
+++ b/packages/tweetdeck/src/lib/utils/time.ts
@@ -0,0 +1,18 @@
+export function timeAgo(date: string | number | Date) {
+ const ts = typeof date === "string" || typeof date === "number" ? new Date(date).getTime() : date.getTime();
+ const diff = Date.now() - ts;
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h`;
+ const days = Math.floor(hours / 24);
+ if (days < 7) return `${days}d`;
+ const weeks = Math.floor(days / 7);
+ if (weeks < 4) return `${weeks}w`;
+ const months = Math.floor(days / 30);
+ if (months < 12) return `${months}mo`;
+ const years = Math.floor(days / 365);
+ return `${years}y`;
+}