Merge branch 'main' into discord-native-multi-image

This commit is contained in:
dangered wolf 2024-03-01 15:09:40 -05:00 committed by GitHub
commit 385b5cd017
Signed by: DevComp
GPG key ID: B5690EEEBB952194
25 changed files with 2673 additions and 681 deletions

BIN
.github/readme/directmedia.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

BIN
.github/readme/fixtweet.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 MiB

BIN
.github/readme/poll.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
.github/readme/quote.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
.github/readme/tco.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
.github/readme/translate.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
.github/readme/videos.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

View file

@ -29,13 +29,13 @@
### For `twitter.com` links on Discord, send a link and type `s/e/p` to make `twittpr.com`.
<img src="https://cdn.discordapp.com/attachments/165560751363325952/1006346785985417307/fixtweet.webp">
<img src="https://raw.githubusercontent.com/FixTweet/FxTwitter/main/.github/readme/fixtweet.webp">
## Embed Videos
We all have videos of memes and other things from Twitter we want to quickly share with friends. With normal Twitter links, embedding videos is often broken on Discord and impossible on Telegram. But using FxTwitter, we embed the raw mp4 file so it's compatible with just about anything supporting video embeds.
![](https://cdn.discordapp.com/attachments/165560751363325952/1184627562811494461/video.png)
![](https://raw.githubusercontent.com/FixTweet/FxTwitter/main/.github/readme/videos.png)
On Discord, we'll also automatically embed videos linked from other platforms, such as YouTube, so they can play without having to open a browser.
@ -43,13 +43,13 @@ On Discord, we'll also automatically embed videos linked from other platforms, s
If you want to share the results of a Twitter poll, you can do so by just linking the post using FxTwitter.
![](https://cdn.discordapp.com/attachments/165560751363325952/1006331192372629544/FixTweet.png)
![](https://raw.githubusercontent.com/FixTweet/FxTwitter/main/.github/readme/poll.png)
## Embed Quotes & Media
Quotes and their media can provide important context to a post. So we'll automatically add said context, and even media if there isn't already media embedded in the quote.
![](https://cdn.discordapp.com/attachments/165560751363325952/1184630052797816863/quote.png)
![](https://raw.githubusercontent.com/FixTweet/FxTwitter/main/.github/readme/quote.png)
## Translate Posts
@ -57,19 +57,19 @@ You can translate a post into any other supported language, with the original an
Just append a post with its 2-letter ISO language code. So for English, add `/en` at the end.
![](https://cdn.discordapp.com/attachments/165560751363325952/1184625715522572298/translation.png)
![](https://raw.githubusercontent.com/FixTweet/FxTwitter/main/.github/readme/translate.png)
## Gallery view
Use `g.fxtwitter.com` or `g.fixupx.com` to generate minimal embeds with just the post's media and author information without other distractions. This can be particularly useful for read-only channels dedicated to sharing media.
![](https://cdn.discordapp.com/attachments/165560751363325952/1182788212482117662/gallery.png)
![](https://raw.githubusercontent.com/FixTweet/FxTwitter/main/.github/readme/gallery.png)
## Direct media links
Want to link directly to a post's media without the embed? You can easily do that using FxTwitter.
![Image demonstrating the feature](https://cdn.discordapp.com/attachments/165560751363325952/1006338772192989194/FixTweet.png)
![Image demonstrating the feature](https://raw.githubusercontent.com/FixTweet/FxTwitter/main/.github/readme/directmedia.png)
There are a few supported ways to do this:
@ -87,12 +87,12 @@ In the future we plan to do even more with Instant View such as embedding entire
Examples from above:
- `https://d.fxtwitter.com/dangeredwolf/status/1548119328498728960`
- `https://fxtwitter.com/dangeredwolf/status/1548117889437208581.jpg`
- `https://d.fxtwitter.com/example/status/1548119328498728960`
- `https://fxtwitter.com/example/status/1548117889437208581.jpg`
Posts with multiple images are supported, so you can do something like this and it will pick the correct one:
`https://d.fxtwitter.com/dangeredwolf/status/1547514042146865153/photo/3`
`https://d.fxtwitter.com/example/status/1547514042146865153/photo/3`
Otherwise, it will default to the first image.
@ -100,7 +100,7 @@ Otherwise, it will default to the first image.
The default Twitter embeds include t.co link shorteners, which make it difficult to know where the link is heading. We automatically replace t.co links with their original links to make things clearer.
![](https://cdn.discordapp.com/attachments/165560751363325952/1006348698659344464/FixTweet.png)
![](https://github.com/FixTweet/FxTwitter/blob/main/.github/readme/tco.png)
## Redirect to Nitter or other custom instances
@ -228,3 +228,7 @@ Feel free to [open an issue](https://github.com/FixTweet/FxTwitter/issues)
[Mosaic](https://github.com/FixTweet/mosaic) Multi-image combiner by [Antonio32A](https://github.com/Antonio32A)
& other contributions by [Antonio32A](https://github.com/Antonio32A), [Burner](https://github.com/YaBoiBurner), [Deer-Spangle](https://github.com/Deer-Spangle), [Eramdam](https://github.com/Eramdam), [SirStendec](https://github.com/SirStendec), [SpeedyFolf](https://github.com/SpeedyFolf), [Wazbat](https://github.com/Wazbat)
## Disclaimer
Twitter, Tweet, and X are trademarks of X Corp. This project is not affiliated in any way with X Corp or Twitter.

3136
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,29 +16,29 @@
"author": "dangered wolf",
"license": "MIT",
"devDependencies": {
"@cloudflare/workers-types": "^4.20240117.0",
"@cloudflare/workers-types": "^4.20240222.0",
"@microsoft/eslint-formatter-sarif": "^3.0.0",
"@sentry/esbuild-plugin": "^2.10.2",
"@sentry/integrations": "^7.94.1",
"@types/jest": "^29.5.11",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"dotenv": "^16.3.2",
"eslint": "^8.56.0",
"@sentry/esbuild-plugin": "^2.14.2",
"@sentry/integrations": "^7.104.0",
"@types/jest": "^29.5.12",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"dotenv": "^16.4.5",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-typescript": "^3.0.0",
"eslint-plugin-optimize-regex": "^1.2.1",
"eslint-plugin-sonarjs": "^0.23.0",
"eslint-plugin-sonarjs": "^0.24.0",
"jest": "^29.7.0",
"jest-environment-miniflare": "^2.14.2",
"prettier": "^3.2.4",
"ts-jest": "^29.1.1",
"prettier": "^3.2.5",
"ts-jest": "^29.1.2",
"ts-loader": "^9.5.1",
"typescript": "^5.3.3",
"wrangler": "^3.23.0"
"wrangler": "^3.30.1"
},
"dependencies": {
"@hono/sentry": "^1.0.0",
"hono": "^3.12.6"
"@hono/sentry": "^1.0.1",
"hono": "^3.12.12"
}
}

View file

@ -20,6 +20,13 @@ export const cacheMiddleware = (): MiddlewareHandler => async (c, next) => {
console.log('cacheUrl', cacheUrl);
let cacheKey: Request;
const returnAsJson = Constants.API_HOST_LIST.includes(cacheUrl.hostname);
/* If caching unavailable, ignore the rest of the cache middleware */
if (typeof caches === 'undefined') {
await next();
return c.res.clone();
}
try {
cacheKey = new Request(cacheUrl.toString(), request);
@ -74,17 +81,21 @@ export const cacheMiddleware = (): MiddlewareHandler => async (c, next) => {
case 'DELETE':
console.log('Purging cache as requested');
await cache.delete(cacheKey);
return c.text('');
if (returnAsJson) return c.json('');
return c.html('');
/* yes, we do give HEAD */
case 'HEAD':
return c.text('');
if (returnAsJson) return c.json('');
return c.html('');
/* We properly state our OPTIONS when asked */
case 'OPTIONS':
c.header('allow', Constants.RESPONSE_HEADERS.allow);
c.status(204);
return c.text('');
if (returnAsJson) return c.json('');
return c.html('');
default:
c.status(405);
return c.text('');
if (returnAsJson) return c.json('');
return c.html('');
}
};

View file

@ -18,7 +18,7 @@ export const Constants = {
TWITTER_GLOBAL_NAME_ROOT: 'twitter.com',
TWITTER_API_ROOT: 'https://api.twitter.com',
BOT_UA_REGEX:
/bot|facebook|embed|got|firefox\/92|firefox\/38|curl|wget|go-http|yahoo|generator|whatsapp|revoltchat|preview|link|proxy|vkshare|images|analyzer|index|crawl|spider|python|cfnetwork|node|mastodon|http\.rb|ruby|bun\/|fiddler/gi,
/bot|facebook|embed|got|firefox\/92|firefox\/38|curl|wget|go-http|yahoo|generator|whatsapp|revoltchat|preview|link|proxy|vkshare|images|analyzer|index|crawl|spider|python|cfnetwork|node|mastodon|http\.rb|ruby|bun\/|fiddler|iframely/gi,
/* 3 hours */
GUEST_TOKEN_MAX_AGE: 3 * 60 * 60,
GUEST_BEARER_TOKEN: `Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA`,

View file

@ -30,7 +30,7 @@ const Experiments: { [key in Experiment]: ExperimentConfig } = {
[Experiment.DISCORD_NATIVE_MULTI_IMAGE]: {
name: 'Discord native multi-image',
description: 'Use Discord native multi-image',
percentage: 0.5
percentage: 0
}
};

View file

@ -59,11 +59,11 @@ export const twitterFetch = async (
}
);
const cache = caches.default;
const cache = typeof caches !== 'undefined' ? caches.default : null;
while (apiAttempts < API_ATTEMPTS) {
/* 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 */
REST can use shorter csrf tokens (32 bytes) but graphql uses a 160 byte one. */
const csrfToken = crypto.randomUUID().replace(/-/g, '');
const headers: Record<string, string> = {
@ -75,7 +75,12 @@ export const twitterFetch = async (
let activate: Response | null = null;
if (!newTokenGenerated && !useElongator) {
if (cache === null) {
console.log('Caching unavailable, requesting new token');
newTokenGenerated = true;
}
if (!newTokenGenerated && !useElongator && cache) {
const timeBefore = performance.now();
const cachedResponse = await cache.match(guestTokenRequestCacheDummy.clone());
const timeAfter = performance.now();
@ -138,13 +143,12 @@ export const twitterFetch = async (
if (useElongator && typeof c.env?.TwitterProxy !== 'undefined') {
console.log('Fetching using elongator');
const performanceStart = performance.now();
apiRequest = await withTimeout(
(signal: AbortSignal) =>
c.env?.TwitterProxy.fetch(url, {
method: 'GET',
headers: headers,
signal: signal
})
apiRequest = await withTimeout((signal: AbortSignal) =>
c.env?.TwitterProxy.fetch(url, {
method: 'GET',
headers: headers,
signal: signal
})
);
const performanceEnd = performance.now();
console.log(`Elongator request successful after ${performanceEnd - performanceStart}ms`);
@ -173,6 +177,7 @@ export const twitterFetch = async (
}
try {
!useElongator &&
cache &&
c.executionCtx &&
c.executionCtx.waitUntil(
cache.delete(guestTokenRequestCacheDummy.clone(), { ignoreMethod: true })
@ -208,6 +213,7 @@ export const twitterFetch = async (
console.log(`Purging token on this edge due to low rate limit remaining`);
try {
c.executionCtx &&
cache &&
c.executionCtx.waitUntil(
cache.delete(guestTokenRequestCacheDummy.clone(), { ignoreMethod: true })
);
@ -232,7 +238,7 @@ export const twitterFetch = async (
}
try {
/* If we've generated a new token, we'll cache it */
if (c.executionCtx && newTokenGenerated && activate) {
if (c.executionCtx && newTokenGenerated && activate && cache) {
const cachingResponse = new Response(await activate.clone().text(), {
headers: {
...tokenHeaders,

View file

@ -3,7 +3,11 @@ import { calculateTimeLeftString } from './pollTime';
/* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */
export const renderCard = (
card: GraphQLTwitterStatus['card']
): { poll?: APIPoll; external_media?: APIExternalMedia } => {
): {
poll?: APIPoll;
external_media?: APIExternalMedia;
media?: { videos: TweetMedia[]; photos: TweetMedia[] };
} => {
if (!Array.isArray(card.legacy?.binding_values)) {
return {};
}
@ -58,5 +62,37 @@ export const renderCard = (
};
}
if (binding_values.unified_card?.string_value) {
try {
const card = JSON.parse(binding_values.unified_card.string_value);
const mediaEntities = card?.media_entities as Record<string, TweetMedia>;
if (mediaEntities) {
const media = {
videos: [] as TweetMedia[],
photos: [] as TweetMedia[]
};
Object.keys(mediaEntities).forEach(key => {
const mediaItem = mediaEntities[key];
switch (mediaItem.type) {
case 'photo':
media.photos.push(mediaItem);
break;
case 'animated_gif':
case 'video':
media.videos.push(mediaItem);
break;
}
});
console.log('media', media);
return { media: media };
}
} catch (e) {
console.error('Failed to parse unified card JSON', e);
}
}
return {};
};

View file

@ -20,7 +20,8 @@ export const processMedia = (media: TweetMedia): APIPhoto | APIVideo | null => {
width: media.original_info?.width,
height: media.original_info?.height,
format: bestVariant?.content_type || '',
type: media.type === 'animated_gif' ? 'gif' : 'video'
type: media.type === 'animated_gif' ? 'gif' : 'video',
variants: media.video_info?.variants ?? []
};
}
return null;

View file

@ -64,13 +64,12 @@ export const translateStatus = async (
tweet.rest_id ?? tweet.legacy?.id_str
},destinationLanguage=None,translationSource=Some(Google),feature=None,timeout=None,onlyCached=None/translation/service/translateTweet`;
console.log(url, headers);
translationApiResponse = (await withTimeout(
(signal: AbortSignal) =>
c.env?.TwitterProxy.fetch(url, {
method: 'GET',
headers: headers,
signal: signal
})
translationApiResponse = (await withTimeout((signal: AbortSignal) =>
c.env?.TwitterProxy.fetch(url, {
method: 'GET',
headers: headers,
signal: signal
})
)) as Response;
translationResults = (await translationApiResponse.json()) as TranslationPartial;

View file

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

View file

@ -58,11 +58,11 @@ export async function withTimeout<T>(
}
export const formatNumber = (num: number) => {
if (num >= 1e6) {
return (num / 1e6).toFixed(2) + 'M';
} else if (num >= 1e3) {
return (num / 1e3).toFixed(1) + 'K';
} else {
return num.toString();
}
};
if (num >= 1e6) {
return (num / 1e6).toFixed(2) + 'M';
} else if (num >= 1e3) {
return (num / 1e3).toFixed(1) + 'K';
} else {
return num.toString();
}
};

View file

@ -230,6 +230,33 @@ export const buildAPITwitterStatus = async (
if (card.poll) {
apiStatus.poll = card.poll;
}
/* TODO: Right now, we push them after native photos and videos but should we prepend them instead? */
if (card.media) {
if (card.media.videos) {
card.media.videos.forEach(video => {
const mediaObject = processMedia(video) as APIVideo;
if (mediaObject) {
apiStatus.media.all = apiStatus.media?.all ?? [];
apiStatus.media?.all?.push(mediaObject);
apiStatus.media.videos = apiStatus.media?.videos ?? [];
apiStatus.media.videos?.push(mediaObject);
}
});
}
if (card.media.photos) {
card.media.photos.forEach(photo => {
const mediaObject = processMedia(photo) as APIPhoto;
if (mediaObject) {
apiStatus.media.all = apiStatus.media?.all ?? [];
apiStatus.media?.all?.push(mediaObject);
apiStatus.media.photos = apiStatus.media?.photos ?? [];
apiStatus.media.photos?.push(mediaObject);
}
});
}
}
} else {
/* Determine if the status contains a YouTube link (either youtube.com or youtu.be) so we can include it */
const youtubeIdRegex = /(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([^\s&]+)/;

View file

@ -102,5 +102,8 @@ twitter.get('/hashtag/:hashtag', genericTwitterRedirect);
twitter.get('/:handle/', _profileRequest);
twitter.get('/:handle', _profileRequest);
/* Redirect profile subpages in case someone links them for some reason (https://github.com/FixTweet/FxTwitter/issues/603) */
twitter.get('/:handle/:subpage', genericTwitterRedirect);
twitter.get('/:handle/:subpage/', genericTwitterRedirect);
twitter.all('*', async c => c.redirect(Constants.REDIRECT_URL, 302));

View file

@ -43,7 +43,7 @@ export const statusRequest = async (c: Context) => {
Also note that all we're doing here is setting the direct flag. If someone
links a video and ends it with .jpg, it will still redirect to a .mp4! */
if (url.pathname.match(/\/status(es)?\/\d{2,20}\.(mp4|png|jpe?g)/g)) {
if (url.pathname.match(/\/status(es)?\/\d{2,20}\.(mp4|png|jpe?g|gifv?)/g)) {
console.log('Direct media request by extension');
flags.direct = true;
} else if (Constants.DIRECT_MEDIA_DOMAINS.includes(url.hostname)) {
@ -58,7 +58,7 @@ export const statusRequest = async (c: Context) => {
} else if (Constants.GALLERY_DOMAINS.includes(url.hostname)) {
console.log('Gallery embed request');
flags.gallery = true;
} else if (Constants.NATIVE_MULTI_IMAGE_DOMAINS.includes(url.hostname)) {
} else if (Constants.NATIVE_MULTI_IMAGE_DOMAINS.includes(url.hostname)) {
console.log('Force mosaic request');
flags.nativeMultiImage = true;
} else if (prefix === 'dl' || prefix === 'dir') {

View file

@ -101,6 +101,7 @@ interface APIVideo extends APIMedia {
thumbnail_url: string;
format: string;
duration: number;
variants: TweetMediaFormat[];
}
interface APIMosaicPhoto extends APIMedia {

View file

@ -420,7 +420,8 @@ type GraphQLTwitterStatus = {
| 'last_updated_datetime_utc'
| 'duration_minutes'
| 'api'
| 'card_url';
| 'card_url'
| 'unified_card';
value:
| {
string_value: string; // "Option text"

View file

@ -10,7 +10,8 @@ import { twitter } from './realms/twitter/router';
import { cacheMiddleware } from './caches';
const noCache = 'max-age=0, no-cache, no-store, must-revalidate';
const embeddingClientRegex = /(discordbot|telegrambot|facebook|whatsapp|firefox\/92|vkshare|revoltchat|preview)/gi;
const embeddingClientRegex =
/(discordbot|telegrambot|facebook|whatsapp|firefox\/92|vkshare|revoltchat|preview|iframely)/gi;
/* This is the root app which contains route trees for multiple "realms".