import { renderCard } from '../../helpers/card'; import { Constants } from '../../constants'; import { linkFixer } from '../../helpers/linkFixer'; import { handleMosaic } from '../../helpers/mosaic'; import { unescapeText } from '../../helpers/utils'; import { processMedia } from '../../helpers/media'; import { convertToApiUser } from './profile'; import { translateStatus } from '../../helpers/translate'; import { Context } from 'hono'; export const buildAPITwitterStatus = async ( c: Context, status: GraphQLTwitterStatus, language: string | undefined, threadPiece = false, legacyAPI = false // eslint-disable-next-line sonarjs/cognitive-complexity ): Promise<APITwitterStatus | FetchResults | null> => { const apiStatus = {} as APITwitterStatus; /* Sometimes, Twitter returns a different kind of type called 'TweetWithVisibilityResults'. It has slightly different attributes from the regular 'Tweet' type. We fix that up here. */ if (typeof status.core === 'undefined' && typeof status.result !== 'undefined') { status = status.result; } if (typeof status.core === 'undefined' && typeof status.tweet?.core !== 'undefined') { status.core = status.tweet.core; } if (typeof status.legacy === 'undefined' && typeof status.tweet?.legacy !== 'undefined') { status.legacy = status.tweet?.legacy; } if (typeof status.views === 'undefined' && typeof status?.tweet?.views !== 'undefined') { status.views = status?.tweet?.views; } if (typeof status.core === 'undefined') { console.log('Status still not valid', status); if (status.__typename === 'TweetUnavailable' && status.reason === 'Protected') { return { status: 401 }; } else { return { status: 404 }; } } const graphQLUser = status.core.user_results.result; const apiUser = convertToApiUser(graphQLUser); /* Sometimes, `rest_id` is undefined for some reason. Inconsistent behavior. See: https://github.com/FixTweet/FxTwitter/issues/416 */ const id = status.rest_id ?? status.legacy.id_str; /* Populating a lot of the basics */ apiStatus.url = `${Constants.TWITTER_ROOT}/${apiUser.screen_name}/status/${id}`; apiStatus.id = id; apiStatus.text = unescapeText( linkFixer(status.legacy.entities?.urls, status.legacy.full_text || '') ); if (!threadPiece) { apiStatus.author = { id: apiUser.id, name: apiUser.name, screen_name: apiUser.screen_name, avatar_url: apiUser.avatar_url?.replace?.('_normal', '_200x200') ?? null, banner_url: apiUser.banner_url, description: apiUser.description, location: apiUser.location, url: apiUser.url, followers: apiUser.followers, following: apiUser.following, joined: apiUser.joined, statuses: apiUser.statuses, likes: apiUser.likes, protected: apiUser.protected, birthday: apiUser.birthday, website: apiUser.website }; } apiStatus.replies = status.legacy.reply_count; if (legacyAPI) { // @ts-expect-error Use retweets for legacy API apiStatus.retweets = status.legacy.retweet_count; // @ts-expect-error `tweets` is only part of legacy API apiStatus.author.tweets = apiStatus.author.statuses; // @ts-expect-error Part of legacy API that we no longer are able to track apiStatus.author.avatar_color = null; // @ts-expect-error Use retweets for legacy API delete apiStatus.reposts; // @ts-expect-error Use tweets and not posts for legacy API delete apiStatus.author.statuses; delete apiStatus.author.global_screen_name; } else { apiStatus.reposts = status.legacy.retweet_count; apiStatus.author.global_screen_name = apiUser.global_screen_name; } apiStatus.likes = status.legacy.favorite_count; apiStatus.embed_card = 'tweet'; apiStatus.created_at = status.legacy.created_at; apiStatus.created_timestamp = new Date(status.legacy.created_at).getTime() / 1000; apiStatus.possibly_sensitive = status.legacy.possibly_sensitive; if (status.views?.state === 'EnabledWithCount') { apiStatus.views = parseInt(status.views.count || '0') ?? null; } else { apiStatus.views = null; } console.log('note_tweet', JSON.stringify(status.note_tweet)); const noteTweetText = status.note_tweet?.note_tweet_results?.result?.text; if (noteTweetText) { status.legacy.entities.urls = status.note_tweet?.note_tweet_results?.result?.entity_set.urls; status.legacy.entities.hashtags = status.note_tweet?.note_tweet_results?.result?.entity_set.hashtags; status.legacy.entities.symbols = status.note_tweet?.note_tweet_results?.result?.entity_set.symbols; console.log('We meet the conditions to use new note tweets'); apiStatus.text = unescapeText(linkFixer(status.legacy.entities.urls, noteTweetText)); apiStatus.is_note_tweet = true; } else { apiStatus.is_note_tweet = false; } if (status.legacy.lang !== 'unk') { apiStatus.lang = status.legacy.lang; } else { apiStatus.lang = null; } if (legacyAPI) { // @ts-expect-error Use replying_to string for legacy API apiStatus.replying_to = status.legacy?.in_reply_to_screen_name || null; // @ts-expect-error Use replying_to_status string for legacy API apiStatus.replying_to_status = status.legacy?.in_reply_to_status_id_str || null; } else if (status.legacy.in_reply_to_screen_name) { apiStatus.replying_to = { screen_name: status.legacy.in_reply_to_screen_name || null, post: status.legacy.in_reply_to_status_id_str || null }; } else { apiStatus.replying_to = null; } apiStatus.media = {}; /* We found a quote, let's process that too */ const quote = status.quoted_status_result; if (quote) { const buildQuote = await buildAPITwitterStatus(c, quote, language, threadPiece, legacyAPI); if ((buildQuote as FetchResults).status) { apiStatus.quote = undefined; } else { apiStatus.quote = buildQuote as APITwitterStatus; } /* Only override the embed_card if it's a basic status, since media always takes precedence */ if (apiStatus.embed_card === 'tweet' && typeof apiStatus.quote !== 'undefined') { apiStatus.embed_card = apiStatus.quote.embed_card; } } const mediaList = Array.from( status.legacy.extended_entities?.media || status.legacy.entities?.media || [] ); // console.log('status', JSON.stringify(status)); /* Populate status media */ mediaList.forEach(media => { const mediaObject = processMedia(media); if (mediaObject) { apiStatus.media.all = apiStatus.media?.all ?? []; apiStatus.media?.all?.push(mediaObject); if (mediaObject.type === 'photo') { apiStatus.embed_card = 'summary_large_image'; apiStatus.media.photos = apiStatus.media?.photos ?? []; apiStatus.media.photos?.push(mediaObject); } else if (mediaObject.type === 'video' || mediaObject.type === 'gif') { apiStatus.embed_card = 'player'; apiStatus.media.videos = apiStatus.media?.videos ?? []; apiStatus.media.videos?.push(mediaObject); } else { console.log('Unknown media type', mediaObject.type); } } }); /* Grab color palette data */ /* if (mediaList[0]?.ext_media_color?.palette) { apiStatus.color = colorFromPalette(mediaList[0].ext_media_color.palette); } */ /* Handle photos and mosaic if available */ if ((apiStatus?.media.photos?.length || 0) > 1 && !threadPiece) { const mosaic = await handleMosaic(apiStatus.media?.photos || [], id); if (typeof apiStatus.media !== 'undefined' && mosaic !== null) { apiStatus.media.mosaic = mosaic; } } // Add source but remove the link HTML tag if (status.source) { apiStatus.source = (status.source || '').replace( /<a href="(.+?)" rel="nofollow">(.+?)<\/a>/, '$2' ); } /* Populate a Twitter card */ if (status.card) { const card = renderCard(status.card); if (card.external_media) { apiStatus.embed_card = 'player'; apiStatus.media.external = card.external_media; if (apiStatus.media.external.url.match('https://www.youtube.com/embed/')) { /* Add YouTube thumbnail URL */ apiStatus.media.external.thumbnail_url = `https://img.youtube.com/vi/${apiStatus.media.external.url.replace( 'https://www.youtube.com/embed/', '' )}/maxresdefault.jpg`; } } if (card.poll) { apiStatus.poll = card.poll; } /* TODO: Right now, we push them after native photos and videos but should we prepend them instead? */ if (card.media) { if (card.media.videos) { card.media.videos.forEach(video => { const mediaObject = processMedia(video) as APIVideo; if (mediaObject) { apiStatus.media.all = apiStatus.media?.all ?? []; apiStatus.media?.all?.push(mediaObject); apiStatus.media.videos = apiStatus.media?.videos ?? []; apiStatus.media.videos?.push(mediaObject); } }); } if (card.media.photos) { card.media.photos.forEach(photo => { const mediaObject = processMedia(photo) as APIPhoto; if (mediaObject) { apiStatus.media.all = apiStatus.media?.all ?? []; apiStatus.media?.all?.push(mediaObject); apiStatus.media.photos = apiStatus.media?.photos ?? []; apiStatus.media.photos?.push(mediaObject); } }); } } } else { /* Determine if the status contains a YouTube link (either youtube.com or youtu.be) so we can include it */ const youtubeIdRegex = /(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([^\s&]+)/; const matches = apiStatus.text.match(youtubeIdRegex); const youtubeId = matches ? matches[4] : null; if (youtubeId) { apiStatus.media.external = { type: 'video', url: `https://www.youtube.com/embed/${youtubeId}`, thumbnail_url: `https://img.youtube.com/vi/${youtubeId}/maxresdefault.jpg`, width: 1280, height: 720 }; apiStatus.embed_card = 'player'; } } if ( apiStatus.media?.videos && apiStatus.media?.videos.length > 0 && apiStatus.embed_card !== 'player' ) { apiStatus.embed_card = 'player'; } console.log('language?', language); /* If a language is specified in API or by user, let's try translating it! */ if ( typeof language === 'string' && (language.length === 2 || language.length === 5) && language !== status.legacy.lang ) { console.log(`Attempting to translate status to ${language}...`); const translateAPI = await translateStatus(status, '', language, c); if (translateAPI !== null && translateAPI?.translation) { apiStatus.translation = { text: unescapeText( linkFixer(status.legacy?.entities?.urls, translateAPI?.translation || '') ), source_lang: translateAPI?.sourceLanguage || '', target_lang: translateAPI?.destinationLanguage || '', source_lang_en: translateAPI?.localizedSourceLanguage || '' }; } } if (legacyAPI) { // @ts-expect-error Use twitter_card for legacy API apiStatus.twitter_card = apiStatus.embed_card; // @ts-expect-error Part of legacy API that we no longer are able to track apiStatus.color = null; // @ts-expect-error Use twitter_card for legacy API delete apiStatus.embed_card; if ((apiStatus.media.all?.length ?? 0) < 1 && !apiStatus.media.external) { // @ts-expect-error media is not required in legacy API if empty delete apiStatus.media; } } return apiStatus; };