/
status.ts
338 lines (288 loc) · 12.3 KB
/
status.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
import { Constants } from './constants';
import { handleQuote } from './helpers/quote';
import { sanitizeText } from './helpers/utils';
import { Strings } from './strings';
import { getAuthorText } from './helpers/author';
import { statusAPI } from './api';
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 statuses (Tweets).
Like Twitter, we use the terminologies interchangably. */
export const handleStatus = async (
status: string,
mediaNumber?: number,
userAgent?: string,
flags?: InputFlags,
language?: string,
event?: FetchEvent
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<StatusResponse> => {
console.log('Direct?', flags?.direct);
const api = await statusAPI(status, language, event as FetchEvent);
const tweet = api?.tweet as APITweet;
/* Catch this request if it's an API response */
if (flags?.api) {
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 Tweet, we'll return it */
switch (api.code) {
case 401:
return returnError(Strings.ERROR_PRIVATE);
case 404:
return returnError(Strings.ERROR_TWEET_NOT_FOUND);
case 500:
return returnError(Strings.ERROR_API_FAIL);
}
/* Catch direct media request (d.fxtwitter.com, or .mp4 / .jpg) */
if (flags?.direct && tweet.media) {
let redirectUrl: string | null = null;
if (tweet.media.videos) {
const { videos } = tweet.media;
redirectUrl = (videos[(mediaNumber || 1) - 1] || videos[0]).url;
} else if (tweet.media.photos) {
const { photos } = tweet.media;
redirectUrl = (photos[(mediaNumber || 1) - 1] || photos[0]).url;
}
if (redirectUrl) {
return { response: Response.redirect(redirectUrl, 302) };
}
}
/* Use quote media if there is no media in this Tweet */
if (!tweet.media && tweet.quote?.media) {
tweet.media = tweet.quote.media;
tweet.twitter_card = tweet.quote.twitter_card;
}
/* At this point, we know we're going to have to create a
regular embed because it's not an API or direct media request */
let authorText = getAuthorText(tweet) || Strings.DEFAULT_AUTHOR_TEXT;
const engagementText = authorText.replace(/ {4}/g, ' ');
let siteName = Constants.BRANDING_NAME;
let newText = tweet.text;
/* Base headers included in all responses */
const headers = [
`<meta property="theme-color" content="${tweet.color}"/>`,
`<meta property="twitter:card" content="${tweet.twitter_card}"/>`,
`<meta property="twitter:site" content="@${tweet.author.screen_name}"/>`,
`<meta property="twitter:creator" content="@${tweet.author.screen_name}"/>`,
`<meta property="twitter:title" content="${tweet.author.name} (@${tweet.author.screen_name})"/>`
];
/* This little thing ensures if by some miracle a FixTweet embed is loaded in a browser,
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) {
headers.push(
`<meta http-equiv="refresh" content="0;url=https://twitter.com/${tweet.author.screen_name}/status/${tweet.id}"/>`
);
}
/* This Tweet has a translation attached to it, so we'll render it. */
if (tweet.translation) {
const { translation } = tweet;
const formatText =
language === 'en'
? Strings.TRANSLATE_TEXT.format({
language: translation.source_lang_en
})
: Strings.TRANSLATE_TEXT_INTL.format({
source: translation.source_lang.toUpperCase(),
destination: translation.target_lang.toUpperCase()
});
newText = `${formatText}\n\n` + `${translation.text}\n\n`;
}
/* This Tweet has a video to render.
Twitter supports multiple videos in a Tweet now. But we have no mechanism to embed more than one.
You can still use /video/:number to get a specific video. Otherwise, it'll pick the first. */
if (tweet.media?.videos) {
authorText = newText || '';
if (tweet?.translation) {
authorText = tweet.translation?.text || '';
}
const { videos } = tweet.media;
const video = videos[(mediaNumber || 1) - 1];
/* This fix is specific to Discord not wanting to render videos that are too large,
or rendering low quality videos too small.
Basically, our solution is to cut the dimensions in half if the video is too big (> 1080p),
or double them if it's too small. (<400p)
We check both height and width so we can apply this to both horizontal and vertical videos equally*/
let sizeMultiplier = 1;
if (video.width > 1920 || video.height > 1920) {
sizeMultiplier = 0.5;
}
if (video.width < 400 && video.height < 400) {
sizeMultiplier = 2;
}
/* Like photos when picking a specific one (not using mosaic),
we'll put an indicator if there are more than one video */
if (videos.length > 1) {
const videoCounter = Strings.VIDEO_COUNT.format({
number: String(videos.indexOf(video) + 1),
total: String(videos.length)
});
authorText =
authorText === Strings.DEFAULT_AUTHOR_TEXT
? videoCounter
: `${authorText}${authorText ? ' ― ' : ''}${videoCounter}`;
siteName = `${Constants.BRANDING_NAME} - ${videoCounter}`;
if (engagementText) {
siteName = `${Constants.BRANDING_NAME} - ${engagementText} - ${videoCounter}`;
}
}
/* Push the raw video-related headers */
headers.push(
`<meta property="twitter:player:stream:content_type" content="${video.format}"/>`,
`<meta property="twitter:player:height" content="${video.height * sizeMultiplier}"/>`,
`<meta property="twitter:player:width" content="${video.width * sizeMultiplier}"/>`,
`<meta property="og:video" content="${video.url}"/>`,
`<meta property="og:video:secure_url" content="${video.url}"/>`,
`<meta property="og:video:height" content="${video.height * sizeMultiplier}"/>`,
`<meta property="og:video:width" content="${video.width * sizeMultiplier}"/>`,
`<meta property="og:video:type" content="${video.format}"/>`,
`<meta property="twitter:image" content="0"/>`
);
}
/* This Tweet has one or more photos to render */
if (tweet.media?.photos) {
const { photos } = tweet.media;
let photo: APIPhoto | APIMosaicPhoto = photos[(mediaNumber || 1) - 1];
/* If there isn't a specified media number and we have a
mosaic response, we'll render it using mosaic */
if (typeof mediaNumber !== 'number' && tweet.media.mosaic) {
photo = {
/* Include dummy height/width for TypeScript reasons. We have a check to make sure we don't use these later. */
height: 0,
width: 0,
url: tweet.media.mosaic.formats.jpeg,
type: 'photo'
};
/* If mosaic isn't available or the link calls for a specific photo,
we'll indicate which photo it is out of the total */
} else if (photos.length > 1) {
const photoCounter = Strings.PHOTO_COUNT.format({
number: String(photos.indexOf(photo) + 1),
total: String(photos.length)
});
authorText =
authorText === Strings.DEFAULT_AUTHOR_TEXT
? photoCounter
: `${authorText}${authorText ? ' ― ' : ''}${photoCounter}`;
siteName = `${Constants.BRANDING_NAME} - ${photoCounter}`;
if (engagementText) {
siteName = `${Constants.BRANDING_NAME} - ${engagementText} - ${photoCounter}`;
}
}
/* Push the raw photo-related headers */
headers.push(
`<meta property="twitter:image" content="${photo.url}"/>`,
`<meta property="og:image" content="${photo.url}"/>`
);
if (!tweet.media.mosaic) {
headers.push(
`<meta property="twitter:image:width" content="${photo.width}"/>`,
`<meta property="twitter:image:height" content="${photo.height}"/>`,
`<meta property="og:image:width" content="${photo.width}"/>`,
`<meta property="og:image:height" content="${photo.height}"/>`
);
}
}
/* We have external media available to us (i.e. YouTube videos) */
if (tweet.media?.external) {
const { external } = tweet.media;
authorText = newText || '';
headers.push(
`<meta property="twitter:player" content="${external.url}">`,
`<meta property="twitter:player:width" content="${external.width}">`,
`<meta property="twitter:player:height" content="${external.height}">`,
`<meta property="og:type" content="video.other">`,
`<meta property="og:video:url" content="${external.url}">`,
`<meta property="og:video:secure_url" content="${external.url}">`,
`<meta property="og:video:width" content="${external.width}">`,
`<meta property="og:video:height" content="${external.height}">`
);
}
/* This Tweet contains a poll, so we'll render it */
if (tweet.poll) {
const { poll } = tweet;
let barLength = 36;
let str = '';
/* Telegram Embeds are smaller, so we use a smaller bar to compensate */
if (userAgent?.indexOf('Telegram') !== -1) {
barLength = 24;
}
/* Render each poll choice */
tweet.poll.choices.forEach(choice => {
const bar = '█'.repeat((choice.percentage / 100) * barLength);
// eslint-disable-next-line no-irregular-whitespace
str += `${bar}\n${choice.label} (${choice.percentage}%)\n`;
});
/* Finally, add the footer of the poll with # of votes and time left */
str += `\n${poll.total_votes} votes · ${poll.time_left_en}`;
/* And now we'll put the poll right after the Tweet text! */
newText += `\n\n${str}`;
}
/* This Tweet quotes another Tweet, so we'll render the other Tweet where possible */
if (api.tweet?.quote) {
const quoteText = handleQuote(api.tweet.quote);
newText += `\n${quoteText}`;
}
/* If we have no media to display, instead we'll display the user profile picture in the embed */
if (!tweet.media?.video && !tweet.media?.photos) {
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"/>`
);
}
/* Notice that user is using deprecated domain */
if (flags?.deprecated) {
siteName = Strings.DEPRECATED_DOMAIN_NOTICE;
}
/* 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)}"/>`,
`<meta property="og:site_name" content="${siteName}"/>`
);
/* Special reply handling if authorText is not overriden */
if (tweet.replying_to && authorText === Strings.DEFAULT_AUTHOR_TEXT) {
authorText = `↪ Replying to @${tweet.replying_to}`;
/* We'll assume it's a thread if it's a reply to themselves */
} else if (tweet.replying_to === tweet.author.screen_name) {
authorText = `↪ A part of @${tweet.author.screen_name}'s thread`;
}
/* The additional oembed is pulled by Discord to enable improved embeds.
Telegram does not use this. */
headers.push(
`<link rel="alternate" href="${Constants.HOST_URL}/owoembed?text=${encodeURIComponent(
authorText.substring(0, 200)
)}${flags?.deprecated ? '&deprecated=true' : ''}&status=${encodeURIComponent(
status
)}&author=${encodeURIComponent(
tweet.author?.screen_name || ''
)}" type="application/json+oembed" title="${tweet.author.name}">`
);
/* When dealing with a Tweet of unknown lang, fall back to en */
const lang = tweet.lang === null ? 'en' : tweet.lang || 'en';
/* Finally, after all that work we return the response HTML! */
return {
text: Strings.BASE_HTML.format({
lang: `lang="${lang}"`,
headers: headers.join('')
})
};
};