Merge remote-tracking branch 'origin/main' into telegram-instant-view

This commit is contained in:
dangered wolf 2023-08-17 17:29:28 -04:00
commit 29119ff75c
No known key found for this signature in database
GPG key ID: 41E4D37680ED8B58
27 changed files with 1990 additions and 1582 deletions

View file

@ -1,5 +1,4 @@
BRANDING_NAME = "FixTweet"
BRANDING_NAME_DISCORD = "FixTweet - Embed videos, polls & more!"
DIRECT_MEDIA_DOMAINS = "d.fxtwitter.com,dl.fxtwitter.com,d.pxtwitter.com,d.twittpr.com,dl.pxtwitter.com,dl.twittpr.com"
TEXT_ONLY_DOMAINS = "t.fxtwitter.com,t.twittpr.com"
DEPRECATED_DOMAIN_LIST = "pxtwitter.com,www.pxtwitter.com"

View file

@ -1,6 +1,6 @@
# FixTweet <img src="https://abs-0.twimg.com/emoji/v2/svg/1f527.svg" height="28">
# FixTweet / FixupX <img src="https://abs-0.twimg.com/emoji/v2/svg/1f527.svg" height="28">
## Embed Twitter videos, polls, translations, & more on Discord and Telegram!
## Embed X / Twitter videos, polls, translations, & more on Discord and Telegram!
![][icons]
@ -20,11 +20,14 @@
[licensebadge]: https://img.shields.io/github/license/FixTweet/FixTweet
[uptimebadge]: https://img.shields.io/uptimerobot/ratio/m792476277-53add6f22c4e6f4d3a2d7e98
## Inspired by [Robin Universe's TwitFix](https://github.com/robinuniverse/TwitFix), rewritten in TypeScript as a Cloudflare Worker to scale, packed with even more features and [best-in-class user privacy 🔒](#built-with-privacy-in-mind).
## Written in TypeScript as a Cloudflare Worker to scale, packed with more features and [best-in-class user privacy 🔒](#built-with-privacy-in-mind).
### Add `fx` before your Twitter link to make it `fxtwitter.com`
### Add `fx` before your `twitter.com` link to make it `fxtwitter.com`, OR
### Change `x.com` to `fixupx.com` in your link
### In a hurry? On Discord, send a Twitter link and type `s/e/p` to make `twittpr.com`.
### For Twitter links on Discord, send a Twitter link and type `s/e/p` to make `twittpr.com`.
### Note: Some extra features described may currently broken due to recent Twitter/X API changes. [Tracking thread for the API changes](https://github.com/FixTweet/FixTweet/issues/333)
<img src="https://cdn.discordapp.com/attachments/165560751363325952/1006346785985417307/fixtweet.webp">

View file

@ -5,7 +5,6 @@
},
"globals": {
"BRANDING_NAME": "FixTweet",
"BRANDING_NAME_DISCORD": "FixTweetBrandingDiscord",
"TEXT_ONLY_DOMAINS": "t.fxtwitter.com,t.twittpr.com",
"DIRECT_MEDIA_DOMAINS": "d.fxtwitter.com,dl.fxtwitter.com",
"MOSAIC_DOMAIN_LIST": "mosaic.fxtwitter.com",

View file

@ -1,4 +1,3 @@
{
"FixTweet": "https://github.com/FixTweet/FixTweet",
"FixTweet - Embed videos, polls & more": "https://github.com/FixTweet/FixTweet"
"FixupX / FixTweet - Recovering from API woes": "https://github.com/FixTweet/FixTweet"
}

2486
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,9 +5,10 @@
"main": "dist/worker.js",
"scripts": {
"build": "webpack",
"publish": "wrangler publish",
"publish": "wrangler deploy",
"deploy": "wrangler deploy",
"log": "wrangler tail",
"reload": "wrangler publish && wrangler tail",
"reload": "wrangler deploy && wrangler tail",
"prettier": "prettier --write .",
"lint:eslint": "eslint --max-warnings=0 src",
"test": "jest --config jestconfig.json --verbose"
@ -15,27 +16,27 @@
"author": "dangered wolf",
"license": "MIT",
"devDependencies": {
"@cloudflare/workers-types": "^4.20230511.0",
"@cloudflare/workers-types": "^4.20230814.0",
"@microsoft/eslint-formatter-sarif": "^3.0.0",
"@sentry/webpack-plugin": "^1.20.1",
"@types/jest": "^29.5.1",
"@typescript-eslint/eslint-plugin": "^5.59.5",
"@typescript-eslint/parser": "^5.59.5",
"dotenv": "^16.0.3",
"eslint": "^8.40.0",
"eslint-config-prettier": "^8.8.0",
"@sentry/webpack-plugin": "^2.6.2",
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"dotenv": "^16.3.1",
"eslint": "^8.47.0",
"eslint-config-prettier": "^8.10.0",
"eslint-config-typescript": "^3.0.0",
"eslint-plugin-optimize-regex": "^1.2.1",
"eslint-plugin-sonarjs": "^0.19.0",
"jest": "^29.5.0",
"eslint-plugin-sonarjs": "^0.20.0",
"jest": "^29.6.2",
"jest-environment-miniflare": "^2.14.0",
"prettier": "^2.8.8",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.2",
"typescript": "^5.0.4",
"webpack": "^5.82.1",
"webpack-cli": "^5.1.1",
"wrangler": "^3.0.1"
"prettier": "^3.0.2",
"ts-jest": "^29.1.1",
"ts-loader": "^9.4.4",
"typescript": "^5.1.6",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"wrangler": "^3.5.1"
},
"dependencies": {
"itty-router": "^3.0.12",

View file

@ -7,82 +7,91 @@ import { colorFromPalette } from '../helpers/palette';
import { translateTweet } from '../helpers/translate';
import { unescapeText } from '../helpers/utils';
import { processMedia } from '../helpers/media';
import { convertToApiUser } from './user';
import { isGraphQLTweet, isGraphQLTweetNotFoundResponse } from '../utils/graphql';
/* This function does the heavy lifting of processing data from Twitter API
and using it to create FixTweet's streamlined API responses */
const populateTweetProperties = async (
tweet: TweetPartial,
conversation: TimelineBlobPartial,
tweet: GraphQLTweet,
conversation: any, // TimelineBlobPartial,
language: string | undefined
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APITweet> => {
const apiTweet = {} as APITweet;
if (typeof tweet.core === 'undefined' && typeof tweet.result !== 'undefined') {
tweet = tweet.result;
} else {
console.log('tweet core exists');
}
/* With v2 conversation API we re-add the user object ot the tweet because
Twitter stores it separately in the conversation API. This is to consolidate
it in case a user appears multiple times in a thread. */
tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {};
const user = tweet.user as UserPartial;
const screenName = user?.screen_name || '';
const name = user?.name || '';
const graphQLUser = tweet.core.user_results.result;
const apiUser = convertToApiUser(graphQLUser);
/* Populating a lot of the basics */
apiTweet.url = `${Constants.TWITTER_ROOT}/${screenName}/status/${tweet.id_str}`;
apiTweet.id = tweet.id_str;
apiTweet.text = unescapeText(linkFixer(tweet, tweet.full_text || ''));
apiTweet.url = `${Constants.TWITTER_ROOT}/${apiUser.screen_name}/status/${tweet.rest_id}`;
apiTweet.id = tweet.rest_id;
apiTweet.text = unescapeText(linkFixer(tweet, tweet.legacy.full_text || ''));
apiTweet.author = {
id: tweet.user_id_str,
name: name,
screen_name: screenName,
id: apiUser.id,
name: apiUser.name,
screen_name: apiUser.screen_name,
avatar_url:
(user?.profile_image_url_https || '').replace('_normal', '_200x200') || '',
avatar_color: colorFromPalette(
(apiUser.avatar_url || '').replace('_normal', '_200x200') || '',
avatar_color: '0000FF' /* colorFromPalette(
tweet.user?.profile_image_extensions_media_color?.palette || []
),
banner_url: user?.profile_banner_url || ''
),*/,
banner_url: apiUser.banner_url || ''
};
apiTweet.replies = tweet.reply_count;
apiTweet.retweets = tweet.retweet_count;
apiTweet.likes = tweet.favorite_count;
apiTweet.replies = tweet.legacy.reply_count;
apiTweet.retweets = tweet.legacy.retweet_count;
apiTweet.likes = tweet.legacy.favorite_count;
apiTweet.color = apiTweet.author.avatar_color;
apiTweet.twitter_card = 'tweet';
apiTweet.created_at = tweet.created_at;
apiTweet.created_timestamp = new Date(tweet.created_at).getTime() / 1000;
apiTweet.created_at = tweet.legacy.created_at;
apiTweet.created_timestamp = new Date(tweet.legacy.created_at).getTime() / 1000;
apiTweet.possibly_sensitive = tweet.possibly_sensitive;
apiTweet.possibly_sensitive = tweet.legacy.possibly_sensitive;
if (tweet.ext_views?.state === 'EnabledWithCount') {
apiTweet.views = parseInt(tweet.ext_views.count || '0') ?? null;
if (tweet.views.state === 'EnabledWithCount') {
apiTweet.views = parseInt(tweet.views.count || '0') ?? null;
} else {
apiTweet.views = null;
}
if (tweet.lang !== 'unk') {
apiTweet.lang = tweet.lang;
if (tweet.legacy.lang !== 'unk') {
apiTweet.lang = tweet.legacy.lang;
} else {
apiTweet.lang = null;
}
apiTweet.replying_to = tweet.in_reply_to_screen_name || null;
apiTweet.replying_to_status = tweet.in_reply_to_status_id_str || null;
apiTweet.replying_to = tweet.legacy?.in_reply_to_screen_name || null;
apiTweet.replying_to_status = tweet.legacy?.in_reply_to_status_id_str || null;
const mediaList = Array.from(
tweet.extended_entities?.media || tweet.entities?.media || []
tweet.legacy.extended_entities?.media || tweet.legacy.entities?.media || []
);
// console.log('tweet', JSON.stringify(tweet));
/* Populate this Tweet's media */
mediaList.forEach(media => {
const mediaObject = processMedia(media);
if (mediaObject) {
apiTweet.media = apiTweet.media || {};
apiTweet.media.all = apiTweet.media?.all || [];
apiTweet.media.all.push(mediaObject);
if (mediaObject.type === 'photo') {
apiTweet.twitter_card = 'summary_large_image';
apiTweet.media = apiTweet.media || {};
apiTweet.media.photos = apiTweet.media.photos || [];
apiTweet.media.photos.push(mediaObject);
} else if (mediaObject.type === 'video' || mediaObject.type === 'gif') {
apiTweet.twitter_card = 'player';
apiTweet.media = apiTweet.media || {};
apiTweet.media.videos = apiTweet.media.videos || [];
apiTweet.media.videos.push(mediaObject);
}
@ -90,13 +99,22 @@ const populateTweetProperties = async (
});
/* Grab color palette data */
/*
if (mediaList[0]?.ext_media_color?.palette) {
apiTweet.color = colorFromPalette(mediaList[0].ext_media_color.palette);
}
*/
console.log('note_tweet', JSON.stringify(tweet.note_tweet));
const noteTweetText = tweet.note_tweet?.note_tweet_results?.result?.text;
/* For now, don't include note tweets */
if (noteTweetText && mediaList.length <= 0 && tweet.legacy.entities?.urls?.length <= 0) {
console.log('We meet the conditions to use new note tweets');
apiTweet.text = unescapeText(noteTweetText);
}
/* Handle photos and mosaic if available */
if ((apiTweet.media?.photos?.length || 0) > 1) {
const mosaic = await handleMosaic(apiTweet.media?.photos || [], tweet.id_str);
const mosaic = await handleMosaic(apiTweet.media?.photos || [], tweet.rest_id);
if (typeof apiTweet.media !== 'undefined' && mosaic !== null) {
apiTweet.media.mosaic = mosaic;
}
@ -111,8 +129,9 @@ const populateTweetProperties = async (
}
/* Populate a Twitter card */
if (tweet.card) {
const card = await renderCard(tweet.card);
const card = renderCard(tweet.card);
if (card.external_media) {
apiTweet.twitter_card = 'summary_large_image';
apiTweet.media = apiTweet.media || {};
@ -124,7 +143,7 @@ const populateTweetProperties = async (
}
/* If a language is specified in API or by user, let's try translating it! */
if (typeof language === 'string' && language.length === 2 && language !== tweet.lang) {
if (typeof language === 'string' && language.length === 2 && language !== tweet.legacy.lang) {
const translateAPI = await translateTweet(
tweet,
conversation.guestToken || '',
@ -182,59 +201,50 @@ export const statusAPI = async (
event: FetchEvent,
flags?: InputFlags
): Promise<TweetAPIResponse> => {
let conversation = await fetchConversation(status, event);
let tweet = conversation?.globalObjects?.tweets?.[status] || {};
let wasMediaBlockedNSFW = false;
if (tweet.retweeted_status_id_str) {
tweet = conversation?.globalObjects?.tweets?.[tweet.retweeted_status_id_str] || {};
let res = await fetchConversation(status, event);
const tweet = res.data?.tweetResult?.result;
if (!tweet) {
return { code: 404, message: 'NOT_FOUND' };
}
if (tweet.__typename === 'TweetUnavailable' && tweet.reason === 'NsfwLoggedOut') {
wasMediaBlockedNSFW = true;
res = await fetchConversation(status, event, true);
}
/* Fallback for if Tweet did not load (i.e. NSFW) */
if (typeof tweet.full_text === 'undefined') {
if (conversation.timeline?.instructions?.length > 0) {
/* Try again using elongator API proxy */
console.log('No Tweet was found, loading again from elongator');
conversation = await fetchConversation(status, event, true);
tweet = conversation?.globalObjects?.tweets?.[status] || {};
if (typeof tweet.full_text !== 'undefined') {
console.log('Successfully loaded Tweet using elongator');
wasMediaBlockedNSFW = true;
} else if (
typeof tweet.full_text === 'undefined' &&
conversation.timeline?.instructions?.length > 0
) {
console.log(
'Tweet could not be accessed with elongator, must be private/suspende, got tweet ',
tweet,
' conversation ',
conversation
);
writeDataPoint(event, language, wasMediaBlockedNSFW, 'PRIVATE_TWEET', flags);
return { code: 401, message: 'PRIVATE_TWEET' };
}
// console.log(JSON.stringify(tweet))
if (tweet.__typename === 'TweetUnavailable') {
if (tweet.reason === 'Protected') {
writeDataPoint(event, language, wasMediaBlockedNSFW, 'PRIVATE_TWEET', flags);
return { code: 401, message: 'PRIVATE_TWEET' };
// } else if (tweet.reason === 'NsfwLoggedOut') {
// // API failure as elongator should have handled this
// writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
// return { code: 500, message: 'API_FAIL' };
} else {
/* {"errors":[{"code":34,"message":"Sorry, that page does not exist."}]} */
if (conversation.errors?.[0]?.code === 34) {
writeDataPoint(event, language, wasMediaBlockedNSFW, 'NOT_FOUND', flags);
return { code: 404, message: 'NOT_FOUND' };
}
/* Tweets object is completely missing, smells like API failure */
if (typeof conversation?.globalObjects?.tweets === 'undefined') {
writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
return { code: 500, message: 'API_FAIL' };
}
/* If we have no idea what happened then just return API error */
// Api failure at parsing status
writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
return { code: 500, message: 'API_FAIL' };
}
}
// If the tweet is not a graphQL tweet something went wrong
if (!isGraphQLTweet(tweet)) {
console.log('Tweet was not a valid tweet', tweet);
writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
return { code: 500, message: 'API_FAIL' };
}
/*
if (tweet.retweeted_status_id_str) {
tweet = conversation?.globalObjects?.tweets?.[tweet.retweeted_status_id_str] || {};
}
*/
if (!tweet) {
return { code: 404, message: 'NOT_FOUND' };
}
const conversation: any[] = [];
/* Creating the response objects */
const response: TweetAPIResponse = { code: 200, message: 'OK' } as TweetAPIResponse;
const apiTweet: APITweet = (await populateTweetProperties(
@ -244,8 +254,7 @@ export const statusAPI = async (
)) as APITweet;
/* We found a quote tweet, let's process that too */
const quoteTweet =
conversation.globalObjects?.tweets?.[tweet.quoted_status_id_str || '0'] || null;
const quoteTweet = tweet.quoted_status_result;
if (quoteTweet) {
apiTweet.quote = (await populateTweetProperties(
quoteTweet,

View file

@ -1,15 +1,8 @@
import { Constants } from '../constants';
import { fetchUser } from '../fetch';
/* This function does the heavy lifting of processing data from Twitter API
and using it to create FixTweet's streamlined API responses */
const populateUserProperties = async (
response: GraphQLUserResponse
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APIUser> => {
export const convertToApiUser = (user: GraphQLUser): APIUser => {
const apiUser = {} as APIUser;
const user = response.data.user.result;
/* Populating a lot of the basics */
apiUser.url = `${Constants.TWITTER_ROOT}/${user.legacy.screen_name}`;
apiUser.id = user.rest_id;
@ -21,6 +14,7 @@ const populateUserProperties = async (
apiUser.screen_name = user.legacy.screen_name;
apiUser.description = user.legacy.description;
apiUser.location = user.legacy.location;
apiUser.banner_url = user.legacy.profile_banner_url;
/*
if (user.is_blue_verified) {
apiUser.verified = 'blue';
@ -51,6 +45,16 @@ const populateUserProperties = async (
return apiUser;
};
/* This function does the heavy lifting of processing data from Twitter API
and using it to create FixTweet's streamlined API responses */
const populateUserProperties = async (
response: GraphQLUserResponse
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APIUser> => {
const user = response.data.user.result;
return convertToApiUser(user);
};
/* API for Twitter profiles (Users)
Used internally by FixTweet's embed service, or
available for free using api.fxtwitter.com. */
@ -60,7 +64,12 @@ export const userAPI = async (
flags?: InputFlags
): Promise<UserAPIResponse> => {
const userResponse = await fetchUser(username, event);
if (!userResponse || !Object.keys(userResponse).length) {
return {
code: 404,
message: 'User not found'
};
}
/* Creating the response objects */
const response: UserAPIResponse = { code: 200, message: 'OK' } as UserAPIResponse;
const apiUser: APIUser = (await populateUserProperties(userResponse)) as APIUser;

View file

@ -1,7 +1,6 @@
export const Constants = {
/* These constants are populated by variables in .env, then set by Webpack */
BRANDING_NAME: BRANDING_NAME,
BRANDING_NAME_DISCORD: BRANDING_NAME_DISCORD,
DIRECT_MEDIA_DOMAINS: DIRECT_MEDIA_DOMAINS.split(','),
TEXT_ONLY_DOMAINS: TEXT_ONLY_DOMAINS.split(','),
DEPRECATED_DOMAIN_LIST: DEPRECATED_DOMAIN_LIST.split(','),
@ -21,7 +20,7 @@ export const Constants = {
GUEST_TOKEN_MAX_AGE: 3 * 60 * 60,
/* Twitter Web App actually uses Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA
instead, but accounts marked as 18+ wouldn't show up then */
GUEST_BEARER_TOKEN: `Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw`,
GUEST_BEARER_TOKEN: `Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA`,
GUEST_FETCH_PARAMETERS: [
'cards_platform=Web-12',
'include_cards=1',
@ -30,10 +29,13 @@ export const Constants = {
'include_quote_count=true',
'include_reply_count=1',
'tweet_mode=extended',
'include_entities=true',
'include_ext_media_color=true',
'include_ext_media_availability=true',
'include_ext_sensitive_media_warning=true',
'simple_quoted_tweet=true'
'include_ext_has_birdwatch_notes=true',
'simple_quoted_tweet=true',
'ext=mediaStats%2ChighlightedLabel'
].join('&'),
BASE_HEADERS: {
'DNT': `1`,

View file

@ -4,6 +4,8 @@ import { formatNumber, sanitizeText } from '../helpers/utils';
import { Strings } from '../strings';
import { getAuthorText } from '../helpers/author';
import { statusAPI } from '../api/status';
import { renderPhoto } from '../render/photo';
import { renderVideo } from '../render/video';
export const returnError = (error: string): StatusResponse => {
return {
@ -47,6 +49,13 @@ export const handleStatus = async (
};
}
let overrideMedia: APIMedia | undefined;
// Check if mediaNumber exists, and if that media exists in tweet.media.all. If it does, we'll store overrideMedia variable
if (mediaNumber && tweet.media && tweet.media.all && tweet.media.all[mediaNumber - 1]) {
overrideMedia = tweet.media.all[mediaNumber - 1];
}
/* If there was any errors fetching the Tweet, we'll return it */
switch (api.code) {
case 401:
@ -54,19 +63,29 @@ export const handleStatus = async (
case 404:
return returnError(Strings.ERROR_TWEET_NOT_FOUND);
case 500:
console.log(api);
return returnError(Strings.ERROR_API_FAIL);
}
/* Catch direct media request (d.fxtwitter.com, or .mp4 / .jpg) */
if (flags?.direct && tweet.media) {
let redirectUrl: string | null = null;
if (tweet.media.videos) {
const { videos } = tweet.media;
redirectUrl = (videos[(mediaNumber || 1) - 1] || videos[0]).url;
} else if (tweet.media.photos) {
const { photos } = tweet.media;
redirectUrl = (photos[(mediaNumber || 1) - 1] || photos[0]).url;
const all = tweet.media.all || [];
// if (tweet.media.videos) {
// const { videos } = tweet.media;
// redirectUrl = (videos[(mediaNumber || 1) - 1] || videos[0]).url;
// } else if (tweet.media.photos) {
// const { photos } = tweet.media;
// redirectUrl = (photos[(mediaNumber || 1) - 1] || photos[0]).url;
// }
const selectedMedia = all[(mediaNumber || 1) - 1];
if (selectedMedia) {
redirectUrl = selectedMedia.url;
} else if (all.length > 0) {
redirectUrl = all[0].url;
}
if (redirectUrl) {
return { response: Response.redirect(redirectUrl, 302) };
}
@ -143,127 +162,82 @@ export const handleStatus = async (
newText = `${formatText}\n\n` + `${translation.text}\n\n`;
}
/* This Tweet has a video to render.
console.log('overrideMedia', JSON.stringify(overrideMedia));
Twitter supports multiple videos in a Tweet now. But we have no mechanism to embed more than one.
You can still use /video/:number to get a specific video. Otherwise, it'll pick the first. */
if (tweet.media?.videos) {
authorText = newText || '';
if (overrideMedia) {
let instructions: ResponseInstructions;
if (tweet?.translation) {
authorText = tweet.translation?.text || '';
switch (overrideMedia.type) {
case 'photo':
/* This Tweet has a photo to render. */
instructions = renderPhoto(
{
tweet: tweet,
authorText: authorText,
engagementText: engagementText,
userAgent: userAgent,
isOverrideMedia: true
},
overrideMedia as APIPhoto
);
headers.push(...instructions.addHeaders);
if (instructions.authorText) {
authorText = instructions.authorText;
}
if (instructions.siteName) {
siteName = instructions.siteName;
}
break;
case 'video':
instructions = renderVideo(
{ tweet: tweet, userAgent: userAgent, text: newText, isOverrideMedia: true },
overrideMedia as APIVideo
);
headers.push(...instructions.addHeaders);
if (instructions.authorText) {
authorText = instructions.authorText;
}
if (instructions.siteName) {
siteName = instructions.siteName;
}
/* This Tweet has a video to render. */
break;
}
const { videos } = tweet.media;
const video = videos[(mediaNumber || 1) - 1];
/* This fix is specific to Discord not wanting to render videos that are too large,
or rendering low quality videos too small.
Basically, our solution is to cut the dimensions in half if the video is too big (> 1080p),
or double them if it's too small. (<400p)
We check both height and width so we can apply this to both horizontal and vertical videos equally*/
let sizeMultiplier = 1;
if (video.width > 1920 || video.height > 1920) {
sizeMultiplier = 0.5;
}
if (video.width < 400 && video.height < 400) {
sizeMultiplier = 2;
}
/* Like photos when picking a specific one (not using mosaic),
we'll put an indicator if there are more than one video */
if (videos.length > 1) {
const videoCounter = Strings.VIDEO_COUNT.format({
number: String(videos.indexOf(video) + 1),
total: String(videos.length)
});
authorText =
authorText === Strings.DEFAULT_AUTHOR_TEXT
? videoCounter
: `${authorText}${authorText ? ' ― ' : ''}${videoCounter}`;
siteName = `${Constants.BRANDING_NAME} - ${videoCounter}`;
if (engagementText) {
siteName = `${Constants.BRANDING_NAME} - ${engagementText} - ${videoCounter}`;
}
}
/* Push the raw video-related headers */
headers.push(
`<meta property="twitter:player:stream:content_type" content="${video.format}"/>`,
`<meta property="twitter:player:height" content="${
video.height * sizeMultiplier
}"/>`,
`<meta property="twitter:player:width" content="${video.width * sizeMultiplier}"/>`,
`<meta property="og:video" content="${video.url}"/>`,
`<meta property="og:video:secure_url" content="${video.url}"/>`,
`<meta property="og:video:height" content="${video.height * sizeMultiplier}"/>`,
`<meta property="og:video:width" content="${video.width * sizeMultiplier}"/>`,
`<meta property="og:video:type" content="${video.format}"/>`,
`<meta property="twitter:image" content="0"/>`
} else if (tweet.media?.mosaic) {
const instructions = renderPhoto(
{
tweet: tweet,
authorText: authorText,
engagementText: engagementText,
userAgent: userAgent
},
tweet.media?.mosaic
);
}
/* This Tweet has one or more photos to render */
if (tweet.media?.photos) {
const { photos } = tweet.media;
let photo: APIPhoto | APIMosaicPhoto = photos[(mediaNumber || 1) - 1];
/* If there isn't a specified media number and we have a
mosaic response, we'll render it using mosaic */
if (typeof mediaNumber !== 'number' && tweet.media.mosaic) {
photo = {
/* Include dummy height/width for TypeScript reasons. We have a check to make sure we don't use these later. */
height: 0,
width: 0,
url: tweet.media.mosaic.formats.jpeg,
type: 'photo',
altText: ''
};
/* If mosaic isn't available or the link calls for a specific photo,
we'll indicate which photo it is out of the total */
} else if (photos.length > 1) {
const photoCounter = Strings.PHOTO_COUNT.format({
number: String(photos.indexOf(photo) + 1),
total: String(photos.length)
});
authorText =
authorText === Strings.DEFAULT_AUTHOR_TEXT
? photoCounter
: `${authorText}${authorText ? ' ― ' : ''}${photoCounter}`;
siteName = `${Constants.BRANDING_NAME} - ${photoCounter}`;
if (engagementText) {
siteName = `${Constants.BRANDING_NAME} - ${engagementText} - ${photoCounter}`;
}
}
/* Push the raw photo-related headers */
headers.push(
`<meta property="twitter:image" content="${photo.url}"/>`,
`<meta property="og:image" content="${photo.url}"/>`
headers.push(...instructions.addHeaders);
} else if (tweet.media?.videos) {
const instructions = renderVideo(
{ tweet: tweet, userAgent: userAgent, text: newText },
tweet.media?.videos[0]
);
if (!tweet.media.mosaic) {
headers.push(
`<meta property="twitter:image:width" content="${photo.width}"/>`,
`<meta property="twitter:image:height" content="${photo.height}"/>`,
`<meta property="og:image:width" content="${photo.width}"/>`,
`<meta property="og:image:height" content="${photo.height}"/>`
);
headers.push(...instructions.addHeaders);
if (instructions.authorText) {
authorText = instructions.authorText;
}
}
/* We have external media available to us (i.e. YouTube videos) */
if (tweet.media?.external) {
if (instructions.siteName) {
siteName = instructions.siteName;
}
} else if (tweet.media?.photos) {
const instructions = renderPhoto(
{
tweet: tweet,
authorText: authorText,
engagementText: engagementText,
userAgent: userAgent
},
tweet.media?.photos[0]
);
headers.push(...instructions.addHeaders);
} else if (tweet.media?.external) {
const { external } = tweet.media;
authorText = newText || '';
headers.push(
@ -335,6 +309,10 @@ export const handleStatus = async (
);
}
}
if (!flags?.isXDomain) {
siteName = Strings.X_DOMAIN_NOTICE;
}
/* Notice that user is using deprecated domain */
if (flags?.deprecated) {
@ -353,7 +331,7 @@ export const handleStatus = async (
/* Push basic headers relating to author, Tweet text, and site name */
headers.push(
`<meta property="og:title" content="${tweet.author.name} (@${tweet.author.screen_name})"/>`,
`<meta property="og:description" content="${text}"/>`,
`<meta property="og:description" content="${sanitizeText(newText).replace(/\n/g, '<br>')}"/>`,
`<meta property="og:site_name" content="${siteName}"/>`
);
@ -377,7 +355,7 @@ export const handleStatus = async (
status
)}&author=${encodeURIComponent(
tweet.author?.screen_name || ''
)}" type="application/json+oembed" title="${tweet.author.name}">`
)}&useXbranding=${flags?.isXDomain ? 'true' : 'false'}" type="application/json+oembed" title="${tweet.author.name}">`
);
/* When dealing with a Tweet of unknown lang, fall back to en */

View file

@ -1,7 +1,14 @@
import { Constants } from './constants';
import { generateUserAgent } from './helpers/useragent';
import { isGraphQLTweet } from './utils/graphql';
const API_ATTEMPTS = 16;
const API_ATTEMPTS = 3;
function generateCSRFToken() {
const randomBytes = new Uint8Array(160/2);
crypto.getRandomValues(randomBytes);
return Array.from(randomBytes, byte => byte.toString(16).padStart(2, '0')).join('');
}
export const twitterFetch = async (
url: string,
@ -51,14 +58,11 @@ export const twitterFetch = async (
const cache = caches.default;
while (apiAttempts < API_ATTEMPTS) {
const csrfToken = crypto
.randomUUID()
.replace(
/-/g,
''
); /* Generate a random CSRF token, this doesn't matter, Twitter just cares that header and cookie match */
/* Generate a random CSRF token, Twitter just cares that header and cookie match,
REST can use shorter csrf tokens (32 bytes) but graphql prefers 160 bytes */
const csrfToken = generateCSRFToken();
const headers: { [header: string]: string } = {
const headers: Record<string, string> = {
Authorization: Constants.GUEST_BEARER_TOKEN,
...Constants.BASE_HEADERS
};
@ -130,7 +134,7 @@ export const twitterFetch = async (
headers: headers
});
}
response = await apiRequest?.json();
} catch (e: unknown) {
/* We'll usually only hit this if we get an invalid response from Twitter.
@ -188,20 +192,61 @@ export const fetchConversation = async (
status: string,
event: FetchEvent,
useElongator = false
): Promise<TimelineBlobPartial> => {
): Promise<TweetResultsByRestIdResult> => {
return (await twitterFetch(
`${Constants.TWITTER_API_ROOT}/2/timeline/conversation/${status}.json?${Constants.GUEST_FETCH_PARAMETERS}`,
`${
Constants.TWITTER_ROOT
}/i/api/graphql/2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId?variables=${encodeURIComponent(
JSON.stringify({"tweetId": status,"withCommunity":false,"includePromotedContent":false,"withVoice":false})
)}&features=${encodeURIComponent(
JSON.stringify({
creator_subscriptions_tweet_preview_api_enabled:true,
tweetypie_unmention_optimization_enabled:true,
responsive_web_edit_tweet_api_enabled:true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled:true,
view_counts_everywhere_api_enabled:true,
longform_notetweets_consumption_enabled:true,
responsive_web_twitter_article_tweet_consumption_enabled:false,
tweet_awards_web_tipping_enabled:false,
freedom_of_speech_not_reach_fetch_enabled:true,
standardized_nudges_misinfo:true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled:true,
longform_notetweets_rich_text_read_enabled:true,
longform_notetweets_inline_media_enabled:true,
responsive_web_graphql_exclude_directive_enabled:true,
verified_phone_label_enabled:false,
responsive_web_media_download_video_enabled:false,
responsive_web_graphql_skip_user_profile_image_extensions_enabled:false,
responsive_web_graphql_timeline_navigation_enabled:true,
responsive_web_enhance_cards_enabled:false})
)}&fieldToggles=${encodeURIComponent(
JSON.stringify({
// TODO Figure out what this property does
withArticleRichContentState: false
})
)}`,
event,
useElongator,
(_conversation: unknown) => {
const conversation = _conversation as TimelineBlobPartial;
return !(
typeof conversation.globalObjects === 'undefined' &&
(typeof conversation.errors === 'undefined' ||
conversation.errors?.[0]?.code === 239)
);
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)) {
return true;
}
if (tweet?.__typename === 'TweetUnavailable' && tweet.reason === 'Protected') {
return true;
}
if (tweet?.__typename === 'TweetUnavailable' && tweet.reason === 'NsfwLoggedOut') {
return true;
}
if (tweet?.__typename === 'TweetUnavailable') {
return true;
}
// Final clause for checking if it's valid is if there's errors
return Array.isArray(conversation.errors)
}
)) as TimelineBlobPartial;
)) as TweetResultsByRestIdResult;
};
export const fetchUser = async (
@ -231,6 +276,10 @@ export const fetchUser = async (
// Validator function
(_res: unknown) => {
const response = _res as GraphQLUserResponse;
// If _res.data is an empty object, we have no user
if (!Object.keys(response?.data).length) {
return false;
}
return !(
response?.data?.user?.result?.__typename !== 'User' ||
typeof response.data.user.result.legacy === 'undefined'

View file

@ -1,34 +1,44 @@
import { calculateTimeLeftString } from './pollTime';
/* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */
export const renderCard = async (
card: TweetCard
): Promise<{ poll?: APIPoll; external_media?: APIExternalMedia }> => {
const values = card.binding_values;
export const renderCard = (
card: GraphQLTweet['card']
): { poll?: APIPoll; external_media?: APIExternalMedia } => {
// We convert the binding_values array into an object with the legacy format
// TODO Clean this up
const binding_values: Record<string, { string_value?: string; boolean_value?: boolean }> = {};
if (Array.isArray(card.legacy.binding_values)) {
card.legacy.binding_values.forEach(value => {
if (value.key && value.value) {
binding_values[value.key] = value.value;
}
});
}
console.log('rendering card');
if (typeof values !== 'undefined') {
if (typeof values.choice1_count !== 'undefined') {
if (typeof binding_values !== 'undefined') {
if (typeof binding_values.choice1_count !== 'undefined') {
const poll = {} as APIPoll;
poll.ends_at = values.end_datetime_utc?.string_value || '';
poll.ends_at = binding_values.end_datetime_utc?.string_value || '';
poll.time_left_en = calculateTimeLeftString(
new Date(values.end_datetime_utc?.string_value || '')
new Date(binding_values.end_datetime_utc?.string_value || '')
);
const choices: { [label: string]: number } = {
[values.choice1_label?.string_value || '']: parseInt(
values.choice1_count?.string_value || '0'
[binding_values.choice1_label?.string_value || '']: parseInt(
binding_values.choice1_count?.string_value || '0'
),
[values.choice2_label?.string_value || '']: parseInt(
values.choice2_count?.string_value || '0'
[binding_values.choice2_label?.string_value || '']: parseInt(
binding_values.choice2_count?.string_value || '0'
),
[values.choice3_label?.string_value || '']: parseInt(
values.choice3_count?.string_value || '0'
[binding_values.choice3_label?.string_value || '']: parseInt(
binding_values.choice3_count?.string_value || '0'
),
[values.choice4_label?.string_value || '']: parseInt(
values.choice4_count?.string_value || '0'
[binding_values.choice4_label?.string_value || '']: parseInt(
binding_values.choice4_count?.string_value || '0'
)
};
@ -46,17 +56,17 @@ export const renderCard = async (
});
return { poll: poll };
} else if (typeof values.player_url !== 'undefined') {
} else if (typeof binding_values.player_url !== 'undefined' && binding_values.player_url.string_value) {
/* Oh good, a non-Twitter video URL! This enables YouTube embeds and stuff to just work */
return {
external_media: {
type: 'video',
url: values.player_url.string_value,
url: binding_values.player_url.string_value,
width: parseInt(
(values.player_width?.string_value || '1280').replace('px', '')
(binding_values.player_width?.string_value || '1280').replace('px', '')
), // TODO: Replacing px might not be necessary, it's just there as a precaution
height: parseInt(
(values.player_height?.string_value || '720').replace('px', '')
(binding_values.player_height?.string_value || '720').replace('px', '')
)
}
};

View file

@ -1,15 +1,23 @@
/* Helps replace t.co links with their originals */
export const linkFixer = (tweet: TweetPartial, text: string): string => {
if (typeof tweet.entities?.urls !== 'undefined') {
tweet.entities?.urls.forEach((url: TcoExpansion) => {
text = text.replace(url.url, url.expanded_url);
});
export const linkFixer = (tweet: GraphQLTweet, text: string): string => {
console.log('got entites', {
entities: tweet.legacy.entities,
})
if (Array.isArray(tweet.legacy.entities?.urls) && tweet.legacy.entities.urls.length) {
tweet.legacy.entities.urls.forEach((url: TcoExpansion) => {
let newURL = url.expanded_url;
/* Remove any link with unavailable original.
This means that stuff like the t.co link to pic.twitter.com
will get removed in image/video Tweets */
text = text.replace(/ ?https:\/\/t\.co\/\w{10}/g, '');
if (newURL.match(/^https:\/\/twitter\.com\/i\/web\/status\/\w+/g) !== null) {
newURL = '';
}
text = text.replace(url.url, newURL);
});
}
/* Remove any link with unavailable original.
This means that stuff like the t.co link to pic.twitter.com
will get removed in image/video Tweets */
text = text.replace(/ ?https:\/\/t\.co\/\w{10}/g, '');
return text;
};

View file

@ -29,10 +29,11 @@ export const handleMosaic = async (
}
return {
type: 'mosaic_photo',
formats: {
jpeg: `${baseUrl}jpeg/${id}${path}`,
webp: `${baseUrl}webp/${id}${path}`
}
} as unknown as APIMosaicPhoto;
} as APIMosaicPhoto;
}
};

View file

@ -2,7 +2,7 @@ import { Constants } from '../constants';
/* Handles translating Tweets when asked! */
export const translateTweet = async (
tweet: TweetPartial,
tweet: GraphQLTweet,
guestToken: string,
language: string
): Promise<TranslationPartial | null> => {
@ -29,7 +29,7 @@ export const translateTweet = async (
try {
apiRequest = await fetch(
`${Constants.TWITTER_ROOT}/i/api/1.1/strato/column/None/tweetId=${tweet.id_str},destinationLanguage=None,translationSource=Some(Google),feature=None,timeout=None,onlyCached=None/translation/service/translateTweet`,
`${Constants.TWITTER_ROOT}/i/api/1.1/strato/column/None/tweetId=${tweet.rest_id},destinationLanguage=None,translationSource=Some(Google),feature=None,timeout=None,onlyCached=None/translation/service/translateTweet`,
{
method: 'GET',
headers: headers

View file

@ -1,6 +1,6 @@
/* We keep this value up-to-date for making our requests to Twitter as
indistinguishable from normal user traffic as possible. */
const fakeChromeVersion = 112;
const fakeChromeVersion = 116;
const platformWindows = 'Windows NT 10.0; Win64; x64';
const platformMac = 'Macintosh; Intel Mac OS X 10_15_7';
const platformLinux = 'X11; Linux x86_64';

62
src/render/photo.ts Normal file
View file

@ -0,0 +1,62 @@
import { Constants } from '../constants';
import { Strings } from '../strings';
export const renderPhoto = (
properties: RenderProperties,
photo: APIPhoto | APIMosaicPhoto
): ResponseInstructions => {
const { tweet, engagementText, authorText, isOverrideMedia, userAgent } = properties;
const instructions: ResponseInstructions = { addHeaders: [] };
if (
(tweet.media?.photos?.length || 0) > 1 &&
(!tweet.media?.mosaic || isOverrideMedia)
) {
photo = photo as APIPhoto;
const all = tweet.media?.all as APIMedia[];
const baseString =
all.length === tweet.media?.photos?.length
? Strings.PHOTO_COUNT
: Strings.MEDIA_COUNT;
const photoCounter = baseString.format({
number: String(all.indexOf(photo) + 1),
total: String(all.length)
});
const isTelegram = (userAgent?.indexOf('Telegram') ?? 0) > -1;
if (authorText === Strings.DEFAULT_AUTHOR_TEXT || isTelegram) {
instructions.authorText = photoCounter;
} else {
instructions.authorText = `${authorText}${
authorText ? ' ― ' : ''
}${photoCounter}`;
}
if (engagementText && !isTelegram) {
instructions.siteName = `${Constants.BRANDING_NAME} - ${engagementText} - ${photoCounter}`;
} else {
instructions.siteName = `${Constants.BRANDING_NAME} - ${photoCounter}`;
}
}
if (photo.type === 'mosaic_photo' && !isOverrideMedia) {
instructions.addHeaders = [
`<meta property="twitter:image" content="${tweet.media?.mosaic?.formats.jpeg}"/>`,
`<meta property="og:image" content="${tweet.media?.mosaic?.formats.jpeg}"/>`
];
} else {
instructions.addHeaders = [
`<meta property="twitter:image" content="${photo.url}"/>`,
`<meta property="og:image" content="${photo.url}"/>`,
`<meta property="twitter:image:width" content="${photo.width}"/>`,
`<meta property="twitter:image:height" content="${photo.height}"/>`,
`<meta property="og:image:width" content="${photo.width}"/>`,
`<meta property="og:image:height" content="${photo.height}"/>`
];
}
return instructions;
};

61
src/render/video.ts Normal file
View file

@ -0,0 +1,61 @@
import { Constants } from '../constants';
import { Strings } from '../strings';
export const renderVideo = (
properties: RenderProperties,
video: APIVideo
): ResponseInstructions => {
const { tweet, userAgent, text } = properties;
const instructions: ResponseInstructions = { addHeaders: [] };
const all = tweet.media?.all as APIMedia[];
/* This fix is specific to Discord not wanting to render videos that are too large,
or rendering low quality videos too small.
Basically, our solution is to cut the dimensions in half if the video is too big (> 1080p),
or double them if it's too small. (<400p)
We check both height and width so we can apply this to both horizontal and vertical videos equally*/
let sizeMultiplier = 1;
if (video.width > 1920 || video.height > 1920) {
sizeMultiplier = 0.5;
}
if (video.width < 400 && video.height < 400) {
sizeMultiplier = 2;
}
/* Like photos when picking a specific one (not using mosaic),
we'll put an indicator if there are more than one video */
if (all && all.length > 1 && (userAgent?.indexOf('Telegram') ?? 0) > -1) {
const baseString =
all.length === tweet.media?.videos?.length
? Strings.VIDEO_COUNT
: Strings.MEDIA_COUNT;
const videoCounter = baseString.format({
number: String(all.indexOf(video) + 1),
total: String(all.length)
});
instructions.siteName = `${Constants.BRANDING_NAME} - ${videoCounter}`;
}
instructions.authorText = tweet.translation?.text || text || '';
/* Push the raw video-related headers */
instructions.addHeaders = [
`<meta property="twitter:player:stream:content_type" content="${video.format}"/>`,
`<meta property="twitter:player:height" content="${video.height * sizeMultiplier}"/>`,
`<meta property="twitter:player:width" content="${video.width * sizeMultiplier}"/>`,
`<meta property="og:video" content="${video.url}"/>`,
`<meta property="og:video:secure_url" content="${video.url}"/>`,
`<meta property="og:video:height" content="${video.height * sizeMultiplier}"/>`,
`<meta property="og:video:width" content="${video.width * sizeMultiplier}"/>`,
`<meta property="og:video:type" content="${video.format}"/>`,
`<meta property="twitter:image" content="0"/>`
];
return instructions;
};

View file

@ -68,6 +68,14 @@ const statusRequest = async (
flags.deprecated = true;
}
/* TODO: Figure out what we're doing with FixTweet / FixupX branding in future */
if (/fixup/g.test(url.href)) {
console.log(`We're using x domain`);
flags.isXDomain = true;
} else {
console.log(`We're using twitter domain`);
}
/* Check if request is to api.fxtwitter.com, or the tweet is appended with .json
Note that unlike TwitFix, FixTweet will never generate embeds for .json, and
in fact we only support .json because it's what people using TwitFix API would
@ -291,6 +299,7 @@ router.get('/owoembed', async (request: IRequest) => {
const text = searchParams.get('text') || 'Twitter';
const author = searchParams.get('author') || 'jack';
const status = searchParams.get('status') || '20';
const useXbranding = searchParams.get('useXbranding') === 'true';
const random = Math.floor(Math.random() * Object.keys(motd).length);
const [name, url] = Object.entries(motd)[random];
@ -304,7 +313,7 @@ router.get('/owoembed', async (request: IRequest) => {
provider_name:
searchParams.get('deprecated') === 'true'
? Strings.DEPRECATED_DOMAIN_NOTICE_DISCORD
: name,
: (useXbranding ? name : Strings.X_DOMAIN_NOTICE),
provider_url: url,
title: Strings.DEFAULT_AUTHOR_TEXT,
type: 'link',

View file

@ -131,8 +131,9 @@ This is caused by Twitter API downtime or a new bug. Try again in a little while
QUOTE_TEXT: `↘️ Quoting {name} (@{screen_name})`,
TRANSLATE_TEXT: `↘️ Translated from {language}`,
TRANSLATE_TEXT_INTL: `↘️ {source} ➡️ {destination}`,
PHOTO_COUNT: `Photo {number} of {total}`,
VIDEO_COUNT: `Video {number} of {total}`,
PHOTO_COUNT: `Photo {number} / {total}`,
VIDEO_COUNT: `Video {number} / {total}`,
MEDIA_COUNT: `Media {number} / {total}`,
SINGULAR_DAY_LEFT: 'day left',
PLURAL_DAYS_LEFT: 'days left',
@ -144,7 +145,7 @@ This is caused by Twitter API downtime or a new bug. Try again in a little while
PLURAL_SECONDS_LEFT: 'seconds left',
FINAL_POLL_RESULTS: 'Final results',
ERROR_API_FAIL: 'Tweet failed to load due to an API error :(',
ERROR_API_FAIL: 'Tweet failed to load due to an API error. This is most common with NSFW Tweets as Twitter / X currently blocks us from fetching them. We\'re still working on a fix for that.🙏',
ERROR_PRIVATE: `Sorry, we can't embed this Tweet because the user is private or suspended :(`,
ERROR_TWEET_NOT_FOUND: `Sorry, that Tweet doesn't exist :(`,
ERROR_USER_NOT_FOUND: `Sorry, that user doesn't exist :(`,
@ -208,5 +209,6 @@ Disallow: /owoembed
Disallow: /owoembed/
Allow: /watch?v=dQw4w9WgXcQ
# 0100011101101111011011110110010000100000011000100110111101110100`
# 0100011101101111011011110110010000100000011000100110111101110100`,
X_DOMAIN_NOTICE: 'FixTweet - 🆕 x.com link? Try fixupx.com'
};

1
src/types/env.d.ts vendored
View file

@ -1,5 +1,4 @@
declare const BRANDING_NAME: string;
declare const BRANDING_NAME_DISCORD: string;
declare const DIRECT_MEDIA_DOMAINS: string;
declare const TEXT_ONLY_DOMAINS: string;
declare const DEPRECATED_DOMAIN_LIST: string;

View file

@ -304,3 +304,181 @@ type GraphQLUser = {
};
};
};
type GraphQLTweet = {
// Workaround
result: GraphQLTweet;
__typename: 'Tweet';
rest_id: string; // "1674824189176590336",
has_birdwatch_notes: false,
core: {
user_results: {
result: GraphQLUser;
}
}
edit_control: unknown,
edit_perspective: unknown,
is_translatable: false,
views: {
count: string; // "562"
state: string; // "EnabledWithCount"
}
source: string; // "<a href=\"https://mobile.twitter.com\" rel=\"nofollow\">Twitter Web App</a>"
quoted_status_result?: GraphQLTweet;
legacy: {
created_at: string; // "Tue Sep 14 20:00:00 +0000 2021"
conversation_id_str: string; // "1674824189176590336"
bookmark_count: number; // 0
bookmarked: boolean; // false
favorite_count: number; // 28
full_text: string; // "This is a test tweet"
in_reply_to_screen_name: string; // "username"
in_reply_to_status_id_str: string; // "1674824189176590336"
in_reply_to_user_id_str: string; // "783214"
is_quote_status: boolean; // false
quote_count: number; // 39
quoted_status_id_str: string; // "1674824189176590336"
quoted_status_permalink: {
url: string; // "https://t.co/aBcDeFgHiJ"
expanded: string; // "https://twitter.com/username/status/1674824189176590336"
display: string; // "twitter.com/username/statu…"
};
reply_count: number; // 1
retweet_count: number; // 4
lang: string; // "en"
possibly_sensitive: boolean; // false
possibly_sensitive_editable: boolean; // false
entities: {
media: {
display_url: string; // "pic.twitter.com/1X2X3X4X5X"
expanded_url: string; // "https://twitter.com/username/status/1674824189176590336/photo/1" "https://twitter.com/username/status/1674824189176590336/video/1"
id_str: string; // "1674824189176590336"
indices: [number, number]; // [number, number]
media_url_https: string; // "https://pbs.twimg.com/media/FAKESCREENSHOT.jpg" With videos appears to be the thumbnail
type: string; // "photo" Seems to be photo even with videos
}[]
user_mentions: unknown[];
urls: TcoExpansion[];
hashtags: unknown[];
symbols: unknown[];
}
extended_entities: {
media: TweetMedia[]
}
}
note_tweet: {
is_expandable: boolean;
entity_set: {
hashtags: unknown[];
urls: unknown[];
user_mentions: unknown[];
},
note_tweet_results: {
result: {
text: string;
}
}
};
card: {
rest_id: string; // "card://1674824189176590336",
legacy: {
binding_values: {
key: `choice${1|2|3|4}_label`|'counts_are_final'|`choice${1|2|3|4}_count`|'last_updated_datetime_utc'|'duration_minutes'|'api'|'card_url'
value: {
string_value: string; // "Option text"
type: 'STRING'
}|{
boolean_value: boolean; // true
type: 'BOOLEAN'
}
}[]
}
}
}
type TweetTombstone = {
__typename: 'TweetTombstone';
tombstone: {
__typename: 'TextTombstone';
text: {
rtl: boolean; // false;
text: string; // "Youre unable to view this Tweet because this account owner limits who can view their Tweets. Learn more"
entities: unknown[];
}
}
}
type GraphQLTimelineTweetEntry = {
/** The entryID contains the tweet ID */
entryId: `tweet-${number}`; // "tweet-1674824189176590336"
sortIndex: string;
content: {
entryType: 'TimelineTimelineItem',
__typename: 'TimelineTimelineItem',
itemContent: {
item: 'TimelineTweet',
__typename: 'TimelineTweet',
tweet_results: {
result: GraphQLTweet|TweetTombstone;
}
}
}
}
type GraphQLConversationThread = {
entryId: `conversationthread-${number}`; // "conversationthread-1674824189176590336"
sortIndex: string;
}
type GraphQLTimelineEntry = GraphQLTimelineTweetEntry|GraphQLConversationThread|unknown;
type V2ThreadInstruction = TimeLineAddEntriesInstruction | TimeLineTerminateTimelineInstruction;
type TimeLineAddEntriesInstruction = {
type: 'TimelineAddEntries';
entries: GraphQLTimelineEntry[];
}
type TimeLineTerminateTimelineInstruction = {
type: 'TimelineTerminateTimeline';
direction: 'Top';
}
type GraphQLTweetNotFoundResponse = {
errors: [{
message: string; // "_Missing: No status found with that ID"
locations: unknown[];
path: string[]; // ["threaded_conversation_with_injections_v2"]
extensions: {
name: string; // "GenericError"
source: string; // "Server"
code: number; // 144
kind: string; // "NonFatal"
tracing: {
trace_id: string; // "2e39ff747de237db"
}
}
code: number; // 144
kind: string; // "NonFatal"
name: string; // "GenericError"
source: string; // "Server"
tracing: {
trace_id: string; // "2e39ff747de237db"
}
}]
data: Record<string, never>;
}
type GraphQLTweetFoundResponse = {
data: {
threaded_conversation_with_injections_v2: {
instructions: V2ThreadInstruction[]
}
}
}
type TweetResultsByRestIdResult = {
errors?: unknown[];
data?: {
tweetResult?: {
result?: {
__typename: 'TweetUnavailable';
reason: 'NsfwLoggedOut'|'Protected';
}|GraphQLTweet
}
}
}

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

@ -7,6 +7,7 @@ type InputFlags = {
api?: boolean;
deprecated?: boolean;
textOnly?: boolean;
isXDomain?: boolean;
};
interface StatusResponse {
@ -15,6 +16,24 @@ interface StatusResponse {
cacheControl?: string | null;
}
interface ResponseInstructions {
addHeaders: string[];
authorText?: string;
siteName?: string;
engagementText?: string;
text?: string;
}
interface RenderProperties {
tweet: APITweet;
siteText?: string;
authorText?: string;
engagementText?: string;
isOverrideMedia?: boolean;
userAgent?: string;
text?: string;
}
interface Request {
params: {
[param: string]: string;
@ -91,15 +110,26 @@ interface APIPoll {
time_left_en: string;
}
interface APIPhoto {
type: 'photo';
interface APIMedia {
type: string;
url: string;
width: number;
height: number;
}
interface APIPhoto extends APIMedia {
type: 'photo';
altText: string;
}
interface APIMosaicPhoto {
interface APIVideo extends APIMedia {
type: 'video' | 'gif';
thumbnail_url: string;
format: string;
duration: number;
}
interface APIMosaicPhoto extends APIMedia {
type: 'mosaic_photo';
formats: {
webp: string;
@ -107,16 +137,6 @@ interface APIMosaicPhoto {
};
}
interface APIVideo {
type: 'video' | 'gif';
url: string;
thumbnail_url: string;
width: number;
height: number;
format: string;
duration: number;
}
interface APITweet {
id: string;
url: string;
@ -140,12 +160,13 @@ interface APITweet {
external?: APIExternalMedia;
photos?: APIPhoto[];
videos?: APIVideo[];
all?: APIMedia[];
mosaic?: APIMosaicPhoto;
};
lang: string | null;
possibly_sensitive: boolean;
replying_to: string | null;
replying_to_status: string | null;

7
src/utils/graphql.ts Normal file
View file

@ -0,0 +1,7 @@
export const isGraphQLTweetNotFoundResponse = (response: unknown): response is GraphQLTweetNotFoundResponse => {
return typeof response === 'object' && response !== null && 'errors' in response && Array.isArray(response.errors) && response.errors.length > 0 && 'message' in response.errors[0] && response.errors[0].message === '_Missing: No status found with that ID';
};
export const isGraphQLTweet = (response: unknown): response is GraphQLTweet => {
return typeof response === 'object' && response !== null && '__typename' in response && response.__typename === 'Tweet';
}

View file

@ -99,7 +99,7 @@ test('API fetch basic Tweet', async () => {
test('API fetch video Tweet', async () => {
const result = await cacheWrapper(
new Request('https://api.fxtwitter.com/Twitter/status/854416760933556224', {
new Request('https://api.fxtwitter.com/X/status/854416760933556224', {
method: 'GET',
headers: botHeaders
})
@ -112,12 +112,12 @@ test('API fetch video Tweet', async () => {
const tweet = response.tweet as APITweet;
expect(tweet).toBeTruthy();
expect(tweet.url).toEqual('https://twitter.com/Twitter/status/854416760933556224');
expect(tweet.url).toEqual('https://twitter.com/X/status/854416760933556224');
expect(tweet.id).toEqual('854416760933556224');
expect(tweet.text).toEqual(
'Get the sauces ready, #NuggsForCarter has 3 million+ Retweets.'
);
expect(tweet.author.screen_name?.toLowerCase()).toEqual('twitter');
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();
@ -158,10 +158,10 @@ test('API fetch multi-photo Tweet', async () => {
const tweet = response.tweet as APITweet;
expect(tweet).toBeTruthy();
expect(tweet.url).toEqual('https://twitter.com/Twitter/status/1445094085593866246');
expect(tweet.url).toEqual('https://twitter.com/X/status/1445094085593866246');
expect(tweet.id).toEqual('1445094085593866246');
expect(tweet.text).toEqual('@netflix');
expect(tweet.author.screen_name?.toLowerCase()).toEqual('twitter');
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();
@ -206,10 +206,10 @@ test('API fetch poll Tweet', async () => {
const tweet = response.tweet as APITweet;
expect(tweet).toBeTruthy();
expect(tweet.url).toEqual('https://twitter.com/Twitter/status/1055475950543167488');
expect(tweet.url).toEqual('https://twitter.com/X/status/1055475950543167488');
expect(tweet.id).toEqual('1055475950543167488');
expect(tweet.text).toEqual('A poll:');
expect(tweet.author.screen_name?.toLowerCase()).toEqual('twitter');
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();
@ -243,7 +243,7 @@ test('API fetch poll Tweet', async () => {
test('API fetch user', async () => {
const result = await cacheWrapper(
new Request('https://api.fxtwitter.com/twitter', {
new Request('https://api.fxtwitter.com/x', {
method: 'GET',
headers: botHeaders
})
@ -256,9 +256,9 @@ test('API fetch user', async () => {
const user = response.user as APIUser;
expect(user).toBeTruthy();
expect(user.url).toEqual('https://twitter.com/Twitter');
expect(user.url).toEqual('https://twitter.com/X');
expect(user.id).toEqual('783214');
expect(user.screen_name).toEqual('Twitter');
expect(user.screen_name).toEqual('X');
expect(user.followers).toEqual(expect.any(Number));
expect(user.following).toEqual(expect.any(Number));
// The official twitter account will never be following as many people as it has followers
@ -266,7 +266,22 @@ test('API fetch user', async () => {
expect(user.likes).toEqual(expect.any(Number));
// expect(user.verified).toEqual('business');
expect(user.joined).toEqual('Tue Feb 20 14:35:54 +0000 2007');
expect(user.birthday.day).toEqual(21);
expect(user.birthday.month).toEqual(3);
expect(user.birthday.year).toBeUndefined();
// expect(user.birthday.day).toEqual(21);
// expect(user.birthday.month).toEqual(3);
// expect(user.birthday.year).toBeUndefined();
});
test('API fetch user that does not exist', async () => {
const result = await cacheWrapper(
new Request('https://api.fxtwitter.com/usesaahah123', {
method: 'GET',
headers: botHeaders
})
);
expect(result.status).toEqual(404);
const response = (await result.json()) as UserAPIResponse;
expect(response).toBeTruthy();
expect(response.code).toEqual(404);
expect(response.message).toEqual('User not found');
expect(response.user).toBeUndefined();
});

View file

@ -1,6 +1,6 @@
const path = require('path');
const webpack = require('webpack');
const SentryWebpackPlugin = require('@sentry/webpack-plugin');
const { sentryWebpackPlugin } = require('@sentry/webpack-plugin');
const gitCommit = require('child_process')
.execSync('git rev-parse --short HEAD')
@ -19,7 +19,6 @@ require('dotenv').config();
let envVariables = [
'BRANDING_NAME',
'BRANDING_NAME_DISCORD',
'DIRECT_MEDIA_DOMAINS',
'TEXT_ONLY_DOMAINS',
'HOST_URL',
@ -45,7 +44,7 @@ let plugins = [
if (process.env.SENTRY_AUTH_TOKEN) {
plugins.push(
new SentryWebpackPlugin({
sentryWebpackPlugin({
release: releaseName,
include: './dist',
urlPrefix: '~/',

View file

@ -1,7 +1,7 @@
name = "fixtweet"
account_id = "[CLOUDFLARE_ACCOUNT_ID]"
main = "./dist/worker.js"
compatibility_date = "2022-08-17"
compatibility_date = "2023-08-15"
send_metrics = false
services = [
{ binding = "TwitterProxy", service = "elongator" }