diff --git a/README.md b/README.md index c043a3c..1d2c9bc 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ ## Written in TypeScript as a Cloudflare Worker to scale, packed with more features and [best-in-class user privacy πŸ”’](#built-with-privacy-in-mind). ### Add `fx` before your `twitter.com` link to make it `fxtwitter.com`, OR + ### Change `x.com` to `fixupx.com` in your link ### For Twitter links on Discord, send a Twitter link and type `s/e/p` to make `twittpr.com`. diff --git a/src/api/status.ts b/src/api/status.ts index 45d1960..1fde4f8 100644 --- a/src/api/status.ts +++ b/src/api/status.ts @@ -40,8 +40,7 @@ const populateTweetProperties = async ( id: apiUser.id, name: apiUser.name, screen_name: apiUser.screen_name, - avatar_url: - (apiUser.avatar_url || '').replace('_normal', '_200x200') || '', + avatar_url: (apiUser.avatar_url || '').replace('_normal', '_200x200') || '', avatar_color: '0000FF' /* colorFromPalette( tweet.user?.profile_image_extensions_media_color?.palette || [] ),*/, @@ -71,7 +70,7 @@ const populateTweetProperties = async ( apiTweet.replying_to = tweet.legacy?.in_reply_to_screen_name || null; apiTweet.replying_to_status = tweet.legacy?.in_reply_to_status_id_str || null; - + const mediaList = Array.from( tweet.legacy.extended_entities?.media || tweet.legacy.entities?.media || [] ); @@ -107,9 +106,14 @@ const populateTweetProperties = async ( console.log('note_tweet', JSON.stringify(tweet.note_tweet)); const noteTweetText = tweet.note_tweet?.note_tweet_results?.result?.text; /* For now, don't include note tweets */ - if (noteTweetText && mediaList.length <= 0 && tweet.legacy.entities?.urls?.length <= 0) { + if ( + noteTweetText /*&& mediaList.length <= 0 && tweet.legacy.entities?.urls?.length <= 0*/ + ) { console.log('We meet the conditions to use new note tweets'); apiTweet.text = unescapeText(noteTweetText); + apiTweet.is_note_tweet = true; + } else { + apiTweet.is_note_tweet = false; } /* Handle photos and mosaic if available */ @@ -129,7 +133,7 @@ const populateTweetProperties = async ( } /* Populate a Twitter card */ - + if (tweet.card) { const card = renderCard(tweet.card); if (card.external_media) { @@ -143,7 +147,11 @@ const populateTweetProperties = async ( } /* If a language is specified in API or by user, let's try translating it! */ - if (typeof language === 'string' && language.length === 2 && language !== tweet.legacy.lang) { + if ( + typeof language === 'string' && + language.length === 2 && + language !== tweet.legacy.lang + ) { const translateAPI = await translateTweet( tweet, conversation.guestToken || '', @@ -213,15 +221,15 @@ export const statusAPI = async ( } // console.log(JSON.stringify(tweet)) - + if (tweet.__typename === 'TweetUnavailable') { if (tweet.reason === 'Protected') { writeDataPoint(event, language, wasMediaBlockedNSFW, 'PRIVATE_TWEET', flags); return { code: 401, message: 'PRIVATE_TWEET' }; - // } else if (tweet.reason === 'NsfwLoggedOut') { - // // API failure as elongator should have handled this - // writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags); - // return { code: 500, message: 'API_FAIL' }; + // } else if (tweet.reason === 'NsfwLoggedOut') { + // // API failure as elongator should have handled this + // writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags); + // return { code: 500, message: 'API_FAIL' }; } else { // Api failure at parsing status writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags); @@ -234,7 +242,7 @@ export const statusAPI = async ( writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags); return { code: 500, message: 'API_FAIL' }; } - + /* if (tweet.retweeted_status_id_str) { tweet = conversation?.globalObjects?.tweets?.[tweet.retweeted_status_id_str] || {}; diff --git a/src/fetch.ts b/src/fetch.ts index 2f87c3c..048211d 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -5,7 +5,7 @@ import { isGraphQLTweet } from './utils/graphql'; const API_ATTEMPTS = 3; function generateCSRFToken() { - const randomBytes = new Uint8Array(160/2); + const randomBytes = new Uint8Array(160 / 2); crypto.getRandomValues(randomBytes); return Array.from(randomBytes, byte => byte.toString(16).padStart(2, '0')).join(''); } @@ -134,7 +134,7 @@ export const twitterFetch = async ( headers: headers }); } - + response = await apiRequest?.json(); } catch (e: unknown) { /* We'll usually only hit this if we get an invalid response from Twitter. @@ -197,28 +197,34 @@ export const fetchConversation = async ( `${ Constants.TWITTER_ROOT }/i/api/graphql/2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId?variables=${encodeURIComponent( - JSON.stringify({"tweetId": status,"withCommunity":false,"includePromotedContent":false,"withVoice":false}) + JSON.stringify({ + tweetId: status, + withCommunity: false, + includePromotedContent: false, + withVoice: false + }) )}&features=${encodeURIComponent( JSON.stringify({ - creator_subscriptions_tweet_preview_api_enabled:true, - tweetypie_unmention_optimization_enabled:true, - responsive_web_edit_tweet_api_enabled:true, - graphql_is_translatable_rweb_tweet_is_translatable_enabled:true, - view_counts_everywhere_api_enabled:true, - longform_notetweets_consumption_enabled:true, - responsive_web_twitter_article_tweet_consumption_enabled:false, - tweet_awards_web_tipping_enabled:false, - freedom_of_speech_not_reach_fetch_enabled:true, - standardized_nudges_misinfo:true, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled:true, - longform_notetweets_rich_text_read_enabled:true, - longform_notetweets_inline_media_enabled:true, - responsive_web_graphql_exclude_directive_enabled:true, - verified_phone_label_enabled:false, - responsive_web_media_download_video_enabled:false, - responsive_web_graphql_skip_user_profile_image_extensions_enabled:false, - responsive_web_graphql_timeline_navigation_enabled:true, - responsive_web_enhance_cards_enabled:false}) + creator_subscriptions_tweet_preview_api_enabled: true, + tweetypie_unmention_optimization_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: false, + tweet_awards_web_tipping_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_graphql_exclude_directive_enabled: true, + verified_phone_label_enabled: false, + responsive_web_media_download_video_enabled: false, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_enhance_cards_enabled: false + }) )}&fieldToggles=${encodeURIComponent( JSON.stringify({ // TODO Figure out what this property does @@ -244,7 +250,7 @@ export const fetchConversation = async ( return true; } // Final clause for checking if it's valid is if there's errors - return Array.isArray(conversation.errors) + return Array.isArray(conversation.errors); } )) as TweetResultsByRestIdResult; }; diff --git a/src/helpers/card.ts b/src/helpers/card.ts index c8e4740..0a4dc69 100644 --- a/src/helpers/card.ts +++ b/src/helpers/card.ts @@ -6,7 +6,10 @@ export const renderCard = ( ): { poll?: APIPoll; external_media?: APIExternalMedia } => { // We convert the binding_values array into an object with the legacy format // TODO Clean this up - const binding_values: Record = {}; + const binding_values: Record< + string, + { string_value?: string; boolean_value?: boolean } + > = {}; if (Array.isArray(card.legacy.binding_values)) { card.legacy.binding_values.forEach(value => { if (value.key && value.value) { @@ -14,7 +17,6 @@ export const renderCard = ( } }); } - console.log('rendering card'); @@ -56,7 +58,10 @@ export const renderCard = ( }); return { poll: poll }; - } else if (typeof binding_values.player_url !== 'undefined' && binding_values.player_url.string_value) { + } else if ( + typeof binding_values.player_url !== 'undefined' && + binding_values.player_url.string_value + ) { /* Oh good, a non-Twitter video URL! This enables YouTube embeds and stuff to just work */ return { external_media: { diff --git a/src/helpers/linkFixer.ts b/src/helpers/linkFixer.ts index bef4a7d..f5b8e54 100644 --- a/src/helpers/linkFixer.ts +++ b/src/helpers/linkFixer.ts @@ -1,8 +1,8 @@ /* Helps replace t.co links with their originals */ export const linkFixer = (tweet: GraphQLTweet, text: string): string => { console.log('got entites', { - entities: tweet.legacy.entities, - }) + entities: tweet.legacy.entities + }); if (Array.isArray(tweet.legacy.entities?.urls) && tweet.legacy.entities.urls.length) { tweet.legacy.entities.urls.forEach((url: TcoExpansion) => { let newURL = url.expanded_url; diff --git a/src/server.ts b/src/server.ts index 8230585..3a650b1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -313,7 +313,9 @@ router.get('/owoembed', async (request: IRequest) => { provider_name: searchParams.get('deprecated') === 'true' ? Strings.DEPRECATED_DOMAIN_NOTICE_DISCORD - : (useXbranding ? name : Strings.X_DOMAIN_NOTICE), + : useXbranding + ? name + : Strings.X_DOMAIN_NOTICE, provider_url: url, title: Strings.DEFAULT_AUTHOR_TEXT, type: 'link', diff --git a/src/strings.ts b/src/strings.ts index caa6883..729f60d 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -145,7 +145,8 @@ This is caused by Twitter API downtime or a new bug. Try again in a little while PLURAL_SECONDS_LEFT: 'seconds left', FINAL_POLL_RESULTS: 'Final results', - ERROR_API_FAIL: 'Tweet failed to load due to an API error. This is most common with NSFW Tweets as Twitter / X currently blocks us from fetching them. We\'re still working on a fix for that.πŸ™', + ERROR_API_FAIL: + "Tweet failed to load due to an API error. This is most common with NSFW Tweets as Twitter / X currently blocks us from fetching them. We're still working on a fix for that.πŸ™", ERROR_PRIVATE: `Sorry, we can't embed this Tweet because the user is private or suspended :(`, ERROR_TWEET_NOT_FOUND: `Sorry, that Tweet doesn't exist :(`, ERROR_USER_NOT_FOUND: `Sorry, that user doesn't exist :(`, diff --git a/src/types/twitterTypes.d.ts b/src/types/twitterTypes.d.ts index 49932da..c84dc2d 100644 --- a/src/types/twitterTypes.d.ts +++ b/src/types/twitterTypes.d.ts @@ -310,19 +310,19 @@ type GraphQLTweet = { result: GraphQLTweet; __typename: 'Tweet'; rest_id: string; // "1674824189176590336", - has_birdwatch_notes: false, + has_birdwatch_notes: false; core: { user_results: { result: GraphQLUser; - } - } - edit_control: unknown, - edit_perspective: unknown, - is_translatable: false, + }; + }; + edit_control: unknown; + edit_perspective: unknown; + is_translatable: false; views: { count: string; // "562" state: string; // "EnabledWithCount" - } + }; source: string; // "Twitter Web App" quoted_status_result?: GraphQLTweet; legacy: { @@ -356,45 +356,54 @@ type GraphQLTweet = { indices: [number, number]; // [number, number] media_url_https: string; // "https://pbs.twimg.com/media/FAKESCREENSHOT.jpg" With videos appears to be the thumbnail type: string; // "photo" Seems to be photo even with videos - }[] + }[]; user_mentions: unknown[]; urls: TcoExpansion[]; hashtags: unknown[]; symbols: unknown[]; - } + }; extended_entities: { - media: TweetMedia[] - } - } + media: TweetMedia[]; + }; + }; note_tweet: { is_expandable: boolean; entity_set: { hashtags: unknown[]; urls: unknown[]; user_mentions: unknown[]; - }, + }; note_tweet_results: { result: { text: string; - } - } + }; + }; }; card: { rest_id: string; // "card://1674824189176590336", legacy: { binding_values: { - key: `choice${1|2|3|4}_label`|'counts_are_final'|`choice${1|2|3|4}_count`|'last_updated_datetime_utc'|'duration_minutes'|'api'|'card_url' - value: { - string_value: string; // "Option text" - type: 'STRING' - }|{ - boolean_value: boolean; // true - type: 'BOOLEAN' - } - }[] - } - } -} + key: + | `choice${1 | 2 | 3 | 4}_label` + | 'counts_are_final' + | `choice${1 | 2 | 3 | 4}_count` + | 'last_updated_datetime_utc' + | 'duration_minutes' + | 'api' + | 'card_url'; + value: + | { + string_value: string; // "Option text" + type: 'STRING'; + } + | { + boolean_value: boolean; // true + type: 'BOOLEAN'; + }; + }[]; + }; + }; +}; type TweetTombstone = { __typename: 'TweetTombstone'; tombstone: { @@ -403,82 +412,91 @@ type TweetTombstone = { rtl: boolean; // false; text: string; // "You’re unable to view this Tweet because this account owner limits who can view their Tweets. Learn more" entities: unknown[]; - } - } -} + }; + }; +}; type GraphQLTimelineTweetEntry = { /** The entryID contains the tweet ID */ entryId: `tweet-${number}`; // "tweet-1674824189176590336" sortIndex: string; content: { - entryType: 'TimelineTimelineItem', - __typename: 'TimelineTimelineItem', + entryType: 'TimelineTimelineItem'; + __typename: 'TimelineTimelineItem'; itemContent: { - item: 'TimelineTweet', - __typename: 'TimelineTweet', + item: 'TimelineTweet'; + __typename: 'TimelineTweet'; tweet_results: { - result: GraphQLTweet|TweetTombstone; - } - } - } -} + result: GraphQLTweet | TweetTombstone; + }; + }; + }; +}; type GraphQLConversationThread = { entryId: `conversationthread-${number}`; // "conversationthread-1674824189176590336" sortIndex: string; -} +}; -type GraphQLTimelineEntry = GraphQLTimelineTweetEntry|GraphQLConversationThread|unknown; +type GraphQLTimelineEntry = + | GraphQLTimelineTweetEntry + | GraphQLConversationThread + | unknown; -type V2ThreadInstruction = TimeLineAddEntriesInstruction | TimeLineTerminateTimelineInstruction; +type V2ThreadInstruction = + | TimeLineAddEntriesInstruction + | TimeLineTerminateTimelineInstruction; type TimeLineAddEntriesInstruction = { type: 'TimelineAddEntries'; entries: GraphQLTimelineEntry[]; -} +}; type TimeLineTerminateTimelineInstruction = { type: 'TimelineTerminateTimeline'; direction: 'Top'; -} +}; type GraphQLTweetNotFoundResponse = { - errors: [{ - message: string; // "_Missing: No status found with that ID" - locations: unknown[]; - path: string[]; // ["threaded_conversation_with_injections_v2"] - extensions: { - name: string; // "GenericError" - source: string; // "Server" + errors: [ + { + message: string; // "_Missing: No status found with that ID" + locations: unknown[]; + path: string[]; // ["threaded_conversation_with_injections_v2"] + extensions: { + name: string; // "GenericError" + source: string; // "Server" + code: number; // 144 + kind: string; // "NonFatal" + tracing: { + trace_id: string; // "2e39ff747de237db" + }; + }; code: number; // 144 kind: string; // "NonFatal" + name: string; // "GenericError" + source: string; // "Server" tracing: { trace_id: string; // "2e39ff747de237db" - } + }; } - code: number; // 144 - kind: string; // "NonFatal" - name: string; // "GenericError" - source: string; // "Server" - tracing: { - trace_id: string; // "2e39ff747de237db" - } - }] + ]; data: Record; -} +}; type GraphQLTweetFoundResponse = { data: { threaded_conversation_with_injections_v2: { - instructions: V2ThreadInstruction[] - } - } -} + instructions: V2ThreadInstruction[]; + }; + }; +}; type TweetResultsByRestIdResult = { errors?: unknown[]; data?: { tweetResult?: { - result?: { - __typename: 'TweetUnavailable'; - reason: 'NsfwLoggedOut'|'Protected'; - }|GraphQLTweet - } - } -} \ No newline at end of file + result?: + | { + __typename: 'TweetUnavailable'; + reason: 'NsfwLoggedOut' | 'Protected'; + } + | GraphQLTweet; + }; + }; +}; diff --git a/src/utils/graphql.ts b/src/utils/graphql.ts index a6b23cd..6ee85ee 100644 --- a/src/utils/graphql.ts +++ b/src/utils/graphql.ts @@ -1,7 +1,22 @@ -export const isGraphQLTweetNotFoundResponse = (response: unknown): response is GraphQLTweetNotFoundResponse => { - return typeof response === 'object' && response !== null && 'errors' in response && Array.isArray(response.errors) && response.errors.length > 0 && 'message' in response.errors[0] && response.errors[0].message === '_Missing: No status found with that ID'; +export const isGraphQLTweetNotFoundResponse = ( + response: unknown +): response is GraphQLTweetNotFoundResponse => { + return ( + typeof response === 'object' && + response !== null && + 'errors' in response && + Array.isArray(response.errors) && + response.errors.length > 0 && + 'message' in response.errors[0] && + response.errors[0].message === '_Missing: No status found with that ID' + ); }; export const isGraphQLTweet = (response: unknown): response is GraphQLTweet => { - return typeof response === 'object' && response !== null && '__typename' in response && response.__typename === 'Tweet'; -} \ No newline at end of file + return ( + typeof response === 'object' && + response !== null && + '__typename' in response && + response.__typename === 'Tweet' + ); +}; diff --git a/test/index.test.ts b/test/index.test.ts index 1427155..655218a 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -284,4 +284,4 @@ test('API fetch user that does not exist', async () => { expect(response.code).toEqual(404); expect(response.message).toEqual('User not found'); expect(response.user).toBeUndefined(); -}); \ No newline at end of file +});