diff --git a/.env.example b/.env.example index fb2d5f8..7b22933 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,4 @@ BRANDING_NAME = "FixTweet" -BRANDING_NAME_DISCORD = "FixTweet - Embed videos, polls & more!" DIRECT_MEDIA_DOMAINS = "d.fxtwitter.com,dl.fxtwitter.com,d.pxtwitter.com,d.twittpr.com,dl.pxtwitter.com,dl.twittpr.com" TEXT_ONLY_DOMAINS = "t.fxtwitter.com,t.twittpr.com" DEPRECATED_DOMAIN_LIST = "pxtwitter.com,www.pxtwitter.com" diff --git a/jestconfig.json b/jestconfig.json index 863ec8b..d743ecb 100644 --- a/jestconfig.json +++ b/jestconfig.json @@ -5,7 +5,6 @@ }, "globals": { "BRANDING_NAME": "FixTweet", - "BRANDING_NAME_DISCORD": "FixTweetBrandingDiscord", "TEXT_ONLY_DOMAINS": "t.fxtwitter.com,t.twittpr.com", "DIRECT_MEDIA_DOMAINS": "d.fxtwitter.com,dl.fxtwitter.com", "MOSAIC_DOMAIN_LIST": "mosaic.fxtwitter.com", diff --git a/package.json b/package.json index ff58381..38e23c9 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,10 @@ "main": "dist/worker.js", "scripts": { "build": "webpack", - "publish": "wrangler publish", + "publish": "wrangler deploy", + "deploy": "wrangler deploy", "log": "wrangler tail", - "reload": "wrangler publish && wrangler tail", + "reload": "wrangler deploy && wrangler tail", "prettier": "prettier --write .", "lint:eslint": "eslint --max-warnings=0 src", "test": "jest --config jestconfig.json --verbose" diff --git a/src/api/status.ts b/src/api/status.ts index d554f1b..545f8fb 100644 --- a/src/api/status.ts +++ b/src/api/status.ts @@ -71,18 +71,22 @@ const populateTweetProperties = async ( tweet.extended_entities?.media || tweet.entities?.media || [] ); + // console.log('tweet', JSON.stringify(tweet)); + /* Populate this Tweet's media */ mediaList.forEach(media => { const mediaObject = processMedia(media); if (mediaObject) { + apiTweet.media = apiTweet.media || {}; + apiTweet.media.all = apiTweet.media?.all || []; + apiTweet.media.all.push(mediaObject); + if (mediaObject.type === 'photo') { apiTweet.twitter_card = 'summary_large_image'; - apiTweet.media = apiTweet.media || {}; apiTweet.media.photos = apiTweet.media.photos || []; apiTweet.media.photos.push(mediaObject); } else if (mediaObject.type === 'video' || mediaObject.type === 'gif') { apiTweet.twitter_card = 'player'; - apiTweet.media = apiTweet.media || {}; apiTweet.media.videos = apiTweet.media.videos || []; apiTweet.media.videos.push(mediaObject); } @@ -207,7 +211,7 @@ export const statusAPI = async ( conversation.timeline?.instructions?.length > 0 ) { console.log( - 'Tweet could not be accessed with elongator, must be private/suspende, got tweet ', + 'Tweet could not be accessed with elongator, must be private/suspended, got tweet ', tweet, ' conversation ', conversation @@ -223,11 +227,13 @@ export const statusAPI = async ( 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 (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); diff --git a/src/constants.ts b/src/constants.ts index ba3367e..45be395 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,7 +1,6 @@ export const Constants = { /* These constants are populated by variables in .env, then set by Webpack */ BRANDING_NAME: BRANDING_NAME, - BRANDING_NAME_DISCORD: BRANDING_NAME_DISCORD, DIRECT_MEDIA_DOMAINS: DIRECT_MEDIA_DOMAINS.split(','), TEXT_ONLY_DOMAINS: TEXT_ONLY_DOMAINS.split(','), DEPRECATED_DOMAIN_LIST: DEPRECATED_DOMAIN_LIST.split(','), @@ -30,10 +29,13 @@ export const Constants = { 'include_quote_count=true', 'include_reply_count=1', 'tweet_mode=extended', + 'include_entities=true', 'include_ext_media_color=true', 'include_ext_media_availability=true', 'include_ext_sensitive_media_warning=true', - 'simple_quoted_tweet=true' + 'include_ext_has_birdwatch_notes=true', + 'simple_quoted_tweet=true', + 'ext=mediaStats%2ChighlightedLabel' ].join('&'), BASE_HEADERS: { 'DNT': `1`, diff --git a/src/embed/status.ts b/src/embed/status.ts index f3c56e2..16caa1c 100644 --- a/src/embed/status.ts +++ b/src/embed/status.ts @@ -4,6 +4,8 @@ import { formatNumber, sanitizeText } from '../helpers/utils'; import { Strings } from '../strings'; import { getAuthorText } from '../helpers/author'; import { statusAPI } from '../api/status'; +import { renderPhoto } from '../render/photo'; +import { renderVideo } from '../render/video'; export const returnError = (error: string): StatusResponse => { return { @@ -43,6 +45,13 @@ export const handleStatus = async ( }; } + let overrideMedia: APIMedia | undefined; + + // Check if mediaNumber exists, and if that media exists in tweet.media.all. If it does, we'll store overrideMedia variable + if (mediaNumber && tweet.media && tweet.media.all && tweet.media.all[mediaNumber - 1]) { + overrideMedia = tweet.media.all[mediaNumber - 1]; + } + /* If there was any errors fetching the Tweet, we'll return it */ switch (api.code) { case 401: @@ -56,13 +65,22 @@ export const handleStatus = async ( /* Catch direct media request (d.fxtwitter.com, or .mp4 / .jpg) */ if (flags?.direct && tweet.media) { let redirectUrl: string | null = null; - if (tweet.media.videos) { - const { videos } = tweet.media; - redirectUrl = (videos[(mediaNumber || 1) - 1] || videos[0]).url; - } else if (tweet.media.photos) { - const { photos } = tweet.media; - redirectUrl = (photos[(mediaNumber || 1) - 1] || photos[0]).url; + const all = tweet.media.all || []; + // if (tweet.media.videos) { + // const { videos } = tweet.media; + // redirectUrl = (videos[(mediaNumber || 1) - 1] || videos[0]).url; + // } else if (tweet.media.photos) { + // const { photos } = tweet.media; + // redirectUrl = (photos[(mediaNumber || 1) - 1] || photos[0]).url; + // } + + const selectedMedia = all[(mediaNumber || 1) - 1]; + if (selectedMedia) { + redirectUrl = selectedMedia.url; + } else if (all.length > 0) { + redirectUrl = all[0].url; } + if (redirectUrl) { return { response: Response.redirect(redirectUrl, 302) }; } @@ -124,127 +142,82 @@ export const handleStatus = async ( newText = `${formatText}\n\n` + `${translation.text}\n\n`; } - /* This Tweet has a video to render. + console.log('overrideMedia', JSON.stringify(overrideMedia)); - Twitter supports multiple videos in a Tweet now. But we have no mechanism to embed more than one. - You can still use /video/:number to get a specific video. Otherwise, it'll pick the first. */ - if (tweet.media?.videos) { - authorText = newText || ''; + if (overrideMedia) { + let instructions: ResponseInstructions; - if (tweet?.translation) { - authorText = tweet.translation?.text || ''; + switch (overrideMedia.type) { + case 'photo': + /* This Tweet has a photo to render. */ + instructions = renderPhoto( + { + tweet: tweet, + authorText: authorText, + engagementText: engagementText, + userAgent: userAgent, + isOverrideMedia: true + }, + overrideMedia as APIPhoto + ); + headers.push(...instructions.addHeaders); + if (instructions.authorText) { + authorText = instructions.authorText; + } + if (instructions.siteName) { + siteName = instructions.siteName; + } + break; + case 'video': + instructions = renderVideo( + { tweet: tweet, userAgent: userAgent, text: newText, isOverrideMedia: true }, + overrideMedia as APIVideo + ); + headers.push(...instructions.addHeaders); + if (instructions.authorText) { + authorText = instructions.authorText; + } + if (instructions.siteName) { + siteName = instructions.siteName; + } + /* This Tweet has a video to render. */ + break; } - - const { videos } = tweet.media; - const video = videos[(mediaNumber || 1) - 1]; - - /* This fix is specific to Discord not wanting to render videos that are too large, - or rendering low quality videos too small. - - Basically, our solution is to cut the dimensions in half if the video is too big (> 1080p), - or double them if it's too small. (<400p) - - We check both height and width so we can apply this to both horizontal and vertical videos equally*/ - - let sizeMultiplier = 1; - - if (video.width > 1920 || video.height > 1920) { - sizeMultiplier = 0.5; - } - if (video.width < 400 && video.height < 400) { - sizeMultiplier = 2; - } - - /* Like photos when picking a specific one (not using mosaic), - we'll put an indicator if there are more than one video */ - if (videos.length > 1) { - const videoCounter = Strings.VIDEO_COUNT.format({ - number: String(videos.indexOf(video) + 1), - total: String(videos.length) - }); - - authorText = - authorText === Strings.DEFAULT_AUTHOR_TEXT - ? videoCounter - : `${authorText}${authorText ? ' ― ' : ''}${videoCounter}`; - - siteName = `${Constants.BRANDING_NAME} - ${videoCounter}`; - - if (engagementText) { - siteName = `${Constants.BRANDING_NAME} - ${engagementText} - ${videoCounter}`; - } - } - - /* Push the raw video-related headers */ - headers.push( - `<meta property="twitter:player:stream:content_type" content="${video.format}"/>`, - `<meta property="twitter:player:height" content="${ - video.height * sizeMultiplier - }"/>`, - `<meta property="twitter:player:width" content="${video.width * sizeMultiplier}"/>`, - `<meta property="og:video" content="${video.url}"/>`, - `<meta property="og:video:secure_url" content="${video.url}"/>`, - `<meta property="og:video:height" content="${video.height * sizeMultiplier}"/>`, - `<meta property="og:video:width" content="${video.width * sizeMultiplier}"/>`, - `<meta property="og:video:type" content="${video.format}"/>`, - `<meta property="twitter:image" content="0"/>` + } else if (tweet.media?.mosaic) { + const instructions = renderPhoto( + { + tweet: tweet, + authorText: authorText, + engagementText: engagementText, + userAgent: userAgent + }, + tweet.media?.mosaic ); - } - - /* This Tweet has one or more photos to render */ - if (tweet.media?.photos) { - const { photos } = tweet.media; - let photo: APIPhoto | APIMosaicPhoto = photos[(mediaNumber || 1) - 1]; - - /* If there isn't a specified media number and we have a - mosaic response, we'll render it using mosaic */ - if (typeof mediaNumber !== 'number' && tweet.media.mosaic) { - photo = { - /* Include dummy height/width for TypeScript reasons. We have a check to make sure we don't use these later. */ - height: 0, - width: 0, - url: tweet.media.mosaic.formats.jpeg, - type: 'photo', - altText: '' - }; - /* If mosaic isn't available or the link calls for a specific photo, - we'll indicate which photo it is out of the total */ - } else if (photos.length > 1) { - const photoCounter = Strings.PHOTO_COUNT.format({ - number: String(photos.indexOf(photo) + 1), - total: String(photos.length) - }); - - authorText = - authorText === Strings.DEFAULT_AUTHOR_TEXT - ? photoCounter - : `${authorText}${authorText ? ' ― ' : ''}${photoCounter}`; - - siteName = `${Constants.BRANDING_NAME} - ${photoCounter}`; - - if (engagementText) { - siteName = `${Constants.BRANDING_NAME} - ${engagementText} - ${photoCounter}`; - } - } - - /* Push the raw photo-related headers */ - headers.push( - `<meta property="twitter:image" content="${photo.url}"/>`, - `<meta property="og:image" content="${photo.url}"/>` + headers.push(...instructions.addHeaders); + } else if (tweet.media?.videos) { + const instructions = renderVideo( + { tweet: tweet, userAgent: userAgent, text: newText }, + tweet.media?.videos[0] ); - - if (!tweet.media.mosaic) { - headers.push( - `<meta property="twitter:image:width" content="${photo.width}"/>`, - `<meta property="twitter:image:height" content="${photo.height}"/>`, - `<meta property="og:image:width" content="${photo.width}"/>`, - `<meta property="og:image:height" content="${photo.height}"/>` - ); + headers.push(...instructions.addHeaders); + if (instructions.authorText) { + authorText = instructions.authorText; } - } - - /* We have external media available to us (i.e. YouTube videos) */ - if (tweet.media?.external) { + if (instructions.siteName) { + siteName = instructions.siteName; + } + } else if (tweet.media?.photos) { + const instructions = renderPhoto( + { + tweet: tweet, + authorText: authorText, + engagementText: engagementText, + userAgent: userAgent + }, + tweet.media?.photos[0] + ); + headers.push(...instructions.addHeaders); + } else if (tweet.media?.external) { const { external } = tweet.media; authorText = newText || ''; headers.push( diff --git a/src/helpers/linkFixer.ts b/src/helpers/linkFixer.ts index 8dfb8e7..d28bcd9 100644 --- a/src/helpers/linkFixer.ts +++ b/src/helpers/linkFixer.ts @@ -2,7 +2,12 @@ export const linkFixer = (tweet: TweetPartial, text: string): string => { if (typeof tweet.entities?.urls !== 'undefined') { tweet.entities?.urls.forEach((url: TcoExpansion) => { - text = text.replace(url.url, url.expanded_url); + let newURL = url.expanded_url; + + if (newURL.match(/^https:\/\/twitter\.com\/i\/web\/status\/\w+/g) !== null) { + newURL = ''; + } + text = text.replace(url.url, newURL); }); /* Remove any link with unavailable original. diff --git a/src/helpers/mosaic.ts b/src/helpers/mosaic.ts index 2ff2d4f..44e5c60 100644 --- a/src/helpers/mosaic.ts +++ b/src/helpers/mosaic.ts @@ -29,10 +29,11 @@ export const handleMosaic = async ( } return { + type: 'mosaic_photo', formats: { jpeg: `${baseUrl}jpeg/${id}${path}`, webp: `${baseUrl}webp/${id}${path}` } - } as unknown as APIMosaicPhoto; + } as APIMosaicPhoto; } }; diff --git a/src/render/photo.ts b/src/render/photo.ts new file mode 100644 index 0000000..67b871e --- /dev/null +++ b/src/render/photo.ts @@ -0,0 +1,62 @@ +import { Constants } from '../constants'; +import { Strings } from '../strings'; + +export const renderPhoto = ( + properties: RenderProperties, + photo: APIPhoto | APIMosaicPhoto +): ResponseInstructions => { + const { tweet, engagementText, authorText, isOverrideMedia, userAgent } = properties; + const instructions: ResponseInstructions = { addHeaders: [] }; + + if ( + (tweet.media?.photos?.length || 0) > 1 && + (!tweet.media?.mosaic || isOverrideMedia) + ) { + photo = photo as APIPhoto; + + const all = tweet.media?.all as APIMedia[]; + const baseString = + all.length === tweet.media?.photos?.length + ? Strings.PHOTO_COUNT + : Strings.MEDIA_COUNT; + + const photoCounter = baseString.format({ + number: String(all.indexOf(photo) + 1), + total: String(all.length) + }); + + const isTelegram = (userAgent?.indexOf('Telegram') ?? 0) > -1; + + if (authorText === Strings.DEFAULT_AUTHOR_TEXT || isTelegram) { + instructions.authorText = photoCounter; + } else { + instructions.authorText = `${authorText}${ + authorText ? ' ― ' : '' + }${photoCounter}`; + } + + if (engagementText && !isTelegram) { + instructions.siteName = `${Constants.BRANDING_NAME} - ${engagementText} - ${photoCounter}`; + } else { + instructions.siteName = `${Constants.BRANDING_NAME} - ${photoCounter}`; + } + } + + if (photo.type === 'mosaic_photo' && !isOverrideMedia) { + instructions.addHeaders = [ + `<meta property="twitter:image" content="${tweet.media?.mosaic?.formats.jpeg}"/>`, + `<meta property="og:image" content="${tweet.media?.mosaic?.formats.jpeg}"/>` + ]; + } else { + instructions.addHeaders = [ + `<meta property="twitter:image" content="${photo.url}"/>`, + `<meta property="og:image" content="${photo.url}"/>`, + `<meta property="twitter:image:width" content="${photo.width}"/>`, + `<meta property="twitter:image:height" content="${photo.height}"/>`, + `<meta property="og:image:width" content="${photo.width}"/>`, + `<meta property="og:image:height" content="${photo.height}"/>` + ]; + } + + return instructions; +}; diff --git a/src/render/video.ts b/src/render/video.ts new file mode 100644 index 0000000..a1d751e --- /dev/null +++ b/src/render/video.ts @@ -0,0 +1,61 @@ +import { Constants } from '../constants'; +import { Strings } from '../strings'; + +export const renderVideo = ( + properties: RenderProperties, + video: APIVideo +): ResponseInstructions => { + const { tweet, userAgent, text } = properties; + const instructions: ResponseInstructions = { addHeaders: [] }; + + const all = tweet.media?.all as APIMedia[]; + + /* This fix is specific to Discord not wanting to render videos that are too large, + or rendering low quality videos too small. + + Basically, our solution is to cut the dimensions in half if the video is too big (> 1080p), + or double them if it's too small. (<400p) + + We check both height and width so we can apply this to both horizontal and vertical videos equally*/ + + let sizeMultiplier = 1; + + if (video.width > 1920 || video.height > 1920) { + sizeMultiplier = 0.5; + } + if (video.width < 400 && video.height < 400) { + sizeMultiplier = 2; + } + + /* Like photos when picking a specific one (not using mosaic), + we'll put an indicator if there are more than one video */ + if (all && all.length > 1 && (userAgent?.indexOf('Telegram') ?? 0) > -1) { + const baseString = + all.length === tweet.media?.videos?.length + ? Strings.VIDEO_COUNT + : Strings.MEDIA_COUNT; + const videoCounter = baseString.format({ + number: String(all.indexOf(video) + 1), + total: String(all.length) + }); + + instructions.siteName = `${Constants.BRANDING_NAME} - ${videoCounter}`; + } + + instructions.authorText = tweet.translation?.text || text || ''; + + /* Push the raw video-related headers */ + instructions.addHeaders = [ + `<meta property="twitter:player:stream:content_type" content="${video.format}"/>`, + `<meta property="twitter:player:height" content="${video.height * sizeMultiplier}"/>`, + `<meta property="twitter:player:width" content="${video.width * sizeMultiplier}"/>`, + `<meta property="og:video" content="${video.url}"/>`, + `<meta property="og:video:secure_url" content="${video.url}"/>`, + `<meta property="og:video:height" content="${video.height * sizeMultiplier}"/>`, + `<meta property="og:video:width" content="${video.width * sizeMultiplier}"/>`, + `<meta property="og:video:type" content="${video.format}"/>`, + `<meta property="twitter:image" content="0"/>` + ]; + + return instructions; +}; diff --git a/src/strings.ts b/src/strings.ts index ec613fa..96ffcc0 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -131,8 +131,9 @@ This is caused by Twitter API downtime or a new bug. Try again in a little while QUOTE_TEXT: `↘️ Quoting {name} (@{screen_name})`, TRANSLATE_TEXT: `↘️ Translated from {language}`, TRANSLATE_TEXT_INTL: `↘️ {source} ➡️ {destination}`, - PHOTO_COUNT: `Photo {number} of {total}`, - VIDEO_COUNT: `Video {number} of {total}`, + PHOTO_COUNT: `Photo {number} / {total}`, + VIDEO_COUNT: `Video {number} / {total}`, + MEDIA_COUNT: `Media {number} / {total}`, SINGULAR_DAY_LEFT: 'day left', PLURAL_DAYS_LEFT: 'days left', diff --git a/src/types/env.d.ts b/src/types/env.d.ts index 3656c89..79ea257 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -1,5 +1,4 @@ declare const BRANDING_NAME: string; -declare const BRANDING_NAME_DISCORD: string; declare const DIRECT_MEDIA_DOMAINS: string; declare const TEXT_ONLY_DOMAINS: string; declare const DEPRECATED_DOMAIN_LIST: string; diff --git a/src/types/types.d.ts b/src/types/types.d.ts index 476e3bd..fc868f8 100644 --- a/src/types/types.d.ts +++ b/src/types/types.d.ts @@ -15,6 +15,24 @@ interface StatusResponse { cacheControl?: string | null; } +interface ResponseInstructions { + addHeaders: string[]; + authorText?: string; + siteName?: string; + engagementText?: string; + text?: string; +} + +interface RenderProperties { + tweet: APITweet; + siteText?: string; + authorText?: string; + engagementText?: string; + isOverrideMedia?: boolean; + userAgent?: string; + text?: string; +} + interface Request { params: { [param: string]: string; @@ -91,15 +109,26 @@ interface APIPoll { time_left_en: string; } -interface APIPhoto { - type: 'photo'; +interface APIMedia { + type: string; url: string; width: number; height: number; +} + +interface APIPhoto extends APIMedia { + type: 'photo'; altText: string; } -interface APIMosaicPhoto { +interface APIVideo extends APIMedia { + type: 'video' | 'gif'; + thumbnail_url: string; + format: string; + duration: number; +} + +interface APIMosaicPhoto extends APIMedia { type: 'mosaic_photo'; formats: { webp: string; @@ -107,16 +136,6 @@ interface APIMosaicPhoto { }; } -interface APIVideo { - type: 'video' | 'gif'; - url: string; - thumbnail_url: string; - width: number; - height: number; - format: string; - duration: number; -} - interface APITweet { id: string; url: string; @@ -140,12 +159,13 @@ interface APITweet { external?: APIExternalMedia; photos?: APIPhoto[]; videos?: APIVideo[]; + all?: APIMedia[]; mosaic?: APIMosaicPhoto; }; lang: string | null; possibly_sensitive: boolean; - + replying_to: string | null; replying_to_status: string | null; diff --git a/webpack.config.js b/webpack.config.js index 8496e52..ab41b68 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -19,7 +19,6 @@ require('dotenv').config(); let envVariables = [ 'BRANDING_NAME', - 'BRANDING_NAME_DISCORD', 'DIRECT_MEDIA_DOMAINS', 'TEXT_ONLY_DOMAINS', 'HOST_URL',