@meltwater/conversations-api-services 1.2.1 → 1.3.1

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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Repository to contain all conversations api services shared across our services.
4
4
 
5
- ## Maintainers
5
+ ## Maintainers.
6
6
 
7
7
  > Maintained by Phoenix (phoenix@meltwater.com).
8
8
  > Contact us via #eng-team-phoenix in Slack.
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.addOAuthToToken = addOAuthToToken;
7
+ exports.createTweet = createTweet;
7
8
  exports.followUser = followUser;
8
9
  exports.getAuthenticatedUser = getAuthenticatedUser;
9
10
  exports.getBrandUserRelationship = getBrandUserRelationship;
@@ -505,6 +506,23 @@ async function getBrandUserRelationship(token, profileId, brandProfileId, logger
505
506
  (0, _loggerHelpers.loggerDebug)(logger, `Error in getting user brand friendship info: ${profileId} and ${brandProfileId}`, error);
506
507
  }
507
508
  }
509
+ async function createTweet(token, text, attachment, logger) {
510
+ let mediaId;
511
+ if (attachment) {
512
+ // Upload media for regular tweets (not DM, so use 'og' but will map to tweet_image/video/gif)
513
+ mediaId = await uploadMedia(attachment, token, 'og', logger);
514
+ }
515
+ const payload = {
516
+ text,
517
+ ...(attachment && {
518
+ media: {
519
+ media_ids: [mediaId]
520
+ }
521
+ })
522
+ };
523
+ let query = 'https://api.twitter.com/2/tweets';
524
+ return publishTweet(token, payload, query, false, logger);
525
+ }
508
526
  async function reply(token, text, attachment, inReplyToId, removeInReplyToId, logger) {
509
527
  let mediaId;
510
528
  if (attachment) {
@@ -535,8 +553,16 @@ async function privateMessage(token, text, attachment, profileId, logger) {
535
553
  let query = `https://api.x.com/2/dm_conversations/with/${(0, _externalIdHelpers.removePrefix)(profileId)}/messages`;
536
554
  let mediaId;
537
555
  if (attachment) {
556
+ (0, _loggerHelpers.loggerDebug)(logger, 'Uploading media for DM', {
557
+ attachmentUrl: attachment.url,
558
+ attachmentMimeType: attachment.mimeType
559
+ });
538
560
  // discussionType only matters if DM or Not
539
561
  mediaId = await uploadMedia(attachment, token, 'dm', logger);
562
+ (0, _loggerHelpers.loggerInfo)(logger, 'Media uploaded for DM', {
563
+ mediaId,
564
+ mediaIdType: typeof mediaId
565
+ });
540
566
  }
541
567
  const payload = {
542
568
  text: text || ' ',
@@ -546,6 +572,11 @@ async function privateMessage(token, text, attachment, profileId, logger) {
546
572
  }]
547
573
  })
548
574
  };
575
+ (0, _loggerHelpers.loggerDebug)(logger, 'Sending DM with payload', {
576
+ query,
577
+ payload: JSON.stringify(payload),
578
+ hasAttachment: !!attachment
579
+ });
549
580
  return publishDirectMessage(token, payload, query, logger);
550
581
  }
551
582
  async function retweetWithComment(token, text, attachment, logger) {
@@ -565,6 +596,84 @@ async function retweetWithComment(token, text, attachment, logger) {
565
596
  let query = 'https://api.twitter.com/2/tweets';
566
597
  return publishTweet(token, payload, query, false, logger);
567
598
  }
599
+
600
+ /**
601
+ * Normalizes Twitter API responses to ensure consistent structure across v1.1 and v2
602
+ * This is critical for backward compatibility - consumers expect certain fields to exist
603
+ *
604
+ * @param {Object} response - The response object to normalize
605
+ * @param {string} responseType - Type of response: 'dm' for direct messages, 'tweet' for tweets
606
+ * @param {Object} logger - Logger instance for debugging
607
+ * @param {Object} fullResponse - Full API response including includes (for extracting author_id from expansions)
608
+ * @returns {Object} Normalized response with consistent field structure
609
+ */
610
+ function normalizeTwitterResponse(response, responseType, logger) {
611
+ let fullResponse = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null;
612
+ if (!response || !response.data) {
613
+ return response;
614
+ }
615
+ (0, _loggerHelpers.loggerDebug)(logger, `Normalizing Twitter API response (${responseType})`, {
616
+ beforeNormalization: JSON.stringify(response.data)
617
+ });
618
+
619
+ // Handle Twitter API v2 Direct Message responses
620
+ // v2 DM structure: { dm_event_id, dm_conversation_id }
621
+ // v1.1 expected structure: { id, author_id }
622
+ if (responseType === 'dm' && response.data.dm_event_id) {
623
+ const normalizedData = {
624
+ ...response.data,
625
+ // Add v1.1-compatible fields for backward compatibility
626
+ id: response.data.dm_event_id,
627
+ // Note: author_id is intentionally not set here. The caller (twitterApi.client.js)
628
+ // will set the correct author_id from the original document since dm_conversation_id
629
+ // doesn't reliably indicate the sender (it's just "user1-user2" format)
630
+ author_id: null
631
+ };
632
+ (0, _loggerHelpers.loggerInfo)(logger, `Normalized Twitter API v2 DM response to v1.1 format`, {
633
+ dm_event_id: response.data.dm_event_id,
634
+ normalized_id: normalizedData.id,
635
+ dm_conversation_id: response.data.dm_conversation_id,
636
+ normalized_author_id: normalizedData.author_id
637
+ });
638
+ return {
639
+ ...response,
640
+ data: normalizedData
641
+ };
642
+ }
643
+
644
+ // Handle Twitter API v2 Tweet responses
645
+ // v2 has 'id' field but author_id is in expansions (includes.users)
646
+ if (responseType === 'tweet' && response.data.id) {
647
+ const normalizedData = {
648
+ ...response.data
649
+ };
650
+
651
+ // If author_id is missing but we have includes.users from expansions
652
+ if (!normalizedData.author_id && fullResponse?.includes?.users?.[0]?.id) {
653
+ normalizedData.author_id = fullResponse.includes.users[0].id;
654
+ (0, _loggerHelpers.loggerInfo)(logger, `Added author_id to tweet response from expansions`, {
655
+ tweet_id: normalizedData.id,
656
+ author_id: normalizedData.author_id
657
+ });
658
+ } else if (normalizedData.author_id) {
659
+ (0, _loggerHelpers.loggerDebug)(logger, `Twitter v2 tweet response already has 'author_id'`, {
660
+ id: response.data.id,
661
+ author_id: normalizedData.author_id
662
+ });
663
+ } else {
664
+ (0, _loggerHelpers.loggerWarn)(logger, `Twitter v2 tweet response missing author_id and no expansions available`, {
665
+ id: response.data.id,
666
+ hasIncludes: !!fullResponse?.includes,
667
+ hasUsers: !!fullResponse?.includes?.users
668
+ });
669
+ }
670
+ return {
671
+ ...response,
672
+ data: normalizedData
673
+ };
674
+ }
675
+ return response;
676
+ }
568
677
  async function publishTweet(token, payload, query, isDirectMessage, logger) {
569
678
  try {
570
679
  let nativeResponse = await postRequest({
@@ -599,7 +708,12 @@ async function publishTweet(token, payload, query, isDirectMessage, logger) {
599
708
  (0, _loggerHelpers.loggerDebug)(logger, `Twitter the data response is`, {
600
709
  response: JSON.stringify(response)
601
710
  });
602
- return response;
711
+
712
+ // Normalize the response to ensure backward compatibility with v1.1 structure
713
+ // Pass full nativeResponse to extract author_id from expansions if needed
714
+ const normalizedResponse = normalizeTwitterResponse(response, 'tweet', logger, nativeResponse.data // Full response with includes.users for author_id
715
+ );
716
+ return normalizedResponse;
603
717
  } catch (err) {
604
718
  (0, _loggerHelpers.loggerError)(logger, `Twitter publish exception details`, err);
605
719
  throw err;
@@ -628,9 +742,13 @@ async function publishDirectMessage(token, payload, query, logger) {
628
742
  (0, _loggerHelpers.loggerDebug)(logger, `Twitter DM response is`, {
629
743
  response: JSON.stringify(response)
630
744
  });
631
- return response;
745
+
746
+ // Normalize the response to ensure backward compatibility with v1.1 structure
747
+ // This is CRITICAL - v2 DM responses have dm_event_id instead of id
748
+ const normalizedResponse = normalizeTwitterResponse(response, 'dm', logger);
749
+ return normalizedResponse;
632
750
  } catch (err) {
633
- (0, _loggerHelpers.loggerError)(logger, `Twitter DM exception details`, err);
751
+ (0, _loggerHelpers.loggerError)(logger, `Twitter DM send failed`, err);
634
752
  throw err;
635
753
  }
636
754
  }
@@ -796,7 +914,17 @@ async function uploadMedia(attachment, token, discussionType, logger) {
796
914
  try {
797
915
  filePath = generateFilePath(attachment.mimeType, attachment.__dirname);
798
916
  await downloadImage(attachment.url, filePath);
799
- const mediaCategory = discussionType === 'dm' ? 'dm_image' : 'tweet_image';
917
+
918
+ // Determine media category based on mime type and discussion type
919
+ const mimeType = attachment.mimeType.toLowerCase();
920
+ let mediaCategory;
921
+ if (mimeType.startsWith('video/')) {
922
+ mediaCategory = discussionType === 'dm' ? 'dm_video' : 'tweet_video';
923
+ } else if (mimeType === 'image/gif') {
924
+ mediaCategory = discussionType === 'dm' ? 'dm_gif' : 'tweet_gif';
925
+ } else {
926
+ mediaCategory = discussionType === 'dm' ? 'dm_image' : 'tweet_image';
927
+ }
800
928
  const fileStats = _fs.default.statSync(filePath);
801
929
  const fileSize = fileStats.size;
802
930
  const fileData = _fs.default.readFileSync(filePath);
@@ -804,7 +932,7 @@ async function uploadMedia(attachment, token, discussionType, logger) {
804
932
  // For small files, use simple upload
805
933
  if (fileSize < 5000000) {
806
934
  // 5MB
807
- const mediaIdString = await simpleMediaUpload(fileData, attachment.mimeType, token, logger);
935
+ const mediaIdString = await simpleMediaUpload(fileData, attachment.mimeType, mediaCategory, token, logger);
808
936
  removeMedia(filePath, logger);
809
937
  return mediaIdString;
810
938
  } else {
@@ -823,12 +951,14 @@ async function uploadMedia(attachment, token, discussionType, logger) {
823
951
  }
824
952
 
825
953
  // Simple upload for small media files
826
- async function simpleMediaUpload(fileData, mimeType, token, logger) {
954
+ async function simpleMediaUpload(fileData, mimeType, mediaCategory, token, logger) {
827
955
  try {
828
956
  const base64Data = fileData.toString('base64');
829
957
  const mediaType = mimeType.split('/')[0]; // 'image', 'video', etc.
830
958
 
831
959
  const url = 'https://upload.twitter.com/1.1/media/upload.json';
960
+
961
+ // Original working code: empty OAuth signature, let superagent handle content-type
832
962
  const {
833
963
  Authorization
834
964
  } = token.oauth.toHeader(token.oauth.authorize({
@@ -839,10 +969,9 @@ async function simpleMediaUpload(fileData, mimeType, token, logger) {
839
969
  key: token.token,
840
970
  secret: token.tokenSecret
841
971
  }));
842
- const result = await _superagent.default.post(url).set('Authorization', Authorization).send({
843
- media_data: base64Data,
844
- media_type: mimeType
845
- });
972
+
973
+ // Use .field() for multipart/form-data (OAuth ignores multipart body)
974
+ const result = await _superagent.default.post(url).set('Authorization', Authorization).field('media_data', base64Data).field('media_type', mimeType);
846
975
  (0, _loggerHelpers.loggerDebug)(logger, 'Media uploaded successfully (simple)', {
847
976
  media_id: result.body.media_id_string
848
977
  });
@@ -913,18 +1042,47 @@ async function chunkedMediaUpload(filePath, fileSize, mimeType, mediaCategory, t
913
1042
  // Make a media upload request
914
1043
  async function mediaUploadRequest(params, token, logger) {
915
1044
  const url = 'https://upload.twitter.com/1.1/media/upload.json';
1045
+
1046
+ // Separate query params from body params
1047
+ // Query params (everything except media_data) must be in OAuth signature
1048
+ const queryParams = {};
1049
+ const bodyParams = {};
1050
+ for (const key in params) {
1051
+ if (key === 'media_data') {
1052
+ // media_data goes in POST body only
1053
+ bodyParams[key] = params[key];
1054
+ } else {
1055
+ // All other params go in query string and OAuth signature
1056
+ queryParams[key] = String(params[key]);
1057
+ }
1058
+ }
1059
+
1060
+ // Build full URL with query params
1061
+ const queryString = Object.keys(queryParams).map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`).join('&');
1062
+ const fullUrl = queryString ? `${url}?${queryString}` : url;
1063
+
1064
+ // OAuth signature: base URL + query params in data field
916
1065
  const {
917
1066
  Authorization
918
1067
  } = token.oauth.toHeader(token.oauth.authorize({
919
- url,
1068
+ url: url,
1069
+ // Base URL only
920
1070
  method: 'post',
921
- data: {}
1071
+ data: queryParams // Query params go here for signature
922
1072
  }, {
923
1073
  key: token.token,
924
1074
  secret: token.tokenSecret
925
1075
  }));
926
1076
  try {
927
- const result = await _superagent.default.post(url).set('Authorization', Authorization).send(params);
1077
+ // Don't set content-type, let superagent handle it
1078
+ // For commands with media_data, it will use application/x-www-form-urlencoded
1079
+ const request = _superagent.default.post(fullUrl).set('Authorization', Authorization);
1080
+
1081
+ // Only set type if we have body params
1082
+ if (Object.keys(bodyParams).length > 0) {
1083
+ request.type('form').send(bodyParams);
1084
+ }
1085
+ const result = await request;
928
1086
  return result.body;
929
1087
  } catch (e) {
930
1088
  (0, _loggerHelpers.loggerError)(logger, `Media upload request failed for command: ${params.command}`, e);
@@ -31,8 +31,7 @@ var TwitterNative = _interopRequireWildcard(require("./http/twitter.native.js"))
31
31
  var InstagramNative = _interopRequireWildcard(require("./http/instagram.native.js"));
32
32
  var ThreadsNative = _interopRequireWildcard(require("./http/threads.native.js"));
33
33
  var AssetManagerClient = _interopRequireWildcard(require("./http/assetManager.client.js"));
34
- function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
35
- function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
34
+ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
36
35
  const DocumentHelperFunctions = {
37
36
  ...messageHelpers,
38
37
  ...applicationTagFunctions,
@@ -481,6 +481,23 @@ export async function getBrandUserRelationship(token, profileId, brandProfileId,
481
481
  loggerDebug(logger, `Error in getting user brand friendship info: ${profileId} and ${brandProfileId}`, error);
482
482
  }
483
483
  }
484
+ export async function createTweet(token, text, attachment, logger) {
485
+ let mediaId;
486
+ if (attachment) {
487
+ // Upload media for regular tweets (not DM, so use 'og' but will map to tweet_image/video/gif)
488
+ mediaId = await uploadMedia(attachment, token, 'og', logger);
489
+ }
490
+ const payload = {
491
+ text,
492
+ ...(attachment && {
493
+ media: {
494
+ media_ids: [mediaId]
495
+ }
496
+ })
497
+ };
498
+ let query = 'https://api.twitter.com/2/tweets';
499
+ return publishTweet(token, payload, query, false, logger);
500
+ }
484
501
  export async function reply(token, text, attachment, inReplyToId, removeInReplyToId, logger) {
485
502
  let mediaId;
486
503
  if (attachment) {
@@ -511,8 +528,16 @@ export async function privateMessage(token, text, attachment, profileId, logger)
511
528
  let query = `https://api.x.com/2/dm_conversations/with/${removePrefix(profileId)}/messages`;
512
529
  let mediaId;
513
530
  if (attachment) {
531
+ loggerDebug(logger, 'Uploading media for DM', {
532
+ attachmentUrl: attachment.url,
533
+ attachmentMimeType: attachment.mimeType
534
+ });
514
535
  // discussionType only matters if DM or Not
515
536
  mediaId = await uploadMedia(attachment, token, 'dm', logger);
537
+ loggerInfo(logger, 'Media uploaded for DM', {
538
+ mediaId,
539
+ mediaIdType: typeof mediaId
540
+ });
516
541
  }
517
542
  const payload = {
518
543
  text: text || ' ',
@@ -522,6 +547,11 @@ export async function privateMessage(token, text, attachment, profileId, logger)
522
547
  }]
523
548
  })
524
549
  };
550
+ loggerDebug(logger, 'Sending DM with payload', {
551
+ query,
552
+ payload: JSON.stringify(payload),
553
+ hasAttachment: !!attachment
554
+ });
525
555
  return publishDirectMessage(token, payload, query, logger);
526
556
  }
527
557
  export async function retweetWithComment(token, text, attachment, logger) {
@@ -541,6 +571,84 @@ export async function retweetWithComment(token, text, attachment, logger) {
541
571
  let query = 'https://api.twitter.com/2/tweets';
542
572
  return publishTweet(token, payload, query, false, logger);
543
573
  }
574
+
575
+ /**
576
+ * Normalizes Twitter API responses to ensure consistent structure across v1.1 and v2
577
+ * This is critical for backward compatibility - consumers expect certain fields to exist
578
+ *
579
+ * @param {Object} response - The response object to normalize
580
+ * @param {string} responseType - Type of response: 'dm' for direct messages, 'tweet' for tweets
581
+ * @param {Object} logger - Logger instance for debugging
582
+ * @param {Object} fullResponse - Full API response including includes (for extracting author_id from expansions)
583
+ * @returns {Object} Normalized response with consistent field structure
584
+ */
585
+ function normalizeTwitterResponse(response, responseType, logger) {
586
+ let fullResponse = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null;
587
+ if (!response || !response.data) {
588
+ return response;
589
+ }
590
+ loggerDebug(logger, `Normalizing Twitter API response (${responseType})`, {
591
+ beforeNormalization: JSON.stringify(response.data)
592
+ });
593
+
594
+ // Handle Twitter API v2 Direct Message responses
595
+ // v2 DM structure: { dm_event_id, dm_conversation_id }
596
+ // v1.1 expected structure: { id, author_id }
597
+ if (responseType === 'dm' && response.data.dm_event_id) {
598
+ const normalizedData = {
599
+ ...response.data,
600
+ // Add v1.1-compatible fields for backward compatibility
601
+ id: response.data.dm_event_id,
602
+ // Note: author_id is intentionally not set here. The caller (twitterApi.client.js)
603
+ // will set the correct author_id from the original document since dm_conversation_id
604
+ // doesn't reliably indicate the sender (it's just "user1-user2" format)
605
+ author_id: null
606
+ };
607
+ loggerInfo(logger, `Normalized Twitter API v2 DM response to v1.1 format`, {
608
+ dm_event_id: response.data.dm_event_id,
609
+ normalized_id: normalizedData.id,
610
+ dm_conversation_id: response.data.dm_conversation_id,
611
+ normalized_author_id: normalizedData.author_id
612
+ });
613
+ return {
614
+ ...response,
615
+ data: normalizedData
616
+ };
617
+ }
618
+
619
+ // Handle Twitter API v2 Tweet responses
620
+ // v2 has 'id' field but author_id is in expansions (includes.users)
621
+ if (responseType === 'tweet' && response.data.id) {
622
+ const normalizedData = {
623
+ ...response.data
624
+ };
625
+
626
+ // If author_id is missing but we have includes.users from expansions
627
+ if (!normalizedData.author_id && fullResponse?.includes?.users?.[0]?.id) {
628
+ normalizedData.author_id = fullResponse.includes.users[0].id;
629
+ loggerInfo(logger, `Added author_id to tweet response from expansions`, {
630
+ tweet_id: normalizedData.id,
631
+ author_id: normalizedData.author_id
632
+ });
633
+ } else if (normalizedData.author_id) {
634
+ loggerDebug(logger, `Twitter v2 tweet response already has 'author_id'`, {
635
+ id: response.data.id,
636
+ author_id: normalizedData.author_id
637
+ });
638
+ } else {
639
+ loggerWarn(logger, `Twitter v2 tweet response missing author_id and no expansions available`, {
640
+ id: response.data.id,
641
+ hasIncludes: !!fullResponse?.includes,
642
+ hasUsers: !!fullResponse?.includes?.users
643
+ });
644
+ }
645
+ return {
646
+ ...response,
647
+ data: normalizedData
648
+ };
649
+ }
650
+ return response;
651
+ }
544
652
  async function publishTweet(token, payload, query, isDirectMessage, logger) {
545
653
  try {
546
654
  let nativeResponse = await postRequest({
@@ -575,7 +683,12 @@ async function publishTweet(token, payload, query, isDirectMessage, logger) {
575
683
  loggerDebug(logger, `Twitter the data response is`, {
576
684
  response: JSON.stringify(response)
577
685
  });
578
- return response;
686
+
687
+ // Normalize the response to ensure backward compatibility with v1.1 structure
688
+ // Pass full nativeResponse to extract author_id from expansions if needed
689
+ const normalizedResponse = normalizeTwitterResponse(response, 'tweet', logger, nativeResponse.data // Full response with includes.users for author_id
690
+ );
691
+ return normalizedResponse;
579
692
  } catch (err) {
580
693
  loggerError(logger, `Twitter publish exception details`, err);
581
694
  throw err;
@@ -604,9 +717,13 @@ async function publishDirectMessage(token, payload, query, logger) {
604
717
  loggerDebug(logger, `Twitter DM response is`, {
605
718
  response: JSON.stringify(response)
606
719
  });
607
- return response;
720
+
721
+ // Normalize the response to ensure backward compatibility with v1.1 structure
722
+ // This is CRITICAL - v2 DM responses have dm_event_id instead of id
723
+ const normalizedResponse = normalizeTwitterResponse(response, 'dm', logger);
724
+ return normalizedResponse;
608
725
  } catch (err) {
609
- loggerError(logger, `Twitter DM exception details`, err);
726
+ loggerError(logger, `Twitter DM send failed`, err);
610
727
  throw err;
611
728
  }
612
729
  }
@@ -772,7 +889,17 @@ async function uploadMedia(attachment, token, discussionType, logger) {
772
889
  try {
773
890
  filePath = generateFilePath(attachment.mimeType, attachment.__dirname);
774
891
  await downloadImage(attachment.url, filePath);
775
- const mediaCategory = discussionType === 'dm' ? 'dm_image' : 'tweet_image';
892
+
893
+ // Determine media category based on mime type and discussion type
894
+ const mimeType = attachment.mimeType.toLowerCase();
895
+ let mediaCategory;
896
+ if (mimeType.startsWith('video/')) {
897
+ mediaCategory = discussionType === 'dm' ? 'dm_video' : 'tweet_video';
898
+ } else if (mimeType === 'image/gif') {
899
+ mediaCategory = discussionType === 'dm' ? 'dm_gif' : 'tweet_gif';
900
+ } else {
901
+ mediaCategory = discussionType === 'dm' ? 'dm_image' : 'tweet_image';
902
+ }
776
903
  const fileStats = fs.statSync(filePath);
777
904
  const fileSize = fileStats.size;
778
905
  const fileData = fs.readFileSync(filePath);
@@ -780,7 +907,7 @@ async function uploadMedia(attachment, token, discussionType, logger) {
780
907
  // For small files, use simple upload
781
908
  if (fileSize < 5000000) {
782
909
  // 5MB
783
- const mediaIdString = await simpleMediaUpload(fileData, attachment.mimeType, token, logger);
910
+ const mediaIdString = await simpleMediaUpload(fileData, attachment.mimeType, mediaCategory, token, logger);
784
911
  removeMedia(filePath, logger);
785
912
  return mediaIdString;
786
913
  } else {
@@ -799,12 +926,14 @@ async function uploadMedia(attachment, token, discussionType, logger) {
799
926
  }
800
927
 
801
928
  // Simple upload for small media files
802
- async function simpleMediaUpload(fileData, mimeType, token, logger) {
929
+ async function simpleMediaUpload(fileData, mimeType, mediaCategory, token, logger) {
803
930
  try {
804
931
  const base64Data = fileData.toString('base64');
805
932
  const mediaType = mimeType.split('/')[0]; // 'image', 'video', etc.
806
933
 
807
934
  const url = 'https://upload.twitter.com/1.1/media/upload.json';
935
+
936
+ // Original working code: empty OAuth signature, let superagent handle content-type
808
937
  const {
809
938
  Authorization
810
939
  } = token.oauth.toHeader(token.oauth.authorize({
@@ -815,10 +944,9 @@ async function simpleMediaUpload(fileData, mimeType, token, logger) {
815
944
  key: token.token,
816
945
  secret: token.tokenSecret
817
946
  }));
818
- const result = await superagent.post(url).set('Authorization', Authorization).send({
819
- media_data: base64Data,
820
- media_type: mimeType
821
- });
947
+
948
+ // Use .field() for multipart/form-data (OAuth ignores multipart body)
949
+ const result = await superagent.post(url).set('Authorization', Authorization).field('media_data', base64Data).field('media_type', mimeType);
822
950
  loggerDebug(logger, 'Media uploaded successfully (simple)', {
823
951
  media_id: result.body.media_id_string
824
952
  });
@@ -889,18 +1017,47 @@ async function chunkedMediaUpload(filePath, fileSize, mimeType, mediaCategory, t
889
1017
  // Make a media upload request
890
1018
  async function mediaUploadRequest(params, token, logger) {
891
1019
  const url = 'https://upload.twitter.com/1.1/media/upload.json';
1020
+
1021
+ // Separate query params from body params
1022
+ // Query params (everything except media_data) must be in OAuth signature
1023
+ const queryParams = {};
1024
+ const bodyParams = {};
1025
+ for (const key in params) {
1026
+ if (key === 'media_data') {
1027
+ // media_data goes in POST body only
1028
+ bodyParams[key] = params[key];
1029
+ } else {
1030
+ // All other params go in query string and OAuth signature
1031
+ queryParams[key] = String(params[key]);
1032
+ }
1033
+ }
1034
+
1035
+ // Build full URL with query params
1036
+ const queryString = Object.keys(queryParams).map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`).join('&');
1037
+ const fullUrl = queryString ? `${url}?${queryString}` : url;
1038
+
1039
+ // OAuth signature: base URL + query params in data field
892
1040
  const {
893
1041
  Authorization
894
1042
  } = token.oauth.toHeader(token.oauth.authorize({
895
- url,
1043
+ url: url,
1044
+ // Base URL only
896
1045
  method: 'post',
897
- data: {}
1046
+ data: queryParams // Query params go here for signature
898
1047
  }, {
899
1048
  key: token.token,
900
1049
  secret: token.tokenSecret
901
1050
  }));
902
1051
  try {
903
- const result = await superagent.post(url).set('Authorization', Authorization).send(params);
1052
+ // Don't set content-type, let superagent handle it
1053
+ // For commands with media_data, it will use application/x-www-form-urlencoded
1054
+ const request = superagent.post(fullUrl).set('Authorization', Authorization);
1055
+
1056
+ // Only set type if we have body params
1057
+ if (Object.keys(bodyParams).length > 0) {
1058
+ request.type('form').send(bodyParams);
1059
+ }
1060
+ const result = await request;
904
1061
  return result.body;
905
1062
  } catch (e) {
906
1063
  loggerError(logger, `Media upload request failed for command: ${params.command}`, e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meltwater/conversations-api-services",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "Repository to contain all conversations api services shared across our services",
5
5
  "main": "dist/cjs/data-access/index.js",
6
6
  "module": "dist/esm/data-access/index.js",
@@ -28,10 +28,6 @@
28
28
  "keywords": [],
29
29
  "author": "",
30
30
  "license": "ISC",
31
- "publishConfig": {
32
- "registry": "https://registry.npmjs.org/",
33
- "access": "public"
34
- },
35
31
  "bugs": {
36
32
  "url": "https://github.com/meltwater/conversations-api-services/issues"
37
33
  },
@@ -609,6 +609,25 @@ export async function getBrandUserRelationship(
609
609
  }
610
610
  }
611
611
 
612
+ export async function createTweet(token, text, attachment, logger) {
613
+ let mediaId;
614
+ if (attachment) {
615
+ // Upload media for regular tweets (not DM, so use 'og' but will map to tweet_image/video/gif)
616
+ mediaId = await uploadMedia(attachment, token, 'og', logger);
617
+ }
618
+ const payload = {
619
+ text,
620
+ ...(attachment && {
621
+ media: {
622
+ media_ids: [mediaId],
623
+ },
624
+ }),
625
+ };
626
+
627
+ let query = 'https://api.twitter.com/2/tweets';
628
+ return publishTweet(token, payload, query, false, logger);
629
+ }
630
+
612
631
  export async function reply(
613
632
  token,
614
633
  text,
@@ -658,8 +677,16 @@ export async function privateMessage(
658
677
  )}/messages`;
659
678
  let mediaId;
660
679
  if (attachment) {
680
+ loggerDebug(logger, 'Uploading media for DM', {
681
+ attachmentUrl: attachment.url,
682
+ attachmentMimeType: attachment.mimeType,
683
+ });
661
684
  // discussionType only matters if DM or Not
662
685
  mediaId = await uploadMedia(attachment, token, 'dm', logger);
686
+ loggerInfo(logger, 'Media uploaded for DM', {
687
+ mediaId,
688
+ mediaIdType: typeof mediaId,
689
+ });
663
690
  }
664
691
  const payload = {
665
692
  text: text || ' ',
@@ -671,6 +698,11 @@ export async function privateMessage(
671
698
  ],
672
699
  }),
673
700
  };
701
+ loggerDebug(logger, 'Sending DM with payload', {
702
+ query,
703
+ payload: JSON.stringify(payload),
704
+ hasAttachment: !!attachment,
705
+ });
674
706
  return publishDirectMessage(token, payload, query, logger);
675
707
  }
676
708
 
@@ -693,6 +725,111 @@ export async function retweetWithComment(token, text, attachment, logger) {
693
725
  return publishTweet(token, payload, query, false, logger);
694
726
  }
695
727
 
728
+ /**
729
+ * Normalizes Twitter API responses to ensure consistent structure across v1.1 and v2
730
+ * This is critical for backward compatibility - consumers expect certain fields to exist
731
+ *
732
+ * @param {Object} response - The response object to normalize
733
+ * @param {string} responseType - Type of response: 'dm' for direct messages, 'tweet' for tweets
734
+ * @param {Object} logger - Logger instance for debugging
735
+ * @param {Object} fullResponse - Full API response including includes (for extracting author_id from expansions)
736
+ * @returns {Object} Normalized response with consistent field structure
737
+ */
738
+ function normalizeTwitterResponse(
739
+ response,
740
+ responseType,
741
+ logger,
742
+ fullResponse = null
743
+ ) {
744
+ if (!response || !response.data) {
745
+ return response;
746
+ }
747
+
748
+ loggerDebug(logger, `Normalizing Twitter API response (${responseType})`, {
749
+ beforeNormalization: JSON.stringify(response.data),
750
+ });
751
+
752
+ // Handle Twitter API v2 Direct Message responses
753
+ // v2 DM structure: { dm_event_id, dm_conversation_id }
754
+ // v1.1 expected structure: { id, author_id }
755
+ if (responseType === 'dm' && response.data.dm_event_id) {
756
+ const normalizedData = {
757
+ ...response.data,
758
+ // Add v1.1-compatible fields for backward compatibility
759
+ id: response.data.dm_event_id,
760
+ // Note: author_id is intentionally not set here. The caller (twitterApi.client.js)
761
+ // will set the correct author_id from the original document since dm_conversation_id
762
+ // doesn't reliably indicate the sender (it's just "user1-user2" format)
763
+ author_id: null,
764
+ };
765
+
766
+ loggerInfo(
767
+ logger,
768
+ `Normalized Twitter API v2 DM response to v1.1 format`,
769
+ {
770
+ dm_event_id: response.data.dm_event_id,
771
+ normalized_id: normalizedData.id,
772
+ dm_conversation_id: response.data.dm_conversation_id,
773
+ normalized_author_id: normalizedData.author_id,
774
+ }
775
+ );
776
+
777
+ return {
778
+ ...response,
779
+ data: normalizedData,
780
+ };
781
+ }
782
+
783
+ // Handle Twitter API v2 Tweet responses
784
+ // v2 has 'id' field but author_id is in expansions (includes.users)
785
+ if (responseType === 'tweet' && response.data.id) {
786
+ const normalizedData = { ...response.data };
787
+
788
+ // If author_id is missing but we have includes.users from expansions
789
+ if (
790
+ !normalizedData.author_id &&
791
+ fullResponse?.includes?.users?.[0]?.id
792
+ ) {
793
+ normalizedData.author_id = fullResponse.includes.users[0].id;
794
+
795
+ loggerInfo(
796
+ logger,
797
+ `Added author_id to tweet response from expansions`,
798
+ {
799
+ tweet_id: normalizedData.id,
800
+ author_id: normalizedData.author_id,
801
+ }
802
+ );
803
+ } else if (normalizedData.author_id) {
804
+ loggerDebug(
805
+ logger,
806
+ `Twitter v2 tweet response already has 'author_id'`,
807
+ {
808
+ id: response.data.id,
809
+ author_id: normalizedData.author_id,
810
+ }
811
+ );
812
+ } else {
813
+ loggerWarn(
814
+ logger,
815
+ `Twitter v2 tweet response missing author_id and no expansions available`,
816
+ {
817
+ id: response.data.id,
818
+ hasIncludes: !!fullResponse?.includes,
819
+ hasUsers: !!fullResponse?.includes?.users,
820
+ }
821
+ );
822
+ }
823
+
824
+ return {
825
+ ...response,
826
+ data: normalizedData,
827
+ };
828
+ }
829
+
830
+ return response;
831
+ }
832
+
696
833
  async function publishTweet(token, payload, query, isDirectMessage, logger) {
697
834
  try {
698
835
  let nativeResponse = await postRequest({
@@ -735,10 +872,21 @@ async function publishTweet(token, payload, query, isDirectMessage, logger) {
735
872
  statusCode: nativeResponse.statusCode,
736
873
  statusMessage: nativeResponse.message,
737
874
  };
875
+
738
876
  loggerDebug(logger, `Twitter the data response is`, {
739
877
  response: JSON.stringify(response),
740
878
  });
741
- return response;
879
+
880
+ // Normalize the response to ensure backward compatibility with v1.1 structure
881
+ // Pass full nativeResponse to extract author_id from expansions if needed
882
+ const normalizedResponse = normalizeTwitterResponse(
883
+ response,
884
+ 'tweet',
885
+ logger,
886
+ nativeResponse.data // Full response with includes.users for author_id
887
+ );
888
+
889
+ return normalizedResponse;
742
890
  } catch (err) {
743
891
  loggerError(logger, `Twitter publish exception details`, err);
744
892
  throw err;
@@ -773,12 +921,22 @@ async function publishDirectMessage(token, payload, query, logger) {
773
921
  statusCode: nativeResponse.statusCode,
774
922
  statusMessage: nativeResponse.message,
775
923
  };
924
+
776
925
  loggerDebug(logger, `Twitter DM response is`, {
777
926
  response: JSON.stringify(response),
778
927
  });
779
- return response;
928
+
929
+ // Normalize the response to ensure backward compatibility with v1.1 structure
930
+ // This is CRITICAL - v2 DM responses have dm_event_id instead of id
931
+ const normalizedResponse = normalizeTwitterResponse(
932
+ response,
933
+ 'dm',
934
+ logger
935
+ );
936
+
937
+ return normalizedResponse;
780
938
  } catch (err) {
781
- loggerError(logger, `Twitter DM exception details`, err);
939
+ loggerError(logger, `Twitter DM send failed`, err);
782
940
  throw err;
783
941
  }
784
942
  }
@@ -971,8 +1129,20 @@ async function uploadMedia(attachment, token, discussionType, logger) {
971
1129
  filePath = generateFilePath(attachment.mimeType, attachment.__dirname);
972
1130
  await downloadImage(attachment.url, filePath);
973
1131
 
974
- const mediaCategory =
975
- discussionType === 'dm' ? 'dm_image' : 'tweet_image';
1132
+ // Determine media category based on mime type and discussion type
1133
+ const mimeType = attachment.mimeType.toLowerCase();
1134
+ let mediaCategory;
1135
+
1136
+ if (mimeType.startsWith('video/')) {
1137
+ mediaCategory =
1138
+ discussionType === 'dm' ? 'dm_video' : 'tweet_video';
1139
+ } else if (mimeType === 'image/gif') {
1140
+ mediaCategory = discussionType === 'dm' ? 'dm_gif' : 'tweet_gif';
1141
+ } else {
1142
+ mediaCategory =
1143
+ discussionType === 'dm' ? 'dm_image' : 'tweet_image';
1144
+ }
1145
+
976
1146
  const fileStats = fs.statSync(filePath);
977
1147
  const fileSize = fileStats.size;
978
1148
  const fileData = fs.readFileSync(filePath);
@@ -983,6 +1153,7 @@ async function uploadMedia(attachment, token, discussionType, logger) {
983
1153
  const mediaIdString = await simpleMediaUpload(
984
1154
  fileData,
985
1155
  attachment.mimeType,
1156
+ mediaCategory,
986
1157
  token,
987
1158
  logger
988
1159
  );
@@ -1011,12 +1182,14 @@ async function uploadMedia(attachment, token, discussionType, logger) {
1011
1182
  }
1012
1183
 
1013
1184
  // Simple upload for small media files
1014
- async function simpleMediaUpload(fileData, mimeType, token, logger) {
1185
+ async function simpleMediaUpload(fileData, mimeType, mediaCategory, token, logger) {
1015
1186
  try {
1016
1187
  const base64Data = fileData.toString('base64');
1017
1188
  const mediaType = mimeType.split('/')[0]; // 'image', 'video', etc.
1018
1189
 
1019
1190
  const url = 'https://upload.twitter.com/1.1/media/upload.json';
1191
+
1192
+ // Original working code: empty OAuth signature, let superagent handle content-type
1020
1193
  const { Authorization } = token.oauth.toHeader(
1021
1194
  token.oauth.authorize(
1022
1195
  {
@@ -1031,13 +1204,12 @@ async function simpleMediaUpload(fileData, mimeType, token, logger) {
1031
1204
  )
1032
1205
  );
1033
1206
 
1207
+ // Use .field() for multipart/form-data (OAuth ignores multipart body)
1034
1208
  const result = await superagent
1035
1209
  .post(url)
1036
1210
  .set('Authorization', Authorization)
1037
- .send({
1038
- media_data: base64Data,
1039
- media_type: mimeType,
1040
- });
1211
+ .field('media_data', base64Data)
1212
+ .field('media_type', mimeType);
1041
1213
 
1042
1214
  loggerDebug(logger, 'Media uploaded successfully (simple)', {
1043
1215
  media_id: result.body.media_id_string,
@@ -1136,12 +1308,34 @@ async function chunkedMediaUpload(
1136
1308
  async function mediaUploadRequest(params, token, logger) {
1137
1309
  const url = 'https://upload.twitter.com/1.1/media/upload.json';
1138
1310
 
1311
+ // Separate query params from body params
1312
+ // Query params (everything except media_data) must be in OAuth signature
1313
+ const queryParams = {};
1314
+ const bodyParams = {};
1315
+
1316
+ for (const key in params) {
1317
+ if (key === 'media_data') {
1318
+ // media_data goes in POST body only
1319
+ bodyParams[key] = params[key];
1320
+ } else {
1321
+ // All other params go in query string and OAuth signature
1322
+ queryParams[key] = String(params[key]);
1323
+ }
1324
+ }
1325
+
1326
+ // Build full URL with query params
1327
+ const queryString = Object.keys(queryParams)
1328
+ .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`)
1329
+ .join('&');
1330
+ const fullUrl = queryString ? `${url}?${queryString}` : url;
1331
+
1332
+ // OAuth signature: base URL + query params in data field
1139
1333
  const { Authorization } = token.oauth.toHeader(
1140
1334
  token.oauth.authorize(
1141
1335
  {
1142
- url,
1336
+ url: url, // Base URL only
1143
1337
  method: 'post',
1144
- data: {},
1338
+ data: queryParams, // Query params go here for signature
1145
1339
  },
1146
1340
  {
1147
1341
  key: token.token,
@@ -1151,11 +1345,18 @@ async function mediaUploadRequest(params, token, logger) {
1151
1345
  );
1152
1346
 
1153
1347
  try {
1154
- const result = await superagent
1155
- .post(url)
1156
- .set('Authorization', Authorization)
1157
- .send(params);
1348
+ // Don't set content-type, let superagent handle it
1349
+ // For commands with media_data, it will use application/x-www-form-urlencoded
1350
+ const request = superagent
1351
+ .post(fullUrl)
1352
+ .set('Authorization', Authorization);
1353
+
1354
+ // Only set type if we have body params
1355
+ if (Object.keys(bodyParams).length > 0) {
1356
+ request.type('form').send(bodyParams);
1357
+ }
1158
1358
 
1359
+ const result = await request;
1159
1360
  return result.body;
1160
1361
  } catch (e) {
1161
1362
  loggerError(
@@ -1,22 +0,0 @@
1
- #!/bin/bash
2
-
3
- # Script to rebuild and re-link conversations-api-services for testing with engage-conversations-api
4
-
5
- set -e
6
-
7
- echo "🔨 Building conversations-api-services..."
8
- npm run build
9
-
10
- echo "🔗 Creating npm link..."
11
- npm link
12
-
13
- echo "🔗 Linking in engage-conversations-api..."
14
- cd ../engage-conversations-api
15
- npm link @meltwater/conversations-api-services
16
-
17
- echo "✅ Successfully linked conversations-api-services for testing!"
18
- echo ""
19
- echo "📋 To unlink when done testing:"
20
- echo " cd ../engage-conversations-api"
21
- echo " npm unlink @meltwater/conversations-api-services"
22
- echo " npm install"