@meltwater/conversations-api-services 1.1.24 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import superagent from 'superagent';
|
|
2
2
|
import { removePrefix } from '../../lib/externalId.helpers.js';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
loggerDebug,
|
|
5
|
+
loggerError,
|
|
6
|
+
loggerInfo,
|
|
7
|
+
loggerWarn,
|
|
8
|
+
} from '../../lib/logger.helpers.js';
|
|
4
9
|
import OAuth from 'oauth-1.0a';
|
|
5
10
|
import crypto from 'crypto';
|
|
6
11
|
import axios from 'axios';
|
|
7
12
|
import fs from 'fs';
|
|
8
|
-
import Twit from '@meltwater/social-twit';
|
|
9
13
|
|
|
10
14
|
const TWITTER_API_CONFIG = {
|
|
11
15
|
v1_1: {
|
|
@@ -13,74 +17,104 @@ const TWITTER_API_CONFIG = {
|
|
|
13
17
|
},
|
|
14
18
|
v2: {
|
|
15
19
|
base_url: 'https://api.x.com/2',
|
|
16
|
-
default_user_fields:
|
|
17
|
-
|
|
20
|
+
default_user_fields:
|
|
21
|
+
'id,name,username,profile_image_url,public_metrics,description,created_at,verified,protected',
|
|
22
|
+
},
|
|
18
23
|
};
|
|
19
24
|
|
|
20
25
|
// Helper function to create v2 API URLs with consistent field selection
|
|
21
26
|
function createTwitterV2Url(endpoint, additionalParams = {}) {
|
|
22
27
|
const baseUrl = `${TWITTER_API_CONFIG.v2.base_url}/${endpoint}`;
|
|
23
|
-
const defaultFields = {
|
|
28
|
+
const defaultFields = {
|
|
29
|
+
'user.fields': TWITTER_API_CONFIG.v2.default_user_fields,
|
|
30
|
+
};
|
|
24
31
|
const allParams = { ...defaultFields, ...additionalParams };
|
|
25
|
-
|
|
32
|
+
|
|
26
33
|
const queryString = Object.entries(allParams)
|
|
27
|
-
.map(
|
|
34
|
+
.map(
|
|
35
|
+
([key, value]) =>
|
|
36
|
+
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`
|
|
37
|
+
)
|
|
28
38
|
.join('&');
|
|
29
|
-
|
|
39
|
+
|
|
30
40
|
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
// Helper function to make v2 API requests with fallback to v1.1
|
|
34
|
-
async function makeTwitterV2Request(
|
|
44
|
+
async function makeTwitterV2Request(
|
|
45
|
+
v2Endpoint,
|
|
46
|
+
v1FallbackQuery,
|
|
47
|
+
token,
|
|
48
|
+
logger,
|
|
49
|
+
requestOptions = {}
|
|
50
|
+
) {
|
|
35
51
|
// Try v2 API first - it supports OAuth 1.0a for user lookup endpoints and has better rate limits!
|
|
36
52
|
try {
|
|
37
53
|
const v2Url = createTwitterV2Url(v2Endpoint, requestOptions.v2Params);
|
|
38
|
-
|
|
54
|
+
|
|
39
55
|
const result = await getRequest({
|
|
40
56
|
token,
|
|
41
57
|
uri: v2Url,
|
|
42
58
|
attachUrlPrefix: false,
|
|
43
59
|
convertPayloadToUri: false,
|
|
44
|
-
logger
|
|
60
|
+
logger,
|
|
45
61
|
});
|
|
46
62
|
return { success: true, data: result.data?.data, source: 'v2' };
|
|
47
63
|
} catch (e) {
|
|
48
|
-
loggerWarn(
|
|
49
|
-
|
|
64
|
+
loggerWarn(
|
|
65
|
+
logger,
|
|
66
|
+
`Twitter API v2 request failed for ${v2Endpoint}, falling back to v1.1`,
|
|
67
|
+
e
|
|
68
|
+
);
|
|
69
|
+
|
|
50
70
|
// Fallback to v1.1 if v2 fails
|
|
51
71
|
if (v1FallbackQuery && requestOptions.enableFallback !== false) {
|
|
52
72
|
try {
|
|
53
|
-
loggerInfo(
|
|
54
|
-
|
|
73
|
+
loggerInfo(
|
|
74
|
+
logger,
|
|
75
|
+
`Falling back to v1.1 API for ${v2Endpoint}`
|
|
76
|
+
);
|
|
77
|
+
const fallbackResult = await (requestOptions.fallbackMethod ===
|
|
78
|
+
'post'
|
|
79
|
+
? postRequest
|
|
80
|
+
: getRequest)({
|
|
55
81
|
token,
|
|
56
82
|
uri: v1FallbackQuery,
|
|
57
|
-
logger
|
|
83
|
+
logger,
|
|
58
84
|
});
|
|
59
|
-
return {
|
|
85
|
+
return {
|
|
86
|
+
success: true,
|
|
87
|
+
data: fallbackResult.data,
|
|
88
|
+
source: 'v1.1',
|
|
89
|
+
};
|
|
60
90
|
} catch (fallbackError) {
|
|
61
|
-
loggerError(
|
|
91
|
+
loggerError(
|
|
92
|
+
logger,
|
|
93
|
+
`Both v2 and v1.1 APIs failed for ${v2Endpoint}`,
|
|
94
|
+
fallbackError
|
|
95
|
+
);
|
|
62
96
|
}
|
|
63
97
|
}
|
|
64
|
-
|
|
98
|
+
|
|
65
99
|
return { success: false, error: e, source: 'failed' };
|
|
66
100
|
}
|
|
67
101
|
}
|
|
68
102
|
|
|
69
103
|
function normalizeUserData(user, apiVersion = 'v1.1') {
|
|
70
104
|
if (!user) return user;
|
|
71
|
-
|
|
105
|
+
|
|
72
106
|
const normalized = { ...user };
|
|
73
|
-
|
|
107
|
+
|
|
74
108
|
if (apiVersion === 'v2') {
|
|
75
109
|
normalized.screen_name = user.username;
|
|
76
|
-
normalized.id_str = user.id;
|
|
77
|
-
normalized.profile_image_url_https = user.profile_image_url;
|
|
110
|
+
normalized.id_str = user.id;
|
|
111
|
+
normalized.profile_image_url_https = user.profile_image_url;
|
|
78
112
|
} else {
|
|
79
113
|
normalized.username = user.screen_name;
|
|
80
114
|
normalized.id = user.id_str;
|
|
81
115
|
normalized.profile_image_url = user.profile_image_url_https;
|
|
82
116
|
}
|
|
83
|
-
|
|
117
|
+
|
|
84
118
|
return normalized;
|
|
85
119
|
}
|
|
86
120
|
|
|
@@ -92,18 +126,42 @@ function normalizeUserData(user, apiVersion = 'v1.1') {
|
|
|
92
126
|
*/
|
|
93
127
|
function normalizeUsersData(users, apiVersion = 'v1.1') {
|
|
94
128
|
if (!users) return users;
|
|
95
|
-
|
|
129
|
+
|
|
96
130
|
if (Array.isArray(users)) {
|
|
97
|
-
return users.map(user => normalizeUserData(user, apiVersion));
|
|
131
|
+
return users.map((user) => normalizeUserData(user, apiVersion));
|
|
98
132
|
}
|
|
99
|
-
|
|
133
|
+
|
|
100
134
|
return normalizeUserData(users, apiVersion);
|
|
101
135
|
}
|
|
102
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Get authenticated user information using Twitter API v2
|
|
139
|
+
* @param {Object} token - OAuth token object
|
|
140
|
+
* @param {Object} logger - Logger instance
|
|
141
|
+
* @returns {Promise<Object>} User data from v2 API
|
|
142
|
+
*/
|
|
143
|
+
export async function getAuthenticatedUser(token, logger) {
|
|
144
|
+
try {
|
|
145
|
+
const result = await getRequest({
|
|
146
|
+
token,
|
|
147
|
+
uri: 'https://api.x.com/2/users/me',
|
|
148
|
+
attachUrlPrefix: false,
|
|
149
|
+
convertPayloadToUri: false,
|
|
150
|
+
logger,
|
|
151
|
+
});
|
|
152
|
+
return result.data;
|
|
153
|
+
} catch (e) {
|
|
154
|
+
loggerError(logger, `Error getting authenticated user info`, e);
|
|
155
|
+
throw e;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
103
158
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
159
|
+
export function addOAuthToToken(
|
|
160
|
+
token,
|
|
161
|
+
TWITTER_CONSUMER_KEY,
|
|
162
|
+
TWITTER_CONSUMER_SECRET
|
|
163
|
+
) {
|
|
164
|
+
if (!token.oauth) {
|
|
107
165
|
token.oauth = OAuth({
|
|
108
166
|
consumer: {
|
|
109
167
|
key: TWITTER_CONSUMER_KEY,
|
|
@@ -120,17 +178,20 @@ export function addOAuthToToken(token, TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SE
|
|
|
120
178
|
token.consumer_key = TWITTER_CONSUMER_KEY;
|
|
121
179
|
token.consumer_secret = TWITTER_CONSUMER_SECRET;
|
|
122
180
|
}
|
|
123
|
-
return token
|
|
181
|
+
return token;
|
|
124
182
|
}
|
|
125
183
|
|
|
126
184
|
export async function getUserInfoFromHandles(token, handles, logger) {
|
|
127
185
|
try {
|
|
128
186
|
const handlesJoin = handles.join(',');
|
|
129
|
-
if(!handlesJoin.length){
|
|
130
|
-
loggerWarn(
|
|
187
|
+
if (!handlesJoin.length) {
|
|
188
|
+
loggerWarn(
|
|
189
|
+
logger,
|
|
190
|
+
`No handles provided to twitterNative getUserInfoFromHandles`
|
|
191
|
+
);
|
|
131
192
|
return [];
|
|
132
193
|
}
|
|
133
|
-
|
|
194
|
+
|
|
134
195
|
// Use Twitter API v2 for much better rate limits (900 req/15min vs v1.1 limits)
|
|
135
196
|
// v2 API supports up to 100 usernames per request vs v1.1's limit
|
|
136
197
|
const result = await makeTwitterV2Request(
|
|
@@ -140,10 +201,10 @@ export async function getUserInfoFromHandles(token, handles, logger) {
|
|
|
140
201
|
logger,
|
|
141
202
|
{
|
|
142
203
|
v2Params: { usernames: handlesJoin },
|
|
143
|
-
fallbackMethod: 'post'
|
|
204
|
+
fallbackMethod: 'post',
|
|
144
205
|
}
|
|
145
206
|
);
|
|
146
|
-
|
|
207
|
+
|
|
147
208
|
const normalizedData = normalizeUsersData(result.data, result.source);
|
|
148
209
|
return normalizedData || [];
|
|
149
210
|
} catch (e) {
|
|
@@ -155,7 +216,12 @@ export async function getUserInfoFromHandles(token, handles, logger) {
|
|
|
155
216
|
export async function getUserInfoFromHandle(token, handleId, logger) {
|
|
156
217
|
try {
|
|
157
218
|
if (!handleId) {
|
|
158
|
-
loggerWarn(
|
|
219
|
+
loggerWarn(
|
|
220
|
+
logger,
|
|
221
|
+
`Invalid handleId provided to getUserInfoFromHandle: ${JSON.stringify(
|
|
222
|
+
handleId
|
|
223
|
+
)}`
|
|
224
|
+
);
|
|
159
225
|
throw new Error(`Invalid handleId: ${handleId}`);
|
|
160
226
|
}
|
|
161
227
|
// Use Twitter API v2 for much better rate limits (900 req/15min vs v1.1 limits)
|
|
@@ -165,15 +231,24 @@ export async function getUserInfoFromHandle(token, handleId, logger) {
|
|
|
165
231
|
token,
|
|
166
232
|
logger
|
|
167
233
|
);
|
|
168
|
-
|
|
234
|
+
|
|
169
235
|
if (result.success) {
|
|
170
236
|
// Normalize the user data to ensure both screen_name and username are present
|
|
171
237
|
return normalizeUserData(result.data, result.source);
|
|
172
238
|
} else {
|
|
173
|
-
throw
|
|
239
|
+
throw (
|
|
240
|
+
result.error ||
|
|
241
|
+
new Error('Failed to get user info from both APIs')
|
|
242
|
+
);
|
|
174
243
|
}
|
|
175
244
|
} catch (e) {
|
|
176
|
-
loggerError(
|
|
245
|
+
loggerError(
|
|
246
|
+
logger,
|
|
247
|
+
`Error in getUserInfoFromHandle with handleId: ${JSON.stringify(
|
|
248
|
+
handleId
|
|
249
|
+
)}`,
|
|
250
|
+
e
|
|
251
|
+
);
|
|
177
252
|
}
|
|
178
253
|
}
|
|
179
254
|
|
|
@@ -190,7 +265,7 @@ export async function getDirectMessageImage(token, imageUrl, logger) {
|
|
|
190
265
|
contentType: result.contentType,
|
|
191
266
|
};
|
|
192
267
|
} catch (e) {
|
|
193
|
-
loggerError(logger
|
|
268
|
+
loggerError(logger, `Error getting image`, e);
|
|
194
269
|
throw e;
|
|
195
270
|
}
|
|
196
271
|
}
|
|
@@ -209,11 +284,11 @@ export async function getCurrentInfo(token, externalId, logger) {
|
|
|
209
284
|
});
|
|
210
285
|
|
|
211
286
|
// this makes it so 'retweeted' is only true on a retweet that isn't a comment
|
|
212
|
-
// sometimes 'retweeted' is true if user retweeted with comment, sometimes not
|
|
287
|
+
// sometimes 'retweeted' is true if user retweeted with comment, sometimes not
|
|
213
288
|
result.data.retweeted = !!result.data.current_user_retweet;
|
|
214
289
|
return result.data.data[0].public_metrics;
|
|
215
290
|
} catch (error) {
|
|
216
|
-
loggerDebug(logger,'Error in twitter getCurrentInfo', error);
|
|
291
|
+
loggerDebug(logger, 'Error in twitter getCurrentInfo', error);
|
|
217
292
|
}
|
|
218
293
|
}
|
|
219
294
|
|
|
@@ -231,7 +306,7 @@ export async function getMentionHandleInfo(token, externalId, logger) {
|
|
|
231
306
|
});
|
|
232
307
|
return result.data;
|
|
233
308
|
} catch (error) {
|
|
234
|
-
loggerDebug(logger,'Error in twitter getMentionHandleInfo', error);
|
|
309
|
+
loggerDebug(logger, 'Error in twitter getMentionHandleInfo', error);
|
|
235
310
|
}
|
|
236
311
|
}
|
|
237
312
|
|
|
@@ -245,7 +320,7 @@ export async function retweet(token, sourceId, externalId, logger) {
|
|
|
245
320
|
payload: { tweet_id: removePrefix(externalId) },
|
|
246
321
|
attachUrlPrefix: false,
|
|
247
322
|
convertPayloadToUri: false,
|
|
248
|
-
logger
|
|
323
|
+
logger,
|
|
249
324
|
});
|
|
250
325
|
|
|
251
326
|
if (
|
|
@@ -257,7 +332,7 @@ export async function retweet(token, sourceId, externalId, logger) {
|
|
|
257
332
|
return false;
|
|
258
333
|
}
|
|
259
334
|
} catch (error) {
|
|
260
|
-
loggerDebug(logger
|
|
335
|
+
loggerDebug(logger, `Error in retweet user: ${sourceId}`, error);
|
|
261
336
|
}
|
|
262
337
|
}
|
|
263
338
|
|
|
@@ -268,7 +343,7 @@ export async function unRetweet(token, sourceId, externalId, logger) {
|
|
|
268
343
|
uri: `https://api.twitter.com/2/users/${sourceId}/retweets/${removePrefix(
|
|
269
344
|
externalId
|
|
270
345
|
)}`,
|
|
271
|
-
logger
|
|
346
|
+
logger,
|
|
272
347
|
});
|
|
273
348
|
|
|
274
349
|
if (
|
|
@@ -280,47 +355,68 @@ export async function unRetweet(token, sourceId, externalId, logger) {
|
|
|
280
355
|
return false;
|
|
281
356
|
}
|
|
282
357
|
} catch (error) {
|
|
283
|
-
loggerDebug(logger
|
|
358
|
+
loggerDebug(logger, `Error in unretweet user: ${sourceId}`, error, {
|
|
284
359
|
[MeltwaterAttributes.SOCIALEXTERNALID]: externalId,
|
|
285
360
|
});
|
|
286
361
|
}
|
|
287
362
|
}
|
|
288
363
|
|
|
289
|
-
export async function like(token,externalId,logger) {
|
|
364
|
+
export async function like(token, externalId, logger) {
|
|
290
365
|
try {
|
|
366
|
+
// Get the authenticated user's ID first
|
|
367
|
+
const userInfo = await getAuthenticatedUser(token, logger);
|
|
368
|
+
const userId = userInfo.data.id;
|
|
369
|
+
|
|
291
370
|
let response = await postRequest({
|
|
292
371
|
token,
|
|
293
|
-
uri:
|
|
372
|
+
uri: `https://api.x.com/2/users/${userId}/likes`,
|
|
373
|
+
payload: {
|
|
374
|
+
tweet_id: removePrefix(externalId),
|
|
375
|
+
},
|
|
376
|
+
attachUrlPrefix: false,
|
|
377
|
+
convertPayloadToUri: false,
|
|
294
378
|
logger,
|
|
295
379
|
});
|
|
296
380
|
|
|
297
381
|
if (
|
|
298
|
-
response.data.
|
|
382
|
+
response.data.data?.liked !== undefined &&
|
|
299
383
|
response.resp.statusCode === 200
|
|
300
384
|
) {
|
|
301
385
|
return true;
|
|
302
386
|
} else {
|
|
303
|
-
loggerInfo(
|
|
304
|
-
|
|
305
|
-
|
|
387
|
+
loggerInfo(
|
|
388
|
+
logger,
|
|
389
|
+
'Twitter Error in like user statusCode non 200 ',
|
|
390
|
+
{
|
|
391
|
+
response: JSON.stringify(response),
|
|
392
|
+
}
|
|
393
|
+
);
|
|
306
394
|
return false;
|
|
307
395
|
}
|
|
308
396
|
} catch (error) {
|
|
309
|
-
loggerDebug(logger
|
|
397
|
+
loggerDebug(logger, `Twitter Error in like user: ${externalId}`, error);
|
|
310
398
|
throw error;
|
|
311
399
|
}
|
|
312
400
|
}
|
|
313
401
|
|
|
314
402
|
export async function unLike(token, externalId, logger) {
|
|
315
403
|
try {
|
|
316
|
-
|
|
404
|
+
// Get the authenticated user's ID first
|
|
405
|
+
const userInfo = await getAuthenticatedUser(token, logger);
|
|
406
|
+
const userId = userInfo.data.id;
|
|
407
|
+
|
|
408
|
+
let response = await deleteRequest({
|
|
317
409
|
token,
|
|
318
|
-
uri:
|
|
410
|
+
uri: `https://api.x.com/2/users/${userId}/likes/${removePrefix(
|
|
411
|
+
externalId
|
|
412
|
+
)}`,
|
|
413
|
+
attachUrlPrefix: false,
|
|
414
|
+
convertPayloadToUri: false,
|
|
319
415
|
logger,
|
|
320
416
|
});
|
|
321
417
|
|
|
322
418
|
if (
|
|
323
|
-
response.data.
|
|
419
|
+
response.data.data?.liked !== undefined &&
|
|
324
420
|
response.resp.statusCode === 200
|
|
325
421
|
) {
|
|
326
422
|
return true;
|
|
@@ -328,84 +424,102 @@ export async function unLike(token, externalId, logger) {
|
|
|
328
424
|
return false;
|
|
329
425
|
}
|
|
330
426
|
} catch (error) {
|
|
331
|
-
loggerDebug(logger
|
|
427
|
+
loggerDebug(logger, `Error in unlike user: ${externalId}`, error);
|
|
332
428
|
throw error;
|
|
333
429
|
}
|
|
334
430
|
}
|
|
335
431
|
|
|
336
432
|
export async function followUser(token, profileId, logger) {
|
|
337
433
|
try {
|
|
434
|
+
// Get the authenticated user's ID first
|
|
435
|
+
const userInfo = await getAuthenticatedUser(token, logger);
|
|
436
|
+
const userId = userInfo.data.id;
|
|
437
|
+
|
|
338
438
|
let response = await postRequest({
|
|
339
439
|
token,
|
|
340
|
-
uri:
|
|
440
|
+
uri: `https://api.x.com/2/users/${userId}/following`,
|
|
341
441
|
payload: {
|
|
342
|
-
|
|
343
|
-
follow: true,
|
|
442
|
+
target_user_id: removePrefix(profileId),
|
|
344
443
|
},
|
|
345
|
-
|
|
444
|
+
attachUrlPrefix: false,
|
|
445
|
+
convertPayloadToUri: false,
|
|
446
|
+
logger,
|
|
346
447
|
});
|
|
347
448
|
|
|
348
|
-
if (response.data.following !== undefined) {
|
|
449
|
+
if (response.data.data?.following !== undefined) {
|
|
349
450
|
return true;
|
|
350
451
|
} else {
|
|
351
452
|
return false;
|
|
352
453
|
}
|
|
353
454
|
} catch (error) {
|
|
354
|
-
loggerDebug(logger
|
|
455
|
+
loggerDebug(logger, `Error in following user: ${profileId}`, error);
|
|
355
456
|
}
|
|
356
457
|
}
|
|
357
458
|
|
|
358
459
|
export async function unFollowUser(token, profileId, logger) {
|
|
359
460
|
try {
|
|
360
|
-
|
|
461
|
+
// Get the authenticated user's ID first
|
|
462
|
+
const userInfo = await getAuthenticatedUser(token, logger);
|
|
463
|
+
const userId = userInfo.data.id;
|
|
464
|
+
|
|
465
|
+
let response = await deleteRequest({
|
|
361
466
|
token,
|
|
362
|
-
uri:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
467
|
+
uri: `https://api.x.com/2/users/${userId}/following/${removePrefix(
|
|
468
|
+
profileId
|
|
469
|
+
)}`,
|
|
470
|
+
attachUrlPrefix: false,
|
|
471
|
+
convertPayloadToUri: false,
|
|
472
|
+
logger,
|
|
367
473
|
});
|
|
368
474
|
|
|
369
|
-
if (response.data.following !== undefined) {
|
|
475
|
+
if (response.data.data?.following !== undefined) {
|
|
370
476
|
return true;
|
|
371
477
|
} else {
|
|
372
478
|
return false;
|
|
373
479
|
}
|
|
374
480
|
} catch (error) {
|
|
375
|
-
loggerDebug(logger
|
|
481
|
+
loggerDebug(logger, `Error in unfollowing user: ${profileId}`, error);
|
|
376
482
|
}
|
|
377
483
|
}
|
|
378
484
|
|
|
379
485
|
export async function userFollowStatus(token, profileId, logger) {
|
|
380
486
|
try {
|
|
487
|
+
// Get the authenticated user's ID first
|
|
488
|
+
const userInfo = await getAuthenticatedUser(token, logger);
|
|
489
|
+
const userId = userInfo.data.id;
|
|
490
|
+
|
|
491
|
+
// Check if authenticated user is following the target user
|
|
492
|
+
// We need to check the following list with pagination support
|
|
381
493
|
let response = await getRequest({
|
|
382
494
|
token,
|
|
383
|
-
uri:
|
|
495
|
+
uri: `https://api.x.com/2/users/${userId}/following`,
|
|
384
496
|
payload: {
|
|
385
|
-
|
|
497
|
+
max_results: 1000, // Max allowed per request
|
|
386
498
|
},
|
|
387
|
-
|
|
499
|
+
attachUrlPrefix: false,
|
|
500
|
+
convertPayloadToUri: true,
|
|
501
|
+
logger,
|
|
388
502
|
});
|
|
389
503
|
|
|
390
|
-
if (response.data
|
|
391
|
-
if
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
i++
|
|
396
|
-
) {
|
|
397
|
-
if (response.data[0].connections[i] === 'following') {
|
|
398
|
-
return true;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
504
|
+
if (response.data?.data) {
|
|
505
|
+
// Check if the profileId is in the following list
|
|
506
|
+
const isFollowing = response.data.data.some(
|
|
507
|
+
(user) => user.id === removePrefix(profileId)
|
|
508
|
+
);
|
|
401
509
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
return false;
|
|
510
|
+
if (isFollowing) {
|
|
511
|
+
return true;
|
|
405
512
|
}
|
|
513
|
+
|
|
514
|
+
// If not found and there's a next page, we might need to paginate
|
|
515
|
+
// For now, return false if not in first page
|
|
516
|
+
return false;
|
|
406
517
|
}
|
|
518
|
+
|
|
519
|
+
return false;
|
|
407
520
|
} catch (error) {
|
|
408
|
-
loggerDebug(
|
|
521
|
+
loggerDebug(
|
|
522
|
+
logger,
|
|
409
523
|
`Error in getting user follow status: ${profileId}`,
|
|
410
524
|
error
|
|
411
525
|
);
|
|
@@ -419,27 +533,90 @@ export async function userFollowStatus(token, profileId, logger) {
|
|
|
419
533
|
}
|
|
420
534
|
}
|
|
421
535
|
|
|
422
|
-
export async function getBrandUserRelationship(
|
|
536
|
+
export async function getBrandUserRelationship(
|
|
537
|
+
token,
|
|
538
|
+
profileId,
|
|
539
|
+
brandProfileId,
|
|
540
|
+
logger
|
|
541
|
+
) {
|
|
423
542
|
try {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
543
|
+
// In v2 API, we need to make separate calls to check following/followers status
|
|
544
|
+
// Check if brand follows user
|
|
545
|
+
let brandFollowsUser = false;
|
|
546
|
+
let userFollowsBrand = false;
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
const followingResponse = await getRequest({
|
|
550
|
+
token,
|
|
551
|
+
uri: `https://api.x.com/2/users/${brandProfileId}/following`,
|
|
552
|
+
payload: {
|
|
553
|
+
max_results: 1000,
|
|
554
|
+
},
|
|
555
|
+
attachUrlPrefix: false,
|
|
556
|
+
convertPayloadToUri: true,
|
|
557
|
+
logger,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
if (followingResponse.data?.data) {
|
|
561
|
+
brandFollowsUser = followingResponse.data.data.some(
|
|
562
|
+
(user) => user.id === removePrefix(profileId)
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
} catch (e) {
|
|
566
|
+
loggerWarn(logger, 'Error checking if brand follows user', e);
|
|
567
|
+
}
|
|
431
568
|
|
|
432
|
-
|
|
569
|
+
try {
|
|
570
|
+
const followersResponse = await getRequest({
|
|
571
|
+
token,
|
|
572
|
+
uri: `https://api.x.com/2/users/${brandProfileId}/followers`,
|
|
573
|
+
payload: {
|
|
574
|
+
max_results: 1000,
|
|
575
|
+
},
|
|
576
|
+
attachUrlPrefix: false,
|
|
577
|
+
convertPayloadToUri: true,
|
|
578
|
+
logger,
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
if (followersResponse.data?.data) {
|
|
582
|
+
userFollowsBrand = followersResponse.data.data.some(
|
|
583
|
+
(user) => user.id === removePrefix(profileId)
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
} catch (e) {
|
|
587
|
+
loggerWarn(logger, 'Error checking if user follows brand', e);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Build a relationship object similar to v1.1 format
|
|
591
|
+
return {
|
|
592
|
+
source: {
|
|
593
|
+
id_str: brandProfileId,
|
|
594
|
+
following: brandFollowsUser,
|
|
595
|
+
followed_by: userFollowsBrand,
|
|
596
|
+
},
|
|
597
|
+
target: {
|
|
598
|
+
id_str: removePrefix(profileId),
|
|
599
|
+
following: userFollowsBrand,
|
|
600
|
+
followed_by: brandFollowsUser,
|
|
601
|
+
},
|
|
602
|
+
};
|
|
433
603
|
} catch (error) {
|
|
434
|
-
loggerDebug(
|
|
604
|
+
loggerDebug(
|
|
605
|
+
logger,
|
|
435
606
|
`Error in getting user brand friendship info: ${profileId} and ${brandProfileId}`,
|
|
436
607
|
error
|
|
437
608
|
);
|
|
438
609
|
}
|
|
439
610
|
}
|
|
440
611
|
|
|
441
|
-
export async function reply(
|
|
442
|
-
|
|
612
|
+
export async function reply(
|
|
613
|
+
token,
|
|
614
|
+
text,
|
|
615
|
+
attachment,
|
|
616
|
+
inReplyToId,
|
|
617
|
+
removeInReplyToId,
|
|
618
|
+
logger
|
|
619
|
+
) {
|
|
443
620
|
let mediaId;
|
|
444
621
|
if (attachment) {
|
|
445
622
|
// discussionType only matters if DM or Not
|
|
@@ -456,50 +633,48 @@ export async function reply(token, text, attachment, inReplyToId, removeInReplyT
|
|
|
456
633
|
in_reply_to_tweet_id: removePrefix(inReplyToId),
|
|
457
634
|
},
|
|
458
635
|
};
|
|
459
|
-
if (removeInReplyToId.length){
|
|
636
|
+
if (removeInReplyToId.length) {
|
|
460
637
|
const filteredIds = removeInReplyToId
|
|
461
638
|
.map(removePrefix)
|
|
462
|
-
.filter(id => id && id.trim().length > 0);
|
|
639
|
+
.filter((id) => id && id.trim().length > 0);
|
|
463
640
|
if (filteredIds.length > 0) {
|
|
464
641
|
payload.reply.exclude_reply_user_ids = filteredIds;
|
|
465
642
|
}
|
|
466
643
|
}
|
|
467
644
|
|
|
468
645
|
let query = 'https://api.twitter.com/2/tweets';
|
|
469
|
-
return publishTweet(token,payload,query, false,logger);
|
|
646
|
+
return publishTweet(token, payload, query, false, logger);
|
|
470
647
|
}
|
|
471
648
|
|
|
472
|
-
export async function privateMessage(
|
|
473
|
-
|
|
649
|
+
export async function privateMessage(
|
|
650
|
+
token,
|
|
651
|
+
text,
|
|
652
|
+
attachment,
|
|
653
|
+
profileId,
|
|
654
|
+
logger
|
|
655
|
+
) {
|
|
656
|
+
let query = `https://api.x.com/2/dm_conversations/with/${removePrefix(
|
|
657
|
+
profileId
|
|
658
|
+
)}/messages`;
|
|
474
659
|
let mediaId;
|
|
475
660
|
if (attachment) {
|
|
476
661
|
// discussionType only matters if DM or Not
|
|
477
662
|
mediaId = await uploadMedia(attachment, token, 'dm', logger);
|
|
478
663
|
}
|
|
479
664
|
const payload = {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
},
|
|
486
|
-
message_data: {
|
|
487
|
-
text: text || ' ',
|
|
488
|
-
...(attachment && {
|
|
489
|
-
attachment: {
|
|
490
|
-
type: 'media',
|
|
491
|
-
media: { id: mediaId },
|
|
492
|
-
},
|
|
493
|
-
}),
|
|
665
|
+
text: text || ' ',
|
|
666
|
+
...(attachment && {
|
|
667
|
+
attachments: [
|
|
668
|
+
{
|
|
669
|
+
media_id: mediaId,
|
|
494
670
|
},
|
|
495
|
-
|
|
496
|
-
},
|
|
671
|
+
],
|
|
672
|
+
}),
|
|
497
673
|
};
|
|
498
|
-
return
|
|
674
|
+
return publishDirectMessage(token, payload, query, logger);
|
|
499
675
|
}
|
|
500
676
|
|
|
501
|
-
export async function retweetWithComment(token, text, attachment,logger){
|
|
502
|
-
|
|
677
|
+
export async function retweetWithComment(token, text, attachment, logger) {
|
|
503
678
|
let mediaId;
|
|
504
679
|
if (attachment) {
|
|
505
680
|
// discussionType only matters if DM or Not
|
|
@@ -515,7 +690,112 @@ export async function retweetWithComment(token, text, attachment,logger){
|
|
|
515
690
|
};
|
|
516
691
|
|
|
517
692
|
let query = 'https://api.twitter.com/2/tweets';
|
|
518
|
-
return publishTweet(token,payload,query, false,logger);
|
|
693
|
+
return publishTweet(token, payload, query, false, logger);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Normalizes Twitter API responses to ensure consistent structure across v1.1 and v2
|
|
698
|
+
* This is critical for backward compatibility - consumers expect certain fields to exist
|
|
699
|
+
*
|
|
700
|
+
* @param {Object} response - The response object to normalize
|
|
701
|
+
* @param {string} responseType - Type of response: 'dm' for direct messages, 'tweet' for tweets
|
|
702
|
+
* @param {Object} logger - Logger instance for debugging
|
|
703
|
+
* @param {Object} fullResponse - Full API response including includes (for extracting author_id from expansions)
|
|
704
|
+
* @returns {Object} Normalized response with consistent field structure
|
|
705
|
+
*/
|
|
706
|
+
function normalizeTwitterResponse(
|
|
707
|
+
response,
|
|
708
|
+
responseType,
|
|
709
|
+
logger,
|
|
710
|
+
fullResponse = null
|
|
711
|
+
) {
|
|
712
|
+
if (!response || !response.data) {
|
|
713
|
+
return response;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
loggerDebug(logger, `Normalizing Twitter API response (${responseType})`, {
|
|
717
|
+
beforeNormalization: JSON.stringify(response.data),
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// Handle Twitter API v2 Direct Message responses
|
|
721
|
+
// v2 DM structure: { dm_event_id, dm_conversation_id }
|
|
722
|
+
// v1.1 expected structure: { id, author_id }
|
|
723
|
+
if (responseType === 'dm' && response.data.dm_event_id) {
|
|
724
|
+
const normalizedData = {
|
|
725
|
+
...response.data,
|
|
726
|
+
// Add v1.1-compatible fields for backward compatibility
|
|
727
|
+
id: response.data.dm_event_id,
|
|
728
|
+
// Note: author_id is intentionally not set here. The caller (twitterApi.client.js)
|
|
729
|
+
// will set the correct author_id from the original document since dm_conversation_id
|
|
730
|
+
// doesn't reliably indicate the sender (it's just "user1-user2" format)
|
|
731
|
+
author_id: null,
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
loggerInfo(
|
|
735
|
+
logger,
|
|
736
|
+
`Normalized Twitter API v2 DM response to v1.1 format`,
|
|
737
|
+
{
|
|
738
|
+
dm_event_id: response.data.dm_event_id,
|
|
739
|
+
normalized_id: normalizedData.id,
|
|
740
|
+
dm_conversation_id: response.data.dm_conversation_id,
|
|
741
|
+
normalized_author_id: normalizedData.author_id,
|
|
742
|
+
}
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
return {
|
|
746
|
+
...response,
|
|
747
|
+
data: normalizedData,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Handle Twitter API v2 Tweet responses
|
|
752
|
+
// v2 has 'id' field but author_id is in expansions (includes.users)
|
|
753
|
+
if (responseType === 'tweet' && response.data.id) {
|
|
754
|
+
const normalizedData = { ...response.data };
|
|
755
|
+
|
|
756
|
+
// If author_id is missing but we have includes.users from expansions
|
|
757
|
+
if (
|
|
758
|
+
!normalizedData.author_id &&
|
|
759
|
+
fullResponse?.includes?.users?.[0]?.id
|
|
760
|
+
) {
|
|
761
|
+
normalizedData.author_id = fullResponse.includes.users[0].id;
|
|
762
|
+
|
|
763
|
+
loggerInfo(
|
|
764
|
+
logger,
|
|
765
|
+
`Added author_id to tweet response from expansions`,
|
|
766
|
+
{
|
|
767
|
+
tweet_id: normalizedData.id,
|
|
768
|
+
author_id: normalizedData.author_id,
|
|
769
|
+
}
|
|
770
|
+
);
|
|
771
|
+
} else if (normalizedData.author_id) {
|
|
772
|
+
loggerDebug(
|
|
773
|
+
logger,
|
|
774
|
+
`Twitter v2 tweet response already has 'author_id'`,
|
|
775
|
+
{
|
|
776
|
+
id: response.data.id,
|
|
777
|
+
author_id: normalizedData.author_id,
|
|
778
|
+
}
|
|
779
|
+
);
|
|
780
|
+
} else {
|
|
781
|
+
loggerWarn(
|
|
782
|
+
logger,
|
|
783
|
+
`Twitter v2 tweet response missing author_id and no expansions available`,
|
|
784
|
+
{
|
|
785
|
+
id: response.data.id,
|
|
786
|
+
hasIncludes: !!fullResponse?.includes,
|
|
787
|
+
hasUsers: !!fullResponse?.includes?.users,
|
|
788
|
+
}
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
...response,
|
|
794
|
+
data: normalizedData,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return response;
|
|
519
799
|
}
|
|
520
800
|
|
|
521
801
|
async function publishTweet(token, payload, query, isDirectMessage, logger) {
|
|
@@ -528,10 +808,7 @@ async function publishTweet(token, payload, query, isDirectMessage, logger) {
|
|
|
528
808
|
attachUrlPrefix: isDirectMessage,
|
|
529
809
|
});
|
|
530
810
|
|
|
531
|
-
if (
|
|
532
|
-
!isDirectMessage &&
|
|
533
|
-
nativeResponse.resp.statusCode === 201
|
|
534
|
-
) {
|
|
811
|
+
if (!isDirectMessage && nativeResponse.resp.statusCode === 201) {
|
|
535
812
|
const uri = `https://api.twitter.com/2/tweets?ids=${nativeResponse.data.data.id}&tweet.fields=public_metrics&expansions=author_id`;
|
|
536
813
|
|
|
537
814
|
nativeResponse = await getRequest({
|
|
@@ -540,14 +817,13 @@ async function publishTweet(token, payload, query, isDirectMessage, logger) {
|
|
|
540
817
|
payload,
|
|
541
818
|
attachUrlPrefix: false,
|
|
542
819
|
convertPayloadToUri: false,
|
|
543
|
-
logger
|
|
820
|
+
logger,
|
|
544
821
|
});
|
|
545
822
|
}
|
|
546
823
|
|
|
547
|
-
loggerInfo(logger,
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
);
|
|
824
|
+
loggerInfo(logger, `finished fetching twitterAPI nativeResponse `, {
|
|
825
|
+
data: JSON.stringify(nativeResponse.data),
|
|
826
|
+
});
|
|
551
827
|
|
|
552
828
|
const response = nativeResponse.resp
|
|
553
829
|
? {
|
|
@@ -564,19 +840,74 @@ async function publishTweet(token, payload, query, isDirectMessage, logger) {
|
|
|
564
840
|
statusCode: nativeResponse.statusCode,
|
|
565
841
|
statusMessage: nativeResponse.message,
|
|
566
842
|
};
|
|
567
|
-
|
|
843
|
+
|
|
844
|
+
loggerDebug(logger, `Twitter the data response is`, {
|
|
568
845
|
response: JSON.stringify(response),
|
|
569
846
|
});
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
847
|
+
|
|
848
|
+
// Normalize the response to ensure backward compatibility with v1.1 structure
|
|
849
|
+
// Pass full nativeResponse to extract author_id from expansions if needed
|
|
850
|
+
const normalizedResponse = normalizeTwitterResponse(
|
|
851
|
+
response,
|
|
852
|
+
'tweet',
|
|
853
|
+
logger,
|
|
854
|
+
nativeResponse.data // Full response with includes.users for author_id
|
|
575
855
|
);
|
|
856
|
+
|
|
857
|
+
return normalizedResponse;
|
|
858
|
+
} catch (err) {
|
|
859
|
+
loggerError(logger, `Twitter publish exception details`, err);
|
|
576
860
|
throw err;
|
|
577
861
|
}
|
|
578
862
|
}
|
|
579
863
|
|
|
864
|
+
async function publishDirectMessage(token, payload, query, logger) {
|
|
865
|
+
try {
|
|
866
|
+
let nativeResponse = await postRequest({
|
|
867
|
+
token,
|
|
868
|
+
uri: query,
|
|
869
|
+
payload,
|
|
870
|
+
convertPayloadToUri: false,
|
|
871
|
+
attachUrlPrefix: false,
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
loggerInfo(logger, `finished sending DM via Twitter API v2`, {
|
|
875
|
+
data: JSON.stringify(nativeResponse.data),
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
const response = nativeResponse.resp
|
|
879
|
+
? {
|
|
880
|
+
statusCode: nativeResponse.resp.statusCode,
|
|
881
|
+
statusMessage:
|
|
882
|
+
nativeResponse.resp.statusCode === 201 ||
|
|
883
|
+
nativeResponse.resp.statusCode === 200
|
|
884
|
+
? 'Success'
|
|
885
|
+
: 'Unknown Failure',
|
|
886
|
+
data: nativeResponse.data.data,
|
|
887
|
+
}
|
|
888
|
+
: {
|
|
889
|
+
statusCode: nativeResponse.statusCode,
|
|
890
|
+
statusMessage: nativeResponse.message,
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
loggerDebug(logger, `Twitter DM response is`, {
|
|
894
|
+
response: JSON.stringify(response),
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// Normalize the response to ensure backward compatibility with v1.1 structure
|
|
898
|
+
// This is CRITICAL - v2 DM responses have dm_event_id instead of id
|
|
899
|
+
const normalizedResponse = normalizeTwitterResponse(
|
|
900
|
+
response,
|
|
901
|
+
'dm',
|
|
902
|
+
logger
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
return normalizedResponse;
|
|
906
|
+
} catch (err) {
|
|
907
|
+
loggerError(logger, `Twitter DM exception details`, err);
|
|
908
|
+
throw err;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
580
911
|
|
|
581
912
|
async function postRequest({
|
|
582
913
|
token,
|
|
@@ -584,7 +915,7 @@ async function postRequest({
|
|
|
584
915
|
payload,
|
|
585
916
|
attachUrlPrefix = true,
|
|
586
917
|
convertPayloadToUri = true,
|
|
587
|
-
logger
|
|
918
|
+
logger,
|
|
588
919
|
}) {
|
|
589
920
|
return doRequest({
|
|
590
921
|
requestMethod: 'post',
|
|
@@ -593,7 +924,7 @@ async function postRequest({
|
|
|
593
924
|
payload,
|
|
594
925
|
uri,
|
|
595
926
|
attachUrlPrefix,
|
|
596
|
-
logger
|
|
927
|
+
logger,
|
|
597
928
|
});
|
|
598
929
|
}
|
|
599
930
|
|
|
@@ -603,7 +934,7 @@ async function getRequest({
|
|
|
603
934
|
payload,
|
|
604
935
|
attachUrlPrefix = true,
|
|
605
936
|
convertPayloadToUri = true,
|
|
606
|
-
logger
|
|
937
|
+
logger,
|
|
607
938
|
}) {
|
|
608
939
|
return doRequest({
|
|
609
940
|
requestMethod: 'get',
|
|
@@ -612,7 +943,7 @@ async function getRequest({
|
|
|
612
943
|
uri,
|
|
613
944
|
attachUrlPrefix,
|
|
614
945
|
convertPayloadToUri,
|
|
615
|
-
logger
|
|
946
|
+
logger,
|
|
616
947
|
});
|
|
617
948
|
}
|
|
618
949
|
|
|
@@ -622,7 +953,7 @@ async function deleteRequest({
|
|
|
622
953
|
payload,
|
|
623
954
|
attachUrlPrefix = false,
|
|
624
955
|
convertPayloadToUri = false,
|
|
625
|
-
logger
|
|
956
|
+
logger,
|
|
626
957
|
}) {
|
|
627
958
|
return doRequest({
|
|
628
959
|
requestMethod: 'delete',
|
|
@@ -631,7 +962,7 @@ async function deleteRequest({
|
|
|
631
962
|
payload,
|
|
632
963
|
uri,
|
|
633
964
|
attachUrlPrefix,
|
|
634
|
-
logger
|
|
965
|
+
logger,
|
|
635
966
|
});
|
|
636
967
|
}
|
|
637
968
|
|
|
@@ -648,7 +979,7 @@ async function doRequest({
|
|
|
648
979
|
payload,
|
|
649
980
|
attachUrlPrefix = true,
|
|
650
981
|
convertPayloadToUri = true,
|
|
651
|
-
logger
|
|
982
|
+
logger,
|
|
652
983
|
}) {
|
|
653
984
|
if (payload && convertPayloadToUri) {
|
|
654
985
|
uri =
|
|
@@ -687,12 +1018,10 @@ async function doRequest({
|
|
|
687
1018
|
.post(url)
|
|
688
1019
|
.set('Authorization', Authorization)
|
|
689
1020
|
.send(
|
|
690
|
-
payload && !convertPayloadToUri
|
|
691
|
-
? payload
|
|
692
|
-
: undefined
|
|
1021
|
+
payload && !convertPayloadToUri ? payload : undefined
|
|
693
1022
|
)
|
|
694
1023
|
: superagent.del(url).set('Authorization', Authorization));
|
|
695
|
-
loggerDebug(logger,'Twitter Native Response ', {
|
|
1024
|
+
loggerDebug(logger, 'Twitter Native Response ', {
|
|
696
1025
|
native: {
|
|
697
1026
|
//data: result.body,
|
|
698
1027
|
contentType: result.headers['content-type'],
|
|
@@ -708,15 +1037,19 @@ async function doRequest({
|
|
|
708
1037
|
resp: { ...result },
|
|
709
1038
|
});
|
|
710
1039
|
} catch (e) {
|
|
711
|
-
if(e.response.status == 503){
|
|
712
|
-
loggerWarn(
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1040
|
+
if (e.response.status == 503) {
|
|
1041
|
+
loggerWarn(
|
|
1042
|
+
logger,
|
|
1043
|
+
`Twitter API 503 error - Service Unavailable`,
|
|
1044
|
+
{
|
|
1045
|
+
url,
|
|
1046
|
+
convertPayloadToUri,
|
|
1047
|
+
payload: JSON.stringify(payload),
|
|
1048
|
+
}
|
|
1049
|
+
);
|
|
717
1050
|
return reject(e);
|
|
718
1051
|
}
|
|
719
|
-
loggerError(logger
|
|
1052
|
+
loggerError(logger, `Error in twitter doRequest`, e, {
|
|
720
1053
|
url,
|
|
721
1054
|
convertPayloadToUri,
|
|
722
1055
|
payload: JSON.stringify(payload),
|
|
@@ -726,7 +1059,7 @@ async function doRequest({
|
|
|
726
1059
|
});
|
|
727
1060
|
}
|
|
728
1061
|
|
|
729
|
-
|
|
1062
|
+
// local
|
|
730
1063
|
async function downloadImage(url, fileName) {
|
|
731
1064
|
const writer = fs.createWriteStream(fileName);
|
|
732
1065
|
const response = await axios({
|
|
@@ -744,7 +1077,7 @@ async function downloadImage(url, fileName) {
|
|
|
744
1077
|
});
|
|
745
1078
|
}
|
|
746
1079
|
|
|
747
|
-
|
|
1080
|
+
// local
|
|
748
1081
|
function generateFilePath(mimeType, dirname) {
|
|
749
1082
|
let dir = `${dirname}/temporaryTwitterMedia`;
|
|
750
1083
|
if (!fs.existsSync(dir)) {
|
|
@@ -757,49 +1090,269 @@ function generateFilePath(mimeType, dirname) {
|
|
|
757
1090
|
}
|
|
758
1091
|
return `${dir}/${randomFileName}.${fileExtension}`;
|
|
759
1092
|
}
|
|
760
|
-
|
|
1093
|
+
// local
|
|
761
1094
|
async function uploadMedia(attachment, token, discussionType, logger) {
|
|
762
|
-
|
|
763
|
-
|
|
1095
|
+
let filePath;
|
|
1096
|
+
try {
|
|
1097
|
+
filePath = generateFilePath(attachment.mimeType, attachment.__dirname);
|
|
764
1098
|
await downloadImage(attachment.url, filePath);
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1099
|
+
|
|
1100
|
+
const mediaCategory =
|
|
1101
|
+
discussionType === 'dm' ? 'dm_image' : 'tweet_image';
|
|
1102
|
+
const fileStats = fs.statSync(filePath);
|
|
1103
|
+
const fileSize = fileStats.size;
|
|
1104
|
+
const fileData = fs.readFileSync(filePath);
|
|
1105
|
+
|
|
1106
|
+
// For small files, use simple upload
|
|
1107
|
+
if (fileSize < 5000000) {
|
|
1108
|
+
// 5MB
|
|
1109
|
+
const mediaIdString = await simpleMediaUpload(
|
|
1110
|
+
fileData,
|
|
1111
|
+
attachment.mimeType,
|
|
1112
|
+
token,
|
|
1113
|
+
logger
|
|
1114
|
+
);
|
|
1115
|
+
removeMedia(filePath, logger);
|
|
1116
|
+
return mediaIdString;
|
|
1117
|
+
} else {
|
|
1118
|
+
// For large files, use chunked upload
|
|
1119
|
+
const mediaIdString = await chunkedMediaUpload(
|
|
1120
|
+
filePath,
|
|
1121
|
+
fileSize,
|
|
1122
|
+
attachment.mimeType,
|
|
776
1123
|
mediaCategory,
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
reject(err);
|
|
780
|
-
}
|
|
781
|
-
resolve(data.media_id_string);
|
|
782
|
-
removeMedia(filePath, logger);
|
|
783
|
-
}
|
|
1124
|
+
token,
|
|
1125
|
+
logger
|
|
784
1126
|
);
|
|
785
|
-
} catch (e) {
|
|
786
|
-
loggerError(logger,`Failed posting media`, e);
|
|
787
|
-
// this is just a safety precaution
|
|
788
1127
|
removeMedia(filePath, logger);
|
|
1128
|
+
return mediaIdString;
|
|
789
1129
|
}
|
|
790
|
-
})
|
|
1130
|
+
} catch (e) {
|
|
1131
|
+
loggerError(logger, `Failed uploading media`, e);
|
|
1132
|
+
if (filePath) {
|
|
1133
|
+
removeMedia(filePath, logger);
|
|
1134
|
+
}
|
|
1135
|
+
throw e;
|
|
1136
|
+
}
|
|
791
1137
|
}
|
|
792
1138
|
|
|
793
|
-
|
|
1139
|
+
// Simple upload for small media files
|
|
1140
|
+
async function simpleMediaUpload(fileData, mimeType, token, logger) {
|
|
1141
|
+
try {
|
|
1142
|
+
const base64Data = fileData.toString('base64');
|
|
1143
|
+
const mediaType = mimeType.split('/')[0]; // 'image', 'video', etc.
|
|
1144
|
+
|
|
1145
|
+
const url = 'https://upload.twitter.com/1.1/media/upload.json';
|
|
1146
|
+
const { Authorization } = token.oauth.toHeader(
|
|
1147
|
+
token.oauth.authorize(
|
|
1148
|
+
{
|
|
1149
|
+
url,
|
|
1150
|
+
method: 'post',
|
|
1151
|
+
data: {},
|
|
1152
|
+
},
|
|
1153
|
+
{
|
|
1154
|
+
key: token.token,
|
|
1155
|
+
secret: token.tokenSecret,
|
|
1156
|
+
}
|
|
1157
|
+
)
|
|
1158
|
+
);
|
|
1159
|
+
|
|
1160
|
+
const result = await superagent
|
|
1161
|
+
.post(url)
|
|
1162
|
+
.set('Authorization', Authorization)
|
|
1163
|
+
.send({
|
|
1164
|
+
media_data: base64Data,
|
|
1165
|
+
media_type: mimeType,
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
loggerDebug(logger, 'Media uploaded successfully (simple)', {
|
|
1169
|
+
media_id: result.body.media_id_string,
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
return result.body.media_id_string;
|
|
1173
|
+
} catch (e) {
|
|
1174
|
+
loggerError(logger, 'Error in simple media upload', e);
|
|
1175
|
+
throw e;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Chunked upload for large media files
|
|
1180
|
+
async function chunkedMediaUpload(
|
|
1181
|
+
filePath,
|
|
1182
|
+
fileSize,
|
|
1183
|
+
mimeType,
|
|
1184
|
+
mediaCategory,
|
|
1185
|
+
token,
|
|
1186
|
+
logger
|
|
1187
|
+
) {
|
|
1188
|
+
try {
|
|
1189
|
+
const mediaType = mimeType.split('/')[0];
|
|
1190
|
+
|
|
1191
|
+
// Step 1: INIT
|
|
1192
|
+
const initResponse = await mediaUploadRequest(
|
|
1193
|
+
{
|
|
1194
|
+
command: 'INIT',
|
|
1195
|
+
total_bytes: fileSize,
|
|
1196
|
+
media_type: mimeType,
|
|
1197
|
+
media_category: mediaCategory,
|
|
1198
|
+
},
|
|
1199
|
+
token,
|
|
1200
|
+
logger
|
|
1201
|
+
);
|
|
1202
|
+
|
|
1203
|
+
const mediaId = initResponse.media_id_string;
|
|
1204
|
+
loggerDebug(logger, 'Media upload INIT successful', { mediaId });
|
|
1205
|
+
|
|
1206
|
+
// Step 2: APPEND (upload in chunks)
|
|
1207
|
+
const chunkSize = 5 * 1024 * 1024; // 5MB chunks
|
|
1208
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
1209
|
+
let segmentIndex = 0;
|
|
1210
|
+
|
|
1211
|
+
for (let offset = 0; offset < fileSize; offset += chunkSize) {
|
|
1212
|
+
const chunk = fileBuffer.slice(
|
|
1213
|
+
offset,
|
|
1214
|
+
Math.min(offset + chunkSize, fileSize)
|
|
1215
|
+
);
|
|
1216
|
+
const base64Chunk = chunk.toString('base64');
|
|
1217
|
+
|
|
1218
|
+
await mediaUploadRequest(
|
|
1219
|
+
{
|
|
1220
|
+
command: 'APPEND',
|
|
1221
|
+
media_id: mediaId,
|
|
1222
|
+
media_data: base64Chunk,
|
|
1223
|
+
segment_index: segmentIndex,
|
|
1224
|
+
},
|
|
1225
|
+
token,
|
|
1226
|
+
logger
|
|
1227
|
+
);
|
|
1228
|
+
|
|
1229
|
+
loggerDebug(logger, `Media chunk ${segmentIndex} uploaded`, {
|
|
1230
|
+
mediaId,
|
|
1231
|
+
segmentIndex,
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
segmentIndex++;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Step 3: FINALIZE
|
|
1238
|
+
const finalizeResponse = await mediaUploadRequest(
|
|
1239
|
+
{
|
|
1240
|
+
command: 'FINALIZE',
|
|
1241
|
+
media_id: mediaId,
|
|
1242
|
+
},
|
|
1243
|
+
token,
|
|
1244
|
+
logger
|
|
1245
|
+
);
|
|
1246
|
+
|
|
1247
|
+
loggerDebug(logger, 'Media upload FINALIZE successful', { mediaId });
|
|
1248
|
+
|
|
1249
|
+
// Step 4: Check processing status if needed
|
|
1250
|
+
if (finalizeResponse.processing_info) {
|
|
1251
|
+
await waitForMediaProcessing(mediaId, token, logger);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
return mediaId;
|
|
1255
|
+
} catch (e) {
|
|
1256
|
+
loggerError(logger, 'Error in chunked media upload', e);
|
|
1257
|
+
throw e;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Make a media upload request
|
|
1262
|
+
async function mediaUploadRequest(params, token, logger) {
|
|
1263
|
+
const url = 'https://upload.twitter.com/1.1/media/upload.json';
|
|
1264
|
+
|
|
1265
|
+
const { Authorization } = token.oauth.toHeader(
|
|
1266
|
+
token.oauth.authorize(
|
|
1267
|
+
{
|
|
1268
|
+
url,
|
|
1269
|
+
method: 'post',
|
|
1270
|
+
data: {},
|
|
1271
|
+
},
|
|
1272
|
+
{
|
|
1273
|
+
key: token.token,
|
|
1274
|
+
secret: token.tokenSecret,
|
|
1275
|
+
}
|
|
1276
|
+
)
|
|
1277
|
+
);
|
|
1278
|
+
|
|
1279
|
+
try {
|
|
1280
|
+
const result = await superagent
|
|
1281
|
+
.post(url)
|
|
1282
|
+
.set('Authorization', Authorization)
|
|
1283
|
+
.send(params);
|
|
1284
|
+
|
|
1285
|
+
return result.body;
|
|
1286
|
+
} catch (e) {
|
|
1287
|
+
loggerError(
|
|
1288
|
+
logger,
|
|
1289
|
+
`Media upload request failed for command: ${params.command}`,
|
|
1290
|
+
e
|
|
1291
|
+
);
|
|
1292
|
+
throw e;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Wait for media processing to complete
|
|
1297
|
+
async function waitForMediaProcessing(
|
|
1298
|
+
mediaId,
|
|
1299
|
+
token,
|
|
1300
|
+
logger,
|
|
1301
|
+
maxAttempts = 20
|
|
1302
|
+
) {
|
|
1303
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1304
|
+
const statusResponse = await mediaUploadRequest(
|
|
1305
|
+
{
|
|
1306
|
+
command: 'STATUS',
|
|
1307
|
+
media_id: mediaId,
|
|
1308
|
+
},
|
|
1309
|
+
token,
|
|
1310
|
+
logger
|
|
1311
|
+
);
|
|
1312
|
+
|
|
1313
|
+
if (!statusResponse.processing_info) {
|
|
1314
|
+
return; // Processing complete
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const state = statusResponse.processing_info.state;
|
|
1318
|
+
|
|
1319
|
+
if (state === 'succeeded') {
|
|
1320
|
+
loggerDebug(logger, 'Media processing succeeded', { mediaId });
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
if (state === 'failed') {
|
|
1325
|
+
throw new Error(
|
|
1326
|
+
`Media processing failed: ${
|
|
1327
|
+
statusResponse.processing_info.error?.message ||
|
|
1328
|
+
'Unknown error'
|
|
1329
|
+
}`
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Still processing, wait before checking again
|
|
1334
|
+
const waitTime = statusResponse.processing_info.check_after_secs || 1;
|
|
1335
|
+
loggerDebug(logger, `Media still processing, waiting ${waitTime}s`, {
|
|
1336
|
+
mediaId,
|
|
1337
|
+
state,
|
|
1338
|
+
});
|
|
1339
|
+
await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
throw new Error('Media processing timeout');
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function removeMedia(file, logger) {
|
|
794
1346
|
try {
|
|
795
1347
|
fs.unlink(file, function (err) {
|
|
796
1348
|
if (err) {
|
|
797
|
-
loggerError(logger
|
|
1349
|
+
loggerError(logger, `Failed removing ${file}`, err);
|
|
798
1350
|
}
|
|
799
1351
|
});
|
|
800
1352
|
} catch (e) {
|
|
801
|
-
loggerError(
|
|
1353
|
+
loggerError(
|
|
1354
|
+
logger,
|
|
802
1355
|
`failed trying to remove media ${file} it may have already been removed`
|
|
803
1356
|
);
|
|
804
1357
|
}
|
|
805
|
-
}
|
|
1358
|
+
}
|