Synchronize terminology (status/tweet/post -> status)

This commit is contained in:
dangered wolf 2023-12-08 14:59:45 -05:00
parent 5cca3203c6
commit 2ff6e15f15
No known key found for this signature in database
GPG key ID: 41E4D37680ED8B58
16 changed files with 446 additions and 447 deletions

View file

@ -24,7 +24,7 @@ export const returnError = (c: Context, error: string): Response => {
Like Twitter, we use the terminologies interchangably. */ Like Twitter, we use the terminologies interchangably. */
export const handleStatus = async ( export const handleStatus = async (
c: Context, c: Context,
status: string, statusId: string,
mediaNumber: number | undefined, mediaNumber: number | undefined,
userAgent: string, userAgent: string,
flags: InputFlags, flags: InputFlags,
@ -41,19 +41,19 @@ export const handleStatus = async (
} }
const thread = await constructTwitterThread( const thread = await constructTwitterThread(
status, statusId,
fetchWithThreads, fetchWithThreads,
c, c,
language, language,
flags?.api ?? false flags?.api ?? false
); );
const tweet = thread?.status as APITweet; const status = thread?.status as APITwitterStatus;
const api = { const api = {
code: thread.code, code: thread.code,
message: '', message: '',
tweet: tweet tweet: status
}; };
switch (api.code) { switch (api.code) {
@ -82,7 +82,7 @@ export const handleStatus = async (
return c.json(api); return c.json(api);
} }
if (tweet === null) { if (status === null) {
return returnError(c, Strings.ERROR_TWEET_NOT_FOUND); return returnError(c, Strings.ERROR_TWEET_NOT_FOUND);
} }
@ -98,17 +98,17 @@ export const handleStatus = async (
} }
const isTelegram = (userAgent || '').indexOf('Telegram') > -1; const isTelegram = (userAgent || '').indexOf('Telegram') > -1;
/* Should sensitive posts be allowed Instant View? */ /* Should sensitive statuses be allowed Instant View? */
let useIV = let useIV =
isTelegram /*&& !tweet.possibly_sensitive*/ && isTelegram /*&& !status.possibly_sensitive*/ &&
!flags?.direct && !flags?.direct &&
!flags?.gallery && !flags?.gallery &&
!flags?.api && !flags?.api &&
(tweet.media?.photos?.[0] || // Force instant view for photos for now https://bugs.telegram.org/c/33679 (status.media?.photos?.[0] || // Force instant view for photos for now https://bugs.telegram.org/c/33679
tweet.media?.mosaic || status.media?.mosaic ||
tweet.is_note_tweet || status.is_note_tweet ||
tweet.quote || status.quote ||
tweet.translation || status.translation ||
flags?.forceInstantView); flags?.forceInstantView);
/* Force enable IV for archivers */ /* Force enable IV for archivers */
@ -120,20 +120,20 @@ export const handleStatus = async (
let overrideMedia: APIMedia | undefined; 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 // Check if mediaNumber exists, and if that media exists in status.media.all. If it does, we'll store overrideMedia variable
if (mediaNumber && tweet.media && tweet.media.all && tweet.media.all[mediaNumber - 1]) { if (mediaNumber && status.media && status.media.all && status.media.all[mediaNumber - 1]) {
overrideMedia = tweet.media.all[mediaNumber - 1]; overrideMedia = status.media.all[mediaNumber - 1];
} }
/* Catch direct media request (d.fxtwitter.com, or .mp4 / .jpg) */ /* Catch direct media request (d.fxtwitter.com, or .mp4 / .jpg) */
if (flags?.direct && !flags?.textOnly && tweet.media) { if (flags?.direct && !flags?.textOnly && status.media) {
let redirectUrl: string | null = null; let redirectUrl: string | null = null;
const all = tweet.media.all || []; const all = status.media.all || [];
// if (tweet.media.videos) { // if (status.media.videos) {
// const { videos } = tweet.media; // const { videos } = status.media;
// redirectUrl = (videos[(mediaNumber || 1) - 1] || videos[0]).url; // redirectUrl = (videos[(mediaNumber || 1) - 1] || videos[0]).url;
// } else if (tweet.media.photos) { // } else if (status.media.photos) {
// const { photos } = tweet.media; // const { photos } = status.media;
// redirectUrl = (photos[(mediaNumber || 1) - 1] || photos[0]).url; // redirectUrl = (photos[(mediaNumber || 1) - 1] || photos[0]).url;
// } // }
@ -149,48 +149,48 @@ export const handleStatus = async (
} }
} }
/* User requested gallery view, but this isn't a post with media */ /* User requested gallery view, but this isn't a status with media */
if (flags.gallery && (tweet.media?.all?.length ?? 0) < 1) { if (flags.gallery && (status.media?.all?.length ?? 0) < 1) {
flags.gallery = false; flags.gallery = false;
} }
/* At this point, we know we're going to have to create a /* 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 */ regular embed because it's not an API or direct media request */
let authorText = getAuthorText(tweet) || Strings.DEFAULT_AUTHOR_TEXT; let authorText = getAuthorText(status) || 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;
let newText = tweet.text; let newText = status.text;
/* Base headers included in all responses */ /* Base headers included in all responses */
const headers = [ const headers = [
`<link rel="canonical" href="${Constants.TWITTER_ROOT}/${tweet.author.screen_name}/status/${tweet.id}"/>`, `<link rel="canonical" href="${Constants.TWITTER_ROOT}/${status.author.screen_name}/status/${status.id}"/>`,
`<meta property="og:url" content="${Constants.TWITTER_ROOT}/${tweet.author.screen_name}/status/${tweet.id}"/>`, `<meta property="og:url" content="${Constants.TWITTER_ROOT}/${status.author.screen_name}/status/${status.id}"/>`,
`<meta property="twitter:site" content="@${tweet.author.screen_name}"/>`, `<meta property="twitter:site" content="@${status.author.screen_name}"/>`,
`<meta property="twitter:creator" content="@${tweet.author.screen_name}"/>`, `<meta property="twitter:creator" content="@${status.author.screen_name}"/>`,
]; ];
if (!flags.gallery) { if (!flags.gallery) {
headers.push( headers.push(
`<meta property="theme-color" content="#00a8fc"/>`, `<meta property="theme-color" content="#00a8fc"/>`,
`<meta property="twitter:title" content="${tweet.author.name} (@${tweet.author.screen_name})"/>` `<meta property="twitter:title" content="${status.author.name} (@${status.author.screen_name})"/>`
); );
} }
/* This little thing ensures if by some miracle a FixTweet embed is loaded in a browser, /* This little thing ensures if by some miracle a Fixstatus embed is loaded in a browser,
it will gracefully redirect to the destination instead of just seeing a blank screen. 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. */ Telegram is dumb and it just gets stuck if this is included, so we never include it for Telegram UAs. */
if (!isTelegram) { if (!isTelegram) {
headers.push( headers.push(
`<meta http-equiv="refresh" content="0;url=${Constants.TWITTER_ROOT}/${tweet.author.screen_name}/status/${tweet.id}"/>` `<meta http-equiv="refresh" content="0;url=${Constants.TWITTER_ROOT}/${status.author.screen_name}/status/${status.id}"/>`
); );
} }
if (useIV) { if (useIV) {
try { try {
const instructions = renderInstantView({ const instructions = renderInstantView({
tweet: tweet, status: status,
text: newText, text: newText,
flags: flags flags: flags
}); });
@ -205,11 +205,11 @@ export const handleStatus = async (
} }
} }
console.log('translation', tweet.translation) console.log('translation', status.translation)
/* This Tweet has a translation attached to it, so we'll render it. */ /* This status has a translation attached to it, so we'll render it. */
if (tweet.translation) { if (status.translation) {
const { translation } = tweet; const { translation } = status;
const formatText = const formatText =
language === 'en' language === 'en'
@ -228,16 +228,16 @@ export const handleStatus = async (
if (!flags?.textOnly) { if (!flags?.textOnly) {
const media = const media =
tweet.media?.all && tweet.media?.all.length > 0 ? tweet.media : tweet.quote?.media || {}; status.media?.all && status.media?.all.length > 0 ? status.media : status.quote?.media || {};
if (overrideMedia) { if (overrideMedia) {
let instructions: ResponseInstructions; let instructions: ResponseInstructions;
switch (overrideMedia.type) { switch (overrideMedia.type) {
case 'photo': case 'photo':
/* This Tweet has a photo to render. */ /* This status has a photo to render. */
instructions = renderPhoto( instructions = renderPhoto(
{ {
tweet: tweet, status: status,
authorText: authorText, authorText: authorText,
engagementText: engagementText, engagementText: engagementText,
userAgent: userAgent, userAgent: userAgent,
@ -253,13 +253,13 @@ export const handleStatus = async (
siteName = instructions.siteName; siteName = instructions.siteName;
} }
/* Overwrite our Twitter Card if overriding media, so it displays correctly in Discord */ /* Overwrite our Twitter Card if overriding media, so it displays correctly in Discord */
if (tweet.embed_card === 'player') { if (status.embed_card === 'player') {
tweet.embed_card = 'summary_large_image'; status.embed_card = 'summary_large_image';
} }
break; break;
case 'video': case 'video':
instructions = renderVideo( instructions = renderVideo(
{ tweet: tweet, userAgent: userAgent, text: newText, isOverrideMedia: true }, { status: status, userAgent: userAgent, text: newText, isOverrideMedia: true },
overrideMedia as APIVideo overrideMedia as APIVideo
); );
headers.push(...instructions.addHeaders); headers.push(...instructions.addHeaders);
@ -270,15 +270,15 @@ export const handleStatus = async (
siteName = instructions.siteName; siteName = instructions.siteName;
} }
/* Overwrite our Twitter Card if overriding media, so it displays correctly in Discord */ /* Overwrite our Twitter Card if overriding media, so it displays correctly in Discord */
if (tweet.embed_card !== 'player') { if (status.embed_card !== 'player') {
tweet.embed_card = 'player'; status.embed_card = 'player';
} }
/* This Tweet has a video to render. */ /* This status has a video to render. */
break; break;
} }
} else if (media?.videos) { } else if (media?.videos) {
const instructions = renderVideo( const instructions = renderVideo(
{ tweet: tweet, userAgent: userAgent, text: newText }, { status: status, userAgent: userAgent, text: newText },
media.videos[0] media.videos[0]
); );
headers.push(...instructions.addHeaders); headers.push(...instructions.addHeaders);
@ -291,7 +291,7 @@ export const handleStatus = async (
} else if (media?.mosaic) { } else if (media?.mosaic) {
const instructions = renderPhoto( const instructions = renderPhoto(
{ {
tweet: tweet, status: status,
authorText: authorText, authorText: authorText,
engagementText: engagementText, engagementText: engagementText,
userAgent: userAgent userAgent: userAgent
@ -302,7 +302,7 @@ export const handleStatus = async (
} else if (media?.photos) { } else if (media?.photos) {
const instructions = renderPhoto( const instructions = renderPhoto(
{ {
tweet: tweet, status: status,
authorText: authorText, authorText: authorText,
engagementText: engagementText, engagementText: engagementText,
userAgent: userAgent userAgent: userAgent
@ -326,9 +326,9 @@ export const handleStatus = async (
} }
} }
/* This Tweet contains a poll, so we'll render it */ /* This status contains a poll, so we'll render it */
if (tweet.poll) { if (status.poll) {
const { poll } = tweet; const { poll } = status;
let barLength = 32; let barLength = 32;
let str = ''; let str = '';
@ -338,7 +338,7 @@ export const handleStatus = async (
} }
/* Render each poll choice */ /* Render each poll choice */
tweet.poll.choices.forEach(choice => { status.poll.choices.forEach(choice => {
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}%)\n`; str += `${bar}\n${choice.label}  (${choice.percentage}%)\n`;
@ -366,14 +366,14 @@ export const handleStatus = async (
/* If we have no media to display, instead we'll display the user profile picture in the embed */ /* If we have no media to display, instead we'll display the user profile picture in the embed */
if ( if (
!tweet.media?.videos && !status.media?.videos &&
!tweet.media?.photos && !status.media?.photos &&
!tweet.quote?.media?.photos && !status.quote?.media?.photos &&
!tweet.quote?.media?.videos && !status.quote?.media?.videos &&
!flags?.textOnly !flags?.textOnly
) { ) {
/* Use a slightly higher resolution image for profile pics */ /* Use a slightly higher resolution image for profile pics */
const avatar = tweet.author.avatar_url; const avatar = status.author.avatar_url;
if (!useIV) { if (!useIV) {
headers.push( headers.push(
`<meta property="og:image" content="${avatar}"/>`, `<meta property="og:image" content="${avatar}"/>`,
@ -398,7 +398,7 @@ export const handleStatus = async (
and we have to pretend to be Medium in order to get working IV, but haven't figured if the template is causing issues. */ 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, '<br>') : sanitizeText(newText); const text = useIV ? sanitizeText(newText).replace(/\n/g, '<br>') : sanitizeText(newText);
const useCard = tweet.embed_card === 'tweet' ? tweet.quote?.embed_card : tweet.embed_card; const useCard = status.embed_card === 'tweet' ? status.quote?.embed_card : status.embed_card;
/* Push basic headers relating to author, Tweet text, and site name */ /* Push basic headers relating to author, Tweet text, and site name */
@ -408,31 +408,31 @@ export const handleStatus = async (
if (!flags.gallery) { if (!flags.gallery) {
headers.push( headers.push(
`<meta property="og:title" content="${tweet.author.name} (@${tweet.author.screen_name})"/>`, `<meta property="og:title" content="${status.author.name} (@${status.author.screen_name})"/>`,
`<meta property="og:description" content="${text}"/>`, `<meta property="og:description" content="${text}"/>`,
`<meta property="og:site_name" content="${siteName}"/>`, `<meta property="og:site_name" content="${siteName}"/>`,
); );
} else { } else {
if (isTelegram) { if (isTelegram) {
headers.push( headers.push(
`<meta property="og:site_name" content="${tweet.author.name} (@${tweet.author.screen_name})"/>` `<meta property="og:site_name" content="${status.author.name} (@${status.author.screen_name})"/>`
) )
} else { } else {
headers.push( headers.push(
`<meta property="og:title" content="${tweet.author.name} (@${tweet.author.screen_name})"/>` `<meta property="og:title" content="${status.author.name} (@${status.author.screen_name})"/>`
) )
} }
} }
/* 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 (status.replying_to && authorText === Strings.DEFAULT_AUTHOR_TEXT) {
authorText = `↪ Replying to @${tweet.replying_to.screen_name}`; authorText = `↪ Replying to @${status.replying_to.screen_name}`;
/* We'll assume it's a thread if it's a reply to themselves */ /* We'll assume it's a thread if it's a reply to themselves */
} else if ( } else if (
tweet.replying_to?.screen_name === tweet.author.screen_name && status.replying_to?.screen_name === status.author.screen_name &&
authorText === Strings.DEFAULT_AUTHOR_TEXT authorText === Strings.DEFAULT_AUTHOR_TEXT
) { ) {
authorText = `↪ A part of @${tweet.author.screen_name}'s thread`; authorText = `↪ A part of @${status.author.screen_name}'s thread`;
} }
if (!flags.gallery) { if (!flags.gallery) {
@ -442,18 +442,18 @@ export const handleStatus = async (
`<link rel="alternate" href="{base}/owoembed?text={text}{deprecatedFlag}&status={status}&author={author}" type="application/json+oembed" title="{name}">`.format( `<link rel="alternate" href="{base}/owoembed?text={text}{deprecatedFlag}&status={status}&author={author}" type="application/json+oembed" title="{name}">`.format(
{ {
base: Constants.HOST_URL, base: Constants.HOST_URL,
text: flags.gallery ? tweet.author.name : encodeURIComponent(truncateWithEllipsis(authorText, 255)), text: flags.gallery ? status.author.name : encodeURIComponent(truncateWithEllipsis(authorText, 255)),
deprecatedFlag: flags?.deprecated ? '&deprecated=true' : '', deprecatedFlag: flags?.deprecated ? '&deprecated=true' : '',
status: encodeURIComponent(status), status: encodeURIComponent(statusId),
author: encodeURIComponent(tweet.author.screen_name || ''), author: encodeURIComponent(status.author.screen_name || ''),
name: tweet.author.name || '' name: status.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 === null ? 'en' : tweet.lang || 'en'; const lang = status.lang === null ? 'en' : status.lang || 'en';
/* Finally, after all that work we return the response HTML! */ /* Finally, after all that work we return the response HTML! */
return c.html( return c.html(

View file

@ -1,26 +1,26 @@
import { formatNumber } from './utils'; import { formatNumber } from './utils';
/* The embed "author" text we populate with replies, retweets, and likes unless it's a video */ /* 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 = (status: APITwitterStatus): string | null => {
/* Build out reply, retweet, like counts */ /* Build out reply, retweet, like counts */
if ( if (
tweet.likes > 0 || status.likes > 0 ||
tweet.reposts > 0 || status.reposts > 0 ||
tweet.replies > 0 || status.replies > 0 ||
(tweet.views ? tweet.views > 0 : false) (status.views ? status.views > 0 : false)
) { ) {
let authorText = ''; let authorText = '';
if (tweet.replies > 0) { if (status.replies > 0) {
authorText += `${formatNumber(tweet.replies)} 💬 `; authorText += `${formatNumber(status.replies)} 💬 `;
} }
if (tweet.reposts > 0) { if (status.reposts > 0) {
authorText += `${formatNumber(tweet.reposts)} 🔁 `; authorText += `${formatNumber(status.reposts)} 🔁 `;
} }
if (tweet.likes > 0) { if (status.likes > 0) {
authorText += `${formatNumber(tweet.likes)} ❤️ `; authorText += `${formatNumber(status.likes)} ❤️ `;
} }
if (tweet.views && tweet.views > 0) { if (status.views && status.views > 0) {
authorText += `${formatNumber(tweet.views)} 👁️ `; authorText += `${formatNumber(status.views)} 👁️ `;
} }
authorText = authorText.trim(); authorText = authorText.trim();
@ -30,22 +30,22 @@ export const getAuthorText = (tweet: APITweet): string | null => {
return null; return null;
}; };
/* The embed "author" text we populate with replies, retweets, and likes unless it's a video */ /* The embed "author" text we populate with replies, reposts, and likes unless it's a video */
export const getSocialTextIV = (tweet: APITweet): string | null => { export const getSocialTextIV = (status: APITwitterStatus): string | null => {
/* Build out reply, retweet, like counts */ /* Build out reply, repost, like counts */
if (tweet.likes > 0 || tweet.reposts > 0 || tweet.replies > 0) { if (status.likes > 0 || status.reposts > 0 || status.replies > 0) {
let authorText = ''; let authorText = '';
if (tweet.replies > 0) { if (status.replies > 0) {
authorText += `💬 ${formatNumber(tweet.replies)} `; authorText += `💬 ${formatNumber(status.replies)} `;
} }
if (tweet.reposts > 0) { if (status.reposts > 0) {
authorText += `🔁 ${formatNumber(tweet.reposts)} `; authorText += `🔁 ${formatNumber(status.reposts)} `;
} }
if (tweet.likes > 0) { if (status.likes > 0) {
authorText += `❤️ ${formatNumber(tweet.likes)} `; authorText += `❤️ ${formatNumber(status.likes)} `;
} }
if (tweet.views && tweet.views > 0) { if (status.views && status.views > 0) {
authorText += `👁️ ${formatNumber(tweet.views)} `; authorText += `👁️ ${formatNumber(status.views)} `;
} }
authorText = authorText.trim(); authorText = authorText.trim();

View file

@ -2,7 +2,7 @@ import { calculateTimeLeftString } from './pollTime';
/* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */ /* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */
export const renderCard = ( export const renderCard = (
card: GraphQLTweet['card'] card: GraphQLTwitterStatus['card']
): { poll?: APIPoll; external_media?: APIExternalMedia } => { ): { poll?: APIPoll; external_media?: APIExternalMedia } => {
if (!Array.isArray(card.legacy.binding_values)) { if (!Array.isArray(card.legacy.binding_values)) {
return {}; return {};

View file

@ -1,6 +1,6 @@
export const isGraphQLTweetNotFoundResponse = ( export const isGraphQLTwitterStatusNotFoundResponse = (
response: unknown response: unknown
): response is GraphQLTweetNotFoundResponse => { ): response is GraphQLTwitterStatusNotFoundResponse => {
return ( return (
typeof response === 'object' && typeof response === 'object' &&
response !== null && response !== null &&
@ -12,12 +12,12 @@ export const isGraphQLTweetNotFoundResponse = (
); );
}; };
export const isGraphQLTweet = (response: unknown): response is GraphQLTweet => { export const isGraphQLTwitterStatus = (response: unknown): response is GraphQLTwitterStatus => {
return ( return (
typeof response === 'object' && typeof response === 'object' &&
response !== null && response !== null &&
(('__typename' in response && (('__typename' in response &&
(response.__typename === 'Tweet' || response.__typename === 'TweetWithVisibilityResults')) || (response.__typename === 'Tweet' || response.__typename === 'TweetWithVisibilityResults')) ||
typeof (response as GraphQLTweet).legacy?.full_text === 'string') typeof (response as GraphQLTwitterStatus).legacy?.full_text === 'string')
); );
}; };

View file

@ -2,9 +2,9 @@ import { Context } from 'hono';
import { Constants } from '../constants'; import { Constants } from '../constants';
import { withTimeout } from './utils'; import { withTimeout } from './utils';
/* Handles translating Tweets when asked! */ /* Handles translating statuses when asked! */
export const translateTweet = async ( export const translateStatus = async (
tweet: GraphQLTweet, tweet: GraphQLTwitterStatus,
guestToken: string, guestToken: string,
language: string, language: string,
c: Context c: Context

View file

@ -1,8 +1,8 @@
import { Constants } from '../../constants'; import { Constants } from '../../constants';
import { twitterFetch } from '../../fetch'; import { twitterFetch } from '../../fetch';
import { buildAPITweet } from './processor'; import { buildAPITwitterStatus } from './processor';
import { Experiment, experimentCheck } from '../../experiments'; import { Experiment, experimentCheck } from '../../experiments';
import { isGraphQLTweet } from '../../helpers/graphql'; import { isGraphQLTwitterStatus } from '../../helpers/graphql';
import { Context } from 'hono'; import { Context } from 'hono';
export const fetchTweetDetail = async ( export const fetchTweetDetail = async (
@ -60,11 +60,11 @@ export const fetchTweetDetail = async (
useElongator, useElongator,
(_conversation: unknown) => { (_conversation: unknown) => {
const conversation = _conversation as TweetDetailResult; const conversation = _conversation as TweetDetailResult;
const tweet = findTweetInBucket( const tweet = findStatusInBucket(
status, status,
processResponse(conversation?.data?.threaded_conversation_with_injections_v2?.instructions) processResponse(conversation?.data?.threaded_conversation_with_injections_v2?.instructions)
); );
if (tweet && isGraphQLTweet(tweet)) { if (tweet && isGraphQLTwitterStatus(tweet)) {
return true; return true;
} }
console.log('invalid graphql tweet', conversation); console.log('invalid graphql tweet', conversation);
@ -126,7 +126,7 @@ export const fetchByRestId = async (
const conversation = _conversation as TweetResultsByRestIdResult; const conversation = _conversation as TweetResultsByRestIdResult;
// If we get a not found error it's still a valid response // If we get a not found error it's still a valid response
const tweet = conversation.data?.tweetResult?.result; const tweet = conversation.data?.tweetResult?.result;
if (isGraphQLTweet(tweet)) { if (isGraphQLTwitterStatus(tweet)) {
return true; return true;
} }
console.log('invalid graphql tweet'); console.log('invalid graphql tweet');
@ -159,7 +159,7 @@ export const fetchByRestId = async (
const processResponse = (instructions: ThreadInstruction[]): GraphQLProcessBucket => { const processResponse = (instructions: ThreadInstruction[]): GraphQLProcessBucket => {
const bucket: GraphQLProcessBucket = { const bucket: GraphQLProcessBucket = {
tweets: [], statuses: [],
cursors: [] cursors: []
}; };
instructions?.forEach?.(instruction => { instructions?.forEach?.(instruction => {
@ -181,10 +181,10 @@ const processResponse = (instructions: ThreadInstruction[]): GraphQLProcessBucke
if (itemContentType === 'TimelineTweet') { if (itemContentType === 'TimelineTweet') {
const entryType = content.itemContent.tweet_results.result.__typename; const entryType = content.itemContent.tweet_results.result.__typename;
if (entryType === 'Tweet') { if (entryType === 'Tweet') {
bucket.tweets.push(content.itemContent.tweet_results.result as GraphQLTweet); bucket.statuses.push(content.itemContent.tweet_results.result as GraphQLTwitterStatus);
} }
if (entryType === 'TweetWithVisibilityResults') { if (entryType === 'TweetWithVisibilityResults') {
bucket.tweets.push(content.itemContent.tweet_results.result.tweet as GraphQLTweet); bucket.statuses.push(content.itemContent.tweet_results.result.tweet as GraphQLTwitterStatus);
} }
} else if (itemContentType === 'TimelineTimelineCursor') { } else if (itemContentType === 'TimelineTimelineCursor') {
bucket.cursors.push(content.itemContent as GraphQLTimelineCursor); bucket.cursors.push(content.itemContent as GraphQLTimelineCursor);
@ -197,11 +197,11 @@ const processResponse = (instructions: ThreadInstruction[]): GraphQLProcessBucke
if (itemContentType === 'TimelineTweet') { if (itemContentType === 'TimelineTweet') {
const entryType = item.item.itemContent.tweet_results?.result?.__typename; const entryType = item.item.itemContent.tweet_results?.result?.__typename;
if (entryType === 'Tweet') { if (entryType === 'Tweet') {
bucket.tweets.push(item.item.itemContent.tweet_results.result as GraphQLTweet); bucket.statuses.push(item.item.itemContent.tweet_results.result as GraphQLTwitterStatus);
} }
if (entryType === 'TweetWithVisibilityResults') { if (entryType === 'TweetWithVisibilityResults') {
bucket.tweets.push( bucket.statuses.push(
item.item.itemContent.tweet_results.result.tweet as GraphQLTweet item.item.itemContent.tweet_results.result.tweet as GraphQLTwitterStatus
); );
} }
} else if (itemContentType === 'TimelineTimelineCursor') { } else if (itemContentType === 'TimelineTimelineCursor') {
@ -216,22 +216,22 @@ const processResponse = (instructions: ThreadInstruction[]): GraphQLProcessBucke
return bucket; return bucket;
}; };
const findTweetInBucket = (id: string, bucket: GraphQLProcessBucket): GraphQLTweet | null => { const findStatusInBucket = (id: string, bucket: GraphQLProcessBucket): GraphQLTwitterStatus | null => {
return bucket.tweets.find(tweet => (tweet.rest_id ?? tweet.legacy?.id_str) === id) ?? null; return bucket.statuses.find(status => (status.rest_id ?? status.legacy?.id_str) === id) ?? null;
}; };
const findNextTweet = (id: string, bucket: GraphQLProcessBucket): number => { const findNextStatus = (id: string, bucket: GraphQLProcessBucket): number => {
return bucket.tweets.findIndex(tweet => tweet.legacy?.in_reply_to_status_id_str === id); return bucket.statuses.findIndex(status => status.legacy?.in_reply_to_status_id_str === id);
}; };
const findPreviousTweet = (id: string, bucket: GraphQLProcessBucket): number => { const findPreviousStatus = (id: string, bucket: GraphQLProcessBucket): number => {
const tweet = bucket.tweets.find(tweet => (tweet.rest_id ?? tweet.legacy?.id_str) === id); const status = bucket.statuses.find(status => (status.rest_id ?? status.legacy?.id_str) === id);
if (!tweet) { if (!status) {
console.log('uhhh, we could not even find that tweet, dunno how that happened'); console.log('uhhh, we could not even find that tweet, dunno how that happened');
return -1; return -1;
} }
return bucket.tweets.findIndex( return bucket.statuses.findIndex(
_tweet => (_tweet.rest_id ?? _tweet.legacy?.id_str) === tweet.legacy?.in_reply_to_status_id_str _status => (_status.rest_id ?? _status.legacy?.id_str) === status.legacy?.in_reply_to_status_id_str
); );
}; };
@ -249,7 +249,7 @@ const consolidateCursors = (
}); });
}; };
const filterBucketTweets = (tweets: GraphQLTweet[], original: GraphQLTweet) => { const filterBucketStatuses = (tweets: GraphQLTwitterStatus[], original: GraphQLTwitterStatus) => {
return tweets.filter( return tweets.filter(
tweet => tweet =>
tweet.core?.user_results?.result?.rest_id === original.core?.user_results?.result?.rest_id tweet.core?.user_results?.result?.rest_id === original.core?.user_results?.result?.rest_id
@ -267,7 +267,7 @@ export const constructTwitterThread = async (
console.log('language', language); console.log('language', language);
let response: TweetDetailResult | TweetResultsByRestIdResult | null = null; let response: TweetDetailResult | TweetResultsByRestIdResult | null = null;
let status: APITweet; let status: APITwitterStatus;
/* We can use TweetDetail on elongator accounts to increase per-account rate limit. /* We can use TweetDetail on elongator accounts to increase per-account rate limit.
We also use TweetDetail to process threads (WIP) We also use TweetDetail to process threads (WIP)
@ -293,13 +293,13 @@ export const constructTwitterThread = async (
console.log('Using TweetResultsByRestId for request...'); console.log('Using TweetResultsByRestId for request...');
response = (await fetchByRestId(id, c)) as TweetResultsByRestIdResult; response = (await fetchByRestId(id, c)) as TweetResultsByRestIdResult;
const result = response?.data?.tweetResult?.result as GraphQLTweet; const result = response?.data?.tweetResult?.result as GraphQLTwitterStatus;
if (typeof result === 'undefined') { if (typeof result === 'undefined') {
return { status: null, thread: null, author: null, code: 404 }; return { status: null, thread: null, author: null, code: 404 };
} }
const buildStatus = await buildAPITweet(c, result, language, false, legacyAPI); const buildStatus = await buildAPITwitterStatus(c, result, language, false, legacyAPI);
if ((buildStatus as FetchResults)?.status === 401) { if ((buildStatus as FetchResults)?.status === 401) {
return { status: null, thread: null, author: null, code: 401 }; return { status: null, thread: null, author: null, code: 401 };
@ -307,7 +307,7 @@ export const constructTwitterThread = async (
return { status: null, thread: null, author: null, code: 404 }; return { status: null, thread: null, author: null, code: 404 };
} }
status = buildStatus as APITweet; status = buildStatus as APITwitterStatus;
return { status: status, thread: null, author: status.author, code: 200 }; return { status: status, thread: null, author: status.author, code: 200 };
} }
@ -315,14 +315,14 @@ export const constructTwitterThread = async (
const bucket = processResponse( const bucket = processResponse(
response?.data?.threaded_conversation_with_injections_v2?.instructions ?? [] response?.data?.threaded_conversation_with_injections_v2?.instructions ?? []
); );
const originalTweet = findTweetInBucket(id, bucket); const originalStatus = findStatusInBucket(id, bucket);
/* Don't bother processing thread on a null tweet */ /* Don't bother processing thread on a null tweet */
if (originalTweet === null) { if (originalStatus === null) {
return { status: null, thread: null, author: null, code: 404 }; return { status: null, thread: null, author: null, code: 404 };
} }
status = (await buildAPITweet(c, originalTweet, undefined, false, legacyAPI)) as APITweet; status = (await buildAPITwitterStatus(c, originalStatus, undefined, false, legacyAPI)) as APITwitterStatus;
if (status === null) { if (status === null) {
return { status: null, thread: null, author: null, code: 404 }; return { status: null, thread: null, author: null, code: 404 };
@ -335,15 +335,15 @@ export const constructTwitterThread = async (
return { status: status, thread: null, author: author, code: 200 }; return { status: status, thread: null, author: author, code: 200 };
} }
const threadTweets = [originalTweet]; const threadStatuses = [originalStatus];
bucket.tweets = filterBucketTweets(bucket.tweets, originalTweet); bucket.statuses = filterBucketStatuses(bucket.statuses, originalStatus);
let currentId = id; let currentId = id;
/* Process tweets that are following the current one in the thread */ /* Process tweets that are following the current one in the thread */
while (findNextTweet(currentId, bucket) !== -1) { while (findNextStatus(currentId, bucket) !== -1) {
const index = findNextTweet(currentId, bucket); const index = findNextStatus(currentId, bucket);
const tweet = bucket.tweets[index]; const tweet = bucket.statuses[index];
const newCurrentId = tweet.rest_id ?? tweet.legacy?.id_str; const newCurrentId = tweet.rest_id ?? tweet.legacy?.id_str;
@ -357,15 +357,15 @@ export const constructTwitterThread = async (
'in bucket' 'in bucket'
); );
threadTweets.push(tweet); threadStatuses.push(tweet);
currentId = newCurrentId; currentId = newCurrentId;
console.log('Current index', index, 'of', bucket.tweets.length); console.log('Current index', index, 'of', bucket.statuses.length);
/* Reached the end of the current list of tweets in thread) */ /* Reached the end of the current list of statuses in thread) */
if (index >= bucket.tweets.length - 1) { if (index >= bucket.statuses.length - 1) {
/* See if we have a cursor to fetch more tweets */ /* See if we have a cursor to fetch more statuses */
const cursor = bucket.cursors.find( const cursor = bucket.cursors.find(
cursor => cursor.cursorType === 'Bottom' || cursor.cursorType === 'ShowMore' cursor => cursor.cursorType === 'Bottom' || cursor.cursorType === 'ShowMore'
); );
@ -396,26 +396,26 @@ export const constructTwitterThread = async (
const cursorResponse = processResponse( const cursorResponse = processResponse(
loadCursor?.data?.threaded_conversation_with_injections_v2?.instructions ?? [] loadCursor?.data?.threaded_conversation_with_injections_v2?.instructions ?? []
); );
bucket.tweets = bucket.tweets.concat( bucket.statuses = bucket.statuses.concat(
filterBucketTweets(cursorResponse.tweets, originalTweet) filterBucketStatuses(cursorResponse.statuses, originalStatus)
); );
/* Remove old cursor and add new bottom cursor if necessary */ /* Remove old cursor and add new bottom cursor if necessary */
consolidateCursors(bucket.cursors, cursorResponse.cursors); consolidateCursors(bucket.cursors, cursorResponse.cursors);
console.log('updated bucket of cursors', bucket.cursors); console.log('updated bucket of cursors', bucket.cursors);
} }
console.log('Preview of next tweet:', findNextTweet(currentId, bucket)); console.log('Preview of next status:', findNextStatus(currentId, bucket));
} }
currentId = id; currentId = id;
while (findPreviousTweet(currentId, bucket) !== -1) { while (findPreviousStatus(currentId, bucket) !== -1) {
const index = findPreviousTweet(currentId, bucket); const index = findPreviousStatus(currentId, bucket);
const tweet = bucket.tweets[index]; const status = bucket.statuses[index];
const newCurrentId = tweet.rest_id ?? tweet.legacy?.id_str; const newCurrentId = status.rest_id ?? status.legacy?.id_str;
console.log( console.log(
'adding previous tweet to thread', 'adding previous status to thread',
newCurrentId, newCurrentId,
'from', 'from',
currentId, currentId,
@ -424,12 +424,12 @@ export const constructTwitterThread = async (
'in bucket' 'in bucket'
); );
threadTweets.unshift(tweet); threadStatuses.unshift(status);
currentId = newCurrentId; currentId = newCurrentId;
if (index === 0) { if (index === 0) {
/* See if we have a cursor to fetch more tweets */ /* See if we have a cursor to fetch more statuses */
const cursor = bucket.cursors.find( const cursor = bucket.cursors.find(
cursor => cursor.cursorType === 'Top' || cursor.cursorType === 'ShowMore' cursor => cursor.cursorType === 'Top' || cursor.cursorType === 'ShowMore'
); );
@ -438,7 +438,7 @@ export const constructTwitterThread = async (
console.log('No cursor present, stopping pagination up'); console.log('No cursor present, stopping pagination up');
break; break;
} }
console.log('Cursor present, fetching more tweets up'); console.log('Cursor present, fetching more statuses up');
let loadCursor: TweetDetailResult; let loadCursor: TweetDetailResult;
@ -459,17 +459,17 @@ export const constructTwitterThread = async (
const cursorResponse = processResponse( const cursorResponse = processResponse(
loadCursor?.data?.threaded_conversation_with_injections_v2?.instructions ?? [] loadCursor?.data?.threaded_conversation_with_injections_v2?.instructions ?? []
); );
bucket.tweets = cursorResponse.tweets.concat( bucket.statuses = cursorResponse.statuses.concat(
filterBucketTweets(bucket.tweets, originalTweet) filterBucketStatuses(bucket.statuses, originalStatus)
); );
/* Remove old cursor and add new top cursor if necessary */ /* Remove old cursor and add new top cursor if necessary */
consolidateCursors(bucket.cursors, cursorResponse.cursors); consolidateCursors(bucket.cursors, cursorResponse.cursors);
// console.log('updated bucket of tweets', bucket.tweets); // console.log('updated bucket of statuses', bucket.statuses);
console.log('updated bucket of cursors', bucket.cursors); console.log('updated bucket of cursors', bucket.cursors);
} }
console.log('Preview of previous tweet:', findPreviousTweet(currentId, bucket)); console.log('Preview of previous status:', findPreviousStatus(currentId, bucket));
} }
const socialThread: SocialThread = { const socialThread: SocialThread = {
@ -479,8 +479,8 @@ export const constructTwitterThread = async (
code: 200 code: 200
}; };
threadTweets.forEach(async tweet => { threadStatuses.forEach(async status => {
socialThread.thread?.push((await buildAPITweet(c, tweet, undefined, true, false)) as APITweet); socialThread.thread?.push((await buildAPITwitterStatus(c, status, undefined, true, false)) as APITwitterStatus);
}); });
return socialThread; return socialThread;

View file

@ -2,65 +2,64 @@ import { renderCard } from '../../helpers/card';
import { Constants } from '../../constants'; import { Constants } from '../../constants';
import { linkFixer } from '../../helpers/linkFixer'; import { linkFixer } from '../../helpers/linkFixer';
import { handleMosaic } from '../../helpers/mosaic'; import { handleMosaic } from '../../helpers/mosaic';
// import { translateTweet } from '../../helpers/translate';
import { unescapeText } from '../../helpers/utils'; import { unescapeText } from '../../helpers/utils';
import { processMedia } from '../../helpers/media'; import { processMedia } from '../../helpers/media';
import { convertToApiUser } from './profile'; import { convertToApiUser } from './profile';
import { translateTweet } from '../../helpers/translate'; import { translateStatus } from '../../helpers/translate';
import { Context } from 'hono'; import { Context } from 'hono';
export const buildAPITweet = async ( export const buildAPITwitterStatus = async (
c: Context, c: Context,
tweet: GraphQLTweet, status: GraphQLTwitterStatus,
language: string | undefined, language: string | undefined,
threadPiece = false, threadPiece = false,
legacyAPI = false legacyAPI = false
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APITweet | FetchResults | null> => { ): Promise<APITwitterStatus | FetchResults | null> => {
const apiTweet = {} as APITweet; const apiStatus = {} as APITwitterStatus;
/* Sometimes, Twitter returns a different kind of Tweet type called 'TweetWithVisibilityResults'. /* Sometimes, Twitter returns a different kind of type called 'TweetWithVisibilityResults'.
It has slightly different attributes from the regular 'Tweet' type. We fix that up here. */ It has slightly different attributes from the regular 'Tweet' type. We fix that up here. */
if (typeof tweet.core === 'undefined' && typeof tweet.result !== 'undefined') { if (typeof status.core === 'undefined' && typeof status.result !== 'undefined') {
tweet = tweet.result; status = status.result;
} }
if (typeof tweet.core === 'undefined' && typeof tweet.tweet?.core !== 'undefined') { if (typeof status.core === 'undefined' && typeof status.tweet?.core !== 'undefined') {
tweet.core = tweet.tweet.core; status.core = status.tweet.core;
} }
if (typeof tweet.legacy === 'undefined' && typeof tweet.tweet?.legacy !== 'undefined') { if (typeof status.legacy === 'undefined' && typeof status.tweet?.legacy !== 'undefined') {
tweet.legacy = tweet.tweet?.legacy; status.legacy = status.tweet?.legacy;
} }
if (typeof tweet.views === 'undefined' && typeof tweet?.tweet?.views !== 'undefined') { if (typeof status.views === 'undefined' && typeof status?.tweet?.views !== 'undefined') {
tweet.views = tweet?.tweet?.views; status.views = status?.tweet?.views;
} }
if (typeof tweet.core === 'undefined') { if (typeof status.core === 'undefined') {
console.log('Tweet still not valid', tweet); console.log('Status still not valid', status);
if (tweet.__typename === 'TweetUnavailable' && tweet.reason === 'Protected') { if (status.__typename === 'TweetUnavailable' && status.reason === 'Protected') {
return { status: 401 }; return { status: 401 };
} else { } else {
return { status: 404 }; return { status: 404 };
} }
} }
const graphQLUser = tweet.core.user_results.result; const graphQLUser = status.core.user_results.result;
const apiUser = convertToApiUser(graphQLUser); const apiUser = convertToApiUser(graphQLUser);
/* Sometimes, `rest_id` is undefined for some reason. Inconsistent behavior. See: https://github.com/FixTweet/FixTweet/issues/416 */ /* Sometimes, `rest_id` is undefined for some reason. Inconsistent behavior. See: https://github.com/FixTweet/FixTweet/issues/416 */
const id = tweet.rest_id ?? tweet.legacy.id_str; const id = status.rest_id ?? status.legacy.id_str;
/* Populating a lot of the basics */ /* Populating a lot of the basics */
apiTweet.url = `${Constants.TWITTER_ROOT}/${apiUser.screen_name}/status/${id}`; apiStatus.url = `${Constants.TWITTER_ROOT}/${apiUser.screen_name}/status/${id}`;
apiTweet.id = id; apiStatus.id = id;
apiTweet.text = unescapeText( apiStatus.text = unescapeText(
linkFixer(tweet.legacy.entities?.urls, tweet.legacy.full_text || '') linkFixer(status.legacy.entities?.urls, status.legacy.full_text || '')
); );
if (!threadPiece) { if (!threadPiece) {
apiTweet.author = { apiStatus.author = {
id: apiUser.id, id: apiUser.id,
name: apiUser.name, name: apiUser.name,
screen_name: apiUser.screen_name, screen_name: apiUser.screen_name,
@ -79,111 +78,111 @@ export const buildAPITweet = async (
website: apiUser.website website: apiUser.website
}; };
} }
apiTweet.replies = tweet.legacy.reply_count; apiStatus.replies = status.legacy.reply_count;
if (legacyAPI) { if (legacyAPI) {
// @ts-expect-error Use retweets for legacy API // @ts-expect-error Use retweets for legacy API
apiTweet.retweets = tweet.legacy.retweet_count; apiStatus.retweets = status.legacy.retweet_count;
// @ts-expect-error `tweets` is only part of legacy API // @ts-expect-error `tweets` is only part of legacy API
apiTweet.author.tweets = apiTweet.author.statuses; apiStatus.author.tweets = apiStatus.author.statuses;
// @ts-expect-error Part of legacy API that we no longer are able to track // @ts-expect-error Part of legacy API that we no longer are able to track
apiTweet.author.avatar_color = null; apiStatus.author.avatar_color = null;
// @ts-expect-error Use retweets for legacy API // @ts-expect-error Use retweets for legacy API
delete apiTweet.reposts; delete apiStatus.reposts;
// @ts-expect-error Use tweets and not posts for legacy API // @ts-expect-error Use tweets and not posts for legacy API
delete apiTweet.author.statuses; delete apiStatus.author.statuses;
delete apiTweet.author.global_screen_name; delete apiStatus.author.global_screen_name;
} else { } else {
apiTweet.reposts = tweet.legacy.retweet_count; apiStatus.reposts = status.legacy.retweet_count;
apiTweet.author.global_screen_name = apiUser.global_screen_name; apiStatus.author.global_screen_name = apiUser.global_screen_name;
} }
apiTweet.likes = tweet.legacy.favorite_count; apiStatus.likes = status.legacy.favorite_count;
apiTweet.embed_card = 'tweet'; apiStatus.embed_card = 'tweet';
apiTweet.created_at = tweet.legacy.created_at; apiStatus.created_at = status.legacy.created_at;
apiTweet.created_timestamp = new Date(tweet.legacy.created_at).getTime() / 1000; apiStatus.created_timestamp = new Date(status.legacy.created_at).getTime() / 1000;
apiTweet.possibly_sensitive = tweet.legacy.possibly_sensitive; apiStatus.possibly_sensitive = status.legacy.possibly_sensitive;
if (tweet.views.state === 'EnabledWithCount') { if (status.views.state === 'EnabledWithCount') {
apiTweet.views = parseInt(tweet.views.count || '0') ?? null; apiStatus.views = parseInt(status.views.count || '0') ?? null;
} else { } else {
apiTweet.views = null; apiStatus.views = null;
} }
console.log('note_tweet', JSON.stringify(tweet.note_tweet)); console.log('note_tweet', JSON.stringify(status.note_tweet));
const noteTweetText = tweet.note_tweet?.note_tweet_results?.result?.text; const noteTweetText = status.note_tweet?.note_tweet_results?.result?.text;
if (noteTweetText) { if (noteTweetText) {
tweet.legacy.entities.urls = tweet.note_tweet?.note_tweet_results?.result?.entity_set.urls; status.legacy.entities.urls = status.note_tweet?.note_tweet_results?.result?.entity_set.urls;
tweet.legacy.entities.hashtags = status.legacy.entities.hashtags =
tweet.note_tweet?.note_tweet_results?.result?.entity_set.hashtags; status.note_tweet?.note_tweet_results?.result?.entity_set.hashtags;
tweet.legacy.entities.symbols = status.legacy.entities.symbols =
tweet.note_tweet?.note_tweet_results?.result?.entity_set.symbols; status.note_tweet?.note_tweet_results?.result?.entity_set.symbols;
console.log('We meet the conditions to use new note tweets'); console.log('We meet the conditions to use new note tweets');
apiTweet.text = unescapeText(linkFixer(tweet.legacy.entities.urls, noteTweetText)); apiStatus.text = unescapeText(linkFixer(status.legacy.entities.urls, noteTweetText));
apiTweet.is_note_tweet = true; apiStatus.is_note_tweet = true;
} else { } else {
apiTweet.is_note_tweet = false; apiStatus.is_note_tweet = false;
} }
if (tweet.legacy.lang !== 'unk') { if (status.legacy.lang !== 'unk') {
apiTweet.lang = tweet.legacy.lang; apiStatus.lang = status.legacy.lang;
} else { } else {
apiTweet.lang = null; apiStatus.lang = null;
} }
if (legacyAPI) { if (legacyAPI) {
// @ts-expect-error Use replying_to string for legacy API // @ts-expect-error Use replying_to string for legacy API
apiTweet.replying_to = tweet.legacy?.in_reply_to_screen_name || null; apiStatus.replying_to = status.legacy?.in_reply_to_screen_name || null;
// @ts-expect-error Use replying_to_status string for legacy API // @ts-expect-error Use replying_to_status string for legacy API
apiTweet.replying_to_status = tweet.legacy?.in_reply_to_status_id_str || null; apiStatus.replying_to_status = status.legacy?.in_reply_to_status_id_str || null;
} else if (tweet.legacy.in_reply_to_screen_name) { } else if (status.legacy.in_reply_to_screen_name) {
apiTweet.replying_to = { apiStatus.replying_to = {
screen_name: tweet.legacy.in_reply_to_screen_name || null, screen_name: status.legacy.in_reply_to_screen_name || null,
post: tweet.legacy.in_reply_to_status_id_str || null post: status.legacy.in_reply_to_status_id_str || null
}; };
} else { } else {
apiTweet.replying_to = null; apiStatus.replying_to = null;
} }
apiTweet.media = {}; apiStatus.media = {};
/* We found a quote tweet, let's process that too */ /* We found a quote, let's process that too */
const quoteTweet = tweet.quoted_status_result; const quote = status.quoted_status_result;
if (quoteTweet) { if (quote) {
const buildQuote = await buildAPITweet(c, quoteTweet, language, threadPiece, legacyAPI); const buildQuote = await buildAPITwitterStatus(c, quote, language, threadPiece, legacyAPI);
if ((buildQuote as FetchResults).status) { if ((buildQuote as FetchResults).status) {
apiTweet.quote = undefined; apiStatus.quote = undefined;
} else { } else {
apiTweet.quote = buildQuote as APITweet; apiStatus.quote = buildQuote as APITwitterStatus;
} }
/* Only override the embed_card if it's a basic tweet, since media always takes precedence */ /* Only override the embed_card if it's a basic status, since media always takes precedence */
if (apiTweet.embed_card === 'tweet' && typeof apiTweet.quote !== 'undefined') { if (apiStatus.embed_card === 'tweet' && typeof apiStatus.quote !== 'undefined') {
apiTweet.embed_card = apiTweet.quote.embed_card; apiStatus.embed_card = apiStatus.quote.embed_card;
} }
} }
const mediaList = Array.from( const mediaList = Array.from(
tweet.legacy.extended_entities?.media || tweet.legacy.entities?.media || [] status.legacy.extended_entities?.media || status.legacy.entities?.media || []
); );
// console.log('tweet', JSON.stringify(tweet)); // console.log('status', JSON.stringify(status));
/* Populate this Tweet's media */ /* Populate status media */
mediaList.forEach(media => { mediaList.forEach(media => {
const mediaObject = processMedia(media); const mediaObject = processMedia(media);
if (mediaObject) { if (mediaObject) {
apiTweet.media.all = apiTweet.media?.all ?? []; apiStatus.media.all = apiStatus.media?.all ?? [];
apiTweet.media?.all?.push(mediaObject); apiStatus.media?.all?.push(mediaObject);
if (mediaObject.type === 'photo') { if (mediaObject.type === 'photo') {
apiTweet.embed_card = 'summary_large_image'; apiStatus.embed_card = 'summary_large_image';
apiTweet.media.photos = apiTweet.media?.photos ?? []; apiStatus.media.photos = apiStatus.media?.photos ?? [];
apiTweet.media.photos?.push(mediaObject); apiStatus.media.photos?.push(mediaObject);
} else if (mediaObject.type === 'video' || mediaObject.type === 'gif') { } else if (mediaObject.type === 'video' || mediaObject.type === 'gif') {
apiTweet.embed_card = 'player'; apiStatus.embed_card = 'player';
apiTweet.media.videos = apiTweet.media?.videos ?? []; apiStatus.media.videos = apiStatus.media?.videos ?? [];
apiTweet.media.videos?.push(mediaObject); apiStatus.media.videos?.push(mediaObject);
} else { } else {
console.log('Unknown media type', mediaObject.type); console.log('Unknown media type', mediaObject.type);
} }
@ -193,21 +192,21 @@ export const buildAPITweet = async (
/* Grab color palette data */ /* 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); apiStatus.color = colorFromPalette(mediaList[0].ext_media_color.palette);
} }
*/ */
/* Handle photos and mosaic if available */ /* Handle photos and mosaic if available */
if ((apiTweet?.media.photos?.length || 0) > 1 && !threadPiece) { if ((apiStatus?.media.photos?.length || 0) > 1 && !threadPiece) {
const mosaic = await handleMosaic(apiTweet.media?.photos || [], id); const mosaic = await handleMosaic(apiStatus.media?.photos || [], id);
if (typeof apiTweet.media !== 'undefined' && mosaic !== null) { if (typeof apiStatus.media !== 'undefined' && mosaic !== null) {
apiTweet.media.mosaic = mosaic; apiStatus.media.mosaic = mosaic;
} }
} }
// Add Tweet source but remove the link HTML tag // Add source but remove the link HTML tag
if (tweet.source) { if (status.source) {
apiTweet.source = (tweet.source || '').replace( apiStatus.source = (status.source || '').replace(
/<a href="(.+?)" rel="nofollow">(.+?)<\/a>/, /<a href="(.+?)" rel="nofollow">(.+?)<\/a>/,
'$2' '$2'
); );
@ -215,34 +214,34 @@ export const buildAPITweet = async (
/* Populate a Twitter card */ /* Populate a Twitter card */
if (tweet.card) { if (status.card) {
const card = renderCard(tweet.card); const card = renderCard(status.card);
if (card.external_media) { if (card.external_media) {
apiTweet.media.external = card.external_media; apiStatus.media.external = card.external_media;
} }
if (card.poll) { if (card.poll) {
apiTweet.poll = card.poll; apiStatus.poll = card.poll;
} }
} }
if ( if (
apiTweet.media?.videos && apiStatus.media?.videos &&
apiTweet.media?.videos.length > 0 && apiStatus.media?.videos.length > 0 &&
apiTweet.embed_card !== 'player' apiStatus.embed_card !== 'player'
) { ) {
apiTweet.embed_card = 'player'; apiStatus.embed_card = 'player';
} }
console.log('language?', language); console.log('language?', language);
/* If a language is specified in API or by user, let's try translating it! */ /* 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 !== status.legacy.lang) {
console.log(`Attempting to translate Tweet to ${language}...`); console.log(`Attempting to translate status to ${language}...`);
const translateAPI = await translateTweet(tweet, '', language, c); const translateAPI = await translateStatus(status, '', language, c);
if (translateAPI !== null && translateAPI?.translation) { if (translateAPI !== null && translateAPI?.translation) {
apiTweet.translation = { apiStatus.translation = {
text: unescapeText( text: unescapeText(
linkFixer(tweet.legacy?.entities?.urls, translateAPI?.translation || '') linkFixer(status.legacy?.entities?.urls, translateAPI?.translation || '')
), ),
source_lang: translateAPI?.sourceLanguage || '', source_lang: translateAPI?.sourceLanguage || '',
target_lang: translateAPI?.destinationLanguage || '', target_lang: translateAPI?.destinationLanguage || '',
@ -253,16 +252,16 @@ export const buildAPITweet = async (
if (legacyAPI) { if (legacyAPI) {
// @ts-expect-error Use twitter_card for legacy API // @ts-expect-error Use twitter_card for legacy API
apiTweet.twitter_card = apiTweet.embed_card; apiStatus.twitter_card = apiStatus.embed_card;
// @ts-expect-error Part of legacy API that we no longer are able to track // @ts-expect-error Part of legacy API that we no longer are able to track
apiTweet.color = null; apiStatus.color = null;
// @ts-expect-error Use twitter_card for legacy API // @ts-expect-error Use twitter_card for legacy API
delete apiTweet.embed_card; delete apiStatus.embed_card;
if ((apiTweet.media.all?.length ?? 0) < 1 && !apiTweet.media.external) { if ((apiStatus.media.all?.length ?? 0) < 1 && !apiStatus.media.external) {
// @ts-expect-error media is not required in legacy API if empty // @ts-expect-error media is not required in legacy API if empty
delete apiTweet.media; delete apiStatus.media;
} }
} }
return apiTweet; return apiStatus;
}; };

View file

@ -27,72 +27,72 @@ export const getBaseRedirectUrl = (c: Context) => {
}; };
/* Workaround for some dumb maybe-build time issue where statusRequest isn't ready or something because none of these trigger*/ /* Workaround for some dumb maybe-build time issue where statusRequest isn't ready or something because none of these trigger*/
const tweetRequest = async (c: Context) => await statusRequest(c); const twitterStatusRequest = async (c: Context) => await statusRequest(c);
const _profileRequest = async (c: Context) => await profileRequest(c); const _profileRequest = async (c: Context) => await profileRequest(c);
/* How can hono not handle trailing slashes? This is so stupid, /* How can hono not handle trailing slashes? This is so stupid,
serious TODO: Figure out how to make this not stupid. */ serious TODO: Figure out how to make this not stupid. */
twitter.get('/:endpoint{status(es)?}/:id', tweetRequest); twitter.get('/:endpoint{status(es)?}/:id', twitterStatusRequest);
twitter.get('/:endpoint{status(es)?}/:id/', tweetRequest); twitter.get('/:endpoint{status(es)?}/:id/', twitterStatusRequest);
twitter.get('/:endpoint{status(es)?}/:id/:language/', tweetRequest); twitter.get('/:endpoint{status(es)?}/:id/:language/', twitterStatusRequest);
twitter.get('/:endpoint{status(es)?}/:id/:language', tweetRequest); twitter.get('/:endpoint{status(es)?}/:id/:language', twitterStatusRequest);
twitter.get( twitter.get(
'/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:language', '/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:language',
tweetRequest twitterStatusRequest
); );
twitter.get( twitter.get(
'/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:language/', '/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:language/',
tweetRequest twitterStatusRequest
); );
twitter.get('/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id', tweetRequest); twitter.get('/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id', twitterStatusRequest);
twitter.get('/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/', tweetRequest); twitter.get('/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/', twitterStatusRequest);
twitter.get( twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:language', '/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:language',
tweetRequest twitterStatusRequest
); );
twitter.get( twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:language/', '/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:language/',
tweetRequest twitterStatusRequest
); );
twitter.get( twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id', '/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id',
tweetRequest twitterStatusRequest
); );
twitter.get( twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/', '/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/',
tweetRequest twitterStatusRequest
); );
twitter.get( twitter.get(
'/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}', '/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}',
tweetRequest twitterStatusRequest
); );
twitter.get( twitter.get(
'/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/', '/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/',
tweetRequest twitterStatusRequest
); );
twitter.get( twitter.get(
'/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/:language', '/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/:language',
tweetRequest twitterStatusRequest
); );
twitter.get( twitter.get(
'/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/:language/', '/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/:language/',
tweetRequest twitterStatusRequest
); );
twitter.get( twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}', '/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}',
tweetRequest twitterStatusRequest
); );
twitter.get( twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/', '/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/',
tweetRequest twitterStatusRequest
); );
twitter.get( twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/:language', '/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/:language',
tweetRequest twitterStatusRequest
); );
twitter.get( twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/:language/', '/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/:language/',
tweetRequest twitterStatusRequest
); );
twitter.get('/version/', versionRoute); twitter.get('/version/', versionRoute);

View file

@ -18,7 +18,7 @@ export const oembed = async (c: Context) => {
const data: OEmbed = { const data: OEmbed = {
author_name: text, author_name: text,
author_url: `${Constants.TWITTER_ROOT}/${encodeURIComponent(author)}/status/${status}`, author_url: `${Constants.TWITTER_ROOT}/${encodeURIComponent(author)}/status/${status}`,
/* Change provider name if tweet is on deprecated domain. */ /* Change provider name if status is on deprecated domain. */
provider_name: provider_name:
searchParams.get('deprecated') === 'true' ? Strings.DEPRECATED_DOMAIN_NOTICE_DISCORD : name, searchParams.get('deprecated') === 'true' ? Strings.DEPRECATED_DOMAIN_NOTICE_DISCORD : name,
provider_url: url, provider_url: url,

View file

@ -4,7 +4,7 @@ import { getBaseRedirectUrl } from '../router';
import { handleStatus } from '../../../embed/status'; import { handleStatus } from '../../../embed/status';
import { Strings } from '../../../strings'; import { Strings } from '../../../strings';
/* Handler for status (Tweet) request */ /* Handler for status request */
export const statusRequest = async (c: Context) => { export const statusRequest = async (c: Context) => {
const { prefix, handle, id, mediaNumber, language } = c.req.param(); const { prefix, handle, id, mediaNumber, language } = c.req.param();
const url = new URL(c.req.url); const url = new URL(c.req.url);
@ -35,8 +35,8 @@ export const statusRequest = async (c: Context) => {
const isBotUA = userAgent.match(Constants.BOT_UA_REGEX) !== null || flags?.archive; const isBotUA = userAgent.match(Constants.BOT_UA_REGEX) !== null || flags?.archive;
/* Check if domain is a direct media domain (i.e. d.fxtwitter.com), /* Check if domain is a direct media domain (i.e. d.fxtwitter.com),
the tweet is prefixed with /dl/ or /dir/ (for TwitFix interop), or the the status is prefixed with /dl/ or /dir/ (for TwitFix interop), or the
tweet ends in .mp4, .jpg, .jpeg, or .png status ends in .mp4, .jpg, .jpeg, or .png
Note that .png is not documented because images always redirect to a jpg, Note that .png is not documented because images always redirect to a jpg,
but it will help someone who does it mistakenly on something like Discord but it will help someone who does it mistakenly on something like Discord
@ -63,7 +63,7 @@ export const statusRequest = async (c: Context) => {
flags.direct = true; flags.direct = true;
} }
/* The pxtwitter.com domain is deprecated and Tweets posted after deprecation /* The pxtwitter.com domain is deprecated and statuses posted after deprecation
date will have a notice saying we've moved to fxtwitter.com! */ date will have a notice saying we've moved to fxtwitter.com! */
if ( if (
Constants.DEPRECATED_DOMAIN_LIST.includes(url.hostname) && Constants.DEPRECATED_DOMAIN_LIST.includes(url.hostname) &&
@ -115,9 +115,9 @@ export const statusRequest = async (c: Context) => {
if (statusResponse) { if (statusResponse) {
/* We're checking if the User Agent is a bot again specifically in case they requested /* We're checking if the User Agent is a bot again specifically in case they requested
direct media (d.fxtwitter.com, .mp4/.jpg, etc) but the Tweet contains no media. direct media (d.fxtwitter.com, .mp4/.jpg, etc) but the status contains no media.
Since we obviously have no media to give the user, we'll just redirect to the Tweet. Since we obviously have no media to give the user, we'll just redirect to the status.
Embeds will return as usual to bots as if direct media was never specified. */ Embeds will return as usual to bots as if direct media was never specified. */
if (!isBotUA && !flags.api && !flags.direct) { if (!isBotUA && !flags.api && !flags.direct) {
const baseUrl = getBaseRedirectUrl(c); const baseUrl = getBaseRedirectUrl(c);
@ -135,7 +135,7 @@ export const statusRequest = async (c: Context) => {
} }
} else { } else {
/* A human has clicked a fxtwitter.com/:screen_name/status/:id link! /* A human has clicked a fxtwitter.com/:screen_name/status/:id link!
Obviously we just need to redirect to the Tweet directly.*/ Obviously we just need to redirect to the status directly.*/
console.log('Matched human UA', userAgent); console.log('Matched human UA', userAgent);
return c.redirect(`${baseUrl}/${handle || 'i'}/status/${id?.match(/\d{2,20}/)?.[0]}`, 302); return c.redirect(`${baseUrl}/${handle || 'i'}/status/${id?.match(/\d{2,20}/)?.[0]}`, 302);

View file

@ -4,7 +4,7 @@ import { getSocialTextIV } from '../helpers/author';
import { sanitizeText } from '../helpers/utils'; import { sanitizeText } from '../helpers/utils';
import { Strings } from '../strings'; import { Strings } from '../strings';
const populateUserLinks = (tweet: APIStatus, text: string): string => { const populateUserLinks = (status: APIStatus, text: string): string => {
/* TODO: Maybe we can add username splices to our API so only genuinely valid users are linked? */ /* 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 => { text.match(/@(\w{1,15})/g)?.forEach(match => {
const username = match.replace('@', ''); const username = match.replace('@', '');
@ -16,10 +16,10 @@ const populateUserLinks = (tweet: APIStatus, text: string): string => {
return text; return text;
}; };
const generateTweetMedia = (tweet: APIStatus): string => { const generateStatusMedia = (status: APIStatus): string => {
let media = ''; let media = '';
if (tweet.media?.all?.length) { if (status.media?.all?.length) {
tweet.media.all.forEach(mediaItem => { status.media.all.forEach(mediaItem => {
switch (mediaItem.type) { switch (mediaItem.type) {
case 'photo': case 'photo':
// eslint-disable-next-line no-case-declarations // eslint-disable-next-line no-case-declarations
@ -30,10 +30,10 @@ const generateTweetMedia = (tweet: APIStatus): string => {
}); });
break; break;
case 'video': case 'video':
media += `<video src="${mediaItem.url}" alt="${tweet.author.name}'s video. Alt text not available."/>`; media += `<video src="${mediaItem.url}" alt="${status.author.name}'s video. Alt text not available."/>`;
break; break;
case 'gif': case 'gif':
media += `<video src="${mediaItem.url}" alt="${tweet.author.name}'s gif. Alt text not available."/>`; media += `<video src="${mediaItem.url}" alt="${status.author.name}'s gif. Alt text not available."/>`;
break; break;
} }
}); });
@ -82,23 +82,23 @@ function paragraphify(text: string, isQuote = false): string {
.join('\n'); .join('\n');
} }
function getTranslatedText(tweet: APITweet, isQuote = false): string | null { function getTranslatedText(status: APITwitterStatus, isQuote = false): string | null {
if (!tweet.translation) { if (!status.translation) {
return null; return null;
} }
let text = paragraphify(sanitizeText(tweet.translation?.text), isQuote); let text = paragraphify(sanitizeText(status.translation?.text), isQuote);
text = htmlifyLinks(text); text = htmlifyLinks(text);
text = htmlifyHashtags(text); text = htmlifyHashtags(text);
text = populateUserLinks(tweet, text); text = populateUserLinks(status, text);
const formatText = const formatText =
tweet.translation.target_lang === 'en' status.translation.target_lang === 'en'
? Strings.TRANSLATE_TEXT.format({ ? Strings.TRANSLATE_TEXT.format({
language: tweet.translation.source_lang_en language: status.translation.source_lang_en
}) })
: Strings.TRANSLATE_TEXT_INTL.format({ : Strings.TRANSLATE_TEXT_INTL.format({
source: tweet.translation.source_lang.toUpperCase(), source: status.translation.source_lang.toUpperCase(),
destination: tweet.translation.target_lang.toUpperCase() destination: status.translation.target_lang.toUpperCase()
}); });
return `<h4>${formatText}</h4>${text}<h4>Original</h4>`; return `<h4>${formatText}</h4>${text}<h4>Original</h4>`;
@ -117,13 +117,13 @@ const truncateSocialCount = (count: number): string => {
} }
}; };
const generateTweetFooter = (tweet: APIStatus, isQuote = false): string => { const generateStatusFooter = (status: APIStatus, isQuote = false): string => {
const { author } = tweet; const { author } = status;
let description = author.description; let description = author.description;
description = htmlifyLinks(description); description = htmlifyLinks(description);
description = htmlifyHashtags(description); description = htmlifyHashtags(description);
description = populateUserLinks(tweet, description); description = populateUserLinks(status, description);
return ` return `
<p>{socialText}</p> <p>{socialText}</p>
@ -131,8 +131,8 @@ const generateTweetFooter = (tweet: APIStatus, isQuote = false): string => {
<!-- Embed profile picture, display name, and screen name in table --> <!-- Embed profile picture, display name, and screen name in table -->
{aboutSection} {aboutSection}
`.format({ `.format({
socialText: getSocialTextIV(tweet as APITweet) || '', socialText: getSocialTextIV(status as APITwitterStatus) || '',
viewOriginal: !isQuote ? `<a href="${tweet.url}">View original post</a>` : notApplicableComment, viewOriginal: !isQuote ? `<a href="${status.url}">View original post</a>` : notApplicableComment,
aboutSection: isQuote aboutSection: isQuote
? '' ? ''
: `<h2>About author</h2> : `<h2>About author</h2>
@ -144,7 +144,7 @@ const generateTweetFooter = (tweet: APIStatus, isQuote = false): string => {
<p> <p>
{following} <b>Following</b> {following} <b>Following</b>
{followers} <b>Followers</b> {followers} <b>Followers</b>
{tweets} <b>Posts</b> {statuses} <b>Posts</b>
</p>`.format({ </p>`.format({
pfp: `<img src="${author.avatar_url?.replace('_200x200', '_400x400')}" alt="${ pfp: `<img src="${author.avatar_url?.replace('_200x200', '_400x400')}" alt="${
author.name author.name
@ -156,44 +156,44 @@ const generateTweetFooter = (tweet: APIStatus, isQuote = false): string => {
joined: author.joined ? `📆 ${formatDate(new Date(author.joined))}` : '', joined: author.joined ? `📆 ${formatDate(new Date(author.joined))}` : '',
following: truncateSocialCount(author.following), following: truncateSocialCount(author.following),
followers: truncateSocialCount(author.followers), followers: truncateSocialCount(author.followers),
tweets: truncateSocialCount(author.statuses) statuses: truncateSocialCount(author.statuses)
}) })
}); });
}; };
const generateTweet = (tweet: APIStatus, isQuote = false): string => { const generateStatus = (status: APIStatus, isQuote = false): string => {
let text = paragraphify(sanitizeText(tweet.text), isQuote); let text = paragraphify(sanitizeText(status.text), isQuote);
text = htmlifyLinks(text); text = htmlifyLinks(text);
text = htmlifyHashtags(text); text = htmlifyHashtags(text);
text = populateUserLinks(tweet, text); text = populateUserLinks(status, text);
const translatedText = getTranslatedText(tweet as APITweet, isQuote); const translatedText = getTranslatedText(status as APITwitterStatus, isQuote);
return `<!-- Telegram Instant View --> return `<!-- Telegram Instant View -->
{quoteHeader} {quoteHeader}
<!-- Embed Tweet media --> <!-- Embed media -->
${generateTweetMedia(tweet)} ${generateStatusMedia(status)}
<!-- Translated text (if applicable) --> <!-- Translated text (if applicable) -->
${translatedText ? translatedText : notApplicableComment} ${translatedText ? translatedText : notApplicableComment}
<!-- Embed Tweet text --> <!-- Embed Status text -->
${text} ${text}
<!-- Embedded quote tweet --> <!-- Embedded quote status -->
${!isQuote && tweet.quote ? generateTweet(tweet.quote, true) : notApplicableComment} ${!isQuote && status.quote ? generateStatus(status.quote, true) : notApplicableComment}
${!isQuote ? generateTweetFooter(tweet) : ''} ${!isQuote ? generateStatusFooter(status) : ''}
<br>${!isQuote ? `<a href="${tweet.url}">View original post</a>` : notApplicableComment} <br>${!isQuote ? `<a href="${status.url}">View original post</a>` : notApplicableComment}
`.format({ `.format({
quoteHeader: isQuote quoteHeader: isQuote
? `<h4><a href="${tweet.url}">Quoting</a> ${tweet.author.name} (<a href="${Constants.TWITTER_ROOT}/${tweet.author.screen_name}">@${tweet.author.screen_name}</a>)</h4>` ? `<h4><a href="${status.url}">Quoting</a> ${status.author.name} (<a href="${Constants.TWITTER_ROOT}/${status.author.screen_name}">@${status.author.screen_name}</a>)</h4>`
: '' : ''
}); });
}; };
export const renderInstantView = (properties: RenderProperties): ResponseInstructions => { export const renderInstantView = (properties: RenderProperties): ResponseInstructions => {
console.log('Generating Instant View...'); console.log('Generating Instant View...');
const { tweet, flags } = properties; const { status, flags } = properties;
const instructions: ResponseInstructions = { addHeaders: [] }; const instructions: ResponseInstructions = { addHeaders: [] };
/* Use ISO date for Medium template */ /* Use ISO date for Medium template */
const postDate = new Date(tweet.created_at).toISOString(); const statusDate = new Date(status.created_at).toISOString();
/* Pretend to be Medium to allow Instant View to work. /* Pretend to be Medium to allow Instant View to work.
Thanks to https://nikstar.me/post/instant-view/ for the help! Thanks to https://nikstar.me/post/instant-view/ for the help!
@ -202,7 +202,7 @@ export const renderInstantView = (properties: RenderProperties): ResponseInstruc
contact me https://t.me/dangeredwolf */ contact me https://t.me/dangeredwolf */
instructions.addHeaders = [ instructions.addHeaders = [
`<meta property="al:android:app_name" content="Medium"/>`, `<meta property="al:android:app_name" content="Medium"/>`,
`<meta property="article:published_time" content="${postDate}"/>`, `<meta property="article:published_time" content="${statusDate}"/>`,
flags?.archive flags?.archive
? `<style>img,video{width:100%;max-width:500px}html{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif}</style>` ? `<style>img,video{width:100%;max-width:500px}html{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif}</style>`
: `` : ``
@ -216,13 +216,13 @@ export const renderInstantView = (properties: RenderProperties): ResponseInstruc
flags?.archive flags?.archive
? `${Constants.BRANDING_NAME} archive` ? `${Constants.BRANDING_NAME} archive`
: 'If you can see this, your browser is doing something weird with your user agent.' : 'If you can see this, your browser is doing something weird with your user agent.'
} <a href="${tweet.url}">View original post</a> } <a href="${status.url}">View original post</a>
</section> </section>
<article> <article>
<sub><a href="${tweet.url}">View original</a></sub> <sub><a href="${status.url}">View original</a></sub>
<h1>${tweet.author.name} (@${tweet.author.screen_name})</h1> <h1>${status.author.name} (@${status.author.screen_name})</h1>
${generateTweet(tweet)} ${generateStatus(status)}
</article>`; </article>`;
return instructions; return instructions;

View file

@ -5,15 +5,15 @@ export const renderPhoto = (
properties: RenderProperties, properties: RenderProperties,
photo: APIPhoto | APIMosaicPhoto photo: APIPhoto | APIMosaicPhoto
): ResponseInstructions => { ): ResponseInstructions => {
const { tweet, engagementText, authorText, isOverrideMedia, userAgent } = properties; const { status, engagementText, authorText, isOverrideMedia, userAgent } = properties;
const instructions: ResponseInstructions = { addHeaders: [] }; const instructions: ResponseInstructions = { addHeaders: [] };
if ((tweet.media?.photos?.length || 0) > 1 && (!tweet.media?.mosaic || isOverrideMedia)) { if ((status.media?.photos?.length || 0) > 1 && (!status.media?.mosaic || isOverrideMedia)) {
photo = photo as APIPhoto; photo = photo as APIPhoto;
const all = tweet.media?.all as APIMedia[]; const all = status.media?.all as APIMedia[];
const baseString = const baseString =
all.length === tweet.media?.photos?.length ? Strings.PHOTO_COUNT : Strings.MEDIA_COUNT; all.length === status.media?.photos?.length ? Strings.PHOTO_COUNT : Strings.MEDIA_COUNT;
const photoCounter = baseString.format({ const photoCounter = baseString.format({
number: String(all.indexOf(photo) + 1), number: String(all.indexOf(photo) + 1),

View file

@ -6,10 +6,10 @@ export const renderVideo = (
properties: RenderProperties, properties: RenderProperties,
video: APIVideo video: APIVideo
): ResponseInstructions => { ): ResponseInstructions => {
const { tweet, userAgent, text } = properties; const { status, userAgent, text } = properties;
const instructions: ResponseInstructions = { addHeaders: [] }; const instructions: ResponseInstructions = { addHeaders: [] };
const all = tweet.media?.all as APIMedia[]; const all = status.media?.all as APIMedia[];
/* This fix is specific to Discord not wanting to render videos that are too large, /* This fix is specific to Discord not wanting to render videos that are too large,
or rendering low quality videos too small. or rendering low quality videos too small.
@ -32,7 +32,7 @@ export const renderVideo = (
we'll put an indicator if there are more than one video */ we'll put an indicator if there are more than one video */
if (all && all.length > 1 && (userAgent?.indexOf('Telegram') ?? 0) > -1) { if (all && all.length > 1 && (userAgent?.indexOf('Telegram') ?? 0) > -1) {
const baseString = const baseString =
all.length === tweet.media?.videos?.length ? Strings.VIDEO_COUNT : Strings.MEDIA_COUNT; all.length === status.media?.videos?.length ? Strings.VIDEO_COUNT : Strings.MEDIA_COUNT;
const videoCounter = baseString.format({ const videoCounter = baseString.format({
number: String(all.indexOf(video) + 1), number: String(all.indexOf(video) + 1),
total: String(all.length) total: String(all.length)
@ -41,10 +41,10 @@ export const renderVideo = (
instructions.siteName = `${Constants.BRANDING_NAME} - ${videoCounter}`; instructions.siteName = `${Constants.BRANDING_NAME} - ${videoCounter}`;
} }
instructions.authorText = tweet.translation?.text || text || ''; instructions.authorText = status.translation?.text || text || '';
if (instructions.authorText.length < 40 && tweet.quote) { if (instructions.authorText.length < 40 && status.quote) {
instructions.authorText += `\n${handleQuote(tweet.quote)}`; instructions.authorText += `\n${handleQuote(status.quote)}`;
} }
/* Push the raw video-related headers */ /* Push the raw video-related headers */

16
src/types/types.d.ts vendored
View file

@ -28,7 +28,7 @@ interface ResponseInstructions {
} }
interface RenderProperties { interface RenderProperties {
tweet: APITweet; status: APITwitterStatus;
siteText?: string; siteText?: string;
authorText?: string; authorText?: string;
engagementText?: string; engagementText?: string;
@ -41,13 +41,13 @@ interface RenderProperties {
interface TweetAPIResponse { interface TweetAPIResponse {
code: number; code: number;
message: string; message: string;
tweet?: APITweet; tweet?: APITwitterStatus;
} }
interface SocialPostAPIResponse { interface StatusAPIResponse {
code: number; code: number;
message: string; message: string;
post?: APITweet; status?: APITwitterStatus;
} }
interface UserAPIResponse { interface UserAPIResponse {
@ -146,7 +146,7 @@ interface APIStatus {
embed_card: 'tweet' | 'summary' | 'summary_large_image' | 'player'; embed_card: 'tweet' | 'summary' | 'summary_large_image' | 'player';
} }
interface APITweet extends APIStatus { interface APITwitterStatus extends APIStatus {
views?: number | null; views?: number | null;
translation?: APITranslate; translation?: APITranslate;
@ -183,13 +183,13 @@ interface APIUser {
} }
interface SocialPost { interface SocialPost {
status: APIStatus | APITweet | null; status: APIStatus | APITwitterStatus | null;
author: APIUser | null; author: APIUser | null;
} }
interface SocialThread { interface SocialThread {
status: APIStatus | APITweet | null; status: APIStatus | APITwitterStatus | null;
thread: (APIStatus | APITweet)[] | null; thread: (APIStatus | APITwitterStatus)[] | null;
author: APIUser | null; author: APIUser | null;
code: number; code: number;
} }

View file

@ -308,7 +308,7 @@ type GraphQLUser = {
}; };
}; };
type GraphQLTweetLegacy = { type GraphQLTwitterStatusLegacy = {
id_str: string; // "1674824189176590336" id_str: string; // "1674824189176590336"
created_at: string; // "Tue Sep 14 20:00:00 +0000 2021" created_at: string; // "Tue Sep 14 20:00:00 +0000 2021"
conversation_id_str: string; // "1674824189176590336" conversation_id_str: string; // "1674824189176590336"
@ -351,9 +351,9 @@ type GraphQLTweetLegacy = {
}; };
}; };
type GraphQLTweet = { type GraphQLTwitterStatus = {
// Workaround // Workaround
result: GraphQLTweet; result: GraphQLTwitterStatus;
__typename: 'Tweet' | 'TweetWithVisibilityResults' | 'TweetUnavailable'; __typename: 'Tweet' | 'TweetWithVisibilityResults' | 'TweetUnavailable';
reason: string; // used for errors reason: string; // used for errors
rest_id: string; // "1674824189176590336", rest_id: string; // "1674824189176590336",
@ -364,7 +364,7 @@ type GraphQLTweet = {
}; };
}; };
tweet?: { tweet?: {
legacy: GraphQLTweetLegacy; legacy: GraphQLTwitterStatusLegacy;
views: { views: {
count: string; // "562" count: string; // "562"
state: string; // "EnabledWithCount" state: string; // "EnabledWithCount"
@ -383,8 +383,8 @@ type GraphQLTweet = {
state: string; // "EnabledWithCount" state: string; // "EnabledWithCount"
}; };
source: string; // "<a href=\"https://mobile.twitter.com\" rel=\"nofollow\">Twitter Web App</a>" source: string; // "<a href=\"https://mobile.twitter.com\" rel=\"nofollow\">Twitter Web App</a>"
quoted_status_result?: GraphQLTweet; quoted_status_result?: GraphQLTwitterStatus;
legacy: GraphQLTweetLegacy; legacy: GraphQLTwitterStatusLegacy;
note_tweet: { note_tweet: {
is_expandable: boolean; is_expandable: boolean;
note_tweet_results: { note_tweet_results: {
@ -450,7 +450,7 @@ type GraphQLTimelineTweet = {
item: 'TimelineTweet'; item: 'TimelineTweet';
__typename: 'TimelineTweet'; __typename: 'TimelineTweet';
tweet_results: { tweet_results: {
result: GraphQLTweet | TweetTombstone; result: GraphQLTwitterStatus | TweetTombstone;
}; };
}; };
@ -521,7 +521,7 @@ type TimelineTerminateTimelineInstruction = {
type: 'TimelineTerminateTimeline'; type: 'TimelineTerminateTimeline';
direction: 'Top'; direction: 'Top';
}; };
type GraphQLTweetNotFoundResponse = { type GraphQLTwitterStatusNotFoundResponse = {
errors: [ errors: [
{ {
message: string; // "_Missing: No status found with that ID" message: string; // "_Missing: No status found with that ID"
@ -560,7 +560,7 @@ type TweetResultsByRestIdResult = {
errors?: unknown[]; errors?: unknown[];
data?: { data?: {
tweetResult?: { tweetResult?: {
result?: TweetStub | GraphQLTweet; result?: TweetStub | GraphQLTwitterStatus;
}; };
}; };
}; };
@ -571,6 +571,6 @@ type TweetStub = {
}; };
interface GraphQLProcessBucket { interface GraphQLProcessBucket {
tweets: GraphQLTweet[]; statuses: GraphQLTwitterStatus[];
cursors: GraphQLTimelineCursor[]; cursors: GraphQLTimelineCursor[];
} }

View file

@ -47,7 +47,7 @@ test('Home page redirect', async () => {
expect(resultHuman.headers.get('location')).toEqual(githubUrl); expect(resultHuman.headers.get('location')).toEqual(githubUrl);
}); });
test('Tweet redirect human', async () => { test('Status redirect human', async () => {
const result = await app.request( const result = await app.request(
new Request('https://fxtwitter.com/jack/status/20', { new Request('https://fxtwitter.com/jack/status/20', {
method: 'GET', method: 'GET',
@ -58,7 +58,7 @@ test('Tweet redirect human', async () => {
expect(result.headers.get('location')).toEqual('https://twitter.com/jack/status/20'); expect(result.headers.get('location')).toEqual('https://twitter.com/jack/status/20');
}); });
test('Tweet redirect human trailing slash', async () => { test('Status redirect human trailing slash', async () => {
const result = await app.request( const result = await app.request(
new Request('https://fxtwitter.com/jack/status/20/', { new Request('https://fxtwitter.com/jack/status/20/', {
method: 'GET', method: 'GET',
@ -69,7 +69,7 @@ test('Tweet redirect human trailing slash', async () => {
expect(result.headers.get('location')).toEqual('https://twitter.com/jack/status/20'); expect(result.headers.get('location')).toEqual('https://twitter.com/jack/status/20');
}); });
test('Tweet redirect human custom base redirect', async () => { test('Status redirect human custom base redirect', async () => {
const result = await app.request( const result = await app.request(
new Request('https://fxtwitter.com/jack/status/20', { new Request('https://fxtwitter.com/jack/status/20', {
method: 'GET', method: 'GET',
@ -97,7 +97,7 @@ test('Twitter moment redirect', async () => {
expect(result.headers.get('location')).toEqual(`${twitterBaseUrl}/i/events/1572638642127966214`); expect(result.headers.get('location')).toEqual(`${twitterBaseUrl}/i/events/1572638642127966214`);
}); });
test('Tweet response robot', async () => { test('Status response robot', async () => {
const result = await app.request( const result = await app.request(
new Request('https://fxtwitter.com/jack/status/20', { new Request('https://fxtwitter.com/jack/status/20', {
method: 'GET', method: 'GET',
@ -107,7 +107,7 @@ test('Tweet response robot', async () => {
expect(result.status).toEqual(200); expect(result.status).toEqual(200);
}); });
test('Tweet response robot (trailing slash/query string and extra characters)', async () => { test('Status response robot (trailing slash/query string and extra characters)', async () => {
const result = await app.request( const result = await app.request(
new Request('https://fxtwitter.com/jack/status/20||/?asdf=ghjk&klop;', { new Request('https://fxtwitter.com/jack/status/20||/?asdf=ghjk&klop;', {
method: 'GET', method: 'GET',
@ -117,7 +117,7 @@ test('Tweet response robot (trailing slash/query string and extra characters)',
expect(result.status).toEqual(200); expect(result.status).toEqual(200);
}); });
test('API fetch basic Tweet', async () => { test('API fetch basic Status', async () => {
const result = await app.request( const result = await app.request(
new Request('https://api.fxtwitter.com/status/20', { new Request('https://api.fxtwitter.com/status/20', {
method: 'GET', method: 'GET',
@ -130,29 +130,29 @@ test('API fetch basic Tweet', async () => {
expect(response.code).toEqual(200); expect(response.code).toEqual(200);
expect(response.message).toEqual('OK'); expect(response.message).toEqual('OK');
const tweet = response.tweet as APITweet; const status = response.tweet as APITwitterStatus;
expect(tweet).toBeTruthy(); expect(status).toBeTruthy();
expect(tweet.url).toEqual(`${twitterBaseUrl}/jack/status/20`); expect(status.url).toEqual(`${twitterBaseUrl}/jack/status/20`);
expect(tweet.id).toEqual('20'); expect(status.id).toEqual('20');
expect(tweet.text).toEqual('just setting up my twttr'); expect(status.text).toEqual('just setting up my twttr');
expect(tweet.author.screen_name?.toLowerCase()).toEqual('jack'); expect(status.author.screen_name?.toLowerCase()).toEqual('jack');
expect(tweet.author.id).toEqual('12'); expect(status.author.id).toEqual('12');
expect(tweet.author.name).toBeTruthy(); expect(status.author.name).toBeTruthy();
expect(tweet.author.avatar_url).toBeTruthy(); expect(status.author.avatar_url).toBeTruthy();
expect(tweet.author.banner_url).toBeTruthy(); expect(status.author.banner_url).toBeTruthy();
expect(tweet.replies).toBeGreaterThan(0); expect(status.replies).toBeGreaterThan(0);
// @ts-expect-error retweets only in legacy API // @ts-expect-error retweets only in legacy API
expect(tweet.retweets).toBeGreaterThan(0); expect(status.retweets).toBeGreaterThan(0);
expect(tweet.likes).toBeGreaterThan(0); expect(status.likes).toBeGreaterThan(0);
// @ts-expect-error twitter_card only in legacy API // @ts-expect-error twitter_card only in legacy API
expect(tweet.twitter_card).toEqual('tweet'); expect(status.twitter_card).toEqual('tweet');
expect(tweet.created_at).toEqual('Tue Mar 21 20:50:14 +0000 2006'); expect(status.created_at).toEqual('Tue Mar 21 20:50:14 +0000 2006');
expect(tweet.created_timestamp).toEqual(1142974214); expect(status.created_timestamp).toEqual(1142974214);
expect(tweet.lang).toEqual('en'); expect(status.lang).toEqual('en');
expect(tweet.replying_to).toBeNull(); expect(status.replying_to).toBeNull();
}); });
// test('API fetch video Tweet', async () => { // test('API fetch video Status', async () => {
// const result = await app.request( // const result = await app.request(
// new Request('https://api.fxtwitter.com/X/status/854416760933556224', { // new Request('https://api.fxtwitter.com/X/status/854416760933556224', {
// method: 'GET', // method: 'GET',
@ -165,27 +165,27 @@ test('API fetch basic Tweet', async () => {
// expect(response.code).toEqual(200); // expect(response.code).toEqual(200);
// expect(response.message).toEqual('OK'); // expect(response.message).toEqual('OK');
// const tweet = response.tweet as APITweet; // const status = response.tweet as APITwitterStatus;
// expect(tweet).toBeTruthy(); // expect(status).toBeTruthy();
// expect(tweet.url).toEqual(`${twitterBaseUrl}/X/status/854416760933556224`); // expect(status.url).toEqual(`${twitterBaseUrl}/X/status/854416760933556224`);
// expect(tweet.id).toEqual('854416760933556224'); // expect(status.id).toEqual('854416760933556224');
// expect(tweet.text).toEqual( // expect(status.text).toEqual(
// 'Get the sauces ready, #NuggsForCarter has 3 million+ Retweets.' // 'Get the sauces ready, #NuggsForCarter has 3 million+ Retweets.'
// ); // );
// expect(tweet.author.screen_name?.toLowerCase()).toEqual('x'); // expect(status.author.screen_name?.toLowerCase()).toEqual('x');
// expect(tweet.author.id).toEqual('783214'); // expect(status.author.id).toEqual('783214');
// expect(tweet.author.name).toBeTruthy(); // expect(status.author.name).toBeTruthy();
// expect(tweet.author.avatar_url).toBeTruthy(); // expect(status.author.avatar_url).toBeTruthy();
// expect(tweet.author.banner_url).toBeTruthy(); // expect(status.author.banner_url).toBeTruthy();
// expect(tweet.replies).toBeGreaterThan(0); // expect(status.replies).toBeGreaterThan(0);
// expect(tweet.retweets).toBeGreaterThan(0); // expect(status.retweets).toBeGreaterThan(0);
// expect(tweet.likes).toBeGreaterThan(0); // expect(status.likes).toBeGreaterThan(0);
// expect(tweet.twitter_card).toEqual('player'); // expect(status.twitter_card).toEqual('player');
// expect(tweet.created_at).toEqual('Tue Apr 18 19:30:04 +0000 2017'); // expect(status.created_at).toEqual('Tue Apr 18 19:30:04 +0000 2017');
// expect(tweet.created_timestamp).toEqual(1492543804); // expect(status.created_timestamp).toEqual(1492543804);
// expect(tweet.lang).toEqual('en'); // expect(status.lang).toEqual('en');
// expect(tweet.replying_to).toBeNull(); // expect(status.replying_to).toBeNull();
// const video = tweet.media?.videos?.[0] as APIVideo; // const video = status.media?.videos?.[0] as APIVideo;
// expect(video.url).toEqual( // expect(video.url).toEqual(
// 'https://video.twimg.com/amplify_video/854415175776059393/vid/720x720/dNEi0crU-jA4mTtr.mp4' // 'https://video.twimg.com/amplify_video/854415175776059393/vid/720x720/dNEi0crU-jA4mTtr.mp4'
// ); // );
@ -197,7 +197,7 @@ test('API fetch basic Tweet', async () => {
// expect(video.type).toEqual('video'); // expect(video.type).toEqual('video');
// }); // });
// test('API fetch multi-photo Tweet', async () => { // test('API fetch multi-photo status', async () => {
// const result = await app.request( // const result = await app.request(
// new Request('https://api.fxtwitter.com/Twitter/status/1445094085593866246', { // new Request('https://api.fxtwitter.com/Twitter/status/1445094085593866246', {
// method: 'GET', // method: 'GET',
@ -210,22 +210,22 @@ test('API fetch basic Tweet', async () => {
// expect(response.code).toEqual(200); // expect(response.code).toEqual(200);
// expect(response.message).toEqual('OK'); // expect(response.message).toEqual('OK');
// const tweet = response.tweet as APITweet; // const status = response.tweet as APITwitterStatus;
// expect(tweet).toBeTruthy(); // expect(status).toBeTruthy();
// expect(tweet.url).toEqual(`${twitterBaseUrl}/X/status/1445094085593866246`); // expect(status.url).toEqual(`${twitterBaseUrl}/X/status/1445094085593866246`);
// expect(tweet.id).toEqual('1445094085593866246'); // expect(status.id).toEqual('1445094085593866246');
// expect(tweet.text).toEqual('@netflix'); // expect(status.text).toEqual('@netflix');
// expect(tweet.author.screen_name?.toLowerCase()).toEqual('x'); // expect(status.author.screen_name?.toLowerCase()).toEqual('x');
// expect(tweet.author.id).toEqual('783214'); // expect(status.author.id).toEqual('783214');
// expect(tweet.author.name).toBeTruthy(); // expect(status.author.name).toBeTruthy();
// expect(tweet.author.avatar_url).toBeTruthy(); // expect(status.author.avatar_url).toBeTruthy();
// expect(tweet.author.banner_url).toBeTruthy(); // expect(status.author.banner_url).toBeTruthy();
// expect(tweet.twitter_card).toEqual('summary_large_image'); // expect(status.twitter_card).toEqual('summary_large_image');
// expect(tweet.created_at).toEqual('Mon Oct 04 18:30:53 +0000 2021'); // expect(status.created_at).toEqual('Mon Oct 04 18:30:53 +0000 2021');
// expect(tweet.created_timestamp).toEqual(1633372253); // expect(status.created_timestamp).toEqual(1633372253);
// expect(tweet.replying_to?.toLowerCase()).toEqual('netflix'); // expect(status.replying_to?.toLowerCase()).toEqual('netflix');
// expect(tweet.media?.photos).toBeTruthy(); // expect(status.media?.photos).toBeTruthy();
// const photos = tweet.media?.photos as APIPhoto[]; // const photos = status.media?.photos as APIPhoto[];
// expect(photos[0].url).toEqual('https://pbs.twimg.com/media/FA4BaFaXoBUV3di.jpg'); // expect(photos[0].url).toEqual('https://pbs.twimg.com/media/FA4BaFaXoBUV3di.jpg');
// expect(photos[0].width).toEqual(950); // expect(photos[0].width).toEqual(950);
// expect(photos[0].height).toEqual(620); // expect(photos[0].height).toEqual(620);
@ -234,8 +234,8 @@ test('API fetch basic Tweet', async () => {
// expect(photos[1].width).toEqual(1386); // expect(photos[1].width).toEqual(1386);
// expect(photos[1].height).toEqual(706); // expect(photos[1].height).toEqual(706);
// expect(photos[1].altText).toBeTruthy(); // expect(photos[1].altText).toBeTruthy();
// expect(tweet.media?.mosaic).toBeTruthy(); // expect(status.media?.mosaic).toBeTruthy();
// const mosaic = tweet.media?.mosaic as APIMosaicPhoto; // const mosaic = status.media?.mosaic as APIMosaicPhoto;
// expect(mosaic.formats?.jpeg).toEqual( // expect(mosaic.formats?.jpeg).toEqual(
// 'https://mosaic.fxtwitter.com/jpeg/1445094085593866246/FA4BaFaXoBUV3di/FA4BaUyXEAcAHvK' // 'https://mosaic.fxtwitter.com/jpeg/1445094085593866246/FA4BaFaXoBUV3di/FA4BaUyXEAcAHvK'
// ); // );
@ -244,7 +244,7 @@ test('API fetch basic Tweet', async () => {
// ); // );
// }); // });
// test('API fetch poll Tweet', async () => { // test('API fetch poll status', async () => {
// const result = await app.request( // const result = await app.request(
// new Request('https://api.fxtwitter.com/status/1055475950543167488', { // new Request('https://api.fxtwitter.com/status/1055475950543167488', {
// method: 'GET', // method: 'GET',
@ -257,23 +257,23 @@ test('API fetch basic Tweet', async () => {
// expect(response.code).toEqual(200); // expect(response.code).toEqual(200);
// expect(response.message).toEqual('OK'); // expect(response.message).toEqual('OK');
// const tweet = response.tweet as APITweet; // const status = response.tweet as APITwitterStatus;
// expect(tweet).toBeTruthy(); // expect(status).toBeTruthy();
// expect(tweet.url).toEqual(`${twitterBaseUrl}/X/status/1055475950543167488`); // expect(status.url).toEqual(`${twitterBaseUrl}/X/status/1055475950543167488`);
// expect(tweet.id).toEqual('1055475950543167488'); // expect(status.id).toEqual('1055475950543167488');
// expect(tweet.text).toEqual('A poll:'); // expect(status.text).toEqual('A poll:');
// expect(tweet.author.screen_name?.toLowerCase()).toEqual('x'); // expect(status.author.screen_name?.toLowerCase()).toEqual('x');
// expect(tweet.author.id).toEqual('783214'); // expect(status.author.id).toEqual('783214');
// expect(tweet.author.name).toBeTruthy(); // expect(status.author.name).toBeTruthy();
// expect(tweet.author.avatar_url).toBeTruthy(); // expect(status.author.avatar_url).toBeTruthy();
// expect(tweet.author.banner_url).toBeTruthy(); // expect(status.author.banner_url).toBeTruthy();
// expect(tweet.twitter_card).toEqual('tweet'); // expect(status.twitter_card).toEqual('tweet');
// expect(tweet.created_at).toEqual('Thu Oct 25 15:07:31 +0000 2018'); // expect(status.created_at).toEqual('Thu Oct 25 15:07:31 +0000 2018');
// expect(tweet.created_timestamp).toEqual(1540480051); // expect(status.created_timestamp).toEqual(1540480051);
// expect(tweet.lang).toEqual('en'); // expect(status.lang).toEqual('en');
// expect(tweet.replying_to).toBeNull(); // expect(status.replying_to).toBeNull();
// expect(tweet.poll).toBeTruthy(); // expect(status.poll).toBeTruthy();
// const poll = tweet.poll as APIPoll; // const poll = status.poll as APIPoll;
// expect(poll.ends_at).toEqual('2018-10-26T03:07:30Z'); // expect(poll.ends_at).toEqual('2018-10-26T03:07:30Z');
// expect(poll.time_left_en).toEqual('Final results'); // expect(poll.time_left_en).toEqual('Final results');
// expect(poll.total_votes).toEqual(54703); // expect(poll.total_votes).toEqual(54703);