358 lines
14 KiB
TypeScript
358 lines
14 KiB
TypeScript
import type { TwitterApiReadOnly } from 'twitter-api-v2';
|
|
import { TwitterApi } from 'twitter-api-v2';
|
|
|
|
import { config } from '@/config';
|
|
import ConfigNotFoundError from '@/errors/types/config-not-found';
|
|
import InvalidParameterError from '@/errors/types/invalid-parameter';
|
|
import cache from '@/utils/cache';
|
|
|
|
interface ClientWrapper {
|
|
client: TwitterApiReadOnly;
|
|
isUserAuth: boolean;
|
|
}
|
|
|
|
const appClients: ClientWrapper[] = [];
|
|
let index = -1;
|
|
|
|
const init = () => {
|
|
if (appClients.length) {
|
|
return;
|
|
}
|
|
if (!config.twitter.consumerKey || !config.twitter.consumerSecret) {
|
|
return;
|
|
}
|
|
|
|
const consumerKeys = config.twitter.consumerKey.split(',');
|
|
const consumerSecrets = config.twitter.consumerSecret.split(',');
|
|
const accessTokens = config.twitter.accessToken?.split(',') || [];
|
|
const accessSecrets = config.twitter.accessSecret?.split(',') || [];
|
|
|
|
for (const [index, consumerKey] of consumerKeys.entries()) {
|
|
const consumerSecret = consumerSecrets[index];
|
|
const accessToken = accessTokens[index];
|
|
const accessSecret = accessSecrets[index];
|
|
|
|
if (!consumerKey || !consumerSecret) {
|
|
continue;
|
|
}
|
|
|
|
if (accessToken && accessSecret) {
|
|
appClients.push({
|
|
client: new TwitterApi({
|
|
appKey: consumerKey,
|
|
appSecret: consumerSecret,
|
|
accessToken,
|
|
accessSecret,
|
|
}).readOnly,
|
|
isUserAuth: true,
|
|
});
|
|
} else {
|
|
appClients.push({
|
|
client: new TwitterApi({
|
|
appKey: consumerKey,
|
|
appSecret: consumerSecret,
|
|
}).readOnly,
|
|
isUserAuth: false,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
export const getAppClient = async () => {
|
|
init();
|
|
if (!appClients.length) {
|
|
throw new ConfigNotFoundError('Twitter API is not configured');
|
|
}
|
|
index += 1;
|
|
|
|
const currentWrapper = appClients[index % appClients.length];
|
|
|
|
return currentWrapper.isUserAuth ? currentWrapper.client : await currentWrapper.client.appLogin();
|
|
};
|
|
|
|
const mapUserToLegacy = (user: Record<string, any>) =>
|
|
user
|
|
? {
|
|
id_str: user.id,
|
|
name: user.name,
|
|
screen_name: user.username,
|
|
description: user.description,
|
|
profile_image_url: user.profile_image_url,
|
|
profile_image_url_https: user.profile_image_url,
|
|
url: user.url,
|
|
verified: user.verified,
|
|
}
|
|
: null;
|
|
|
|
const mapUrlsToLegacy = (urls: Array<Record<string, any>> = []) =>
|
|
urls.map((url) => ({
|
|
url: url.url,
|
|
expanded_url: url.expanded_url ?? url.unwound_url ?? url.url,
|
|
display_url: url.display_url ?? url.url,
|
|
}));
|
|
|
|
const mapHashtagsToLegacy = (hashtags: Array<Record<string, any>> = []) => hashtags.map((hashtag) => ({ text: hashtag.tag }));
|
|
|
|
const mapMentionsToLegacy = (mentions: Array<Record<string, any>> = []) =>
|
|
mentions.map((mention) => ({
|
|
id_str: mention.id,
|
|
screen_name: mention.username,
|
|
name: mention.username,
|
|
}));
|
|
|
|
const mapMediaToLegacy = (media: Record<string, any>) => {
|
|
const url = media.url ?? media.preview_image_url;
|
|
const mapped = {
|
|
id_str: media.media_key,
|
|
type: media.type,
|
|
media_url_https: url,
|
|
media_url: url,
|
|
url,
|
|
sizes: {
|
|
large: {
|
|
w: media.width ?? 0,
|
|
h: media.height ?? 0,
|
|
resize: 'fit',
|
|
},
|
|
},
|
|
} as Record<string, any>;
|
|
|
|
if (media.variants?.length) {
|
|
mapped.video_info = {
|
|
variants: media.variants.map((variant) => ({
|
|
bitrate: variant.bit_rate,
|
|
content_type: variant.content_type,
|
|
url: variant.url,
|
|
})),
|
|
};
|
|
}
|
|
|
|
return mapped;
|
|
};
|
|
|
|
const mapTweetToLegacy = (tweet: Record<string, any>, includes: Record<string, any> | undefined, cacheMap: Map<string, Record<string, any>>) => {
|
|
if (cacheMap.has(tweet.id)) {
|
|
return cacheMap.get(tweet.id);
|
|
}
|
|
|
|
const users = new Map((includes?.users ?? []).map((user) => [user.id, user]));
|
|
const tweets = new Map((includes?.tweets ?? []).map((item) => [item.id, item]));
|
|
const media = new Map((includes?.media ?? []).map((item) => [item.media_key, item]));
|
|
|
|
const user = users.get(tweet.author_id);
|
|
const legacyUser = mapUserToLegacy(user);
|
|
const legacy: Record<string, any> = {
|
|
id_str: tweet.id,
|
|
conversation_id_str: tweet.conversation_id,
|
|
full_text: tweet.text,
|
|
text: tweet.text,
|
|
created_at: tweet.created_at,
|
|
entities: {
|
|
urls: mapUrlsToLegacy(tweet.entities?.urls),
|
|
hashtags: mapHashtagsToLegacy(tweet.entities?.hashtags),
|
|
user_mentions: mapMentionsToLegacy(tweet.entities?.mentions),
|
|
symbols: [],
|
|
},
|
|
extended_entities: {
|
|
media: (tweet.attachments?.media_keys ?? [])
|
|
.map((key) => media.get(key))
|
|
.filter(Boolean)
|
|
.map((item) => mapMediaToLegacy(item)),
|
|
},
|
|
user: legacyUser,
|
|
user_id_str: tweet.author_id,
|
|
in_reply_to_user_id_str: tweet.in_reply_to_user_id,
|
|
};
|
|
|
|
cacheMap.set(tweet.id, legacy);
|
|
|
|
for (const reference of tweet.referenced_tweets ?? []) {
|
|
const referenced = tweets.get(reference.id);
|
|
if (!referenced) {
|
|
continue;
|
|
}
|
|
const mappedReferenced = mapTweetToLegacy(referenced, includes, cacheMap);
|
|
switch (reference.type) {
|
|
case 'retweeted':
|
|
legacy.retweeted_status = mappedReferenced;
|
|
break;
|
|
|
|
case 'quoted':
|
|
legacy.quoted_status = mappedReferenced;
|
|
break;
|
|
|
|
case 'replied_to': {
|
|
legacy.in_reply_to_status_id_str = reference.id;
|
|
legacy.in_reply_to_user_id_str = referenced.author_id;
|
|
const replyUser = users.get(referenced.author_id);
|
|
legacy.in_reply_to_screen_name = replyUser?.username;
|
|
|
|
break;
|
|
}
|
|
default:
|
|
// Do nothing
|
|
}
|
|
}
|
|
|
|
if (!legacy.extended_entities.media?.length) {
|
|
delete legacy.extended_entities;
|
|
}
|
|
|
|
return legacy;
|
|
};
|
|
|
|
const mapTweetResponseToLegacy = (response: Record<string, any>) => {
|
|
const cacheMap = new Map<string, Record<string, any>>();
|
|
return (response?.data ?? []).map((tweet) => mapTweetToLegacy(tweet, response.includes, cacheMap));
|
|
};
|
|
|
|
const getUserData = (id: string) =>
|
|
cache.tryGet(`twitter-userdata-${id}`, async () => {
|
|
const client = await getAppClient();
|
|
const params = {
|
|
'user.fields': 'profile_image_url,description,verified,url',
|
|
};
|
|
const response = id.startsWith('+') ? await client.v2.user(id.slice(1), params) : await client.v2.userByUsername(id, params);
|
|
return mapUserToLegacy(response?.data);
|
|
});
|
|
|
|
const cacheTryGet = async (_id: string, params: Record<string, any> | undefined, func: (id: string, params?: Record<string, any>) => Promise<any>) => {
|
|
const userData: any = await getUserData(_id);
|
|
const id = userData?.id_str;
|
|
if (id === undefined) {
|
|
cache.set(`twitter-userdata-${_id}`, '', config.cache.contentExpire);
|
|
throw new InvalidParameterError('User not found');
|
|
}
|
|
const funcName = func.name;
|
|
const paramsString = JSON.stringify(params);
|
|
return cache.tryGet(`twitter:${id}:${funcName}:${paramsString}`, () => func(id, params), config.cache.routeExpire, false);
|
|
};
|
|
|
|
const getUserTimeline = async (id: string, params?: Record<string, any>, options: Record<string, any> = {}) => {
|
|
const client = await getAppClient();
|
|
const response = await client.v2.get(`users/${id}/tweets`, {
|
|
max_results: params?.count ?? 20,
|
|
expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
|
|
'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id',
|
|
'user.fields': 'username,name,profile_image_url,description',
|
|
'media.fields': 'preview_image_url,url,type,width,height,variants',
|
|
...options,
|
|
});
|
|
return mapTweetResponseToLegacy(response);
|
|
};
|
|
|
|
const getUserTweets = (id: string, params?: Record<string, any>) => cacheTryGet(id, params, (id, params = {}) => getUserTimeline(id, params, { exclude: 'replies' }));
|
|
|
|
const getUserTweetsAndReplies = (id: string, params?: Record<string, any>) => cacheTryGet(id, params, (id, params = {}) => getUserTimeline(id, params));
|
|
|
|
const getUserMedia = (id: string, params?: Record<string, any>) =>
|
|
cacheTryGet(id, params, async (id, params = {}) => {
|
|
const data = await getUserTimeline(id, params);
|
|
return data.filter((tweet) => tweet.extended_entities?.media);
|
|
});
|
|
|
|
const getUserLikes = (id: string, params?: Record<string, any>) =>
|
|
cacheTryGet(id, params, async (id, params = {}) => {
|
|
const client = await getAppClient();
|
|
const response = await client.v2.get(`users/${id}/liked_tweets`, {
|
|
max_results: params.count ?? 20,
|
|
expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
|
|
'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id',
|
|
'user.fields': 'username,name,profile_image_url,description',
|
|
'media.fields': 'preview_image_url,url,type,width,height,variants',
|
|
});
|
|
return mapTweetResponseToLegacy(response);
|
|
});
|
|
|
|
const getUserTweet = (id: string, params?: Record<string, any>) =>
|
|
cacheTryGet(id, params, async (_id, params = {}) => {
|
|
const client = await getAppClient();
|
|
const tweetId = params.focalTweetId;
|
|
if (!tweetId) {
|
|
throw new InvalidParameterError('Tweet ID is required');
|
|
}
|
|
const response = await client.v2.get(`tweets/${tweetId}`, {
|
|
expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
|
|
'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id',
|
|
'user.fields': 'username,name,profile_image_url,description',
|
|
'media.fields': 'preview_image_url,url,type,width,height,variants',
|
|
});
|
|
return mapTweetResponseToLegacy({ data: response?.data ? [response.data] : [], includes: response?.includes });
|
|
});
|
|
|
|
const getSearch = (keywords: string, params?: Record<string, any>) =>
|
|
cache.tryGet(
|
|
`twitter:search:${keywords}:${JSON.stringify(params)}`,
|
|
async () => {
|
|
const client = await getAppClient();
|
|
const response = await client.v2.get('tweets/search/recent', {
|
|
query: keywords,
|
|
max_results: params?.count ?? 20,
|
|
expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
|
|
'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id',
|
|
'user.fields': 'username,name,profile_image_url,description',
|
|
'media.fields': 'preview_image_url,url,type,width,height,variants',
|
|
});
|
|
return mapTweetResponseToLegacy(response);
|
|
},
|
|
config.cache.routeExpire,
|
|
false
|
|
);
|
|
|
|
const getList = (id: string, params?: Record<string, any>) =>
|
|
cache.tryGet(
|
|
`twitter:list:${id}:${JSON.stringify(params)}`,
|
|
async () => {
|
|
const client = await getAppClient();
|
|
const response = await client.v2.get(`lists/${id}/tweets`, {
|
|
max_results: params?.count ?? 20,
|
|
expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
|
|
'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id',
|
|
'user.fields': 'username,name,profile_image_url,description',
|
|
'media.fields': 'preview_image_url,url,type,width,height,variants',
|
|
});
|
|
return mapTweetResponseToLegacy(response);
|
|
},
|
|
config.cache.routeExpire,
|
|
false
|
|
);
|
|
|
|
const getHomeTimeline = (_id: string, params?: Record<string, any>) =>
|
|
cache.tryGet(
|
|
`twitter:home:${JSON.stringify(params)}`,
|
|
async () => {
|
|
if (!_id) {
|
|
throw new InvalidParameterError('User ID is required for the v2 home timeline');
|
|
}
|
|
const client = await getAppClient();
|
|
const response = await client.v2.get(`users/${_id}/timelines/reverse_chronological`, {
|
|
max_results: params?.count ?? 20,
|
|
expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
|
|
'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id',
|
|
'user.fields': 'username,name,profile_image_url,description',
|
|
'media.fields': 'preview_image_url,url,type,width,height,variants',
|
|
});
|
|
return mapTweetResponseToLegacy(response);
|
|
},
|
|
config.cache.routeExpire,
|
|
false
|
|
);
|
|
|
|
const getHomeLatestTimeline = (id: string, params?: Record<string, any>) => getHomeTimeline(id, params);
|
|
|
|
const getUser = (id: string) => getUserData(id);
|
|
|
|
export default {
|
|
getUser,
|
|
getUserTweets,
|
|
getUserTweetsAndReplies,
|
|
getUserMedia,
|
|
getUserLikes,
|
|
getUserTweet,
|
|
getSearch,
|
|
getList,
|
|
getHomeTimeline,
|
|
getHomeLatestTimeline,
|
|
init,
|
|
};
|