Skip to content

Commit 2f6303f

Browse files
authored
feat(route/twitter): official api support (DIYgod#21109)
* feat(route/twitter): official api support * fix: update namespace docs
1 parent 8cc0b1b commit 2f6303f

14 files changed

Lines changed: 437 additions & 129 deletions

File tree

lib/config.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,12 @@ type ConfigEnvKeys =
204204
| 'TUMBLR_CLIENT_ID'
205205
| 'TUMBLR_CLIENT_SECRET'
206206
| 'TUMBLR_REFRESH_TOKEN'
207-
| 'TWITTER_USERNAME'
208-
| 'TWITTER_PASSWORD'
209-
| 'TWITTER_AUTHENTICATION_SECRET'
210-
| 'TWITTER_PHONE_OR_EMAIL'
207+
| 'TWITTER_CONSUMER_KEY'
208+
| 'TWITTER_CONSUMER_SECRET'
209+
// | 'TWITTER_USERNAME'
210+
// | 'TWITTER_PASSWORD'
211+
// | 'TWITTER_AUTHENTICATION_SECRET'
212+
// | 'TWITTER_PHONE_OR_EMAIL'
211213
| 'TWITTER_AUTH_TOKEN'
212214
| 'TWITTER_THIRD_PARTY_API'
213215
| 'UESTC_BBS_COOKIE'
@@ -617,10 +619,12 @@ export type Config = {
617619
refreshToken?: string;
618620
};
619621
twitter: {
620-
username?: string[];
621-
password?: string[];
622-
authenticationSecret?: string[];
623-
phoneOrEmail?: string[];
622+
consumerKey?: string;
623+
consumerSecret?: string;
624+
// username?: string[];
625+
// password?: string[];
626+
// authenticationSecret?: string[];
627+
// phoneOrEmail?: string[];
624628
authToken?: string[];
625629
thirdPartyApi?: string;
626630
};
@@ -1102,10 +1106,12 @@ const calculateValue = () => {
11021106
refreshToken: envs.TUMBLR_REFRESH_TOKEN,
11031107
},
11041108
twitter: {
1105-
username: envs.TWITTER_USERNAME?.split(','),
1106-
password: envs.TWITTER_PASSWORD?.split(','),
1107-
authenticationSecret: envs.TWITTER_AUTHENTICATION_SECRET?.split(','),
1108-
phoneOrEmail: envs.TWITTER_PHONE_OR_EMAIL?.split(','),
1109+
consumerKey: envs.TWITTER_CONSUMER_KEY,
1110+
consumerSecret: envs.TWITTER_CONSUMER_SECRET,
1111+
// username: envs.TWITTER_USERNAME?.split(','),
1112+
// password: envs.TWITTER_PASSWORD?.split(','),
1113+
// authenticationSecret: envs.TWITTER_AUTHENTICATION_SECRET?.split(','),
1114+
// phoneOrEmail: envs.TWITTER_PHONE_OR_EMAIL?.split(','),
11091115
authToken: envs.TWITTER_AUTH_TOKEN?.split(','),
11101116
thirdPartyApi: envs.TWITTER_THIRD_PARTY_API,
11111117
},
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
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

Comments
 (0)