diff --git a/src/api/status.ts b/src/api/status.ts index 545f8fb..05a3e5a 100644 --- a/src/api/status.ts +++ b/src/api/status.ts @@ -7,12 +7,14 @@ import { colorFromPalette } from '../helpers/palette'; import { translateTweet } from '../helpers/translate'; import { unescapeText } from '../helpers/utils'; import { processMedia } from '../helpers/media'; +import { convertToApiUser } from './user'; +import { isGraphQLTweet, isGraphQLTweetNotFoundResponse } from '../utils/graphql'; /* This function does the heavy lifting of processing data from Twitter API and using it to create FixTweet's streamlined API responses */ const populateTweetProperties = async ( - tweet: TweetPartial, - conversation: TimelineBlobPartial, + tweet: GraphQLTweet, + conversation: any, // TimelineBlobPartial, language: string | undefined // eslint-disable-next-line sonarjs/cognitive-complexity ): Promise => { @@ -21,54 +23,51 @@ const populateTweetProperties = async ( /* With v2 conversation API we re-add the user object ot the tweet because Twitter stores it separately in the conversation API. This is to consolidate it in case a user appears multiple times in a thread. */ - tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {}; - - const user = tweet.user as UserPartial; - const screenName = user?.screen_name || ''; - const name = user?.name || ''; + const graphQLUser = tweet.core.user_results.result; + const apiUser = convertToApiUser(graphQLUser); /* Populating a lot of the basics */ - apiTweet.url = `${Constants.TWITTER_ROOT}/${screenName}/status/${tweet.id_str}`; - apiTweet.id = tweet.id_str; - apiTweet.text = unescapeText(linkFixer(tweet, tweet.full_text || '')); + apiTweet.url = `${Constants.TWITTER_ROOT}/${apiUser.screen_name}/status/${tweet.rest_id}`; + apiTweet.id = tweet.rest_id; + apiTweet.text = unescapeText(linkFixer(tweet, tweet.legacy.full_text || '')); apiTweet.author = { - id: tweet.user_id_str, - name: name, - screen_name: screenName, + id: apiUser.id, + name: apiUser.name, + screen_name: apiUser.screen_name, avatar_url: - (user?.profile_image_url_https || '').replace('_normal', '_200x200') || '', - avatar_color: colorFromPalette( + (apiUser.avatar_url || '').replace('_normal', '_200x200') || '', + avatar_color: '0000FF' /* colorFromPalette( tweet.user?.profile_image_extensions_media_color?.palette || [] - ), - banner_url: user?.profile_banner_url || '' + ),*/, + banner_url: apiUser.banner_url || '' }; - apiTweet.replies = tweet.reply_count; - apiTweet.retweets = tweet.retweet_count; - apiTweet.likes = tweet.favorite_count; + apiTweet.replies = tweet.legacy.reply_count; + apiTweet.retweets = tweet.legacy.retweet_count; + apiTweet.likes = tweet.legacy.favorite_count; apiTweet.color = apiTweet.author.avatar_color; apiTweet.twitter_card = 'tweet'; - apiTweet.created_at = tweet.created_at; - apiTweet.created_timestamp = new Date(tweet.created_at).getTime() / 1000; + apiTweet.created_at = tweet.legacy.created_at; + apiTweet.created_timestamp = new Date(tweet.legacy.created_at).getTime() / 1000; - apiTweet.possibly_sensitive = tweet.possibly_sensitive; + apiTweet.possibly_sensitive = tweet.legacy.possibly_sensitive; - if (tweet.ext_views?.state === 'EnabledWithCount') { - apiTweet.views = parseInt(tweet.ext_views.count || '0') ?? null; + if (tweet.views.state === 'EnabledWithCount') { + apiTweet.views = parseInt(tweet.views.count || '0') ?? null; } else { apiTweet.views = null; } - if (tweet.lang !== 'unk') { - apiTweet.lang = tweet.lang; + if (tweet.legacy.lang !== 'unk') { + apiTweet.lang = tweet.legacy.lang; } else { apiTweet.lang = null; } - apiTweet.replying_to = tweet.in_reply_to_screen_name || null; - apiTweet.replying_to_status = tweet.in_reply_to_status_id_str || null; - + apiTweet.replying_to = tweet.legacy?.in_reply_to_screen_name || null; + apiTweet.replying_to_status = tweet.legacy?.in_reply_to_status_id_str || null; + const mediaList = Array.from( - tweet.extended_entities?.media || tweet.entities?.media || [] + tweet.legacy.extended_entities?.media || tweet.legacy.entities?.media || [] ); // console.log('tweet', JSON.stringify(tweet)); @@ -94,13 +93,15 @@ const populateTweetProperties = async ( }); /* Grab color palette data */ + /* if (mediaList[0]?.ext_media_color?.palette) { apiTweet.color = colorFromPalette(mediaList[0].ext_media_color.palette); } + */ /* Handle photos and mosaic if available */ if ((apiTweet.media?.photos?.length || 0) > 1) { - const mosaic = await handleMosaic(apiTweet.media?.photos || [], tweet.id_str); + const mosaic = await handleMosaic(apiTweet.media?.photos || [], tweet.rest_id); if (typeof apiTweet.media !== 'undefined' && mosaic !== null) { apiTweet.media.mosaic = mosaic; } @@ -115,6 +116,7 @@ const populateTweetProperties = async ( } /* Populate a Twitter card */ + if (tweet.card) { const card = await renderCard(tweet.card); if (card.external_media) { @@ -128,7 +130,7 @@ const populateTweetProperties = async ( } /* If a language is specified in API or by user, let's try translating it! */ - if (typeof language === 'string' && language.length === 2 && language !== tweet.lang) { + if (typeof language === 'string' && language.length === 2 && language !== tweet.legacy.lang) { const translateAPI = await translateTweet( tweet, conversation.guestToken || '', @@ -186,59 +188,62 @@ export const statusAPI = async ( event: FetchEvent, flags?: InputFlags ): Promise => { - let conversation = await fetchConversation(status, event); - let tweet = conversation?.globalObjects?.tweets?.[status] || {}; - let wasMediaBlockedNSFW = false; - + let conversation = await fetchConversation(status, event); + let tweet: GraphQLTweet | TweetTombstone; + if (isGraphQLTweetNotFoundResponse(conversation)) { + writeDataPoint(event, language, wasMediaBlockedNSFW, 'NOT_FOUND', flags); + return { code: 404, message: 'NOT_FOUND' }; + } + /* Fallback for if Tweet did not load (i.e. NSFW) */ + if (Object.keys(conversation).length === 0) { + // Try again using elongator API proxy + console.log('No Tweet was found, loading again from elongator'); + conversation = await fetchConversation(status, event, true); + if (Object.keys(conversation).length === 0) { + writeDataPoint(event, language, wasMediaBlockedNSFW, 'NOT_FOUND', flags); + return { code: 404, message: 'NOT_FOUND' }; + } + // If the tweet now loads, it was probably NSFW + wasMediaBlockedNSFW = true; + } + // Find this specific tweet in the conversation + try { + const instructions = conversation?.data?.threaded_conversation_with_injections_v2?.instructions; + if (!Array.isArray(instructions)) { + console.log(JSON.stringify(conversation, null, 2)); + throw new Error('Invalid instructions'); + } + const timelineAddEntries = instructions.find((e): e is TimeLineAddEntriesInstruction => e?.type === 'TimelineAddEntries'); + if (!timelineAddEntries) throw new Error('No valid timeline entries'); + const graphQLTimelineTweetEntry = timelineAddEntries.entries + .find((e): e is GraphQLTimelineTweetEntry => + // TODO Fix this idk what's up with the typings + !!(e && typeof e === 'object' && ('entryId' in e) && e?.entryId === `tweet-${status}`)); + if (!graphQLTimelineTweetEntry) throw new Error('No tweet entry with'); + tweet = graphQLTimelineTweetEntry?.content?.itemContent?.tweet_results?.result; + if (!tweet) throw new Error('No tweet in timeline entry'); + } catch (e) { + // Api failure at parsing status + console.log('Tweet could not be accessed, got conversation ', conversation); + writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags); + return { code: 500, message: 'API_FAIL' }; + } + // If the tweet is not a graphQL tweet it's a tombstone, return the error to the user + if (!isGraphQLTweet(tweet)) { + console.log('Tweet was not a valid tweet', tweet); + writeDataPoint(event, language, wasMediaBlockedNSFW, 'PRIVATE_TWEET', flags); + return { code: 401, message: 'PRIVATE_TWEET' }; + } + + /* if (tweet.retweeted_status_id_str) { tweet = conversation?.globalObjects?.tweets?.[tweet.retweeted_status_id_str] || {}; } + */ - /* Fallback for if Tweet did not load (i.e. NSFW) */ - if (typeof tweet.full_text === 'undefined') { - if (conversation.timeline?.instructions?.length > 0) { - /* Try again using elongator API proxy */ - console.log('No Tweet was found, loading again from elongator'); - conversation = await fetchConversation(status, event, true); - tweet = conversation?.globalObjects?.tweets?.[status] || {}; - - if (typeof tweet.full_text !== 'undefined') { - console.log('Successfully loaded Tweet using elongator'); - wasMediaBlockedNSFW = true; - } else if ( - typeof tweet.full_text === 'undefined' && - conversation.timeline?.instructions?.length > 0 - ) { - console.log( - 'Tweet could not be accessed with elongator, must be private/suspended, got tweet ', - tweet, - ' conversation ', - conversation - ); - - writeDataPoint(event, language, wasMediaBlockedNSFW, 'PRIVATE_TWEET', flags); - return { code: 401, message: 'PRIVATE_TWEET' }; - } - } else { - /* {"errors":[{"code":34,"message":"Sorry, that page does not exist."}]} */ - if (conversation.errors?.[0]?.code === 34) { - writeDataPoint(event, language, wasMediaBlockedNSFW, 'NOT_FOUND', flags); - return { code: 404, message: 'NOT_FOUND' }; - } - - /* Commented this the part below out for now since it seems like atm this check doesn't actually do anything */ - - /* Tweets object is completely missing, smells like API failure */ - // if (typeof conversation?.globalObjects?.tweets === 'undefined') { - // writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags); - // return { code: 500, message: 'API_FAIL' }; - // } - - /* If we have no idea what happened then just return API error */ - writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags); - return { code: 500, message: 'API_FAIL' }; - } + if (!tweet) { + return { code: 404, message: 'NOT_FOUND' }; } /* Creating the response objects */ @@ -250,8 +255,7 @@ export const statusAPI = async ( )) as APITweet; /* We found a quote tweet, let's process that too */ - const quoteTweet = - conversation.globalObjects?.tweets?.[tweet.quoted_status_id_str || '0'] || null; + const quoteTweet = tweet.quoted_status_result; if (quoteTweet) { apiTweet.quote = (await populateTweetProperties( quoteTweet, diff --git a/src/api/user.ts b/src/api/user.ts index 9230fd5..603d35c 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -1,15 +1,8 @@ import { Constants } from '../constants'; import { fetchUser } from '../fetch'; -/* This function does the heavy lifting of processing data from Twitter API - and using it to create FixTweet's streamlined API responses */ -const populateUserProperties = async ( - response: GraphQLUserResponse - // eslint-disable-next-line sonarjs/cognitive-complexity -): Promise => { +export const convertToApiUser = (user: GraphQLUser): APIUser => { const apiUser = {} as APIUser; - - const user = response.data.user.result; /* Populating a lot of the basics */ apiUser.url = `${Constants.TWITTER_ROOT}/${user.legacy.screen_name}`; apiUser.id = user.rest_id; @@ -51,6 +44,16 @@ const populateUserProperties = async ( return apiUser; }; +/* This function does the heavy lifting of processing data from Twitter API + and using it to create FixTweet's streamlined API responses */ +const populateUserProperties = async ( + response: GraphQLUserResponse + // eslint-disable-next-line sonarjs/cognitive-complexity +): Promise => { + const user = response.data.user.result; + return convertToApiUser(user); +}; + /* API for Twitter profiles (Users) Used internally by FixTweet's embed service, or available for free using api.fxtwitter.com. */ diff --git a/src/fetch.ts b/src/fetch.ts index 6c6ef68..d66d943 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,5 +1,6 @@ import { Constants } from './constants'; import { generateUserAgent } from './helpers/useragent'; +import { isGraphQLTweetNotFoundResponse } from './utils/graphql'; const API_ATTEMPTS = 16; @@ -130,7 +131,12 @@ export const twitterFetch = async ( headers: headers }); } - + /* + If the tweet is nsfw, the body is empty and status is 404 + const raw = await apiRequest?.clone().text(); + console.log('Raw response:', raw); + console.log('Response code:', apiRequest?.status); + */ response = await apiRequest?.json(); } catch (e: unknown) { /* We'll usually only hit this if we get an invalid response from Twitter. @@ -188,20 +194,71 @@ export const fetchConversation = async ( status: string, event: FetchEvent, useElongator = false -): Promise => { +): Promise => { return (await twitterFetch( - `${Constants.TWITTER_API_ROOT}/2/timeline/conversation/${status}.json?${Constants.GUEST_FETCH_PARAMETERS}`, + `${ + Constants.TWITTER_ROOT + }/i/api/graphql/TuC3CinYecrqAyqccUyFhw/TweetDetail?variables=${encodeURIComponent( + JSON.stringify({ + focalTweetId: status, + referrer: 'messages', + with_rux_injections:false, + includePromotedContent:true, + withCommunity:true, + withQuickPromoteEligibilityTweetFields:true, + withArticleRichContent:false, + withBirdwatchNotes:true, + withVoice:true, + withV2Timeline:true + }) + )}&features=${encodeURIComponent( + JSON.stringify({ + rweb_lists_timeline_redesign_enabled:true, + responsive_web_graphql_exclude_directive_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, + tweetypie_unmention_optimization_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:false, + tweet_awards_web_tipping_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_media_download_video_enabled:false, + responsive_web_enhance_cards_enabled:false + }) + )}&fieldToggles=${encodeURIComponent( + JSON.stringify({ + // TODO Figure out what this property does + withArticleRichContentState: false + }) + )}`, event, useElongator, (_conversation: unknown) => { - const conversation = _conversation as TimelineBlobPartial; - return !( - typeof conversation.globalObjects === 'undefined' && - (typeof conversation.errors === 'undefined' || - conversation.errors?.[0]?.code === 239) - ); + const conversation = _conversation as GraphQLTweetDetailResponse; + // If we get a not found error it's still a valid response + if (isGraphQLTweetNotFoundResponse(conversation)) return true; + const instructions = conversation?.data?.threaded_conversation_with_injections_v2?.instructions; + if (!Array.isArray(instructions)) return false; + const timelineAddEntries = instructions.find((e): e is TimeLineAddEntriesInstruction => e?.type === 'TimelineAddEntries'); + if (!timelineAddEntries) return false; + const graphQLTimelineTweetEntry = timelineAddEntries.entries + .find((e): e is GraphQLTimelineTweetEntry => + // TODO Fix this idk what's up with the typings + !!(e && typeof e === 'object' && ('entryId' in e) && e?.entryId === `tweet-${status}`)); + if (!graphQLTimelineTweetEntry) return false; + const tweet = graphQLTimelineTweetEntry?.content?.itemContent?.tweet_results?.result; + return !!tweet; } - )) as TimelineBlobPartial; + )) as GraphQLTweetDetailResponse; }; export const fetchUser = async ( diff --git a/src/helpers/card.ts b/src/helpers/card.ts index 865e350..1be6ebe 100644 --- a/src/helpers/card.ts +++ b/src/helpers/card.ts @@ -2,33 +2,43 @@ import { calculateTimeLeftString } from './pollTime'; /* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */ export const renderCard = async ( - card: TweetCard + card: GraphQLTweet['card'] ): Promise<{ poll?: APIPoll; external_media?: APIExternalMedia }> => { - const values = card.binding_values; + // We convert the binding_values array into an object with the legacy format + // TODO Clean this up + const binding_values: Record = {}; + if (Array.isArray(card.legacy.binding_values)) { + card.legacy.binding_values.forEach(value => { + if (value.key && value.value) { + binding_values[value.key] = value.value; + } + }); + } + console.log('rendering card'); - if (typeof values !== 'undefined') { - if (typeof values.choice1_count !== 'undefined') { + if (typeof binding_values !== 'undefined') { + if (typeof binding_values.choice1_count !== 'undefined') { const poll = {} as APIPoll; - poll.ends_at = values.end_datetime_utc?.string_value || ''; + poll.ends_at = binding_values.end_datetime_utc?.string_value || ''; poll.time_left_en = calculateTimeLeftString( - new Date(values.end_datetime_utc?.string_value || '') + new Date(binding_values.end_datetime_utc?.string_value || '') ); const choices: { [label: string]: number } = { - [values.choice1_label?.string_value || '']: parseInt( - values.choice1_count?.string_value || '0' + [binding_values.choice1_label?.string_value || '']: parseInt( + binding_values.choice1_count?.string_value || '0' ), - [values.choice2_label?.string_value || '']: parseInt( - values.choice2_count?.string_value || '0' + [binding_values.choice2_label?.string_value || '']: parseInt( + binding_values.choice2_count?.string_value || '0' ), - [values.choice3_label?.string_value || '']: parseInt( - values.choice3_count?.string_value || '0' + [binding_values.choice3_label?.string_value || '']: parseInt( + binding_values.choice3_count?.string_value || '0' ), - [values.choice4_label?.string_value || '']: parseInt( - values.choice4_count?.string_value || '0' + [binding_values.choice4_label?.string_value || '']: parseInt( + binding_values.choice4_count?.string_value || '0' ) }; @@ -46,17 +56,17 @@ export const renderCard = async ( }); return { poll: poll }; - } else if (typeof values.player_url !== 'undefined') { + } else if (typeof binding_values.player_url !== 'undefined' && binding_values.player_url.string_value) { /* Oh good, a non-Twitter video URL! This enables YouTube embeds and stuff to just work */ return { external_media: { type: 'video', - url: values.player_url.string_value, + url: binding_values.player_url.string_value, width: parseInt( - (values.player_width?.string_value || '1280').replace('px', '') + (binding_values.player_width?.string_value || '1280').replace('px', '') ), // TODO: Replacing px might not be necessary, it's just there as a precaution height: parseInt( - (values.player_height?.string_value || '720').replace('px', '') + (binding_values.player_height?.string_value || '720').replace('px', '') ) } }; diff --git a/src/helpers/linkFixer.ts b/src/helpers/linkFixer.ts index d28bcd9..29a82d3 100644 --- a/src/helpers/linkFixer.ts +++ b/src/helpers/linkFixer.ts @@ -1,7 +1,7 @@ /* Helps replace t.co links with their originals */ -export const linkFixer = (tweet: TweetPartial, text: string): string => { - if (typeof tweet.entities?.urls !== 'undefined') { - tweet.entities?.urls.forEach((url: TcoExpansion) => { +export const linkFixer = (tweet: GraphQLTweet, text: string): string => { + if (Array.isArray(tweet.legacy.entities?.urls) && tweet.legacy.entities.urls.length) { + tweet.legacy.entities.urls.forEach((url: TcoExpansion) => { let newURL = url.expanded_url; if (newURL.match(/^https:\/\/twitter\.com\/i\/web\/status\/\w+/g) !== null) { diff --git a/src/helpers/translate.ts b/src/helpers/translate.ts index fa8921c..79bc2b3 100644 --- a/src/helpers/translate.ts +++ b/src/helpers/translate.ts @@ -2,7 +2,7 @@ import { Constants } from '../constants'; /* Handles translating Tweets when asked! */ export const translateTweet = async ( - tweet: TweetPartial, + tweet: GraphQLTweet, guestToken: string, language: string ): Promise => { @@ -29,7 +29,7 @@ export const translateTweet = async ( try { apiRequest = await fetch( - `${Constants.TWITTER_ROOT}/i/api/1.1/strato/column/None/tweetId=${tweet.id_str},destinationLanguage=None,translationSource=Some(Google),feature=None,timeout=None,onlyCached=None/translation/service/translateTweet`, + `${Constants.TWITTER_ROOT}/i/api/1.1/strato/column/None/tweetId=${tweet.rest_id},destinationLanguage=None,translationSource=Some(Google),feature=None,timeout=None,onlyCached=None/translation/service/translateTweet`, { method: 'GET', headers: headers diff --git a/src/types/twitterTypes.d.ts b/src/types/twitterTypes.d.ts index 2086021..bf85f94 100644 --- a/src/types/twitterTypes.d.ts +++ b/src/types/twitterTypes.d.ts @@ -304,3 +304,156 @@ type GraphQLUser = { }; }; }; + +type GraphQLTweet = { + __typename: 'Tweet'; + rest_id: string; // "1674824189176590336", + has_birdwatch_notes: false, + core: { + user_results: { + result: GraphQLUser; + } + } + edit_control: unknown, + edit_perspective: unknown, + is_translatable: false, + views: { + count: string; // "562" + state: string; // "EnabledWithCount" + } + source: string; // "Twitter Web App" + quoted_status_result?: GraphQLTweet; + legacy: { + created_at: string; // "Tue Sep 14 20:00:00 +0000 2021" + conversation_id_str: string; // "1674824189176590336" + bookmark_count: number; // 0 + bookmarked: boolean; // false + favorite_count: number; // 28 + full_text: string; // "This is a test tweet" + in_reply_to_screen_name: string; // "username" + in_reply_to_status_id_str: string; // "1674824189176590336" + in_reply_to_user_id_str: string; // "783214" + is_quote_status: boolean; // false + quote_count: number; // 39 + quoted_status_id_str: string; // "1674824189176590336" + quoted_status_permalink: { + url: string; // "https://t.co/aBcDeFgHiJ" + expanded: string; // "https://twitter.com/username/status/1674824189176590336" + display: string; // "twitter.com/username/statu…" + }; + reply_count: number; // 1 + retweet_count: number; // 4 + lang: string; // "en" + possibly_sensitive: boolean; // false + possibly_sensitive_editable: boolean; // false + entities: { + media: { + display_url: string; // "pic.twitter.com/1X2X3X4X5X" + expanded_url: string; // "https://twitter.com/username/status/1674824189176590336/photo/1" "https://twitter.com/username/status/1674824189176590336/video/1" + id_str: string; // "1674824189176590336" + indices: [number, number]; // [number, number] + media_url_https: string; // "https://pbs.twimg.com/media/FAKESCREENSHOT.jpg" With videos appears to be the thumbnail + type: string; // "photo" Seems to be photo even with videos + }[] + user_mentions: unknown[]; + urls: TcoExpansion[]; + hashtags: unknown[]; + symbols: unknown[]; + } + extended_entities: { + media: TweetMedia[] + } + } + card: { + rest_id: string; // "card://1674824189176590336", + legacy: { + binding_values: { + key: `choice${1|2|3|4}_label`|'counts_are_final'|`choice${1|2|3|4}_count`|'last_updated_datetime_utc'|'duration_minutes'|'api'|'card_url' + value: { + string_value: string; // "Option text" + type: 'STRING' + }|{ + boolean_value: boolean; // true + type: 'BOOLEAN' + } + }[] + } + } +} +type TweetTombstone = { + __typename: 'TweetTombstone'; + tombstone: { + __typename: 'TextTombstone'; + text: { + rtl: boolean; // false; + text: string; // "You’re unable to view this Tweet because this account owner limits who can view their Tweets. Learn more" + entities: unknown[]; + } + } +} +type GraphQLTimelineTweetEntry = { + /** The entryID contains the tweet ID */ + entryId: `tweet-${number}`; // "tweet-1674824189176590336" + sortIndex: string; + content: { + entryType: 'TimelineTimelineItem', + __typename: 'TimelineTimelineItem', + itemContent: { + item: 'TimelineTweet', + __typename: 'TimelineTweet', + tweet_results: { + result: GraphQLTweet|TweetTombstone; + } + } + } +} +type GraphQLConversationThread = { + entryId: `conversationthread-${number}`; // "conversationthread-1674824189176590336" + sortIndex: string; +} + +type GraphQLTimelineEntry = GraphQLTimelineTweetEntry|GraphQLConversationThread|unknown; + +type V2ThreadInstruction = TimeLineAddEntriesInstruction | TimeLineTerminateTimelineInstruction; + +type TimeLineAddEntriesInstruction = { + type: 'TimelineAddEntries'; + entries: GraphQLTimelineEntry[]; +} + +type TimeLineTerminateTimelineInstruction = { + type: 'TimelineTerminateTimeline'; + direction: 'Top'; +} +type GraphQLTweetNotFoundResponse = { + errors: [{ + message: string; // "_Missing: No status found with that ID" + locations: unknown[]; + path: string[]; // ["threaded_conversation_with_injections_v2"] + extensions: { + name: string; // "GenericError" + source: string; // "Server" + code: number; // 144 + kind: string; // "NonFatal" + tracing: { + trace_id: string; // "2e39ff747de237db" + } + } + code: number; // 144 + kind: string; // "NonFatal" + name: string; // "GenericError" + source: string; // "Server" + tracing: { + trace_id: string; // "2e39ff747de237db" + } + }] + data: Record; +} +type GraphQLTweetFoundResponse = { + data: { + threaded_conversation_with_injections_v2: { + instructions: V2ThreadInstruction[] + } + } +} +type GraphQLTweetDetailResponse = GraphQLTweetFoundResponse | GraphQLTweetNotFoundResponse; \ No newline at end of file diff --git a/src/utils/graphql.ts b/src/utils/graphql.ts new file mode 100644 index 0000000..a6b23cd --- /dev/null +++ b/src/utils/graphql.ts @@ -0,0 +1,7 @@ +export const isGraphQLTweetNotFoundResponse = (response: unknown): response is GraphQLTweetNotFoundResponse => { + return typeof response === 'object' && response !== null && 'errors' in response && Array.isArray(response.errors) && response.errors.length > 0 && 'message' in response.errors[0] && response.errors[0].message === '_Missing: No status found with that ID'; +}; + +export const isGraphQLTweet = (response: unknown): response is GraphQLTweet => { + return typeof response === 'object' && response !== null && '__typename' in response && response.__typename === 'Tweet'; +} \ No newline at end of file