diff --git a/motd.json b/motd.json index 344f288..7c504e4 100644 --- a/motd.json +++ b/motd.json @@ -1,4 +1,3 @@ { - "FixTweet": "https://github.com/FixTweet/FixTweet", - "FixTweet - Embed videos, polls & more": "https://github.com/FixTweet/FixTweet" + "FixTweet - Recovering from API woes": "https://github.com/FixTweet/FixTweet/issues/333" } diff --git a/src/api/status.ts b/src/api/status.ts index 0adfa67..9c8150c 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 => { @@ -27,54 +29,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)); @@ -100,13 +99,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; } @@ -121,8 +122,9 @@ const populateTweetProperties = async ( } /* Populate a Twitter card */ + if (tweet.card) { - const card = await renderCard(tweet.card); + const card = renderCard(tweet.card); if (card.external_media) { apiTweet.twitter_card = 'summary_large_image'; apiTweet.media = apiTweet.media || {}; @@ -134,7 +136,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 || '', @@ -192,61 +194,50 @@ export const statusAPI = async ( event: FetchEvent, flags?: InputFlags ): Promise => { - let conversation = await fetchConversation(status, event); - let tweet = conversation?.globalObjects?.tweets?.[status] || {}; - let wasMediaBlockedNSFW = false; - - if (tweet.retweeted_status_id_str) { - tweet = conversation?.globalObjects?.tweets?.[tweet.retweeted_status_id_str] || {}; + let res = await fetchConversation(status, event); + const tweet = res.data?.tweetResult?.result; + if (!tweet) { + return { code: 404, message: 'NOT_FOUND' }; + } + if (tweet.__typename === 'TweetUnavailable' && tweet.reason === 'NsfwLoggedOut') { + wasMediaBlockedNSFW = true; + res = await fetchConversation(status, event, true); } - /* 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' }; - } + console.log(JSON.stringify(tweet)) + + if (tweet.__typename === 'TweetUnavailable') { + if (tweet.reason === 'Protected') { + writeDataPoint(event, language, wasMediaBlockedNSFW, 'PRIVATE_TWEET', flags); + return { code: 401, message: 'PRIVATE_TWEET' }; + } else if (tweet.reason === 'NsfwLoggedOut') { + // API failure as elongator should have handled this + writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags); + return { code: 500, message: 'API_FAIL' }; } 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 */ + // Api failure at parsing status writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags); return { code: 500, message: 'API_FAIL' }; } } + // If the tweet is not a graphQL tweet something went wrong + if (!isGraphQLTweet(tweet)) { + console.log('Tweet was not a valid tweet', tweet); + writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags); + return { code: 500, message: 'API_FAIL' }; + } + + /* + if (tweet.retweeted_status_id_str) { + tweet = conversation?.globalObjects?.tweets?.[tweet.retweeted_status_id_str] || {}; + } + */ + if (!tweet) { + return { code: 404, message: 'NOT_FOUND' }; + } + const conversation: any[] = []; /* Creating the response objects */ const response: TweetAPIResponse = { code: 200, message: 'OK' } as TweetAPIResponse; const apiTweet: APITweet = (await populateTweetProperties( @@ -256,8 +247,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 593c047..3eca62d 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; @@ -21,6 +14,7 @@ const populateUserProperties = async ( apiUser.screen_name = user.legacy.screen_name; apiUser.description = user.legacy.description; apiUser.location = user.legacy.location; + apiUser.banner_url = user.legacy.profile_banner_url; /* if (user.is_blue_verified) { apiUser.verified = 'blue'; @@ -51,6 +45,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. */ @@ -60,7 +64,12 @@ export const userAPI = async ( flags?: InputFlags ): Promise => { const userResponse = await fetchUser(username, event); - + if (!userResponse || !Object.keys(userResponse).length) { + return { + code: 404, + message: 'User not found' + }; + } /* Creating the response objects */ const response: UserAPIResponse = { code: 200, message: 'OK' } as UserAPIResponse; const apiUser: APIUser = (await populateUserProperties(userResponse)) as APIUser; diff --git a/src/constants.ts b/src/constants.ts index 45be395..9072ae1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -20,7 +20,7 @@ export const Constants = { GUEST_TOKEN_MAX_AGE: 3 * 60 * 60, /* Twitter Web App actually uses Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA instead, but accounts marked as 18+ wouldn't show up then */ - GUEST_BEARER_TOKEN: `Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw`, + GUEST_BEARER_TOKEN: `Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA`, GUEST_FETCH_PARAMETERS: [ 'cards_platform=Web-12', 'include_cards=1', diff --git a/src/embed/status.ts b/src/embed/status.ts index 16caa1c..1a34ca9 100644 --- a/src/embed/status.ts +++ b/src/embed/status.ts @@ -59,6 +59,7 @@ export const handleStatus = async ( case 404: return returnError(Strings.ERROR_TWEET_NOT_FOUND); case 500: + console.log(api); return returnError(Strings.ERROR_API_FAIL); } diff --git a/src/fetch.ts b/src/fetch.ts index c78c579..2f87c3c 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,7 +1,14 @@ import { Constants } from './constants'; import { generateUserAgent } from './helpers/useragent'; +import { isGraphQLTweet } from './utils/graphql'; -const API_ATTEMPTS = 16; +const API_ATTEMPTS = 3; + +function generateCSRFToken() { + const randomBytes = new Uint8Array(160/2); + crypto.getRandomValues(randomBytes); + return Array.from(randomBytes, byte => byte.toString(16).padStart(2, '0')).join(''); +} export const twitterFetch = async ( url: string, @@ -51,14 +58,11 @@ export const twitterFetch = async ( const cache = caches.default; while (apiAttempts < API_ATTEMPTS) { - const csrfToken = crypto - .randomUUID() - .replace( - /-/g, - '' - ); /* Generate a random CSRF token, this doesn't matter, Twitter just cares that header and cookie match */ + /* Generate a random CSRF token, Twitter just cares that header and cookie match, + REST can use shorter csrf tokens (32 bytes) but graphql prefers 160 bytes */ + const csrfToken = generateCSRFToken(); - const headers: { [header: string]: string } = { + const headers: Record = { Authorization: Constants.GUEST_BEARER_TOKEN, ...Constants.BASE_HEADERS }; @@ -130,7 +134,7 @@ export const twitterFetch = async ( headers: headers }); } - + response = await apiRequest?.json(); } catch (e: unknown) { /* We'll usually only hit this if we get an invalid response from Twitter. @@ -188,20 +192,61 @@ 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/2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId?variables=${encodeURIComponent( + JSON.stringify({"tweetId": status,"withCommunity":false,"includePromotedContent":false,"withVoice":false}) + )}&features=${encodeURIComponent( + JSON.stringify({ + creator_subscriptions_tweet_preview_api_enabled:true, + 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_graphql_exclude_directive_enabled:true, + verified_phone_label_enabled:false, + responsive_web_media_download_video_enabled:false, + responsive_web_graphql_skip_user_profile_image_extensions_enabled:false, + responsive_web_graphql_timeline_navigation_enabled:true, + 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 TweetResultsByRestIdResult; + // If we get a not found error it's still a valid response + const tweet = conversation.data?.tweetResult?.result; + if (isGraphQLTweet(tweet)) { + return true; + } + if (tweet?.__typename === 'TweetUnavailable' && tweet.reason === 'Protected') { + return true; + } + if (tweet?.__typename === 'TweetUnavailable' && tweet.reason === 'NsfwLoggedOut') { + return true; + } + if (tweet?.__typename === 'TweetUnavailable') { + return true; + } + // Final clause for checking if it's valid is if there's errors + return Array.isArray(conversation.errors) } - )) as TimelineBlobPartial; + )) as TweetResultsByRestIdResult; }; export const fetchUser = async ( @@ -231,6 +276,10 @@ export const fetchUser = async ( // Validator function (_res: unknown) => { const response = _res as GraphQLUserResponse; + // If _res.data is an empty object, we have no user + if (!Object.keys(response?.data).length) { + return false; + } return !( response?.data?.user?.result?.__typename !== 'User' || typeof response.data.user.result.legacy === 'undefined' diff --git a/src/helpers/card.ts b/src/helpers/card.ts index 865e350..c8e4740 100644 --- a/src/helpers/card.ts +++ b/src/helpers/card.ts @@ -1,34 +1,44 @@ import { calculateTimeLeftString } from './pollTime'; /* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */ -export const renderCard = async ( - card: TweetCard -): Promise<{ poll?: APIPoll; external_media?: APIExternalMedia }> => { - const values = card.binding_values; +export const renderCard = ( + card: GraphQLTweet['card'] +): { poll?: APIPoll; external_media?: APIExternalMedia } => { + // 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..825494b 100644 --- a/src/helpers/linkFixer.ts +++ b/src/helpers/linkFixer.ts @@ -1,7 +1,10 @@ /* 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 => { + console.log('got entites', { + entities: tweet.legacy.entities, + }) + 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/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 diff --git a/test/index.test.ts b/test/index.test.ts index f5b06f0..52ea87b 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -115,7 +115,7 @@ test('API fetch video Tweet', async () => { expect(tweet.url).toEqual('https://twitter.com/Twitter/status/854416760933556224'); expect(tweet.id).toEqual('854416760933556224'); expect(tweet.text).toEqual( - 'Get the sauces ready, #NuggsForCarter has 3 million+ Retweets.' + 'Get the sauces ready, #NuggsForCarter has 3 million+ Retweets. https://t.co/ydLBtfK3Z3' ); expect(tweet.author.screen_name?.toLowerCase()).toEqual('twitter'); expect(tweet.author.id).toEqual('783214'); @@ -160,7 +160,7 @@ test('API fetch multi-photo Tweet', async () => { expect(tweet).toBeTruthy(); expect(tweet.url).toEqual('https://twitter.com/Twitter/status/1445094085593866246'); expect(tweet.id).toEqual('1445094085593866246'); - expect(tweet.text).toEqual('@netflix'); + expect(tweet.text).toEqual('@netflix https://t.co/W0XPnj2qLP'); expect(tweet.author.screen_name?.toLowerCase()).toEqual('twitter'); expect(tweet.author.id).toEqual('783214'); expect(tweet.author.name).toBeTruthy(); @@ -270,3 +270,18 @@ test('API fetch user', async () => { expect(user.birthday.month).toEqual(3); expect(user.birthday.year).toBeUndefined(); }); + +test('API fetch user that does not exist', async () => { + const result = await cacheWrapper( + new Request('https://api.fxtwitter.com/usesaahah123', { + method: 'GET', + headers: botHeaders + }) + ); + expect(result.status).toEqual(404); + const response = (await result.json()) as UserAPIResponse; + expect(response).toBeTruthy(); + expect(response.code).toEqual(404); + expect(response.message).toEqual('User not found'); + expect(response.user).toBeUndefined(); +}); \ No newline at end of file