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 = "FixTweet"
BRANDING_NAME_DISCORD = "FixTweet by @dangeredwolf - Embed videos, polls & more!" 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" 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" HOST_URL = "https://fxtwitter.com"
REDIRECT_URL = "https://github.com/dangeredwolf/FixTweet" 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", "version": "1.0.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pxtwitter", "name": "fixtweet",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -21,7 +21,7 @@
"typescript": "^4.7.4", "typescript": "^4.7.4",
"webpack": "^5.73.0", "webpack": "^5.73.0",
"webpack-cli": "^4.10.0", "webpack-cli": "^4.10.0",
"wrangler": "^2.0.22" "wrangler": "^2.0.23"
} }
}, },
"node_modules/@cloudflare/kv-asset-handler": { "node_modules/@cloudflare/kv-asset-handler": {
@ -675,6 +675,28 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "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": { "node_modules/blake3-wasm": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", "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" "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": { "node_modules/chrome-trace-event": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
@ -1414,6 +1463,18 @@
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true "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": { "node_modules/glob-to-regexp": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
@ -1496,6 +1557,18 @@
"node": ">= 0.10" "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": { "node_modules/is-core-module": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", "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" "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": { "node_modules/is-number": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -1832,6 +1926,15 @@
"integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
"dev": true "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": { "node_modules/p-limit": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@ -1961,6 +2064,18 @@
"safe-buffer": "^5.1.0" "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": { "node_modules/realistic-structured-clone": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/realistic-structured-clone/-/realistic-structured-clone-1.0.1.tgz", "resolved": "https://registry.npmjs.org/realistic-structured-clone/-/realistic-structured-clone-1.0.1.tgz",
@ -2610,15 +2725,16 @@
"dev": true "dev": true
}, },
"node_modules/wrangler": { "node_modules/wrangler": {
"version": "2.0.22", "version": "2.0.23",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-2.0.22.tgz", "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-2.0.23.tgz",
"integrity": "sha512-mCKNvv3Yq8ClBaiEKZ/KGTYhwhf5r5ElkTNtUj50Y0Qo9JJYvLnphMteEjfnID5iopv2FxmHDeRSn/Jx7zSAkw==", "integrity": "sha512-qMyK/pmHIrubxuJuXnCwcidY4LlQImcTTyOGoqMJtNz8y+pDfsluExSBpwqGSl3JPUQF2FiofqaBkj/iB8rvYw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@cloudflare/kv-asset-handler": "^0.2.0", "@cloudflare/kv-asset-handler": "^0.2.0",
"@esbuild-plugins/node-globals-polyfill": "^0.1.1", "@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4", "@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"blake3-wasm": "^2.1.5", "blake3-wasm": "^2.1.5",
"chokidar": "^3.5.3",
"esbuild": "0.14.47", "esbuild": "0.14.47",
"miniflare": "^2.6.0", "miniflare": "^2.6.0",
"nanoid": "^3.3.3", "nanoid": "^3.3.3",
@ -3236,6 +3352,22 @@
"color-convert": "^2.0.1" "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": { "blake3-wasm": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
@ -3300,6 +3432,22 @@
"supports-color": "^7.1.0" "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": { "chrome-trace-event": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
@ -3685,6 +3833,15 @@
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true "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": { "glob-to-regexp": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "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==", "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
"dev": true "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": { "is-core-module": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz",
@ -3755,6 +3921,21 @@
"has": "^1.0.3" "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": { "is-number": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -3999,6 +4180,12 @@
"integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
"dev": true "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": { "p-limit": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@ -4089,6 +4276,15 @@
"safe-buffer": "^5.1.0" "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": { "realistic-structured-clone": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/realistic-structured-clone/-/realistic-structured-clone-1.0.1.tgz", "resolved": "https://registry.npmjs.org/realistic-structured-clone/-/realistic-structured-clone-1.0.1.tgz",
@ -4531,15 +4727,16 @@
"dev": true "dev": true
}, },
"wrangler": { "wrangler": {
"version": "2.0.22", "version": "2.0.23",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-2.0.22.tgz", "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-2.0.23.tgz",
"integrity": "sha512-mCKNvv3Yq8ClBaiEKZ/KGTYhwhf5r5ElkTNtUj50Y0Qo9JJYvLnphMteEjfnID5iopv2FxmHDeRSn/Jx7zSAkw==", "integrity": "sha512-qMyK/pmHIrubxuJuXnCwcidY4LlQImcTTyOGoqMJtNz8y+pDfsluExSBpwqGSl3JPUQF2FiofqaBkj/iB8rvYw==",
"dev": true, "dev": true,
"requires": { "requires": {
"@cloudflare/kv-asset-handler": "^0.2.0", "@cloudflare/kv-asset-handler": "^0.2.0",
"@esbuild-plugins/node-globals-polyfill": "^0.1.1", "@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4", "@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"blake3-wasm": "^2.1.5", "blake3-wasm": "^2.1.5",
"chokidar": "^3.5.3",
"esbuild": "0.14.47", "esbuild": "0.14.47",
"fsevents": "~2.3.2", "fsevents": "~2.3.2",
"miniflare": "^2.6.0", "miniflare": "^2.6.0",

View file

@ -1,5 +1,5 @@
{ {
"name": "pxtwitter", "name": "fixtweet",
"version": "1.0.0", "version": "1.0.0",
"description": "Embed Twitter videos, polls, and more on Discord and Telegram", "description": "Embed Twitter videos, polls, and more on Discord and Telegram",
"main": "dist/worker.js", "main": "dist/worker.js",
@ -23,7 +23,7 @@
"typescript": "^4.7.4", "typescript": "^4.7.4",
"webpack": "^5.73.0", "webpack": "^5.73.0",
"webpack-cli": "^4.10.0", "webpack-cli": "^4.10.0",
"wrangler": "^2.0.22" "wrangler": "^2.0.23"
}, },
"dependencies": { "dependencies": {
"itty-router": "^2.6.1" "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 => { export const getAuthorText = (tweet: APITweet): string | null => {
/* Build out reply, retweet, like counts */ /* Build out reply, retweet, like counts */
if (tweet.favorite_count > 0 || tweet.retweet_count > 0 || tweet.reply_count > 0) { if (tweet.likes > 0 || tweet.retweets > 0 || tweet.replies > 0) {
let authorText = ''; let authorText = '';
if (tweet.reply_count > 0) { if (tweet.replies > 0) {
authorText += `${tweet.reply_count} 💬 `; authorText += `${tweet.replies} 💬 `;
} }
if (tweet.retweet_count > 0) { if (tweet.retweets > 0) {
authorText += `${tweet.retweet_count} 🔁 `; authorText += `${tweet.retweets} 🔁 `;
} }
if (tweet.favorite_count > 0) { if (tweet.likes > 0) {
authorText += `${tweet.favorite_count} ❤️ `; authorText += `${tweet.likes} ❤️ `;
} }
authorText = authorText.trim(); authorText = authorText.trim();
@ -17,4 +17,4 @@ export const getAuthorText = (tweet: TweetPartial): string | null => {
} }
return null; return null;
} };

View file

@ -1,66 +1,16 @@
import { Strings } from './strings'; import { calculateTimeLeftString } from './pollHelper';
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
);
};
export const renderCard = async ( export const renderCard = async (
card: TweetCard, card: TweetCard
headers: string[], ): Promise<{ poll?: APIPoll; external_media?: APIExternalMedia }> => {
userAgent: string = ''
): Promise<string> => {
let str = '\n\n'; let str = '\n\n';
const values = card.binding_values; const values = card.binding_values;
console.log('rendering card on ', card); console.log('rendering card on ', card);
// Telegram's bars need to be a lot smaller to fit its bubbles // 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 choices: { [label: string]: number } = {};
let totalVotes = 0; let totalVotes = 0;
let timeLeft = '';
if (typeof values !== 'undefined') { if (typeof values !== 'undefined') {
/* TODO: make poll code cleaner */ /* TODO: make poll code cleaner */
@ -68,9 +18,13 @@ export const renderCard = async (
typeof values.choice1_count !== 'undefined' && typeof values.choice1_count !== 'undefined' &&
typeof values.choice2_count !== 'undefined' typeof values.choice2_count !== 'undefined'
) { ) {
let poll = {} as APIPoll;
if (typeof values.end_datetime_utc !== 'undefined') { 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); 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( choices[values.choice1_label?.string_value || ''] = parseInt(
values.choice1_count.string_value values.choice1_count.string_value
@ -87,46 +41,36 @@ export const renderCard = async (
totalVotes += parseInt(values.choice3_count.string_value); totalVotes += parseInt(values.choice3_count.string_value);
} }
if (typeof values.choice4_count !== 'undefined') { if (typeof values.choice4_count !== 'undefined') {
choices[values.choice4_label?.string_value || ''] = parseInt( choices[values.choice4_label?.string_value || ''] =
values.choice4_count.string_value parseInt(values.choice4_count.string_value) || 0;
);
totalVotes += parseInt(values.choice4_count.string_value); totalVotes += parseInt(values.choice4_count.string_value);
} }
for (const [label, votes] of Object.entries(choices)) { poll.total_votes = totalVotes;
// render bar poll.choices = Object.keys(choices).map(label => {
const bar = '█'.repeat(Math.round((votes / totalVotes || 0) * barLength)); return {
str += `${bar} label: label,
${label}  (${Math.round((votes / totalVotes || 0) * 100)}%) 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 */ /* Oh good, a non-Twitter video URL! This enables YouTube embeds and stuff to just work */
} else if (typeof values.player_url !== 'undefined') { } else if (typeof values.player_url !== 'undefined') {
headers.push( return {
`<meta name="twitter:player" content="${values.player_url.string_value}">`, external_media: {
`<meta name="twitter:player:width" content="${ type: 'video',
values.player_width?.string_value || '1280' url: values.player_url.string_value,
}">`, width: parseInt(
`<meta name="twitter:player:height" content="${ (values.player_width?.string_value || '1280').replace('px', '')
values.player_height?.string_value || '720' ), // TODO: Replacing px might not be necessary, it's just there as a precaution
}">`, height: parseInt(
`<meta property="og:type" content="video.other">`, (values.player_height?.string_value || '720').replace('px', '')
`<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 str; }
return {};
}; };

View file

@ -5,6 +5,7 @@ export const Constants = {
BRANDING_NAME_DISCORD: BRANDING_NAME_DISCORD, BRANDING_NAME_DISCORD: BRANDING_NAME_DISCORD,
DIRECT_MEDIA_DOMAINS: DIRECT_MEDIA_DOMAINS.split(','), DIRECT_MEDIA_DOMAINS: DIRECT_MEDIA_DOMAINS.split(','),
MOSAIC_DOMAIN_LIST: MOSAIC_DOMAIN_LIST.split(','), MOSAIC_DOMAIN_LIST: MOSAIC_DOMAIN_LIST.split(','),
API_HOST: API_HOST,
HOST_URL: HOST_URL, HOST_URL: HOST_URL,
REDIRECT_URL: REDIRECT_URL, REDIRECT_URL: REDIRECT_URL,
TWITTER_ROOT: 'https://twitter.com', 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 HOST_URL: string;
declare const REDIRECT_URL: string; declare const REDIRECT_URL: string;
declare const MOSAIC_DOMAIN_LIST: string; declare const MOSAIC_DOMAIN_LIST: string;
declare const API_HOST: string;

View file

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

View file

@ -14,12 +14,22 @@ export const colorFromPalette = (palette: MediaPlaceholderColor[]) => {
const rgb = palette[i].rgb; const rgb = palette[i].rgb;
// We need vibrant colors, grey backgrounds won't do! // 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; continue;
} }
return rgbToHex(rgb.red, rgb.green, rgb.blue); 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; 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'; import { Strings } from './strings';
export const handleQuote = (quote: TweetPartial): string | null => { export const handleQuote = (quote: APITweet): string | null => {
console.log('Quoting status ', quote.id_str); console.log('Quoting status ', quote.id);
let str = `\n`; let str = `\n`;
str += Strings.QUOTE_TEXT.format({ str += Strings.QUOTE_TEXT.format({
name: quote.user?.name, name: quote.author?.name,
screen_name: quote.user?.screen_name screen_name: quote.author?.screen_name
}); });
str += ` \n\n`; str += ` \n\n`;
str += linkFixer(quote, quote.full_text); str += quote.text;
return str; return str;
}; };

View file

@ -15,18 +15,22 @@ const statusRequest = async (
const userAgent = request.headers.get('User-Agent') || ''; const userAgent = request.headers.get('User-Agent') || '';
let isBotUA = 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 ( if (
url.pathname.match(/\/status(es)?\/\d+\.(mp4|png|jpg)/g) !== null || url.pathname.match(/\/status(es)?\/\d+\.(mp4|png|jpg)/g) !== null ||
Constants.DIRECT_MEDIA_DOMAINS.includes(url.hostname) || Constants.DIRECT_MEDIA_DOMAINS.includes(url.hostname) ||
(prefix === 'dl' || prefix === 'dir') prefix === 'dl' ||
prefix === 'dir'
) { ) {
console.log('Direct media request by extension'); console.log('Direct media request by extension');
flags.direct = true; 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'); console.log('JSON API request');
flags.api = true; 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/statuses/:id/video/:mediaNumber', statusRequest);
router.get('/:prefix?/:handle/status/:id/:language', statusRequest); router.get('/:prefix?/:handle/status/:id/:language', statusRequest);
router.get('/:prefix?/:handle/statuses/: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) => { router.get('/owoembed', async (request: Request) => {
console.log('oembed hit!'); console.log('oembed hit!');
@ -162,6 +168,7 @@ const cacheWrapper = async (event: FetchEvent): Promise<Response> => {
switch (request.method) { switch (request.method) {
case 'GET': case 'GET':
if (cacheUrl.hostname !== Constants.API_HOST) {
let cachedResponse = await cache.match(cacheKey); let cachedResponse = await cache.match(cacheKey);
if (cachedResponse) { if (cachedResponse) {
@ -170,6 +177,7 @@ const cacheWrapper = async (event: FetchEvent): Promise<Response> => {
} }
console.log('Cache miss'); console.log('Cache miss');
}
let response = await router.handle(event.request, event); let response = await router.handle(event.request, event);

View file

@ -1,14 +1,9 @@
import { Constants } from './constants'; 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 { handleQuote } from './quote';
import { sanitizeText } from './utils'; import { sanitizeText } from './utils';
import { Strings } from './strings'; import { Strings } from './strings';
import { handleMosaic } from './mosaic';
import { translateTweet } from './translate';
import { getAuthorText } from './author'; import { getAuthorText } from './author';
import { statusAPI } from './api';
export const returnError = (error: string): StatusResponse => { export const returnError = (error: string): StatusResponse => {
return { return {
@ -32,253 +27,100 @@ export const handleStatus = async (
): Promise<StatusResponse> => { ): Promise<StatusResponse> => {
console.log('Direct?', flags?.direct); 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] || {}; if (flags?.api) {
/* With v2 conversation API we re-add the user object ot the tweet because return {
Twitter stores it separately in the conversation API. This is to consolidate response: new Response(JSON.stringify(api), {
it in case a user appears multiple times in a thread. */ headers: { ...Constants.RESPONSE_HEADERS, 'content-type': 'application/json' },
tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {}; status: api.code
})
};
}
let headers: string[] = []; switch (api.code) {
case 401:
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) {
return returnError(Strings.ERROR_PRIVATE); return returnError(Strings.ERROR_PRIVATE);
} case 404:
/* {"errors":[{"code":34,"message":"Sorry, that page does not exist."}]} */
if (conversation.errors?.[0]?.code === 34) {
return returnError(Strings.ERROR_TWEET_NOT_FOUND); return returnError(Strings.ERROR_TWEET_NOT_FOUND);
} case 500:
/* Tweets object is completely missing, smells like API failure */
if (typeof conversation?.globalObjects?.tweets === 'undefined') {
return returnError(Strings.ERROR_API_FAIL); return returnError(Strings.ERROR_API_FAIL);
} }
/* If we have no idea what happened then just return API error */ if (flags?.direct) {
return returnError(Strings.ERROR_API_FAIL); 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) };
}
}
} }
let text = tweet.full_text; /* Use quote media if there is no media */
let engagementText = ''; if (!tweet.media && tweet.quote?.media) {
tweet.media = tweet.quote.media;
const user = tweet.user; tweet.twitter_card = 'summary_large_image';
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');
} }
let mediaList = Array.from(
tweet.extended_entities?.media || tweet.entities?.media || []
);
let authorText = getAuthorText(tweet) || Strings.DEFAULT_AUTHOR_TEXT; let authorText = getAuthorText(tweet) || Strings.DEFAULT_AUTHOR_TEXT;
let engagementText = authorText.replace(/ /g, ' ');
// engagementText has less spacing than authorText let headers: string[] = [
engagementText = authorText.replace(/ /g, ' '); `<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 */ const { video } = tweet.media;
if (tweet.card) {
let cardRender = await renderCard(tweet.card, headers, userAgent);
if (cardRender === 'EMBED_CARD') { headers.push(
authorText = encodeURIComponent(text); `<meta name="twitter:player:stream:content_type" content="${video.format}"/>`,
} else { `<meta name="twitter:player:height" content="${video.height}"/>`,
text += cardRender; `<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}"/>`
);
} }
/* Trying to uncover a quote tweet referenced by this tweet */ /* Photo renderer */
let quoteTweetMaybe = if (tweet.media?.photos) {
conversation.globalObjects?.tweets?.[tweet.quoted_status_id_str || '0'] || null; const { photos } = tweet.media;
let photo = photos[(mediaNumber || 1) - 1];
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 ( if (
mediaList.length === 0 && typeof mediaNumber !== 'number' &&
(quoteTweetMaybe.extended_entities?.media?.length || tweet.media.mosaic &&
quoteTweetMaybe.entities?.media?.length || userAgent?.indexOf('Telegram') === -1
0) > 0
) { ) {
console.log( photo = {
`No media in main tweet, let's try embedding the quote tweet's media instead!` url:
); userAgent?.indexOf('Telegram') !== -1
mediaList = Array.from( ? tweet.media.mosaic.formats.webp
quoteTweetMaybe.extended_entities?.media || quoteTweetMaybe.entities?.media || [] : tweet.media.mosaic.formats.jpeg,
); width: tweet.media.mosaic.width,
height: tweet.media.mosaic.height,
console.log('updated mediaList', mediaList); type: 'photo'
}
}
/* 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);
}
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"/>`
);
} 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;
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}"/>`
);
}
}; };
} else if (photos.length > 1) {
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'
) {
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) {
let photoCounter = Strings.PHOTO_COUNT.format({ let photoCounter = Strings.PHOTO_COUNT.format({
number: actualMediaNumber + 1, number: photos.indexOf(photo) + 1,
total: mediaList.length total: photos.length
}); });
authorText = authorText =
@ -293,21 +135,100 @@ export const handleStatus = async (
} }
headers.push(`<meta property="og:site_name" content="${siteName}"/>`); headers.push(`<meta property="og:site_name" content="${siteName}"/>`);
} else {
headers.push(
`<meta property="og:site_name" content="${Constants.BRANDING_NAME}"/>`
);
} }
headers.push( headers.push(
`<meta content="${name} (@${screenName})" property="og:title"/>`, `<meta name="twitter:image" content="${photo.url}"/>`,
`<meta content="${sanitizeText(text)}" property="og:description"/>` `<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 */ /* Special reply handling if authorText is not overriden */
if (tweet.in_reply_to_screen_name && authorText === 'Twitter') { if (tweet.replying_to && authorText === Strings.DEFAULT_AUTHOR_TEXT) {
authorText = `↪ Replying to @${tweet.in_reply_to_screen_name}`; authorText = `↪ Replying to @${tweet.replying_to}`;
} }
/* The additional oembed is pulled by Discord to enable improved embeds. /* 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( `<link rel="alternate" href="${Constants.HOST_URL}/owoembed?text=${encodeURIComponent(
authorText authorText
)}&status=${encodeURIComponent(status)}&author=${encodeURIComponent( )}&status=${encodeURIComponent(status)}&author=${encodeURIComponent(
user?.screen_name || '' tweet.author?.screen_name || ''
)}" type="application/json+oembed" title="${name}">` )}" type="application/json+oembed" title="${tweet.author.name}">`
); );
/* When dealing with a Tweet of unknown lang, fall back to en */ /* When dealing with a Tweet of unknown lang, fall back to en */

View file

@ -1,12 +1,10 @@
import { Constants } from './constants'; import { Constants } from './constants';
import { linkFixer } from './linkFixer';
import { Strings } from './strings';
export const translateTweet = async ( export const translateTweet = async (
tweet: TweetPartial, tweet: TweetPartial,
guestToken: string, guestToken: string,
language: 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 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 } = { let headers: { [header: string]: string } = {
@ -25,7 +23,6 @@ export const translateTweet = async (
let apiRequest; let apiRequest;
let translationResults: TranslationPartial; let translationResults: TranslationPartial;
let resultText = tweet.full_text;
headers['x-twitter-client-language'] = language; headers['x-twitter-client-language'] = language;
@ -39,35 +36,14 @@ export const translateTweet = async (
); );
translationResults = (await apiRequest.json()) as TranslationPartial; translationResults = (await apiRequest.json()) as TranslationPartial;
if (translationResults.translationState !== 'Success') {
return null;
}
console.log(translationResults); console.log(translationResults);
return translationResults;
if (
translationResults.sourceLanguage === translationResults.destinationLanguage ||
translationResults.translationState !== 'Success'
) {
return tweet.full_text; // No work to do
}
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}`;
} catch (e: any) { } catch (e: any) {
console.error('Unknown error while fetching from Translation API'); console.error('Unknown error while fetching from Translation API', e);
return tweet.full_text; // No work to do return {} as TranslationPartial; // No work to do
} }
return linkFixer(tweet, resultText);
}; };

View file

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

90
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 { interface APITweet {
id: string; id: string;
url: string;
tweet: string; tweet: string;
text?: string; text: string;
created_at: string; created_at: string;
likes: number; likes: number;
retweets: number; retweets: number;
replies: number; replies: number;
name?: string; color: string;
screen_name?: string;
profile_picture_url?: string;
profile_banner_url?: 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({ new webpack.DefinePlugin({
MOSAIC_DOMAIN_LIST: `'${process.env.MOSAIC_DOMAIN_LIST}'` MOSAIC_DOMAIN_LIST: `'${process.env.MOSAIC_DOMAIN_LIST}'`
}),
new webpack.DefinePlugin({
API_HOST: `'${process.env.API_HOST}'`
}) })
], ],
optimization: { optimization: {