@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 { loggerDebug, loggerError, loggerInfo, loggerWarn } from '../../lib/logger.helpers.js';
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: 'id,name,username,profile_image_url,public_metrics,description,created_at,verified,protected'
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 = { 'user.fields': TWITTER_API_CONFIG.v2.default_user_fields };
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(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
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(v2Endpoint, v1FallbackQuery, token, logger, requestOptions = {}) {
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(logger, `Twitter API v2 request failed for ${v2Endpoint}, falling back to v1.1`, e);
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(logger, `Falling back to v1.1 API for ${v2Endpoint}`);
54
- const fallbackResult = await (requestOptions.fallbackMethod === 'post' ? postRequest : getRequest)({
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 { success: true, data: fallbackResult.data, source: 'v1.1' };
85
+ return {
86
+ success: true,
87
+ data: fallbackResult.data,
88
+ source: 'v1.1',
89
+ };
60
90
  } catch (fallbackError) {
61
- loggerError(logger, `Both v2 and v1.1 APIs failed for ${v2Endpoint}`, fallbackError);
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
- export function addOAuthToToken(token, TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET) {
106
- if(!token.oauth){
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(logger,`No handles provided to twitterNative getUserInfoFromHandles`);
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(logger, `Invalid handleId provided to getUserInfoFromHandle: ${JSON.stringify(handleId)}`);
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 result.error || new Error('Failed to get user info from both APIs');
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(logger, `Error in getUserInfoFromHandle with handleId: ${JSON.stringify(handleId)}`, e);
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,`Error getting image`, e);
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 :sadness:
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,`Error in retweet user: ${sourceId}`, error);
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,`Error in unretweet user: ${sourceId}`, error, {
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: 'favorites/create.json?id=' + removePrefix(externalId),
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.favorited !== undefined &&
382
+ response.data.data?.liked !== undefined &&
299
383
  response.resp.statusCode === 200
300
384
  ) {
301
385
  return true;
302
386
  } else {
303
- loggerInfo(logger,'Twitter Error in like user statusCode non 200 ', {
304
- response: JSON.stringify(response),
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,`Twitter Error in like user: ${externalId}`, error);
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
- let response = await postRequest({
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: 'favorites/destroy.json?id=' + removePrefix(externalId),
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.favorited !== undefined &&
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,`Error in unlike user: ${externalId}`, error);
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: 'friendships/create.json',
440
+ uri: `https://api.x.com/2/users/${userId}/following`,
341
441
  payload: {
342
- user_id: removePrefix(profileId),
343
- follow: true,
442
+ target_user_id: removePrefix(profileId),
344
443
  },
345
- logger
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,`Error in following user: ${profileId}`, error);
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
- let response = await postRequest({
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: 'friendships/destroy.json',
363
- payload: {
364
- user_id: removePrefix(profileId),
365
- },
366
- logger
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,`Error in unfollowing user: ${profileId}`, error);
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: 'friendships/lookup.json',
495
+ uri: `https://api.x.com/2/users/${userId}/following`,
384
496
  payload: {
385
- user_id: removePrefix(profileId),
497
+ max_results: 1000, // Max allowed per request
386
498
  },
387
- logger
499
+ attachUrlPrefix: false,
500
+ convertPayloadToUri: true,
501
+ logger,
388
502
  });
389
503
 
390
- if (response.data.length > 0) {
391
- if (response.data[0].connections.length > 0) {
392
- for (
393
- var i = 0;
394
- i < response.data[0].connections.length;
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
- return false;
403
- } else {
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( logger,
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(token, profileId, brandProfileId, logger) {
536
+ export async function getBrandUserRelationship(
537
+ token,
538
+ profileId,
539
+ brandProfileId,
540
+ logger
541
+ ) {
423
542
  try {
424
- let { data: { relationship } = {} } = await getRequest({
425
- token,
426
- uri: `friendships/show.json?source_id=${brandProfileId}&target_id=${removePrefix(
427
- profileId
428
- )}`,
429
- logger,
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
- return relationship;
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(logger,
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(token, text, attachment, inReplyToId, removeInReplyToId, logger){
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(token, text, attachment, profileId,logger){
473
- let query = 'direct_messages/events/new.json';
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
- event: {
481
- type: 'message_create',
482
- message_create: {
483
- target: {
484
- recipient_id: removePrefix(profileId),
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 publishTweet(token,payload,query, true,logger);
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
- `finished fetching twitterAPI nativeResponse `,
549
- { data: JSON.stringify(nativeResponse.data) }
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
- loggerDebug(logger,`Twitter the data response is`, {
843
+
844
+ loggerDebug(logger, `Twitter the data response is`, {
568
845
  response: JSON.stringify(response),
569
846
  });
570
- return response;
571
- } catch (err) {
572
- loggerError(logger,
573
- `Twitter publish exception details`,
574
- err
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(logger,`Twitter API 503 error - Service Unavailable`,{
713
- url,
714
- convertPayloadToUri,
715
- payload: JSON.stringify(payload),
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,`Error in twitter doRequest`, e, {
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
- // local
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
- // local
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
- // local
1093
+ // local
761
1094
  async function uploadMedia(attachment, token, discussionType, logger) {
762
- return new Promise(async (resolve, reject) => {
763
- let filePath = generateFilePath(attachment.mimeType, attachment.__dirname);
1095
+ let filePath;
1096
+ try {
1097
+ filePath = generateFilePath(attachment.mimeType, attachment.__dirname);
764
1098
  await downloadImage(attachment.url, filePath);
765
- const T = new Twit({
766
- consumer_key: token.consumer_key,
767
- consumer_secret: token.consumer_secret,
768
- access_token: token.token,
769
- access_token_secret: token.tokenSecret,
770
- });
771
- let mediaCategory = discussionType === 'dm' ? 'dm' : 'tweet';
772
-
773
- try {
774
- T.postMediaChunked(
775
- { file_path: filePath },
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
- function (err, data, response) {
778
- if (err) {
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
- function removeMedia(file, logger){
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,`Failed removing ${file}`, err);
1349
+ loggerError(logger, `Failed removing ${file}`, err);
798
1350
  }
799
1351
  });
800
1352
  } catch (e) {
801
- loggerError(logger,
1353
+ loggerError(
1354
+ logger,
802
1355
  `failed trying to remove media ${file} it may have already been removed`
803
1356
  );
804
1357
  }
805
- };
1358
+ }