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. */
export const handleStatus = async (
c: Context,
status: string,
statusId: string,
mediaNumber: number | undefined,
userAgent: string,
flags: InputFlags,
@ -41,19 +41,19 @@ export const handleStatus = async (
}
const thread = await constructTwitterThread(
status,
statusId,
fetchWithThreads,
c,
language,
flags?.api ?? false
);
const tweet = thread?.status as APITweet;
const status = thread?.status as APITwitterStatus;
const api = {
code: thread.code,
message: '',
tweet: tweet
tweet: status
};
switch (api.code) {
@ -82,7 +82,7 @@ export const handleStatus = async (
return c.json(api);
}
if (tweet === null) {
if (status === null) {
return returnError(c, Strings.ERROR_TWEET_NOT_FOUND);
}
@ -98,17 +98,17 @@ export const handleStatus = async (
}
const isTelegram = (userAgent || '').indexOf('Telegram') > -1;
/* Should sensitive posts be allowed Instant View? */
/* Should sensitive statuses be allowed Instant View? */
let useIV =
isTelegram /*&& !tweet.possibly_sensitive*/ &&
isTelegram /*&& !status.possibly_sensitive*/ &&
!flags?.direct &&
!flags?.gallery &&
!flags?.api &&
(tweet.media?.photos?.[0] || // Force instant view for photos for now https://bugs.telegram.org/c/33679
tweet.media?.mosaic ||
tweet.is_note_tweet ||
tweet.quote ||
tweet.translation ||
(status.media?.photos?.[0] || // Force instant view for photos for now https://bugs.telegram.org/c/33679
status.media?.mosaic ||
status.is_note_tweet ||
status.quote ||
status.translation ||
flags?.forceInstantView);
/* Force enable IV for archivers */
@ -120,20 +120,20 @@ export const handleStatus = async (
let overrideMedia: APIMedia | undefined;
// Check if mediaNumber exists, and if that media exists in tweet.media.all. If it does, we'll store overrideMedia variable
if (mediaNumber && tweet.media && tweet.media.all && tweet.media.all[mediaNumber - 1]) {
overrideMedia = tweet.media.all[mediaNumber - 1];
// Check if mediaNumber exists, and if that media exists in status.media.all. If it does, we'll store overrideMedia variable
if (mediaNumber && status.media && status.media.all && status.media.all[mediaNumber - 1]) {
overrideMedia = status.media.all[mediaNumber - 1];
}
/* 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;
const all = tweet.media.all || [];
// if (tweet.media.videos) {
// const { videos } = tweet.media;
const all = status.media.all || [];
// if (status.media.videos) {
// const { videos } = status.media;
// redirectUrl = (videos[(mediaNumber || 1) - 1] || videos[0]).url;
// } else if (tweet.media.photos) {
// const { photos } = tweet.media;
// } else if (status.media.photos) {
// const { photos } = status.media;
// 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 */
if (flags.gallery && (tweet.media?.all?.length ?? 0) < 1) {
/* User requested gallery view, but this isn't a status with media */
if (flags.gallery && (status.media?.all?.length ?? 0) < 1) {
flags.gallery = false;
}
/* At this point, we know we're going to have to create a
regular embed because it's not an API or direct media request */
let authorText = getAuthorText(tweet) || Strings.DEFAULT_AUTHOR_TEXT;
let authorText = getAuthorText(status) || Strings.DEFAULT_AUTHOR_TEXT;
const engagementText = authorText.replace(/ {4}/g, ' ');
let siteName = Constants.BRANDING_NAME;
let newText = tweet.text;
let newText = status.text;
/* Base headers included in all responses */
const headers = [
`<link rel="canonical" href="${Constants.TWITTER_ROOT}/${tweet.author.screen_name}/status/${tweet.id}"/>`,
`<meta property="og:url" content="${Constants.TWITTER_ROOT}/${tweet.author.screen_name}/status/${tweet.id}"/>`,
`<meta property="twitter:site" content="@${tweet.author.screen_name}"/>`,
`<meta property="twitter:creator" content="@${tweet.author.screen_name}"/>`,
`<link rel="canonical" href="${Constants.TWITTER_ROOT}/${status.author.screen_name}/status/${status.id}"/>`,
`<meta property="og:url" content="${Constants.TWITTER_ROOT}/${status.author.screen_name}/status/${status.id}"/>`,
`<meta property="twitter:site" content="@${status.author.screen_name}"/>`,
`<meta property="twitter:creator" content="@${status.author.screen_name}"/>`,
];
if (!flags.gallery) {
headers.push(
`<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.
Telegram is dumb and it just gets stuck if this is included, so we never include it for Telegram UAs. */
if (!isTelegram) {
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) {
try {
const instructions = renderInstantView({
tweet: tweet,
status: status,
text: newText,
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. */
if (tweet.translation) {
const { translation } = tweet;
/* This status has a translation attached to it, so we'll render it. */
if (status.translation) {
const { translation } = status;
const formatText =
language === 'en'
@ -228,16 +228,16 @@ export const handleStatus = async (
if (!flags?.textOnly) {
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) {
let instructions: ResponseInstructions;
switch (overrideMedia.type) {
case 'photo':
/* This Tweet has a photo to render. */
/* This status has a photo to render. */
instructions = renderPhoto(
{
tweet: tweet,
status: status,
authorText: authorText,
engagementText: engagementText,
userAgent: userAgent,
@ -253,13 +253,13 @@ export const handleStatus = async (
siteName = instructions.siteName;
}
/* Overwrite our Twitter Card if overriding media, so it displays correctly in Discord */
if (tweet.embed_card === 'player') {
tweet.embed_card = 'summary_large_image';
if (status.embed_card === 'player') {
status.embed_card = 'summary_large_image';
}
break;
case 'video':
instructions = renderVideo(
{ tweet: tweet, userAgent: userAgent, text: newText, isOverrideMedia: true },
{ status: status, userAgent: userAgent, text: newText, isOverrideMedia: true },
overrideMedia as APIVideo
);
headers.push(...instructions.addHeaders);
@ -270,15 +270,15 @@ export const handleStatus = async (
siteName = instructions.siteName;
}
/* Overwrite our Twitter Card if overriding media, so it displays correctly in Discord */
if (tweet.embed_card !== 'player') {
tweet.embed_card = 'player';
if (status.embed_card !== 'player') {
status.embed_card = 'player';
}
/* This Tweet has a video to render. */
/* This status has a video to render. */
break;
}
} else if (media?.videos) {
const instructions = renderVideo(
{ tweet: tweet, userAgent: userAgent, text: newText },
{ status: status, userAgent: userAgent, text: newText },
media.videos[0]
);
headers.push(...instructions.addHeaders);
@ -291,7 +291,7 @@ export const handleStatus = async (
} else if (media?.mosaic) {
const instructions = renderPhoto(
{
tweet: tweet,
status: status,
authorText: authorText,
engagementText: engagementText,
userAgent: userAgent
@ -302,7 +302,7 @@ export const handleStatus = async (
} else if (media?.photos) {
const instructions = renderPhoto(
{
tweet: tweet,
status: status,
authorText: authorText,
engagementText: engagementText,
userAgent: userAgent
@ -326,9 +326,9 @@ export const handleStatus = async (
}
}
/* This Tweet contains a poll, so we'll render it */
if (tweet.poll) {
const { poll } = tweet;
/* This status contains a poll, so we'll render it */
if (status.poll) {
const { poll } = status;
let barLength = 32;
let str = '';
@ -338,7 +338,7 @@ export const handleStatus = async (
}
/* Render each poll choice */
tweet.poll.choices.forEach(choice => {
status.poll.choices.forEach(choice => {
const bar = '█'.repeat((choice.percentage / 100) * barLength);
// eslint-disable-next-line no-irregular-whitespace
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 (
!tweet.media?.videos &&
!tweet.media?.photos &&
!tweet.quote?.media?.photos &&
!tweet.quote?.media?.videos &&
!status.media?.videos &&
!status.media?.photos &&
!status.quote?.media?.photos &&
!status.quote?.media?.videos &&
!flags?.textOnly
) {
/* Use a slightly higher resolution image for profile pics */
const avatar = tweet.author.avatar_url;
const avatar = status.author.avatar_url;
if (!useIV) {
headers.push(
`<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. */
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 */
@ -408,31 +408,31 @@ export const handleStatus = async (
if (!flags.gallery) {
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:site_name" content="${siteName}"/>`,
);
} else {
if (isTelegram) {
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 {
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 */
if (tweet.replying_to && authorText === Strings.DEFAULT_AUTHOR_TEXT) {
authorText = `↪ Replying to @${tweet.replying_to.screen_name}`;
if (status.replying_to && authorText === Strings.DEFAULT_AUTHOR_TEXT) {
authorText = `↪ Replying to @${status.replying_to.screen_name}`;
/* We'll assume it's a thread if it's a reply to themselves */
} 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 = `↪ A part of @${tweet.author.screen_name}'s thread`;
authorText = `↪ A part of @${status.author.screen_name}'s thread`;
}
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(
{
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' : '',
status: encodeURIComponent(status),
author: encodeURIComponent(tweet.author.screen_name || ''),
name: tweet.author.name || ''
status: encodeURIComponent(statusId),
author: encodeURIComponent(status.author.screen_name || ''),
name: status.author.name || ''
}
)
);
}
/* 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! */
return c.html(

View file

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

View file

@ -1,6 +1,6 @@
export const isGraphQLTweetNotFoundResponse = (
export const isGraphQLTwitterStatusNotFoundResponse = (
response: unknown
): response is GraphQLTweetNotFoundResponse => {
): response is GraphQLTwitterStatusNotFoundResponse => {
return (
typeof response === 'object' &&
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 (
typeof response === 'object' &&
response !== null &&
(('__typename' in response &&
(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 { withTimeout } from './utils';
/* Handles translating Tweets when asked! */
export const translateTweet = async (
tweet: GraphQLTweet,
/* Handles translating statuses when asked! */
export const translateStatus = async (
tweet: GraphQLTwitterStatus,
guestToken: string,
language: string,
c: Context

View file

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

View file

@ -2,65 +2,64 @@ import { renderCard } from '../../helpers/card';
import { Constants } from '../../constants';
import { linkFixer } from '../../helpers/linkFixer';
import { handleMosaic } from '../../helpers/mosaic';
// import { translateTweet } from '../../helpers/translate';
import { unescapeText } from '../../helpers/utils';
import { processMedia } from '../../helpers/media';
import { convertToApiUser } from './profile';
import { translateTweet } from '../../helpers/translate';
import { translateStatus } from '../../helpers/translate';
import { Context } from 'hono';
export const buildAPITweet = async (
export const buildAPITwitterStatus = async (
c: Context,
tweet: GraphQLTweet,
status: GraphQLTwitterStatus,
language: string | undefined,
threadPiece = false,
legacyAPI = false
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APITweet | FetchResults | null> => {
const apiTweet = {} as APITweet;
): Promise<APITwitterStatus | FetchResults | null> => {
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. */
if (typeof tweet.core === 'undefined' && typeof tweet.result !== 'undefined') {
tweet = tweet.result;
if (typeof status.core === 'undefined' && typeof status.result !== 'undefined') {
status = status.result;
}
if (typeof tweet.core === 'undefined' && typeof tweet.tweet?.core !== 'undefined') {
tweet.core = tweet.tweet.core;
if (typeof status.core === 'undefined' && typeof status.tweet?.core !== 'undefined') {
status.core = status.tweet.core;
}
if (typeof tweet.legacy === 'undefined' && typeof tweet.tweet?.legacy !== 'undefined') {
tweet.legacy = tweet.tweet?.legacy;
if (typeof status.legacy === 'undefined' && typeof status.tweet?.legacy !== 'undefined') {
status.legacy = status.tweet?.legacy;
}
if (typeof tweet.views === 'undefined' && typeof tweet?.tweet?.views !== 'undefined') {
tweet.views = tweet?.tweet?.views;
if (typeof status.views === 'undefined' && typeof status?.tweet?.views !== 'undefined') {
status.views = status?.tweet?.views;
}
if (typeof tweet.core === 'undefined') {
console.log('Tweet still not valid', tweet);
if (tweet.__typename === 'TweetUnavailable' && tweet.reason === 'Protected') {
if (typeof status.core === 'undefined') {
console.log('Status still not valid', status);
if (status.__typename === 'TweetUnavailable' && status.reason === 'Protected') {
return { status: 401 };
} else {
return { status: 404 };
}
}
const graphQLUser = tweet.core.user_results.result;
const graphQLUser = status.core.user_results.result;
const apiUser = convertToApiUser(graphQLUser);
/* 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 */
apiTweet.url = `${Constants.TWITTER_ROOT}/${apiUser.screen_name}/status/${id}`;
apiTweet.id = id;
apiTweet.text = unescapeText(
linkFixer(tweet.legacy.entities?.urls, tweet.legacy.full_text || '')
apiStatus.url = `${Constants.TWITTER_ROOT}/${apiUser.screen_name}/status/${id}`;
apiStatus.id = id;
apiStatus.text = unescapeText(
linkFixer(status.legacy.entities?.urls, status.legacy.full_text || '')
);
if (!threadPiece) {
apiTweet.author = {
apiStatus.author = {
id: apiUser.id,
name: apiUser.name,
screen_name: apiUser.screen_name,
@ -79,111 +78,111 @@ export const buildAPITweet = async (
website: apiUser.website
};
}
apiTweet.replies = tweet.legacy.reply_count;
apiStatus.replies = status.legacy.reply_count;
if (legacyAPI) {
// @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
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
apiTweet.author.avatar_color = null;
apiStatus.author.avatar_color = null;
// @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
delete apiTweet.author.statuses;
delete apiTweet.author.global_screen_name;
delete apiStatus.author.statuses;
delete apiStatus.author.global_screen_name;
} else {
apiTweet.reposts = tweet.legacy.retweet_count;
apiTweet.author.global_screen_name = apiUser.global_screen_name;
apiStatus.reposts = status.legacy.retweet_count;
apiStatus.author.global_screen_name = apiUser.global_screen_name;
}
apiTweet.likes = tweet.legacy.favorite_count;
apiTweet.embed_card = 'tweet';
apiTweet.created_at = tweet.legacy.created_at;
apiTweet.created_timestamp = new Date(tweet.legacy.created_at).getTime() / 1000;
apiStatus.likes = status.legacy.favorite_count;
apiStatus.embed_card = 'tweet';
apiStatus.created_at = status.legacy.created_at;
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') {
apiTweet.views = parseInt(tweet.views.count || '0') ?? null;
if (status.views.state === 'EnabledWithCount') {
apiStatus.views = parseInt(status.views.count || '0') ?? null;
} else {
apiTweet.views = null;
apiStatus.views = null;
}
console.log('note_tweet', JSON.stringify(tweet.note_tweet));
const noteTweetText = tweet.note_tweet?.note_tweet_results?.result?.text;
console.log('note_tweet', JSON.stringify(status.note_tweet));
const noteTweetText = status.note_tweet?.note_tweet_results?.result?.text;
if (noteTweetText) {
tweet.legacy.entities.urls = tweet.note_tweet?.note_tweet_results?.result?.entity_set.urls;
tweet.legacy.entities.hashtags =
tweet.note_tweet?.note_tweet_results?.result?.entity_set.hashtags;
tweet.legacy.entities.symbols =
tweet.note_tweet?.note_tweet_results?.result?.entity_set.symbols;
status.legacy.entities.urls = status.note_tweet?.note_tweet_results?.result?.entity_set.urls;
status.legacy.entities.hashtags =
status.note_tweet?.note_tweet_results?.result?.entity_set.hashtags;
status.legacy.entities.symbols =
status.note_tweet?.note_tweet_results?.result?.entity_set.symbols;
console.log('We meet the conditions to use new note tweets');
apiTweet.text = unescapeText(linkFixer(tweet.legacy.entities.urls, noteTweetText));
apiTweet.is_note_tweet = true;
apiStatus.text = unescapeText(linkFixer(status.legacy.entities.urls, noteTweetText));
apiStatus.is_note_tweet = true;
} else {
apiTweet.is_note_tweet = false;
apiStatus.is_note_tweet = false;
}
if (tweet.legacy.lang !== 'unk') {
apiTweet.lang = tweet.legacy.lang;
if (status.legacy.lang !== 'unk') {
apiStatus.lang = status.legacy.lang;
} else {
apiTweet.lang = null;
apiStatus.lang = null;
}
if (legacyAPI) {
// @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
apiTweet.replying_to_status = tweet.legacy?.in_reply_to_status_id_str || null;
} else if (tweet.legacy.in_reply_to_screen_name) {
apiTweet.replying_to = {
screen_name: tweet.legacy.in_reply_to_screen_name || null,
post: tweet.legacy.in_reply_to_status_id_str || null
apiStatus.replying_to_status = status.legacy?.in_reply_to_status_id_str || null;
} else if (status.legacy.in_reply_to_screen_name) {
apiStatus.replying_to = {
screen_name: status.legacy.in_reply_to_screen_name || null,
post: status.legacy.in_reply_to_status_id_str || null
};
} else {
apiTweet.replying_to = null;
apiStatus.replying_to = null;
}
apiTweet.media = {};
apiStatus.media = {};
/* We found a quote tweet, let's process that too */
const quoteTweet = tweet.quoted_status_result;
if (quoteTweet) {
const buildQuote = await buildAPITweet(c, quoteTweet, language, threadPiece, legacyAPI);
/* We found a quote, let's process that too */
const quote = status.quoted_status_result;
if (quote) {
const buildQuote = await buildAPITwitterStatus(c, quote, language, threadPiece, legacyAPI);
if ((buildQuote as FetchResults).status) {
apiTweet.quote = undefined;
apiStatus.quote = undefined;
} 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 */
if (apiTweet.embed_card === 'tweet' && typeof apiTweet.quote !== 'undefined') {
apiTweet.embed_card = apiTweet.quote.embed_card;
/* Only override the embed_card if it's a basic status, since media always takes precedence */
if (apiStatus.embed_card === 'tweet' && typeof apiStatus.quote !== 'undefined') {
apiStatus.embed_card = apiStatus.quote.embed_card;
}
}
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 => {
const mediaObject = processMedia(media);
if (mediaObject) {
apiTweet.media.all = apiTweet.media?.all ?? [];
apiTweet.media?.all?.push(mediaObject);
apiStatus.media.all = apiStatus.media?.all ?? [];
apiStatus.media?.all?.push(mediaObject);
if (mediaObject.type === 'photo') {
apiTweet.embed_card = 'summary_large_image';
apiTweet.media.photos = apiTweet.media?.photos ?? [];
apiTweet.media.photos?.push(mediaObject);
apiStatus.embed_card = 'summary_large_image';
apiStatus.media.photos = apiStatus.media?.photos ?? [];
apiStatus.media.photos?.push(mediaObject);
} else if (mediaObject.type === 'video' || mediaObject.type === 'gif') {
apiTweet.embed_card = 'player';
apiTweet.media.videos = apiTweet.media?.videos ?? [];
apiTweet.media.videos?.push(mediaObject);
apiStatus.embed_card = 'player';
apiStatus.media.videos = apiStatus.media?.videos ?? [];
apiStatus.media.videos?.push(mediaObject);
} else {
console.log('Unknown media type', mediaObject.type);
}
@ -193,21 +192,21 @@ export const buildAPITweet = async (
/* Grab color palette data */
/*
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 */
if ((apiTweet?.media.photos?.length || 0) > 1 && !threadPiece) {
const mosaic = await handleMosaic(apiTweet.media?.photos || [], id);
if (typeof apiTweet.media !== 'undefined' && mosaic !== null) {
apiTweet.media.mosaic = mosaic;
if ((apiStatus?.media.photos?.length || 0) > 1 && !threadPiece) {
const mosaic = await handleMosaic(apiStatus.media?.photos || [], id);
if (typeof apiStatus.media !== 'undefined' && mosaic !== null) {
apiStatus.media.mosaic = mosaic;
}
}
// Add Tweet source but remove the link HTML tag
if (tweet.source) {
apiTweet.source = (tweet.source || '').replace(
// Add source but remove the link HTML tag
if (status.source) {
apiStatus.source = (status.source || '').replace(
/<a href="(.+?)" rel="nofollow">(.+?)<\/a>/,
'$2'
);
@ -215,34 +214,34 @@ export const buildAPITweet = async (
/* Populate a Twitter card */
if (tweet.card) {
const card = renderCard(tweet.card);
if (status.card) {
const card = renderCard(status.card);
if (card.external_media) {
apiTweet.media.external = card.external_media;
apiStatus.media.external = card.external_media;
}
if (card.poll) {
apiTweet.poll = card.poll;
apiStatus.poll = card.poll;
}
}
if (
apiTweet.media?.videos &&
apiTweet.media?.videos.length > 0 &&
apiTweet.embed_card !== 'player'
apiStatus.media?.videos &&
apiStatus.media?.videos.length > 0 &&
apiStatus.embed_card !== 'player'
) {
apiTweet.embed_card = 'player';
apiStatus.embed_card = 'player';
}
console.log('language?', language);
/* 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) {
console.log(`Attempting to translate Tweet to ${language}...`);
const translateAPI = await translateTweet(tweet, '', language, c);
if (typeof language === 'string' && language.length === 2 && language !== status.legacy.lang) {
console.log(`Attempting to translate status to ${language}...`);
const translateAPI = await translateStatus(status, '', language, c);
if (translateAPI !== null && translateAPI?.translation) {
apiTweet.translation = {
apiStatus.translation = {
text: unescapeText(
linkFixer(tweet.legacy?.entities?.urls, translateAPI?.translation || '')
linkFixer(status.legacy?.entities?.urls, translateAPI?.translation || '')
),
source_lang: translateAPI?.sourceLanguage || '',
target_lang: translateAPI?.destinationLanguage || '',
@ -253,16 +252,16 @@ export const buildAPITweet = async (
if (legacyAPI) {
// @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
apiTweet.color = null;
apiStatus.color = null;
// @ts-expect-error Use twitter_card for legacy API
delete apiTweet.embed_card;
if ((apiTweet.media.all?.length ?? 0) < 1 && !apiTweet.media.external) {
delete apiStatus.embed_card;
if ((apiStatus.media.all?.length ?? 0) < 1 && !apiStatus.media.external) {
// @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*/
const tweetRequest = async (c: Context) => await statusRequest(c);
const twitterStatusRequest = async (c: Context) => await statusRequest(c);
const _profileRequest = async (c: Context) => await profileRequest(c);
/* How can hono not handle trailing slashes? This is so stupid,
serious TODO: Figure out how to make this not stupid. */
twitter.get('/:endpoint{status(es)?}/:id', tweetRequest);
twitter.get('/:endpoint{status(es)?}/:id/', tweetRequest);
twitter.get('/:endpoint{status(es)?}/:id/:language/', tweetRequest);
twitter.get('/:endpoint{status(es)?}/:id/:language', tweetRequest);
twitter.get('/:endpoint{status(es)?}/:id', twitterStatusRequest);
twitter.get('/:endpoint{status(es)?}/:id/', twitterStatusRequest);
twitter.get('/:endpoint{status(es)?}/:id/:language/', twitterStatusRequest);
twitter.get('/:endpoint{status(es)?}/:id/:language', twitterStatusRequest);
twitter.get(
'/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:language',
tweetRequest
twitterStatusRequest
);
twitter.get(
'/: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/', tweetRequest);
twitter.get('/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id', twitterStatusRequest);
twitter.get('/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/', twitterStatusRequest);
twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:language',
tweetRequest
twitterStatusRequest
);
twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:language/',
tweetRequest
twitterStatusRequest
);
twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id',
tweetRequest
twitterStatusRequest
);
twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/',
tweetRequest
twitterStatusRequest
);
twitter.get(
'/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}',
tweetRequest
twitterStatusRequest
);
twitter.get(
'/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/',
tweetRequest
twitterStatusRequest
);
twitter.get(
'/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/:language',
tweetRequest
twitterStatusRequest
);
twitter.get(
'/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/:language/',
tweetRequest
twitterStatusRequest
);
twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}',
tweetRequest
twitterStatusRequest
);
twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/',
tweetRequest
twitterStatusRequest
);
twitter.get(
'/:prefix{(dir|dl)}/:handle{[0-9a-zA-Z_]+}/:endpoint{status(es)?}/:id/:mediaType{(photos?|videos?)}/:mediaNumber{[1-4]}/:language',
tweetRequest
twitterStatusRequest
);
twitter.get(
'/: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);

View file

@ -18,7 +18,7 @@ export const oembed = async (c: Context) => {
const data: OEmbed = {
author_name: text,
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:
searchParams.get('deprecated') === 'true' ? Strings.DEPRECATED_DOMAIN_NOTICE_DISCORD : name,
provider_url: url,

View file

@ -4,7 +4,7 @@ import { getBaseRedirectUrl } from '../router';
import { handleStatus } from '../../../embed/status';
import { Strings } from '../../../strings';
/* Handler for status (Tweet) request */
/* Handler for status request */
export const statusRequest = async (c: Context) => {
const { prefix, handle, id, mediaNumber, language } = c.req.param();
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;
/* 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
tweet ends in .mp4, .jpg, .jpeg, or .png
the status is prefixed with /dl/ or /dir/ (for TwitFix interop), or the
status ends in .mp4, .jpg, .jpeg, or .png
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
@ -63,7 +63,7 @@ export const statusRequest = async (c: Context) => {
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! */
if (
Constants.DEPRECATED_DOMAIN_LIST.includes(url.hostname) &&
@ -115,9 +115,9 @@ export const statusRequest = async (c: Context) => {
if (statusResponse) {
/* 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. */
if (!isBotUA && !flags.api && !flags.direct) {
const baseUrl = getBaseRedirectUrl(c);
@ -135,7 +135,7 @@ export const statusRequest = async (c: Context) => {
}
} else {
/* 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);
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 { 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? */
text.match(/@(\w{1,15})/g)?.forEach(match => {
const username = match.replace('@', '');
@ -16,10 +16,10 @@ const populateUserLinks = (tweet: APIStatus, text: string): string => {
return text;
};
const generateTweetMedia = (tweet: APIStatus): string => {
const generateStatusMedia = (status: APIStatus): string => {
let media = '';
if (tweet.media?.all?.length) {
tweet.media.all.forEach(mediaItem => {
if (status.media?.all?.length) {
status.media.all.forEach(mediaItem => {
switch (mediaItem.type) {
case 'photo':
// eslint-disable-next-line no-case-declarations
@ -30,10 +30,10 @@ const generateTweetMedia = (tweet: APIStatus): string => {
});
break;
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;
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;
}
});
@ -82,23 +82,23 @@ function paragraphify(text: string, isQuote = false): string {
.join('\n');
}
function getTranslatedText(tweet: APITweet, isQuote = false): string | null {
if (!tweet.translation) {
function getTranslatedText(status: APITwitterStatus, isQuote = false): string | null {
if (!status.translation) {
return null;
}
let text = paragraphify(sanitizeText(tweet.translation?.text), isQuote);
let text = paragraphify(sanitizeText(status.translation?.text), isQuote);
text = htmlifyLinks(text);
text = htmlifyHashtags(text);
text = populateUserLinks(tweet, text);
text = populateUserLinks(status, text);
const formatText =
tweet.translation.target_lang === 'en'
status.translation.target_lang === 'en'
? Strings.TRANSLATE_TEXT.format({
language: tweet.translation.source_lang_en
language: status.translation.source_lang_en
})
: Strings.TRANSLATE_TEXT_INTL.format({
source: tweet.translation.source_lang.toUpperCase(),
destination: tweet.translation.target_lang.toUpperCase()
source: status.translation.source_lang.toUpperCase(),
destination: status.translation.target_lang.toUpperCase()
});
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 { author } = tweet;
const generateStatusFooter = (status: APIStatus, isQuote = false): string => {
const { author } = status;
let description = author.description;
description = htmlifyLinks(description);
description = htmlifyHashtags(description);
description = populateUserLinks(tweet, description);
description = populateUserLinks(status, description);
return `
<p>{socialText}</p>
@ -131,8 +131,8 @@ const generateTweetFooter = (tweet: APIStatus, isQuote = false): string => {
<!-- Embed profile picture, display name, and screen name in table -->
{aboutSection}
`.format({
socialText: getSocialTextIV(tweet as APITweet) || '',
viewOriginal: !isQuote ? `<a href="${tweet.url}">View original post</a>` : notApplicableComment,
socialText: getSocialTextIV(status as APITwitterStatus) || '',
viewOriginal: !isQuote ? `<a href="${status.url}">View original post</a>` : notApplicableComment,
aboutSection: isQuote
? ''
: `<h2>About author</h2>
@ -144,7 +144,7 @@ const generateTweetFooter = (tweet: APIStatus, isQuote = false): string => {
<p>
{following} <b>Following</b>
{followers} <b>Followers</b>
{tweets} <b>Posts</b>
{statuses} <b>Posts</b>
</p>`.format({
pfp: `<img src="${author.avatar_url?.replace('_200x200', '_400x400')}" alt="${
author.name
@ -156,44 +156,44 @@ const generateTweetFooter = (tweet: APIStatus, isQuote = false): string => {
joined: author.joined ? `📆 ${formatDate(new Date(author.joined))}` : '',
following: truncateSocialCount(author.following),
followers: truncateSocialCount(author.followers),
tweets: truncateSocialCount(author.statuses)
statuses: truncateSocialCount(author.statuses)
})
});
};
const generateTweet = (tweet: APIStatus, isQuote = false): string => {
let text = paragraphify(sanitizeText(tweet.text), isQuote);
const generateStatus = (status: APIStatus, isQuote = false): string => {
let text = paragraphify(sanitizeText(status.text), isQuote);
text = htmlifyLinks(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 -->
{quoteHeader}
<!-- Embed Tweet media -->
${generateTweetMedia(tweet)}
<!-- Embed media -->
${generateStatusMedia(status)}
<!-- Translated text (if applicable) -->
${translatedText ? translatedText : notApplicableComment}
<!-- Embed Tweet text -->
<!-- Embed Status text -->
${text}
<!-- Embedded quote tweet -->
${!isQuote && tweet.quote ? generateTweet(tweet.quote, true) : notApplicableComment}
${!isQuote ? generateTweetFooter(tweet) : ''}
<br>${!isQuote ? `<a href="${tweet.url}">View original post</a>` : notApplicableComment}
<!-- Embedded quote status -->
${!isQuote && status.quote ? generateStatus(status.quote, true) : notApplicableComment}
${!isQuote ? generateStatusFooter(status) : ''}
<br>${!isQuote ? `<a href="${status.url}">View original post</a>` : notApplicableComment}
`.format({
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 => {
console.log('Generating Instant View...');
const { tweet, flags } = properties;
const { status, flags } = properties;
const instructions: ResponseInstructions = { addHeaders: [] };
/* 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.
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 */
instructions.addHeaders = [
`<meta property="al:android:app_name" content="Medium"/>`,
`<meta property="article:published_time" content="${postDate}"/>`,
`<meta property="article:published_time" content="${statusDate}"/>`,
flags?.archive
? `<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
? `${Constants.BRANDING_NAME} archive`
: '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>
<article>
<sub><a href="${tweet.url}">View original</a></sub>
<h1>${tweet.author.name} (@${tweet.author.screen_name})</h1>
<sub><a href="${status.url}">View original</a></sub>
<h1>${status.author.name} (@${status.author.screen_name})</h1>
${generateTweet(tweet)}
${generateStatus(status)}
</article>`;
return instructions;

View file

@ -5,15 +5,15 @@ export const renderPhoto = (
properties: RenderProperties,
photo: APIPhoto | APIMosaicPhoto
): ResponseInstructions => {
const { tweet, engagementText, authorText, isOverrideMedia, userAgent } = properties;
const { status, engagementText, authorText, isOverrideMedia, userAgent } = properties;
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;
const all = tweet.media?.all as APIMedia[];
const all = status.media?.all as APIMedia[];
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({
number: String(all.indexOf(photo) + 1),

View file

@ -6,10 +6,10 @@ export const renderVideo = (
properties: RenderProperties,
video: APIVideo
): ResponseInstructions => {
const { tweet, userAgent, text } = properties;
const { status, userAgent, text } = properties;
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,
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 */
if (all && all.length > 1 && (userAgent?.indexOf('Telegram') ?? 0) > -1) {
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({
number: String(all.indexOf(video) + 1),
total: String(all.length)
@ -41,10 +41,10 @@ export const renderVideo = (
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) {
instructions.authorText += `\n${handleQuote(tweet.quote)}`;
if (instructions.authorText.length < 40 && status.quote) {
instructions.authorText += `\n${handleQuote(status.quote)}`;
}
/* Push the raw video-related headers */

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

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

View file

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

View file

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