Re-organizing stuff! Better comments!

This commit is contained in:
dangered wolf 2022-08-15 18:38:52 -04:00
parent aa7d3bef64
commit 043f6b6e0c
No known key found for this signature in database
GPG key ID: 41E4D37680ED8B58
18 changed files with 156 additions and 87 deletions

View file

@ -1,38 +1,15 @@
import { renderCard } from './card'; import { renderCard } from './helpers/card';
import { Constants } from './constants'; import { Constants } from './constants';
import { fetchUsingGuest } from './fetch'; import { fetchUsingGuest } from './fetch';
import { linkFixer } from './linkFixer'; import { linkFixer } from './helpers/linkFixer';
import { handleMosaic } from './mosaic'; import { handleMosaic } from './helpers/mosaic';
import { colorFromPalette } from './palette'; import { colorFromPalette } from './helpers/palette';
import { translateTweet } from './translate'; import { translateTweet } from './helpers/translate';
import { unescapeText } from './utils'; import { unescapeText } from './helpers/utils';
import { processMedia } from './helpers/media';
const processMedia = (media: TweetMedia): APIPhoto | APIVideo | null => {
if (media.type === 'photo') {
return {
type: 'photo',
url: media.media_url_https,
width: media.original_info.width,
height: media.original_info.height
};
} else if (media.type === 'video' || media.type === 'animated_gif') {
// Find the variant with the highest bitrate
const bestVariant = media.video_info?.variants?.reduce?.((a, b) =>
(a.bitrate ?? 0) > (b.bitrate ?? 0) ? a : b
);
return {
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,
format: bestVariant?.content_type || '',
type: media.type === 'animated_gif' ? 'gif' : 'video'
};
}
return null;
};
/* 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 ( const populateTweetProperties = async (
tweet: TweetPartial, tweet: TweetPartial,
conversation: TimelineBlobPartial, conversation: TimelineBlobPartial,
@ -50,6 +27,7 @@ const populateTweetProperties = async (
const screenName = user?.screen_name || ''; const screenName = user?.screen_name || '';
const name = user?.name || ''; const name = user?.name || '';
/* Populating a lot of the basics */
apiTweet.url = `${Constants.TWITTER_ROOT}/${screenName}/status/${tweet.id_str}`; apiTweet.url = `${Constants.TWITTER_ROOT}/${screenName}/status/${tweet.id_str}`;
apiTweet.id = tweet.id_str; apiTweet.id = tweet.id_str;
apiTweet.text = unescapeText(linkFixer(tweet, tweet.full_text)); apiTweet.text = unescapeText(linkFixer(tweet, tweet.full_text));
@ -82,6 +60,7 @@ const populateTweetProperties = async (
tweet.extended_entities?.media || tweet.entities?.media || [] tweet.extended_entities?.media || tweet.entities?.media || []
); );
/* Populate this Tweet's media */
mediaList.forEach(media => { mediaList.forEach(media => {
const mediaObject = processMedia(media); const mediaObject = processMedia(media);
if (mediaObject) { if (mediaObject) {
@ -99,7 +78,7 @@ const populateTweetProperties = async (
apiTweet.media.video = { apiTweet.media.video = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error // @ts-expect-error Temporary warning
WARNING: WARNING:
'video is deprecated and will be removed. Please use videos[0] instead.', 'video is deprecated and will be removed. Please use videos[0] instead.',
...mediaObject ...mediaObject
@ -108,10 +87,12 @@ const populateTweetProperties = async (
} }
}); });
/* 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 */
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.id_str);
if (typeof apiTweet.media !== 'undefined' && mosaic !== null) { if (typeof apiTweet.media !== 'undefined' && mosaic !== null) {
@ -119,6 +100,7 @@ const populateTweetProperties = async (
} }
} }
/* Populate a Twitter card */
if (tweet.card) { if (tweet.card) {
const card = await renderCard(tweet.card); const card = await renderCard(tweet.card);
if (card.external_media) { if (card.external_media) {
@ -131,9 +113,7 @@ const populateTweetProperties = async (
} }
} }
console.log('language', language); /* If a language is specified in API or by user, let's try translating it! */
/* If a language is specified, let's try translating it! */
if (typeof language === 'string' && language.length === 2 && language !== tweet.lang) { if (typeof language === 'string' && language.length === 2 && language !== tweet.lang) {
const translateAPI = await translateTweet( const translateAPI = await translateTweet(
tweet, tweet,
@ -153,6 +133,9 @@ const populateTweetProperties = async (
return apiTweet; return apiTweet;
}; };
/* API for Twitter statuses (Tweets)
Used internally by FixTweet's embed service, or
available for free using api.fxtwitter.com. */
export const statusAPI = async ( export const statusAPI = async (
status: string, status: string,
language: string | undefined language: string | undefined
@ -183,6 +166,7 @@ export const statusAPI = async (
return { code: 500, message: 'API_FAIL' }; return { code: 500, message: 'API_FAIL' };
} }
/* Creating the response objects */
const response: APIResponse = { code: 200, message: 'OK' } as APIResponse; const response: APIResponse = { code: 200, message: 'OK' } as APIResponse;
const apiTweet: APITweet = (await populateTweetProperties( const apiTweet: APITweet = (await populateTweetProperties(
tweet, tweet,
@ -190,6 +174,7 @@ export const statusAPI = async (
language language
)) as APITweet; )) as APITweet;
/* We found a quote tweet, let's process that too */
const quoteTweet = const quoteTweet =
conversation.globalObjects?.tweets?.[tweet.quoted_status_id_str || '0'] || null; conversation.globalObjects?.tweets?.[tweet.quoted_status_id_str || '0'] || null;
if (quoteTweet) { if (quoteTweet) {
@ -200,6 +185,8 @@ export const statusAPI = async (
)) as APITweet; )) as APITweet;
} }
/* Finally, staple the Tweet to the response and return it */
response.tweet = apiTweet; response.tweet = apiTweet;
return response; return response;
}; };

View file

@ -1,6 +1,9 @@
/* We keep this value up-to-date for making our requests to Twitter as
indistinguishable from normal user traffic as possible. */
const fakeChromeVersion = '104'; const fakeChromeVersion = '104';
export const Constants = { export const Constants = {
/* These constants are populated by variables in .env, then set by Webpack */
BRANDING_NAME: BRANDING_NAME, BRANDING_NAME: BRANDING_NAME,
BRANDING_NAME_DISCORD: BRANDING_NAME_DISCORD, BRANDING_NAME_DISCORD: BRANDING_NAME_DISCORD,
DIRECT_MEDIA_DOMAINS: DIRECT_MEDIA_DOMAINS.split(','), DIRECT_MEDIA_DOMAINS: DIRECT_MEDIA_DOMAINS.split(','),

View file

@ -25,7 +25,12 @@ export const fetchUsingGuest = async (status: string): Promise<TimelineBlobParti
const cache = caches.default; const cache = caches.default;
while (apiAttempts < 10) { while (apiAttempts < 10) {
const csrfToken = crypto.randomUUID().replace(/-/g, ''); // Generate a random CSRF token, this doesn't matter, Twitter just cares that header and cookie match const csrfToken = crypto
.randomUUID()
.replace(
/-/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: { [header: string]: string } = {
Authorization: Constants.GUEST_BEARER_TOKEN, Authorization: Constants.GUEST_BEARER_TOKEN,
@ -84,7 +89,8 @@ export const fetchUsingGuest = async (status: string): Promise<TimelineBlobParti
headers['x-guest-token'] = guestToken; headers['x-guest-token'] = guestToken;
/* We pretend to be the Twitter Web App as closely as possible, /* We pretend to be the Twitter Web App as closely as possible,
so we use twitter.com/i/api/2 instead of api.twitter.com/2 */ so we use twitter.com/i/api/2 instead of api.twitter.com/2.
We probably don't have to do this at all. But hey, better to be consistent with Twitter Web App. */
let conversation: TimelineBlobPartial; let conversation: TimelineBlobPartial;
let apiRequest; let apiRequest;
@ -99,7 +105,7 @@ export const fetchUsingGuest = async (status: string): Promise<TimelineBlobParti
conversation = await apiRequest.json(); conversation = 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.
It's rare, but it happens */ It's uncommon, but it happens */
console.error('Unknown error while fetching conversation from API'); console.error('Unknown error while fetching conversation from API');
cachedTokenFailed = true; cachedTokenFailed = true;
continue; continue;
@ -108,7 +114,8 @@ export const fetchUsingGuest = async (status: string): Promise<TimelineBlobParti
if ( if (
typeof conversation.globalObjects === 'undefined' && typeof conversation.globalObjects === 'undefined' &&
(typeof conversation.errors === 'undefined' || (typeof conversation.errors === 'undefined' ||
conversation.errors?.[0]?.code === 239) conversation.errors?.[0]?.code ===
239) /* TODO: i forgot what code 239 actually is lol */
) { ) {
console.log('Failed to fetch conversation, got', conversation); console.log('Failed to fetch conversation, got', conversation);
cachedTokenFailed = true; cachedTokenFailed = true;
@ -122,6 +129,6 @@ export const fetchUsingGuest = async (status: string): Promise<TimelineBlobParti
} }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - This is only returned if we completely failed to fetch the conversation // @ts-expect-error - This is only returned if we completely failed to fetch the conversation
return {}; return {};
}; };

View file

@ -1,3 +1,4 @@
/* The embed "author" text we populate with replies, retweets, and likes unless it's a video */
export const getAuthorText = (tweet: APITweet): string | null => { export const getAuthorText = (tweet: APITweet): string | null => {
/* Build out reply, retweet, like counts */ /* Build out reply, retweet, like counts */
if (tweet.likes > 0 || tweet.retweets > 0 || tweet.replies > 0) { if (tweet.likes > 0 || tweet.retweets > 0 || tweet.replies > 0) {

View file

@ -1,5 +1,6 @@
import { calculateTimeLeftString } from './pollHelper'; import { calculateTimeLeftString } from './pollTime';
/* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */
export const renderCard = async ( export const renderCard = async (
card: TweetCard card: TweetCard
): Promise<{ poll?: APIPoll; external_media?: APIExternalMedia }> => { ): Promise<{ poll?: APIPoll; external_media?: APIExternalMedia }> => {
@ -11,7 +12,7 @@ export const renderCard = async (
let totalVotes = 0; let totalVotes = 0;
if (typeof values !== 'undefined') { if (typeof values !== 'undefined') {
/* TODO: make poll code cleaner */ /* TODO: make poll code cleaner. It really sucks. */
if ( if (
typeof values.choice1_count !== 'undefined' && typeof values.choice1_count !== 'undefined' &&
typeof values.choice2_count !== 'undefined' typeof values.choice2_count !== 'undefined'
@ -54,8 +55,8 @@ export const renderCard = async (
}); });
return { poll: poll }; return { poll: poll };
/* Oh good, a non-Twitter video URL! This enables YouTube embeds and stuff to just work */
} else if (typeof values.player_url !== 'undefined') { } else if (typeof values.player_url !== 'undefined') {
/* 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',

View file

@ -1,10 +1,13 @@
/* Helps replace t.co links with their originals */
export const linkFixer = (tweet: TweetPartial, text: string): string => { export const linkFixer = (tweet: TweetPartial, text: string): string => {
// Replace t.co links with their full counterparts
if (typeof tweet.entities?.urls !== 'undefined') { if (typeof tweet.entities?.urls !== 'undefined') {
tweet.entities?.urls.forEach((url: TcoExpansion) => { tweet.entities?.urls.forEach((url: TcoExpansion) => {
text = text.replace(url.url, url.expanded_url); text = text.replace(url.url, url.expanded_url);
}); });
/* Remove any link with unavailable original.
This means that stuff like the t.co link to pic.twitter.com
will get removed in image/video Tweets */
text = text.replace(/ ?https:\/\/t\.co\/\w{10}/g, ''); text = text.replace(/ ?https:\/\/t\.co\/\w{10}/g, '');
} }

26
src/helpers/media.ts Normal file
View file

@ -0,0 +1,26 @@
/* Help populate API response for media */
export const processMedia = (media: TweetMedia): APIPhoto | APIVideo | null => {
if (media.type === 'photo') {
return {
type: 'photo',
url: media.media_url_https,
width: media.original_info.width,
height: media.original_info.height
};
} else if (media.type === 'video' || media.type === 'animated_gif') {
/* Find the variant with the highest bitrate */
const bestVariant = media.video_info?.variants?.reduce?.((a, b) =>
(a.bitrate ?? 0) > (b.bitrate ?? 0) ? a : b
);
return {
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,
format: bestVariant?.content_type || '',
type: media.type === 'animated_gif' ? 'gif' : 'video'
};
}
return null;
};

View file

@ -1,4 +1,4 @@
import { Constants } from './constants'; import { Constants } from '../constants';
export const handleMosaic = async ( export const handleMosaic = async (
mediaList: APIPhoto[], mediaList: APIPhoto[],
@ -11,15 +11,13 @@ export const handleMosaic = async (
selectedDomain = domain; selectedDomain = domain;
} }
// Fallback if there are no Mosaic servers /* Fallback if there are no Mosaic servers */
if (selectedDomain === null) { if (selectedDomain === null) {
return null; return null;
} else { } else {
// console.log('mediaList', mediaList);
const mosaicMedia = mediaList.map( const mosaicMedia = mediaList.map(
media => media.url?.match(/(?<=\/media\/)[\w-]+(?=[.?])/g)?.[0] || '' media => media.url?.match(/(?<=\/media\/)[\w-]+(?=[.?])/g)?.[0] || ''
); );
// console.log('mosaicMedia', mosaicMedia);
const baseUrl = `https://${selectedDomain}/`; const baseUrl = `https://${selectedDomain}/`;
let path = ''; let path = '';
@ -50,7 +48,8 @@ export const handleMosaic = async (
} }
}; };
// Port of https://github.com/FixTweet/mosaic/blob/feature/size-endpoint/src/mosaic.rs#L236 /* TypeScript Port of https://github.com/FixTweet/mosaic/blob/feature/size-endpoint/src/mosaic.rs#L236
We use this to generate accurate mosaic sizes which helps Discord render it correctly */
const SPACING_SIZE = 10; const SPACING_SIZE = 10;
/* /*

View file

@ -1,6 +1,7 @@
import { Constants } from './constants'; import { Constants } from '../constants';
// https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb /* Converts rgb to hex, as we use hex for API and embeds
https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb */
const componentToHex = (component: number) => { const componentToHex = (component: number) => {
const hex = component.toString(16); const hex = component.toString(16);
return hex.length === 1 ? '0' + hex : hex; return hex.length === 1 ? '0' + hex : hex;
@ -9,6 +10,7 @@ const componentToHex = (component: number) => {
const rgbToHex = (r: number, g: number, b: number) => const rgbToHex = (r: number, g: number, b: number) =>
`#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`; `#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`;
/* Selects the (hopefully) best color from Twitter's palette */
export const colorFromPalette = (palette: MediaPlaceholderColor[]) => { export const colorFromPalette = (palette: MediaPlaceholderColor[]) => {
for (let i = 0; i < palette.length; i++) { for (let i = 0; i < palette.length; i++) {
const rgb = palette[i].rgb; const rgb = palette[i].rgb;

View file

@ -1,4 +1,6 @@
import { Strings } from './strings'; /* Helps create strings for polls! */
import { Strings } from '../strings';
export const calculateTimeLeft = (date: Date) => { export const calculateTimeLeft = (date: Date) => {
const now = new Date(); const now = new Date();

View file

@ -1,5 +1,6 @@
import { Strings } from './strings'; import { Strings } from '../strings';
/* Helper for Quote Tweets */
export const handleQuote = (quote: APITweet): string | null => { export const handleQuote = (quote: APITweet): string | null => {
console.log('Quoting status ', quote.id); console.log('Quoting status ', quote.id);

View file

@ -1,5 +1,6 @@
import { Constants } from './constants'; import { Constants } from '../constants';
/* Handles translating Tweets when asked! */
export const translateTweet = async ( export const translateTweet = async (
tweet: TweetPartial, tweet: TweetPartial,
guestToken: string, guestToken: string,

View file

@ -1,8 +1,8 @@
import { Constants } from './constants'; import { Constants } from './constants';
import { handleQuote } from './quote'; import { handleQuote } from './helpers/quote';
import { sanitizeText } from './utils'; import { sanitizeText } from './helpers/utils';
import { Strings } from './strings'; import { Strings } from './strings';
import { getAuthorText } from './author'; import { getAuthorText } from './helpers/author';
import { statusAPI } from './api'; import { statusAPI } from './api';
export const returnError = (error: string): StatusResponse => { export const returnError = (error: string): StatusResponse => {
@ -17,18 +17,22 @@ export const returnError = (error: string): StatusResponse => {
}; };
}; };
/* Handler for Twitter statuses (Tweets).
Like Twitter, we use the terminologies interchangably. */
export const handleStatus = async ( export const handleStatus = async (
status: string, status: string,
mediaNumber?: number, mediaNumber?: number,
userAgent?: string, userAgent?: string,
flags?: InputFlags, flags?: InputFlags,
language?: string language?: string
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<StatusResponse> => { ): Promise<StatusResponse> => {
console.log('Direct?', flags?.direct); console.log('Direct?', flags?.direct);
const api = await statusAPI(status, language); const api = await statusAPI(status, language);
const tweet = api?.tweet as APITweet; const tweet = api?.tweet as APITweet;
/* Catch this request if it's an API response */
if (flags?.api) { if (flags?.api) {
return { return {
response: new Response(JSON.stringify(api), { response: new Response(JSON.stringify(api), {
@ -38,6 +42,7 @@ export const handleStatus = async (
}; };
} }
/* If there was any errors fetching the Tweet, we'll return it */
switch (api.code) { switch (api.code) {
case 401: case 401:
return returnError(Strings.ERROR_PRIVATE); return returnError(Strings.ERROR_PRIVATE);
@ -47,6 +52,7 @@ export const handleStatus = async (
return returnError(Strings.ERROR_API_FAIL); return returnError(Strings.ERROR_API_FAIL);
} }
/* Catch direct media request (d.fxtwitter.com, or .mp4 / .jpg) */
if (flags?.direct && tweet.media) { if (flags?.direct && tweet.media) {
let redirectUrl: string | null = null; let redirectUrl: string | null = null;
if (tweet.media.videos) { if (tweet.media.videos) {
@ -61,12 +67,15 @@ export const handleStatus = async (
} }
} }
/* Use quote media if there is no media */ /* Use quote media if there is no media in this Tweet */
if (!tweet.media && tweet.quote?.media) { if (!tweet.media && tweet.quote?.media) {
tweet.media = tweet.quote.media; tweet.media = tweet.quote.media;
tweet.twitter_card = tweet.quote.twitter_card; tweet.twitter_card = tweet.quote.twitter_card;
} }
/* At this point, we know we're going to have to create a
regular embed because it's not an API or direct media request */
let authorText = getAuthorText(tweet) || Strings.DEFAULT_AUTHOR_TEXT; let authorText = getAuthorText(tweet) || Strings.DEFAULT_AUTHOR_TEXT;
const engagementText = authorText.replace(/ {4}/g, ' '); const engagementText = authorText.replace(/ {4}/g, ' ');
let siteName = Constants.BRANDING_NAME; let siteName = Constants.BRANDING_NAME;
@ -80,12 +89,17 @@ export const handleStatus = async (
`<meta name="twitter:title" content="${tweet.author.name} (@${tweet.author.screen_name})"/>` `<meta name="twitter:title" content="${tweet.author.name} (@${tweet.author.screen_name})"/>`
]; ];
/* This little thing ensures if by some miracle a FixTweet embed is loaded in a browser,
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 (userAgent?.indexOf('Telegram') === -1) {
headers.push( headers.push(
`<meta http-equiv="refresh" content="0;url=https://twitter.com/${tweet.author.screen_name}/status/${tweet.id}"/>` `<meta http-equiv="refresh" content="0;url=https://twitter.com/${tweet.author.screen_name}/status/${tweet.id}"/>`
); );
} }
/* This Tweet has a translation attached to it, so we'll render it. */
if (tweet.translation) { if (tweet.translation) {
const { translation } = tweet; const { translation } = tweet;
@ -102,7 +116,10 @@ export const handleStatus = async (
newText = `${translation.text}\n\n` + `${formatText}\n\n` + `${newText}`; newText = `${translation.text}\n\n` + `${formatText}\n\n` + `${newText}`;
} }
/* Video renderer */ /* This Tweet has a video to render.
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) { if (tweet.media?.videos) {
authorText = newText || ''; authorText = newText || '';
@ -113,8 +130,13 @@ export const handleStatus = async (
const { videos } = tweet.media; const { videos } = tweet.media;
const video = videos[(mediaNumber || 1) - 1]; const video = videos[(mediaNumber || 1) - 1];
/* Multiplying by 0.5 is an ugly hack to fix Discord /* This fix is specific to Discord not wanting to render videos that are too large,
disliking videos that are too large lol */ 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; let sizeMultiplier = 1;
@ -125,6 +147,8 @@ export const handleStatus = async (
sizeMultiplier = 2; 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) { if (videos.length > 1) {
const videoCounter = Strings.VIDEO_COUNT.format({ const videoCounter = Strings.VIDEO_COUNT.format({
number: String(videos.indexOf(video) + 1), number: String(videos.indexOf(video) + 1),
@ -143,9 +167,9 @@ export const handleStatus = async (
} }
} }
headers.push(`<meta property="og:site_name" content="${siteName}"/>`); /* Push the raw video-related headers */
headers.push( headers.push(
`<meta property="og:site_name" content="${siteName}"/>`,
`<meta name="twitter:player:stream:content_type" content="${video.format}"/>`, `<meta name="twitter:player:stream:content_type" content="${video.format}"/>`,
`<meta name="twitter:player:height" content="${video.height * sizeMultiplier}"/>`, `<meta name="twitter:player:height" content="${video.height * sizeMultiplier}"/>`,
`<meta name="twitter:player:width" content="${video.width * sizeMultiplier}"/>`, `<meta name="twitter:player:width" content="${video.width * sizeMultiplier}"/>`,
@ -158,13 +182,16 @@ export const handleStatus = async (
); );
} }
/* Photo renderer */ /* This Tweet has one or more photos to render */
if (tweet.media?.photos) { if (tweet.media?.photos) {
const { photos } = tweet.media; const { photos } = tweet.media;
let photo = photos[(mediaNumber || 1) - 1]; let photo = 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) { if (typeof mediaNumber !== 'number' && tweet.media.mosaic) {
photo = { photo = {
/* Telegram is dumb and doesn't support webp in opengraph embeds */
url: url:
userAgent?.indexOf('Telegram') === -1 userAgent?.indexOf('Telegram') === -1
? tweet.media.mosaic.formats.webp ? tweet.media.mosaic.formats.webp
@ -173,6 +200,8 @@ export const handleStatus = async (
height: tweet.media.mosaic.height, height: tweet.media.mosaic.height,
type: 'photo' type: 'photo'
}; };
/* 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) { } else if (photos.length > 1) {
const photoCounter = Strings.PHOTO_COUNT.format({ const photoCounter = Strings.PHOTO_COUNT.format({
number: String(photos.indexOf(photo) + 1), number: String(photos.indexOf(photo) + 1),
@ -191,6 +220,7 @@ export const handleStatus = async (
} }
} }
/* Push the raw photo-related headers */
headers.push( headers.push(
`<meta name="twitter:image" content="${photo.url}"/>`, `<meta name="twitter:image" content="${photo.url}"/>`,
`<meta name="twitter:image:width" content="${photo.width}"/>`, `<meta name="twitter:image:width" content="${photo.width}"/>`,
@ -201,7 +231,7 @@ export const handleStatus = async (
); );
} }
/* External media renderer (i.e. YouTube) */ /* We have external media available to us (i.e. YouTube videos) */
if (tweet.media?.external) { if (tweet.media?.external) {
const { external } = tweet.media; const { external } = tweet.media;
authorText = newText || ''; authorText = newText || '';
@ -217,32 +247,41 @@ export const handleStatus = async (
); );
} }
/* Poll renderer */ /* This Tweet contains a poll, so we'll render it */
if (tweet.poll) { if (tweet.poll) {
const { poll } = tweet; const { poll } = tweet;
let barLength = 36; let barLength = 36;
let str = ''; let str = '';
/* Telegram Embeds are smaller, so we use a smaller bar to compensate */
if (userAgent?.indexOf('Telegram') !== -1) { if (userAgent?.indexOf('Telegram') !== -1) {
barLength = 24; barLength = 24;
} }
/* Render each poll choice */
tweet.poll.choices.forEach(choice => { tweet.poll.choices.forEach(choice => {
// render bar
const bar = '█'.repeat((choice.percentage / 100) * barLength); const bar = '█'.repeat((choice.percentage / 100) * barLength);
// eslint-disable-next-line no-irregular-whitespace // eslint-disable-next-line no-irregular-whitespace
str += `${bar}\n${choice.label}  (${choice.percentage}%) str += `${bar}\n${choice.label}  (${choice.percentage}%)\n`;
`;
}); });
/* Finally, add the footer of the poll with # of votes and time left */
str += `\n${poll.total_votes} votes · ${poll.time_left_en}`; str += `\n${poll.total_votes} votes · ${poll.time_left_en}`;
/* And now we'll put the poll right after the Tweet text! */
newText += `\n\n${str}`; newText += `\n\n${str}`;
} }
/* This Tweet quotes another Tweet, so we'll render the other Tweet where possible */
if (api.tweet?.quote) {
const quoteText = handleQuote(api.tweet.quote);
newText += `\n${quoteText}`;
}
/* If we have no media to display, instead we'll display the user profile picture in the embed */
if (!tweet.media?.video && !tweet.media?.photos) { if (!tweet.media?.video && !tweet.media?.photos) {
headers.push( headers.push(
// Use a slightly higher resolution image for profile pics /* Use a slightly higher resolution image for profile pics */
`<meta property="og:image" content="${tweet.author.avatar_url?.replace( `<meta property="og:image" content="${tweet.author.avatar_url?.replace(
'_normal', '_normal',
'_200x200' '_200x200'
@ -251,11 +290,7 @@ export const handleStatus = async (
); );
} }
if (api.tweet?.quote) { /* Push basic headers relating to author, Tweet text, and site name */
const quoteText = handleQuote(api.tweet.quote);
newText += `\n${quoteText}`;
}
headers.push( headers.push(
`<meta content="${tweet.author.name} (@${tweet.author.screen_name})" property="og:title"/>`, `<meta content="${tweet.author.name} (@${tweet.author.screen_name})" property="og:title"/>`,
`<meta content="${sanitizeText(newText)}" property="og:description"/>`, `<meta content="${sanitizeText(newText)}" property="og:description"/>`,
@ -265,6 +300,9 @@ export const handleStatus = async (
/* Special reply handling if authorText is not overriden */ /* Special reply handling if authorText is not overriden */
if (tweet.replying_to && authorText === Strings.DEFAULT_AUTHOR_TEXT) { if (tweet.replying_to && authorText === Strings.DEFAULT_AUTHOR_TEXT) {
authorText = `↪ Replying to @${tweet.replying_to}`; authorText = `↪ Replying to @${tweet.replying_to}`;
/* We'll assume it's a thread if it's a reply to themselves */
} else if (tweet.replying_to === tweet.author.screen_name) {
authorText = `↪ A part @${tweet.author.screen_name}'s thread`;
} }
/* The additional oembed is pulled by Discord to enable improved embeds. /* The additional oembed is pulled by Discord to enable improved embeds.
@ -277,9 +315,10 @@ export const handleStatus = async (
)}" type="application/json+oembed" title="${tweet.author.name}">` )}" type="application/json+oembed" title="${tweet.author.name}">`
); );
/* When dealing with a Tweet of unknown lang, fall back to en */ /* When dealing with a Tweet of unknown lang, fall back to en */
const lang = tweet.lang === 'unk' ? 'en' : tweet.lang || 'en'; const lang = tweet.lang === null ? 'en' : tweet.lang || 'en';
/* Finally, after all that work we return the response HTML! */
return { return {
text: Strings.BASE_HTML.format({ text: Strings.BASE_HTML.format({
lang: `lang="${lang}"`, lang: `lang="${lang}"`,

View file

@ -4,10 +4,7 @@ declare global {
} }
} }
/* /* Useful little function to format strings for us */
Useful little function to format strings for us
*/
String.prototype.format = function (options: { [find: string]: string }) { String.prototype.format = function (options: { [find: string]: string }) {
return this.replace(/{([^{}]+)}/g, (match: string, name: string) => { return this.replace(/{([^{}]+)}/g, (match: string, name: string) => {
if (options[name] !== undefined) { if (options[name] !== undefined) {
@ -17,6 +14,7 @@ String.prototype.format = function (options: { [find: string]: string }) {
}); });
}; };
/* Lots of strings! These are strings used in HTML or are shown to end users in embeds. */
export const Strings = { export const Strings = {
BASE_HTML: `<!DOCTYPE html><html {lang}><!-- BASE_HTML: `<!DOCTYPE html><html {lang}><!--

View file

View file

@ -1,7 +1,5 @@
/* /* Types for various Twitter API objects.
Types for various objects. Note that a lot of these are not actually complete types. Many unused values may be missing.*/
Note that a lot of these are not actually complete types. Many unused values may be missing.
*/
type TimelineContent = { type TimelineContent = {
item?: { item?: {

View file

@ -1,4 +1,5 @@
/* tweetTypes has all the Twitter API-related types */ /* This file contains types relevant to FixTweet and the FixTweet API
For Twitter API types, see twitterTypes.d.ts */
type InputFlags = { type InputFlags = {
standard?: boolean; standard?: boolean;