Merge pull request #10 from dangeredwolf/api

Merge API revamp into main
This commit is contained in:
dangered wolf 2022-07-25 20:04:35 -04:00 committed by GitHub
commit 09f18a2f9c
Signed by: DevComp
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 794 additions and 430 deletions

View file

@ -1,6 +1,7 @@
BRANDING_NAME = "FixTweet"
BRANDING_NAME_DISCORD = "FixTweet by @dangeredwolf - 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"
MOSAIC_DOMAIN_LIST = "mosaic.pxtwitter.com"
MOSAIC_DOMAIN_LIST = "mosaic.fxtwitter.com"
API_HOST = "api.fxtwitter.com"
HOST_URL = "https://fxtwitter.com"
REDIRECT_URL = "https://github.com/dangeredwolf/FixTweet"

215
package-lock.json generated
View file

@ -1,11 +1,11 @@
{
"name": "pxtwitter",
"name": "fixtweet",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "pxtwitter",
"name": "fixtweet",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
@ -21,7 +21,7 @@
"typescript": "^4.7.4",
"webpack": "^5.73.0",
"webpack-cli": "^4.10.0",
"wrangler": "^2.0.22"
"wrangler": "^2.0.23"
}
},
"node_modules/@cloudflare/kv-asset-handler": {
@ -675,6 +675,28 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
"integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
"dev": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/blake3-wasm": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
@ -777,6 +799,33 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chrome-trace-event": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
@ -1414,6 +1463,18 @@
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/glob-to-regexp": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
@ -1496,6 +1557,18 @@
"node": ">= 0.10"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-core-module": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz",
@ -1508,6 +1581,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -1832,6 +1926,15 @@
"integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
"dev": true
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@ -1961,6 +2064,18 @@
"safe-buffer": "^5.1.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/realistic-structured-clone": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/realistic-structured-clone/-/realistic-structured-clone-1.0.1.tgz",
@ -2610,15 +2725,16 @@
"dev": true
},
"node_modules/wrangler": {
"version": "2.0.22",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-2.0.22.tgz",
"integrity": "sha512-mCKNvv3Yq8ClBaiEKZ/KGTYhwhf5r5ElkTNtUj50Y0Qo9JJYvLnphMteEjfnID5iopv2FxmHDeRSn/Jx7zSAkw==",
"version": "2.0.23",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-2.0.23.tgz",
"integrity": "sha512-qMyK/pmHIrubxuJuXnCwcidY4LlQImcTTyOGoqMJtNz8y+pDfsluExSBpwqGSl3JPUQF2FiofqaBkj/iB8rvYw==",
"dev": true,
"dependencies": {
"@cloudflare/kv-asset-handler": "^0.2.0",
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"blake3-wasm": "^2.1.5",
"chokidar": "^3.5.3",
"esbuild": "0.14.47",
"miniflare": "^2.6.0",
"nanoid": "^3.3.3",
@ -3236,6 +3352,22 @@
"color-convert": "^2.0.1"
}
},
"anymatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
"integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
"dev": true,
"requires": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
}
},
"binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true
},
"blake3-wasm": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
@ -3300,6 +3432,22 @@
"supports-color": "^7.1.0"
}
},
"chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"dev": true,
"requires": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"fsevents": "~2.3.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
}
},
"chrome-trace-event": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
@ -3685,6 +3833,15 @@
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
},
"glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"requires": {
"is-glob": "^4.0.1"
}
},
"glob-to-regexp": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
@ -3746,6 +3903,15 @@
"integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
"dev": true
},
"is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"requires": {
"binary-extensions": "^2.0.0"
}
},
"is-core-module": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz",
@ -3755,6 +3921,21 @@
"has": "^1.0.3"
}
},
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true
},
"is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"requires": {
"is-extglob": "^2.1.1"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -3999,6 +4180,12 @@
"integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
"dev": true
},
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@ -4089,6 +4276,15 @@
"safe-buffer": "^5.1.0"
}
},
"readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"requires": {
"picomatch": "^2.2.1"
}
},
"realistic-structured-clone": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/realistic-structured-clone/-/realistic-structured-clone-1.0.1.tgz",
@ -4531,15 +4727,16 @@
"dev": true
},
"wrangler": {
"version": "2.0.22",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-2.0.22.tgz",
"integrity": "sha512-mCKNvv3Yq8ClBaiEKZ/KGTYhwhf5r5ElkTNtUj50Y0Qo9JJYvLnphMteEjfnID5iopv2FxmHDeRSn/Jx7zSAkw==",
"version": "2.0.23",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-2.0.23.tgz",
"integrity": "sha512-qMyK/pmHIrubxuJuXnCwcidY4LlQImcTTyOGoqMJtNz8y+pDfsluExSBpwqGSl3JPUQF2FiofqaBkj/iB8rvYw==",
"dev": true,
"requires": {
"@cloudflare/kv-asset-handler": "^0.2.0",
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"blake3-wasm": "^2.1.5",
"chokidar": "^3.5.3",
"esbuild": "0.14.47",
"fsevents": "~2.3.2",
"miniflare": "^2.6.0",

View file

@ -1,5 +1,5 @@
{
"name": "pxtwitter",
"name": "fixtweet",
"version": "1.0.0",
"description": "Embed Twitter videos, polls, and more on Discord and Telegram",
"main": "dist/worker.js",
@ -23,7 +23,7 @@
"typescript": "^4.7.4",
"webpack": "^5.73.0",
"webpack-cli": "^4.10.0",
"wrangler": "^2.0.22"
"wrangler": "^2.0.23"
},
"dependencies": {
"itty-router": "^2.6.1"

189
src/api.ts Normal file
View file

@ -0,0 +1,189 @@
import { renderCard } from './card';
import { Constants } from './constants';
import { fetchUsingGuest } from './fetch';
import { linkFixer } from './linkFixer';
import { handleMosaic } from './mosaic';
import { colorFromPalette } from './palette';
import { translateTweet } from './translate';
const processMedia = (media: TweetMedia): APIPhoto | APIVideo | null => {
if (media.type === 'photo') {
return {
type: 'photo',
url: media.media_url_https,
width: media.original_info.width,
height: media.original_info.height
};
} else if (media.type === 'video' || media.type === 'animated_gif') {
// Find the variant with the highest bitrate
let bestVariant = media.video_info?.variants?.reduce?.((a, b) =>
(a.bitrate ?? 0) > (b.bitrate ?? 0) ? a : b
);
return {
url: bestVariant?.url || '',
thumbnail_url: media.media_url_https,
width: media.original_info.width,
height: media.original_info.height,
format: bestVariant?.content_type || '',
type: media.type === 'animated_gif' ? 'gif' : 'video'
};
}
return null;
};
const populateTweetProperties = async (
tweet: TweetPartial,
conversation: TimelineBlobPartial,
language: string = 'en'
): Promise<APITweet> => {
let apiTweet = {} as APITweet;
/* With v2 conversation API we re-add the user object ot the tweet because
Twitter stores it separately in the conversation API. This is to consolidate
it in case a user appears multiple times in a thread. */
tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {};
const user = tweet.user as UserPartial;
const screenName = user?.screen_name || '';
const name = user?.name || '';
apiTweet.url = `${Constants.TWITTER_ROOT}/${screenName}/status/${tweet.id_str}`;
apiTweet.text = linkFixer(tweet, tweet.full_text);
apiTweet.author = {
name: name,
screen_name: screenName,
avatar_url: user?.profile_image_url_https.replace('_normal', '_200x200') || '',
avatar_color: colorFromPalette(
tweet.user?.profile_image_extensions_media_color?.palette || []
),
banner_url: user?.profile_banner_url || ''
};
apiTweet.replies = tweet.reply_count;
apiTweet.retweets = tweet.retweet_count;
apiTweet.likes = tweet.favorite_count;
apiTweet.color = apiTweet.author.avatar_color;
apiTweet.twitter_card = 'tweet';
if (tweet.lang !== 'unk') {
apiTweet.lang = tweet.lang;
} else {
apiTweet.lang = null;
}
apiTweet.replying_to = tweet.in_reply_to_screen_name || null;
let mediaList = Array.from(
tweet.extended_entities?.media || tweet.entities?.media || []
);
mediaList.forEach(media => {
let mediaObject = processMedia(media);
if (mediaObject) {
if (mediaObject.type === 'photo') {
apiTweet.twitter_card = 'summary_large_image';
apiTweet.media = apiTweet.media || {};
apiTweet.media.photos = apiTweet.media.photos || [];
apiTweet.media.photos.push(mediaObject);
} else if (mediaObject.type === 'video' || mediaObject.type === 'gif') {
apiTweet.twitter_card = 'player';
apiTweet.media = apiTweet.media || {};
apiTweet.media.video = mediaObject as APIVideo;
}
}
});
if (mediaList[0]?.ext_media_color?.palette) {
apiTweet.color = colorFromPalette(mediaList[0].ext_media_color.palette);
}
if ((apiTweet.media?.photos?.length || 0) > 1) {
let mosaic = await handleMosaic(apiTweet.media?.photos || []);
if (typeof apiTweet.media !== 'undefined' && mosaic !== null) {
apiTweet.media.mosaic = mosaic;
}
}
if (tweet.card) {
let card = await renderCard(tweet.card);
if (card.external_media) {
apiTweet.twitter_card = 'summary_large_image';
apiTweet.media = apiTweet.media || {};
apiTweet.media.external = card.external_media;
}
if (card.poll) {
apiTweet.poll = card.poll;
}
}
/* If a language is specified, let's try translating it! */
if (typeof language === 'string' && language.length === 2 && language !== tweet.lang) {
let translateAPI = await translateTweet(
tweet,
conversation.guestToken || '',
language
);
apiTweet.translation = {
text: translateAPI?.translation || '',
source_lang: translateAPI?.sourceLanguage || '',
target_lang: translateAPI?.destinationLanguage || ''
};
}
return apiTweet;
};
export const statusAPI = async (
event: FetchEvent,
status: string,
language: string
): Promise<APIResponse> => {
const conversation = await fetchUsingGuest(status, event);
const tweet = conversation?.globalObjects?.tweets?.[status] || {};
console.log('users', JSON.stringify(conversation?.globalObjects?.users));
console.log('user_id_str', tweet.user_id_str);
/* Fallback for if Tweet did not load */
if (typeof tweet.full_text === 'undefined') {
console.log('Invalid status, got tweet ', tweet, ' conversation ', conversation);
/* We've got timeline instructions, so the Tweet is probably private */
if (conversation.timeline?.instructions?.length > 0) {
return { code: 401, message: 'PRIVATE_TWEET' };
}
/* {"errors":[{"code":34,"message":"Sorry, that page does not exist."}]} */
if (conversation.errors?.[0]?.code === 34) {
return { code: 404, message: 'NOT_FOUND' };
}
/* Tweets object is completely missing, smells like API failure */
if (typeof conversation?.globalObjects?.tweets === 'undefined') {
return { code: 500, message: 'API_FAIL' };
}
/* If we have no idea what happened then just return API error */
return { code: 500, message: 'API_FAIL' };
}
let response: APIResponse = { code: 200, message: 'OK' } as APIResponse;
let apiTweet: APITweet = (await populateTweetProperties(
tweet,
conversation,
language
)) as APITweet;
let quoteTweet =
conversation.globalObjects?.tweets?.[tweet.quoted_status_id_str || '0'] || null;
if (quoteTweet) {
apiTweet.quote = (await populateTweetProperties(
quoteTweet,
conversation,
language
)) as APITweet;
}
response.tweet = apiTweet;
return response;
};

View file

@ -1,15 +1,15 @@
export const getAuthorText = (tweet: TweetPartial): string | null => {
/* Build out reply, retweet, like counts */
if (tweet.favorite_count > 0 || tweet.retweet_count > 0 || tweet.reply_count > 0) {
export const getAuthorText = (tweet: APITweet): string | null => {
/* Build out reply, retweet, like counts */
if (tweet.likes > 0 || tweet.retweets > 0 || tweet.replies > 0) {
let authorText = '';
if (tweet.reply_count > 0) {
authorText += `${tweet.reply_count} 💬 `;
if (tweet.replies > 0) {
authorText += `${tweet.replies} 💬 `;
}
if (tweet.retweet_count > 0) {
authorText += `${tweet.retweet_count} 🔁 `;
if (tweet.retweets > 0) {
authorText += `${tweet.retweets} 🔁 `;
}
if (tweet.favorite_count > 0) {
authorText += `${tweet.favorite_count} ❤️ `;
if (tweet.likes > 0) {
authorText += `${tweet.likes} ❤️ `;
}
authorText = authorText.trim();
@ -17,4 +17,4 @@ export const getAuthorText = (tweet: TweetPartial): string | null => {
}
return null;
}
};

View file

@ -1,66 +1,16 @@
import { Strings } from './strings';
let barLength = 36;
export const calculateTimeLeft = (date: Date) => {
const now = new Date();
const diff = date.getTime() - now.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
return { days, hours, minutes, seconds };
};
export const calculateTimeLeftString = (date: Date) => {
const { days, hours, minutes, seconds } = calculateTimeLeft(date);
const daysString =
days > 0
? `${days} ${days === 1 ? Strings.SINGULAR_DAY_LEFT : Strings.PLURAL_DAYS_LEFT}`
: '';
const hoursString =
hours > 0
? `${hours} ${hours === 1 ? Strings.SINGULAR_HOUR_LEFT : Strings.PLURAL_HOURS_LEFT}`
: '';
const minutesString =
minutes > 0
? `${minutes} ${
minutes === 1 ? Strings.SINGULAR_MINUTE_LEFT : Strings.PLURAL_MINUTES_LEFT
}`
: '';
const secondsString =
seconds > 0
? `${seconds} ${
seconds === 1 ? Strings.SINGULAR_SECOND_LEFT : Strings.PLURAL_SECONDS_LEFT
}`
: '';
return (
daysString ||
hoursString ||
minutesString ||
secondsString ||
Strings.FINAL_POLL_RESULTS
);
};
import { calculateTimeLeftString } from './pollHelper';
export const renderCard = async (
card: TweetCard,
headers: string[],
userAgent: string = ''
): Promise<string> => {
card: TweetCard
): Promise<{ poll?: APIPoll; external_media?: APIExternalMedia }> => {
let str = '\n\n';
const values = card.binding_values;
console.log('rendering card on ', card);
// Telegram's bars need to be a lot smaller to fit its bubbles
if (userAgent.indexOf('Telegram') > -1) {
barLength = 24;
}
let choices: { [label: string]: number } = {};
let totalVotes = 0;
let timeLeft = '';
if (typeof values !== 'undefined') {
/* TODO: make poll code cleaner */
@ -68,9 +18,13 @@ export const renderCard = async (
typeof values.choice1_count !== 'undefined' &&
typeof values.choice2_count !== 'undefined'
) {
let poll = {} as APIPoll;
if (typeof values.end_datetime_utc !== 'undefined') {
poll.ends_at = values.end_datetime_utc.string_value || '';
const date = new Date(values.end_datetime_utc.string_value);
timeLeft = calculateTimeLeftString(date);
poll.time_left_en = calculateTimeLeftString(date);
}
choices[values.choice1_label?.string_value || ''] = parseInt(
values.choice1_count.string_value
@ -87,46 +41,36 @@ export const renderCard = async (
totalVotes += parseInt(values.choice3_count.string_value);
}
if (typeof values.choice4_count !== 'undefined') {
choices[values.choice4_label?.string_value || ''] = parseInt(
values.choice4_count.string_value
);
choices[values.choice4_label?.string_value || ''] =
parseInt(values.choice4_count.string_value) || 0;
totalVotes += parseInt(values.choice4_count.string_value);
}
for (const [label, votes] of Object.entries(choices)) {
// render bar
const bar = '█'.repeat(Math.round((votes / totalVotes || 0) * barLength));
str += `${bar}
${label}  (${Math.round((votes / totalVotes || 0) * 100)}%)
`;
}
poll.total_votes = totalVotes;
poll.choices = Object.keys(choices).map(label => {
return {
label: label,
count: choices[label],
percentage: (Math.round((choices[label] / totalVotes) * 1000) || 0) / 10 || 0
};
});
str += `\n${totalVotes} votes · ${timeLeft}`;
return { poll: poll };
/* Oh good, a non-Twitter video URL! This enables YouTube embeds and stuff to just work */
} else if (typeof values.player_url !== 'undefined') {
headers.push(
`<meta name="twitter:player" content="${values.player_url.string_value}">`,
`<meta name="twitter:player:width" content="${
values.player_width?.string_value || '1280'
}">`,
`<meta name="twitter:player:height" content="${
values.player_height?.string_value || '720'
}">`,
`<meta property="og:type" content="video.other">`,
`<meta property="og:video:url" content="${values.player_url.string_value}">`,
`<meta property="og:video:secure_url" content="${values.player_url.string_value}">`,
`<meta property="og:video:width" content="${
values.player_width?.string_value || '1280'
}">`,
`<meta property="og:video:height" content="${
values.player_height?.string_value || '720'
}">`
);
/* A control sequence I made up to tell status.ts that external media is being embedded */
str = 'EMBED_CARD';
return {
external_media: {
type: 'video',
url: values.player_url.string_value,
width: parseInt(
(values.player_width?.string_value || '1280').replace('px', '')
), // TODO: Replacing px might not be necessary, it's just there as a precaution
height: parseInt(
(values.player_height?.string_value || '720').replace('px', '')
)
}
};
}
}
return str;
return {};
};

View file

@ -5,6 +5,7 @@ export const Constants = {
BRANDING_NAME_DISCORD: BRANDING_NAME_DISCORD,
DIRECT_MEDIA_DOMAINS: DIRECT_MEDIA_DOMAINS.split(','),
MOSAIC_DOMAIN_LIST: MOSAIC_DOMAIN_LIST.split(','),
API_HOST: API_HOST,
HOST_URL: HOST_URL,
REDIRECT_URL: REDIRECT_URL,
TWITTER_ROOT: 'https://twitter.com',

1
src/env.d.ts vendored
View file

@ -4,3 +4,4 @@ declare const DIRECT_MEDIA_DOMAINS: string;
declare const HOST_URL: string;
declare const REDIRECT_URL: string;
declare const MOSAIC_DOMAIN_LIST: string;
declare const API_HOST: string;

View file

@ -1,9 +1,8 @@
import { Constants } from './constants';
export const handleMosaic = async (
mediaList: TweetMedia[],
userAgent: string
): Promise<TweetMedia> => {
mediaList: APIPhoto[]
): Promise<APIMosaicPhoto | null> => {
let mosaicDomains = Constants.MOSAIC_DOMAIN_LIST;
let selectedDomain: string | null = null;
while (selectedDomain === null && mosaicDomains.length > 0) {
@ -20,41 +19,37 @@ export const handleMosaic = async (
// Fallback if all Mosaic servers are down
if (selectedDomain === null) {
return mediaList[0];
return null;
} else {
// console.log('mediaList', mediaList);
let mosaicMedia = mediaList.map(
media =>
media.media_url_https?.match(/(?<=\/media\/)[a-zA-Z0-9_\-]+(?=[\.\?])/g)?.[0] ||
''
media => media.url?.match(/(?<=\/media\/)[a-zA-Z0-9_\-]+(?=[\.\?])/g)?.[0] || ''
);
// console.log('mosaicMedia', mosaicMedia);
// TODO: use a better system for this, 0 gets png 1 gets webp, usually
let constructUrl = `https://${selectedDomain}/${
userAgent.indexOf('Telegram') > -1 ? 'jpeg' : 'webp'
}/0`;
let baseUrl = `https://${selectedDomain}/`;
let path = '';
if (mosaicMedia[0]) {
constructUrl += `/${mosaicMedia[0]}`;
path += `/${mosaicMedia[0]}`;
}
if (mosaicMedia[1]) {
constructUrl += `/${mosaicMedia[1]}`;
path += `/${mosaicMedia[1]}`;
}
if (mosaicMedia[2]) {
constructUrl += `/${mosaicMedia[2]}`;
path += `/${mosaicMedia[2]}`;
}
if (mosaicMedia[3]) {
constructUrl += `/${mosaicMedia[3]}`;
path += `/${mosaicMedia[3]}`;
}
console.log(`Mosaic URL: ${constructUrl}`);
return {
media_url_https: constructUrl,
original_info: {
height: mediaList.reduce((acc, media) => acc + media.original_info?.height, 0),
width: mediaList.reduce((acc, media) => acc + media.original_info?.width, 0)
},
type: 'photo'
} as TweetMedia;
height: mediaList.reduce((acc, media) => acc + media.height, 0),
width: mediaList.reduce((acc, media) => acc + media.width, 0),
formats: {
jpeg: `${baseUrl}jpeg${path}`,
webp: `${baseUrl}webp${path}`
}
} as APIMosaicPhoto;
}
};

View file

@ -14,12 +14,22 @@ export const colorFromPalette = (palette: MediaPlaceholderColor[]) => {
const rgb = palette[i].rgb;
// We need vibrant colors, grey backgrounds won't do!
if (rgb.red + rgb.green + rgb.blue < 120) {
if (
rgb.red + rgb.green + rgb.blue < 120 ||
rgb.red + rgb.green + rgb.blue > 240 * 3
) {
continue;
}
return rgbToHex(rgb.red, rgb.green, rgb.blue);
}
/* If no other color passes vibrancy test (not too white or black)
Then we'll use the top color anyway. */
if (palette?.[0]?.rgb) {
console.log('falling back to top color regardless of vibrancy');
return rgbToHex(palette[0].rgb.red, palette[0].rgb.green, palette[0].rgb.blue);
}
return Constants.DEFAULT_COLOR;
};

42
src/pollHelper.ts Normal file
View file

@ -0,0 +1,42 @@
import { Strings } from './strings';
export const calculateTimeLeft = (date: Date) => {
const now = new Date();
const diff = date.getTime() - now.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
return { days, hours, minutes, seconds };
};
export const calculateTimeLeftString = (date: Date) => {
const { days, hours, minutes, seconds } = calculateTimeLeft(date);
const daysString =
days > 0
? `${days} ${days === 1 ? Strings.SINGULAR_DAY_LEFT : Strings.PLURAL_DAYS_LEFT}`
: '';
const hoursString =
hours > 0
? `${hours} ${hours === 1 ? Strings.SINGULAR_HOUR_LEFT : Strings.PLURAL_HOURS_LEFT}`
: '';
const minutesString =
minutes > 0
? `${minutes} ${
minutes === 1 ? Strings.SINGULAR_MINUTE_LEFT : Strings.PLURAL_MINUTES_LEFT
}`
: '';
const secondsString =
seconds > 0
? `${seconds} ${
seconds === 1 ? Strings.SINGULAR_SECOND_LEFT : Strings.PLURAL_SECONDS_LEFT
}`
: '';
return (
daysString ||
hoursString ||
minutesString ||
secondsString ||
Strings.FINAL_POLL_RESULTS
);
};

View file

@ -1,17 +1,16 @@
import { linkFixer } from './linkFixer';
import { Strings } from './strings';
export const handleQuote = (quote: TweetPartial): string | null => {
console.log('Quoting status ', quote.id_str);
export const handleQuote = (quote: APITweet): string | null => {
console.log('Quoting status ', quote.id);
let str = `\n`;
str += Strings.QUOTE_TEXT.format({
name: quote.user?.name,
screen_name: quote.user?.screen_name
name: quote.author?.name,
screen_name: quote.author?.screen_name
});
str += ` \n\n`;
str += linkFixer(quote, quote.full_text);
str += quote.text;
return str;
};

View file

@ -15,18 +15,22 @@ const statusRequest = async (
const userAgent = request.headers.get('User-Agent') || '';
let isBotUA =
userAgent.match(/bot|facebook|embed|got|Firefox\/92|curl|wget/gi) !== null;
userAgent.match(/bot|facebook|embed|got|Firefox\/92|curl|wget/gi) !== null || true;
if (
url.pathname.match(/\/status(es)?\/\d+\.(mp4|png|jpg)/g) !== null ||
Constants.DIRECT_MEDIA_DOMAINS.includes(url.hostname) ||
(prefix === 'dl' || prefix === 'dir')
prefix === 'dl' ||
prefix === 'dir'
) {
console.log('Direct media request by extension');
flags.direct = true;
}
if (url.pathname.match(/\/status(es)?\/\d+\.(json)/g) !== null) {
if (
url.pathname.match(/\/status(es)?\/\d+\.(json)/g) !== null ||
url.hostname === Constants.API_HOST
) {
console.log('JSON API request');
flags.api = true;
}
@ -94,6 +98,8 @@ router.get('/:prefix?/:handle/statuses/:id/photos/:mediaNumber', statusRequest);
router.get('/:prefix?/:handle/statuses/:id/video/:mediaNumber', statusRequest);
router.get('/:prefix?/:handle/status/:id/:language', statusRequest);
router.get('/:prefix?/:handle/statuses/:id/:language', statusRequest);
router.get('/status/:id', statusRequest);
router.get('/status/:id/:language', statusRequest);
router.get('/owoembed', async (request: Request) => {
console.log('oembed hit!');
@ -162,15 +168,17 @@ const cacheWrapper = async (event: FetchEvent): Promise<Response> => {
switch (request.method) {
case 'GET':
let cachedResponse = await cache.match(cacheKey);
if (cacheUrl.hostname !== Constants.API_HOST) {
let cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
console.log('Cache hit');
return cachedResponse;
if (cachedResponse) {
console.log('Cache hit');
return cachedResponse;
}
console.log('Cache miss');
}
console.log('Cache miss');
let response = await router.handle(event.request, event);
// Store the fetched response as cacheKey

View file

@ -1,14 +1,9 @@
import { Constants } from './constants';
import { fetchUsingGuest } from './fetch';
import { linkFixer } from './linkFixer';
import { colorFromPalette } from './palette';
import { renderCard } from './card';
import { handleQuote } from './quote';
import { sanitizeText } from './utils';
import { Strings } from './strings';
import { handleMosaic } from './mosaic';
import { translateTweet } from './translate';
import { getAuthorText } from './author';
import { statusAPI } from './api';
export const returnError = (error: string): StatusResponse => {
return {
@ -32,253 +27,100 @@ export const handleStatus = async (
): Promise<StatusResponse> => {
console.log('Direct?', flags?.direct);
const conversation = await fetchUsingGuest(status, event);
let api = await statusAPI(event, status, language || 'en');
const tweet = api?.tweet as APITweet;
const tweet = conversation?.globalObjects?.tweets?.[status] || {};
/* With v2 conversation API we re-add the user object ot the tweet because
Twitter stores it separately in the conversation API. This is to consolidate
it in case a user appears multiple times in a thread. */
tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {};
if (flags?.api) {
return {
response: new Response(JSON.stringify(api), {
headers: { ...Constants.RESPONSE_HEADERS, 'content-type': 'application/json' },
status: api.code
})
};
}
let headers: string[] = [];
let redirectMedia = '';
/* Fallback for if Tweet did not load */
if (typeof tweet.full_text === 'undefined') {
console.log('Invalid status, got tweet ', tweet, ' conversation ', conversation);
/* We've got timeline instructions, so the Tweet is probably private */
if (conversation.timeline?.instructions?.length > 0) {
switch (api.code) {
case 401:
return returnError(Strings.ERROR_PRIVATE);
}
/* {"errors":[{"code":34,"message":"Sorry, that page does not exist."}]} */
if (conversation.errors?.[0]?.code === 34) {
case 404:
return returnError(Strings.ERROR_TWEET_NOT_FOUND);
}
/* Tweets object is completely missing, smells like API failure */
if (typeof conversation?.globalObjects?.tweets === 'undefined') {
case 500:
return returnError(Strings.ERROR_API_FAIL);
}
if (flags?.direct) {
if (tweet.media) {
let redirectUrl: string | null = null;
if (tweet.media.video) {
redirectUrl = tweet.media.video.url;
} else if (tweet.media.photos) {
redirectUrl = (tweet.media.photos[mediaNumber || 0] || tweet.media.photos[0]).url;
}
if (redirectUrl) {
return { response: Response.redirect(redirectUrl, 302) };
}
}
/* If we have no idea what happened then just return API error */
return returnError(Strings.ERROR_API_FAIL);
}
let text = tweet.full_text;
let engagementText = '';
const user = tweet.user;
const screenName = user?.screen_name || '';
const name = user?.name || '';
/* If a language is specified, let's try translating it! */
if (typeof language === 'string' && language.length === 2) {
text = await translateTweet(tweet, conversation.guestToken || '', language || 'en');
/* Use quote media if there is no media */
if (!tweet.media && tweet.quote?.media) {
tweet.media = tweet.quote.media;
tweet.twitter_card = 'summary_large_image';
}
let mediaList = Array.from(
tweet.extended_entities?.media || tweet.entities?.media || []
);
let authorText = getAuthorText(tweet) || Strings.DEFAULT_AUTHOR_TEXT;
let engagementText = authorText.replace(/ /g, ' ');
// engagementText has less spacing than authorText
engagementText = authorText.replace(/ /g, ' ');
let headers: string[] = [
`<meta content="${tweet.color}" property="theme-color"/>`,
`<meta name="twitter:card" content="${tweet.twitter_card}"/>`,
`<meta name="twitter:site" content="@${tweet.author.screen_name}"/>`,
`<meta name="twitter:creator" content="@${tweet.author.screen_name}"/>`,
`<meta name="twitter:title" content="${tweet.author.name} (@${tweet.author.screen_name})"/>`
];
text = linkFixer(tweet, text);
/* Video renderer */
if (tweet.media?.video) {
authorText = encodeURIComponent(tweet.text || '');
/* Cards are used by polls and non-Twitter video embeds */
if (tweet.card) {
let cardRender = await renderCard(tweet.card, headers, userAgent);
if (cardRender === 'EMBED_CARD') {
authorText = encodeURIComponent(text);
} else {
text += cardRender;
}
}
/* Trying to uncover a quote tweet referenced by this tweet */
let quoteTweetMaybe =
conversation.globalObjects?.tweets?.[tweet.quoted_status_id_str || '0'] || null;
if (quoteTweetMaybe) {
/* Populate quote tweet user from globalObjects */
quoteTweetMaybe.user =
conversation?.globalObjects?.users?.[quoteTweetMaybe.user_id_str] || {};
const quoteText = handleQuote(quoteTweetMaybe);
if (quoteText) {
console.log('quoteText', quoteText);
text += `\n${quoteText}`;
}
/* This code handles checking the quote tweet for media.
We'll embed a quote tweet's media if the linked tweet does not have any. */
if (
mediaList.length === 0 &&
(quoteTweetMaybe.extended_entities?.media?.length ||
quoteTweetMaybe.entities?.media?.length ||
0) > 0
) {
console.log(
`No media in main tweet, let's try embedding the quote tweet's media instead!`
);
mediaList = Array.from(
quoteTweetMaybe.extended_entities?.media || quoteTweetMaybe.entities?.media || []
);
console.log('updated mediaList', mediaList);
}
}
/* No media was found, but that's OK because we can still enrichen the Tweet
with a profile picture and color-matched embed in Discord! */
if (mediaList.length === 0) {
console.log('No media');
let palette = user?.profile_image_extensions_media_color?.palette;
let colorOverride: string = Constants.DEFAULT_COLOR;
if (palette) {
colorOverride = colorFromPalette(palette);
}
const { video } = tweet.media;
headers.push(
`<meta content="${colorOverride}" property="theme-color"/>`,
`<meta property="og:site_name" content="${Constants.BRANDING_NAME}"/>`,
// Use a slightly higher resolution image for profile pics
`<meta property="og:image" content="${user?.profile_image_url_https.replace(
'_normal',
'_200x200'
)}"/>`,
`<meta name="twitter:card" content="tweet"/>`,
`<meta name="twitter:title" content="${name} (@${screenName})"/>`,
`<meta name="twitter:image" content="0"/>`,
`<meta name="twitter:creator" content="@${name}"/>`,
`<meta content="${sanitizeText(text)}" property="og:description"/>`
`<meta name="twitter:player:stream:content_type" content="${video.format}"/>`,
`<meta name="twitter:player:height" content="${video.height}"/>`,
`<meta name="twitter:player:width" content="${video.width}"/>`,
`<meta name="og:video" content="${video.url}"/>`,
`<meta name="og:video:secure_url" content="${video.url}"/>`,
`<meta name="og:video:height" content="${video.height}"/>`,
`<meta name="og:video:width" content="${video.width}"/>`,
`<meta name="og:video:type" content="${video.format}"/>`,
`<meta name="twitter:image" content="${video.thumbnail_url}"/>`
);
} else {
console.log('Media available');
let firstMedia = mediaList[0];
}
/* Try grabbing media color palette */
let palette = firstMedia?.ext_media_color?.palette;
let colorOverride: string = Constants.DEFAULT_COLOR;
let pushedCardType = false;
/* Photo renderer */
if (tweet.media?.photos) {
const { photos } = tweet.media;
let photo = photos[(mediaNumber || 1) - 1];
if (palette) {
colorOverride = colorFromPalette(palette);
}
/* theme-color is used by discord to style the embed.
We take full advantage of that!*/
headers.push(`<meta content="${colorOverride}" property="theme-color"/>`);
/* Inline helper function for handling media */
const processMedia = (media: TweetMedia) => {
if (media.type === 'photo') {
if (flags?.direct && typeof media.media_url_https === 'string') {
redirectMedia = media.media_url_https;
return;
}
headers.push(
`<meta name="twitter:image" content="${media.media_url_https}"/>`,
`<meta property="og:image" content="${media.media_url_https}"/>`
);
if (media.original_info?.width && media.original_info?.height) {
headers.push(
`<meta name="twitter:image:width" content="${media.original_info.width}"/>`,
`<meta name="twitter:image:height" content="${media.original_info.height}"/>`,
`<meta name="og:image:width" content="${media.original_info.width}"/>`,
`<meta name="og:image:height" content="${media.original_info.height}"/>`
);
}
if (!pushedCardType) {
headers.push(`<meta name="twitter:card" content="summary_large_image"/>`);
pushedCardType = true;
}
} else if (media.type === 'video' || media.type === 'animated_gif') {
// Find the variant with the highest bitrate
let bestVariant = media.video_info?.variants?.reduce?.((a, b) =>
(a.bitrate ?? 0) > (b.bitrate ?? 0) ? a : b
);
if (flags?.direct && bestVariant?.url) {
console.log(`Redirecting to ${bestVariant.url}`);
redirectMedia = bestVariant.url;
return;
}
/* This is for the video thumbnail */
headers.push(`<meta name="twitter:image" content="${media.media_url_https}"/>`);
/* On Discord we have to use the author field in order to get the tweet text
to display on videos. This length is limited, however, and if there is too
much text Discord will refuse to display it at all, so we trim down as much
as the client will display. */
if (userAgent && userAgent?.indexOf?.('Discord') > -1) {
text = text.substr(0, 179);
}
authorText = encodeURIComponent(text);
headers.push(
`<meta name="twitter:card" content="player"/>`,
`<meta name="twitter:player:stream" content="${bestVariant?.url}"/>`,
`<meta name="twitter:player:stream:content_type" content="${bestVariant?.content_type}"/>`,
`<meta name="twitter:player:height" content="${media.original_info.height}"/>`,
`<meta name="twitter:player:width" content="${media.original_info.width}"/>`,
`<meta name="og:video" content="${bestVariant?.url}"/>`,
`<meta name="og:video:secure_url" content="${bestVariant?.url}"/>`,
`<meta name="og:video:height" content="${media.original_info.height}"/>`,
`<meta name="og:video:width" content="${media.original_info.width}"/>`,
`<meta name="og:video:type" content="${bestVariant?.content_type}"/>`
);
}
};
let actualMediaNumber = 0;
let renderedMosaic = false;
console.log('mediaNumber', mediaNumber);
console.log('mediaList length', mediaList.length);
/* You can specify a specific photo in the URL and we'll pull the correct one,
otherwise it falls back to first */
if (
typeof mediaNumber !== 'undefined' &&
typeof mediaList[mediaNumber - 1] !== 'undefined'
typeof mediaNumber !== 'number' &&
tweet.media.mosaic &&
userAgent?.indexOf('Telegram') === -1
) {
console.log(`Media ${mediaNumber} found`);
actualMediaNumber = mediaNumber - 1;
processMedia(mediaList[actualMediaNumber]);
} else if (mediaList.length === 1) {
console.log(`Media ${mediaNumber} not found, ${mediaList.length} total`);
processMedia(firstMedia);
} else if (mediaList.length > 1) {
console.log('Handling mosaic');
processMedia(await handleMosaic(mediaList, userAgent || ''));
renderedMosaic = true;
}
if (flags?.direct && redirectMedia) {
let response = Response.redirect(redirectMedia, 302);
console.log(response);
return { response: response };
}
if (mediaList.length > 1 && !renderedMosaic) {
photo = {
url:
userAgent?.indexOf('Telegram') !== -1
? tweet.media.mosaic.formats.webp
: tweet.media.mosaic.formats.jpeg,
width: tweet.media.mosaic.width,
height: tweet.media.mosaic.height,
type: 'photo'
};
} else if (photos.length > 1) {
let photoCounter = Strings.PHOTO_COUNT.format({
number: actualMediaNumber + 1,
total: mediaList.length
number: photos.indexOf(photo) + 1,
total: photos.length
});
authorText =
@ -293,21 +135,100 @@ export const handleStatus = async (
}
headers.push(`<meta property="og:site_name" content="${siteName}"/>`);
} else {
headers.push(
`<meta property="og:site_name" content="${Constants.BRANDING_NAME}"/>`
);
}
headers.push(
`<meta content="${name} (@${screenName})" property="og:title"/>`,
`<meta content="${sanitizeText(text)}" property="og:description"/>`
`<meta name="twitter:image" content="${photo.url}"/>`,
`<meta name="twitter:image:width" content="${photo.width}"/>`,
`<meta name="twitter:image:height" content="${photo.height}"/>`,
`<meta name="og:image" content="${photo.url}"/>`,
`<meta name="og:image:width" content="${photo.width}"/>`,
`<meta name="og:image:height" content="${photo.height}"/>`
);
}
/* External media renderer (i.e. YouTube) */
if (tweet.media?.external) {
const { external } = tweet.media;
headers.push(
`<meta name="twitter:player" content="${external.url}">`,
`<meta name="twitter:player:width" content="${external.width}">`,
`<meta name="twitter:player:height" content="${external.height}">`,
`<meta property="og:type" content="video.other">`,
`<meta property="og:video:url" content="${external.url}">`,
`<meta property="og:video:secure_url" content="${external.url}">`,
`<meta property="og:video:width" content="${external.width}">`,
`<meta property="og:video:height" content="${external.height}">`
);
}
let siteName = Constants.BRANDING_NAME;
let newText = tweet.text;
/* Poll renderer */
if (tweet.poll) {
const { poll } = tweet;
let barLength = 34;
let str = '';
if (userAgent?.indexOf('Telegram') !== -1) {
barLength = 24;
}
tweet.poll.choices.forEach(choice => {
// render bar
const bar = '█'.repeat((choice.percentage / 100) * barLength);
str += `${bar}
${choice.label}  (${choice.percentage}%)
`;
});
str += `\n${poll.total_votes} votes · ${poll.time_left_en}`;
newText += `\n\n${str}`;
}
if (!tweet.media?.video && !tweet.media?.photos) {
headers.push(
// Use a slightly higher resolution image for profile pics
`<meta property="og:image" content="${tweet.author.avatar_url?.replace(
'_normal',
'_200x200'
)}"/>`,
`<meta name="twitter:image" content="0"/>`
);
}
if (api.tweet?.translation) {
const { translation } = api.tweet;
let formatText =
language === 'en'
? Strings.TRANSLATE_TEXT.format({
language: translation.source_lang
})
: Strings.TRANSLATE_TEXT_INTL.format({
source: translation.source_lang.toUpperCase(),
destination: translation.target_lang.toUpperCase()
});
newText = `${translation.text}\n\n` + `${formatText}\n\n` + `${newText}`;
}
if (api.tweet?.quote) {
const quoteText = handleQuote(api.tweet.quote);
newText += `\n${quoteText}`;
}
headers.push(
`<meta content="${tweet.author.name} (@${tweet.author.screen_name})" property="og:title"/>`,
`<meta content="${sanitizeText(newText)}" property="og:description"/>`,
`<meta content="${siteName}" property="og:site_name"/>`
);
/* Special reply handling if authorText is not overriden */
if (tweet.in_reply_to_screen_name && authorText === 'Twitter') {
authorText = `↪ Replying to @${tweet.in_reply_to_screen_name}`;
if (tweet.replying_to && authorText === Strings.DEFAULT_AUTHOR_TEXT) {
authorText = `↪ Replying to @${tweet.replying_to}`;
}
/* The additional oembed is pulled by Discord to enable improved embeds.
@ -316,8 +237,8 @@ export const handleStatus = async (
`<link rel="alternate" href="${Constants.HOST_URL}/owoembed?text=${encodeURIComponent(
authorText
)}&status=${encodeURIComponent(status)}&author=${encodeURIComponent(
user?.screen_name || ''
)}" type="application/json+oembed" title="${name}">`
tweet.author?.screen_name || ''
)}" type="application/json+oembed" title="${tweet.author.name}">`
);
/* When dealing with a Tweet of unknown lang, fall back to en */

View file

@ -1,12 +1,10 @@
import { Constants } from './constants';
import { linkFixer } from './linkFixer';
import { Strings } from './strings';
export const translateTweet = async (
tweet: TweetPartial,
guestToken: string,
language: string
): Promise<string> => {
): Promise<TranslationPartial | null> => {
const csrfToken = crypto.randomUUID().replace(/-/g, ''); // Generate a random CSRF token, this doesn't matter, Twitter just cares that header and cookie match
let headers: { [header: string]: string } = {
@ -25,7 +23,6 @@ export const translateTweet = async (
let apiRequest;
let translationResults: TranslationPartial;
let resultText = tweet.full_text;
headers['x-twitter-client-language'] = language;
@ -39,35 +36,14 @@ export const translateTweet = async (
);
translationResults = (await apiRequest.json()) as TranslationPartial;
console.log(translationResults);
if (
translationResults.sourceLanguage === translationResults.destinationLanguage ||
translationResults.translationState !== 'Success'
) {
return tweet.full_text; // No work to do
if (translationResults.translationState !== 'Success') {
return null;
}
console.log(`Twitter interpreted language as ${tweet.lang}`);
let formatText =
language === 'en'
? Strings.TRANSLATE_TEXT.format({
language: translationResults.localizedSourceLanguage
})
: Strings.TRANSLATE_TEXT_INTL.format({
source: translationResults.sourceLanguage.toUpperCase(),
destination: translationResults.destinationLanguage.toUpperCase()
});
resultText =
`${translationResults.translation}\n\n` +
`${formatText}\n\n` +
`${tweet.full_text}`;
console.log(translationResults);
return translationResults;
} catch (e: any) {
console.error('Unknown error while fetching from Translation API');
return tweet.full_text; // No work to do
console.error('Unknown error while fetching from Translation API', e);
return {} as TranslationPartial; // No work to do
}
return linkFixer(tweet, resultText);
};

View file

@ -85,7 +85,7 @@ type TweetMedia = {
medium: TweetMediaSize;
small: TweetMediaSize;
};
type: 'photo' | 'video';
type: 'photo' | 'video' | 'animated_gif';
url: string;
video_info?: {
aspect_ratio: [number, number];
@ -157,6 +157,7 @@ type UserPartial = {
name: string;
screen_name: string;
profile_image_url_https: string;
profile_banner_url: string;
profile_image_extensions_media_color?: {
palette?: MediaPlaceholderColor[];
};

92
src/types.d.ts vendored
View file

@ -17,23 +17,99 @@ interface Request {
};
}
interface APIResponse {
code: number;
message: string;
tweet?: APITweet;
}
interface APITranslate {
text: string;
source_lang: string;
target_lang: string;
}
interface APIAuthor {
name?: string;
screen_name?: string;
avatar_url?: string;
avatar_color: string;
banner_url?: string;
}
interface APIExternalMedia {
type: 'video';
url: string;
height: number;
width: number;
}
interface APIPollChoice {
label: string;
count: number;
percentage: number;
}
interface APIPoll {
choices: APIPollChoice[];
total_votes: number;
ends_at: string;
time_left_en: string;
}
interface APIPhoto {
type: 'photo';
url: string;
width: number;
height: number;
}
interface APIMosaicPhoto {
type: 'mosaic_photo';
width: number;
height: number;
formats: {
webp: string;
jpeg: string;
};
}
interface APIVideo {
type: 'video' | 'gif';
url: string;
thumbnail_url: string;
width: number;
height: number;
format: string;
}
interface APITweet {
id: string;
url: string;
tweet: string;
text?: string;
text: string;
created_at: string;
likes: number;
retweets: number;
replies: number;
name?: string;
screen_name?: string;
profile_picture_url?: string;
profile_banner_url?: string;
color: string;
quote_tweet?: APITweet;
quote?: APITweet;
poll?: APIPoll;
translation?: APITranslate;
author: APIAuthor;
thumbnail: string;
media?: {
external?: APIExternalMedia;
photos?: APIPhoto[];
video?: APIVideo;
mosaic?: APIMosaicPhoto;
};
}
lang: string | null;
replying_to: string | null;
twitter_card: 'tweet' | 'summary' | 'summary_large_image' | 'player';
}

View file

@ -34,6 +34,9 @@ module.exports = {
}),
new webpack.DefinePlugin({
MOSAIC_DOMAIN_LIST: `'${process.env.MOSAIC_DOMAIN_LIST}'`
}),
new webpack.DefinePlugin({
API_HOST: `'${process.env.API_HOST}'`
})
],
optimization: {