mirror of
https://github.com/CompeyDev/fxtwitter-docker.git
synced 2025-04-07 19:40:54 +01:00
Merge remote-tracking branch 'origin/main' into telegram-instant-view
This commit is contained in:
commit
29119ff75c
27 changed files with 1990 additions and 1582 deletions
.env.exampleREADME.mdjestconfig.jsonmotd.jsonpackage-lock.jsonpackage.json
src
test
webpack.config.jswrangler.example.toml
|
@ -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"
|
||||
|
|
13
README.md
13
README.md
|
@ -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">
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
2486
package-lock.json
generated
File diff suppressed because it is too large
Load diff
39
package.json
39
package.json
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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 */
|
||||
|
|
85
src/fetch.ts
85
src/fetch.ts
|
@ -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'
|
||||
|
|
|
@ -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', '')
|
||||
)
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
62
src/render/photo.ts
Normal 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
61
src/render/video.ts
Normal 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;
|
||||
};
|
|
@ -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',
|
||||
|
|
|
@ -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
1
src/types/env.d.ts
vendored
|
@ -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;
|
||||
|
|
178
src/types/twitterTypes.d.ts
vendored
178
src/types/twitterTypes.d.ts
vendored
|
@ -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; // "You’re 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
49
src/types/types.d.ts
vendored
|
@ -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
7
src/utils/graphql.ts
Normal 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';
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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: '~/',
|
||||
|
|
|
@ -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" }
|
||||
|
|
Loading…
Add table
Reference in a new issue