diff options
Diffstat (limited to 'packages/tweetdeck/src/lib')
| -rw-r--r-- | packages/tweetdeck/src/lib/client/twitterClient.ts | 75 | ||||
| -rw-r--r-- | packages/tweetdeck/src/lib/fetching/python.ts | 52 | ||||
| -rw-r--r-- | packages/tweetdeck/src/lib/fetching/twitter-api.ts | 1178 | ||||
| -rw-r--r-- | packages/tweetdeck/src/lib/fetching/types.ts | 596 | ||||
| -rw-r--r-- | packages/tweetdeck/src/lib/utils/id.ts | 4 | ||||
| -rw-r--r-- | packages/tweetdeck/src/lib/utils/time.ts | 18 |
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`; +} |
