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..e5057c8 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.text = unescapeText(linkFixer(tweet, 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/embed/status.ts b/src/embed/status.ts index df6f17f..8a88a2f 100644 --- a/src/embed/status.ts +++ b/src/embed/status.ts @@ -6,6 +6,7 @@ import { getAuthorText } from '../helpers/author'; import { statusAPI } from '../api/status'; import { renderPhoto } from '../render/photo'; import { renderVideo } from '../render/video'; +import { renderInstantView } from '../render/instantview'; export const returnError = (error: string): StatusResponse => { return { @@ -34,6 +35,12 @@ export const handleStatus = async ( const api = await statusAPI(status, language, event as FetchEvent, flags); const tweet = api?.tweet as APITweet; + + const isTelegram = (userAgent || '').indexOf('Telegram') > -1; + /* Should sensitive posts be allowed Instant View? */ + const useIV = isTelegram /*&& !tweet.possibly_sensitive*/ && !flags?.direct && !flags?.api && (tweet.media?.mosaic || tweet.is_note_tweet); + + let ivbody = ''; /* Catch this request if it's an API response */ if (flags?.api) { @@ -120,12 +127,21 @@ export const handleStatus = async ( it will gracefully redirect to the destination instead of just seeing a blank screen. Telegram is dumb and it just gets stuck if this is included, so we never include it for Telegram UAs. */ - if (userAgent?.indexOf('Telegram') === -1) { + if (!isTelegram) { headers.push( `<meta http-equiv="refresh" content="0;url=https://twitter.com/${tweet.author.screen_name}/status/${tweet.id}"/>` ); } + if (useIV) { + const instructions = renderInstantView({ tweet: tweet, text: newText }); + headers.push(...instructions.addHeaders); + if (instructions.authorText) { + authorText = instructions.authorText; + } + ivbody = instructions.text || ''; + } + /* This Tweet has a translation attached to it, so we'll render it. */ if (tweet.translation) { const { translation } = tweet; @@ -240,7 +256,7 @@ export const handleStatus = async ( let str = ''; /* Telegram Embeds are smaller, so we use a smaller bar to compensate */ - if (userAgent?.indexOf('Telegram') !== -1) { + if (isTelegram) { barLength = 24; } @@ -273,16 +289,21 @@ export const handleStatus = async ( /* If we have no media to display, instead we'll display the user profile picture in the embed */ if (!tweet.media?.videos && !tweet.media?.photos && !flags?.textOnly) { - headers.push( - /* Use a slightly higher resolution image for profile pics */ - `<meta property="og:image" content="${tweet.author.avatar_url?.replace( - '_normal', - '_200x200' - )}"/>`, - `<meta property="twitter:image" content="0"/>` - ); + const avatar = tweet.author.avatar_url?.replace('_200x200', '_normal'); + if (!useIV) { + headers.push( + /* Use a slightly higher resolution image for profile pics */ + `<meta property="og:image" content="${avatar}"/>`, + `<meta property="twitter:image" content="0"/>` + ); + } else { + headers.push( + /* Use a slightly higher resolution image for profile pics */ + `<meta property="twitter:image" content="${avatar}"/>` + ); + } } - + if (!flags?.isXDomain) { siteName = Strings.X_DOMAIN_NOTICE; } @@ -291,11 +312,22 @@ export const handleStatus = async ( if (flags?.deprecated) { siteName = Strings.DEPRECATED_DOMAIN_NOTICE; } + /* For supporting Telegram IV, we have to replace newlines with <br> within the og:description <meta> tag because of its weird (undocumented?) behavior. + If you don't use IV, it uses newlines just fine. Just like Discord and others. But with IV, suddenly newlines don't actually break the line anymore. + + This is incredibly stupid, and you'd think this weird behavior would not be the case. You'd also think embedding a <br> inside the quotes inside + a meta tag shouldn't work, because that's stupid, but alas it does. + + A possible explanation for this weird behavior is due to the Medium template we are forced to use because Telegram IV is not an open platform + and we have to pretend to be Medium in order to get working IV, but haven't figured if the template is causing issues. */ + const text = useIV + ? sanitizeText(newText).replace(/\n/g, '<br>') + : sanitizeText(newText); /* Push basic headers relating to author, Tweet text, and site name */ headers.push( `<meta property="og:title" content="${tweet.author.name} (@${tweet.author.screen_name})"/>`, - `<meta property="og:description" content="${sanitizeText(newText).replace(/\n/g, '<br>')}"/>`, + `<meta property="og:description" content="${text}"/>`, `<meta property="og:site_name" content="${siteName}"/>` ); @@ -317,9 +349,9 @@ export const handleStatus = async ( authorText.substring(0, 200) )}${flags?.deprecated ? '&deprecated=true' : ''}&status=${encodeURIComponent( status - )}&author=${encodeURIComponent( - tweet.author?.screen_name || '' - )}&useXbranding=${flags?.isXDomain ? 'true' : 'false'}" type="application/json+oembed" title="${tweet.author.name}">` + )}&author=${encodeURIComponent(tweet.author?.screen_name || '')}&useXbranding=${ + flags?.isXDomain ? 'true' : 'false' + }" type="application/json+oembed" title="${tweet.author.name}">` ); /* When dealing with a Tweet of unknown lang, fall back to en */ @@ -329,7 +361,8 @@ export const handleStatus = async ( return { text: Strings.BASE_HTML.format({ lang: `lang="${lang}"`, - headers: headers.join('') + headers: headers.join(''), + body: ivbody }), cacheControl: cacheControl }; 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/author.ts b/src/helpers/author.ts index e2c55fe..bf0a4a8 100644 --- a/src/helpers/author.ts +++ b/src/helpers/author.ts @@ -24,3 +24,28 @@ export const getAuthorText = (tweet: APITweet): string | null => { return null; }; + +/* The embed "author" text we populate with replies, retweets, and likes unless it's a video */ +export const getSocialTextIV = (tweet: APITweet): string | null => { + /* Build out reply, retweet, like counts */ + if (tweet.likes > 0 || tweet.retweets > 0 || tweet.replies > 0) { + let authorText = ''; + if (tweet.replies > 0) { + authorText += `π¬ ${formatNumber(tweet.replies)} `; + } + if (tweet.retweets > 0) { + authorText += `π ${formatNumber(tweet.retweets)} `; + } + if (tweet.likes > 0) { + authorText += `β€οΈ ${formatNumber(tweet.likes)} `; + } + if (tweet.views && tweet.views > 0) { + authorText += `ποΈ ${formatNumber(tweet.views)} `; + } + authorText = authorText.trim(); + + return authorText; + } + + return null; +}; 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<string, { string_value?: string; boolean_value?: boolean }> = {}; + 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/helpers/media.ts b/src/helpers/media.ts index 24e6b9b..b1e4b84 100644 --- a/src/helpers/media.ts +++ b/src/helpers/media.ts @@ -4,8 +4,8 @@ export const processMedia = (media: TweetMedia): APIPhoto | APIVideo | null => { return { type: 'photo', url: media.media_url_https, - width: media.original_info.width, - height: media.original_info.height, + width: media.original_info?.width, + height: media.original_info?.height, altText: media.ext_alt_text || '' }; } else if (media.type === 'video' || media.type === 'animated_gif') { @@ -17,8 +17,8 @@ export const processMedia = (media: TweetMedia): APIPhoto | APIVideo | null => { url: bestVariant?.url || '', thumbnail_url: media.media_url_https, duration: (media.video_info?.duration_millis || 0) / 1000, - width: media.original_info.width, - height: media.original_info.height, + width: media.original_info?.width, + height: media.original_info?.height, format: bestVariant?.content_type || '', type: media.type === 'animated_gif' ? 'gif' : 'video' }; diff --git a/src/render/instantview.ts b/src/render/instantview.ts new file mode 100644 index 0000000..b67cc46 --- /dev/null +++ b/src/render/instantview.ts @@ -0,0 +1,108 @@ +import { Constants } from "../constants"; +import { getSocialTextIV } from "../helpers/author"; +import { sanitizeText } from "../helpers/utils"; + +const populateUserLinks = (tweet: APITweet, text: string): string => { + /* TODO: Maybe we can add username splices to our API so only genuinely valid users are linked? */ + text.match(/@(\w{1,15})/g)?.forEach((match) => { + const username = match.replace('@', ''); + text = text.replace( + match, + `<a href="${Constants.TWITTER_ROOT}/${username}" target="_blank" rel="noopener noreferrer">${match}</a>` + ); + }); + return text; +} + +const generateTweetMedia = (tweet: APITweet): string => { + let media = ''; + if (tweet.media?.all?.length) { + tweet.media.all.forEach((mediaItem) => { + switch(mediaItem.type) { + case 'photo': + media += `<img src="${mediaItem.url}" alt="${tweet.author.name}'s photo" />`; + break; + case 'video': + media += `<video src="${mediaItem.url}" alt="${tweet.author.name}'s video" />`; + break; + case 'gif': + media += `<video src="${mediaItem.url}" alt="${tweet.author.name}'s gif" />`; + break; + } + }); + } + return media; +} + +// const formatDateTime = (date: Date): string => { +// const yyyy = date.getFullYear(); +// const mm = String(date.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed +// const dd = String(date.getDate()).padStart(2, '0'); +// const hh = String(date.getHours()).padStart(2, '0'); +// const min = String(date.getMinutes()).padStart(2, '0'); +// return `${hh}:${min} - ${yyyy}/${mm}/${dd}`; +// } + +const htmlifyLinks = (input: string): string => { + const urlPattern = /\bhttps?:\/\/\S+/g; + return input.replace(urlPattern, (url) => { + return `<a href="${url}">${url}</a>`; + }); +} + +const htmlifyHashtags = (input: string): string => { + const hashtagPattern = /#([a-zA-Z_]\w*)/g; + return input.replace(hashtagPattern, (match, hashtag) => { + const encodedHashtag = encodeURIComponent(hashtag); + return `<a href="https://twitter.com/hashtag/${encodedHashtag}?src=hashtag_click">${match}</a>`; + }); +} + +export const renderInstantView = (properties: RenderProperties): ResponseInstructions => { + console.log('Generating Instant View (placeholder)...'); + const { tweet } = properties; + const instructions: ResponseInstructions = { addHeaders: [] }; + /* Use ISO date for Medium template */ + const postDate = new Date(tweet.created_at).toISOString(); + + /* Include Instant-View related headers. This is an unfinished project. Thanks to https://nikstar.me/post/instant-view/ for the help! */ + instructions.addHeaders = [ + `<meta property="al:android:app_name" content="Medium"/>`, + `<meta property="article:published_time" content="${postDate}"/>` + ]; + + let text = sanitizeText(tweet.text).replace(/\n/g, '<br>'); + text = htmlifyLinks(text); + text = htmlifyHashtags(text); + text = populateUserLinks(tweet, text); + + instructions.text = ` + <section class="section-backgroundImage"> + <figure class="graf--layoutFillWidth"></figure> + </section> + <section class="section--first" style="font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 64px;"> + If you can see this, your browser is doing something weird with your user agent. <a href="${tweet.url}">View original post</a> + </section> + <article> + <h1>${tweet.author.name} (@${tweet.author.screen_name})</h1> + <p>Instant View (β¨ Beta) - <a href="${tweet.url}">View original</a></p> + + <!--blockquote class="twitter-tweet" data-dnt="true"><p lang="en" dir="ltr"> <a href="${tweet.url}">_</a></blockquote--> + + <!-- Embed profile picture, display name, and screen name in table --> + <table> + <img src="${tweet.author.avatar_url}" alt="${tweet.author.name}'s profile picture" /> + <h2>${tweet.author.name}</h2> + <p>@${tweet.author.screen_name}</p> + <p>${getSocialTextIV(tweet)}</p> + </table> + + <!-- Embed Tweet text --> + <p>${text}</p> + ${generateTweetMedia(tweet)} + <a href="${tweet.url}">View original</a> +</article> +`; + + return instructions; +}; diff --git a/src/server.ts b/src/server.ts index 8230585..53d6bc9 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', @@ -394,7 +396,7 @@ export const cacheWrapper = async ( ) { return new Response(Strings.TWITFIX_API_SUNSET, { headers: Constants.RESPONSE_HEADERS, - status: 404 + status: 410 }); } diff --git a/src/strings.ts b/src/strings.ts index 591a4ba..729f60d 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -30,7 +30,7 @@ export const Strings = { βββ A better way to embed Tweets on Discord, Telegram, and more. βββ Worker build ${RELEASE_NAME} ---><head>{headers}</head><body></body></html>`, +--><head>{headers}</head><body>{body}</body></html>`, ERROR_HTML: `<!DOCTYPE html> <html lang="en"> <head> @@ -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; // "<a href=\"https://mobile.twitter.com\" rel=\"nofollow\">Twitter Web App</a>" 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<string, never>; -} +}; 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/types/types.d.ts b/src/types/types.d.ts index 05a5857..69ffc15 100644 --- a/src/types/types.d.ts +++ b/src/types/types.d.ts @@ -172,6 +172,8 @@ interface APITweet { source: string; + is_note_tweet: boolean; + twitter_card: 'tweet' | 'summary' | 'summary_large_image' | 'player'; } 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 +});