Almost done with API rewrite

This commit is contained in:
dangered wolf 2022-07-25 18:44:46 -04:00
parent 65e0b775af
commit 2bcf9b0a80
No known key found for this signature in database
GPG key ID: 41E4D37680ED8B58
8 changed files with 174 additions and 187 deletions

View file

@ -13,7 +13,7 @@ const processMedia = (media: TweetMedia): APIPhoto | APIVideo | null => {
url: media.media_url_https, url: media.media_url_https,
width: media.original_info.width, width: media.original_info.width,
height: media.original_info.height height: media.original_info.height
} };
} else if (media.type === 'video' || media.type === 'animated_gif') { } else if (media.type === 'video' || media.type === 'animated_gif') {
// Find the variant with the highest bitrate // Find the variant with the highest bitrate
let bestVariant = media.video_info?.variants?.reduce?.((a, b) => let bestVariant = media.video_info?.variants?.reduce?.((a, b) =>
@ -26,7 +26,7 @@ const processMedia = (media: TweetMedia): APIPhoto | APIVideo | null => {
height: media.original_info.height, height: media.original_info.height,
format: bestVariant?.content_type || '', format: bestVariant?.content_type || '',
type: media.type === 'animated_gif' ? 'gif' : 'video' type: media.type === 'animated_gif' ? 'gif' : 'video'
} };
} }
return null; return null;
}; };
@ -38,6 +38,11 @@ const populateTweetProperties = async (
): Promise<APITweet> => { ): Promise<APITweet> => {
let apiTweet = {} as APITweet; let apiTweet = {} as APITweet;
/* 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
it in case a user appears multiple times in a thread. */
tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {};
const user = tweet.user as UserPartial; const user = tweet.user as UserPartial;
const screenName = user?.screen_name || ''; const screenName = user?.screen_name || '';
const name = user?.name || ''; const name = user?.name || '';
@ -48,7 +53,9 @@ const populateTweetProperties = async (
name: name, name: name,
screen_name: screenName, screen_name: screenName,
avatar_url: user?.profile_image_url_https.replace('_normal', '_200x200') || '', avatar_url: user?.profile_image_url_https.replace('_normal', '_200x200') || '',
avatar_color: colorFromPalette(tweet.user?.profile_image_extensions_media_color?.palette || []), avatar_color: colorFromPalette(
tweet.user?.profile_image_extensions_media_color?.palette || []
),
banner_url: user?.profile_banner_url || '' banner_url: user?.profile_banner_url || ''
}; };
apiTweet.replies = tweet.reply_count; apiTweet.replies = tweet.reply_count;
@ -57,13 +64,21 @@ const populateTweetProperties = async (
apiTweet.color = apiTweet.author.avatar_color; apiTweet.color = apiTweet.author.avatar_color;
apiTweet.twitter_card = 'tweet'; apiTweet.twitter_card = 'tweet';
if (tweet.lang !== 'unk') {
apiTweet.lang = tweet.lang;
} else {
apiTweet.lang = null;
}
apiTweet.replying_to = tweet.in_reply_to_screen_name || null;
let mediaList = Array.from( let mediaList = Array.from(
tweet.extended_entities?.media || tweet.entities?.media || [] tweet.extended_entities?.media || tweet.entities?.media || []
); );
mediaList.forEach(media => { mediaList.forEach(media => {
let mediaObject = processMedia(media); let mediaObject = processMedia(media);
console.log('mediaObject', JSON.stringify(mediaObject)) console.log('mediaObject', JSON.stringify(mediaObject));
if (mediaObject) { if (mediaObject) {
apiTweet.twitter_card = 'summary_large_image'; apiTweet.twitter_card = 'summary_large_image';
if (mediaObject.type === 'photo') { if (mediaObject.type === 'photo') {
@ -71,21 +86,21 @@ const populateTweetProperties = async (
apiTweet.media.photos = apiTweet.media.photos || []; apiTweet.media.photos = apiTweet.media.photos || [];
apiTweet.media.photos.push(mediaObject); apiTweet.media.photos.push(mediaObject);
console.log('media',apiTweet.media); console.log('media', apiTweet.media);
} else if (mediaObject.type === 'video' || mediaObject.type === 'gif') { } else if (mediaObject.type === 'video' || mediaObject.type === 'gif') {
apiTweet.media = apiTweet.media || {}; apiTweet.media = apiTweet.media || {};
apiTweet.media.video = mediaObject as APIVideo; apiTweet.media.video = mediaObject as APIVideo;
} }
} }
}) });
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);
} }
if (apiTweet.media?.photos?.length || 0 > 1) { if (apiTweet.media?.photos?.length || 0 > 1) {
let mosaic = await handleMosaic(apiTweet.media.photos || []); let mosaic = await handleMosaic(apiTweet.media?.photos || []);
if (mosaic !== null) { if (typeof apiTweet.media !== 'undefined' && mosaic !== null) {
apiTweet.media.mosaic = mosaic; apiTweet.media.mosaic = mosaic;
} }
} }
@ -106,7 +121,7 @@ const populateTweetProperties = async (
if (typeof language === 'string' && language.length === 2 && language !== tweet.lang) { if (typeof language === 'string' && language.length === 2 && language !== tweet.lang) {
let translateAPI = await translateTweet( let translateAPI = await translateTweet(
tweet, tweet,
conversation.guestToken || '', conversation.guestToken || '',
language language
); );
apiTweet.translation = { apiTweet.translation = {
@ -126,10 +141,9 @@ export const statusAPI = async (
): Promise<APIResponse> => { ): Promise<APIResponse> => {
const conversation = await fetchUsingGuest(status, event); const conversation = await fetchUsingGuest(status, event);
const tweet = conversation?.globalObjects?.tweets?.[status] || {}; const tweet = conversation?.globalObjects?.tweets?.[status] || {};
/* 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 console.log('users', JSON.stringify(conversation?.globalObjects?.users));
it in case a user appears multiple times in a thread. */ console.log('user_id_str', tweet.user_id_str);
tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {};
/* Fallback for if Tweet did not load */ /* Fallback for if Tweet did not load */
if (typeof tweet.full_text === 'undefined') { if (typeof tweet.full_text === 'undefined') {

View file

@ -1,15 +1,15 @@
export const getAuthorText = (tweet: TweetPartial): string | null => { export const getAuthorText = (tweet: APITweet): string | null => {
/* Build out reply, retweet, like counts */ /* Build out reply, retweet, like counts */
if (tweet.favorite_count > 0 || tweet.retweet_count > 0 || tweet.reply_count > 0) { if (tweet.likes > 0 || tweet.retweets > 0 || tweet.replies > 0) {
let authorText = ''; let authorText = '';
if (tweet.reply_count > 0) { if (tweet.replies > 0) {
authorText += `${tweet.reply_count} 💬 `; authorText += `${tweet.replies} 💬 `;
} }
if (tweet.retweet_count > 0) { if (tweet.retweets > 0) {
authorText += `${tweet.retweet_count} 🔁 `; authorText += `${tweet.retweets} 🔁 `;
} }
if (tweet.favorite_count > 0) { if (tweet.likes > 0) {
authorText += `${tweet.favorite_count} ❤️ `; authorText += `${tweet.likes} ❤️ `;
} }
authorText = authorText.trim(); authorText = authorText.trim();

View file

@ -84,9 +84,8 @@ export const renderCard = async (
totalVotes += parseInt(values.choice3_count.string_value); totalVotes += parseInt(values.choice3_count.string_value);
} }
if (typeof values.choice4_count !== 'undefined') { if (typeof values.choice4_count !== 'undefined') {
choices[values.choice4_label?.string_value || ''] = parseInt( choices[values.choice4_label?.string_value || ''] =
values.choice4_count.string_value parseInt(values.choice4_count.string_value) || 0;
) || 0;
totalVotes += parseInt(values.choice4_count.string_value); totalVotes += parseInt(values.choice4_count.string_value);
} }
@ -95,7 +94,7 @@ export const renderCard = async (
return { return {
label: label, label: label,
count: choices[label], count: choices[label],
percentage: ((Math.round((choices[label] / totalVotes) * 1000) || 0) / 10 || 0) percentage: (Math.round((choices[label] / totalVotes) * 1000) || 0) / 10 || 0
}; };
}); });

View file

@ -23,9 +23,7 @@ export const handleMosaic = async (
} else { } else {
// console.log('mediaList', mediaList); // console.log('mediaList', mediaList);
let mosaicMedia = mediaList.map( let mosaicMedia = mediaList.map(
media => media => media.url?.match(/(?<=\/media\/)[a-zA-Z0-9_\-]+(?=[\.\?])/g)?.[0] || ''
media.url?.match(/(?<=\/media\/)[a-zA-Z0-9_\-]+(?=[\.\?])/g)?.[0] ||
''
); );
// console.log('mosaicMedia', mosaicMedia); // console.log('mosaicMedia', mosaicMedia);
// TODO: use a better system for this, 0 gets png 1 gets webp, usually // TODO: use a better system for this, 0 gets png 1 gets webp, usually
@ -50,7 +48,7 @@ export const handleMosaic = async (
width: mediaList.reduce((acc, media) => acc + media.width, 0), width: mediaList.reduce((acc, media) => acc + media.width, 0),
formats: { formats: {
jpeg: `${baseUrl}jpeg${path}`, jpeg: `${baseUrl}jpeg${path}`,
webp: `${baseUrl}webp${path}`, webp: `${baseUrl}webp${path}`
} }
} as APIMosaicPhoto; } as APIMosaicPhoto;
} }

View file

@ -1,17 +1,16 @@
import { linkFixer } from './linkFixer';
import { Strings } from './strings'; import { Strings } from './strings';
export const handleQuote = (quote: TweetPartial): string | null => { export const handleQuote = (quote: APITweet): string | null => {
console.log('Quoting status ', quote.id_str); console.log('Quoting status ', quote.id);
let str = `\n`; let str = `\n`;
str += Strings.QUOTE_TEXT.format({ str += Strings.QUOTE_TEXT.format({
name: quote.user?.name, name: quote.author?.name,
screen_name: quote.user?.screen_name screen_name: quote.author?.screen_name
}); });
str += ` \n\n`; str += ` \n\n`;
str += linkFixer(quote, quote.full_text); str += quote.text;
return str; return str;
}; };

View file

@ -15,7 +15,7 @@ const statusRequest = async (
const userAgent = request.headers.get('User-Agent') || ''; const userAgent = request.headers.get('User-Agent') || '';
let isBotUA = let isBotUA =
userAgent.match(/bot|facebook|embed|got|Firefox\/92|curl|wget/gi) !== null; userAgent.match(/bot|facebook|embed|got|Firefox\/92|curl|wget/gi) !== null || true;
if ( if (
url.pathname.match(/\/status(es)?\/\d+\.(mp4|png|jpg)/g) !== null || url.pathname.match(/\/status(es)?\/\d+\.(mp4|png|jpg)/g) !== null ||
@ -169,17 +169,16 @@ const cacheWrapper = async (event: FetchEvent): Promise<Response> => {
switch (request.method) { switch (request.method) {
case 'GET': case 'GET':
if (cacheUrl.hostname !== Constants.API_HOST) { if (cacheUrl.hostname !== Constants.API_HOST) {
let cachedResponse = await cache.match(cacheKey); let cachedResponse = await cache.match(cacheKey);
if (cachedResponse) { if (cachedResponse) {
console.log('Cache hit'); console.log('Cache hit');
return cachedResponse; return cachedResponse;
} }
console.log('Cache miss'); console.log('Cache miss');
} }
let response = await router.handle(event.request, event); let response = await router.handle(event.request, event);
// Store the fetched response as cacheKey // Store the fetched response as cacheKey

View file

@ -1,5 +1,4 @@
import { Constants } from './constants'; import { Constants } from './constants';
import { fetchUsingGuest } from './fetch';
import { linkFixer } from './linkFixer'; import { linkFixer } from './linkFixer';
import { colorFromPalette } from './palette'; import { colorFromPalette } from './palette';
import { renderCard } from './card'; import { renderCard } from './card';
@ -7,7 +6,6 @@ import { handleQuote } from './quote';
import { sanitizeText } from './utils'; import { sanitizeText } from './utils';
import { Strings } from './strings'; import { Strings } from './strings';
import { handleMosaic } from './mosaic'; import { handleMosaic } from './mosaic';
import { translateTweet } from './translate';
import { getAuthorText } from './author'; import { getAuthorText } from './author';
import { statusAPI } from './api'; import { statusAPI } from './api';
@ -34,8 +32,9 @@ export const handleStatus = async (
console.log('Direct?', flags?.direct); console.log('Direct?', flags?.direct);
let api = await statusAPI(event, status, language || 'en'); let api = await statusAPI(event, status, language || 'en');
const tweet = api?.tweet as APITweet;
if (flags?.api || true) { if (flags?.api) {
return { return {
response: new Response(JSON.stringify(api), { response: new Response(JSON.stringify(api), {
headers: { ...Constants.RESPONSE_HEADERS, 'content-type': 'application/json' }, headers: { ...Constants.RESPONSE_HEADERS, 'content-type': 'application/json' },
@ -53,151 +52,72 @@ export const handleStatus = async (
return returnError(Strings.ERROR_API_FAIL); return returnError(Strings.ERROR_API_FAIL);
} }
let headers: string[] = []; if (flags?.direct) {
if (tweet.media) {
let redirectMedia = ''; let redirectUrl: string | null = null;
let engagementText = ''; if (tweet.media.video) {
redirectUrl = tweet.media.video.url;
if (api?.tweet?.translation) { } else if (tweet.media.photos) {
redirectUrl = (tweet.media.photos[mediaNumber || 0] || tweet.media.photos[0]).url;
}
if (redirectUrl) {
return { response: Response.redirect(redirectUrl, 302) };
}
}
} }
let mediaList = Array.from( if (!tweet.media && tweet.quote?.media) {
tweet.extended_entities?.media || tweet.entities?.media || [] tweet.media = tweet.quote.media;
); }
let authorText = getAuthorText(tweet) || Strings.DEFAULT_AUTHOR_TEXT; let authorText = getAuthorText(tweet) || Strings.DEFAULT_AUTHOR_TEXT;
let engagementText = authorText.replace(/ /g, ' ');
// engagementText has less spacing than authorText let headers: string[] = [
engagementText = authorText.replace(/ /g, ' '); `<meta content="${tweet.color}" property="theme-color"/>`,
`<meta name="twitter:card" content="${tweet.twitter_card}"/>`,
`<meta name="twitter:site" content="@${tweet.author.screen_name}"/>`,
`<meta name="twitter:creator" content="@${tweet.author.screen_name}"/>`,
`<meta name="twitter:title" content="${tweet.author.name} (@${tweet.author.screen_name})"/>`
];
text = linkFixer(tweet, text); if (tweet.media?.video) {
authorText = encodeURIComponent(tweet.text || '');
/* Cards are used by polls and non-Twitter video embeds */ const { video } = tweet.media;
if (tweet.card) {
let cardRender = await renderCard(tweet.card, headers, userAgent);
if (cardRender === 'EMBED_CARD') {
authorText = encodeURIComponent(text);
} else {
text += cardRender;
}
}
/* Trying to uncover a quote tweet referenced by this tweet */
let quoteTweetMaybe =
conversation.globalObjects?.tweets?.[tweet.quoted_status_id_str || '0'] || null;
if (quoteTweetMaybe) {
/* Populate quote tweet user from globalObjects */
quoteTweetMaybe.user =
conversation?.globalObjects?.users?.[quoteTweetMaybe.user_id_str] || {};
const quoteText = handleQuote(quoteTweetMaybe);
if (quoteText) {
console.log('quoteText', quoteText);
text += `\n${quoteText}`;
}
/* This code handles checking the quote tweet for media.
We'll embed a quote tweet's media if the linked tweet does not have any. */
if (
mediaList.length === 0 &&
(quoteTweetMaybe.extended_entities?.media?.length ||
quoteTweetMaybe.entities?.media?.length ||
0) > 0
) {
console.log(
`No media in main tweet, let's try embedding the quote tweet's media instead!`
);
mediaList = Array.from(
quoteTweetMaybe.extended_entities?.media || quoteTweetMaybe.entities?.media || []
);
console.log('updated mediaList', mediaList);
}
}
/* No media was found, but that's OK because we can still enrichen the Tweet
with a profile picture and color-matched embed in Discord! */
if (mediaList.length === 0) {
console.log('No media');
let palette = user?.profile_image_extensions_media_color?.palette;
let colorOverride: string = Constants.DEFAULT_COLOR;
if (palette) {
colorOverride = colorFromPalette(palette);
}
headers.push( headers.push(
`<meta content="${colorOverride}" property="theme-color"/>`, `<meta name="twitter:image" content="${video.thumbnail_url}"/>`,
`<meta property="og:site_name" content="${Constants.BRANDING_NAME}"/>`, `<meta name="twitter:player:stream" content="${video.url}"/>`,
// Use a slightly higher resolution image for profile pics `<meta name="twitter:player:stream:content_type" content="${video.format}"/>`,
`<meta property="og:image" content="${user?.profile_image_url_https.replace( `<meta name="twitter:player:height" content="${video.height}"/>`,
'_normal', `<meta name="twitter:player:width" content="${video.width}"/>`,
'_200x200' `<meta name="og:video" content="${video.url}"/>`,
)}"/>`, `<meta name="og:video:secure_url" content="${video.url}"/>`,
`<meta name="twitter:card" content="tweet"/>`, `<meta name="og:video:height" content="${video.height}"/>`,
`<meta name="twitter:title" content="${name} (@${screenName})"/>`, `<meta name="og:video:width" content="${video.width}"/>`,
`<meta name="twitter:image" content="0"/>`, `<meta name="og:video:type" content="${video.format}"/>`
`<meta name="twitter:creator" content="@${name}"/>`,
`<meta content="${sanitizeText(text)}" property="og:description"/>`
); );
} else { }
console.log('Media available');
let firstMedia = mediaList[0];
/* Try grabbing media color palette */ if (tweet.media?.photos) {
let palette = firstMedia?.ext_media_color?.palette; const { photos } = tweet.media;
let colorOverride: string = Constants.DEFAULT_COLOR; let photo = photos[mediaNumber || 0];
let pushedCardType = false;
if (palette) { if (typeof mediaNumber !== 'number' && tweet.media.mosaic) {
colorOverride = colorFromPalette(palette); photo = {
} url:
userAgent?.indexOf('Telegram') !== -1
/* theme-color is used by discord to style the embed. ? tweet.media.mosaic.formats.webp
: tweet.media.mosaic.formats.jpeg,
We take full advantage of that!*/ width: tweet.media.mosaic.width,
headers.push(`<meta content="${colorOverride}" property="theme-color"/>`); height: tweet.media.mosaic.height,
type: 'photo'
/* Inline helper function for handling media */ };
} else if (photos.length > 1) {
let actualMediaNumber = 0;
let renderedMosaic = false;
console.log('mediaNumber', mediaNumber);
console.log('mediaList length', mediaList.length);
/* You can specify a specific photo in the URL and we'll pull the correct one,
otherwise it falls back to first */
if (
typeof mediaNumber !== 'undefined' &&
typeof mediaList[mediaNumber - 1] !== 'undefined'
) {
console.log(`Media ${mediaNumber} found`);
actualMediaNumber = mediaNumber - 1;
processMedia(mediaList[actualMediaNumber]);
} else if (mediaList.length === 1) {
console.log(`Media ${mediaNumber} not found, ${mediaList.length} total`);
processMedia(firstMedia);
} else if (mediaList.length > 1) {
console.log('Handling mosaic');
processMedia(await handleMosaic(mediaList, userAgent || ''));
renderedMosaic = true;
}
if (flags?.direct && redirectMedia) {
let response = Response.redirect(redirectMedia, 302);
console.log(response);
return { response: response };
}
if (mediaList.length > 1 && !renderedMosaic) {
let photoCounter = Strings.PHOTO_COUNT.format({ let photoCounter = Strings.PHOTO_COUNT.format({
number: actualMediaNumber + 1, number: photos.indexOf(photo) + 1,
total: mediaList.length total: photos.length
}); });
authorText = authorText =
@ -212,21 +132,76 @@ export const handleStatus = async (
} }
headers.push(`<meta property="og:site_name" content="${siteName}"/>`); headers.push(`<meta property="og:site_name" content="${siteName}"/>`);
} else {
headers.push(
`<meta property="og:site_name" content="${Constants.BRANDING_NAME}"/>`
);
} }
headers.push( headers.push(
`<meta content="${name} (@${screenName})" property="og:title"/>`, `<meta name="twitter:image" content="${photo.url}"/>`,
`<meta content="${sanitizeText(text)}" property="og:description"/>` `<meta name="twitter:image:width" content="${photo.width}"/>`,
`<meta name="twitter:image:height" content="${photo.height}"/>`,
`<meta name="og:image" content="${photo.url}"/>`,
`<meta name="og:image:width" content="${photo.width}"/>`,
`<meta name="og:image:height" content="${photo.height}"/>`
); );
} }
if (tweet.media?.external) {
const { external } = tweet.media;
headers.push(
`<meta name="twitter:player" content="${external.url}">`,
`<meta name="twitter:player:width" content="${external.width}">`,
`<meta name="twitter:player:height" content="${external.height}">`,
`<meta property="og:type" content="video.other">`,
`<meta property="og:video:url" content="${external.url}">`,
`<meta property="og:video:secure_url" content="${external.url}">`,
`<meta property="og:video:width" content="${external.width}">`,
`<meta property="og:video:height" content="${external.height}">`
);
}
let siteName = Constants.BRANDING_NAME;
if (!tweet.media?.video && !tweet.media?.photos) {
headers.push(
// Use a slightly higher resolution image for profile pics
`<meta property="og:image" content="${tweet.author.avatar_url?.replace(
'_normal',
'_200x200'
)}"/>`,
`<meta name="twitter:image" content="0"/>`
);
}
let newText = tweet.text;
if (api.tweet?.translation) {
const { translation } = api.tweet;
let formatText =
language === 'en'
? Strings.TRANSLATE_TEXT.format({
language: translation.source_lang
})
: Strings.TRANSLATE_TEXT_INTL.format({
source: translation.source_lang.toUpperCase(),
destination: translation.target_lang.toUpperCase()
});
newText = `${translation.text}\n\n` + `${formatText}\n\n` + `${newText}`;
}
if (api.tweet?.quote) {
const quoteText = handleQuote(api.tweet.quote);
newText += `\n${quoteText}`;
}
headers.push(
`<meta content="${tweet.author.name} (@${tweet.author.screen_name})" property="og:title"/>`,
`<meta content="${sanitizeText(newText)}" property="og:description"/>`
);
/* Special reply handling if authorText is not overriden */ /* Special reply handling if authorText is not overriden */
if (tweet.in_reply_to_screen_name && authorText === 'Twitter') { if (tweet.replying_to && authorText === Strings.DEFAULT_AUTHOR_TEXT) {
authorText = `↪ Replying to @${tweet.in_reply_to_screen_name}`; authorText = `↪ Replying to @${tweet.replying_to}`;
} }
/* The additional oembed is pulled by Discord to enable improved embeds. /* The additional oembed is pulled by Discord to enable improved embeds.
@ -235,8 +210,8 @@ export const handleStatus = async (
`<link rel="alternate" href="${Constants.HOST_URL}/owoembed?text=${encodeURIComponent( `<link rel="alternate" href="${Constants.HOST_URL}/owoembed?text=${encodeURIComponent(
authorText authorText
)}&status=${encodeURIComponent(status)}&author=${encodeURIComponent( )}&status=${encodeURIComponent(status)}&author=${encodeURIComponent(
user?.screen_name || '' tweet.author?.screen_name || ''
)}" type="application/json+oembed" title="${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 */

9
src/types.d.ts vendored
View file

@ -70,7 +70,7 @@ interface APIMosaicPhoto {
formats: { formats: {
webp: string; webp: string;
jpeg: string; jpeg: string;
} };
} }
interface APIVideo { interface APIVideo {
@ -86,7 +86,7 @@ interface APITweet {
id: string; id: string;
url: string; url: string;
tweet: string; tweet: string;
text?: string; text: string;
created_at: string; created_at: string;
likes: number; likes: number;
@ -100,12 +100,15 @@ interface APITweet {
translation?: APITranslate; translation?: APITranslate;
author: APIAuthor; author: APIAuthor;
media: { media?: {
external?: APIExternalMedia; external?: APIExternalMedia;
photos?: APIPhoto[]; photos?: APIPhoto[];
video?: APIVideo; video?: APIVideo;
mosaic?: APIMosaicPhoto; mosaic?: APIMosaicPhoto;
}; };
lang: string | null;
replying_to: string | null;
twitter_card: 'tweet' | 'summary' | 'summary_large_image' | 'player'; twitter_card: 'tweet' | 'summary' | 'summary_large_image' | 'player';
} }