Merge branch 'main' into discord-native-multi-image
BIN
.github/readme/directmedia.png
vendored
Normal file
After Width: | Height: | Size: 366 KiB |
BIN
.github/readme/fixtweet.webp
vendored
Normal file
After Width: | Height: | Size: 5 MiB |
BIN
.github/readme/poll.png
vendored
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
.github/readme/quote.png
vendored
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
.github/readme/tco.png
vendored
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
.github/readme/translate.png
vendored
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
.github/readme/videos.png
vendored
Normal file
After Width: | Height: | Size: 359 KiB |
26
README.md
|
@ -29,13 +29,13 @@
|
||||||
|
|
||||||
### For `twitter.com` links on Discord, send a link and type `s/e/p` to make `twittpr.com`.
|
### 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
|
## 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.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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.
|
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.
|
If you want to share the results of a Twitter poll, you can do so by just linking the post using FxTwitter.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Embed Quotes & Media
|
## 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.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Translate Posts
|
## 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.
|
Just append a post with its 2-letter ISO language code. So for English, add `/en` at the end.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Gallery view
|
## 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.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Direct media links
|
## Direct media links
|
||||||
|
|
||||||
Want to link directly to a post's media without the embed? You can easily do that using FxTwitter.
|
Want to link directly to a post's media without the embed? You can easily do that using FxTwitter.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
There are a few supported ways to do this:
|
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:
|
Examples from above:
|
||||||
|
|
||||||
- `https://d.fxtwitter.com/dangeredwolf/status/1548119328498728960`
|
- `https://d.fxtwitter.com/example/status/1548119328498728960`
|
||||||
- `https://fxtwitter.com/dangeredwolf/status/1548117889437208581.jpg`
|
- `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:
|
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.
|
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.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Redirect to Nitter or other custom instances
|
## 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)
|
[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)
|
& 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
28
package.json
|
@ -16,29 +16,29 @@
|
||||||
"author": "dangered wolf",
|
"author": "dangered wolf",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20240117.0",
|
"@cloudflare/workers-types": "^4.20240222.0",
|
||||||
"@microsoft/eslint-formatter-sarif": "^3.0.0",
|
"@microsoft/eslint-formatter-sarif": "^3.0.0",
|
||||||
"@sentry/esbuild-plugin": "^2.10.2",
|
"@sentry/esbuild-plugin": "^2.14.2",
|
||||||
"@sentry/integrations": "^7.94.1",
|
"@sentry/integrations": "^7.104.0",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||||
"@typescript-eslint/parser": "^6.19.0",
|
"@typescript-eslint/parser": "^7.1.0",
|
||||||
"dotenv": "^16.3.2",
|
"dotenv": "^16.4.5",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.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.23.0",
|
"eslint-plugin-sonarjs": "^0.24.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-miniflare": "^2.14.2",
|
"jest-environment-miniflare": "^2.14.2",
|
||||||
"prettier": "^3.2.4",
|
"prettier": "^3.2.5",
|
||||||
"ts-jest": "^29.1.1",
|
"ts-jest": "^29.1.2",
|
||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.1",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"wrangler": "^3.23.0"
|
"wrangler": "^3.30.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/sentry": "^1.0.0",
|
"@hono/sentry": "^1.0.1",
|
||||||
"hono": "^3.12.6"
|
"hono": "^3.12.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,13 @@ export const cacheMiddleware = (): MiddlewareHandler => async (c, next) => {
|
||||||
console.log('cacheUrl', cacheUrl);
|
console.log('cacheUrl', cacheUrl);
|
||||||
|
|
||||||
let cacheKey: Request;
|
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 {
|
try {
|
||||||
cacheKey = new Request(cacheUrl.toString(), request);
|
cacheKey = new Request(cacheUrl.toString(), request);
|
||||||
|
@ -74,17 +81,21 @@ export const cacheMiddleware = (): MiddlewareHandler => async (c, next) => {
|
||||||
case 'DELETE':
|
case 'DELETE':
|
||||||
console.log('Purging cache as requested');
|
console.log('Purging cache as requested');
|
||||||
await cache.delete(cacheKey);
|
await cache.delete(cacheKey);
|
||||||
return c.text('');
|
if (returnAsJson) return c.json('');
|
||||||
|
return c.html('');
|
||||||
/* yes, we do give HEAD */
|
/* yes, we do give HEAD */
|
||||||
case 'HEAD':
|
case 'HEAD':
|
||||||
return c.text('');
|
if (returnAsJson) return c.json('');
|
||||||
|
return c.html('');
|
||||||
/* We properly state our OPTIONS when asked */
|
/* We properly state our OPTIONS when asked */
|
||||||
case 'OPTIONS':
|
case 'OPTIONS':
|
||||||
c.header('allow', Constants.RESPONSE_HEADERS.allow);
|
c.header('allow', Constants.RESPONSE_HEADERS.allow);
|
||||||
c.status(204);
|
c.status(204);
|
||||||
return c.text('');
|
if (returnAsJson) return c.json('');
|
||||||
|
return c.html('');
|
||||||
default:
|
default:
|
||||||
c.status(405);
|
c.status(405);
|
||||||
return c.text('');
|
if (returnAsJson) return c.json('');
|
||||||
|
return c.html('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,7 +18,7 @@ export const Constants = {
|
||||||
TWITTER_GLOBAL_NAME_ROOT: 'twitter.com',
|
TWITTER_GLOBAL_NAME_ROOT: 'twitter.com',
|
||||||
TWITTER_API_ROOT: 'https://api.twitter.com',
|
TWITTER_API_ROOT: 'https://api.twitter.com',
|
||||||
BOT_UA_REGEX:
|
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 */
|
/* 3 hours */
|
||||||
GUEST_TOKEN_MAX_AGE: 3 * 60 * 60,
|
GUEST_TOKEN_MAX_AGE: 3 * 60 * 60,
|
||||||
GUEST_BEARER_TOKEN: `Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA`,
|
GUEST_BEARER_TOKEN: `Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA`,
|
||||||
|
|
|
@ -30,7 +30,7 @@ const Experiments: { [key in Experiment]: ExperimentConfig } = {
|
||||||
[Experiment.DISCORD_NATIVE_MULTI_IMAGE]: {
|
[Experiment.DISCORD_NATIVE_MULTI_IMAGE]: {
|
||||||
name: 'Discord native multi-image',
|
name: 'Discord native multi-image',
|
||||||
description: 'Use Discord native multi-image',
|
description: 'Use Discord native multi-image',
|
||||||
percentage: 0.5
|
percentage: 0
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
18
src/fetch.ts
|
@ -59,11 +59,11 @@ export const twitterFetch = async (
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const cache = caches.default;
|
const cache = typeof caches !== 'undefined' ? caches.default : null;
|
||||||
|
|
||||||
while (apiAttempts < API_ATTEMPTS) {
|
while (apiAttempts < API_ATTEMPTS) {
|
||||||
/* Generate a random CSRF token, Twitter just cares that header and cookie match,
|
/* Generate a random CSRF token, Twitter just cares that header and cookie match,
|
||||||
REST can use shorter csrf tokens (32 bytes) but graphql prefers 160 bytes */
|
REST can use shorter csrf tokens (32 bytes) but graphql uses a 160 byte one. */
|
||||||
const csrfToken = crypto.randomUUID().replace(/-/g, '');
|
const csrfToken = crypto.randomUUID().replace(/-/g, '');
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
|
@ -75,7 +75,12 @@ export const twitterFetch = async (
|
||||||
|
|
||||||
let activate: Response | null = null;
|
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 timeBefore = performance.now();
|
||||||
const cachedResponse = await cache.match(guestTokenRequestCacheDummy.clone());
|
const cachedResponse = await cache.match(guestTokenRequestCacheDummy.clone());
|
||||||
const timeAfter = performance.now();
|
const timeAfter = performance.now();
|
||||||
|
@ -138,8 +143,7 @@ export const twitterFetch = async (
|
||||||
if (useElongator && typeof c.env?.TwitterProxy !== 'undefined') {
|
if (useElongator && typeof c.env?.TwitterProxy !== 'undefined') {
|
||||||
console.log('Fetching using elongator');
|
console.log('Fetching using elongator');
|
||||||
const performanceStart = performance.now();
|
const performanceStart = performance.now();
|
||||||
apiRequest = await withTimeout(
|
apiRequest = await withTimeout((signal: AbortSignal) =>
|
||||||
(signal: AbortSignal) =>
|
|
||||||
c.env?.TwitterProxy.fetch(url, {
|
c.env?.TwitterProxy.fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
|
@ -173,6 +177,7 @@ export const twitterFetch = async (
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
!useElongator &&
|
!useElongator &&
|
||||||
|
cache &&
|
||||||
c.executionCtx &&
|
c.executionCtx &&
|
||||||
c.executionCtx.waitUntil(
|
c.executionCtx.waitUntil(
|
||||||
cache.delete(guestTokenRequestCacheDummy.clone(), { ignoreMethod: true })
|
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`);
|
console.log(`Purging token on this edge due to low rate limit remaining`);
|
||||||
try {
|
try {
|
||||||
c.executionCtx &&
|
c.executionCtx &&
|
||||||
|
cache &&
|
||||||
c.executionCtx.waitUntil(
|
c.executionCtx.waitUntil(
|
||||||
cache.delete(guestTokenRequestCacheDummy.clone(), { ignoreMethod: true })
|
cache.delete(guestTokenRequestCacheDummy.clone(), { ignoreMethod: true })
|
||||||
);
|
);
|
||||||
|
@ -232,7 +238,7 @@ export const twitterFetch = async (
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
/* If we've generated a new token, we'll cache it */
|
/* 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(), {
|
const cachingResponse = new Response(await activate.clone().text(), {
|
||||||
headers: {
|
headers: {
|
||||||
...tokenHeaders,
|
...tokenHeaders,
|
||||||
|
|
|
@ -3,7 +3,11 @@ 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 = (
|
export const renderCard = (
|
||||||
card: GraphQLTwitterStatus['card']
|
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)) {
|
if (!Array.isArray(card.legacy?.binding_values)) {
|
||||||
return {};
|
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 {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,7 +20,8 @@ export const processMedia = (media: TweetMedia): APIPhoto | APIVideo | null => {
|
||||||
width: media.original_info?.width,
|
width: media.original_info?.width,
|
||||||
height: media.original_info?.height,
|
height: media.original_info?.height,
|
||||||
format: bestVariant?.content_type || '',
|
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;
|
return null;
|
||||||
|
|
|
@ -64,8 +64,7 @@ export const translateStatus = async (
|
||||||
tweet.rest_id ?? tweet.legacy?.id_str
|
tweet.rest_id ?? tweet.legacy?.id_str
|
||||||
},destinationLanguage=None,translationSource=Some(Google),feature=None,timeout=None,onlyCached=None/translation/service/translateTweet`;
|
},destinationLanguage=None,translationSource=Some(Google),feature=None,timeout=None,onlyCached=None/translation/service/translateTweet`;
|
||||||
console.log(url, headers);
|
console.log(url, headers);
|
||||||
translationApiResponse = (await withTimeout(
|
translationApiResponse = (await withTimeout((signal: AbortSignal) =>
|
||||||
(signal: AbortSignal) =>
|
|
||||||
c.env?.TwitterProxy.fetch(url, {
|
c.env?.TwitterProxy.fetch(url, {
|
||||||
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 = 120;
|
const fakeChromeVersion = 121;
|
||||||
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';
|
||||||
|
|
|
@ -230,6 +230,33 @@ export const buildAPITwitterStatus = async (
|
||||||
if (card.poll) {
|
if (card.poll) {
|
||||||
apiStatus.poll = 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 {
|
} else {
|
||||||
/* Determine if the status contains a YouTube link (either youtube.com or youtu.be) so we can include it */
|
/* 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&]+)/;
|
const youtubeIdRegex = /(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([^\s&]+)/;
|
||||||
|
|
|
@ -102,5 +102,8 @@ twitter.get('/hashtag/:hashtag', genericTwitterRedirect);
|
||||||
|
|
||||||
twitter.get('/:handle/', _profileRequest);
|
twitter.get('/:handle/', _profileRequest);
|
||||||
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));
|
twitter.all('*', async c => c.redirect(Constants.REDIRECT_URL, 302));
|
||||||
|
|
|
@ -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
|
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! */
|
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');
|
console.log('Direct media request by extension');
|
||||||
flags.direct = true;
|
flags.direct = true;
|
||||||
} else if (Constants.DIRECT_MEDIA_DOMAINS.includes(url.hostname)) {
|
} else if (Constants.DIRECT_MEDIA_DOMAINS.includes(url.hostname)) {
|
||||||
|
|
1
src/types/types.d.ts
vendored
|
@ -101,6 +101,7 @@ interface APIVideo extends APIMedia {
|
||||||
thumbnail_url: string;
|
thumbnail_url: string;
|
||||||
format: string;
|
format: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
variants: TweetMediaFormat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface APIMosaicPhoto extends APIMedia {
|
interface APIMosaicPhoto extends APIMedia {
|
||||||
|
|
3
src/types/vendor/twitter.d.ts
vendored
|
@ -420,7 +420,8 @@ type GraphQLTwitterStatus = {
|
||||||
| 'last_updated_datetime_utc'
|
| 'last_updated_datetime_utc'
|
||||||
| 'duration_minutes'
|
| 'duration_minutes'
|
||||||
| 'api'
|
| 'api'
|
||||||
| 'card_url';
|
| 'card_url'
|
||||||
|
| 'unified_card';
|
||||||
value:
|
value:
|
||||||
| {
|
| {
|
||||||
string_value: string; // "Option text"
|
string_value: string; // "Option text"
|
||||||
|
|
|
@ -10,7 +10,8 @@ import { twitter } from './realms/twitter/router';
|
||||||
import { cacheMiddleware } from './caches';
|
import { cacheMiddleware } from './caches';
|
||||||
|
|
||||||
const noCache = 'max-age=0, no-cache, no-store, must-revalidate';
|
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".
|
/* This is the root app which contains route trees for multiple "realms".
|
||||||
|
|
||||||
|
|