This commit is contained in:
dangered wolf 2023-08-16 14:57:04 -04:00
commit 2b86939c63
No known key found for this signature in database
GPG key ID: 41E4D37680ED8B58
11 changed files with 222 additions and 139 deletions

View file

@ -1,4 +1,3 @@
{ {
"FixTweet": "https://github.com/FixTweet/FixTweet", "FixTweet - Recovering from API woes": "https://github.com/FixTweet/FixTweet/issues/333"
"FixTweet - Embed videos, polls & more": "https://github.com/FixTweet/FixTweet"
} }

View file

@ -7,12 +7,14 @@ import { colorFromPalette } from '../helpers/palette';
import { translateTweet } from '../helpers/translate'; import { translateTweet } from '../helpers/translate';
import { unescapeText } from '../helpers/utils'; import { unescapeText } from '../helpers/utils';
import { processMedia } from '../helpers/media'; 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 /* This function does the heavy lifting of processing data from Twitter API
and using it to create FixTweet's streamlined API responses */ and using it to create FixTweet's streamlined API responses */
const populateTweetProperties = async ( const populateTweetProperties = async (
tweet: TweetPartial, tweet: GraphQLTweet,
conversation: TimelineBlobPartial, conversation: any, // TimelineBlobPartial,
language: string | undefined language: string | undefined
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APITweet> => { ): Promise<APITweet> => {
@ -27,54 +29,51 @@ const populateTweetProperties = async (
/* With v2 conversation API we re-add the user object ot the tweet because /* 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 Twitter stores it separately in the conversation API. This is to consolidate
it in case a user appears multiple times in a thread. */ it in case a user appears multiple times in a thread. */
tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {}; const graphQLUser = tweet.core.user_results.result;
const apiUser = convertToApiUser(graphQLUser);
const user = tweet.user as UserPartial;
const screenName = user?.screen_name || '';
const name = user?.name || '';
/* Populating a lot of the basics */ /* Populating a lot of the basics */
apiTweet.url = `${Constants.TWITTER_ROOT}/${screenName}/status/${tweet.id_str}`; apiTweet.url = `${Constants.TWITTER_ROOT}/${apiUser.screen_name}/status/${tweet.rest_id}`;
apiTweet.id = tweet.id_str; apiTweet.id = tweet.rest_id;
apiTweet.text = unescapeText(linkFixer(tweet, tweet.full_text || '')); apiTweet.text = unescapeText(linkFixer(tweet, tweet.legacy.full_text || ''));
apiTweet.author = { apiTweet.author = {
id: tweet.user_id_str, id: apiUser.id,
name: name, name: apiUser.name,
screen_name: screenName, screen_name: apiUser.screen_name,
avatar_url: avatar_url:
(user?.profile_image_url_https || '').replace('_normal', '_200x200') || '', (apiUser.avatar_url || '').replace('_normal', '_200x200') || '',
avatar_color: colorFromPalette( avatar_color: '0000FF' /* colorFromPalette(
tweet.user?.profile_image_extensions_media_color?.palette || [] 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.replies = tweet.legacy.reply_count;
apiTweet.retweets = tweet.retweet_count; apiTweet.retweets = tweet.legacy.retweet_count;
apiTweet.likes = tweet.favorite_count; apiTweet.likes = tweet.legacy.favorite_count;
apiTweet.color = apiTweet.author.avatar_color; apiTweet.color = apiTweet.author.avatar_color;
apiTweet.twitter_card = 'tweet'; apiTweet.twitter_card = 'tweet';
apiTweet.created_at = tweet.created_at; apiTweet.created_at = tweet.legacy.created_at;
apiTweet.created_timestamp = new Date(tweet.created_at).getTime() / 1000; 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') { if (tweet.views.state === 'EnabledWithCount') {
apiTweet.views = parseInt(tweet.ext_views.count || '0') ?? null; apiTweet.views = parseInt(tweet.views.count || '0') ?? null;
} else { } else {
apiTweet.views = null; apiTweet.views = null;
} }
if (tweet.lang !== 'unk') { if (tweet.legacy.lang !== 'unk') {
apiTweet.lang = tweet.lang; apiTweet.lang = tweet.legacy.lang;
} else { } else {
apiTweet.lang = null; apiTweet.lang = null;
} }
apiTweet.replying_to = tweet.in_reply_to_screen_name || null; apiTweet.replying_to = tweet.legacy?.in_reply_to_screen_name || null;
apiTweet.replying_to_status = tweet.in_reply_to_status_id_str || null; apiTweet.replying_to_status = tweet.legacy?.in_reply_to_status_id_str || null;
const mediaList = Array.from( 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)); // console.log('tweet', JSON.stringify(tweet));
@ -100,13 +99,15 @@ const populateTweetProperties = async (
}); });
/* Grab color palette data */ /* Grab color palette data */
/*
if (mediaList[0]?.ext_media_color?.palette) { if (mediaList[0]?.ext_media_color?.palette) {
apiTweet.color = colorFromPalette(mediaList[0].ext_media_color.palette); apiTweet.color = colorFromPalette(mediaList[0].ext_media_color.palette);
} }
*/
/* Handle photos and mosaic if available */ /* Handle photos and mosaic if available */
if ((apiTweet.media?.photos?.length || 0) > 1) { 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) { if (typeof apiTweet.media !== 'undefined' && mosaic !== null) {
apiTweet.media.mosaic = mosaic; apiTweet.media.mosaic = mosaic;
} }
@ -121,8 +122,9 @@ const populateTweetProperties = async (
} }
/* Populate a Twitter card */ /* Populate a Twitter card */
if (tweet.card) { if (tweet.card) {
const card = await renderCard(tweet.card); const card = renderCard(tweet.card);
if (card.external_media) { if (card.external_media) {
apiTweet.twitter_card = 'summary_large_image'; apiTweet.twitter_card = 'summary_large_image';
apiTweet.media = apiTweet.media || {}; 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 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( const translateAPI = await translateTweet(
tweet, tweet,
conversation.guestToken || '', conversation.guestToken || '',
@ -192,61 +194,50 @@ export const statusAPI = async (
event: FetchEvent, event: FetchEvent,
flags?: InputFlags flags?: InputFlags
): Promise<TweetAPIResponse> => { ): Promise<TweetAPIResponse> => {
let conversation = await fetchConversation(status, event);
let tweet = conversation?.globalObjects?.tweets?.[status] || {};
let wasMediaBlockedNSFW = false; let wasMediaBlockedNSFW = false;
let res = await fetchConversation(status, event);
if (tweet.retweeted_status_id_str) { const tweet = res.data?.tweetResult?.result;
tweet = conversation?.globalObjects?.tweets?.[tweet.retweeted_status_id_str] || {}; 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) */ console.log(JSON.stringify(tweet))
if (typeof tweet.full_text === 'undefined') {
if (conversation.timeline?.instructions?.length > 0) { if (tweet.__typename === 'TweetUnavailable') {
/* Try again using elongator API proxy */ if (tweet.reason === 'Protected') {
console.log('No Tweet was found, loading again from elongator'); writeDataPoint(event, language, wasMediaBlockedNSFW, 'PRIVATE_TWEET', flags);
conversation = await fetchConversation(status, event, true); return { code: 401, message: 'PRIVATE_TWEET' };
tweet = conversation?.globalObjects?.tweets?.[status] || {}; } else if (tweet.reason === 'NsfwLoggedOut') {
// API failure as elongator should have handled this
if (typeof tweet.full_text !== 'undefined') { writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
console.log('Successfully loaded Tweet using elongator'); return { code: 500, message: 'API_FAIL' };
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 { } else {
/* {"errors":[{"code":34,"message":"Sorry, that page does not exist."}]} */ // Api failure at parsing status
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); writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
return { code: 500, message: 'API_FAIL' }; 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 */ /* Creating the response objects */
const response: TweetAPIResponse = { code: 200, message: 'OK' } as TweetAPIResponse; const response: TweetAPIResponse = { code: 200, message: 'OK' } as TweetAPIResponse;
const apiTweet: APITweet = (await populateTweetProperties( const apiTweet: APITweet = (await populateTweetProperties(
@ -256,8 +247,7 @@ export const statusAPI = async (
)) as APITweet; )) as APITweet;
/* We found a quote tweet, let's process that too */ /* We found a quote tweet, let's process that too */
const quoteTweet = const quoteTweet = tweet.quoted_status_result;
conversation.globalObjects?.tweets?.[tweet.quoted_status_id_str || '0'] || null;
if (quoteTweet) { if (quoteTweet) {
apiTweet.quote = (await populateTweetProperties( apiTweet.quote = (await populateTweetProperties(
quoteTweet, quoteTweet,

View file

@ -1,15 +1,8 @@
import { Constants } from '../constants'; import { Constants } from '../constants';
import { fetchUser } from '../fetch'; import { fetchUser } from '../fetch';
/* This function does the heavy lifting of processing data from Twitter API export const convertToApiUser = (user: GraphQLUser): APIUser => {
and using it to create FixTweet's streamlined API responses */
const populateUserProperties = async (
response: GraphQLUserResponse
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APIUser> => {
const apiUser = {} as APIUser; const apiUser = {} as APIUser;
const user = response.data.user.result;
/* Populating a lot of the basics */ /* Populating a lot of the basics */
apiUser.url = `${Constants.TWITTER_ROOT}/${user.legacy.screen_name}`; apiUser.url = `${Constants.TWITTER_ROOT}/${user.legacy.screen_name}`;
apiUser.id = user.rest_id; apiUser.id = user.rest_id;
@ -21,6 +14,7 @@ const populateUserProperties = async (
apiUser.screen_name = user.legacy.screen_name; apiUser.screen_name = user.legacy.screen_name;
apiUser.description = user.legacy.description; apiUser.description = user.legacy.description;
apiUser.location = user.legacy.location; apiUser.location = user.legacy.location;
apiUser.banner_url = user.legacy.profile_banner_url;
/* /*
if (user.is_blue_verified) { if (user.is_blue_verified) {
apiUser.verified = 'blue'; apiUser.verified = 'blue';
@ -51,6 +45,16 @@ const populateUserProperties = async (
return apiUser; 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<APIUser> => {
const user = response.data.user.result;
return convertToApiUser(user);
};
/* API for Twitter profiles (Users) /* API for Twitter profiles (Users)
Used internally by FixTweet's embed service, or Used internally by FixTweet's embed service, or
available for free using api.fxtwitter.com. */ available for free using api.fxtwitter.com. */
@ -60,7 +64,12 @@ export const userAPI = async (
flags?: InputFlags flags?: InputFlags
): Promise<UserAPIResponse> => { ): Promise<UserAPIResponse> => {
const userResponse = await fetchUser(username, event); const userResponse = await fetchUser(username, event);
if (!userResponse || !Object.keys(userResponse).length) {
return {
code: 404,
message: 'User not found'
};
}
/* Creating the response objects */ /* Creating the response objects */
const response: UserAPIResponse = { code: 200, message: 'OK' } as UserAPIResponse; const response: UserAPIResponse = { code: 200, message: 'OK' } as UserAPIResponse;
const apiUser: APIUser = (await populateUserProperties(userResponse)) as APIUser; const apiUser: APIUser = (await populateUserProperties(userResponse)) as APIUser;

View file

@ -20,7 +20,7 @@ export const Constants = {
GUEST_TOKEN_MAX_AGE: 3 * 60 * 60, GUEST_TOKEN_MAX_AGE: 3 * 60 * 60,
/* Twitter Web App actually uses Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA /* Twitter Web App actually uses Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA
instead, but accounts marked as 18+ wouldn't show up then */ 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: [ GUEST_FETCH_PARAMETERS: [
'cards_platform=Web-12', 'cards_platform=Web-12',
'include_cards=1', 'include_cards=1',

View file

@ -59,6 +59,7 @@ export const handleStatus = async (
case 404: case 404:
return returnError(Strings.ERROR_TWEET_NOT_FOUND); return returnError(Strings.ERROR_TWEET_NOT_FOUND);
case 500: case 500:
console.log(api);
return returnError(Strings.ERROR_API_FAIL); return returnError(Strings.ERROR_API_FAIL);
} }

View file

@ -1,7 +1,14 @@
import { Constants } from './constants'; import { Constants } from './constants';
import { generateUserAgent } from './helpers/useragent'; 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 ( export const twitterFetch = async (
url: string, url: string,
@ -51,14 +58,11 @@ export const twitterFetch = async (
const cache = caches.default; const cache = caches.default;
while (apiAttempts < API_ATTEMPTS) { while (apiAttempts < API_ATTEMPTS) {
const csrfToken = crypto /* Generate a random CSRF token, Twitter just cares that header and cookie match,
.randomUUID() REST can use shorter csrf tokens (32 bytes) but graphql prefers 160 bytes */
.replace( const csrfToken = generateCSRFToken();
/-/g,
''
); /* Generate a random CSRF token, this doesn't matter, Twitter just cares that header and cookie match */
const headers: { [header: string]: string } = { const headers: Record<string, string> = {
Authorization: Constants.GUEST_BEARER_TOKEN, Authorization: Constants.GUEST_BEARER_TOKEN,
...Constants.BASE_HEADERS ...Constants.BASE_HEADERS
}; };
@ -130,7 +134,7 @@ export const twitterFetch = async (
headers: headers headers: headers
}); });
} }
response = await apiRequest?.json(); response = await apiRequest?.json();
} catch (e: unknown) { } catch (e: unknown) {
/* We'll usually only hit this if we get an invalid response from Twitter. /* We'll usually only hit this if we get an invalid response from Twitter.
@ -188,20 +192,61 @@ export const fetchConversation = async (
status: string, status: string,
event: FetchEvent, event: FetchEvent,
useElongator = false useElongator = false
): Promise<TimelineBlobPartial> => { ): Promise<TweetResultsByRestIdResult> => {
return (await twitterFetch( 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, event,
useElongator, useElongator,
(_conversation: unknown) => { (_conversation: unknown) => {
const conversation = _conversation as TimelineBlobPartial; const conversation = _conversation as TweetResultsByRestIdResult;
return !( // If we get a not found error it's still a valid response
typeof conversation.globalObjects === 'undefined' && const tweet = conversation.data?.tweetResult?.result;
(typeof conversation.errors === 'undefined' || if (isGraphQLTweet(tweet)) {
conversation.errors?.[0]?.code === 239) 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 ( export const fetchUser = async (
@ -231,6 +276,10 @@ export const fetchUser = async (
// Validator function // Validator function
(_res: unknown) => { (_res: unknown) => {
const response = _res as GraphQLUserResponse; 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 !( return !(
response?.data?.user?.result?.__typename !== 'User' || response?.data?.user?.result?.__typename !== 'User' ||
typeof response.data.user.result.legacy === 'undefined' typeof response.data.user.result.legacy === 'undefined'

View file

@ -1,34 +1,44 @@
import { calculateTimeLeftString } from './pollTime'; import { calculateTimeLeftString } from './pollTime';
/* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */ /* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */
export const renderCard = async ( export const renderCard = (
card: TweetCard card: GraphQLTweet['card']
): Promise<{ poll?: APIPoll; external_media?: APIExternalMedia }> => { ): { 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<string, { string_value?: string; boolean_value?: boolean }> = {};
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'); console.log('rendering card');
if (typeof values !== 'undefined') { if (typeof binding_values !== 'undefined') {
if (typeof values.choice1_count !== 'undefined') { if (typeof binding_values.choice1_count !== 'undefined') {
const poll = {} as APIPoll; 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( 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 } = { const choices: { [label: string]: number } = {
[values.choice1_label?.string_value || '']: parseInt( [binding_values.choice1_label?.string_value || '']: parseInt(
values.choice1_count?.string_value || '0' binding_values.choice1_count?.string_value || '0'
), ),
[values.choice2_label?.string_value || '']: parseInt( [binding_values.choice2_label?.string_value || '']: parseInt(
values.choice2_count?.string_value || '0' binding_values.choice2_count?.string_value || '0'
), ),
[values.choice3_label?.string_value || '']: parseInt( [binding_values.choice3_label?.string_value || '']: parseInt(
values.choice3_count?.string_value || '0' binding_values.choice3_count?.string_value || '0'
), ),
[values.choice4_label?.string_value || '']: parseInt( [binding_values.choice4_label?.string_value || '']: parseInt(
values.choice4_count?.string_value || '0' binding_values.choice4_count?.string_value || '0'
) )
}; };
@ -46,17 +56,17 @@ export const renderCard = async (
}); });
return { poll: poll }; 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 */ /* Oh good, a non-Twitter video URL! This enables YouTube embeds and stuff to just work */
return { return {
external_media: { external_media: {
type: 'video', type: 'video',
url: values.player_url.string_value, url: binding_values.player_url.string_value,
width: parseInt( 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 ), // TODO: Replacing px might not be necessary, it's just there as a precaution
height: parseInt( height: parseInt(
(values.player_height?.string_value || '720').replace('px', '') (binding_values.player_height?.string_value || '720').replace('px', '')
) )
} }
}; };

View file

@ -1,7 +1,10 @@
/* Helps replace t.co links with their originals */ /* Helps replace t.co links with their originals */
export const linkFixer = (tweet: TweetPartial, text: string): string => { export const linkFixer = (tweet: GraphQLTweet, text: string): string => {
if (typeof tweet.entities?.urls !== 'undefined') { console.log('got entites', {
tweet.entities?.urls.forEach((url: TcoExpansion) => { 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; let newURL = url.expanded_url;
if (newURL.match(/^https:\/\/twitter\.com\/i\/web\/status\/\w+/g) !== null) { if (newURL.match(/^https:\/\/twitter\.com\/i\/web\/status\/\w+/g) !== null) {

View file

@ -2,7 +2,7 @@ import { Constants } from '../constants';
/* Handles translating Tweets when asked! */ /* Handles translating Tweets when asked! */
export const translateTweet = async ( export const translateTweet = async (
tweet: TweetPartial, tweet: GraphQLTweet,
guestToken: string, guestToken: string,
language: string language: string
): Promise<TranslationPartial | null> => { ): Promise<TranslationPartial | null> => {
@ -29,7 +29,7 @@ export const translateTweet = async (
try { try {
apiRequest = await fetch( 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', method: 'GET',
headers: headers headers: headers

7
src/utils/graphql.ts Normal file
View file

@ -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';
}

View file

@ -115,7 +115,7 @@ test('API fetch video Tweet', async () => {
expect(tweet.url).toEqual('https://twitter.com/Twitter/status/854416760933556224'); expect(tweet.url).toEqual('https://twitter.com/Twitter/status/854416760933556224');
expect(tweet.id).toEqual('854416760933556224'); expect(tweet.id).toEqual('854416760933556224');
expect(tweet.text).toEqual( 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.screen_name?.toLowerCase()).toEqual('twitter');
expect(tweet.author.id).toEqual('783214'); expect(tweet.author.id).toEqual('783214');
@ -160,7 +160,7 @@ test('API fetch multi-photo Tweet', async () => {
expect(tweet).toBeTruthy(); expect(tweet).toBeTruthy();
expect(tweet.url).toEqual('https://twitter.com/Twitter/status/1445094085593866246'); expect(tweet.url).toEqual('https://twitter.com/Twitter/status/1445094085593866246');
expect(tweet.id).toEqual('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.screen_name?.toLowerCase()).toEqual('twitter');
expect(tweet.author.id).toEqual('783214'); expect(tweet.author.id).toEqual('783214');
expect(tweet.author.name).toBeTruthy(); expect(tweet.author.name).toBeTruthy();
@ -270,3 +270,18 @@ test('API fetch user', async () => {
expect(user.birthday.month).toEqual(3); expect(user.birthday.month).toEqual(3);
expect(user.birthday.year).toBeUndefined(); 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();
});