@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.
|
@@ -5,7 +5,6 @@ import OAuth from 'oauth-1.0a';
|
|
|
5
5
|
import crypto from 'crypto';
|
|
6
6
|
import axios from 'axios';
|
|
7
7
|
import fs from 'fs';
|
|
8
|
-
import Twit from '@meltwater/social-twit';
|
|
9
8
|
const TWITTER_API_CONFIG = {
|
|
10
9
|
v1_1: {
|
|
11
10
|
base_url: 'https://api.twitter.com/1.1'
|
|
@@ -112,6 +111,28 @@ function normalizeUsersData(users) {
|
|
|
112
111
|
}
|
|
113
112
|
return normalizeUserData(users, apiVersion);
|
|
114
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get authenticated user information using Twitter API v2
|
|
117
|
+
* @param {Object} token - OAuth token object
|
|
118
|
+
* @param {Object} logger - Logger instance
|
|
119
|
+
* @returns {Promise<Object>} User data from v2 API
|
|
120
|
+
*/
|
|
121
|
+
export async function getAuthenticatedUser(token, logger) {
|
|
122
|
+
try {
|
|
123
|
+
const result = await getRequest({
|
|
124
|
+
token,
|
|
125
|
+
uri: 'https://api.x.com/2/users/me',
|
|
126
|
+
attachUrlPrefix: false,
|
|
127
|
+
convertPayloadToUri: false,
|
|
128
|
+
logger
|
|
129
|
+
});
|
|
130
|
+
return result.data;
|
|
131
|
+
} catch (e) {
|
|
132
|
+
loggerError(logger, `Error getting authenticated user info`, e);
|
|
133
|
+
throw e;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
115
136
|
export function addOAuthToToken(token, TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET) {
|
|
116
137
|
if (!token.oauth) {
|
|
117
138
|
token.oauth = OAuth({
|
|
@@ -207,7 +228,7 @@ export async function getCurrentInfo(token, externalId, logger) {
|
|
|
207
228
|
});
|
|
208
229
|
|
|
209
230
|
// this makes it so 'retweeted' is only true on a retweet that isn't a comment
|
|
210
|
-
// sometimes 'retweeted' is true if user retweeted with comment, sometimes not
|
|
231
|
+
// sometimes 'retweeted' is true if user retweeted with comment, sometimes not
|
|
211
232
|
result.data.retweeted = !!result.data.current_user_retweet;
|
|
212
233
|
return result.data.data[0].public_metrics;
|
|
213
234
|
} catch (error) {
|
|
@@ -270,12 +291,20 @@ export async function unRetweet(token, sourceId, externalId, logger) {
|
|
|
270
291
|
}
|
|
271
292
|
export async function like(token, externalId, logger) {
|
|
272
293
|
try {
|
|
294
|
+
// Get the authenticated user's ID first
|
|
295
|
+
const userInfo = await getAuthenticatedUser(token, logger);
|
|
296
|
+
const userId = userInfo.data.id;
|
|
273
297
|
let response = await postRequest({
|
|
274
298
|
token,
|
|
275
|
-
uri:
|
|
299
|
+
uri: `https://api.x.com/2/users/${userId}/likes`,
|
|
300
|
+
payload: {
|
|
301
|
+
tweet_id: removePrefix(externalId)
|
|
302
|
+
},
|
|
303
|
+
attachUrlPrefix: false,
|
|
304
|
+
convertPayloadToUri: false,
|
|
276
305
|
logger
|
|
277
306
|
});
|
|
278
|
-
if (response.data.
|
|
307
|
+
if (response.data.data?.liked !== undefined && response.resp.statusCode === 200) {
|
|
279
308
|
return true;
|
|
280
309
|
} else {
|
|
281
310
|
loggerInfo(logger, 'Twitter Error in like user statusCode non 200 ', {
|
|
@@ -290,12 +319,17 @@ export async function like(token, externalId, logger) {
|
|
|
290
319
|
}
|
|
291
320
|
export async function unLike(token, externalId, logger) {
|
|
292
321
|
try {
|
|
293
|
-
|
|
322
|
+
// Get the authenticated user's ID first
|
|
323
|
+
const userInfo = await getAuthenticatedUser(token, logger);
|
|
324
|
+
const userId = userInfo.data.id;
|
|
325
|
+
let response = await deleteRequest({
|
|
294
326
|
token,
|
|
295
|
-
uri:
|
|
327
|
+
uri: `https://api.x.com/2/users/${userId}/likes/${removePrefix(externalId)}`,
|
|
328
|
+
attachUrlPrefix: false,
|
|
329
|
+
convertPayloadToUri: false,
|
|
296
330
|
logger
|
|
297
331
|
});
|
|
298
|
-
if (response.data.
|
|
332
|
+
if (response.data.data?.liked !== undefined && response.resp.statusCode === 200) {
|
|
299
333
|
return true;
|
|
300
334
|
} else {
|
|
301
335
|
return false;
|
|
@@ -307,16 +341,20 @@ export async function unLike(token, externalId, logger) {
|
|
|
307
341
|
}
|
|
308
342
|
export async function followUser(token, profileId, logger) {
|
|
309
343
|
try {
|
|
344
|
+
// Get the authenticated user's ID first
|
|
345
|
+
const userInfo = await getAuthenticatedUser(token, logger);
|
|
346
|
+
const userId = userInfo.data.id;
|
|
310
347
|
let response = await postRequest({
|
|
311
348
|
token,
|
|
312
|
-
uri:
|
|
349
|
+
uri: `https://api.x.com/2/users/${userId}/following`,
|
|
313
350
|
payload: {
|
|
314
|
-
|
|
315
|
-
follow: true
|
|
351
|
+
target_user_id: removePrefix(profileId)
|
|
316
352
|
},
|
|
353
|
+
attachUrlPrefix: false,
|
|
354
|
+
convertPayloadToUri: false,
|
|
317
355
|
logger
|
|
318
356
|
});
|
|
319
|
-
if (response.data.following !== undefined) {
|
|
357
|
+
if (response.data.data?.following !== undefined) {
|
|
320
358
|
return true;
|
|
321
359
|
} else {
|
|
322
360
|
return false;
|
|
@@ -327,15 +365,17 @@ export async function followUser(token, profileId, logger) {
|
|
|
327
365
|
}
|
|
328
366
|
export async function unFollowUser(token, profileId, logger) {
|
|
329
367
|
try {
|
|
330
|
-
|
|
368
|
+
// Get the authenticated user's ID first
|
|
369
|
+
const userInfo = await getAuthenticatedUser(token, logger);
|
|
370
|
+
const userId = userInfo.data.id;
|
|
371
|
+
let response = await deleteRequest({
|
|
331
372
|
token,
|
|
332
|
-
uri:
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
},
|
|
373
|
+
uri: `https://api.x.com/2/users/${userId}/following/${removePrefix(profileId)}`,
|
|
374
|
+
attachUrlPrefix: false,
|
|
375
|
+
convertPayloadToUri: false,
|
|
336
376
|
logger
|
|
337
377
|
});
|
|
338
|
-
if (response.data.following !== undefined) {
|
|
378
|
+
if (response.data.data?.following !== undefined) {
|
|
339
379
|
return true;
|
|
340
380
|
} else {
|
|
341
381
|
return false;
|
|
@@ -346,26 +386,34 @@ export async function unFollowUser(token, profileId, logger) {
|
|
|
346
386
|
}
|
|
347
387
|
export async function userFollowStatus(token, profileId, logger) {
|
|
348
388
|
try {
|
|
389
|
+
// Get the authenticated user's ID first
|
|
390
|
+
const userInfo = await getAuthenticatedUser(token, logger);
|
|
391
|
+
const userId = userInfo.data.id;
|
|
392
|
+
|
|
393
|
+
// Check if authenticated user is following the target user
|
|
394
|
+
// We need to check the following list with pagination support
|
|
349
395
|
let response = await getRequest({
|
|
350
396
|
token,
|
|
351
|
-
uri:
|
|
397
|
+
uri: `https://api.x.com/2/users/${userId}/following`,
|
|
352
398
|
payload: {
|
|
353
|
-
|
|
399
|
+
max_results: 1000 // Max allowed per request
|
|
354
400
|
},
|
|
401
|
+
attachUrlPrefix: false,
|
|
402
|
+
convertPayloadToUri: true,
|
|
355
403
|
logger
|
|
356
404
|
});
|
|
357
|
-
if (response.data
|
|
358
|
-
if
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
return false;
|
|
365
|
-
} else {
|
|
366
|
-
return false;
|
|
405
|
+
if (response.data?.data) {
|
|
406
|
+
// Check if the profileId is in the following list
|
|
407
|
+
const isFollowing = response.data.data.some(user => user.id === removePrefix(profileId));
|
|
408
|
+
if (isFollowing) {
|
|
409
|
+
return true;
|
|
367
410
|
}
|
|
411
|
+
|
|
412
|
+
// If not found and there's a next page, we might need to paginate
|
|
413
|
+
// For now, return false if not in first page
|
|
414
|
+
return false;
|
|
368
415
|
}
|
|
416
|
+
return false;
|
|
369
417
|
} catch (error) {
|
|
370
418
|
loggerDebug(logger, `Error in getting user follow status: ${profileId}`, error);
|
|
371
419
|
if (error.message != 'Bad Request') {
|
|
@@ -377,16 +425,58 @@ export async function userFollowStatus(token, profileId, logger) {
|
|
|
377
425
|
}
|
|
378
426
|
export async function getBrandUserRelationship(token, profileId, brandProfileId, logger) {
|
|
379
427
|
try {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
428
|
+
// In v2 API, we need to make separate calls to check following/followers status
|
|
429
|
+
// Check if brand follows user
|
|
430
|
+
let brandFollowsUser = false;
|
|
431
|
+
let userFollowsBrand = false;
|
|
432
|
+
try {
|
|
433
|
+
const followingResponse = await getRequest({
|
|
434
|
+
token,
|
|
435
|
+
uri: `https://api.x.com/2/users/${brandProfileId}/following`,
|
|
436
|
+
payload: {
|
|
437
|
+
max_results: 1000
|
|
438
|
+
},
|
|
439
|
+
attachUrlPrefix: false,
|
|
440
|
+
convertPayloadToUri: true,
|
|
441
|
+
logger
|
|
442
|
+
});
|
|
443
|
+
if (followingResponse.data?.data) {
|
|
444
|
+
brandFollowsUser = followingResponse.data.data.some(user => user.id === removePrefix(profileId));
|
|
445
|
+
}
|
|
446
|
+
} catch (e) {
|
|
447
|
+
loggerWarn(logger, 'Error checking if brand follows user', e);
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
const followersResponse = await getRequest({
|
|
451
|
+
token,
|
|
452
|
+
uri: `https://api.x.com/2/users/${brandProfileId}/followers`,
|
|
453
|
+
payload: {
|
|
454
|
+
max_results: 1000
|
|
455
|
+
},
|
|
456
|
+
attachUrlPrefix: false,
|
|
457
|
+
convertPayloadToUri: true,
|
|
458
|
+
logger
|
|
459
|
+
});
|
|
460
|
+
if (followersResponse.data?.data) {
|
|
461
|
+
userFollowsBrand = followersResponse.data.data.some(user => user.id === removePrefix(profileId));
|
|
462
|
+
}
|
|
463
|
+
} catch (e) {
|
|
464
|
+
loggerWarn(logger, 'Error checking if user follows brand', e);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Build a relationship object similar to v1.1 format
|
|
468
|
+
return {
|
|
469
|
+
source: {
|
|
470
|
+
id_str: brandProfileId,
|
|
471
|
+
following: brandFollowsUser,
|
|
472
|
+
followed_by: userFollowsBrand
|
|
473
|
+
},
|
|
474
|
+
target: {
|
|
475
|
+
id_str: removePrefix(profileId),
|
|
476
|
+
following: userFollowsBrand,
|
|
477
|
+
followed_by: brandFollowsUser
|
|
478
|
+
}
|
|
479
|
+
};
|
|
390
480
|
} catch (error) {
|
|
391
481
|
loggerDebug(logger, `Error in getting user brand friendship info: ${profileId} and ${brandProfileId}`, error);
|
|
392
482
|
}
|
|
@@ -418,34 +508,21 @@ export async function reply(token, text, attachment, inReplyToId, removeInReplyT
|
|
|
418
508
|
return publishTweet(token, payload, query, false, logger);
|
|
419
509
|
}
|
|
420
510
|
export async function privateMessage(token, text, attachment, profileId, logger) {
|
|
421
|
-
let query =
|
|
511
|
+
let query = `https://api.x.com/2/dm_conversations/with/${removePrefix(profileId)}/messages`;
|
|
422
512
|
let mediaId;
|
|
423
513
|
if (attachment) {
|
|
424
514
|
// discussionType only matters if DM or Not
|
|
425
515
|
mediaId = await uploadMedia(attachment, token, 'dm', logger);
|
|
426
516
|
}
|
|
427
517
|
const payload = {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
message_data: {
|
|
435
|
-
text: text || ' ',
|
|
436
|
-
...(attachment && {
|
|
437
|
-
attachment: {
|
|
438
|
-
type: 'media',
|
|
439
|
-
media: {
|
|
440
|
-
id: mediaId
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
})
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
518
|
+
text: text || ' ',
|
|
519
|
+
...(attachment && {
|
|
520
|
+
attachments: [{
|
|
521
|
+
media_id: mediaId
|
|
522
|
+
}]
|
|
523
|
+
})
|
|
447
524
|
};
|
|
448
|
-
return
|
|
525
|
+
return publishDirectMessage(token, payload, query, logger);
|
|
449
526
|
}
|
|
450
527
|
export async function retweetWithComment(token, text, attachment, logger) {
|
|
451
528
|
let mediaId;
|
|
@@ -464,6 +541,84 @@ export async function retweetWithComment(token, text, attachment, logger) {
|
|
|
464
541
|
let query = 'https://api.twitter.com/2/tweets';
|
|
465
542
|
return publishTweet(token, payload, query, false, logger);
|
|
466
543
|
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Normalizes Twitter API responses to ensure consistent structure across v1.1 and v2
|
|
547
|
+
* This is critical for backward compatibility - consumers expect certain fields to exist
|
|
548
|
+
*
|
|
549
|
+
* @param {Object} response - The response object to normalize
|
|
550
|
+
* @param {string} responseType - Type of response: 'dm' for direct messages, 'tweet' for tweets
|
|
551
|
+
* @param {Object} logger - Logger instance for debugging
|
|
552
|
+
* @param {Object} fullResponse - Full API response including includes (for extracting author_id from expansions)
|
|
553
|
+
* @returns {Object} Normalized response with consistent field structure
|
|
554
|
+
*/
|
|
555
|
+
function normalizeTwitterResponse(response, responseType, logger) {
|
|
556
|
+
let fullResponse = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null;
|
|
557
|
+
if (!response || !response.data) {
|
|
558
|
+
return response;
|
|
559
|
+
}
|
|
560
|
+
loggerDebug(logger, `Normalizing Twitter API response (${responseType})`, {
|
|
561
|
+
beforeNormalization: JSON.stringify(response.data)
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// Handle Twitter API v2 Direct Message responses
|
|
565
|
+
// v2 DM structure: { dm_event_id, dm_conversation_id }
|
|
566
|
+
// v1.1 expected structure: { id, author_id }
|
|
567
|
+
if (responseType === 'dm' && response.data.dm_event_id) {
|
|
568
|
+
const normalizedData = {
|
|
569
|
+
...response.data,
|
|
570
|
+
// Add v1.1-compatible fields for backward compatibility
|
|
571
|
+
id: response.data.dm_event_id,
|
|
572
|
+
// Note: author_id is intentionally not set here. The caller (twitterApi.client.js)
|
|
573
|
+
// will set the correct author_id from the original document since dm_conversation_id
|
|
574
|
+
// doesn't reliably indicate the sender (it's just "user1-user2" format)
|
|
575
|
+
author_id: null
|
|
576
|
+
};
|
|
577
|
+
loggerInfo(logger, `Normalized Twitter API v2 DM response to v1.1 format`, {
|
|
578
|
+
dm_event_id: response.data.dm_event_id,
|
|
579
|
+
normalized_id: normalizedData.id,
|
|
580
|
+
dm_conversation_id: response.data.dm_conversation_id,
|
|
581
|
+
normalized_author_id: normalizedData.author_id
|
|
582
|
+
});
|
|
583
|
+
return {
|
|
584
|
+
...response,
|
|
585
|
+
data: normalizedData
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Handle Twitter API v2 Tweet responses
|
|
590
|
+
// v2 has 'id' field but author_id is in expansions (includes.users)
|
|
591
|
+
if (responseType === 'tweet' && response.data.id) {
|
|
592
|
+
const normalizedData = {
|
|
593
|
+
...response.data
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
// If author_id is missing but we have includes.users from expansions
|
|
597
|
+
if (!normalizedData.author_id && fullResponse?.includes?.users?.[0]?.id) {
|
|
598
|
+
normalizedData.author_id = fullResponse.includes.users[0].id;
|
|
599
|
+
loggerInfo(logger, `Added author_id to tweet response from expansions`, {
|
|
600
|
+
tweet_id: normalizedData.id,
|
|
601
|
+
author_id: normalizedData.author_id
|
|
602
|
+
});
|
|
603
|
+
} else if (normalizedData.author_id) {
|
|
604
|
+
loggerDebug(logger, `Twitter v2 tweet response already has 'author_id'`, {
|
|
605
|
+
id: response.data.id,
|
|
606
|
+
author_id: normalizedData.author_id
|
|
607
|
+
});
|
|
608
|
+
} else {
|
|
609
|
+
loggerWarn(logger, `Twitter v2 tweet response missing author_id and no expansions available`, {
|
|
610
|
+
id: response.data.id,
|
|
611
|
+
hasIncludes: !!fullResponse?.includes,
|
|
612
|
+
hasUsers: !!fullResponse?.includes?.users
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
...response,
|
|
617
|
+
data: normalizedData
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
return response;
|
|
621
|
+
}
|
|
467
622
|
async function publishTweet(token, payload, query, isDirectMessage, logger) {
|
|
468
623
|
try {
|
|
469
624
|
let nativeResponse = await postRequest({
|
|
@@ -498,12 +653,50 @@ async function publishTweet(token, payload, query, isDirectMessage, logger) {
|
|
|
498
653
|
loggerDebug(logger, `Twitter the data response is`, {
|
|
499
654
|
response: JSON.stringify(response)
|
|
500
655
|
});
|
|
501
|
-
|
|
656
|
+
|
|
657
|
+
// Normalize the response to ensure backward compatibility with v1.1 structure
|
|
658
|
+
// Pass full nativeResponse to extract author_id from expansions if needed
|
|
659
|
+
const normalizedResponse = normalizeTwitterResponse(response, 'tweet', logger, nativeResponse.data // Full response with includes.users for author_id
|
|
660
|
+
);
|
|
661
|
+
return normalizedResponse;
|
|
502
662
|
} catch (err) {
|
|
503
663
|
loggerError(logger, `Twitter publish exception details`, err);
|
|
504
664
|
throw err;
|
|
505
665
|
}
|
|
506
666
|
}
|
|
667
|
+
async function publishDirectMessage(token, payload, query, logger) {
|
|
668
|
+
try {
|
|
669
|
+
let nativeResponse = await postRequest({
|
|
670
|
+
token,
|
|
671
|
+
uri: query,
|
|
672
|
+
payload,
|
|
673
|
+
convertPayloadToUri: false,
|
|
674
|
+
attachUrlPrefix: false
|
|
675
|
+
});
|
|
676
|
+
loggerInfo(logger, `finished sending DM via Twitter API v2`, {
|
|
677
|
+
data: JSON.stringify(nativeResponse.data)
|
|
678
|
+
});
|
|
679
|
+
const response = nativeResponse.resp ? {
|
|
680
|
+
statusCode: nativeResponse.resp.statusCode,
|
|
681
|
+
statusMessage: nativeResponse.resp.statusCode === 201 || nativeResponse.resp.statusCode === 200 ? 'Success' : 'Unknown Failure',
|
|
682
|
+
data: nativeResponse.data.data
|
|
683
|
+
} : {
|
|
684
|
+
statusCode: nativeResponse.statusCode,
|
|
685
|
+
statusMessage: nativeResponse.message
|
|
686
|
+
};
|
|
687
|
+
loggerDebug(logger, `Twitter DM response is`, {
|
|
688
|
+
response: JSON.stringify(response)
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// Normalize the response to ensure backward compatibility with v1.1 structure
|
|
692
|
+
// This is CRITICAL - v2 DM responses have dm_event_id instead of id
|
|
693
|
+
const normalizedResponse = normalizeTwitterResponse(response, 'dm', logger);
|
|
694
|
+
return normalizedResponse;
|
|
695
|
+
} catch (err) {
|
|
696
|
+
loggerError(logger, `Twitter DM exception details`, err);
|
|
697
|
+
throw err;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
507
700
|
async function postRequest(_ref2) {
|
|
508
701
|
let {
|
|
509
702
|
token,
|
|
@@ -662,32 +855,177 @@ function generateFilePath(mimeType, dirname) {
|
|
|
662
855
|
}
|
|
663
856
|
// local
|
|
664
857
|
async function uploadMedia(attachment, token, discussionType, logger) {
|
|
665
|
-
|
|
666
|
-
|
|
858
|
+
let filePath;
|
|
859
|
+
try {
|
|
860
|
+
filePath = generateFilePath(attachment.mimeType, attachment.__dirname);
|
|
667
861
|
await downloadImage(attachment.url, filePath);
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
862
|
+
const mediaCategory = discussionType === 'dm' ? 'dm_image' : 'tweet_image';
|
|
863
|
+
const fileStats = fs.statSync(filePath);
|
|
864
|
+
const fileSize = fileStats.size;
|
|
865
|
+
const fileData = fs.readFileSync(filePath);
|
|
866
|
+
|
|
867
|
+
// For small files, use simple upload
|
|
868
|
+
if (fileSize < 5000000) {
|
|
869
|
+
// 5MB
|
|
870
|
+
const mediaIdString = await simpleMediaUpload(fileData, attachment.mimeType, token, logger);
|
|
871
|
+
removeMedia(filePath, logger);
|
|
872
|
+
return mediaIdString;
|
|
873
|
+
} else {
|
|
874
|
+
// For large files, use chunked upload
|
|
875
|
+
const mediaIdString = await chunkedMediaUpload(filePath, fileSize, attachment.mimeType, mediaCategory, token, logger);
|
|
876
|
+
removeMedia(filePath, logger);
|
|
877
|
+
return mediaIdString;
|
|
878
|
+
}
|
|
879
|
+
} catch (e) {
|
|
880
|
+
loggerError(logger, `Failed uploading media`, e);
|
|
881
|
+
if (filePath) {
|
|
882
|
+
removeMedia(filePath, logger);
|
|
883
|
+
}
|
|
884
|
+
throw e;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Simple upload for small media files
|
|
889
|
+
async function simpleMediaUpload(fileData, mimeType, token, logger) {
|
|
890
|
+
try {
|
|
891
|
+
const base64Data = fileData.toString('base64');
|
|
892
|
+
const mediaType = mimeType.split('/')[0]; // 'image', 'video', etc.
|
|
893
|
+
|
|
894
|
+
const url = 'https://upload.twitter.com/1.1/media/upload.json';
|
|
895
|
+
const {
|
|
896
|
+
Authorization
|
|
897
|
+
} = token.oauth.toHeader(token.oauth.authorize({
|
|
898
|
+
url,
|
|
899
|
+
method: 'post',
|
|
900
|
+
data: {}
|
|
901
|
+
}, {
|
|
902
|
+
key: token.token,
|
|
903
|
+
secret: token.tokenSecret
|
|
904
|
+
}));
|
|
905
|
+
const result = await superagent.post(url).set('Authorization', Authorization).send({
|
|
906
|
+
media_data: base64Data,
|
|
907
|
+
media_type: mimeType
|
|
673
908
|
});
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
909
|
+
loggerDebug(logger, 'Media uploaded successfully (simple)', {
|
|
910
|
+
media_id: result.body.media_id_string
|
|
911
|
+
});
|
|
912
|
+
return result.body.media_id_string;
|
|
913
|
+
} catch (e) {
|
|
914
|
+
loggerError(logger, 'Error in simple media upload', e);
|
|
915
|
+
throw e;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Chunked upload for large media files
|
|
920
|
+
async function chunkedMediaUpload(filePath, fileSize, mimeType, mediaCategory, token, logger) {
|
|
921
|
+
try {
|
|
922
|
+
const mediaType = mimeType.split('/')[0];
|
|
923
|
+
|
|
924
|
+
// Step 1: INIT
|
|
925
|
+
const initResponse = await mediaUploadRequest({
|
|
926
|
+
command: 'INIT',
|
|
927
|
+
total_bytes: fileSize,
|
|
928
|
+
media_type: mimeType,
|
|
929
|
+
media_category: mediaCategory
|
|
930
|
+
}, token, logger);
|
|
931
|
+
const mediaId = initResponse.media_id_string;
|
|
932
|
+
loggerDebug(logger, 'Media upload INIT successful', {
|
|
933
|
+
mediaId
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
// Step 2: APPEND (upload in chunks)
|
|
937
|
+
const chunkSize = 5 * 1024 * 1024; // 5MB chunks
|
|
938
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
939
|
+
let segmentIndex = 0;
|
|
940
|
+
for (let offset = 0; offset < fileSize; offset += chunkSize) {
|
|
941
|
+
const chunk = fileBuffer.slice(offset, Math.min(offset + chunkSize, fileSize));
|
|
942
|
+
const base64Chunk = chunk.toString('base64');
|
|
943
|
+
await mediaUploadRequest({
|
|
944
|
+
command: 'APPEND',
|
|
945
|
+
media_id: mediaId,
|
|
946
|
+
media_data: base64Chunk,
|
|
947
|
+
segment_index: segmentIndex
|
|
948
|
+
}, token, logger);
|
|
949
|
+
loggerDebug(logger, `Media chunk ${segmentIndex} uploaded`, {
|
|
950
|
+
mediaId,
|
|
951
|
+
segmentIndex
|
|
684
952
|
});
|
|
685
|
-
|
|
686
|
-
loggerError(logger, `Failed posting media`, e);
|
|
687
|
-
// this is just a safety precaution
|
|
688
|
-
removeMedia(filePath, logger);
|
|
953
|
+
segmentIndex++;
|
|
689
954
|
}
|
|
690
|
-
|
|
955
|
+
|
|
956
|
+
// Step 3: FINALIZE
|
|
957
|
+
const finalizeResponse = await mediaUploadRequest({
|
|
958
|
+
command: 'FINALIZE',
|
|
959
|
+
media_id: mediaId
|
|
960
|
+
}, token, logger);
|
|
961
|
+
loggerDebug(logger, 'Media upload FINALIZE successful', {
|
|
962
|
+
mediaId
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// Step 4: Check processing status if needed
|
|
966
|
+
if (finalizeResponse.processing_info) {
|
|
967
|
+
await waitForMediaProcessing(mediaId, token, logger);
|
|
968
|
+
}
|
|
969
|
+
return mediaId;
|
|
970
|
+
} catch (e) {
|
|
971
|
+
loggerError(logger, 'Error in chunked media upload', e);
|
|
972
|
+
throw e;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Make a media upload request
|
|
977
|
+
async function mediaUploadRequest(params, token, logger) {
|
|
978
|
+
const url = 'https://upload.twitter.com/1.1/media/upload.json';
|
|
979
|
+
const {
|
|
980
|
+
Authorization
|
|
981
|
+
} = token.oauth.toHeader(token.oauth.authorize({
|
|
982
|
+
url,
|
|
983
|
+
method: 'post',
|
|
984
|
+
data: {}
|
|
985
|
+
}, {
|
|
986
|
+
key: token.token,
|
|
987
|
+
secret: token.tokenSecret
|
|
988
|
+
}));
|
|
989
|
+
try {
|
|
990
|
+
const result = await superagent.post(url).set('Authorization', Authorization).send(params);
|
|
991
|
+
return result.body;
|
|
992
|
+
} catch (e) {
|
|
993
|
+
loggerError(logger, `Media upload request failed for command: ${params.command}`, e);
|
|
994
|
+
throw e;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Wait for media processing to complete
|
|
999
|
+
async function waitForMediaProcessing(mediaId, token, logger) {
|
|
1000
|
+
let maxAttempts = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 20;
|
|
1001
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1002
|
+
const statusResponse = await mediaUploadRequest({
|
|
1003
|
+
command: 'STATUS',
|
|
1004
|
+
media_id: mediaId
|
|
1005
|
+
}, token, logger);
|
|
1006
|
+
if (!statusResponse.processing_info) {
|
|
1007
|
+
return; // Processing complete
|
|
1008
|
+
}
|
|
1009
|
+
const state = statusResponse.processing_info.state;
|
|
1010
|
+
if (state === 'succeeded') {
|
|
1011
|
+
loggerDebug(logger, 'Media processing succeeded', {
|
|
1012
|
+
mediaId
|
|
1013
|
+
});
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (state === 'failed') {
|
|
1017
|
+
throw new Error(`Media processing failed: ${statusResponse.processing_info.error?.message || 'Unknown error'}`);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Still processing, wait before checking again
|
|
1021
|
+
const waitTime = statusResponse.processing_info.check_after_secs || 1;
|
|
1022
|
+
loggerDebug(logger, `Media still processing, waiting ${waitTime}s`, {
|
|
1023
|
+
mediaId,
|
|
1024
|
+
state
|
|
1025
|
+
});
|
|
1026
|
+
await new Promise(resolve => setTimeout(resolve, waitTime * 1000));
|
|
1027
|
+
}
|
|
1028
|
+
throw new Error('Media processing timeout');
|
|
691
1029
|
}
|
|
692
1030
|
function removeMedia(file, logger) {
|
|
693
1031
|
try {
|
|
@@ -699,5 +1037,4 @@ function removeMedia(file, logger) {
|
|
|
699
1037
|
} catch (e) {
|
|
700
1038
|
loggerError(logger, `failed trying to remove media ${file} it may have already been removed`);
|
|
701
1039
|
}
|
|
702
|
-
}
|
|
703
|
-
;
|
|
1040
|
+
}
|