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
|
@ -1,5 +1,4 @@
|
||||||
BRANDING_NAME = "FixTweet"
|
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"
|
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"
|
TEXT_ONLY_DOMAINS = "t.fxtwitter.com,t.twittpr.com"
|
||||||
DEPRECATED_DOMAIN_LIST = "pxtwitter.com,www.pxtwitter.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]
|
![][icons]
|
||||||
|
|
||||||
|
@ -20,11 +20,14 @@
|
||||||
[licensebadge]: https://img.shields.io/github/license/FixTweet/FixTweet
|
[licensebadge]: https://img.shields.io/github/license/FixTweet/FixTweet
|
||||||
[uptimebadge]: https://img.shields.io/uptimerobot/ratio/m792476277-53add6f22c4e6f4d3a2d7e98
|
[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">
|
<img src="https://cdn.discordapp.com/attachments/165560751363325952/1006346785985417307/fixtweet.webp">
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
},
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
"BRANDING_NAME": "FixTweet",
|
"BRANDING_NAME": "FixTweet",
|
||||||
"BRANDING_NAME_DISCORD": "FixTweetBrandingDiscord",
|
|
||||||
"TEXT_ONLY_DOMAINS": "t.fxtwitter.com,t.twittpr.com",
|
"TEXT_ONLY_DOMAINS": "t.fxtwitter.com,t.twittpr.com",
|
||||||
"DIRECT_MEDIA_DOMAINS": "d.fxtwitter.com,dl.fxtwitter.com",
|
"DIRECT_MEDIA_DOMAINS": "d.fxtwitter.com,dl.fxtwitter.com",
|
||||||
"MOSAIC_DOMAIN_LIST": "mosaic.fxtwitter.com",
|
"MOSAIC_DOMAIN_LIST": "mosaic.fxtwitter.com",
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
{
|
{
|
||||||
"FixTweet": "https://github.com/FixTweet/FixTweet",
|
"FixupX / FixTweet - Recovering from API woes": "https://github.com/FixTweet/FixTweet"
|
||||||
"FixTweet - Embed videos, polls & more": "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",
|
"main": "dist/worker.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "webpack",
|
"build": "webpack",
|
||||||
"publish": "wrangler publish",
|
"publish": "wrangler deploy",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
"log": "wrangler tail",
|
"log": "wrangler tail",
|
||||||
"reload": "wrangler publish && wrangler tail",
|
"reload": "wrangler deploy && wrangler tail",
|
||||||
"prettier": "prettier --write .",
|
"prettier": "prettier --write .",
|
||||||
"lint:eslint": "eslint --max-warnings=0 src",
|
"lint:eslint": "eslint --max-warnings=0 src",
|
||||||
"test": "jest --config jestconfig.json --verbose"
|
"test": "jest --config jestconfig.json --verbose"
|
||||||
|
@ -15,27 +16,27 @@
|
||||||
"author": "dangered wolf",
|
"author": "dangered wolf",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20230511.0",
|
"@cloudflare/workers-types": "^4.20230814.0",
|
||||||
"@microsoft/eslint-formatter-sarif": "^3.0.0",
|
"@microsoft/eslint-formatter-sarif": "^3.0.0",
|
||||||
"@sentry/webpack-plugin": "^1.20.1",
|
"@sentry/webpack-plugin": "^2.6.2",
|
||||||
"@types/jest": "^29.5.1",
|
"@types/jest": "^29.5.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.59.5",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
"@typescript-eslint/parser": "^5.59.5",
|
"@typescript-eslint/parser": "^5.62.0",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.3.1",
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.47.0",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^8.10.0",
|
||||||
"eslint-config-typescript": "^3.0.0",
|
"eslint-config-typescript": "^3.0.0",
|
||||||
"eslint-plugin-optimize-regex": "^1.2.1",
|
"eslint-plugin-optimize-regex": "^1.2.1",
|
||||||
"eslint-plugin-sonarjs": "^0.19.0",
|
"eslint-plugin-sonarjs": "^0.20.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.6.2",
|
||||||
"jest-environment-miniflare": "^2.14.0",
|
"jest-environment-miniflare": "^2.14.0",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^3.0.2",
|
||||||
"ts-jest": "^29.1.0",
|
"ts-jest": "^29.1.1",
|
||||||
"ts-loader": "^9.4.2",
|
"ts-loader": "^9.4.4",
|
||||||
"typescript": "^5.0.4",
|
"typescript": "^5.1.6",
|
||||||
"webpack": "^5.82.1",
|
"webpack": "^5.88.2",
|
||||||
"webpack-cli": "^5.1.1",
|
"webpack-cli": "^5.1.4",
|
||||||
"wrangler": "^3.0.1"
|
"wrangler": "^3.5.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"itty-router": "^3.0.12",
|
"itty-router": "^3.0.12",
|
||||||
|
|
|
@ -7,82 +7,91 @@ import { colorFromPalette } from '../helpers/palette';
|
||||||
import { translateTweet } from '../helpers/translate';
|
import { translateTweet } from '../helpers/translate';
|
||||||
import { unescapeText } from '../helpers/utils';
|
import { unescapeText } from '../helpers/utils';
|
||||||
import { processMedia } from '../helpers/media';
|
import { processMedia } from '../helpers/media';
|
||||||
|
import { convertToApiUser } from './user';
|
||||||
|
import { isGraphQLTweet, isGraphQLTweetNotFoundResponse } from '../utils/graphql';
|
||||||
|
|
||||||
/* This function does the heavy lifting of processing data from Twitter API
|
/* This function does the heavy lifting of processing data from Twitter API
|
||||||
and using it to create FixTweet's streamlined API responses */
|
and using it to create FixTweet's streamlined API responses */
|
||||||
const populateTweetProperties = async (
|
const populateTweetProperties = async (
|
||||||
tweet: TweetPartial,
|
tweet: GraphQLTweet,
|
||||||
conversation: TimelineBlobPartial,
|
conversation: any, // TimelineBlobPartial,
|
||||||
language: string | undefined
|
language: string | undefined
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
): Promise<APITweet> => {
|
): Promise<APITweet> => {
|
||||||
const apiTweet = {} as 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
|
/* 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
|
Twitter stores it separately in the conversation API. This is to consolidate
|
||||||
it in case a user appears multiple times in a thread. */
|
it in case a user appears multiple times in a thread. */
|
||||||
tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {};
|
const graphQLUser = tweet.core.user_results.result;
|
||||||
|
const apiUser = convertToApiUser(graphQLUser);
|
||||||
const user = tweet.user as UserPartial;
|
|
||||||
const screenName = user?.screen_name || '';
|
|
||||||
const name = user?.name || '';
|
|
||||||
|
|
||||||
/* Populating a lot of the basics */
|
/* Populating a lot of the basics */
|
||||||
apiTweet.url = `${Constants.TWITTER_ROOT}/${screenName}/status/${tweet.id_str}`;
|
apiTweet.url = `${Constants.TWITTER_ROOT}/${apiUser.screen_name}/status/${tweet.rest_id}`;
|
||||||
apiTweet.id = tweet.id_str;
|
apiTweet.id = tweet.rest_id;
|
||||||
apiTweet.text = unescapeText(linkFixer(tweet, tweet.full_text || ''));
|
apiTweet.text = unescapeText(linkFixer(tweet, tweet.legacy.full_text || ''));
|
||||||
apiTweet.author = {
|
apiTweet.author = {
|
||||||
id: tweet.user_id_str,
|
id: apiUser.id,
|
||||||
name: name,
|
name: apiUser.name,
|
||||||
screen_name: screenName,
|
screen_name: apiUser.screen_name,
|
||||||
avatar_url:
|
avatar_url:
|
||||||
(user?.profile_image_url_https || '').replace('_normal', '_200x200') || '',
|
(apiUser.avatar_url || '').replace('_normal', '_200x200') || '',
|
||||||
avatar_color: colorFromPalette(
|
avatar_color: '0000FF' /* colorFromPalette(
|
||||||
tweet.user?.profile_image_extensions_media_color?.palette || []
|
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.replies = tweet.legacy.reply_count;
|
||||||
apiTweet.retweets = tweet.retweet_count;
|
apiTweet.retweets = tweet.legacy.retweet_count;
|
||||||
apiTweet.likes = tweet.favorite_count;
|
apiTweet.likes = tweet.legacy.favorite_count;
|
||||||
apiTweet.color = apiTweet.author.avatar_color;
|
apiTweet.color = apiTweet.author.avatar_color;
|
||||||
apiTweet.twitter_card = 'tweet';
|
apiTweet.twitter_card = 'tweet';
|
||||||
apiTweet.created_at = tweet.created_at;
|
apiTweet.created_at = tweet.legacy.created_at;
|
||||||
apiTweet.created_timestamp = new Date(tweet.created_at).getTime() / 1000;
|
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') {
|
if (tweet.views.state === 'EnabledWithCount') {
|
||||||
apiTweet.views = parseInt(tweet.ext_views.count || '0') ?? null;
|
apiTweet.views = parseInt(tweet.views.count || '0') ?? null;
|
||||||
} else {
|
} else {
|
||||||
apiTweet.views = null;
|
apiTweet.views = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tweet.lang !== 'unk') {
|
if (tweet.legacy.lang !== 'unk') {
|
||||||
apiTweet.lang = tweet.lang;
|
apiTweet.lang = tweet.legacy.lang;
|
||||||
} else {
|
} else {
|
||||||
apiTweet.lang = null;
|
apiTweet.lang = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
apiTweet.replying_to = tweet.in_reply_to_screen_name || null;
|
apiTweet.replying_to = tweet.legacy?.in_reply_to_screen_name || null;
|
||||||
apiTweet.replying_to_status = tweet.in_reply_to_status_id_str || null;
|
apiTweet.replying_to_status = tweet.legacy?.in_reply_to_status_id_str || null;
|
||||||
|
|
||||||
const mediaList = Array.from(
|
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 */
|
/* Populate this Tweet's media */
|
||||||
mediaList.forEach(media => {
|
mediaList.forEach(media => {
|
||||||
const mediaObject = processMedia(media);
|
const mediaObject = processMedia(media);
|
||||||
if (mediaObject) {
|
if (mediaObject) {
|
||||||
|
apiTweet.media = apiTweet.media || {};
|
||||||
|
apiTweet.media.all = apiTweet.media?.all || [];
|
||||||
|
apiTweet.media.all.push(mediaObject);
|
||||||
|
|
||||||
if (mediaObject.type === 'photo') {
|
if (mediaObject.type === 'photo') {
|
||||||
apiTweet.twitter_card = 'summary_large_image';
|
apiTweet.twitter_card = 'summary_large_image';
|
||||||
apiTweet.media = apiTweet.media || {};
|
|
||||||
apiTweet.media.photos = apiTweet.media.photos || [];
|
apiTweet.media.photos = apiTweet.media.photos || [];
|
||||||
apiTweet.media.photos.push(mediaObject);
|
apiTweet.media.photos.push(mediaObject);
|
||||||
} else if (mediaObject.type === 'video' || mediaObject.type === 'gif') {
|
} else if (mediaObject.type === 'video' || mediaObject.type === 'gif') {
|
||||||
apiTweet.twitter_card = 'player';
|
apiTweet.twitter_card = 'player';
|
||||||
apiTweet.media = apiTweet.media || {};
|
|
||||||
apiTweet.media.videos = apiTweet.media.videos || [];
|
apiTweet.media.videos = apiTweet.media.videos || [];
|
||||||
apiTweet.media.videos.push(mediaObject);
|
apiTweet.media.videos.push(mediaObject);
|
||||||
}
|
}
|
||||||
|
@ -90,13 +99,22 @@ const populateTweetProperties = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Grab color palette data */
|
/* Grab color palette data */
|
||||||
|
/*
|
||||||
if (mediaList[0]?.ext_media_color?.palette) {
|
if (mediaList[0]?.ext_media_color?.palette) {
|
||||||
apiTweet.color = colorFromPalette(mediaList[0].ext_media_color.palette);
|
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 */
|
/* Handle photos and mosaic if available */
|
||||||
if ((apiTweet.media?.photos?.length || 0) > 1) {
|
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) {
|
if (typeof apiTweet.media !== 'undefined' && mosaic !== null) {
|
||||||
apiTweet.media.mosaic = mosaic;
|
apiTweet.media.mosaic = mosaic;
|
||||||
}
|
}
|
||||||
|
@ -111,8 +129,9 @@ const populateTweetProperties = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Populate a Twitter card */
|
/* Populate a Twitter card */
|
||||||
|
|
||||||
if (tweet.card) {
|
if (tweet.card) {
|
||||||
const card = await renderCard(tweet.card);
|
const card = renderCard(tweet.card);
|
||||||
if (card.external_media) {
|
if (card.external_media) {
|
||||||
apiTweet.twitter_card = 'summary_large_image';
|
apiTweet.twitter_card = 'summary_large_image';
|
||||||
apiTweet.media = apiTweet.media || {};
|
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 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(
|
const translateAPI = await translateTweet(
|
||||||
tweet,
|
tweet,
|
||||||
conversation.guestToken || '',
|
conversation.guestToken || '',
|
||||||
|
@ -182,59 +201,50 @@ export const statusAPI = async (
|
||||||
event: FetchEvent,
|
event: FetchEvent,
|
||||||
flags?: InputFlags
|
flags?: InputFlags
|
||||||
): Promise<TweetAPIResponse> => {
|
): Promise<TweetAPIResponse> => {
|
||||||
let conversation = await fetchConversation(status, event);
|
|
||||||
let tweet = conversation?.globalObjects?.tweets?.[status] || {};
|
|
||||||
|
|
||||||
let wasMediaBlockedNSFW = false;
|
let wasMediaBlockedNSFW = false;
|
||||||
|
let res = await fetchConversation(status, event);
|
||||||
if (tweet.retweeted_status_id_str) {
|
const tweet = res.data?.tweetResult?.result;
|
||||||
tweet = conversation?.globalObjects?.tweets?.[tweet.retweeted_status_id_str] || {};
|
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) */
|
// console.log(JSON.stringify(tweet))
|
||||||
if (typeof tweet.full_text === 'undefined') {
|
|
||||||
if (conversation.timeline?.instructions?.length > 0) {
|
if (tweet.__typename === 'TweetUnavailable') {
|
||||||
/* Try again using elongator API proxy */
|
if (tweet.reason === 'Protected') {
|
||||||
console.log('No Tweet was found, loading again from elongator');
|
writeDataPoint(event, language, wasMediaBlockedNSFW, 'PRIVATE_TWEET', flags);
|
||||||
conversation = await fetchConversation(status, event, true);
|
return { code: 401, message: 'PRIVATE_TWEET' };
|
||||||
tweet = conversation?.globalObjects?.tweets?.[status] || {};
|
// } else if (tweet.reason === 'NsfwLoggedOut') {
|
||||||
|
// // API failure as elongator should have handled this
|
||||||
if (typeof tweet.full_text !== 'undefined') {
|
// writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
|
||||||
console.log('Successfully loaded Tweet using elongator');
|
// return { code: 500, message: 'API_FAIL' };
|
||||||
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' };
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
/* {"errors":[{"code":34,"message":"Sorry, that page does not exist."}]} */
|
// Api failure at parsing status
|
||||||
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 */
|
|
||||||
writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
|
writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
|
||||||
return { code: 500, message: 'API_FAIL' };
|
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 */
|
/* Creating the response objects */
|
||||||
const response: TweetAPIResponse = { code: 200, message: 'OK' } as TweetAPIResponse;
|
const response: TweetAPIResponse = { code: 200, message: 'OK' } as TweetAPIResponse;
|
||||||
const apiTweet: APITweet = (await populateTweetProperties(
|
const apiTweet: APITweet = (await populateTweetProperties(
|
||||||
|
@ -244,8 +254,7 @@ export const statusAPI = async (
|
||||||
)) as APITweet;
|
)) as APITweet;
|
||||||
|
|
||||||
/* We found a quote tweet, let's process that too */
|
/* We found a quote tweet, let's process that too */
|
||||||
const quoteTweet =
|
const quoteTweet = tweet.quoted_status_result;
|
||||||
conversation.globalObjects?.tweets?.[tweet.quoted_status_id_str || '0'] || null;
|
|
||||||
if (quoteTweet) {
|
if (quoteTweet) {
|
||||||
apiTweet.quote = (await populateTweetProperties(
|
apiTweet.quote = (await populateTweetProperties(
|
||||||
quoteTweet,
|
quoteTweet,
|
||||||
|
|
|
@ -1,15 +1,8 @@
|
||||||
import { Constants } from '../constants';
|
import { Constants } from '../constants';
|
||||||
import { fetchUser } from '../fetch';
|
import { fetchUser } from '../fetch';
|
||||||
|
|
||||||
/* This function does the heavy lifting of processing data from Twitter API
|
export const convertToApiUser = (user: GraphQLUser): APIUser => {
|
||||||
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 apiUser = {} as APIUser;
|
const apiUser = {} as APIUser;
|
||||||
|
|
||||||
const user = response.data.user.result;
|
|
||||||
/* Populating a lot of the basics */
|
/* Populating a lot of the basics */
|
||||||
apiUser.url = `${Constants.TWITTER_ROOT}/${user.legacy.screen_name}`;
|
apiUser.url = `${Constants.TWITTER_ROOT}/${user.legacy.screen_name}`;
|
||||||
apiUser.id = user.rest_id;
|
apiUser.id = user.rest_id;
|
||||||
|
@ -21,6 +14,7 @@ const populateUserProperties = async (
|
||||||
apiUser.screen_name = user.legacy.screen_name;
|
apiUser.screen_name = user.legacy.screen_name;
|
||||||
apiUser.description = user.legacy.description;
|
apiUser.description = user.legacy.description;
|
||||||
apiUser.location = user.legacy.location;
|
apiUser.location = user.legacy.location;
|
||||||
|
apiUser.banner_url = user.legacy.profile_banner_url;
|
||||||
/*
|
/*
|
||||||
if (user.is_blue_verified) {
|
if (user.is_blue_verified) {
|
||||||
apiUser.verified = 'blue';
|
apiUser.verified = 'blue';
|
||||||
|
@ -51,6 +45,16 @@ const populateUserProperties = async (
|
||||||
return apiUser;
|
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)
|
/* API for Twitter profiles (Users)
|
||||||
Used internally by FixTweet's embed service, or
|
Used internally by FixTweet's embed service, or
|
||||||
available for free using api.fxtwitter.com. */
|
available for free using api.fxtwitter.com. */
|
||||||
|
@ -60,7 +64,12 @@ export const userAPI = async (
|
||||||
flags?: InputFlags
|
flags?: InputFlags
|
||||||
): Promise<UserAPIResponse> => {
|
): Promise<UserAPIResponse> => {
|
||||||
const userResponse = await fetchUser(username, event);
|
const userResponse = await fetchUser(username, event);
|
||||||
|
if (!userResponse || !Object.keys(userResponse).length) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: 'User not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
/* Creating the response objects */
|
/* Creating the response objects */
|
||||||
const response: UserAPIResponse = { code: 200, message: 'OK' } as UserAPIResponse;
|
const response: UserAPIResponse = { code: 200, message: 'OK' } as UserAPIResponse;
|
||||||
const apiUser: APIUser = (await populateUserProperties(userResponse)) as APIUser;
|
const apiUser: APIUser = (await populateUserProperties(userResponse)) as APIUser;
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
export const Constants = {
|
export const Constants = {
|
||||||
/* These constants are populated by variables in .env, then set by Webpack */
|
/* These constants are populated by variables in .env, then set by Webpack */
|
||||||
BRANDING_NAME: BRANDING_NAME,
|
BRANDING_NAME: BRANDING_NAME,
|
||||||
BRANDING_NAME_DISCORD: BRANDING_NAME_DISCORD,
|
|
||||||
DIRECT_MEDIA_DOMAINS: DIRECT_MEDIA_DOMAINS.split(','),
|
DIRECT_MEDIA_DOMAINS: DIRECT_MEDIA_DOMAINS.split(','),
|
||||||
TEXT_ONLY_DOMAINS: TEXT_ONLY_DOMAINS.split(','),
|
TEXT_ONLY_DOMAINS: TEXT_ONLY_DOMAINS.split(','),
|
||||||
DEPRECATED_DOMAIN_LIST: DEPRECATED_DOMAIN_LIST.split(','),
|
DEPRECATED_DOMAIN_LIST: DEPRECATED_DOMAIN_LIST.split(','),
|
||||||
|
@ -21,7 +20,7 @@ export const Constants = {
|
||||||
GUEST_TOKEN_MAX_AGE: 3 * 60 * 60,
|
GUEST_TOKEN_MAX_AGE: 3 * 60 * 60,
|
||||||
/* Twitter Web App actually uses Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA
|
/* Twitter Web App actually uses Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA
|
||||||
instead, but accounts marked as 18+ wouldn't show up then */
|
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: [
|
GUEST_FETCH_PARAMETERS: [
|
||||||
'cards_platform=Web-12',
|
'cards_platform=Web-12',
|
||||||
'include_cards=1',
|
'include_cards=1',
|
||||||
|
@ -30,10 +29,13 @@ export const Constants = {
|
||||||
'include_quote_count=true',
|
'include_quote_count=true',
|
||||||
'include_reply_count=1',
|
'include_reply_count=1',
|
||||||
'tweet_mode=extended',
|
'tweet_mode=extended',
|
||||||
|
'include_entities=true',
|
||||||
'include_ext_media_color=true',
|
'include_ext_media_color=true',
|
||||||
'include_ext_media_availability=true',
|
'include_ext_media_availability=true',
|
||||||
'include_ext_sensitive_media_warning=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('&'),
|
].join('&'),
|
||||||
BASE_HEADERS: {
|
BASE_HEADERS: {
|
||||||
'DNT': `1`,
|
'DNT': `1`,
|
||||||
|
|
|
@ -4,6 +4,8 @@ import { formatNumber, sanitizeText } from '../helpers/utils';
|
||||||
import { Strings } from '../strings';
|
import { Strings } from '../strings';
|
||||||
import { getAuthorText } from '../helpers/author';
|
import { getAuthorText } from '../helpers/author';
|
||||||
import { statusAPI } from '../api/status';
|
import { statusAPI } from '../api/status';
|
||||||
|
import { renderPhoto } from '../render/photo';
|
||||||
|
import { renderVideo } from '../render/video';
|
||||||
|
|
||||||
export const returnError = (error: string): StatusResponse => {
|
export const returnError = (error: string): StatusResponse => {
|
||||||
return {
|
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 */
|
/* If there was any errors fetching the Tweet, we'll return it */
|
||||||
switch (api.code) {
|
switch (api.code) {
|
||||||
case 401:
|
case 401:
|
||||||
|
@ -54,19 +63,29 @@ export const handleStatus = async (
|
||||||
case 404:
|
case 404:
|
||||||
return returnError(Strings.ERROR_TWEET_NOT_FOUND);
|
return returnError(Strings.ERROR_TWEET_NOT_FOUND);
|
||||||
case 500:
|
case 500:
|
||||||
|
console.log(api);
|
||||||
return returnError(Strings.ERROR_API_FAIL);
|
return returnError(Strings.ERROR_API_FAIL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Catch direct media request (d.fxtwitter.com, or .mp4 / .jpg) */
|
/* Catch direct media request (d.fxtwitter.com, or .mp4 / .jpg) */
|
||||||
if (flags?.direct && tweet.media) {
|
if (flags?.direct && tweet.media) {
|
||||||
let redirectUrl: string | null = null;
|
let redirectUrl: string | null = null;
|
||||||
if (tweet.media.videos) {
|
const all = tweet.media.all || [];
|
||||||
const { videos } = tweet.media;
|
// if (tweet.media.videos) {
|
||||||
redirectUrl = (videos[(mediaNumber || 1) - 1] || videos[0]).url;
|
// const { videos } = tweet.media;
|
||||||
} else if (tweet.media.photos) {
|
// redirectUrl = (videos[(mediaNumber || 1) - 1] || videos[0]).url;
|
||||||
const { photos } = tweet.media;
|
// } else if (tweet.media.photos) {
|
||||||
redirectUrl = (photos[(mediaNumber || 1) - 1] || photos[0]).url;
|
// 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) {
|
if (redirectUrl) {
|
||||||
return { response: Response.redirect(redirectUrl, 302) };
|
return { response: Response.redirect(redirectUrl, 302) };
|
||||||
}
|
}
|
||||||
|
@ -143,127 +162,82 @@ export const handleStatus = async (
|
||||||
newText = `${formatText}\n\n` + `${translation.text}\n\n`;
|
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.
|
if (overrideMedia) {
|
||||||
You can still use /video/:number to get a specific video. Otherwise, it'll pick the first. */
|
let instructions: ResponseInstructions;
|
||||||
if (tweet.media?.videos) {
|
|
||||||
authorText = newText || '';
|
|
||||||
|
|
||||||
if (tweet?.translation) {
|
switch (overrideMedia.type) {
|
||||||
authorText = tweet.translation?.text || '';
|
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;
|
||||||
}
|
}
|
||||||
|
} else if (tweet.media?.mosaic) {
|
||||||
const { videos } = tweet.media;
|
const instructions = renderPhoto(
|
||||||
const video = videos[(mediaNumber || 1) - 1];
|
{
|
||||||
|
tweet: tweet,
|
||||||
/* This fix is specific to Discord not wanting to render videos that are too large,
|
authorText: authorText,
|
||||||
or rendering low quality videos too small.
|
engagementText: engagementText,
|
||||||
|
userAgent: userAgent
|
||||||
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)
|
tweet.media?.mosaic
|
||||||
|
|
||||||
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"/>`
|
|
||||||
);
|
);
|
||||||
}
|
headers.push(...instructions.addHeaders);
|
||||||
|
} else if (tweet.media?.videos) {
|
||||||
/* This Tweet has one or more photos to render */
|
const instructions = renderVideo(
|
||||||
if (tweet.media?.photos) {
|
{ tweet: tweet, userAgent: userAgent, text: newText },
|
||||||
const { photos } = tweet.media;
|
tweet.media?.videos[0]
|
||||||
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);
|
||||||
if (!tweet.media.mosaic) {
|
if (instructions.authorText) {
|
||||||
headers.push(
|
authorText = instructions.authorText;
|
||||||
`<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}"/>`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
if (instructions.siteName) {
|
||||||
|
siteName = instructions.siteName;
|
||||||
/* We have external media available to us (i.e. YouTube videos) */
|
}
|
||||||
if (tweet.media?.external) {
|
} 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;
|
const { external } = tweet.media;
|
||||||
authorText = newText || '';
|
authorText = newText || '';
|
||||||
headers.push(
|
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 */
|
/* Notice that user is using deprecated domain */
|
||||||
if (flags?.deprecated) {
|
if (flags?.deprecated) {
|
||||||
|
@ -353,7 +331,7 @@ export const handleStatus = async (
|
||||||
/* Push basic headers relating to author, Tweet text, and site name */
|
/* Push basic headers relating to author, Tweet text, and site name */
|
||||||
headers.push(
|
headers.push(
|
||||||
`<meta property="og:title" content="${tweet.author.name} (@${tweet.author.screen_name})"/>`,
|
`<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}"/>`
|
`<meta property="og:site_name" content="${siteName}"/>`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -377,7 +355,7 @@ export const handleStatus = async (
|
||||||
status
|
status
|
||||||
)}&author=${encodeURIComponent(
|
)}&author=${encodeURIComponent(
|
||||||
tweet.author?.screen_name || ''
|
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 */
|
/* 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 { Constants } from './constants';
|
||||||
import { generateUserAgent } from './helpers/useragent';
|
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 (
|
export const twitterFetch = async (
|
||||||
url: string,
|
url: string,
|
||||||
|
@ -51,14 +58,11 @@ export const twitterFetch = async (
|
||||||
const cache = caches.default;
|
const cache = caches.default;
|
||||||
|
|
||||||
while (apiAttempts < API_ATTEMPTS) {
|
while (apiAttempts < API_ATTEMPTS) {
|
||||||
const csrfToken = crypto
|
/* Generate a random CSRF token, Twitter just cares that header and cookie match,
|
||||||
.randomUUID()
|
REST can use shorter csrf tokens (32 bytes) but graphql prefers 160 bytes */
|
||||||
.replace(
|
const csrfToken = generateCSRFToken();
|
||||||
/-/g,
|
|
||||||
''
|
|
||||||
); /* Generate a random CSRF token, this doesn't matter, Twitter just cares that header and cookie match */
|
|
||||||
|
|
||||||
const headers: { [header: string]: string } = {
|
const headers: Record<string, string> = {
|
||||||
Authorization: Constants.GUEST_BEARER_TOKEN,
|
Authorization: Constants.GUEST_BEARER_TOKEN,
|
||||||
...Constants.BASE_HEADERS
|
...Constants.BASE_HEADERS
|
||||||
};
|
};
|
||||||
|
@ -130,7 +134,7 @@ export const twitterFetch = async (
|
||||||
headers: headers
|
headers: headers
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
response = await apiRequest?.json();
|
response = await apiRequest?.json();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
/* We'll usually only hit this if we get an invalid response from Twitter.
|
/* We'll usually only hit this if we get an invalid response from Twitter.
|
||||||
|
@ -188,20 +192,61 @@ export const fetchConversation = async (
|
||||||
status: string,
|
status: string,
|
||||||
event: FetchEvent,
|
event: FetchEvent,
|
||||||
useElongator = false
|
useElongator = false
|
||||||
): Promise<TimelineBlobPartial> => {
|
): Promise<TweetResultsByRestIdResult> => {
|
||||||
return (await twitterFetch(
|
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,
|
event,
|
||||||
useElongator,
|
useElongator,
|
||||||
(_conversation: unknown) => {
|
(_conversation: unknown) => {
|
||||||
const conversation = _conversation as TimelineBlobPartial;
|
const conversation = _conversation as TweetResultsByRestIdResult;
|
||||||
return !(
|
// If we get a not found error it's still a valid response
|
||||||
typeof conversation.globalObjects === 'undefined' &&
|
const tweet = conversation.data?.tweetResult?.result;
|
||||||
(typeof conversation.errors === 'undefined' ||
|
if (isGraphQLTweet(tweet)) {
|
||||||
conversation.errors?.[0]?.code === 239)
|
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 (
|
export const fetchUser = async (
|
||||||
|
@ -231,6 +276,10 @@ export const fetchUser = async (
|
||||||
// Validator function
|
// Validator function
|
||||||
(_res: unknown) => {
|
(_res: unknown) => {
|
||||||
const response = _res as GraphQLUserResponse;
|
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 !(
|
return !(
|
||||||
response?.data?.user?.result?.__typename !== 'User' ||
|
response?.data?.user?.result?.__typename !== 'User' ||
|
||||||
typeof response.data.user.result.legacy === 'undefined'
|
typeof response.data.user.result.legacy === 'undefined'
|
||||||
|
|
|
@ -1,34 +1,44 @@
|
||||||
import { calculateTimeLeftString } from './pollTime';
|
import { calculateTimeLeftString } from './pollTime';
|
||||||
|
|
||||||
/* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */
|
/* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */
|
||||||
export const renderCard = async (
|
export const renderCard = (
|
||||||
card: TweetCard
|
card: GraphQLTweet['card']
|
||||||
): Promise<{ poll?: APIPoll; external_media?: APIExternalMedia }> => {
|
): { poll?: APIPoll; external_media?: APIExternalMedia } => {
|
||||||
const values = card.binding_values;
|
// 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');
|
console.log('rendering card');
|
||||||
|
|
||||||
if (typeof values !== 'undefined') {
|
if (typeof binding_values !== 'undefined') {
|
||||||
if (typeof values.choice1_count !== 'undefined') {
|
if (typeof binding_values.choice1_count !== 'undefined') {
|
||||||
const poll = {} as APIPoll;
|
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(
|
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 } = {
|
const choices: { [label: string]: number } = {
|
||||||
[values.choice1_label?.string_value || '']: parseInt(
|
[binding_values.choice1_label?.string_value || '']: parseInt(
|
||||||
values.choice1_count?.string_value || '0'
|
binding_values.choice1_count?.string_value || '0'
|
||||||
),
|
),
|
||||||
[values.choice2_label?.string_value || '']: parseInt(
|
[binding_values.choice2_label?.string_value || '']: parseInt(
|
||||||
values.choice2_count?.string_value || '0'
|
binding_values.choice2_count?.string_value || '0'
|
||||||
),
|
),
|
||||||
[values.choice3_label?.string_value || '']: parseInt(
|
[binding_values.choice3_label?.string_value || '']: parseInt(
|
||||||
values.choice3_count?.string_value || '0'
|
binding_values.choice3_count?.string_value || '0'
|
||||||
),
|
),
|
||||||
[values.choice4_label?.string_value || '']: parseInt(
|
[binding_values.choice4_label?.string_value || '']: parseInt(
|
||||||
values.choice4_count?.string_value || '0'
|
binding_values.choice4_count?.string_value || '0'
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -46,17 +56,17 @@ export const renderCard = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
return { poll: poll };
|
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 */
|
/* Oh good, a non-Twitter video URL! This enables YouTube embeds and stuff to just work */
|
||||||
return {
|
return {
|
||||||
external_media: {
|
external_media: {
|
||||||
type: 'video',
|
type: 'video',
|
||||||
url: values.player_url.string_value,
|
url: binding_values.player_url.string_value,
|
||||||
width: parseInt(
|
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
|
), // TODO: Replacing px might not be necessary, it's just there as a precaution
|
||||||
height: parseInt(
|
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 */
|
/* Helps replace t.co links with their originals */
|
||||||
export const linkFixer = (tweet: TweetPartial, text: string): string => {
|
export const linkFixer = (tweet: GraphQLTweet, text: string): string => {
|
||||||
if (typeof tweet.entities?.urls !== 'undefined') {
|
console.log('got entites', {
|
||||||
tweet.entities?.urls.forEach((url: TcoExpansion) => {
|
entities: tweet.legacy.entities,
|
||||||
text = text.replace(url.url, url.expanded_url);
|
})
|
||||||
});
|
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.
|
if (newURL.match(/^https:\/\/twitter\.com\/i\/web\/status\/\w+/g) !== null) {
|
||||||
This means that stuff like the t.co link to pic.twitter.com
|
newURL = '';
|
||||||
will get removed in image/video Tweets */
|
}
|
||||||
text = text.replace(/ ?https:\/\/t\.co\/\w{10}/g, '');
|
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;
|
return text;
|
||||||
};
|
};
|
||||||
|
|
|
@ -29,10 +29,11 @@ export const handleMosaic = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
type: 'mosaic_photo',
|
||||||
formats: {
|
formats: {
|
||||||
jpeg: `${baseUrl}jpeg/${id}${path}`,
|
jpeg: `${baseUrl}jpeg/${id}${path}`,
|
||||||
webp: `${baseUrl}webp/${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! */
|
/* Handles translating Tweets when asked! */
|
||||||
export const translateTweet = async (
|
export const translateTweet = async (
|
||||||
tweet: TweetPartial,
|
tweet: GraphQLTweet,
|
||||||
guestToken: string,
|
guestToken: string,
|
||||||
language: string
|
language: string
|
||||||
): Promise<TranslationPartial | null> => {
|
): Promise<TranslationPartial | null> => {
|
||||||
|
@ -29,7 +29,7 @@ export const translateTweet = async (
|
||||||
|
|
||||||
try {
|
try {
|
||||||
apiRequest = await fetch(
|
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',
|
method: 'GET',
|
||||||
headers: headers
|
headers: headers
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* We keep this value up-to-date for making our requests to Twitter as
|
/* We keep this value up-to-date for making our requests to Twitter as
|
||||||
indistinguishable from normal user traffic as possible. */
|
indistinguishable from normal user traffic as possible. */
|
||||||
const fakeChromeVersion = 112;
|
const fakeChromeVersion = 116;
|
||||||
const platformWindows = 'Windows NT 10.0; Win64; x64';
|
const platformWindows = 'Windows NT 10.0; Win64; x64';
|
||||||
const platformMac = 'Macintosh; Intel Mac OS X 10_15_7';
|
const platformMac = 'Macintosh; Intel Mac OS X 10_15_7';
|
||||||
const platformLinux = 'X11; Linux x86_64';
|
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;
|
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
|
/* 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
|
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
|
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 text = searchParams.get('text') || 'Twitter';
|
||||||
const author = searchParams.get('author') || 'jack';
|
const author = searchParams.get('author') || 'jack';
|
||||||
const status = searchParams.get('status') || '20';
|
const status = searchParams.get('status') || '20';
|
||||||
|
const useXbranding = searchParams.get('useXbranding') === 'true';
|
||||||
|
|
||||||
const random = Math.floor(Math.random() * Object.keys(motd).length);
|
const random = Math.floor(Math.random() * Object.keys(motd).length);
|
||||||
const [name, url] = Object.entries(motd)[random];
|
const [name, url] = Object.entries(motd)[random];
|
||||||
|
@ -304,7 +313,7 @@ router.get('/owoembed', async (request: IRequest) => {
|
||||||
provider_name:
|
provider_name:
|
||||||
searchParams.get('deprecated') === 'true'
|
searchParams.get('deprecated') === 'true'
|
||||||
? Strings.DEPRECATED_DOMAIN_NOTICE_DISCORD
|
? Strings.DEPRECATED_DOMAIN_NOTICE_DISCORD
|
||||||
: name,
|
: (useXbranding ? name : Strings.X_DOMAIN_NOTICE),
|
||||||
provider_url: url,
|
provider_url: url,
|
||||||
title: Strings.DEFAULT_AUTHOR_TEXT,
|
title: Strings.DEFAULT_AUTHOR_TEXT,
|
||||||
type: 'link',
|
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})`,
|
QUOTE_TEXT: `↘️ Quoting {name} (@{screen_name})`,
|
||||||
TRANSLATE_TEXT: `↘️ Translated from {language}`,
|
TRANSLATE_TEXT: `↘️ Translated from {language}`,
|
||||||
TRANSLATE_TEXT_INTL: `↘️ {source} ➡️ {destination}`,
|
TRANSLATE_TEXT_INTL: `↘️ {source} ➡️ {destination}`,
|
||||||
PHOTO_COUNT: `Photo {number} of {total}`,
|
PHOTO_COUNT: `Photo {number} / {total}`,
|
||||||
VIDEO_COUNT: `Video {number} of {total}`,
|
VIDEO_COUNT: `Video {number} / {total}`,
|
||||||
|
MEDIA_COUNT: `Media {number} / {total}`,
|
||||||
|
|
||||||
SINGULAR_DAY_LEFT: 'day left',
|
SINGULAR_DAY_LEFT: 'day left',
|
||||||
PLURAL_DAYS_LEFT: 'days 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',
|
PLURAL_SECONDS_LEFT: 'seconds left',
|
||||||
FINAL_POLL_RESULTS: 'Final results',
|
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_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_TWEET_NOT_FOUND: `Sorry, that Tweet doesn't exist :(`,
|
||||||
ERROR_USER_NOT_FOUND: `Sorry, that user doesn't exist :(`,
|
ERROR_USER_NOT_FOUND: `Sorry, that user doesn't exist :(`,
|
||||||
|
@ -208,5 +209,6 @@ Disallow: /owoembed
|
||||||
Disallow: /owoembed/
|
Disallow: /owoembed/
|
||||||
Allow: /watch?v=dQw4w9WgXcQ
|
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: string;
|
||||||
declare const BRANDING_NAME_DISCORD: string;
|
|
||||||
declare const DIRECT_MEDIA_DOMAINS: string;
|
declare const DIRECT_MEDIA_DOMAINS: string;
|
||||||
declare const TEXT_ONLY_DOMAINS: string;
|
declare const TEXT_ONLY_DOMAINS: string;
|
||||||
declare const DEPRECATED_DOMAIN_LIST: 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;
|
api?: boolean;
|
||||||
deprecated?: boolean;
|
deprecated?: boolean;
|
||||||
textOnly?: boolean;
|
textOnly?: boolean;
|
||||||
|
isXDomain?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface StatusResponse {
|
interface StatusResponse {
|
||||||
|
@ -15,6 +16,24 @@ interface StatusResponse {
|
||||||
cacheControl?: string | null;
|
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 {
|
interface Request {
|
||||||
params: {
|
params: {
|
||||||
[param: string]: string;
|
[param: string]: string;
|
||||||
|
@ -91,15 +110,26 @@ interface APIPoll {
|
||||||
time_left_en: string;
|
time_left_en: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface APIPhoto {
|
interface APIMedia {
|
||||||
type: 'photo';
|
type: string;
|
||||||
url: string;
|
url: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface APIPhoto extends APIMedia {
|
||||||
|
type: 'photo';
|
||||||
altText: string;
|
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';
|
type: 'mosaic_photo';
|
||||||
formats: {
|
formats: {
|
||||||
webp: string;
|
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 {
|
interface APITweet {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
@ -140,12 +160,13 @@ interface APITweet {
|
||||||
external?: APIExternalMedia;
|
external?: APIExternalMedia;
|
||||||
photos?: APIPhoto[];
|
photos?: APIPhoto[];
|
||||||
videos?: APIVideo[];
|
videos?: APIVideo[];
|
||||||
|
all?: APIMedia[];
|
||||||
mosaic?: APIMosaicPhoto;
|
mosaic?: APIMosaicPhoto;
|
||||||
};
|
};
|
||||||
|
|
||||||
lang: string | null;
|
lang: string | null;
|
||||||
possibly_sensitive: boolean;
|
possibly_sensitive: boolean;
|
||||||
|
|
||||||
replying_to: string | null;
|
replying_to: string | null;
|
||||||
replying_to_status: 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 () => {
|
test('API fetch video Tweet', async () => {
|
||||||
const result = await cacheWrapper(
|
const result = await cacheWrapper(
|
||||||
new Request('https://api.fxtwitter.com/Twitter/status/854416760933556224', {
|
new Request('https://api.fxtwitter.com/X/status/854416760933556224', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: botHeaders
|
headers: botHeaders
|
||||||
})
|
})
|
||||||
|
@ -112,12 +112,12 @@ test('API fetch video Tweet', async () => {
|
||||||
|
|
||||||
const tweet = response.tweet as APITweet;
|
const tweet = response.tweet as APITweet;
|
||||||
expect(tweet).toBeTruthy();
|
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.id).toEqual('854416760933556224');
|
||||||
expect(tweet.text).toEqual(
|
expect(tweet.text).toEqual(
|
||||||
'Get the sauces ready, #NuggsForCarter has 3 million+ Retweets.'
|
'Get the sauces ready, #NuggsForCarter has 3 million+ Retweets.'
|
||||||
);
|
);
|
||||||
expect(tweet.author.screen_name?.toLowerCase()).toEqual('twitter');
|
expect(tweet.author.screen_name?.toLowerCase()).toEqual('x');
|
||||||
expect(tweet.author.id).toEqual('783214');
|
expect(tweet.author.id).toEqual('783214');
|
||||||
expect(tweet.author.name).toBeTruthy();
|
expect(tweet.author.name).toBeTruthy();
|
||||||
expect(tweet.author.avatar_url).toBeTruthy();
|
expect(tweet.author.avatar_url).toBeTruthy();
|
||||||
|
@ -158,10 +158,10 @@ test('API fetch multi-photo Tweet', async () => {
|
||||||
|
|
||||||
const tweet = response.tweet as APITweet;
|
const tweet = response.tweet as APITweet;
|
||||||
expect(tweet).toBeTruthy();
|
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.id).toEqual('1445094085593866246');
|
||||||
expect(tweet.text).toEqual('@netflix');
|
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.id).toEqual('783214');
|
||||||
expect(tweet.author.name).toBeTruthy();
|
expect(tweet.author.name).toBeTruthy();
|
||||||
expect(tweet.author.avatar_url).toBeTruthy();
|
expect(tweet.author.avatar_url).toBeTruthy();
|
||||||
|
@ -206,10 +206,10 @@ test('API fetch poll Tweet', async () => {
|
||||||
|
|
||||||
const tweet = response.tweet as APITweet;
|
const tweet = response.tweet as APITweet;
|
||||||
expect(tweet).toBeTruthy();
|
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.id).toEqual('1055475950543167488');
|
||||||
expect(tweet.text).toEqual('A poll:');
|
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.id).toEqual('783214');
|
||||||
expect(tweet.author.name).toBeTruthy();
|
expect(tweet.author.name).toBeTruthy();
|
||||||
expect(tweet.author.avatar_url).toBeTruthy();
|
expect(tweet.author.avatar_url).toBeTruthy();
|
||||||
|
@ -243,7 +243,7 @@ test('API fetch poll Tweet', async () => {
|
||||||
|
|
||||||
test('API fetch user', async () => {
|
test('API fetch user', async () => {
|
||||||
const result = await cacheWrapper(
|
const result = await cacheWrapper(
|
||||||
new Request('https://api.fxtwitter.com/twitter', {
|
new Request('https://api.fxtwitter.com/x', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: botHeaders
|
headers: botHeaders
|
||||||
})
|
})
|
||||||
|
@ -256,9 +256,9 @@ test('API fetch user', async () => {
|
||||||
|
|
||||||
const user = response.user as APIUser;
|
const user = response.user as APIUser;
|
||||||
expect(user).toBeTruthy();
|
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.id).toEqual('783214');
|
||||||
expect(user.screen_name).toEqual('Twitter');
|
expect(user.screen_name).toEqual('X');
|
||||||
expect(user.followers).toEqual(expect.any(Number));
|
expect(user.followers).toEqual(expect.any(Number));
|
||||||
expect(user.following).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
|
// 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.likes).toEqual(expect.any(Number));
|
||||||
// expect(user.verified).toEqual('business');
|
// expect(user.verified).toEqual('business');
|
||||||
expect(user.joined).toEqual('Tue Feb 20 14:35:54 +0000 2007');
|
expect(user.joined).toEqual('Tue Feb 20 14:35:54 +0000 2007');
|
||||||
expect(user.birthday.day).toEqual(21);
|
// expect(user.birthday.day).toEqual(21);
|
||||||
expect(user.birthday.month).toEqual(3);
|
// expect(user.birthday.month).toEqual(3);
|
||||||
expect(user.birthday.year).toBeUndefined();
|
// 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 path = require('path');
|
||||||
const webpack = require('webpack');
|
const webpack = require('webpack');
|
||||||
const SentryWebpackPlugin = require('@sentry/webpack-plugin');
|
const { sentryWebpackPlugin } = require('@sentry/webpack-plugin');
|
||||||
|
|
||||||
const gitCommit = require('child_process')
|
const gitCommit = require('child_process')
|
||||||
.execSync('git rev-parse --short HEAD')
|
.execSync('git rev-parse --short HEAD')
|
||||||
|
@ -19,7 +19,6 @@ require('dotenv').config();
|
||||||
|
|
||||||
let envVariables = [
|
let envVariables = [
|
||||||
'BRANDING_NAME',
|
'BRANDING_NAME',
|
||||||
'BRANDING_NAME_DISCORD',
|
|
||||||
'DIRECT_MEDIA_DOMAINS',
|
'DIRECT_MEDIA_DOMAINS',
|
||||||
'TEXT_ONLY_DOMAINS',
|
'TEXT_ONLY_DOMAINS',
|
||||||
'HOST_URL',
|
'HOST_URL',
|
||||||
|
@ -45,7 +44,7 @@ let plugins = [
|
||||||
|
|
||||||
if (process.env.SENTRY_AUTH_TOKEN) {
|
if (process.env.SENTRY_AUTH_TOKEN) {
|
||||||
plugins.push(
|
plugins.push(
|
||||||
new SentryWebpackPlugin({
|
sentryWebpackPlugin({
|
||||||
release: releaseName,
|
release: releaseName,
|
||||||
include: './dist',
|
include: './dist',
|
||||||
urlPrefix: '~/',
|
urlPrefix: '~/',
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
name = "fixtweet"
|
name = "fixtweet"
|
||||||
account_id = "[CLOUDFLARE_ACCOUNT_ID]"
|
account_id = "[CLOUDFLARE_ACCOUNT_ID]"
|
||||||
main = "./dist/worker.js"
|
main = "./dist/worker.js"
|
||||||
compatibility_date = "2022-08-17"
|
compatibility_date = "2023-08-15"
|
||||||
send_metrics = false
|
send_metrics = false
|
||||||
services = [
|
services = [
|
||||||
{ binding = "TwitterProxy", service = "elongator" }
|
{ binding = "TwitterProxy", service = "elongator" }
|
||||||
|
|
Loading…
Add table
Reference in a new issue