Added support for fetching user profiles

This commit is contained in:
Wazbat 2023-04-15 17:12:41 +02:00
parent 2d0faced46
commit e7d8933056
9 changed files with 445 additions and 9 deletions

View file

@ -187,7 +187,7 @@ export const statusAPI = async (
language: string | undefined,
event: FetchEvent,
flags?: InputFlags
): Promise<APIResponse> => {
): Promise<TweetAPIResponse> => {
let conversation = await fetchConversation(status, event);
let tweet = conversation?.globalObjects?.tweets?.[status] || {};
@ -242,7 +242,7 @@ export const statusAPI = async (
}
/* Creating the response objects */
const response: APIResponse = { code: 200, message: 'OK' } as APIResponse;
const response: TweetAPIResponse = { code: 200, message: 'OK' } as TweetAPIResponse;
const apiTweet: APITweet = (await populateTweetProperties(
tweet,
conversation,

91
src/api/user.ts Normal file
View file

@ -0,0 +1,91 @@
import { renderCard } from '../helpers/card';
import { Constants } from '../constants';
import { fetchConversation, fetchUser } from '../fetch';
import { linkFixer } from '../helpers/linkFixer';
import { handleMosaic } from '../helpers/mosaic';
import { colorFromPalette } from '../helpers/palette';
import { translateTweet } from '../helpers/translate';
import { unescapeText } from '../helpers/utils';
import { processMedia } from '../helpers/media';
/* This function does the heavy lifting of processing data from Twitter API
and using it to create FixTweet's streamlined API responses */
const populateUserProperties = async (
response: GraphQLUserResponse,
language: string | undefined
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APIUser> => {
const apiUser = {} as APIUser;
const user = response.data.user.result;
/* Populating a lot of the basics */
apiUser.url = `${Constants.TWITTER_ROOT}/${user.legacy.screen_name}`;
apiUser.id = user.id;
apiUser.followers = user.legacy.followers_count;
apiUser.following = user.legacy.friends_count;
apiUser.likes = user.legacy.favourites_count;
apiUser.tweets = user.legacy.statuses_count;
apiUser.name = user.legacy.name;
apiUser.screen_name = user.legacy.screen_name;
apiUser.description = user.legacy.description;
apiUser.location = user.legacy.location;
apiUser.verified = user.legacy.verified;
apiUser.avatar_url = user.legacy.profile_image_url_https;
return apiUser;
};
const writeDataPoint = (
event: FetchEvent,
language: string | undefined,
returnCode: string,
flags?: InputFlags
) => {
console.log('Writing data point...');
if (typeof AnalyticsEngine !== 'undefined') {
const flagString =
Object.keys(flags || {})
// @ts-expect-error - TypeScript doesn't like iterating over the keys, but that's OK
.filter(flag => flags?.[flag])[0] || 'standard';
AnalyticsEngine.writeDataPoint({
blobs: [
event.request.cf?.colo as string /* Datacenter location */,
event.request.cf?.country as string /* Country code */,
event.request.headers.get('user-agent') ??
'' /* User agent (for aggregating bots calling) */,
returnCode /* Return code */,
flagString /* Type of request */,
language ?? '' /* For translate feature */
],
doubles: [0 /* NSFW media = 1, No NSFW Media = 0 */],
indexes: [event.request.headers.get('cf-ray') ?? '' /* CF Ray */]
});
}
};
/* API for Twitter profiles (Users)
Used internally by FixTweet's embed service, or
available for free using api.fxtwitter.com. */
export const userAPI = async (
username: string,
language: string | undefined,
event: FetchEvent,
flags?: InputFlags
): Promise<UserAPIResponse> => {
const userResponse = await fetchUser(username, event);
/* Creating the response objects */
const response: UserAPIResponse = { code: 200, message: 'OK' } as UserAPIResponse;
const apiTweet: APIUser = (await populateUserProperties(
userResponse,
language
)) as APIUser;
/* Finally, staple the User to the response and return it */
response.user = apiTweet;
writeDataPoint(event, language, 'OK', flags);
return response;
};

View file

@ -203,3 +203,40 @@ export const fetchConversation = async (
}
)) as TimelineBlobPartial;
};
export const fetchUser = async (
username: string,
event: FetchEvent,
useElongator = false
): Promise<GraphQLUserResponse> => {
return (await twitterFetch(
`${Constants.TWITTER_ROOT}/i/api/graphql/sLVLhk0bGj3MVFEKTdax1w/UserByScreenName?variables=${
encodeURIComponent(
JSON.stringify({
screen_name: username,
withSafetyModeUserFields: true
})
)
}&features=${encodeURIComponent(
JSON.stringify({
blue_business_profile_image_shape_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: true
})
)}`,
event,
useElongator,
// Validator function
(_res: unknown) => {
const response = _res as GraphQLUserResponse;
return !(response?.data?.user?.result?.__typename !== 'User' || typeof response.data.user.result.legacy === 'undefined');
/*
return !(
typeof conversation.globalObjects === 'undefined' &&
(typeof conversation.errors === 'undefined' ||
conversation.errors?.[0]?.code === 239)
);
*/
}
)) as GraphQLUserResponse;
};

View file

@ -7,6 +7,7 @@ import { Strings } from './strings';
import motd from '../motd.json';
import { sanitizeText } from './helpers/utils';
import { handleProfile } from './user';
const router = Router();
@ -140,18 +141,100 @@ const statusRequest = async (
}
};
/* Redirects to user profile when linked.
We don't do any fancy special embeds yet, just Twitter default embeds. */
const profileRequest = async (request: IRequest) => {
const { handle } = request.params;
/* Handler for User Profiles */
const profileRequest = async (request: IRequest, event: FetchEvent,
flags: InputFlags = {}) => {
const { handle, language } = request.params;
const url = new URL(request.url);
const userAgent = request.headers.get('User-Agent') || '';
/* User Agent matching for embed generators, bots, crawlers, and other automated
tools. It's pretty all-encompassing. Note that Firefox/92 is in here because
Discord sometimes uses the following UA:
Mozilla/5.0 (Macintosh; Intel Mac OS X 11.6; rv:92.0) Gecko/20100101 Firefox/92.0
I'm not sure why that specific one, it's pretty weird, but this edge case ensures
stuff keeps working.
On the very rare off chance someone happens to be using specifically Firefox 92,
the http-equiv="refresh" meta tag will ensure an actual human is sent to the destination. */
const isBotUA = userAgent.match(Constants.BOT_UA_REGEX) !== null;
/* If not a valid screen name, we redirect to project GitHub */
if (handle.match(/\w{1,15}/gi)?.[0] !== handle) {
return Response.redirect(Constants.REDIRECT_URL, 302);
} else {
return Response.redirect(`${Constants.TWITTER_ROOT}${url.pathname}`, 302);
}
const username = handle.match(/\w{1,15}/gi)?.[0];
/* Check if request is to api.fxtwitter.com, or the tweet is appended with .json
Note that unlike TwitFix, FixTweet will never generate embeds for .json, and
in fact we only support .json because it's what people using TwitFix API would
be used to. */
if (
url.pathname.match(/\/status(es)?\/\d{2,20}\.(json)/g) !== null ||
Constants.API_HOST_LIST.includes(url.hostname)
) {
console.log('JSON API request');
flags.api = true;
}
/* Direct media or API access bypasses bot check, returning same response regardless of UA */
if (isBotUA || flags.direct || flags.api) {
if (isBotUA) {
console.log(`Matched bot UA ${userAgent}`);
} else {
console.log('Bypass bot check');
}
/* This throws the necessary data to handleStatus (in status.ts) */
const profileResponse = await handleProfile(
handle.match(/\w{1,15}/gi)?.[0] || '',
userAgent,
flags,
language,
event
);
/* Complete responses are normally sent just by errors. Normal embeds send a `text` value. */
if (profileResponse.response) {
console.log('handleProfile sent response');
return profileResponse.response;
} else if (profileResponse.text) {
console.log('handleProfile sent embed');
/* TODO This check has purpose in the original handleStatus handler, but I'm not sure if this edge case can happen here */
if (!isBotUA) {
return Response.redirect(`${Constants.TWITTER_ROOT}/${handle}`, 302);
}
let headers = Constants.RESPONSE_HEADERS;
if (profileResponse.cacheControl) {
headers = { ...headers, 'cache-control': profileResponse.cacheControl };
}
/* Return the response containing embed information */
return new Response(profileResponse.text, {
headers: headers,
status: 200
});
} else {
/* Somehow handleStatus sent us nothing. This should *never* happen, but we have a case for it. */
return new Response(Strings.ERROR_UNKNOWN, {
headers: Constants.RESPONSE_HEADERS,
status: 500
});
}
} else {
/* A human has clicked a fxtwitter.com/:screen_name link!
Obviously we just need to redirect to the user directly.*/
console.log('Matched human UA', userAgent);
return Response.redirect(
`${Constants.TWITTER_ROOT}/${handle}`,
302
);
}
};
const genericTwitterRedirect = async (request: IRequest) => {

View file

@ -148,6 +148,7 @@ This is caused by Twitter API downtime or a new bug. Try again in a little while
ERROR_API_FAIL: 'Tweet failed to load due to an API error :(',
ERROR_PRIVATE: `Due to Twitter API changes, some NSFW Tweets are currently being blocked. We are currently looking into a workaround. 🙏`,
ERROR_TWEET_NOT_FOUND: `Sorry, that Tweet doesn't exist :(`,
ERROR_USER_NOT_FOUND: `Sorry, that Tweet doesn't exist :(`,
ERROR_UNKNOWN: `Unknown error occurred, sorry about that :(`,
TWITFIX_API_SUNSET: `The original TwitFix API has been sunset. To learn more about the FixTweet API, check out <a href="https://${

View file

@ -192,3 +192,100 @@ type TranslationPartial = {
translation: string;
entities: TweetEntities;
};
type GraphQLUserResponse = {
data: {
user: {
result: GraphQLUser;
}
}
}
type GraphQLUser = {
__typename: "User",
id: string; // "VXNlcjozNzg0MTMxMzIy",
"rest_id": string; // "3784131322",
"affiliates_highlighted_label": Record<string, unknown>; // {},
"is_blue_verified": boolean; // false,
"profile_image_shape": 'Circle' | 'Square'; // "Circle",
"legacy": {
"created_at": string; // "Sat Sep 26 17:20:55 +0000 2015",
"default_profile": boolean // false,
"default_profile_image": boolean // false,
"description": string; // "dangered wolf#3621 https://t.co/eBTS4kksMw",
"entities": {
"description": {
"urls": {
"display_url": string; // "t.me/dangeredwolf",
"expanded_url": string; // "http://t.me/dangeredwolf",
"url": string; // "https://t.co/eBTS4kksMw",
"indices": [
19,
42
]
}[]
}
},
"fast_followers_count": 0,
"favourites_count": number; // 126708,
"followers_count": number; // 4996,
"friends_count": number; // 2125,
"has_custom_timelines": boolean; // true,
"is_translator": boolean; // false,
"listed_count": number; // 69,
"location": string; // "they/them",
"media_count": number; // 20839,
"name": string; // "dangered wolf",
"normal_followers_count": number; // 4996,
"pinned_tweet_ids_str": string[]; // Array of tweet ids
"possibly_sensitive": boolean; // false,
"profile_banner_url": string; // "https://pbs.twimg.com/profile_banners/3784131322/1658599775",
"profile_image_url_https": string; // "https://pbs.twimg.com/profile_images/1555638673705783299/3gaaetxC_normal.jpg",
"profile_interstitial_type": string; // "",
"screen_name": string; // "dangeredwolf",
"statuses_count": number; // 108222,
"translator_type": string; // "regular",
"verified": boolean; // false,
"withheld_in_countries": []
},
"professional": {
"rest_id": string; // "1508134739420536845",
"professional_type": string; // "Creator",
"category": [
{
"id": number; // 354,
"name": string // "Fish & Chips Restaurant",
"icon_name": string; // "IconBriefcaseStroke"
}
]
},
"legacy_extended_profile": {
birthdate?: {
day: number; // 7,
month: number; // 1,
visibility: string; // "Public"
year: number; // 2000
year_visibility: string; // "Public"
};
profile_image_shape: string; // "Circle",
rest_id: string; // "3784131322",
},
"is_profile_translatable": false,
"verification_info": {
reason: {
description: {
entities: {
from_index: number; // 98,
ref: {
url: string; // "https://help.twitter.com/managing-your-account/about-twitter-verified-accounts",
url_type: string; // "ExternalUrl"
};
to_index: number; // 108
}[];
text: string; // "This account is verified because its subscribed to Twitter Blue or is a legacy verified account. Learn more"
}
}
},
"business_account": {}
}

27
src/types/types.d.ts vendored
View file

@ -40,12 +40,18 @@ interface VerticalSize {
secondHeight: number;
}
interface APIResponse {
interface TweetAPIResponse {
code: number;
message: string;
tweet?: APITweet;
}
interface UserAPIResponse {
code: number;
message: string;
user?: APIUser;
}
interface APITranslate {
text: string;
source_lang: string;
@ -141,3 +147,22 @@ interface APITweet {
twitter_card: 'tweet' | 'summary' | 'summary_large_image' | 'player';
}
interface APIUser {
id: string;
name: string;
screen_name: string;
avatar_url: string;
banner_url: string;
avatar_color: string;
description: string;
location: string;
url: string;
protected: boolean;
verified: boolean;
followers: number;
following: number;
tweets: number;
likes: number;
joined: string;
}

53
src/user.ts Normal file
View file

@ -0,0 +1,53 @@
import { Constants } from './constants';
import { handleQuote } from './helpers/quote';
import { formatNumber, sanitizeText } from './helpers/utils';
import { Strings } from './strings';
import { getAuthorText } from './helpers/author';
import { userAPI } from './api/user';
export const returnError = (error: string): StatusResponse => {
return {
text: Strings.BASE_HTML.format({
lang: '',
headers: [
`<meta property="og:title" content="${Constants.BRANDING_NAME}"/>`,
`<meta property="og:description" content="${error}"/>`
].join('')
})
};
};
/* Handler for Twitter users */
export const handleProfile = async (
username: string,
userAgent?: string,
flags?: InputFlags,
language?: string,
event?: FetchEvent
): Promise<StatusResponse> => {
console.log('Direct?', flags?.direct);
const api = await userAPI(username, language, event as FetchEvent);
const user = api?.user as APIUser;
/* Catch this request if it's an API response */
// For now we just always return the API response while testing
if (flags?.api || true) {
return {
response: new Response(JSON.stringify(api), {
headers: { ...Constants.RESPONSE_HEADERS, ...Constants.API_RESPONSE_HEADERS },
status: api.code
})
};
}
/* If there was any errors fetching the User, we'll return it */
switch (api.code || true) {
case 401:
return returnError(Strings.ERROR_PRIVATE);
case 404:
return returnError(Strings.ERROR_USER_NOT_FOUND);
case 500:
return returnError(Strings.ERROR_API_FAIL);
}
};

View file

@ -312,3 +312,52 @@ test('API fetch poll Tweet', async () => {
expect(choices[3].count).toEqual(31706);
expect(choices[3].percentage).toEqual(58);
});
test('API fetch user', async () => {
const result = await cacheWrapper(
new Request('https://api.fxtwitter.com/wazbat', {
method: 'GET',
headers: botHeaders
})
);
expect(result.status).toEqual(200);
const response = (await result.json()) as APIResponse;
expect(response).toBeTruthy();
expect(response.code).toEqual(200);
expect(response.message).toEqual('OK');
const tweet = response.tweet as APITweet;
expect(tweet).toBeTruthy();
expect(tweet.url).toEqual('https://twitter.com/Twitter/status/1055475950543167488');
expect(tweet.id).toEqual('1055475950543167488');
expect(tweet.text).toEqual('A poll:');
expect(tweet.author.screen_name?.toLowerCase()).toEqual('twitter');
expect(tweet.author.name).toBeTruthy();
expect(tweet.author.avatar_url).toBeTruthy();
expect(tweet.author.banner_url).toBeTruthy();
expect(tweet.author.avatar_color).toBeTruthy();
expect(tweet.twitter_card).toEqual('tweet');
expect(tweet.created_at).toEqual('Thu Oct 25 15:07:31 +0000 2018');
expect(tweet.created_timestamp).toEqual(1540480051);
expect(tweet.lang).toEqual('en');
expect(tweet.replying_to).toBeNull();
expect(tweet.poll).toBeTruthy();
const poll = tweet.poll as APIPoll;
expect(poll.ends_at).toEqual('2018-10-26T03:07:30Z');
expect(poll.time_left_en).toEqual('Final results');
expect(poll.total_votes).toEqual(54703);
const choices = poll.choices as APIPollChoice[];
expect(choices[0].label).toEqual('Yesssss');
expect(choices[0].count).toEqual(14773);
expect(choices[0].percentage).toEqual(27);
expect(choices[1].label).toEqual('No');
expect(choices[1].count).toEqual(3618);
expect(choices[1].percentage).toEqual(6.6);
expect(choices[2].label).toEqual('Maybe?');
expect(choices[2].count).toEqual(4606);
expect(choices[2].percentage).toEqual(8.4);
expect(choices[3].label).toEqual('Just show me the results');
expect(choices[3].count).toEqual(31706);
expect(choices[3].percentage).toEqual(58);
});