diff --git a/README.md b/README.md index c043a3c..1d2c9bc 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ ## Written in TypeScript as a Cloudflare Worker to scale, packed with more features and [best-in-class user privacy πŸ”’](#built-with-privacy-in-mind). ### Add `fx` before your `twitter.com` link to make it `fxtwitter.com`, OR + ### Change `x.com` to `fixupx.com` in your link ### For Twitter links on Discord, send a Twitter link and type `s/e/p` to make `twittpr.com`. diff --git a/src/api/status.ts b/src/api/status.ts index 45d1960..e5057c8 100644 --- a/src/api/status.ts +++ b/src/api/status.ts @@ -40,8 +40,7 @@ const populateTweetProperties = async ( id: apiUser.id, name: apiUser.name, screen_name: apiUser.screen_name, - avatar_url: - (apiUser.avatar_url || '').replace('_normal', '_200x200') || '', + avatar_url: (apiUser.avatar_url || '').replace('_normal', '_200x200') || '', avatar_color: '0000FF' /* colorFromPalette( tweet.user?.profile_image_extensions_media_color?.palette || [] ),*/, @@ -71,7 +70,7 @@ const populateTweetProperties = async ( 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.legacy.extended_entities?.media || tweet.legacy.entities?.media || [] ); @@ -107,9 +106,14 @@ const populateTweetProperties = async ( console.log('note_tweet', JSON.stringify(tweet.note_tweet)); const noteTweetText = tweet.note_tweet?.note_tweet_results?.result?.text; /* For now, don't include note tweets */ - if (noteTweetText && mediaList.length <= 0 && tweet.legacy.entities?.urls?.length <= 0) { + if ( + noteTweetText /*&& mediaList.length <= 0 && tweet.legacy.entities?.urls?.length <= 0*/ + ) { console.log('We meet the conditions to use new note tweets'); - apiTweet.text = unescapeText(noteTweetText); + apiTweet.text = unescapeText(linkFixer(tweet, noteTweetText)); + apiTweet.is_note_tweet = true; + } else { + apiTweet.is_note_tweet = false; } /* Handle photos and mosaic if available */ @@ -129,7 +133,7 @@ const populateTweetProperties = async ( } /* Populate a Twitter card */ - + if (tweet.card) { const card = renderCard(tweet.card); if (card.external_media) { @@ -143,7 +147,11 @@ 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.legacy.lang) { + if ( + typeof language === 'string' && + language.length === 2 && + language !== tweet.legacy.lang + ) { const translateAPI = await translateTweet( tweet, conversation.guestToken || '', @@ -213,15 +221,15 @@ export const statusAPI = async ( } // 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 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 { // Api failure at parsing status writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags); @@ -234,7 +242,7 @@ export const statusAPI = async ( 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] || {}; diff --git a/src/embed/status.ts b/src/embed/status.ts index df6f17f..8a88a2f 100644 --- a/src/embed/status.ts +++ b/src/embed/status.ts @@ -6,6 +6,7 @@ import { getAuthorText } from '../helpers/author'; import { statusAPI } from '../api/status'; import { renderPhoto } from '../render/photo'; import { renderVideo } from '../render/video'; +import { renderInstantView } from '../render/instantview'; export const returnError = (error: string): StatusResponse => { return { @@ -34,6 +35,12 @@ export const handleStatus = async ( const api = await statusAPI(status, language, event as FetchEvent, flags); const tweet = api?.tweet as APITweet; + + const isTelegram = (userAgent || '').indexOf('Telegram') > -1; + /* Should sensitive posts be allowed Instant View? */ + const useIV = isTelegram /*&& !tweet.possibly_sensitive*/ && !flags?.direct && !flags?.api && (tweet.media?.mosaic || tweet.is_note_tweet); + + let ivbody = ''; /* Catch this request if it's an API response */ if (flags?.api) { @@ -120,12 +127,21 @@ export const handleStatus = async ( it will gracefully redirect to the destination instead of just seeing a blank screen. Telegram is dumb and it just gets stuck if this is included, so we never include it for Telegram UAs. */ - if (userAgent?.indexOf('Telegram') === -1) { + if (!isTelegram) { headers.push( `` ); } + if (useIV) { + const instructions = renderInstantView({ tweet: tweet, text: newText }); + headers.push(...instructions.addHeaders); + if (instructions.authorText) { + authorText = instructions.authorText; + } + ivbody = instructions.text || ''; + } + /* This Tweet has a translation attached to it, so we'll render it. */ if (tweet.translation) { const { translation } = tweet; @@ -240,7 +256,7 @@ export const handleStatus = async ( let str = ''; /* Telegram Embeds are smaller, so we use a smaller bar to compensate */ - if (userAgent?.indexOf('Telegram') !== -1) { + if (isTelegram) { barLength = 24; } @@ -273,16 +289,21 @@ export const handleStatus = async ( /* If we have no media to display, instead we'll display the user profile picture in the embed */ if (!tweet.media?.videos && !tweet.media?.photos && !flags?.textOnly) { - headers.push( - /* Use a slightly higher resolution image for profile pics */ - ``, - `` - ); + const avatar = tweet.author.avatar_url?.replace('_200x200', '_normal'); + if (!useIV) { + headers.push( + /* Use a slightly higher resolution image for profile pics */ + ``, + `` + ); + } else { + headers.push( + /* Use a slightly higher resolution image for profile pics */ + `` + ); + } } - + if (!flags?.isXDomain) { siteName = Strings.X_DOMAIN_NOTICE; } @@ -291,11 +312,22 @@ export const handleStatus = async ( if (flags?.deprecated) { siteName = Strings.DEPRECATED_DOMAIN_NOTICE; } + /* For supporting Telegram IV, we have to replace newlines with
within the og:description tag because of its weird (undocumented?) behavior. + If you don't use IV, it uses newlines just fine. Just like Discord and others. But with IV, suddenly newlines don't actually break the line anymore. + + This is incredibly stupid, and you'd think this weird behavior would not be the case. You'd also think embedding a
inside the quotes inside + a meta tag shouldn't work, because that's stupid, but alas it does. + + A possible explanation for this weird behavior is due to the Medium template we are forced to use because Telegram IV is not an open platform + and we have to pretend to be Medium in order to get working IV, but haven't figured if the template is causing issues. */ + const text = useIV + ? sanitizeText(newText).replace(/\n/g, '
') + : sanitizeText(newText); /* Push basic headers relating to author, Tweet text, and site name */ headers.push( ``, - ``, + ``, `` ); @@ -317,9 +349,9 @@ export const handleStatus = async ( authorText.substring(0, 200) )}${flags?.deprecated ? '&deprecated=true' : ''}&status=${encodeURIComponent( status - )}&author=${encodeURIComponent( - tweet.author?.screen_name || '' - )}&useXbranding=${flags?.isXDomain ? 'true' : 'false'}" type="application/json+oembed" title="${tweet.author.name}">` + )}&author=${encodeURIComponent(tweet.author?.screen_name || '')}&useXbranding=${ + flags?.isXDomain ? 'true' : 'false' + }" type="application/json+oembed" title="${tweet.author.name}">` ); /* When dealing with a Tweet of unknown lang, fall back to en */ @@ -329,7 +361,8 @@ export const handleStatus = async ( return { text: Strings.BASE_HTML.format({ lang: `lang="${lang}"`, - headers: headers.join('') + headers: headers.join(''), + body: ivbody }), cacheControl: cacheControl }; diff --git a/src/fetch.ts b/src/fetch.ts index 2f87c3c..048211d 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -5,7 +5,7 @@ import { isGraphQLTweet } from './utils/graphql'; const API_ATTEMPTS = 3; function generateCSRFToken() { - const randomBytes = new Uint8Array(160/2); + const randomBytes = new Uint8Array(160 / 2); crypto.getRandomValues(randomBytes); return Array.from(randomBytes, byte => byte.toString(16).padStart(2, '0')).join(''); } @@ -134,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. @@ -197,28 +197,34 @@ export const fetchConversation = async ( `${ Constants.TWITTER_ROOT }/i/api/graphql/2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId?variables=${encodeURIComponent( - JSON.stringify({"tweetId": status,"withCommunity":false,"includePromotedContent":false,"withVoice":false}) + 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}) + 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 @@ -244,7 +250,7 @@ export const fetchConversation = async ( return true; } // Final clause for checking if it's valid is if there's errors - return Array.isArray(conversation.errors) + return Array.isArray(conversation.errors); } )) as TweetResultsByRestIdResult; }; diff --git a/src/helpers/author.ts b/src/helpers/author.ts index e2c55fe..bf0a4a8 100644 --- a/src/helpers/author.ts +++ b/src/helpers/author.ts @@ -24,3 +24,28 @@ export const getAuthorText = (tweet: APITweet): string | null => { return null; }; + +/* The embed "author" text we populate with replies, retweets, and likes unless it's a video */ +export const getSocialTextIV = (tweet: APITweet): string | null => { + /* Build out reply, retweet, like counts */ + if (tweet.likes > 0 || tweet.retweets > 0 || tweet.replies > 0) { + let authorText = ''; + if (tweet.replies > 0) { + authorText += `πŸ’¬ ${formatNumber(tweet.replies)} `; + } + if (tweet.retweets > 0) { + authorText += `πŸ” ${formatNumber(tweet.retweets)} `; + } + if (tweet.likes > 0) { + authorText += `❀️ ${formatNumber(tweet.likes)} `; + } + if (tweet.views && tweet.views > 0) { + authorText += `πŸ‘οΈ ${formatNumber(tweet.views)} `; + } + authorText = authorText.trim(); + + return authorText; + } + + return null; +}; diff --git a/src/helpers/card.ts b/src/helpers/card.ts index c8e4740..0a4dc69 100644 --- a/src/helpers/card.ts +++ b/src/helpers/card.ts @@ -6,7 +6,10 @@ export const renderCard = ( ): { 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 = {}; + 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) { @@ -14,7 +17,6 @@ export const renderCard = ( } }); } - console.log('rendering card'); @@ -56,7 +58,10 @@ export const renderCard = ( }); return { poll: poll }; - } else if (typeof binding_values.player_url !== 'undefined' && binding_values.player_url.string_value) { + } 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: { diff --git a/src/helpers/linkFixer.ts b/src/helpers/linkFixer.ts index bef4a7d..f5b8e54 100644 --- a/src/helpers/linkFixer.ts +++ b/src/helpers/linkFixer.ts @@ -1,8 +1,8 @@ /* Helps replace t.co links with their originals */ export const linkFixer = (tweet: GraphQLTweet, text: string): string => { console.log('got entites', { - entities: tweet.legacy.entities, - }) + 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; diff --git a/src/helpers/media.ts b/src/helpers/media.ts index 24e6b9b..b1e4b84 100644 --- a/src/helpers/media.ts +++ b/src/helpers/media.ts @@ -4,8 +4,8 @@ export const processMedia = (media: TweetMedia): APIPhoto | APIVideo | null => { return { type: 'photo', url: media.media_url_https, - width: media.original_info.width, - height: media.original_info.height, + width: media.original_info?.width, + height: media.original_info?.height, altText: media.ext_alt_text || '' }; } else if (media.type === 'video' || media.type === 'animated_gif') { @@ -17,8 +17,8 @@ export const processMedia = (media: TweetMedia): APIPhoto | APIVideo | null => { url: bestVariant?.url || '', thumbnail_url: media.media_url_https, duration: (media.video_info?.duration_millis || 0) / 1000, - width: media.original_info.width, - height: media.original_info.height, + width: media.original_info?.width, + height: media.original_info?.height, format: bestVariant?.content_type || '', type: media.type === 'animated_gif' ? 'gif' : 'video' }; diff --git a/src/render/instantview.ts b/src/render/instantview.ts new file mode 100644 index 0000000..b67cc46 --- /dev/null +++ b/src/render/instantview.ts @@ -0,0 +1,108 @@ +import { Constants } from "../constants"; +import { getSocialTextIV } from "../helpers/author"; +import { sanitizeText } from "../helpers/utils"; + +const populateUserLinks = (tweet: APITweet, text: string): string => { + /* TODO: Maybe we can add username splices to our API so only genuinely valid users are linked? */ + text.match(/@(\w{1,15})/g)?.forEach((match) => { + const username = match.replace('@', ''); + text = text.replace( + match, + `${match}` + ); + }); + return text; +} + +const generateTweetMedia = (tweet: APITweet): string => { + let media = ''; + if (tweet.media?.all?.length) { + tweet.media.all.forEach((mediaItem) => { + switch(mediaItem.type) { + case 'photo': + media += `${tweet.author.name}'s photo`; + break; + case 'video': + media += `