|
| 1 | +import type { TwitterApiReadOnly } from 'twitter-api-v2'; |
| 2 | +import { TwitterApi } from 'twitter-api-v2'; |
| 3 | + |
| 4 | +import { config } from '@/config'; |
| 5 | +import ConfigNotFoundError from '@/errors/types/config-not-found'; |
| 6 | +import InvalidParameterError from '@/errors/types/invalid-parameter'; |
| 7 | +import cache from '@/utils/cache'; |
| 8 | + |
| 9 | +const appClients: TwitterApiReadOnly[] = []; |
| 10 | +let index = -1; |
| 11 | + |
| 12 | +const init = () => { |
| 13 | + if (appClients.length) { |
| 14 | + return; |
| 15 | + } |
| 16 | + if (!config.twitter.consumerKey || !config.twitter.consumerSecret) { |
| 17 | + return; |
| 18 | + } |
| 19 | + |
| 20 | + const consumerKeys = config.twitter.consumerKey.split(','); |
| 21 | + const consumerSecrets = config.twitter.consumerSecret.split(','); |
| 22 | + |
| 23 | + for (const [index, consumerKey] of consumerKeys.entries()) { |
| 24 | + const consumerSecret = consumerSecrets[index]; |
| 25 | + if (!consumerKey || !consumerSecret) { |
| 26 | + continue; |
| 27 | + } |
| 28 | + appClients.push( |
| 29 | + new TwitterApi({ |
| 30 | + appKey: consumerKey, |
| 31 | + appSecret: consumerSecret, |
| 32 | + }).readOnly |
| 33 | + ); |
| 34 | + } |
| 35 | +}; |
| 36 | + |
| 37 | +export const getAppClient = async () => { |
| 38 | + init(); |
| 39 | + if (!appClients.length) { |
| 40 | + throw new ConfigNotFoundError('Twitter API is not configured'); |
| 41 | + } |
| 42 | + index += 1; |
| 43 | + return await appClients[index % appClients.length].appLogin(); |
| 44 | +}; |
| 45 | + |
| 46 | +const mapUserToLegacy = (user: Record<string, any>) => |
| 47 | + user |
| 48 | + ? { |
| 49 | + id_str: user.id, |
| 50 | + name: user.name, |
| 51 | + screen_name: user.username, |
| 52 | + description: user.description, |
| 53 | + profile_image_url: user.profile_image_url, |
| 54 | + profile_image_url_https: user.profile_image_url, |
| 55 | + url: user.url, |
| 56 | + verified: user.verified, |
| 57 | + } |
| 58 | + : null; |
| 59 | + |
| 60 | +const mapUrlsToLegacy = (urls: Array<Record<string, any>> = []) => |
| 61 | + urls.map((url) => ({ |
| 62 | + url: url.url, |
| 63 | + expanded_url: url.expanded_url ?? url.unwound_url ?? url.url, |
| 64 | + display_url: url.display_url ?? url.url, |
| 65 | + })); |
| 66 | + |
| 67 | +const mapHashtagsToLegacy = (hashtags: Array<Record<string, any>> = []) => hashtags.map((hashtag) => ({ text: hashtag.tag })); |
| 68 | + |
| 69 | +const mapMentionsToLegacy = (mentions: Array<Record<string, any>> = []) => |
| 70 | + mentions.map((mention) => ({ |
| 71 | + id_str: mention.id, |
| 72 | + screen_name: mention.username, |
| 73 | + name: mention.username, |
| 74 | + })); |
| 75 | + |
| 76 | +const mapMediaToLegacy = (media: Record<string, any>) => { |
| 77 | + const url = media.url ?? media.preview_image_url; |
| 78 | + const mapped = { |
| 79 | + id_str: media.media_key, |
| 80 | + type: media.type, |
| 81 | + media_url_https: url, |
| 82 | + media_url: url, |
| 83 | + url, |
| 84 | + sizes: { |
| 85 | + large: { |
| 86 | + w: media.width ?? 0, |
| 87 | + h: media.height ?? 0, |
| 88 | + resize: 'fit', |
| 89 | + }, |
| 90 | + }, |
| 91 | + } as Record<string, any>; |
| 92 | + |
| 93 | + if (media.variants?.length) { |
| 94 | + mapped.video_info = { |
| 95 | + variants: media.variants.map((variant) => ({ |
| 96 | + bitrate: variant.bit_rate, |
| 97 | + content_type: variant.content_type, |
| 98 | + url: variant.url, |
| 99 | + })), |
| 100 | + }; |
| 101 | + } |
| 102 | + |
| 103 | + return mapped; |
| 104 | +}; |
| 105 | + |
| 106 | +const mapTweetToLegacy = (tweet: Record<string, any>, includes: Record<string, any> | undefined, cacheMap: Map<string, Record<string, any>>) => { |
| 107 | + if (cacheMap.has(tweet.id)) { |
| 108 | + return cacheMap.get(tweet.id); |
| 109 | + } |
| 110 | + |
| 111 | + const users = new Map((includes?.users ?? []).map((user) => [user.id, user])); |
| 112 | + const tweets = new Map((includes?.tweets ?? []).map((item) => [item.id, item])); |
| 113 | + const media = new Map((includes?.media ?? []).map((item) => [item.media_key, item])); |
| 114 | + |
| 115 | + const user = users.get(tweet.author_id); |
| 116 | + const legacyUser = mapUserToLegacy(user); |
| 117 | + const legacy: Record<string, any> = { |
| 118 | + id_str: tweet.id, |
| 119 | + conversation_id_str: tweet.conversation_id, |
| 120 | + full_text: tweet.text, |
| 121 | + text: tweet.text, |
| 122 | + created_at: tweet.created_at, |
| 123 | + entities: { |
| 124 | + urls: mapUrlsToLegacy(tweet.entities?.urls), |
| 125 | + hashtags: mapHashtagsToLegacy(tweet.entities?.hashtags), |
| 126 | + user_mentions: mapMentionsToLegacy(tweet.entities?.mentions), |
| 127 | + symbols: [], |
| 128 | + }, |
| 129 | + extended_entities: { |
| 130 | + media: (tweet.attachments?.media_keys ?? []) |
| 131 | + .map((key) => media.get(key)) |
| 132 | + .filter(Boolean) |
| 133 | + .map((item) => mapMediaToLegacy(item)), |
| 134 | + }, |
| 135 | + user: legacyUser, |
| 136 | + user_id_str: tweet.author_id, |
| 137 | + in_reply_to_user_id_str: tweet.in_reply_to_user_id, |
| 138 | + }; |
| 139 | + |
| 140 | + cacheMap.set(tweet.id, legacy); |
| 141 | + |
| 142 | + for (const reference of tweet.referenced_tweets ?? []) { |
| 143 | + const referenced = tweets.get(reference.id); |
| 144 | + if (!referenced) { |
| 145 | + continue; |
| 146 | + } |
| 147 | + const mappedReferenced = mapTweetToLegacy(referenced, includes, cacheMap); |
| 148 | + switch (reference.type) { |
| 149 | + case 'retweeted': |
| 150 | + legacy.retweeted_status = mappedReferenced; |
| 151 | + break; |
| 152 | + |
| 153 | + case 'quoted': |
| 154 | + legacy.quoted_status = mappedReferenced; |
| 155 | + break; |
| 156 | + |
| 157 | + case 'replied_to': { |
| 158 | + legacy.in_reply_to_status_id_str = reference.id; |
| 159 | + legacy.in_reply_to_user_id_str = referenced.author_id; |
| 160 | + const replyUser = users.get(referenced.author_id); |
| 161 | + legacy.in_reply_to_screen_name = replyUser?.username; |
| 162 | + |
| 163 | + break; |
| 164 | + } |
| 165 | + default: |
| 166 | + // Do nothing |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + if (!legacy.extended_entities.media?.length) { |
| 171 | + delete legacy.extended_entities; |
| 172 | + } |
| 173 | + |
| 174 | + return legacy; |
| 175 | +}; |
| 176 | + |
| 177 | +const mapTweetResponseToLegacy = (response: Record<string, any>) => { |
| 178 | + const cacheMap = new Map<string, Record<string, any>>(); |
| 179 | + return (response?.data ?? []).map((tweet) => mapTweetToLegacy(tweet, response.includes, cacheMap)); |
| 180 | +}; |
| 181 | + |
| 182 | +const getUserData = (id: string) => |
| 183 | + cache.tryGet(`twitter-userdata-${id}`, async () => { |
| 184 | + const client = await getAppClient(); |
| 185 | + const params = { |
| 186 | + 'user.fields': 'profile_image_url,description,verified,url', |
| 187 | + }; |
| 188 | + const response = id.startsWith('+') ? await client.v2.user(id.slice(1), params) : await client.v2.userByUsername(id, params); |
| 189 | + return mapUserToLegacy(response?.data); |
| 190 | + }); |
| 191 | + |
| 192 | +const cacheTryGet = async (_id: string, params: Record<string, any> | undefined, func: (id: string, params?: Record<string, any>) => Promise<any>) => { |
| 193 | + const userData: any = await getUserData(_id); |
| 194 | + const id = userData?.id_str; |
| 195 | + if (id === undefined) { |
| 196 | + cache.set(`twitter-userdata-${_id}`, '', config.cache.contentExpire); |
| 197 | + throw new InvalidParameterError('User not found'); |
| 198 | + } |
| 199 | + const funcName = func.name; |
| 200 | + const paramsString = JSON.stringify(params); |
| 201 | + return cache.tryGet(`twitter:${id}:${funcName}:${paramsString}`, () => func(id, params), config.cache.routeExpire, false); |
| 202 | +}; |
| 203 | + |
| 204 | +const getUserTimeline = async (id: string, params?: Record<string, any>, options: Record<string, any> = {}) => { |
| 205 | + const client = await getAppClient(); |
| 206 | + const response = await client.v2.get(`users/${id}/tweets`, { |
| 207 | + max_results: params?.count ?? 20, |
| 208 | + expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id', |
| 209 | + 'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id', |
| 210 | + 'user.fields': 'username,name,profile_image_url,description', |
| 211 | + 'media.fields': 'preview_image_url,url,type,width,height,variants', |
| 212 | + ...options, |
| 213 | + }); |
| 214 | + return mapTweetResponseToLegacy(response); |
| 215 | +}; |
| 216 | + |
| 217 | +const getUserTweets = (id: string, params?: Record<string, any>) => cacheTryGet(id, params, (id, params = {}) => getUserTimeline(id, params, { exclude: 'replies' })); |
| 218 | + |
| 219 | +const getUserTweetsAndReplies = (id: string, params?: Record<string, any>) => cacheTryGet(id, params, (id, params = {}) => getUserTimeline(id, params)); |
| 220 | + |
| 221 | +const getUserMedia = (id: string, params?: Record<string, any>) => |
| 222 | + cacheTryGet(id, params, async (id, params = {}) => { |
| 223 | + const data = await getUserTimeline(id, params); |
| 224 | + return data.filter((tweet) => tweet.extended_entities?.media); |
| 225 | + }); |
| 226 | + |
| 227 | +const getUserLikes = (id: string, params?: Record<string, any>) => |
| 228 | + cacheTryGet(id, params, async (id, params = {}) => { |
| 229 | + const client = await getAppClient(); |
| 230 | + const response = await client.v2.get(`users/${id}/liked_tweets`, { |
| 231 | + max_results: params.count ?? 20, |
| 232 | + expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id', |
| 233 | + 'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id', |
| 234 | + 'user.fields': 'username,name,profile_image_url,description', |
| 235 | + 'media.fields': 'preview_image_url,url,type,width,height,variants', |
| 236 | + }); |
| 237 | + return mapTweetResponseToLegacy(response); |
| 238 | + }); |
| 239 | + |
| 240 | +const getUserTweet = (id: string, params?: Record<string, any>) => |
| 241 | + cacheTryGet(id, params, async (_id, params = {}) => { |
| 242 | + const client = await getAppClient(); |
| 243 | + const tweetId = params.focalTweetId; |
| 244 | + if (!tweetId) { |
| 245 | + throw new InvalidParameterError('Tweet ID is required'); |
| 246 | + } |
| 247 | + const response = await client.v2.get(`tweets/${tweetId}`, { |
| 248 | + expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id', |
| 249 | + 'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id', |
| 250 | + 'user.fields': 'username,name,profile_image_url,description', |
| 251 | + 'media.fields': 'preview_image_url,url,type,width,height,variants', |
| 252 | + }); |
| 253 | + return mapTweetResponseToLegacy({ data: response?.data ? [response.data] : [], includes: response?.includes }); |
| 254 | + }); |
| 255 | + |
| 256 | +const getSearch = (keywords: string, params?: Record<string, any>) => |
| 257 | + cache.tryGet( |
| 258 | + `twitter:search:${keywords}:${JSON.stringify(params)}`, |
| 259 | + async () => { |
| 260 | + const client = await getAppClient(); |
| 261 | + const response = await client.v2.get('tweets/search/recent', { |
| 262 | + query: keywords, |
| 263 | + max_results: params?.count ?? 20, |
| 264 | + expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id', |
| 265 | + 'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id', |
| 266 | + 'user.fields': 'username,name,profile_image_url,description', |
| 267 | + 'media.fields': 'preview_image_url,url,type,width,height,variants', |
| 268 | + }); |
| 269 | + return mapTweetResponseToLegacy(response); |
| 270 | + }, |
| 271 | + config.cache.routeExpire, |
| 272 | + false |
| 273 | + ); |
| 274 | + |
| 275 | +const getList = (id: string, params?: Record<string, any>) => |
| 276 | + cache.tryGet( |
| 277 | + `twitter:list:${id}:${JSON.stringify(params)}`, |
| 278 | + async () => { |
| 279 | + const client = await getAppClient(); |
| 280 | + const response = await client.v2.get(`lists/${id}/tweets`, { |
| 281 | + max_results: params?.count ?? 20, |
| 282 | + expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id', |
| 283 | + 'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id', |
| 284 | + 'user.fields': 'username,name,profile_image_url,description', |
| 285 | + 'media.fields': 'preview_image_url,url,type,width,height,variants', |
| 286 | + }); |
| 287 | + return mapTweetResponseToLegacy(response); |
| 288 | + }, |
| 289 | + config.cache.routeExpire, |
| 290 | + false |
| 291 | + ); |
| 292 | + |
| 293 | +const getHomeTimeline = (_id: string, params?: Record<string, any>) => |
| 294 | + cache.tryGet( |
| 295 | + `twitter:home:${JSON.stringify(params)}`, |
| 296 | + async () => { |
| 297 | + if (!_id) { |
| 298 | + throw new InvalidParameterError('User ID is required for the v2 home timeline'); |
| 299 | + } |
| 300 | + const client = await getAppClient(); |
| 301 | + const response = await client.v2.get(`users/${_id}/timelines/reverse_chronological`, { |
| 302 | + max_results: params?.count ?? 20, |
| 303 | + expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id', |
| 304 | + 'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id', |
| 305 | + 'user.fields': 'username,name,profile_image_url,description', |
| 306 | + 'media.fields': 'preview_image_url,url,type,width,height,variants', |
| 307 | + }); |
| 308 | + return mapTweetResponseToLegacy(response); |
| 309 | + }, |
| 310 | + config.cache.routeExpire, |
| 311 | + false |
| 312 | + ); |
| 313 | + |
| 314 | +const getHomeLatestTimeline = (id: string, params?: Record<string, any>) => getHomeTimeline(id, params); |
| 315 | + |
| 316 | +const getUser = (id: string) => getUserData(id); |
| 317 | + |
| 318 | +export default { |
| 319 | + getUser, |
| 320 | + getUserTweets, |
| 321 | + getUserTweetsAndReplies, |
| 322 | + getUserMedia, |
| 323 | + getUserLikes, |
| 324 | + getUserTweet, |
| 325 | + getSearch, |
| 326 | + getList, |
| 327 | + getHomeTimeline, |
| 328 | + getHomeLatestTimeline, |
| 329 | + init, |
| 330 | +}; |
0 commit comments