@webex/internal-plugin-conversation 2.59.2 → 2.59.3-next.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.
Files changed (48) hide show
  1. package/.eslintrc.js +6 -6
  2. package/README.md +47 -47
  3. package/babel.config.js +3 -3
  4. package/dist/activities.js +4 -4
  5. package/dist/activities.js.map +1 -1
  6. package/dist/activity-thread-ordering.js +34 -34
  7. package/dist/activity-thread-ordering.js.map +1 -1
  8. package/dist/config.js +12 -12
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.js.map +1 -1
  11. package/dist/conversation.js +474 -474
  12. package/dist/conversation.js.map +1 -1
  13. package/dist/convo-error.js +4 -4
  14. package/dist/convo-error.js.map +1 -1
  15. package/dist/decryption-transforms.js +155 -155
  16. package/dist/decryption-transforms.js.map +1 -1
  17. package/dist/encryption-transforms.js.map +1 -1
  18. package/dist/index.js +2 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/share-activity.js +57 -57
  21. package/dist/share-activity.js.map +1 -1
  22. package/dist/to-array.js +7 -7
  23. package/dist/to-array.js.map +1 -1
  24. package/jest.config.js +3 -3
  25. package/package.json +21 -20
  26. package/process +1 -1
  27. package/src/activities.js +157 -157
  28. package/src/activity-thread-ordering.js +283 -283
  29. package/src/activity-threading.md +282 -282
  30. package/src/config.js +37 -37
  31. package/src/constants.js +3 -3
  32. package/src/conversation.js +2535 -2535
  33. package/src/convo-error.js +15 -15
  34. package/src/decryption-transforms.js +541 -541
  35. package/src/encryption-transforms.js +345 -345
  36. package/src/index.js +327 -327
  37. package/src/share-activity.js +436 -436
  38. package/src/to-array.js +29 -29
  39. package/test/integration/spec/create.js +290 -290
  40. package/test/integration/spec/encryption.js +333 -333
  41. package/test/integration/spec/get.js +1255 -1255
  42. package/test/integration/spec/mercury.js +94 -94
  43. package/test/integration/spec/share.js +537 -537
  44. package/test/integration/spec/verbs.js +1041 -1041
  45. package/test/unit/spec/conversation.js +823 -823
  46. package/test/unit/spec/decrypt-transforms.js +460 -460
  47. package/test/unit/spec/encryption-transforms.js +93 -93
  48. package/test/unit/spec/share-activity.js +178 -178
@@ -1,2535 +1,2535 @@
1
- /*!
2
- * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
3
- */
4
-
5
- import querystring from 'querystring';
6
- import {EventEmitter} from 'events';
7
-
8
- import hmacSHA256 from 'crypto-js/hmac-sha256';
9
- import hex from 'crypto-js/enc-hex';
10
- import {proxyEvents, tap} from '@webex/common';
11
- import {Page, WebexPlugin} from '@webex/webex-core';
12
- import {
13
- cloneDeep,
14
- cloneDeepWith,
15
- defaults,
16
- isArray,
17
- isObject,
18
- isString,
19
- last,
20
- map,
21
- merge,
22
- omit,
23
- pick,
24
- uniq,
25
- } from 'lodash';
26
- import {readExifData} from '@webex/helper-image';
27
- import uuid from 'uuid';
28
-
29
- import {InvalidUserCreation} from './convo-error';
30
- import ShareActivity from './share-activity';
31
- import {
32
- minBatchSize,
33
- defaultMinDisplayableActivities,
34
- getLoopCounterFailsafe,
35
- batchSizeIncrementCount,
36
- getActivityObjectsFromMap,
37
- bookendManager,
38
- noMoreActivitiesManager,
39
- getQuery,
40
- rootActivityManager,
41
- activityManager,
42
- } from './activity-thread-ordering';
43
- import {
44
- ACTIVITY_TYPES,
45
- getActivityType,
46
- isDeleteActivity,
47
- getIsActivityOrphaned,
48
- determineActivityType,
49
- createRootActivity,
50
- createReplyActivity,
51
- createEditActivity,
52
- createReplyEditActivity,
53
- OLDER,
54
- MID,
55
- INITIAL,
56
- NEWER,
57
- getPublishedDate,
58
- sortActivitiesByPublishedDate,
59
- sanitizeActivity,
60
- } from './activities';
61
- import {ENCRYPTION_KEY_URL_MISMATCH, KEY_ALREADY_ROTATED, KEY_ROTATION_REQUIRED} from './constants';
62
-
63
- const idToUrl = new Map();
64
-
65
- const getConvoLimit = (options = {}) => {
66
- let limit;
67
-
68
- if (options.conversationsLimit) {
69
- limit = {
70
- value: options.conversationsLimit,
71
- name: 'conversationsLimit',
72
- };
73
- }
74
-
75
- return limit;
76
- };
77
-
78
- const Conversation = WebexPlugin.extend({
79
- namespace: 'Conversation',
80
-
81
- /**
82
- * @param {String} cluster the cluster containing the id
83
- * @param {UUID} [id] the id of the conversation.
84
- * If empty, just return the base URL.
85
- * @returns {String} url of the conversation
86
- */
87
- getUrlFromClusterId({cluster = 'us', id} = {}) {
88
- const url = this.webex.internal.services.getServiceUrlFromClusterId(
89
- {
90
- cluster,
91
- },
92
- this.webex
93
- );
94
-
95
- return id ? `${url}/conversations/${id}` : url;
96
- },
97
-
98
- /**
99
- * @param {Object} conversation
100
- * @param {Object} object
101
- * @param {Object} activity
102
- * @returns {Promise}
103
- */
104
- acknowledge(conversation, object, activity) {
105
- const url = this.getConvoUrl(conversation);
106
- const convoWithUrl = {...conversation, url};
107
-
108
- if (!isObject(object)) {
109
- return Promise.reject(new Error('`object` must be an object'));
110
- }
111
-
112
- return this.prepare(activity, {
113
- verb: 'acknowledge',
114
- target: this.prepareConversation(convoWithUrl),
115
- object: {
116
- objectType: 'activity',
117
- id: object.id,
118
- url: object.url,
119
- },
120
- }).then((a) => this.submit(a));
121
- },
122
-
123
- /**
124
- * Adds a participant to a conversation
125
- * @param {Object} conversation
126
- * @param {Object|string} participant
127
- * @param {Object} activity Reference to the activity that will eventually be
128
- * posted. Use this to (a) pass in e.g. clientTempId and (b) render a
129
- * provisional activity
130
- * @returns {Promise<Activity>}
131
- */
132
- add(conversation, participant, activity) {
133
- const url = this.getConvoUrl(conversation);
134
- const convoWithUrl = {...conversation, url};
135
-
136
- return this.webex.internal.user.asUUID(participant, {create: true}).then((id) =>
137
- this.prepare(activity, {
138
- verb: 'add',
139
- target: this.prepareConversation(convoWithUrl),
140
- object: {
141
- id,
142
- objectType: 'person',
143
- },
144
- kmsMessage: {
145
- method: 'create',
146
- uri: '/authorizations',
147
- resourceUri: '<KRO>',
148
- userIds: [id],
149
- },
150
- }).then((a) => this.submit(a))
151
- );
152
- },
153
-
154
- /**
155
- * Creates a conversation
156
- * @param {Object} params
157
- * @param {Array<Participant>} params.participants
158
- * @param {Array<File>} params.files
159
- * @param {string} params.comment
160
- * @param {string} params.html
161
- * @param {Object} params.displayName
162
- * @param {string} params.classificationId
163
- * @param {string} params.effectiveDate
164
- * @param {Boolean} params.isDefaultClassification
165
- * @param {Array<string>} params.tags
166
- * @param {Boolean} params.favorite
167
- * @param {Object} options
168
- * @param {Boolean} options.allowPartialCreation
169
- * @param {Boolean} options.forceGrouped
170
- * @param {Boolean} options.skipOneOnOneFetch skips checking 1:1 exists before creating conversation
171
- * @returns {Promise<Conversation>}
172
- */
173
- create(params, options = {}) {
174
- if (!params.participants || params.participants.length === 0) {
175
- return Promise.reject(new Error('`params.participants` is required'));
176
- }
177
-
178
- return Promise.all(
179
- params.participants.map((participant) =>
180
- this.webex.internal.user
181
- .asUUID(participant, {create: true})
182
- // eslint-disable-next-line arrow-body-style
183
- .catch((err) => {
184
- return options.allowPartialCreation ? undefined : Promise.reject(err);
185
- })
186
- )
187
- )
188
- .then((participants) => {
189
- participants.unshift(this.webex.internal.device.userId);
190
- participants = uniq(participants);
191
-
192
- const validParticipants = participants.filter((participant) => participant);
193
-
194
- params.participants = validParticipants;
195
-
196
- // check if original participants list was to create a 1:1
197
- if (participants.length === 2 && !(options && options.forceGrouped)) {
198
- if (!params.participants[1]) {
199
- return Promise.reject(new InvalidUserCreation());
200
- }
201
-
202
- if (options.skipOneOnOneFetch) {
203
- return this._createOneOnOne(params);
204
- }
205
-
206
- return this._maybeCreateOneOnOneThenPost(params, options);
207
- }
208
-
209
- return this._createGrouped(params, options);
210
- })
211
- .then((c) => {
212
- idToUrl.set(c.id, c.url);
213
-
214
- if (!params.files) {
215
- return c;
216
- }
217
-
218
- return this.webex.internal.conversation.share(c, params.files).then((a) => {
219
- c.activities.items.push(a);
220
-
221
- return c;
222
- });
223
- });
224
- },
225
-
226
- /**
227
- * @private
228
- * generate a deterministic HMAC for a reaction
229
- * @param {Object} displayName displayName of reaction we are sending
230
- * @param {Object} parent parent activity of reaction
231
- * @returns {Promise<HMAC>}
232
- */
233
- createReactionHmac(displayName, parent) {
234
- // not using webex.internal.encryption.getKey() because the JWK it returns does not have a 'k'
235
- // property. we need jwk.k to correctly generate the HMAC
236
-
237
- return this.webex.internal.encryption.unboundedStorage
238
- .get(parent.encryptionKeyUrl)
239
- .then((keyString) => {
240
- const key = JSON.parse(keyString);
241
- // when we stringify this object, keys must be in this order to generate same HMAC as
242
- // desktop clients
243
- const formatjwk = {k: key.jwk.k, kid: key.jwk.kid, kty: key.jwk.kty};
244
-
245
- const source = `${JSON.stringify(formatjwk)}${parent.id}${displayName}`;
246
-
247
- const hmac = hex.stringify(hmacSHA256(source, parent.id));
248
-
249
- return Promise.resolve(hmac);
250
- });
251
- },
252
-
253
- /**
254
- * @typedef {Object} ReactionPayload
255
- * @property {Object} actor
256
- * @property {string} actor.objectType
257
- * @property {string} actor.id
258
- * @property {string} objectType
259
- * @property {string} verb will be either add' or 'delete'
260
- * @property {Object} target
261
- * @property {string} target.id
262
- * @property {string} target.objectType
263
- * @property {Object} object this will change on delete vs. add
264
- * @property {string} object.id present in delete case
265
- * @property {string} object.objectType 'activity' in delete case, 'reaction2' in add case
266
- * @property {string} object.displayName must be 'celebrate', 'heart', 'thumbsup', 'smiley', 'haha', 'confused', 'sad'
267
- * @property {string} object.hmac
268
- */
269
-
270
- /**
271
- * @private
272
- * send add or delete reaction to convo service
273
- * @param {Object} conversation
274
- * The payload to send a reaction
275
- * @param {ReactionPayload} reactionPayload
276
- * @returns {Promise<Activity>}
277
- */
278
- sendReaction(conversation, reactionPayload) {
279
- const url = this.getConvoUrl(conversation);
280
- const convoWithUrl = {...conversation, url};
281
-
282
- if (!isObject(reactionPayload)) {
283
- return Promise.reject(new Error('`object` must be an object'));
284
- }
285
-
286
- return this.prepare(reactionPayload, {
287
- target: this.prepareConversation(convoWithUrl),
288
- object: pick(reactionPayload, 'id', 'url', 'objectType'),
289
- }).then((act) => this.submit(act));
290
- },
291
-
292
- /**
293
- * delete a reaction
294
- * @param {Object} conversation
295
- * @param {Object} reactionId
296
- * @returns {Promise<Activity>}
297
- */
298
- deleteReaction(conversation, reactionId) {
299
- const deleteReactionPayload = {
300
- actor: {objectType: 'person', id: this.webex.internal.device.userId},
301
- object: {
302
- id: reactionId,
303
- objectType: 'activity',
304
- },
305
- objectType: 'activity',
306
- target: {
307
- id: conversation.id,
308
- objectType: 'conversation',
309
- },
310
- verb: 'delete',
311
- };
312
-
313
- return this.sendReaction(conversation, deleteReactionPayload);
314
- },
315
-
316
- /**
317
- * create a reaction
318
- * @param {Object} conversation
319
- * @param {Object} displayName must be 'celebrate', 'heart', 'thumbsup', 'smiley', 'haha', 'confused', 'sad'
320
- * @param {Object} activity activity object from convo we are reacting to
321
- * @returns {Promise<Activity>}
322
- */
323
- addReaction(conversation, displayName, activity) {
324
- return this.createReactionHmac(displayName, activity).then((hmac) => {
325
- const addReactionPayload = {
326
- actor: {objectType: 'person', id: this.webex.internal.device.userId},
327
- target: {
328
- id: conversation.id,
329
- objectType: 'conversation',
330
- },
331
- verb: 'add',
332
- objectType: 'activity',
333
- parent: {
334
- type: 'reaction',
335
- id: activity.id,
336
- },
337
- object: {
338
- objectType: 'reaction2',
339
- displayName,
340
- hmac,
341
- },
342
- };
343
-
344
- return this.sendReaction(conversation, addReactionPayload);
345
- });
346
- },
347
-
348
- /**
349
- * delete content
350
- * @param {Object} conversation
351
- * @param {Object} object
352
- * @param {Object} activity
353
- * @returns {Promise}
354
- */
355
- delete(conversation, object, activity) {
356
- const url = this.getConvoUrl(conversation);
357
- const convoWithUrl = {...conversation, url};
358
-
359
- if (!isObject(object)) {
360
- return Promise.reject(new Error('`object` must be an object'));
361
- }
362
-
363
- const request = {
364
- verb: 'delete',
365
- target: this.prepareConversation(convoWithUrl),
366
- object: pick(object, 'id', 'url', 'objectType'),
367
- };
368
-
369
- // Deleting meeting container requires KMS message
370
- if (object.object.objectType === 'meetingContainer') {
371
- // It's building a string uri + "/authorizations?authId=" + id, where uri is the meeting container's KRO URL, and id is the conversation's KRO URL.
372
- request.target.kmsResourceObjectUrl = object.object.kmsResourceObjectUrl;
373
- request.kmsMessage = {
374
- method: 'delete',
375
- uri: `<KRO>/authorizations?${querystring.stringify({
376
- authId: convoWithUrl.kmsResourceObjectUrl,
377
- })}`,
378
- };
379
- }
380
-
381
- return this.prepare(activity, request).then((a) => this.submit(a));
382
- },
383
-
384
- /**
385
- * Downloads the file specified in item.scr or item.url
386
- * @param {Object} item
387
- * @param {Object} item.scr
388
- * @param {string} item.url
389
- * @param {Object} options
390
- * @param {Object} options.headers
391
- * @param {boolean} options.shouldNotAddExifData
392
- * @returns {Promise<File>}
393
- */
394
- download(item, options = {}) {
395
- const isEncrypted = Boolean(item.scr && item.scr.key);
396
- const shunt = new EventEmitter();
397
- let promise;
398
-
399
- if (isEncrypted) {
400
- promise = this.webex.internal.encryption.download(item.scr, item.options);
401
- } else if (item.scr && item.scr.loc) {
402
- promise = this._downloadUnencryptedFile(item.scr.loc, options);
403
- } else {
404
- promise = this._downloadUnencryptedFile(item.url, options);
405
- }
406
-
407
- promise = promise
408
- .on('progress', (...args) => shunt.emit('progress', ...args))
409
- .then((res) => {
410
- if (options.shouldNotAddExifData) {
411
- return res;
412
- }
413
-
414
- return readExifData(item, res);
415
- })
416
- .then((file) => {
417
- this.logger.info('conversation: file downloaded');
418
-
419
- if (item.displayName && !file.name) {
420
- file.name = item.displayName;
421
- }
422
-
423
- if (!file.type && item.mimeType) {
424
- file.type = item.mimeType;
425
- }
426
-
427
- return file;
428
- });
429
-
430
- proxyEvents(shunt, promise);
431
-
432
- return promise;
433
- },
434
-
435
- /**
436
- * Downloads an unencrypted file
437
- * @param {string} uri
438
- * @param {Object} options
439
- * @param {Object} options.headers
440
- * @returns {Promise<File>}
441
- */
442
- _downloadUnencryptedFile(uri, options = {}) {
443
- Object.assign(options, {
444
- uri,
445
- responseType: 'buffer',
446
- });
447
-
448
- const promise = this.request(options).then((res) => res.body);
449
-
450
- proxyEvents(options.download, promise);
451
-
452
- return promise;
453
- },
454
-
455
- /**
456
- * Helper method that expands a set of parameters into an activty object
457
- * @param {string} verb
458
- * @param {Object} object
459
- * @param {Object} target
460
- * @param {Object|string} actor
461
- * @returns {Object}
462
- */
463
- expand(verb, object, target, actor) {
464
- const activity = {
465
- actor,
466
- objectType: 'activity',
467
- verb,
468
- };
469
-
470
- if (!actor) {
471
- actor = this.webex.internal.device.userId;
472
- }
473
-
474
- if (isString(actor)) {
475
- activity.actor = {
476
- objectType: 'person',
477
- id: actor,
478
- };
479
- }
480
-
481
- if (object) {
482
- activity.object = object;
483
- }
484
-
485
- if (target) {
486
- activity.target = target;
487
- }
488
-
489
- return activity;
490
- },
491
-
492
- /**
493
- * Gets an array of activities with an array of activity URLS
494
- * @param {Array} activityUrls
495
- * @param {Object} options
496
- * @param {String} options.cluster cluster where the activities are located
497
- * @param {String} options.url base convo url where the activities are located
498
- * @returns {Promise<Object>} Resolves with the activities
499
- */
500
- bulkActivitiesFetch(activityUrls, options = {}) {
501
- let cluster;
502
- let url;
503
-
504
- if (typeof options === 'string') {
505
- cluster = options;
506
- } else {
507
- ({cluster, url} = options);
508
- }
509
-
510
- const resource = 'bulk_activities_fetch';
511
- const params = {
512
- method: 'POST',
513
- body: {
514
- activityUrls,
515
- },
516
- };
517
-
518
- if (url) {
519
- const uri = `${url}/${resource}`;
520
-
521
- Object.assign(params, {
522
- uri,
523
- });
524
- } else if (cluster) {
525
- const uri = `${this.getUrlFromClusterId({cluster})}/${resource}`;
526
-
527
- Object.assign(params, {
528
- uri,
529
- });
530
- } else {
531
- Object.assign(params, {
532
- api: 'conversation',
533
- resource,
534
- });
535
- }
536
-
537
- return this.webex.request(params).then((res) => {
538
- const activitiesArr = [];
539
-
540
- if (res.body.multistatus) {
541
- res.body.multistatus.forEach((statusData) => {
542
- if (statusData.status === '200' && statusData.data && statusData.data.activity) {
543
- activitiesArr.push(statusData.data.activity);
544
- }
545
- });
546
- }
547
-
548
- return activitiesArr;
549
- });
550
- },
551
-
552
- /**
553
- * Fetches a single conversation
554
- * @param {Object} conversation
555
- * @param {String} [conversation.url] The URL where the conversation is located.
556
- * @param {String|UUID} [conversation.user] The user to look up in the conversation service
557
- * If specified, the user lookup will take precedence over the url lookup
558
- * @param {Object} options
559
- * @returns {Promise<Conversation>}
560
- */
561
- get(conversation, options = {}) {
562
- const {user} = conversation;
563
- let uri;
564
-
565
- try {
566
- uri = !user ? this.getConvoUrl(conversation) : '';
567
- } catch (err) {
568
- return Promise.reject(Error(err));
569
- }
570
-
571
- const params = {
572
- qs: {
573
- uuidEntryFormat: true,
574
- personRefresh: true,
575
- activitiesLimit: 0,
576
- includeConvWithDeletedUserUUID: false,
577
- includeParticipants: false,
578
- ...omit(options, 'id', 'user', 'url'),
579
- },
580
- disableTransform: options.disableTransform,
581
- };
582
-
583
- // Default behavior is to set includeParticipants=false,
584
- // which makes the payload lighter by removing participant info.
585
- // If the caller explicitly sets the participantAckFilter or
586
- // participantsLimit, we don't want that default setting.
587
- if ('participantAckFilter' in options || 'participantsLimit' in options) {
588
- delete params.qs.includeParticipants;
589
- }
590
-
591
- return Promise.resolve(user ? this.webex.internal.user.asUUID(user) : null)
592
- .then((userId) => {
593
- if (userId) {
594
- Object.assign(params, {
595
- service: 'conversation',
596
- resource: `conversations/user/${userId}`,
597
- });
598
- } else {
599
- params.uri = uri;
600
- }
601
-
602
- return this.request(params);
603
- })
604
- .then(
605
- tap(({body}) => {
606
- const {id, url} = body;
607
-
608
- this._recordUUIDs(body);
609
- idToUrl.set(id, url);
610
- })
611
- )
612
- .then((res) => res.body);
613
- },
614
-
615
- /**
616
- * Leaves the conversation or removes the specified user from the specified
617
- * conversation
618
- * @param {Object} conversation
619
- * @param {Object|string} participant If not specified, defaults to current
620
- * user
621
- * @param {Object} activity Reference to the activity that will eventually be
622
- * posted. Use this to (a) pass in e.g. clientTempId and (b) render a
623
- * provisional activity
624
- * @returns {Promise<Activity>}
625
- */
626
- leave(conversation, participant, activity) {
627
- const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
628
-
629
- return Promise.resolve()
630
- .then(() => {
631
- if (!participant) {
632
- participant = this.webex.internal.device.userId;
633
- }
634
-
635
- return this.webex.internal.user.asUUID(participant).then((id) =>
636
- this.prepare(activity, {
637
- verb: 'leave',
638
- target: this.prepareConversation(convoWithUrl),
639
- object: {
640
- id,
641
- objectType: 'person',
642
- },
643
- kmsMessage: {
644
- method: 'delete',
645
- uri: `<KRO>/authorizations?${querystring.stringify({authId: id})}`,
646
- },
647
- })
648
- );
649
- })
650
- .then((a) => this.submit(a));
651
- },
652
-
653
- /**
654
- * Lists a set of conversations. By default does not fetch activities or
655
- * participants
656
- * @param {Object} options
657
- * @param {boolean} options.summary - when true, use conversationSummary resource
658
- * @param {Number} options.conversationsLimit - limit the number of conversations fetched
659
- * @param {boolean} options.deferDecrypt - when true, deferDecrypt tells the
660
- * payload transformer to normalize (but not decrypt) each received
661
- * conversation. Instead, the received conversations will each have a bound
662
- * decrypt method that can be executed at the consumer's leisure
663
- * @returns {Promise<Array<Conversation>>}
664
- */
665
- list(options = {}) {
666
- return this._list({
667
- service: 'conversation',
668
- resource: options.summary ? 'conversationsSummary' : 'conversations',
669
- qs: omit(options, ['deferDecrypt', 'summary']),
670
- deferDecrypt: options.deferDecrypt,
671
- limit: getConvoLimit(options),
672
- }).then((results) => {
673
- for (const convo of results) {
674
- idToUrl.set(convo.id, convo.url);
675
- }
676
-
677
- return results;
678
- });
679
- },
680
-
681
- /**
682
- * Paginates through a set of conversations. By default does not fetch activities or
683
- * participants
684
- * @param {Object} options
685
- * @param {boolean} options.deferDecrypt - when true, deferDecrypt tells the
686
- * payload transformer to normalize (but not decrypt) each received
687
- * conversation. Instead, the received conversations will each have a bound
688
- * decrypt method that can be executed at the consumer's leisure
689
- * @param {Page} options.page - After the first result has been returned to a consumer,
690
- * you can pass the Page back to the sdk to get the next list of results.
691
- * @returns {Promise<Array<Conversation>>}
692
- */
693
- async paginate(options = {}) {
694
- if (options.page) {
695
- // We were passed a page but we are out of results
696
- if (!options.page.links || !options.page.links.next) {
697
- throw new Error('No link to follow for the provided page');
698
- }
699
-
700
- // Go get the next page of results
701
- return this.request({
702
- url: options.page.links.next,
703
- }).then((res) => ({page: new Page(res, this.webex)}));
704
- }
705
-
706
- // No page - so this is the first request to kick off the pagination process
707
- const queryOptions = {
708
- personRefresh: true,
709
- uuidEntryFormat: true,
710
- activitiesLimit: 0,
711
- participantsLimit: 0,
712
- paginate: true,
713
- ...omit(options, ['deferDecrypt', 'url']),
714
- };
715
-
716
- const reqOptions = {
717
- qs: queryOptions,
718
- deferDecrypt: options.deferDecrypt,
719
- limit: getConvoLimit(options),
720
- };
721
-
722
- // if options.url is present we likely received one or more additional urls due to federation. In this case
723
- // we need to initialize pagination against that url instead of the default home cluster
724
- if (options.url) {
725
- reqOptions.uri = `${options.url}/conversations`;
726
- } else {
727
- reqOptions.service = 'conversation';
728
- reqOptions.resource = 'conversations';
729
- }
730
-
731
- return this.request(reqOptions).then((res) => {
732
- const response = {
733
- page: new Page(res, this.webex),
734
- };
735
-
736
- if (res.body && res.body.additionalUrls) {
737
- response.additionalUrls = res.body.additionalUrls;
738
- }
739
-
740
- return response;
741
- });
742
- },
743
-
744
- /**
745
- * Lists the conversations the current user has left. By default does not
746
- * fetch activities or participants
747
- * @param {Object} options
748
- * @returns {Promise<Array<Conversation>>}
749
- */
750
- listLeft(options) {
751
- return this._list({
752
- service: 'conversation',
753
- resource: 'conversations/left',
754
- qs: options,
755
- limit: getConvoLimit(options),
756
- }).then((results) => {
757
- for (const convo of results) {
758
- idToUrl.set(convo.id, convo.url);
759
- }
760
-
761
- return results;
762
- });
763
- },
764
-
765
- /**
766
- * List activities for the specified conversation
767
- * @param {Object} options
768
- * @param {String} options.conversationUrl URL to the conversation
769
- * @returns {Promise<Array<Activity>>}
770
- */
771
- listActivities(options) {
772
- return this._listActivities(Object.assign(options, {resource: 'activities'}));
773
- },
774
-
775
- /**
776
- * @typedef QueryOptions
777
- * @param {number} [limit] The limit of child activities that can be returned per request
778
- * @param {boolean} [latestActivityFirst] Sort order for the child activities
779
- * @param {boolean} [includeParentActivity] Enables the parent activity to be returned in the activity list
780
- * @param {string} [sinceDate] Get all child activities after this date
781
- * @param {string} [maxDate] Get all child activities before this date
782
- * @param {boolean} [latestActivityFirst] Sort order for the child activities
783
- * @param {string} [activityType] The type of children to return the parents of, a null value here returns parents of all types of children.
784
- * The value is one of 'reply', 'edit', 'cardAction', 'reaction', 'reactionSummary', 'reactionSelfSummary'
785
- */
786
-
787
- /**
788
- * Get all parent ids for a conversation.
789
- * @param {string} conversationUrl conversation URL.
790
- * @param {QueryOptions} [query] object containing query string values to be appended to the url
791
- * @returns {Promise<Array<String>>}
792
- */
793
- async listParentActivityIds(conversationUrl, query) {
794
- const params = {
795
- method: 'GET',
796
- url: `${conversationUrl}/parents`,
797
- qs: query,
798
- };
799
-
800
- const response = await this.request(params);
801
-
802
- return response.body;
803
- },
804
-
805
- /**
806
- * Returns a list of _all_ child activities for a given parentId within a given conversation
807
- * @param {object} [options = {}]
808
- * @param {string} [options.conversationUrl] targeted conversation URL
809
- * @param {string} [options.activityParentId] parent id of edit activities or thread activities
810
- * @param {QueryOptions} [options.query] object containing query string values to be appended to the url
811
- * @returns {Promise<Array>}
812
- */
813
- async listAllChildActivitiesByParentId(options = {}) {
814
- const {conversationUrl, activityParentId, query} = options;
815
- const {activityType} = query;
816
-
817
- const initialResponse = await this.listChildActivitiesByParentId(
818
- conversationUrl,
819
- activityParentId,
820
- activityType,
821
- query
822
- );
823
-
824
- let page = new Page(initialResponse, this.webex);
825
-
826
- const items = [...page.items];
827
-
828
- while (page.hasNext()) {
829
- // eslint-disable-next-line no-await-in-loop
830
- page = await page.next();
831
- for (const activity of page) {
832
- items.push(activity);
833
- }
834
- }
835
-
836
- // reverse list if needed (see _list for precedent)
837
- if (items.length && last(items).published < items[0].published) {
838
- items.reverse();
839
- }
840
-
841
- return items;
842
- },
843
-
844
- /**
845
- * Return a list of child activities with a given conversation, parentId and other constraints.
846
- * @param {string} conversationUrl targeted conversation URL
847
- * @param {string} activityParentId parent id of edit activities or thread activities
848
- * @param {string} activityType type of child activity to return
849
- * The value is one of 'reply', 'edit', 'cardAction', 'reaction', 'reactionSummary', 'reactionSelfSummary'
850
- * @param {QueryOptions} [query = {}] object containing query string values to be appended to the url
851
- * @returns {Promise<Array>}
852
- */
853
- async listChildActivitiesByParentId(conversationUrl, activityParentId, activityType, query = {}) {
854
- const finalQuery = {
855
- ...query,
856
- activityType,
857
- };
858
- const params = {
859
- method: 'GET',
860
- url: `${conversationUrl}/parents/${activityParentId}`,
861
- qs: finalQuery,
862
- };
863
-
864
- return this.request(params);
865
- },
866
-
867
- /**
868
- * Return an array of reactionSummary and reactionSelfSummary objects
869
- * @param {string} conversationUrl targeted conversation URL
870
- * @param {string} activityParentId parent id of reaction activities
871
- * @param {QueryOptions} query object representing query parameters to pass to convo endpoint
872
- * @returns {Promise<Array>}
873
- */
874
- async getReactionSummaryByParentId(conversationUrl, activityParentId, query) {
875
- const {body} = await this.request({
876
- method: 'GET',
877
- url: `${conversationUrl}/activities/${activityParentId}`,
878
- qs: query,
879
- });
880
-
881
- const reactionObjects = body.children
882
- ? body.children.filter(
883
- (child) => child.type === 'reactionSelfSummary' || child.type === 'reactionSummary'
884
- )
885
- : [];
886
-
887
- return reactionObjects;
888
- },
889
-
890
- /**
891
- * Lists activities in which the current user was mentioned
892
- * @param {Object} options
893
- * @returns {Promise<Array<Activity>>}
894
- */
895
- listMentions(options) {
896
- return this._list({
897
- service: 'conversation',
898
- resource: 'mentions',
899
- qs: omit(options, 'mentions'),
900
- });
901
- },
902
-
903
- /**
904
- * Mutes the mentions of a conversation
905
- * @param {Conversation~ConversationObject} conversation
906
- * @param {Conversation~ActivityObject} activity
907
- * @returns {Promise} Resolves with the created activity
908
- */
909
- muteMentions(conversation, activity) {
910
- return this.tag(
911
- conversation,
912
- {
913
- tags: ['MENTION_NOTIFICATIONS_OFF'],
914
- },
915
- activity
916
- );
917
- },
918
-
919
- /**
920
- * Mutes the messages of a conversation
921
- * @param {Conversation~ConversationObject} conversation
922
- * @param {Conversation~ActivityObject} activity
923
- * @returns {Promise} Resolves with the created activity
924
- */
925
- muteMessages(conversation, activity) {
926
- return this.tag(
927
- conversation,
928
- {
929
- tags: ['MESSAGE_NOTIFICATIONS_OFF'],
930
- },
931
- activity
932
- );
933
- },
934
-
935
- /**
936
- * Starts ignoring conversation
937
- * @param {Conversation~ConversationObject} conversation
938
- * @param {Conversation~ActivityObject} activity
939
- * @returns {Promise} Resolves with the created activity
940
- */
941
- ignore(conversation, activity) {
942
- return this.tag(
943
- conversation,
944
- {
945
- tags: ['IGNORED'],
946
- },
947
- activity
948
- );
949
- },
950
-
951
- /**
952
- * @param {Object} conversation
953
- * @param {Object} inputs
954
- * @param {Object} parentActivity
955
- * @param {Object} activity
956
- * @returns {Promise}
957
- */
958
- cardAction(conversation, inputs, parentActivity, activity = {}) {
959
- const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
960
-
961
- activity.parent = {
962
- id: parentActivity.id,
963
- type: 'cardAction',
964
- };
965
-
966
- return this.prepare(activity, {
967
- verb: 'cardAction',
968
- target: this.prepareConversation(convoWithUrl),
969
- object: {objectType: 'submit', ...inputs},
970
- }).then((a) => this.submit(a));
971
- },
972
-
973
- /**
974
- * Posts a message to a conversation
975
- * @param {Object} conversation
976
- * @param {Object|string} message if string, treated as plaintext; if object,
977
- * assumed to be object property of `post` activity
978
- * @param {Object} activity Reference to the activity that will eventually be
979
- * posted. Use this to (a) pass in e.g. clientTempId and (b) render a
980
- * provisional activity
981
- * @returns {Promise<Activity>}
982
- */
983
- post(conversation, message, activity) {
984
- const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
985
-
986
- if (isString(message)) {
987
- message = {
988
- displayName: message,
989
- };
990
- }
991
-
992
- return this.prepare(activity, {
993
- verb: 'post',
994
- target: this.prepareConversation(convoWithUrl),
995
- object: {objectType: 'comment', ...message},
996
- }).then((a) => this.submit(a));
997
- },
998
-
999
- prepareConversation(conversation) {
1000
- return defaults(
1001
- pick(
1002
- conversation,
1003
- 'id',
1004
- 'url',
1005
- 'objectType',
1006
- 'defaultActivityEncryptionKeyUrl',
1007
- 'kmsResourceObjectUrl'
1008
- ),
1009
- {
1010
- objectType: 'conversation',
1011
- }
1012
- );
1013
- },
1014
-
1015
- prepare(activity, params) {
1016
- params = params || {};
1017
- activity = activity || {};
1018
-
1019
- return Promise.resolve(activity.prepare ? activity.prepare(params) : activity).then((act) => {
1020
- defaults(act, {
1021
- verb: params.verb,
1022
- kmsMessage: params.kmsMessage,
1023
- objectType: 'activity',
1024
- clientTempId: uuid.v4(),
1025
- actor: this.webex.internal.device.userId,
1026
- });
1027
-
1028
- // Workaround because parent is a reserved props in Ampersand
1029
- if (
1030
- (activity.parentActivityId && activity.activityType) ||
1031
- (activity.parent && activity.parent.id && activity.parent.type)
1032
- ) {
1033
- act.parent = {
1034
- id: activity.parentActivityId || activity.parent.id,
1035
- type: activity.activityType || activity.parent.type,
1036
- };
1037
- }
1038
-
1039
- if (isString(act.actor)) {
1040
- act.actor = {
1041
- objectType: 'person',
1042
- id: act.actor,
1043
- };
1044
- }
1045
-
1046
- ['actor', 'object'].forEach((key) => {
1047
- if (params[key]) {
1048
- act[key] = act[key] || {};
1049
- defaults(act[key], params[key]);
1050
- }
1051
- });
1052
-
1053
- if (params.target) {
1054
- merge(act, {
1055
- target: pick(
1056
- params.target,
1057
- 'id',
1058
- 'url',
1059
- 'objectType',
1060
- 'kmsResourceObjectUrl',
1061
- 'defaultActivityEncryptionKeyUrl'
1062
- ),
1063
- });
1064
- }
1065
-
1066
- ['object', 'target'].forEach((key) => {
1067
- if (act[key] && act[key].url && !act[key].id) {
1068
- act[key].id = act[key].url.split('/').pop();
1069
- }
1070
- });
1071
-
1072
- ['actor', 'object', 'target'].forEach((key) => {
1073
- if (act[key] && !act[key].objectType) {
1074
- // Reminder: throwing here because it's the only way to get out of
1075
- // this loop in event of an error.
1076
- throw new Error(`\`act.${key}.objectType\` must be defined`);
1077
- }
1078
- });
1079
-
1080
- if (act.object && act.object.content && !act.object.displayName) {
1081
- return Promise.reject(
1082
- new Error('Cannot submit activity object with `content` but no `displayName`')
1083
- );
1084
- }
1085
-
1086
- return act;
1087
- });
1088
- },
1089
-
1090
- /**
1091
- * Get a subset of threads for a user.
1092
- * @param {Object} options
1093
- * @returns {Promise<Array<Activity>>}
1094
- */
1095
- async listThreads(options) {
1096
- return this._list({
1097
- service: 'conversation',
1098
- resource: 'threads',
1099
- qs: omit(options, 'showAllTypes'),
1100
- });
1101
- },
1102
-
1103
- /**
1104
- * Handles incoming conversation.activity mercury messages
1105
- * @param {Event} event
1106
- * @returns {Promise}
1107
- */
1108
- processActivityEvent(event) {
1109
- return this.webex.transform('inbound', event).then(() => event);
1110
- },
1111
-
1112
- /**
1113
- * Handles incoming conversation.inmeetingchat.activity mercury messages
1114
- * @param {Event} event
1115
- * @returns {Promise}
1116
- */
1117
- processInmeetingchatEvent(event) {
1118
- return this.webex.transform('inbound', event).then(() => event);
1119
- },
1120
-
1121
- /**
1122
- * Removes all mute-related tags
1123
- * @param {Conversation~ConversationObject} conversation
1124
- * @param {Conversation~ActivityObject} activity
1125
- * @returns {Promise} Resolves with the created activity
1126
- */
1127
- removeAllMuteTags(conversation, activity) {
1128
- return this.untag(
1129
- conversation,
1130
- {
1131
- tags: [
1132
- 'MENTION_NOTIFICATIONS_OFF',
1133
- 'MENTION_NOTIFICATIONS_ON',
1134
- 'MESSAGE_NOTIFICATIONS_OFF',
1135
- 'MESSAGE_NOTIFICATIONS_ON',
1136
- ],
1137
- },
1138
- activity
1139
- );
1140
- },
1141
-
1142
- /**
1143
- * Creates a ShareActivty for the specified conversation
1144
- * @param {Object} conversation
1145
- * @param {Object} activity
1146
- * @returns {ShareActivty}
1147
- */
1148
- makeShare(conversation, activity) {
1149
- // if we pass activity as null then it does not take care of the
1150
- // clientTempId created by the web-client while making the provisional
1151
- // activity, hence we need to pass the activity which was created by the
1152
- // web-client. This fixes the issue where the image activities do not come
1153
- // back properly oriented from the server since the clientTempId is missing
1154
- return ShareActivity.create(conversation, activity, this.webex);
1155
- },
1156
-
1157
- /**
1158
- * Assigns an avatar to a room
1159
- * @param {Object} conversation
1160
- * @param {File} avatar
1161
- * @returns {Promise<Activity>}
1162
- */
1163
- assign(conversation, avatar) {
1164
- const uploadOptions = {role: 'spaceAvatar'};
1165
-
1166
- if ((avatar.size || avatar.length) > 1024 * 1024) {
1167
- return Promise.reject(new Error('Room avatars must be less than 1MB'));
1168
- }
1169
-
1170
- const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
1171
-
1172
- return Promise.resolve()
1173
- .then(() => {
1174
- const activity = ShareActivity.create(conversation, null, this.webex);
1175
-
1176
- activity.enableThumbnails = false;
1177
- activity.add(avatar, uploadOptions);
1178
-
1179
- return this.prepare(activity, {
1180
- target: this.prepareConversation(convoWithUrl),
1181
- });
1182
- })
1183
- .then((a) => {
1184
- // yes, this seems a little hacky; will likely be resolved as a result
1185
- // of #213
1186
- a.verb = 'assign';
1187
-
1188
- return this.submit(a);
1189
- });
1190
- },
1191
-
1192
- /**
1193
- * Get url from convo object. If there isn't one, get it from the cache
1194
- *
1195
- * @param {String} url The location of the conversation
1196
- * @param {UUID} id If there is no url, fall back to id to lookup in cache or with cluster
1197
- * @param {String} cluster Used with id to lookup url
1198
- * @param {UUID} generalConversationUuid If this is a team, the id of the general conversation
1199
- * @param {Object} conversations If this is a team, the list of conversations in the team
1200
- * @returns {String} url for the specific convo
1201
- */
1202
- getConvoUrl({id, url, cluster, conversations, generalConversationUuid}) {
1203
- if (generalConversationUuid) {
1204
- // This is a Team
1205
- // Because Convo doesn't have an endpoint for the team URL
1206
- // we have to use the general convo URL.
1207
- const generalConvo = conversations.items.find(
1208
- (convo) => convo.id === generalConversationUuid
1209
- );
1210
-
1211
- return generalConvo.url;
1212
- }
1213
-
1214
- if (url) {
1215
- return url;
1216
- }
1217
-
1218
- if (id) {
1219
- if (cluster) {
1220
- return this.getUrlFromClusterId({cluster, id});
1221
- }
1222
- this.logger.warn('You should be using the `url` instead of the `id` property');
1223
- const relatedUrl = idToUrl.get(id);
1224
-
1225
- if (!relatedUrl) {
1226
- throw Error('Could not find the `url` from the given `id`');
1227
- }
1228
-
1229
- return relatedUrl;
1230
- }
1231
-
1232
- throw Error('The space needs a `url` property');
1233
- },
1234
-
1235
- /**
1236
- * Sets the typing status of the current user in a conversation
1237
- *
1238
- * @param {Object} conversation
1239
- * @param {Object} options
1240
- * @param {boolean} options.typing
1241
- * @returns {Promise}
1242
- */
1243
- updateTypingStatus(conversation, options) {
1244
- if (!conversation.id) {
1245
- if (conversation.url) {
1246
- conversation.id = conversation.url.split('/').pop();
1247
- } else {
1248
- return Promise.reject(new Error('conversation: could not identify conversation'));
1249
- }
1250
- }
1251
-
1252
- let eventType;
1253
-
1254
- if (options.typing) {
1255
- eventType = 'status.start_typing';
1256
- } else {
1257
- eventType = 'status.stop_typing';
1258
- }
1259
-
1260
- const url = this.getConvoUrl(conversation);
1261
- const resource = 'status/typing';
1262
- const params = {
1263
- method: 'POST',
1264
- body: {
1265
- conversationId: conversation.id,
1266
- eventType,
1267
- },
1268
- url: `${url}/${resource}`,
1269
- };
1270
-
1271
- return this.request(params);
1272
- },
1273
-
1274
- /**
1275
- * Shares files to the specified conversation
1276
- * @param {Object} conversation
1277
- * @param {ShareActivity|Array<File>} activity
1278
- * @returns {Promise<Activity>}
1279
- */
1280
- share(conversation, activity) {
1281
- if (isArray(activity)) {
1282
- activity = {
1283
- object: {
1284
- files: activity,
1285
- },
1286
- };
1287
- }
1288
-
1289
- const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
1290
-
1291
- if (!(activity instanceof ShareActivity)) {
1292
- activity = ShareActivity.create(convoWithUrl, activity, this.webex);
1293
- }
1294
-
1295
- return this.prepare(activity, {
1296
- target: this.prepareConversation(convoWithUrl),
1297
- }).then((a) => this.submit(a));
1298
- },
1299
-
1300
- /**
1301
- * Submits an activity to the conversation service
1302
- * @param {Object} activity
1303
- * @param {String} [endpoint] endpoint to submit activity. If empty, find in activity
1304
- * @returns {Promise<Activity>}
1305
- */
1306
- submit(activity, endpoint) {
1307
- const url = endpoint || this.getConvoUrl(activity.target);
1308
- const resource = activity.verb === 'share' ? 'content' : 'activities';
1309
- const params = {
1310
- method: 'POST',
1311
- body: activity,
1312
- qs: {
1313
- personRefresh: true,
1314
- },
1315
- url: `${url}/${resource}`,
1316
- };
1317
-
1318
- if (activity.verb === 'share') {
1319
- Object.assign(params.qs, {
1320
- transcode: true,
1321
- async: false,
1322
- });
1323
- }
1324
- /**
1325
- * helper to cloneDeepWith for copying instance function
1326
- * @param {Object|String|Symbol|Array|Date} value (recursive value to clone from params)
1327
- * @returns {Object|null}
1328
- */
1329
- // eslint-disable-next-line consistent-return
1330
- const customActivityCopy = (value) => {
1331
- const {files} = params.body.object;
1332
-
1333
- if (
1334
- files &&
1335
- value &&
1336
- files.items.length > 0 &&
1337
- value.constructor === files.items[0].scr.constructor
1338
- ) {
1339
- const copySrc = cloneDeep(value);
1340
-
1341
- copySrc.toJWE = value.toJWE;
1342
- copySrc.toJSON = value.toJSON;
1343
-
1344
- return copySrc;
1345
- }
1346
- };
1347
- const cloneActivity = cloneDeepWith(params, customActivityCopy);
1348
-
1349
- // triggers user-activity to reset logout timer
1350
- this.webex.trigger('user-activity');
1351
-
1352
- return this.request(params)
1353
- .then((res) => res.body)
1354
- .catch((error) => {
1355
- // handle when key need to rotate
1356
- if (error.body && error.body.errorCode === KEY_ROTATION_REQUIRED) {
1357
- cloneActivity.body.target.defaultActivityEncryptionKeyUrl = null;
1358
- this.request(cloneActivity);
1359
- } else if (
1360
- error.body &&
1361
- (error.body.errorCode === KEY_ALREADY_ROTATED ||
1362
- error.body.errorCode === ENCRYPTION_KEY_URL_MISMATCH)
1363
- ) {
1364
- // handle when key need to update
1365
- this.webex
1366
- .request({
1367
- method: 'GET',
1368
- api: 'conversation',
1369
- resource: `conversations/${params.body.target.id}`,
1370
- })
1371
- .then((res) => {
1372
- cloneActivity.body.target.defaultActivityEncryptionKeyUrl =
1373
- res.body.defaultActivityEncryptionkeyUrl;
1374
- this.request(cloneActivity);
1375
- });
1376
- } else {
1377
- throw error;
1378
- }
1379
- });
1380
- },
1381
- /**
1382
- * Remove the avatar from a room
1383
- * @param {Conversation~ConversationObject} conversation
1384
- * @param {Conversation~ActivityObject} activity
1385
- * @returns {Promise}
1386
- */
1387
- unassign(conversation, activity) {
1388
- const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
1389
-
1390
- return this.prepare(activity, {
1391
- verb: 'unassign',
1392
- target: this.prepareConversation(convoWithUrl),
1393
- object: {
1394
- objectType: 'content',
1395
- files: {
1396
- items: [],
1397
- },
1398
- },
1399
- }).then((a) => this.submit(a));
1400
- },
1401
-
1402
- /**
1403
- * Mutes the mentions of a conversation
1404
- * @param {Conversation~ConversationObject} conversation
1405
- * @param {Conversation~ActivityObject} activity
1406
- * @returns {Promise} Resolves with the created activity
1407
- */
1408
- unmuteMentions(conversation, activity) {
1409
- return this.tag(
1410
- conversation,
1411
- {
1412
- tags: ['MENTION_NOTIFICATIONS_ON'],
1413
- },
1414
- activity
1415
- );
1416
- },
1417
-
1418
- /**
1419
- * Mutes the messages of a conversation
1420
- * @param {Conversation~ConversationObject} conversation
1421
- * @param {Conversation~ActivityObject} activity
1422
- * @returns {Promise} Resolves with the created activity
1423
- */
1424
- unmuteMessages(conversation, activity) {
1425
- return this.tag(
1426
- conversation,
1427
- {
1428
- tags: ['MESSAGE_NOTIFICATIONS_ON'],
1429
- },
1430
- activity
1431
- );
1432
- },
1433
-
1434
- /**
1435
- * Stops ignoring conversation
1436
- * @param {Conversation~ConversationObject} conversation
1437
- * @param {Conversation~ActivityObject} activity
1438
- * @returns {Promise} Resolves with the created activity
1439
- */
1440
- unignore(conversation, activity) {
1441
- return this.untag(
1442
- conversation,
1443
- {
1444
- tags: ['IGNORED'],
1445
- },
1446
- activity
1447
- );
1448
- },
1449
-
1450
- /**
1451
- * Update an existing activity
1452
- * @param {Object} conversation
1453
- * @param {Object} object
1454
- * @param {Object} activity
1455
- * @returns {Promise}
1456
- */
1457
- update(conversation, object, activity) {
1458
- if (!isObject(object)) {
1459
- return Promise.reject(new Error('`object` must be an object'));
1460
- }
1461
-
1462
- const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
1463
-
1464
- return this.prepare(activity, {
1465
- verb: 'update',
1466
- target: this.prepareConversation(convoWithUrl),
1467
- object,
1468
- }).then((a) => this.submit(a));
1469
- },
1470
-
1471
- /**
1472
- * Sets a new key for the conversation
1473
- * @param {Object} conversation
1474
- * @param {Key|string} key (optional)
1475
- * @param {Object} activity Reference to the activity that will eventually be
1476
- * posted. Use this to (a) pass in e.g. clientTempId and (b) render a
1477
- * provisional activity
1478
- * @returns {Promise<Activity>}
1479
- */
1480
- updateKey(conversation, key, activity) {
1481
- const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
1482
-
1483
- return this.get(convoWithUrl, {
1484
- activitiesLimit: 0,
1485
- includeParticipants: true,
1486
- }).then((c) => this._updateKey(c, key, activity));
1487
- },
1488
-
1489
- /**
1490
- * Sets a new key for the conversation
1491
- * @param {Object} conversation
1492
- * @param {Key|string} key (optional)
1493
- * @param {Object} activity Reference to the activity that will eventually be
1494
- * posted. Use this to (a) pass in e.g. clientTempId and (b) render a
1495
- * provisional activity
1496
- * @private
1497
- * @returns {Promise<Activity>}
1498
- */
1499
- _updateKey(conversation, key, activity) {
1500
- const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
1501
-
1502
- return Promise.resolve(
1503
- key || this.webex.internal.encryption.kms.createUnboundKeys({count: 1})
1504
- ).then((keys) => {
1505
- const k = isArray(keys) ? keys[0] : keys;
1506
- const params = {
1507
- verb: 'updateKey',
1508
- target: this.prepareConversation(convoWithUrl),
1509
- object: {
1510
- defaultActivityEncryptionKeyUrl: k.uri,
1511
- objectType: 'conversation',
1512
- },
1513
- };
1514
-
1515
- // Reminder: the kmsResourceObjectUrl is only usable if there is
1516
- // defaultActivityEncryptionKeyUrl.
1517
- // Valid defaultActivityEncryptionKeyUrl start with 'kms:'
1518
- if (
1519
- convoWithUrl.kmsResourceObjectUrl &&
1520
- convoWithUrl.kmsResourceObjectUrl.startsWith('kms:')
1521
- ) {
1522
- params.kmsMessage = {
1523
- method: 'update',
1524
- resourceUri: '<KRO>',
1525
- uri: k.uri,
1526
- };
1527
- } else {
1528
- params.kmsMessage = {
1529
- method: 'create',
1530
- uri: '/resources',
1531
- userIds: map(convoWithUrl.participants.items, 'id'),
1532
- keyUris: [k.uri],
1533
- };
1534
- }
1535
-
1536
- return this.prepare(activity, params).then((a) => this.submit(a));
1537
- });
1538
- },
1539
-
1540
- /**
1541
- * @param {Object} payload
1542
- * @param {Object} options
1543
- * @private
1544
- * @returns {Promise<Activity>}
1545
- */
1546
- _create(payload, options = {}) {
1547
- return this.request({
1548
- method: 'POST',
1549
- service: 'conversation',
1550
- resource: 'conversations',
1551
- body: payload,
1552
- qs: {
1553
- forceCreate: options.allowPartialCreation,
1554
- },
1555
- }).then((res) => res.body);
1556
- },
1557
-
1558
- /**
1559
- * @param {Object} params
1560
- * @param {Object} options
1561
- * @private
1562
- * @returns {Promise}
1563
- */
1564
- _createGrouped(params, options) {
1565
- return this._create(this._prepareConversationForCreation(params), options);
1566
- },
1567
-
1568
- /**
1569
- * @param {Object} params
1570
- * @param {Object} options
1571
- * @private
1572
- * @returns {Promise}
1573
- */
1574
- _createOneOnOne(params) {
1575
- const payload = this._prepareConversationForCreation(params);
1576
-
1577
- payload.tags = ['ONE_ON_ONE'];
1578
-
1579
- return this._create(payload);
1580
- },
1581
-
1582
- /**
1583
- * Get the current conversation url.
1584
- *
1585
- * @returns {Promise<string>} - conversation url
1586
- */
1587
- getConversationUrl() {
1588
- this.logger.info('conversation: getting the conversation service url');
1589
-
1590
- const convoUrl = this.webex.internal.services.get('conversation');
1591
-
1592
- // Validate if the conversation url exists in the services plugin and
1593
- // resolve with its value.
1594
- if (convoUrl) {
1595
- return Promise.resolve(convoUrl);
1596
- }
1597
-
1598
- // Wait for the postauth catalog to update and then try to retrieve the
1599
- // conversation service url again.
1600
- return this.webex.internal
1601
- .waitForCatalog('postauth')
1602
- .then(() => this.webex.internal.services.get('conversation'))
1603
- .catch((error) => {
1604
- this.logger.warn('conversation: unable to get conversation url', error.message);
1605
-
1606
- return Promise.reject(error);
1607
- });
1608
- },
1609
-
1610
- /**
1611
- * @param {Object} conversation
1612
- * @private
1613
- * @returns {Promise}
1614
- */
1615
- _inferConversationUrl(conversation) {
1616
- if (conversation.id) {
1617
- return this.webex.internal.feature
1618
- .getFeature('developer', 'web-high-availability')
1619
- .then((haMessagingEnabled) => {
1620
- if (haMessagingEnabled) {
1621
- // recompute conversation URL each time as the host may have changed
1622
- // since last usage
1623
- return this.getConversationUrl().then((url) => {
1624
- conversation.url = `${url}/conversations/${conversation.id}`;
1625
-
1626
- return conversation;
1627
- });
1628
- }
1629
- if (!conversation.url) {
1630
- return this.getConversationUrl().then((url) => {
1631
- conversation.url = `${url}/conversations/${conversation.id}`;
1632
- /* istanbul ignore else */
1633
- if (process.env.NODE_ENV !== 'production') {
1634
- this.logger.warn(
1635
- 'conversation: inferred conversation url from conversation id; please pass whole conversation objects to Conversation methods'
1636
- );
1637
- }
1638
-
1639
- return conversation;
1640
- });
1641
- }
1642
-
1643
- return Promise.resolve(conversation);
1644
- });
1645
- }
1646
-
1647
- return Promise.resolve(conversation);
1648
- },
1649
-
1650
- /**
1651
- * @param {Object} options
1652
- * @param {String} options.conversationUrl URL to the conversation
1653
- * @param {String} options.resource The URL resource to hit for a list of objects
1654
- * @private
1655
- * @returns {Promise<Array<Activity>>}
1656
- */
1657
- _listActivities(options) {
1658
- const id = options.conversationId;
1659
- const url = this.getConvoUrl({url: options.conversationUrl, id});
1660
- const {resource} = options;
1661
-
1662
- return this._list({
1663
- qs: omit(options, 'resource'),
1664
- url: `${url}/${resource}`,
1665
- });
1666
- },
1667
-
1668
- /**
1669
- * common interface for facade of generator functions
1670
- * @typedef {object} IGeneratorResponse
1671
- * @param {boolean} done whether there is more to fetch
1672
- * @param {any} value the value yielded or returned by generator
1673
- */
1674
-
1675
- /**
1676
- * @param {object} options
1677
- * @param {string} options.conversationId
1678
- * @param {string} options.conversationUrl,
1679
- * @param {boolean} options.includeChildren, If set to true, parent activities will be enhanced with child objects
1680
- * @param {number} options.minActivities how many activities to return in first batch
1681
- * @param {?string} [options.queryType] one of older, newer, mid. defines which direction to fetch
1682
- * @param {?object} [options.search] server activity to use as search middle date
1683
- *
1684
- * @returns {object}
1685
- * returns three functions:
1686
- *
1687
- * getOlder - gets older activities than oldest fetched
1688
- *
1689
- * getNewer - gets newer activities than newest fetched
1690
- *
1691
- * jumpToActivity - gets searched-for activity and surrounding activities
1692
- */
1693
- listActivitiesThreadOrdered(options) {
1694
- const {conversationUrl, conversationId} = options;
1695
-
1696
- if (!conversationUrl && !conversationId) {
1697
- throw new Error('must provide a conversation URL or conversation ID');
1698
- }
1699
-
1700
- const url = this.getConvoUrl({url: conversationUrl, id: conversationId});
1701
-
1702
- const baseOptions = {...omit(options, ['conversationUrl', 'conversationId']), url};
1703
-
1704
- const olderOptions = {...baseOptions, queryType: OLDER};
1705
-
1706
- let threadOrderer = this._listActivitiesThreadOrdered(baseOptions);
1707
-
1708
- /**
1709
- * gets queried activity and surrounding activities
1710
- * calling this function creates a new generator instance, losing the previous instance's internal state
1711
- * this ensures that jumping to older and newer activities is relative to a single set of timestamps, not many
1712
- * @param {object} searchObject activity object from convo
1713
- * @returns {IGeneratorResponse}
1714
- */
1715
- const jumpToActivity = async (searchObject) => {
1716
- if (!searchObject) {
1717
- throw new Error('Search must be an activity object from conversation service');
1718
- }
1719
- const newUrl = searchObject.target && searchObject.target.url;
1720
-
1721
- if (!newUrl) {
1722
- throw new Error('Search object must have a target url!');
1723
- }
1724
-
1725
- const searchOptions = {
1726
- ...baseOptions,
1727
- url: newUrl,
1728
- queryType: MID,
1729
- search: searchObject,
1730
- };
1731
-
1732
- threadOrderer = this._listActivitiesThreadOrdered(searchOptions);
1733
-
1734
- const {value: searchResults} = await threadOrderer.next(searchOptions);
1735
-
1736
- return {
1737
- done: true,
1738
- value: searchResults,
1739
- };
1740
- };
1741
-
1742
- /**
1743
- * gets older activities than oldest fetched
1744
- * @returns {IGeneratorResponse}
1745
- */
1746
- const getOlder = async () => {
1747
- const {value = []} = await threadOrderer.next(olderOptions);
1748
-
1749
- const oldestInBatch = value[0] && value[0].activity;
1750
- const moreActivitiesExist =
1751
- oldestInBatch && getActivityType(oldestInBatch) !== ACTIVITY_TYPES.CREATE;
1752
-
1753
- return {
1754
- done: !moreActivitiesExist,
1755
- value,
1756
- };
1757
- };
1758
-
1759
- /**
1760
- * gets newer activities than newest fetched
1761
- * @returns {IGeneratorResponse}
1762
- */
1763
- const getNewer = async () => {
1764
- const newerOptions = {...baseOptions, queryType: NEWER};
1765
-
1766
- const {value} = await threadOrderer.next(newerOptions);
1767
-
1768
- return {
1769
- done: !value.length,
1770
- value,
1771
- };
1772
- };
1773
-
1774
- return {
1775
- jumpToActivity,
1776
- getNewer,
1777
- getOlder,
1778
- };
1779
- },
1780
-
1781
- /**
1782
- * Represents reactions to messages
1783
- * @typedef {object} Reaction
1784
- * @property {object} activity reaction2summary server activity object
1785
- */
1786
-
1787
- /**
1788
- * Represents a root (parent, with or without children) activity, along with any replies and reactions
1789
- * @typedef {object} Activity
1790
- * @property {object} activity server activity object
1791
- * @property {Reaction} reactions
1792
- * @property {Reaction} reactionSelf
1793
- */
1794
-
1795
- /**
1796
- * @generator
1797
- * @method
1798
- * @async
1799
- * @private
1800
- * @param {object} options
1801
- * @param {string} options.url
1802
- * @param {boolean} options.includeChildren, If set to true, parent activities will be enhanced with child objects
1803
- * @param {string} [options.minActivities] how many activities to return in first batch
1804
- * @param {string} [options.queryType] one of older, newer, mid. defines which direction to fetch
1805
- * @param {object} [options.search] server activity to use as search middle date
1806
- *
1807
- * @yields {Activity[]}
1808
- *
1809
- * @returns {void}
1810
- */
1811
- async *_listActivitiesThreadOrdered(options = {}) {
1812
- // ***********************************************
1813
- // INSTANCE STATE VARIABLES
1814
- // variables that will be used for the life of the generator
1815
- // ***********************************************
1816
-
1817
- let {minActivities = defaultMinDisplayableActivities, queryType = INITIAL} = options;
1818
-
1819
- // must fetch initially before getting newer activities!
1820
- if (queryType === NEWER) {
1821
- queryType = INITIAL;
1822
- }
1823
-
1824
- const {url: convoUrl, search = {}, includeChildren} = options;
1825
-
1826
- // manage oldest, newest activities (ie bookends)
1827
- const {setBookends, getNewestAct, getOldestAct} = bookendManager();
1828
-
1829
- // default batch should be equal to minActivities when fetching back in time, but halved when fetching newer due to subsequent child fetches filling up the minActivities count
1830
- // reduces server RTs when fetching older activities
1831
- const defaultBatchSize =
1832
- queryType === INITIAL || queryType === OLDER
1833
- ? minActivities
1834
- : Math.max(minBatchSize, Math.ceil(minActivities / 2));
1835
- let batchSize = defaultBatchSize;
1836
-
1837
- // exposes activity states and handlers with simple getters
1838
- const {getActivityHandlerByKey, getActivityByTypeAndParentId} = activityManager();
1839
-
1840
- // set initial query
1841
- let query = getQuery(queryType, {activityToSearch: search, batchSize});
1842
-
1843
- /* eslint-disable no-await-in-loop */
1844
- /* eslint-disable no-loop-func */
1845
- while (true) {
1846
- // ***********************************************
1847
- // EXECUTION STATE VARIABLES
1848
- // variables that will be used for each "batch" of activities asked for
1849
- // ***********************************************
1850
-
1851
- // stores all "root" activities (activities that are, or could be, thread parents)
1852
- const {getRootActivityHash, addNewRoot} = rootActivityManager();
1853
-
1854
- // used to determine if we should continue to fetch older activities
1855
- // must be set per iteration, as querying newer activities is still valid when all end of convo has been reached
1856
- const {getNoMoreActs, checkAndSetNoMoreActs, checkAndSetNoOlderActs, checkAndSetNoNewerActs} =
1857
- noMoreActivitiesManager();
1858
-
1859
- const getActivityHandlerByType = (type) =>
1860
- ({
1861
- [ACTIVITY_TYPES.ROOT]: addNewRoot,
1862
- [ACTIVITY_TYPES.REPLY]: getActivityHandlerByKey(ACTIVITY_TYPES.REPLY),
1863
- [ACTIVITY_TYPES.EDIT]: getActivityHandlerByKey(ACTIVITY_TYPES.EDIT),
1864
- [ACTIVITY_TYPES.REACTION]: getActivityHandlerByKey(ACTIVITY_TYPES.REACTION),
1865
- [ACTIVITY_TYPES.REACTION_SELF]: getActivityHandlerByKey(ACTIVITY_TYPES.REACTION_SELF),
1866
- [ACTIVITY_TYPES.TOMBSTONE]: addNewRoot,
1867
- [ACTIVITY_TYPES.CREATE]: addNewRoot,
1868
- }[type]);
1869
-
1870
- const handleNewActivity = (activity) => {
1871
- const actType = getActivityType(activity);
1872
-
1873
- // ignore deletes
1874
- if (isDeleteActivity(activity)) {
1875
- return;
1876
- }
1877
-
1878
- const activityHandler = getActivityHandlerByType(actType);
1879
-
1880
- activityHandler(activity);
1881
- };
1882
-
1883
- const handleNewActivities = (activities) => {
1884
- activities.forEach((act) => {
1885
- handleNewActivity(act);
1886
- checkAndSetNoOlderActs(act);
1887
- });
1888
- };
1889
-
1890
- const handleOlderQuery = (activities) => {
1891
- setBookends(activities, OLDER);
1892
- handleNewActivities(activities);
1893
- };
1894
- const handleNewerQuery = (activities) => {
1895
- checkAndSetNoNewerActs(activities);
1896
- if (activities.length) {
1897
- setBookends(activities, NEWER);
1898
- handleNewActivities(activities);
1899
- }
1900
- };
1901
- const handleSearch = (activities) => {
1902
- setBookends(activities, MID);
1903
- handleNewActivities(activities);
1904
- };
1905
-
1906
- const getQueryResponseHandler = (type) =>
1907
- ({
1908
- [OLDER]: handleOlderQuery,
1909
- [NEWER]: handleNewerQuery,
1910
- [MID]: handleSearch,
1911
- [INITIAL]: handleOlderQuery,
1912
- }[type]);
1913
-
1914
- // ***********************************************
1915
- // INNER LOOP
1916
- // responsible for fetching and building our maps of activities
1917
- // fetch until minActivities is reached, or no more acts to fetch, or we hit our max fetch count
1918
- // ***********************************************
1919
-
1920
- const incrementLoopCounter = getLoopCounterFailsafe();
1921
-
1922
- while (!getNoMoreActs()) {
1923
- // count loops and throw if we detect infinite loop
1924
- incrementLoopCounter();
1925
-
1926
- // configure fetch request. Use a smaller limit when fetching newer or mids to account for potential children fetches
1927
- const allBatchActivitiesConfig = {
1928
- conversationUrl: convoUrl,
1929
- limit: batchSize,
1930
- includeChildren,
1931
- ...query,
1932
- };
1933
-
1934
- // request activities in batches
1935
- const $allBatchActivitiesFetch = this.listActivities(allBatchActivitiesConfig);
1936
-
1937
- // contain fetches in array to parallelize fetching as needed
1938
- const $fetchRequests = [$allBatchActivitiesFetch];
1939
-
1940
- // if query requires recursive fetches for children acts, add the additional fetch
1941
- if (queryType === MID || queryType === NEWER) {
1942
- const params = {activityType: null};
1943
-
1944
- if (query.sinceDate) {
1945
- params.sinceDate = query.sinceDate;
1946
- }
1947
-
1948
- const $parentsFetch = this.listParentActivityIds(convoUrl, params);
1949
-
1950
- $fetchRequests.push($parentsFetch);
1951
- }
1952
-
1953
- // we dont always need to fetch for parents
1954
- const [allBatchActivities, parents = {}] = await Promise.all($fetchRequests);
1955
-
1956
- // use query type to decide how to handle response
1957
- const handler = getQueryResponseHandler(queryType);
1958
-
1959
- handler(allBatchActivities);
1960
-
1961
- /*
1962
- next we must selectively fetch the children of each of the parents to ensure completeness
1963
- do this by checking the hash for each of the above parent IDs
1964
- fetch children when we have a parent whose ID is represented in the parent ID lists
1965
- */
1966
- const {reply: replyIds = [], edit: editIds = [], reaction: reactionIds = []} = parents;
1967
-
1968
- // if no parent IDs returned, do nothing
1969
- if (replyIds.length || editIds.length || reactionIds.length) {
1970
- const $reactionFetches = [];
1971
- const $replyFetches = [];
1972
- const $editFetches = [];
1973
-
1974
- for (const activity of allBatchActivities) {
1975
- const actId = activity.id;
1976
-
1977
- const childFetchOptions = {
1978
- conversationUrl: convoUrl,
1979
- activityParentId: actId,
1980
- };
1981
-
1982
- if (reactionIds.includes(actId)) {
1983
- $reactionFetches.push(
1984
- this.getReactionSummaryByParentId(convoUrl, actId, {
1985
- activityType: 'reactionSummary',
1986
- includeChildren: true,
1987
- })
1988
- );
1989
- }
1990
- if (replyIds.includes(actId)) {
1991
- childFetchOptions.query = {activityType: 'reply'};
1992
- $replyFetches.push(this.listAllChildActivitiesByParentId(childFetchOptions));
1993
- }
1994
- if (editIds.includes(actId)) {
1995
- childFetchOptions.query = {activityType: 'edit'};
1996
- $editFetches.push(this.listAllChildActivitiesByParentId(childFetchOptions));
1997
- }
1998
- }
1999
-
2000
- // parallelize fetch for speeedz
2001
- const [reactions, replies, edits] = await Promise.all([
2002
- Promise.all($reactionFetches),
2003
- Promise.all($replyFetches),
2004
- Promise.all($editFetches),
2005
- ]);
2006
-
2007
- // new reactions may have come in that also need their reactions fetched
2008
- const newReplyReactions = await Promise.all(
2009
- replies
2010
- .filter((reply) => replyIds.includes(reply.id))
2011
- .map((reply) =>
2012
- this.getReactionSummaryByParentId(convoUrl, reply.id, {
2013
- activityType: 'reactionSummary',
2014
- includeChildren: true,
2015
- })
2016
- )
2017
- );
2018
-
2019
- const allReactions = [...reactions, ...newReplyReactions];
2020
-
2021
- // stick them into activity hashes
2022
- replies.forEach((replyArr) => handleNewActivities(replyArr));
2023
- edits.forEach((editArr) => handleNewActivities(editArr));
2024
- allReactions.forEach((reactionArr) => handleNewActivities(reactionArr));
2025
- }
2026
-
2027
- const rootActivityHash = getRootActivityHash();
2028
- let visibleActivitiesCount = rootActivityHash.size;
2029
-
2030
- for (const rootActivity of rootActivityHash.values()) {
2031
- const {id: rootId} = rootActivity;
2032
- const repliesByRootId = getActivityByTypeAndParentId(ACTIVITY_TYPES.REPLY, rootId);
2033
-
2034
- if (repliesByRootId && repliesByRootId.size) {
2035
- visibleActivitiesCount += repliesByRootId.size || 0;
2036
- }
2037
- }
2038
-
2039
- // stop fetching if we've reached desired count of visible activities
2040
- if (visibleActivitiesCount >= minActivities) {
2041
- break;
2042
- }
2043
-
2044
- checkAndSetNoMoreActs(queryType, visibleActivitiesCount, batchSize);
2045
-
2046
- // batchSize should be equal to minimum activities when fetching older activities
2047
- // covers "best case" when we reach minActivities on the first fetch
2048
- if (queryType === OLDER) {
2049
- batchSize = minActivities;
2050
- }
2051
-
2052
- // since a MID query can bump the batchSize, we need to reset it _after_ a potential MID query
2053
- // reset batchSize in case of MID queries bumping it up
2054
- if (queryType === NEWER) {
2055
- batchSize = defaultBatchSize;
2056
- }
2057
-
2058
- const currentOldestPublishedDate = getPublishedDate(getOldestAct());
2059
- const currentNewestPublishedDate = getPublishedDate(getNewestAct());
2060
-
2061
- // we're still building our activity list - derive new query from prior query and start loop again
2062
- if (queryType === INITIAL) {
2063
- query = getQuery(OLDER, {oldestPublishedDate: currentOldestPublishedDate, batchSize});
2064
- } else {
2065
- query = getQuery(queryType, {
2066
- batchSize,
2067
- activityToSearch: search,
2068
- oldestPublishedDate: currentOldestPublishedDate,
2069
- newestPublishedDate: currentNewestPublishedDate,
2070
- });
2071
- }
2072
-
2073
- // if we're still building out the midDate search, bump the search limit to include activities on both sides
2074
- if (queryType === MID) {
2075
- batchSize += batchSizeIncrementCount;
2076
- }
2077
- }
2078
-
2079
- const orderedActivities = [];
2080
-
2081
- const getRepliesByParentId = (replyParentId) => {
2082
- const replies = [];
2083
-
2084
- const repliesByParentId = getActivityByTypeAndParentId(ACTIVITY_TYPES.REPLY, replyParentId);
2085
-
2086
- if (!repliesByParentId) {
2087
- return replies;
2088
- }
2089
-
2090
- const sortedReplies = sortActivitiesByPublishedDate(
2091
- getActivityObjectsFromMap(repliesByParentId)
2092
- );
2093
-
2094
- sortedReplies.forEach((replyActivity) => {
2095
- const replyId = replyActivity.id;
2096
- const edit = getActivityByTypeAndParentId(ACTIVITY_TYPES.EDIT, replyId);
2097
- const reaction = getActivityByTypeAndParentId(ACTIVITY_TYPES.REACTION, replyId);
2098
- const reactionSelf = getActivityByTypeAndParentId(ACTIVITY_TYPES.REACTION_SELF, replyId);
2099
-
2100
- const latestActivity = edit || replyActivity;
2101
- // hash of root activities (in case of plain reply) and the reply activity (in case of edit)
2102
- const allRelevantActivitiesArr = [
2103
- ...getActivityObjectsFromMap(getRootActivityHash()),
2104
- ...getActivityObjectsFromMap(repliesByParentId),
2105
- ];
2106
- const allRelevantActivities = allRelevantActivitiesArr.reduce((hashMap, act) => {
2107
- hashMap[act.id] = act;
2108
-
2109
- return hashMap;
2110
- }, {});
2111
-
2112
- const finalReply = this._createParsedServerActivity(
2113
- latestActivity,
2114
- allRelevantActivities
2115
- );
2116
-
2117
- const fullReply = {
2118
- id: replyId,
2119
- activity: finalReply,
2120
- reaction,
2121
- reactionSelf,
2122
- };
2123
-
2124
- const sanitizedFullReply = sanitizeActivity(fullReply);
2125
-
2126
- replies.push(sanitizedFullReply);
2127
- });
2128
-
2129
- return replies;
2130
- };
2131
-
2132
- const orderedRoots = sortActivitiesByPublishedDate(
2133
- getActivityObjectsFromMap(getRootActivityHash())
2134
- );
2135
-
2136
- orderedRoots.forEach((rootActivity) => {
2137
- const rootId = rootActivity.id;
2138
- const replies = getRepliesByParentId(rootId);
2139
- const edit = getActivityByTypeAndParentId(ACTIVITY_TYPES.EDIT, rootId);
2140
- const reaction = getActivityByTypeAndParentId(ACTIVITY_TYPES.REACTION, rootId);
2141
- const reactionSelf = getActivityByTypeAndParentId(ACTIVITY_TYPES.REACTION_SELF, rootId);
2142
-
2143
- const latestActivity = edit || rootActivity;
2144
- const finalActivity = this._createParsedServerActivity(latestActivity, {
2145
- [rootId]: rootActivity,
2146
- });
2147
-
2148
- const fullRoot = {
2149
- id: rootId,
2150
- activity: finalActivity,
2151
- reaction,
2152
- reactionSelf,
2153
- };
2154
-
2155
- const sanitizedFullRoot = sanitizeActivity(fullRoot);
2156
-
2157
- orderedActivities.push(sanitizedFullRoot);
2158
- replies.forEach((reply) => orderedActivities.push(reply));
2159
- });
2160
-
2161
- const nextOptions = yield orderedActivities;
2162
-
2163
- if (nextOptions) {
2164
- minActivities = nextOptions.minActivities || minActivities;
2165
-
2166
- const currentOldestPublishedDate = getPublishedDate(getOldestAct());
2167
- const currentNewestPublishedDate = getPublishedDate(getNewestAct());
2168
-
2169
- queryType = nextOptions.queryType;
2170
- query = getQuery(queryType, {
2171
- activityToSearch: search,
2172
- oldestPublishedDate: currentOldestPublishedDate,
2173
- newestPublishedDate: currentNewestPublishedDate,
2174
- batchSize,
2175
- });
2176
- } else {
2177
- return;
2178
- }
2179
- }
2180
- },
2181
-
2182
- /**
2183
- * @typedef {object} EditActivity
2184
- * @property {object} editParent
2185
- *
2186
- * @typedef {object} ReplyActivity
2187
- * @property {object} replyParent
2188
- *
2189
- * @typedef {object} EditedReplyActivity
2190
- * @property {object} replyParent
2191
- * @property {object} editParent
2192
- *
2193
- * @typedef {EditActivity | ReplyActivity | EditedReplyActivity} ParsedServerActivity
2194
- */
2195
-
2196
- /**
2197
- * hashmap of server activities, keyed by id
2198
- * @typedef {object} ActivityHash
2199
- * @property {Object}
2200
- */
2201
-
2202
- /**
2203
- * extends a given server object with fields that point to their parent activities from the hashmap passed in
2204
- * @param {object} activity server activity
2205
- * @param {ActivityHash} allActivitiesHash hashmap of all server activities caller would like to pass in
2206
- * @returns {ParsedServerActivity} server activity extended with edit and reply parent fields
2207
- */
2208
- _createParsedServerActivity(activity, allActivitiesHash = {}) {
2209
- const isOrphan = getIsActivityOrphaned(activity, allActivitiesHash);
2210
-
2211
- if (isOrphan) {
2212
- throw new Error(
2213
- 'activity has a parent that cannot be found in allActivitiesHash! please handle this as necessary'
2214
- );
2215
- }
2216
-
2217
- const activityType = determineActivityType(activity, allActivitiesHash);
2218
-
2219
- switch (activityType) {
2220
- case ACTIVITY_TYPES.ROOT: {
2221
- return createRootActivity(activity);
2222
- }
2223
- case ACTIVITY_TYPES.EDIT: {
2224
- // `activities` must also have the original activity
2225
- return createEditActivity(activity, allActivitiesHash);
2226
- }
2227
- case ACTIVITY_TYPES.REPLY: {
2228
- return createReplyActivity(activity);
2229
- }
2230
- case ACTIVITY_TYPES.REPLY_EDIT: {
2231
- // `activities` must also have the reply activity
2232
- return createReplyEditActivity(activity, allActivitiesHash);
2233
- }
2234
- default: {
2235
- return activity;
2236
- }
2237
- }
2238
- },
2239
-
2240
- /**
2241
- * @param {Object} options
2242
- * @private
2243
- * @returns {Promise<Array<Conversation>>}
2244
- */
2245
- async _list(options) {
2246
- options.qs = {
2247
- personRefresh: true,
2248
- uuidEntryFormat: true,
2249
- activitiesLimit: 0,
2250
- participantsLimit: 0,
2251
- ...options.qs,
2252
- };
2253
-
2254
- const res = await this.request(options);
2255
-
2256
- let list;
2257
-
2258
- if (!res.body || !res.body.items || res.body.items.length === 0) {
2259
- list = [];
2260
- } else {
2261
- list = res.body.items.slice(0);
2262
- if (last(list).published < list[0].published) {
2263
- list.reverse();
2264
- }
2265
- }
2266
-
2267
- // The user has more data in another cluster.
2268
- // Follow the 'additionalUrls' for that data.
2269
- if (res.body.additionalUrls) {
2270
- let limit = 0;
2271
-
2272
- // If the user asked for a specific amount of data,
2273
- // don't fetch more than what was asked.
2274
- // Here we figure out how much is left from the original request.
2275
- // Divide that by the number of additional URLS.
2276
- // This won't get us the exact limit but it will retrieve something
2277
- // from every cluster listed.
2278
- if (options.limit) {
2279
- limit = Math.floor((options.limit.value - list.length) / res.body.additionalUrls.length);
2280
- }
2281
-
2282
- // If the limit is 0 for some reason,
2283
- // don't bother requesting from other clusters
2284
- if (!options.limit || limit !== 0) {
2285
- const results = await Promise.all(
2286
- res.body.additionalUrls.map((host) => {
2287
- const url = `${host}/${options.resource}`;
2288
- const newOptions = {...options, uri: url, url};
2289
-
2290
- if (options.limit) {
2291
- newOptions.qs[newOptions.limit.name] = limit;
2292
- }
2293
-
2294
- return this.request(newOptions);
2295
- })
2296
- );
2297
-
2298
- for (const result of results) {
2299
- if (result.body && result.body.items && result.body.items.length) {
2300
- const {items} = result.body;
2301
-
2302
- if (last(items).published < items[0].published) {
2303
- items.reverse();
2304
- }
2305
- list = list.concat(items);
2306
- }
2307
- }
2308
- }
2309
- }
2310
-
2311
- await Promise.all(list.map((item) => this._recordUUIDs(item)));
2312
-
2313
- return list;
2314
- },
2315
-
2316
- /**
2317
- * @param {Object} params
2318
- * @param {Object} options
2319
- * @private
2320
- * @returns {Promise<Conversation>}
2321
- */
2322
- _maybeCreateOneOnOneThenPost(params, options) {
2323
- return this.get(
2324
- defaults({
2325
- // the use of uniq in Conversation#create guarantees participant[1] will
2326
- // always be the other user
2327
- user: params.participants[1],
2328
- }),
2329
- Object.assign(options, {includeConvWithDeletedUserUUID: true, includeParticipants: true})
2330
- )
2331
- .then((conversation) => {
2332
- if (params.comment || params.html) {
2333
- return this.post(conversation, {content: params.html, displayName: params.comment}).then(
2334
- (activity) => {
2335
- conversation.activities.items.push(activity);
2336
-
2337
- return conversation;
2338
- }
2339
- );
2340
- }
2341
-
2342
- return conversation;
2343
- })
2344
- .catch((reason) => {
2345
- if (reason.statusCode !== 404) {
2346
- return Promise.reject(reason);
2347
- }
2348
-
2349
- return this._createOneOnOne(params);
2350
- });
2351
- },
2352
-
2353
- /**
2354
- * @param {Object} params
2355
- * @private
2356
- * @returns {Object}
2357
- */
2358
- _prepareConversationForCreation(params) {
2359
- const payload = {
2360
- activities: {
2361
- items: [this.expand('create')],
2362
- },
2363
- objectType: 'conversation',
2364
- kmsMessage: {
2365
- method: 'create',
2366
- uri: '/resources',
2367
- userIds: cloneDeep(params.participants),
2368
- keyUris: [],
2369
- },
2370
- };
2371
-
2372
- if (params.displayName) {
2373
- payload.displayName = params.displayName;
2374
- }
2375
-
2376
- if (params.tags) {
2377
- payload.tags = params.tags;
2378
- }
2379
-
2380
- params.participants.forEach((participant) => {
2381
- payload.activities.items.push(
2382
- this.expand('add', {
2383
- objectType: 'person',
2384
- id: participant,
2385
- })
2386
- );
2387
- });
2388
-
2389
- if (params.comment) {
2390
- payload.activities.items.push(
2391
- this.expand('post', {
2392
- objectType: 'comment',
2393
- content: params.html,
2394
- displayName: params.comment,
2395
- })
2396
- );
2397
- }
2398
-
2399
- if (!params.isDefaultClassification && params.classificationId) {
2400
- payload.activities.items.push(
2401
- this.expand('update', {
2402
- objectType: 'classification',
2403
- classificationId: params.classificationId,
2404
- effectiveDate: params.effectiveDate,
2405
- })
2406
- );
2407
- }
2408
-
2409
- if (params.favorite) {
2410
- payload.activities.items.push(
2411
- this.expand('favorite', {
2412
- objectType: 'conversation',
2413
- })
2414
- );
2415
- }
2416
-
2417
- return payload;
2418
- },
2419
-
2420
- /**
2421
- * @param {Object} conversation
2422
- * @private
2423
- * @returns {Promise}
2424
- */
2425
- _recordUUIDs(conversation) {
2426
- if (!conversation.participants || !conversation.participants.items) {
2427
- return Promise.resolve(conversation);
2428
- }
2429
-
2430
- return Promise.all(
2431
- conversation.participants.items.map((participant) => {
2432
- // ROOMs or LYRA_SPACEs do not have email addresses, so there's no point attempting to
2433
- // record their UUIDs.
2434
- if (participant.type === 'ROOM' || participant.type === 'LYRA_SPACE') {
2435
- return Promise.resolve();
2436
- }
2437
-
2438
- return this.webex.internal.user
2439
- .recordUUID(participant)
2440
- .catch((err) => this.logger.warn('Could not record uuid', err));
2441
- })
2442
- );
2443
- },
2444
- });
2445
-
2446
- ['favorite', 'hide', 'lock', 'mute', 'unfavorite', 'unhide', 'unlock', 'unmute'].forEach((verb) => {
2447
- Conversation.prototype[verb] = function submitSimpleActivity(conversation, activity) {
2448
- const convoWithUrl = this.prepareConversation({
2449
- ...conversation,
2450
- url: this.getConvoUrl(conversation),
2451
- });
2452
-
2453
- return this.prepare(activity, {
2454
- verb,
2455
- object: convoWithUrl,
2456
- target: convoWithUrl,
2457
- }).then((a) => this.submit(a));
2458
- };
2459
- });
2460
-
2461
- ['assignModerator', 'unassignModerator'].forEach((verb) => {
2462
- Conversation.prototype[verb] = function submitModerationChangeActivity(
2463
- conversation,
2464
- moderator,
2465
- activity
2466
- ) {
2467
- const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
2468
-
2469
- return Promise.all([
2470
- convoWithUrl,
2471
- moderator ? this.webex.internal.user.asUUID(moderator) : this.webex.internal.device.userId,
2472
- ])
2473
- .then(([c, userId]) =>
2474
- this.prepare(activity, {
2475
- verb,
2476
- target: this.prepareConversation(c),
2477
- object: {
2478
- id: userId,
2479
- objectType: 'person',
2480
- },
2481
- })
2482
- )
2483
- .then((a) => this.submit(a));
2484
- };
2485
- });
2486
-
2487
- /**
2488
- * Sets/unsets space property for convo
2489
- * @param {Object} conversation
2490
- * @param {string} tag
2491
- * @param {Activity} activity
2492
- * @returns {Promise<Activity>}
2493
- */
2494
- ['setSpaceProperty', 'unsetSpaceProperty'].forEach((fnName) => {
2495
- const verb = fnName.startsWith('set') ? 'set' : 'unset';
2496
-
2497
- Conversation.prototype[fnName] = function submitSpacePropertyActivity(
2498
- conversation,
2499
- tag,
2500
- activity
2501
- ) {
2502
- if (!isString(tag)) {
2503
- return Promise.reject(new Error('`tag` must be a string'));
2504
- }
2505
-
2506
- const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
2507
-
2508
- return this.prepare(activity, {
2509
- verb,
2510
- target: this.prepareConversation(convoWithUrl),
2511
- object: {
2512
- tags: [tag],
2513
- objectType: 'spaceProperty',
2514
- },
2515
- }).then((a) => this.submit(a));
2516
- };
2517
- });
2518
-
2519
- ['tag', 'untag'].forEach((verb) => {
2520
- Conversation.prototype[verb] = function submitObjectActivity(conversation, object, activity) {
2521
- if (!isObject(object)) {
2522
- return Promise.reject(new Error('`object` must be an object'));
2523
- }
2524
-
2525
- const c = this.prepareConversation({...conversation, url: this.getConvoUrl(conversation)});
2526
-
2527
- return this.prepare(activity, {
2528
- verb,
2529
- target: c,
2530
- object: Object.assign(c, object),
2531
- }).then((a) => this.submit(a));
2532
- };
2533
- });
2534
-
2535
- export default Conversation;
1
+ /*!
2
+ * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
3
+ */
4
+
5
+ import querystring from 'querystring';
6
+ import {EventEmitter} from 'events';
7
+
8
+ import hmacSHA256 from 'crypto-js/hmac-sha256';
9
+ import hex from 'crypto-js/enc-hex';
10
+ import {proxyEvents, tap} from '@webex/common';
11
+ import {Page, WebexPlugin} from '@webex/webex-core';
12
+ import {
13
+ cloneDeep,
14
+ cloneDeepWith,
15
+ defaults,
16
+ isArray,
17
+ isObject,
18
+ isString,
19
+ last,
20
+ map,
21
+ merge,
22
+ omit,
23
+ pick,
24
+ uniq,
25
+ } from 'lodash';
26
+ import {readExifData} from '@webex/helper-image';
27
+ import uuid from 'uuid';
28
+
29
+ import {InvalidUserCreation} from './convo-error';
30
+ import ShareActivity from './share-activity';
31
+ import {
32
+ minBatchSize,
33
+ defaultMinDisplayableActivities,
34
+ getLoopCounterFailsafe,
35
+ batchSizeIncrementCount,
36
+ getActivityObjectsFromMap,
37
+ bookendManager,
38
+ noMoreActivitiesManager,
39
+ getQuery,
40
+ rootActivityManager,
41
+ activityManager,
42
+ } from './activity-thread-ordering';
43
+ import {
44
+ ACTIVITY_TYPES,
45
+ getActivityType,
46
+ isDeleteActivity,
47
+ getIsActivityOrphaned,
48
+ determineActivityType,
49
+ createRootActivity,
50
+ createReplyActivity,
51
+ createEditActivity,
52
+ createReplyEditActivity,
53
+ OLDER,
54
+ MID,
55
+ INITIAL,
56
+ NEWER,
57
+ getPublishedDate,
58
+ sortActivitiesByPublishedDate,
59
+ sanitizeActivity,
60
+ } from './activities';
61
+ import {ENCRYPTION_KEY_URL_MISMATCH, KEY_ALREADY_ROTATED, KEY_ROTATION_REQUIRED} from './constants';
62
+
63
+ const idToUrl = new Map();
64
+
65
+ const getConvoLimit = (options = {}) => {
66
+ let limit;
67
+
68
+ if (options.conversationsLimit) {
69
+ limit = {
70
+ value: options.conversationsLimit,
71
+ name: 'conversationsLimit',
72
+ };
73
+ }
74
+
75
+ return limit;
76
+ };
77
+
78
+ const Conversation = WebexPlugin.extend({
79
+ namespace: 'Conversation',
80
+
81
+ /**
82
+ * @param {String} cluster the cluster containing the id
83
+ * @param {UUID} [id] the id of the conversation.
84
+ * If empty, just return the base URL.
85
+ * @returns {String} url of the conversation
86
+ */
87
+ getUrlFromClusterId({cluster = 'us', id} = {}) {
88
+ const url = this.webex.internal.services.getServiceUrlFromClusterId(
89
+ {
90
+ cluster,
91
+ },
92
+ this.webex
93
+ );
94
+
95
+ return id ? `${url}/conversations/${id}` : url;
96
+ },
97
+
98
+ /**
99
+ * @param {Object} conversation
100
+ * @param {Object} object
101
+ * @param {Object} activity
102
+ * @returns {Promise}
103
+ */
104
+ acknowledge(conversation, object, activity) {
105
+ const url = this.getConvoUrl(conversation);
106
+ const convoWithUrl = {...conversation, url};
107
+
108
+ if (!isObject(object)) {
109
+ return Promise.reject(new Error('`object` must be an object'));
110
+ }
111
+
112
+ return this.prepare(activity, {
113
+ verb: 'acknowledge',
114
+ target: this.prepareConversation(convoWithUrl),
115
+ object: {
116
+ objectType: 'activity',
117
+ id: object.id,
118
+ url: object.url,
119
+ },
120
+ }).then((a) => this.submit(a));
121
+ },
122
+
123
+ /**
124
+ * Adds a participant to a conversation
125
+ * @param {Object} conversation
126
+ * @param {Object|string} participant
127
+ * @param {Object} activity Reference to the activity that will eventually be
128
+ * posted. Use this to (a) pass in e.g. clientTempId and (b) render a
129
+ * provisional activity
130
+ * @returns {Promise<Activity>}
131
+ */
132
+ add(conversation, participant, activity) {
133
+ const url = this.getConvoUrl(conversation);
134
+ const convoWithUrl = {...conversation, url};
135
+
136
+ return this.webex.internal.user.asUUID(participant, {create: true}).then((id) =>
137
+ this.prepare(activity, {
138
+ verb: 'add',
139
+ target: this.prepareConversation(convoWithUrl),
140
+ object: {
141
+ id,
142
+ objectType: 'person',
143
+ },
144
+ kmsMessage: {
145
+ method: 'create',
146
+ uri: '/authorizations',
147
+ resourceUri: '<KRO>',
148
+ userIds: [id],
149
+ },
150
+ }).then((a) => this.submit(a))
151
+ );
152
+ },
153
+
154
+ /**
155
+ * Creates a conversation
156
+ * @param {Object} params
157
+ * @param {Array<Participant>} params.participants
158
+ * @param {Array<File>} params.files
159
+ * @param {string} params.comment
160
+ * @param {string} params.html
161
+ * @param {Object} params.displayName
162
+ * @param {string} params.classificationId
163
+ * @param {string} params.effectiveDate
164
+ * @param {Boolean} params.isDefaultClassification
165
+ * @param {Array<string>} params.tags
166
+ * @param {Boolean} params.favorite
167
+ * @param {Object} options
168
+ * @param {Boolean} options.allowPartialCreation
169
+ * @param {Boolean} options.forceGrouped
170
+ * @param {Boolean} options.skipOneOnOneFetch skips checking 1:1 exists before creating conversation
171
+ * @returns {Promise<Conversation>}
172
+ */
173
+ create(params, options = {}) {
174
+ if (!params.participants || params.participants.length === 0) {
175
+ return Promise.reject(new Error('`params.participants` is required'));
176
+ }
177
+
178
+ return Promise.all(
179
+ params.participants.map((participant) =>
180
+ this.webex.internal.user
181
+ .asUUID(participant, {create: true})
182
+ // eslint-disable-next-line arrow-body-style
183
+ .catch((err) => {
184
+ return options.allowPartialCreation ? undefined : Promise.reject(err);
185
+ })
186
+ )
187
+ )
188
+ .then((participants) => {
189
+ participants.unshift(this.webex.internal.device.userId);
190
+ participants = uniq(participants);
191
+
192
+ const validParticipants = participants.filter((participant) => participant);
193
+
194
+ params.participants = validParticipants;
195
+
196
+ // check if original participants list was to create a 1:1
197
+ if (participants.length === 2 && !(options && options.forceGrouped)) {
198
+ if (!params.participants[1]) {
199
+ return Promise.reject(new InvalidUserCreation());
200
+ }
201
+
202
+ if (options.skipOneOnOneFetch) {
203
+ return this._createOneOnOne(params);
204
+ }
205
+
206
+ return this._maybeCreateOneOnOneThenPost(params, options);
207
+ }
208
+
209
+ return this._createGrouped(params, options);
210
+ })
211
+ .then((c) => {
212
+ idToUrl.set(c.id, c.url);
213
+
214
+ if (!params.files) {
215
+ return c;
216
+ }
217
+
218
+ return this.webex.internal.conversation.share(c, params.files).then((a) => {
219
+ c.activities.items.push(a);
220
+
221
+ return c;
222
+ });
223
+ });
224
+ },
225
+
226
+ /**
227
+ * @private
228
+ * generate a deterministic HMAC for a reaction
229
+ * @param {Object} displayName displayName of reaction we are sending
230
+ * @param {Object} parent parent activity of reaction
231
+ * @returns {Promise<HMAC>}
232
+ */
233
+ createReactionHmac(displayName, parent) {
234
+ // not using webex.internal.encryption.getKey() because the JWK it returns does not have a 'k'
235
+ // property. we need jwk.k to correctly generate the HMAC
236
+
237
+ return this.webex.internal.encryption.unboundedStorage
238
+ .get(parent.encryptionKeyUrl)
239
+ .then((keyString) => {
240
+ const key = JSON.parse(keyString);
241
+ // when we stringify this object, keys must be in this order to generate same HMAC as
242
+ // desktop clients
243
+ const formatjwk = {k: key.jwk.k, kid: key.jwk.kid, kty: key.jwk.kty};
244
+
245
+ const source = `${JSON.stringify(formatjwk)}${parent.id}${displayName}`;
246
+
247
+ const hmac = hex.stringify(hmacSHA256(source, parent.id));
248
+
249
+ return Promise.resolve(hmac);
250
+ });
251
+ },
252
+
253
+ /**
254
+ * @typedef {Object} ReactionPayload
255
+ * @property {Object} actor
256
+ * @property {string} actor.objectType
257
+ * @property {string} actor.id
258
+ * @property {string} objectType
259
+ * @property {string} verb will be either add' or 'delete'
260
+ * @property {Object} target
261
+ * @property {string} target.id
262
+ * @property {string} target.objectType
263
+ * @property {Object} object this will change on delete vs. add
264
+ * @property {string} object.id present in delete case
265
+ * @property {string} object.objectType 'activity' in delete case, 'reaction2' in add case
266
+ * @property {string} object.displayName must be 'celebrate', 'heart', 'thumbsup', 'smiley', 'haha', 'confused', 'sad'
267
+ * @property {string} object.hmac
268
+ */
269
+
270
+ /**
271
+ * @private
272
+ * send add or delete reaction to convo service
273
+ * @param {Object} conversation
274
+ * The payload to send a reaction
275
+ * @param {ReactionPayload} reactionPayload
276
+ * @returns {Promise<Activity>}
277
+ */
278
+ sendReaction(conversation, reactionPayload) {
279
+ const url = this.getConvoUrl(conversation);
280
+ const convoWithUrl = {...conversation, url};
281
+
282
+ if (!isObject(reactionPayload)) {
283
+ return Promise.reject(new Error('`object` must be an object'));
284
+ }
285
+
286
+ return this.prepare(reactionPayload, {
287
+ target: this.prepareConversation(convoWithUrl),
288
+ object: pick(reactionPayload, 'id', 'url', 'objectType'),
289
+ }).then((act) => this.submit(act));
290
+ },
291
+
292
+ /**
293
+ * delete a reaction
294
+ * @param {Object} conversation
295
+ * @param {Object} reactionId
296
+ * @returns {Promise<Activity>}
297
+ */
298
+ deleteReaction(conversation, reactionId) {
299
+ const deleteReactionPayload = {
300
+ actor: {objectType: 'person', id: this.webex.internal.device.userId},
301
+ object: {
302
+ id: reactionId,
303
+ objectType: 'activity',
304
+ },
305
+ objectType: 'activity',
306
+ target: {
307
+ id: conversation.id,
308
+ objectType: 'conversation',
309
+ },
310
+ verb: 'delete',
311
+ };
312
+
313
+ return this.sendReaction(conversation, deleteReactionPayload);
314
+ },
315
+
316
+ /**
317
+ * create a reaction
318
+ * @param {Object} conversation
319
+ * @param {Object} displayName must be 'celebrate', 'heart', 'thumbsup', 'smiley', 'haha', 'confused', 'sad'
320
+ * @param {Object} activity activity object from convo we are reacting to
321
+ * @returns {Promise<Activity>}
322
+ */
323
+ addReaction(conversation, displayName, activity) {
324
+ return this.createReactionHmac(displayName, activity).then((hmac) => {
325
+ const addReactionPayload = {
326
+ actor: {objectType: 'person', id: this.webex.internal.device.userId},
327
+ target: {
328
+ id: conversation.id,
329
+ objectType: 'conversation',
330
+ },
331
+ verb: 'add',
332
+ objectType: 'activity',
333
+ parent: {
334
+ type: 'reaction',
335
+ id: activity.id,
336
+ },
337
+ object: {
338
+ objectType: 'reaction2',
339
+ displayName,
340
+ hmac,
341
+ },
342
+ };
343
+
344
+ return this.sendReaction(conversation, addReactionPayload);
345
+ });
346
+ },
347
+
348
+ /**
349
+ * delete content
350
+ * @param {Object} conversation
351
+ * @param {Object} object
352
+ * @param {Object} activity
353
+ * @returns {Promise}
354
+ */
355
+ delete(conversation, object, activity) {
356
+ const url = this.getConvoUrl(conversation);
357
+ const convoWithUrl = {...conversation, url};
358
+
359
+ if (!isObject(object)) {
360
+ return Promise.reject(new Error('`object` must be an object'));
361
+ }
362
+
363
+ const request = {
364
+ verb: 'delete',
365
+ target: this.prepareConversation(convoWithUrl),
366
+ object: pick(object, 'id', 'url', 'objectType'),
367
+ };
368
+
369
+ // Deleting meeting container requires KMS message
370
+ if (object.object.objectType === 'meetingContainer') {
371
+ // It's building a string uri + "/authorizations?authId=" + id, where uri is the meeting container's KRO URL, and id is the conversation's KRO URL.
372
+ request.target.kmsResourceObjectUrl = object.object.kmsResourceObjectUrl;
373
+ request.kmsMessage = {
374
+ method: 'delete',
375
+ uri: `<KRO>/authorizations?${querystring.stringify({
376
+ authId: convoWithUrl.kmsResourceObjectUrl,
377
+ })}`,
378
+ };
379
+ }
380
+
381
+ return this.prepare(activity, request).then((a) => this.submit(a));
382
+ },
383
+
384
+ /**
385
+ * Downloads the file specified in item.scr or item.url
386
+ * @param {Object} item
387
+ * @param {Object} item.scr
388
+ * @param {string} item.url
389
+ * @param {Object} options
390
+ * @param {Object} options.headers
391
+ * @param {boolean} options.shouldNotAddExifData
392
+ * @returns {Promise<File>}
393
+ */
394
+ download(item, options = {}) {
395
+ const isEncrypted = Boolean(item.scr && item.scr.key);
396
+ const shunt = new EventEmitter();
397
+ let promise;
398
+
399
+ if (isEncrypted) {
400
+ promise = this.webex.internal.encryption.download(item.scr, item.options);
401
+ } else if (item.scr && item.scr.loc) {
402
+ promise = this._downloadUnencryptedFile(item.scr.loc, options);
403
+ } else {
404
+ promise = this._downloadUnencryptedFile(item.url, options);
405
+ }
406
+
407
+ promise = promise
408
+ .on('progress', (...args) => shunt.emit('progress', ...args))
409
+ .then((res) => {
410
+ if (options.shouldNotAddExifData) {
411
+ return res;
412
+ }
413
+
414
+ return readExifData(item, res);
415
+ })
416
+ .then((file) => {
417
+ this.logger.info('conversation: file downloaded');
418
+
419
+ if (item.displayName && !file.name) {
420
+ file.name = item.displayName;
421
+ }
422
+
423
+ if (!file.type && item.mimeType) {
424
+ file.type = item.mimeType;
425
+ }
426
+
427
+ return file;
428
+ });
429
+
430
+ proxyEvents(shunt, promise);
431
+
432
+ return promise;
433
+ },
434
+
435
+ /**
436
+ * Downloads an unencrypted file
437
+ * @param {string} uri
438
+ * @param {Object} options
439
+ * @param {Object} options.headers
440
+ * @returns {Promise<File>}
441
+ */
442
+ _downloadUnencryptedFile(uri, options = {}) {
443
+ Object.assign(options, {
444
+ uri,
445
+ responseType: 'buffer',
446
+ });
447
+
448
+ const promise = this.request(options).then((res) => res.body);
449
+
450
+ proxyEvents(options.download, promise);
451
+
452
+ return promise;
453
+ },
454
+
455
+ /**
456
+ * Helper method that expands a set of parameters into an activty object
457
+ * @param {string} verb
458
+ * @param {Object} object
459
+ * @param {Object} target
460
+ * @param {Object|string} actor
461
+ * @returns {Object}
462
+ */
463
+ expand(verb, object, target, actor) {
464
+ const activity = {
465
+ actor,
466
+ objectType: 'activity',
467
+ verb,
468
+ };
469
+
470
+ if (!actor) {
471
+ actor = this.webex.internal.device.userId;
472
+ }
473
+
474
+ if (isString(actor)) {
475
+ activity.actor = {
476
+ objectType: 'person',
477
+ id: actor,
478
+ };
479
+ }
480
+
481
+ if (object) {
482
+ activity.object = object;
483
+ }
484
+
485
+ if (target) {
486
+ activity.target = target;
487
+ }
488
+
489
+ return activity;
490
+ },
491
+
492
+ /**
493
+ * Gets an array of activities with an array of activity URLS
494
+ * @param {Array} activityUrls
495
+ * @param {Object} options
496
+ * @param {String} options.cluster cluster where the activities are located
497
+ * @param {String} options.url base convo url where the activities are located
498
+ * @returns {Promise<Object>} Resolves with the activities
499
+ */
500
+ bulkActivitiesFetch(activityUrls, options = {}) {
501
+ let cluster;
502
+ let url;
503
+
504
+ if (typeof options === 'string') {
505
+ cluster = options;
506
+ } else {
507
+ ({cluster, url} = options);
508
+ }
509
+
510
+ const resource = 'bulk_activities_fetch';
511
+ const params = {
512
+ method: 'POST',
513
+ body: {
514
+ activityUrls,
515
+ },
516
+ };
517
+
518
+ if (url) {
519
+ const uri = `${url}/${resource}`;
520
+
521
+ Object.assign(params, {
522
+ uri,
523
+ });
524
+ } else if (cluster) {
525
+ const uri = `${this.getUrlFromClusterId({cluster})}/${resource}`;
526
+
527
+ Object.assign(params, {
528
+ uri,
529
+ });
530
+ } else {
531
+ Object.assign(params, {
532
+ api: 'conversation',
533
+ resource,
534
+ });
535
+ }
536
+
537
+ return this.webex.request(params).then((res) => {
538
+ const activitiesArr = [];
539
+
540
+ if (res.body.multistatus) {
541
+ res.body.multistatus.forEach((statusData) => {
542
+ if (statusData.status === '200' && statusData.data && statusData.data.activity) {
543
+ activitiesArr.push(statusData.data.activity);
544
+ }
545
+ });
546
+ }
547
+
548
+ return activitiesArr;
549
+ });
550
+ },
551
+
552
+ /**
553
+ * Fetches a single conversation
554
+ * @param {Object} conversation
555
+ * @param {String} [conversation.url] The URL where the conversation is located.
556
+ * @param {String|UUID} [conversation.user] The user to look up in the conversation service
557
+ * If specified, the user lookup will take precedence over the url lookup
558
+ * @param {Object} options
559
+ * @returns {Promise<Conversation>}
560
+ */
561
+ get(conversation, options = {}) {
562
+ const {user} = conversation;
563
+ let uri;
564
+
565
+ try {
566
+ uri = !user ? this.getConvoUrl(conversation) : '';
567
+ } catch (err) {
568
+ return Promise.reject(Error(err));
569
+ }
570
+
571
+ const params = {
572
+ qs: {
573
+ uuidEntryFormat: true,
574
+ personRefresh: true,
575
+ activitiesLimit: 0,
576
+ includeConvWithDeletedUserUUID: false,
577
+ includeParticipants: false,
578
+ ...omit(options, 'id', 'user', 'url'),
579
+ },
580
+ disableTransform: options.disableTransform,
581
+ };
582
+
583
+ // Default behavior is to set includeParticipants=false,
584
+ // which makes the payload lighter by removing participant info.
585
+ // If the caller explicitly sets the participantAckFilter or
586
+ // participantsLimit, we don't want that default setting.
587
+ if ('participantAckFilter' in options || 'participantsLimit' in options) {
588
+ delete params.qs.includeParticipants;
589
+ }
590
+
591
+ return Promise.resolve(user ? this.webex.internal.user.asUUID(user) : null)
592
+ .then((userId) => {
593
+ if (userId) {
594
+ Object.assign(params, {
595
+ service: 'conversation',
596
+ resource: `conversations/user/${userId}`,
597
+ });
598
+ } else {
599
+ params.uri = uri;
600
+ }
601
+
602
+ return this.request(params);
603
+ })
604
+ .then(
605
+ tap(({body}) => {
606
+ const {id, url} = body;
607
+
608
+ this._recordUUIDs(body);
609
+ idToUrl.set(id, url);
610
+ })
611
+ )
612
+ .then((res) => res.body);
613
+ },
614
+
615
+ /**
616
+ * Leaves the conversation or removes the specified user from the specified
617
+ * conversation
618
+ * @param {Object} conversation
619
+ * @param {Object|string} participant If not specified, defaults to current
620
+ * user
621
+ * @param {Object} activity Reference to the activity that will eventually be
622
+ * posted. Use this to (a) pass in e.g. clientTempId and (b) render a
623
+ * provisional activity
624
+ * @returns {Promise<Activity>}
625
+ */
626
+ leave(conversation, participant, activity) {
627
+ const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
628
+
629
+ return Promise.resolve()
630
+ .then(() => {
631
+ if (!participant) {
632
+ participant = this.webex.internal.device.userId;
633
+ }
634
+
635
+ return this.webex.internal.user.asUUID(participant).then((id) =>
636
+ this.prepare(activity, {
637
+ verb: 'leave',
638
+ target: this.prepareConversation(convoWithUrl),
639
+ object: {
640
+ id,
641
+ objectType: 'person',
642
+ },
643
+ kmsMessage: {
644
+ method: 'delete',
645
+ uri: `<KRO>/authorizations?${querystring.stringify({authId: id})}`,
646
+ },
647
+ })
648
+ );
649
+ })
650
+ .then((a) => this.submit(a));
651
+ },
652
+
653
+ /**
654
+ * Lists a set of conversations. By default does not fetch activities or
655
+ * participants
656
+ * @param {Object} options
657
+ * @param {boolean} options.summary - when true, use conversationSummary resource
658
+ * @param {Number} options.conversationsLimit - limit the number of conversations fetched
659
+ * @param {boolean} options.deferDecrypt - when true, deferDecrypt tells the
660
+ * payload transformer to normalize (but not decrypt) each received
661
+ * conversation. Instead, the received conversations will each have a bound
662
+ * decrypt method that can be executed at the consumer's leisure
663
+ * @returns {Promise<Array<Conversation>>}
664
+ */
665
+ list(options = {}) {
666
+ return this._list({
667
+ service: 'conversation',
668
+ resource: options.summary ? 'conversationsSummary' : 'conversations',
669
+ qs: omit(options, ['deferDecrypt', 'summary']),
670
+ deferDecrypt: options.deferDecrypt,
671
+ limit: getConvoLimit(options),
672
+ }).then((results) => {
673
+ for (const convo of results) {
674
+ idToUrl.set(convo.id, convo.url);
675
+ }
676
+
677
+ return results;
678
+ });
679
+ },
680
+
681
+ /**
682
+ * Paginates through a set of conversations. By default does not fetch activities or
683
+ * participants
684
+ * @param {Object} options
685
+ * @param {boolean} options.deferDecrypt - when true, deferDecrypt tells the
686
+ * payload transformer to normalize (but not decrypt) each received
687
+ * conversation. Instead, the received conversations will each have a bound
688
+ * decrypt method that can be executed at the consumer's leisure
689
+ * @param {Page} options.page - After the first result has been returned to a consumer,
690
+ * you can pass the Page back to the sdk to get the next list of results.
691
+ * @returns {Promise<Array<Conversation>>}
692
+ */
693
+ async paginate(options = {}) {
694
+ if (options.page) {
695
+ // We were passed a page but we are out of results
696
+ if (!options.page.links || !options.page.links.next) {
697
+ throw new Error('No link to follow for the provided page');
698
+ }
699
+
700
+ // Go get the next page of results
701
+ return this.request({
702
+ url: options.page.links.next,
703
+ }).then((res) => ({page: new Page(res, this.webex)}));
704
+ }
705
+
706
+ // No page - so this is the first request to kick off the pagination process
707
+ const queryOptions = {
708
+ personRefresh: true,
709
+ uuidEntryFormat: true,
710
+ activitiesLimit: 0,
711
+ participantsLimit: 0,
712
+ paginate: true,
713
+ ...omit(options, ['deferDecrypt', 'url']),
714
+ };
715
+
716
+ const reqOptions = {
717
+ qs: queryOptions,
718
+ deferDecrypt: options.deferDecrypt,
719
+ limit: getConvoLimit(options),
720
+ };
721
+
722
+ // if options.url is present we likely received one or more additional urls due to federation. In this case
723
+ // we need to initialize pagination against that url instead of the default home cluster
724
+ if (options.url) {
725
+ reqOptions.uri = `${options.url}/conversations`;
726
+ } else {
727
+ reqOptions.service = 'conversation';
728
+ reqOptions.resource = 'conversations';
729
+ }
730
+
731
+ return this.request(reqOptions).then((res) => {
732
+ const response = {
733
+ page: new Page(res, this.webex),
734
+ };
735
+
736
+ if (res.body && res.body.additionalUrls) {
737
+ response.additionalUrls = res.body.additionalUrls;
738
+ }
739
+
740
+ return response;
741
+ });
742
+ },
743
+
744
+ /**
745
+ * Lists the conversations the current user has left. By default does not
746
+ * fetch activities or participants
747
+ * @param {Object} options
748
+ * @returns {Promise<Array<Conversation>>}
749
+ */
750
+ listLeft(options) {
751
+ return this._list({
752
+ service: 'conversation',
753
+ resource: 'conversations/left',
754
+ qs: options,
755
+ limit: getConvoLimit(options),
756
+ }).then((results) => {
757
+ for (const convo of results) {
758
+ idToUrl.set(convo.id, convo.url);
759
+ }
760
+
761
+ return results;
762
+ });
763
+ },
764
+
765
+ /**
766
+ * List activities for the specified conversation
767
+ * @param {Object} options
768
+ * @param {String} options.conversationUrl URL to the conversation
769
+ * @returns {Promise<Array<Activity>>}
770
+ */
771
+ listActivities(options) {
772
+ return this._listActivities(Object.assign(options, {resource: 'activities'}));
773
+ },
774
+
775
+ /**
776
+ * @typedef QueryOptions
777
+ * @param {number} [limit] The limit of child activities that can be returned per request
778
+ * @param {boolean} [latestActivityFirst] Sort order for the child activities
779
+ * @param {boolean} [includeParentActivity] Enables the parent activity to be returned in the activity list
780
+ * @param {string} [sinceDate] Get all child activities after this date
781
+ * @param {string} [maxDate] Get all child activities before this date
782
+ * @param {boolean} [latestActivityFirst] Sort order for the child activities
783
+ * @param {string} [activityType] The type of children to return the parents of, a null value here returns parents of all types of children.
784
+ * The value is one of 'reply', 'edit', 'cardAction', 'reaction', 'reactionSummary', 'reactionSelfSummary'
785
+ */
786
+
787
+ /**
788
+ * Get all parent ids for a conversation.
789
+ * @param {string} conversationUrl conversation URL.
790
+ * @param {QueryOptions} [query] object containing query string values to be appended to the url
791
+ * @returns {Promise<Array<String>>}
792
+ */
793
+ async listParentActivityIds(conversationUrl, query) {
794
+ const params = {
795
+ method: 'GET',
796
+ url: `${conversationUrl}/parents`,
797
+ qs: query,
798
+ };
799
+
800
+ const response = await this.request(params);
801
+
802
+ return response.body;
803
+ },
804
+
805
+ /**
806
+ * Returns a list of _all_ child activities for a given parentId within a given conversation
807
+ * @param {object} [options = {}]
808
+ * @param {string} [options.conversationUrl] targeted conversation URL
809
+ * @param {string} [options.activityParentId] parent id of edit activities or thread activities
810
+ * @param {QueryOptions} [options.query] object containing query string values to be appended to the url
811
+ * @returns {Promise<Array>}
812
+ */
813
+ async listAllChildActivitiesByParentId(options = {}) {
814
+ const {conversationUrl, activityParentId, query} = options;
815
+ const {activityType} = query;
816
+
817
+ const initialResponse = await this.listChildActivitiesByParentId(
818
+ conversationUrl,
819
+ activityParentId,
820
+ activityType,
821
+ query
822
+ );
823
+
824
+ let page = new Page(initialResponse, this.webex);
825
+
826
+ const items = [...page.items];
827
+
828
+ while (page.hasNext()) {
829
+ // eslint-disable-next-line no-await-in-loop
830
+ page = await page.next();
831
+ for (const activity of page) {
832
+ items.push(activity);
833
+ }
834
+ }
835
+
836
+ // reverse list if needed (see _list for precedent)
837
+ if (items.length && last(items).published < items[0].published) {
838
+ items.reverse();
839
+ }
840
+
841
+ return items;
842
+ },
843
+
844
+ /**
845
+ * Return a list of child activities with a given conversation, parentId and other constraints.
846
+ * @param {string} conversationUrl targeted conversation URL
847
+ * @param {string} activityParentId parent id of edit activities or thread activities
848
+ * @param {string} activityType type of child activity to return
849
+ * The value is one of 'reply', 'edit', 'cardAction', 'reaction', 'reactionSummary', 'reactionSelfSummary'
850
+ * @param {QueryOptions} [query = {}] object containing query string values to be appended to the url
851
+ * @returns {Promise<Array>}
852
+ */
853
+ async listChildActivitiesByParentId(conversationUrl, activityParentId, activityType, query = {}) {
854
+ const finalQuery = {
855
+ ...query,
856
+ activityType,
857
+ };
858
+ const params = {
859
+ method: 'GET',
860
+ url: `${conversationUrl}/parents/${activityParentId}`,
861
+ qs: finalQuery,
862
+ };
863
+
864
+ return this.request(params);
865
+ },
866
+
867
+ /**
868
+ * Return an array of reactionSummary and reactionSelfSummary objects
869
+ * @param {string} conversationUrl targeted conversation URL
870
+ * @param {string} activityParentId parent id of reaction activities
871
+ * @param {QueryOptions} query object representing query parameters to pass to convo endpoint
872
+ * @returns {Promise<Array>}
873
+ */
874
+ async getReactionSummaryByParentId(conversationUrl, activityParentId, query) {
875
+ const {body} = await this.request({
876
+ method: 'GET',
877
+ url: `${conversationUrl}/activities/${activityParentId}`,
878
+ qs: query,
879
+ });
880
+
881
+ const reactionObjects = body.children
882
+ ? body.children.filter(
883
+ (child) => child.type === 'reactionSelfSummary' || child.type === 'reactionSummary'
884
+ )
885
+ : [];
886
+
887
+ return reactionObjects;
888
+ },
889
+
890
+ /**
891
+ * Lists activities in which the current user was mentioned
892
+ * @param {Object} options
893
+ * @returns {Promise<Array<Activity>>}
894
+ */
895
+ listMentions(options) {
896
+ return this._list({
897
+ service: 'conversation',
898
+ resource: 'mentions',
899
+ qs: omit(options, 'mentions'),
900
+ });
901
+ },
902
+
903
+ /**
904
+ * Mutes the mentions of a conversation
905
+ * @param {Conversation~ConversationObject} conversation
906
+ * @param {Conversation~ActivityObject} activity
907
+ * @returns {Promise} Resolves with the created activity
908
+ */
909
+ muteMentions(conversation, activity) {
910
+ return this.tag(
911
+ conversation,
912
+ {
913
+ tags: ['MENTION_NOTIFICATIONS_OFF'],
914
+ },
915
+ activity
916
+ );
917
+ },
918
+
919
+ /**
920
+ * Mutes the messages of a conversation
921
+ * @param {Conversation~ConversationObject} conversation
922
+ * @param {Conversation~ActivityObject} activity
923
+ * @returns {Promise} Resolves with the created activity
924
+ */
925
+ muteMessages(conversation, activity) {
926
+ return this.tag(
927
+ conversation,
928
+ {
929
+ tags: ['MESSAGE_NOTIFICATIONS_OFF'],
930
+ },
931
+ activity
932
+ );
933
+ },
934
+
935
+ /**
936
+ * Starts ignoring conversation
937
+ * @param {Conversation~ConversationObject} conversation
938
+ * @param {Conversation~ActivityObject} activity
939
+ * @returns {Promise} Resolves with the created activity
940
+ */
941
+ ignore(conversation, activity) {
942
+ return this.tag(
943
+ conversation,
944
+ {
945
+ tags: ['IGNORED'],
946
+ },
947
+ activity
948
+ );
949
+ },
950
+
951
+ /**
952
+ * @param {Object} conversation
953
+ * @param {Object} inputs
954
+ * @param {Object} parentActivity
955
+ * @param {Object} activity
956
+ * @returns {Promise}
957
+ */
958
+ cardAction(conversation, inputs, parentActivity, activity = {}) {
959
+ const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
960
+
961
+ activity.parent = {
962
+ id: parentActivity.id,
963
+ type: 'cardAction',
964
+ };
965
+
966
+ return this.prepare(activity, {
967
+ verb: 'cardAction',
968
+ target: this.prepareConversation(convoWithUrl),
969
+ object: {objectType: 'submit', ...inputs},
970
+ }).then((a) => this.submit(a));
971
+ },
972
+
973
+ /**
974
+ * Posts a message to a conversation
975
+ * @param {Object} conversation
976
+ * @param {Object|string} message if string, treated as plaintext; if object,
977
+ * assumed to be object property of `post` activity
978
+ * @param {Object} activity Reference to the activity that will eventually be
979
+ * posted. Use this to (a) pass in e.g. clientTempId and (b) render a
980
+ * provisional activity
981
+ * @returns {Promise<Activity>}
982
+ */
983
+ post(conversation, message, activity) {
984
+ const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
985
+
986
+ if (isString(message)) {
987
+ message = {
988
+ displayName: message,
989
+ };
990
+ }
991
+
992
+ return this.prepare(activity, {
993
+ verb: 'post',
994
+ target: this.prepareConversation(convoWithUrl),
995
+ object: {objectType: 'comment', ...message},
996
+ }).then((a) => this.submit(a));
997
+ },
998
+
999
+ prepareConversation(conversation) {
1000
+ return defaults(
1001
+ pick(
1002
+ conversation,
1003
+ 'id',
1004
+ 'url',
1005
+ 'objectType',
1006
+ 'defaultActivityEncryptionKeyUrl',
1007
+ 'kmsResourceObjectUrl'
1008
+ ),
1009
+ {
1010
+ objectType: 'conversation',
1011
+ }
1012
+ );
1013
+ },
1014
+
1015
+ prepare(activity, params) {
1016
+ params = params || {};
1017
+ activity = activity || {};
1018
+
1019
+ return Promise.resolve(activity.prepare ? activity.prepare(params) : activity).then((act) => {
1020
+ defaults(act, {
1021
+ verb: params.verb,
1022
+ kmsMessage: params.kmsMessage,
1023
+ objectType: 'activity',
1024
+ clientTempId: uuid.v4(),
1025
+ actor: this.webex.internal.device.userId,
1026
+ });
1027
+
1028
+ // Workaround because parent is a reserved props in Ampersand
1029
+ if (
1030
+ (activity.parentActivityId && activity.activityType) ||
1031
+ (activity.parent && activity.parent.id && activity.parent.type)
1032
+ ) {
1033
+ act.parent = {
1034
+ id: activity.parentActivityId || activity.parent.id,
1035
+ type: activity.activityType || activity.parent.type,
1036
+ };
1037
+ }
1038
+
1039
+ if (isString(act.actor)) {
1040
+ act.actor = {
1041
+ objectType: 'person',
1042
+ id: act.actor,
1043
+ };
1044
+ }
1045
+
1046
+ ['actor', 'object'].forEach((key) => {
1047
+ if (params[key]) {
1048
+ act[key] = act[key] || {};
1049
+ defaults(act[key], params[key]);
1050
+ }
1051
+ });
1052
+
1053
+ if (params.target) {
1054
+ merge(act, {
1055
+ target: pick(
1056
+ params.target,
1057
+ 'id',
1058
+ 'url',
1059
+ 'objectType',
1060
+ 'kmsResourceObjectUrl',
1061
+ 'defaultActivityEncryptionKeyUrl'
1062
+ ),
1063
+ });
1064
+ }
1065
+
1066
+ ['object', 'target'].forEach((key) => {
1067
+ if (act[key] && act[key].url && !act[key].id) {
1068
+ act[key].id = act[key].url.split('/').pop();
1069
+ }
1070
+ });
1071
+
1072
+ ['actor', 'object', 'target'].forEach((key) => {
1073
+ if (act[key] && !act[key].objectType) {
1074
+ // Reminder: throwing here because it's the only way to get out of
1075
+ // this loop in event of an error.
1076
+ throw new Error(`\`act.${key}.objectType\` must be defined`);
1077
+ }
1078
+ });
1079
+
1080
+ if (act.object && act.object.content && !act.object.displayName) {
1081
+ return Promise.reject(
1082
+ new Error('Cannot submit activity object with `content` but no `displayName`')
1083
+ );
1084
+ }
1085
+
1086
+ return act;
1087
+ });
1088
+ },
1089
+
1090
+ /**
1091
+ * Get a subset of threads for a user.
1092
+ * @param {Object} options
1093
+ * @returns {Promise<Array<Activity>>}
1094
+ */
1095
+ async listThreads(options) {
1096
+ return this._list({
1097
+ service: 'conversation',
1098
+ resource: 'threads',
1099
+ qs: omit(options, 'showAllTypes'),
1100
+ });
1101
+ },
1102
+
1103
+ /**
1104
+ * Handles incoming conversation.activity mercury messages
1105
+ * @param {Event} event
1106
+ * @returns {Promise}
1107
+ */
1108
+ processActivityEvent(event) {
1109
+ return this.webex.transform('inbound', event).then(() => event);
1110
+ },
1111
+
1112
+ /**
1113
+ * Handles incoming conversation.inmeetingchat.activity mercury messages
1114
+ * @param {Event} event
1115
+ * @returns {Promise}
1116
+ */
1117
+ processInmeetingchatEvent(event) {
1118
+ return this.webex.transform('inbound', event).then(() => event);
1119
+ },
1120
+
1121
+ /**
1122
+ * Removes all mute-related tags
1123
+ * @param {Conversation~ConversationObject} conversation
1124
+ * @param {Conversation~ActivityObject} activity
1125
+ * @returns {Promise} Resolves with the created activity
1126
+ */
1127
+ removeAllMuteTags(conversation, activity) {
1128
+ return this.untag(
1129
+ conversation,
1130
+ {
1131
+ tags: [
1132
+ 'MENTION_NOTIFICATIONS_OFF',
1133
+ 'MENTION_NOTIFICATIONS_ON',
1134
+ 'MESSAGE_NOTIFICATIONS_OFF',
1135
+ 'MESSAGE_NOTIFICATIONS_ON',
1136
+ ],
1137
+ },
1138
+ activity
1139
+ );
1140
+ },
1141
+
1142
+ /**
1143
+ * Creates a ShareActivty for the specified conversation
1144
+ * @param {Object} conversation
1145
+ * @param {Object} activity
1146
+ * @returns {ShareActivty}
1147
+ */
1148
+ makeShare(conversation, activity) {
1149
+ // if we pass activity as null then it does not take care of the
1150
+ // clientTempId created by the web-client while making the provisional
1151
+ // activity, hence we need to pass the activity which was created by the
1152
+ // web-client. This fixes the issue where the image activities do not come
1153
+ // back properly oriented from the server since the clientTempId is missing
1154
+ return ShareActivity.create(conversation, activity, this.webex);
1155
+ },
1156
+
1157
+ /**
1158
+ * Assigns an avatar to a room
1159
+ * @param {Object} conversation
1160
+ * @param {File} avatar
1161
+ * @returns {Promise<Activity>}
1162
+ */
1163
+ assign(conversation, avatar) {
1164
+ const uploadOptions = {role: 'spaceAvatar'};
1165
+
1166
+ if ((avatar.size || avatar.length) > 1024 * 1024) {
1167
+ return Promise.reject(new Error('Room avatars must be less than 1MB'));
1168
+ }
1169
+
1170
+ const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
1171
+
1172
+ return Promise.resolve()
1173
+ .then(() => {
1174
+ const activity = ShareActivity.create(conversation, null, this.webex);
1175
+
1176
+ activity.enableThumbnails = false;
1177
+ activity.add(avatar, uploadOptions);
1178
+
1179
+ return this.prepare(activity, {
1180
+ target: this.prepareConversation(convoWithUrl),
1181
+ });
1182
+ })
1183
+ .then((a) => {
1184
+ // yes, this seems a little hacky; will likely be resolved as a result
1185
+ // of #213
1186
+ a.verb = 'assign';
1187
+
1188
+ return this.submit(a);
1189
+ });
1190
+ },
1191
+
1192
+ /**
1193
+ * Get url from convo object. If there isn't one, get it from the cache
1194
+ *
1195
+ * @param {String} url The location of the conversation
1196
+ * @param {UUID} id If there is no url, fall back to id to lookup in cache or with cluster
1197
+ * @param {String} cluster Used with id to lookup url
1198
+ * @param {UUID} generalConversationUuid If this is a team, the id of the general conversation
1199
+ * @param {Object} conversations If this is a team, the list of conversations in the team
1200
+ * @returns {String} url for the specific convo
1201
+ */
1202
+ getConvoUrl({id, url, cluster, conversations, generalConversationUuid}) {
1203
+ if (generalConversationUuid) {
1204
+ // This is a Team
1205
+ // Because Convo doesn't have an endpoint for the team URL
1206
+ // we have to use the general convo URL.
1207
+ const generalConvo = conversations.items.find(
1208
+ (convo) => convo.id === generalConversationUuid
1209
+ );
1210
+
1211
+ return generalConvo.url;
1212
+ }
1213
+
1214
+ if (url) {
1215
+ return url;
1216
+ }
1217
+
1218
+ if (id) {
1219
+ if (cluster) {
1220
+ return this.getUrlFromClusterId({cluster, id});
1221
+ }
1222
+ this.logger.warn('You should be using the `url` instead of the `id` property');
1223
+ const relatedUrl = idToUrl.get(id);
1224
+
1225
+ if (!relatedUrl) {
1226
+ throw Error('Could not find the `url` from the given `id`');
1227
+ }
1228
+
1229
+ return relatedUrl;
1230
+ }
1231
+
1232
+ throw Error('The space needs a `url` property');
1233
+ },
1234
+
1235
+ /**
1236
+ * Sets the typing status of the current user in a conversation
1237
+ *
1238
+ * @param {Object} conversation
1239
+ * @param {Object} options
1240
+ * @param {boolean} options.typing
1241
+ * @returns {Promise}
1242
+ */
1243
+ updateTypingStatus(conversation, options) {
1244
+ if (!conversation.id) {
1245
+ if (conversation.url) {
1246
+ conversation.id = conversation.url.split('/').pop();
1247
+ } else {
1248
+ return Promise.reject(new Error('conversation: could not identify conversation'));
1249
+ }
1250
+ }
1251
+
1252
+ let eventType;
1253
+
1254
+ if (options.typing) {
1255
+ eventType = 'status.start_typing';
1256
+ } else {
1257
+ eventType = 'status.stop_typing';
1258
+ }
1259
+
1260
+ const url = this.getConvoUrl(conversation);
1261
+ const resource = 'status/typing';
1262
+ const params = {
1263
+ method: 'POST',
1264
+ body: {
1265
+ conversationId: conversation.id,
1266
+ eventType,
1267
+ },
1268
+ url: `${url}/${resource}`,
1269
+ };
1270
+
1271
+ return this.request(params);
1272
+ },
1273
+
1274
+ /**
1275
+ * Shares files to the specified conversation
1276
+ * @param {Object} conversation
1277
+ * @param {ShareActivity|Array<File>} activity
1278
+ * @returns {Promise<Activity>}
1279
+ */
1280
+ share(conversation, activity) {
1281
+ if (isArray(activity)) {
1282
+ activity = {
1283
+ object: {
1284
+ files: activity,
1285
+ },
1286
+ };
1287
+ }
1288
+
1289
+ const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
1290
+
1291
+ if (!(activity instanceof ShareActivity)) {
1292
+ activity = ShareActivity.create(convoWithUrl, activity, this.webex);
1293
+ }
1294
+
1295
+ return this.prepare(activity, {
1296
+ target: this.prepareConversation(convoWithUrl),
1297
+ }).then((a) => this.submit(a));
1298
+ },
1299
+
1300
+ /**
1301
+ * Submits an activity to the conversation service
1302
+ * @param {Object} activity
1303
+ * @param {String} [endpoint] endpoint to submit activity. If empty, find in activity
1304
+ * @returns {Promise<Activity>}
1305
+ */
1306
+ submit(activity, endpoint) {
1307
+ const url = endpoint || this.getConvoUrl(activity.target);
1308
+ const resource = activity.verb === 'share' ? 'content' : 'activities';
1309
+ const params = {
1310
+ method: 'POST',
1311
+ body: activity,
1312
+ qs: {
1313
+ personRefresh: true,
1314
+ },
1315
+ url: `${url}/${resource}`,
1316
+ };
1317
+
1318
+ if (activity.verb === 'share') {
1319
+ Object.assign(params.qs, {
1320
+ transcode: true,
1321
+ async: false,
1322
+ });
1323
+ }
1324
+ /**
1325
+ * helper to cloneDeepWith for copying instance function
1326
+ * @param {Object|String|Symbol|Array|Date} value (recursive value to clone from params)
1327
+ * @returns {Object|null}
1328
+ */
1329
+ // eslint-disable-next-line consistent-return
1330
+ const customActivityCopy = (value) => {
1331
+ const {files} = params.body.object;
1332
+
1333
+ if (
1334
+ files &&
1335
+ value &&
1336
+ files.items.length > 0 &&
1337
+ value.constructor === files.items[0].scr.constructor
1338
+ ) {
1339
+ const copySrc = cloneDeep(value);
1340
+
1341
+ copySrc.toJWE = value.toJWE;
1342
+ copySrc.toJSON = value.toJSON;
1343
+
1344
+ return copySrc;
1345
+ }
1346
+ };
1347
+ const cloneActivity = cloneDeepWith(params, customActivityCopy);
1348
+
1349
+ // triggers user-activity to reset logout timer
1350
+ this.webex.trigger('user-activity');
1351
+
1352
+ return this.request(params)
1353
+ .then((res) => res.body)
1354
+ .catch((error) => {
1355
+ // handle when key need to rotate
1356
+ if (error.body && error.body.errorCode === KEY_ROTATION_REQUIRED) {
1357
+ cloneActivity.body.target.defaultActivityEncryptionKeyUrl = null;
1358
+ this.request(cloneActivity);
1359
+ } else if (
1360
+ error.body &&
1361
+ (error.body.errorCode === KEY_ALREADY_ROTATED ||
1362
+ error.body.errorCode === ENCRYPTION_KEY_URL_MISMATCH)
1363
+ ) {
1364
+ // handle when key need to update
1365
+ this.webex
1366
+ .request({
1367
+ method: 'GET',
1368
+ api: 'conversation',
1369
+ resource: `conversations/${params.body.target.id}`,
1370
+ })
1371
+ .then((res) => {
1372
+ cloneActivity.body.target.defaultActivityEncryptionKeyUrl =
1373
+ res.body.defaultActivityEncryptionkeyUrl;
1374
+ this.request(cloneActivity);
1375
+ });
1376
+ } else {
1377
+ throw error;
1378
+ }
1379
+ });
1380
+ },
1381
+ /**
1382
+ * Remove the avatar from a room
1383
+ * @param {Conversation~ConversationObject} conversation
1384
+ * @param {Conversation~ActivityObject} activity
1385
+ * @returns {Promise}
1386
+ */
1387
+ unassign(conversation, activity) {
1388
+ const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
1389
+
1390
+ return this.prepare(activity, {
1391
+ verb: 'unassign',
1392
+ target: this.prepareConversation(convoWithUrl),
1393
+ object: {
1394
+ objectType: 'content',
1395
+ files: {
1396
+ items: [],
1397
+ },
1398
+ },
1399
+ }).then((a) => this.submit(a));
1400
+ },
1401
+
1402
+ /**
1403
+ * Mutes the mentions of a conversation
1404
+ * @param {Conversation~ConversationObject} conversation
1405
+ * @param {Conversation~ActivityObject} activity
1406
+ * @returns {Promise} Resolves with the created activity
1407
+ */
1408
+ unmuteMentions(conversation, activity) {
1409
+ return this.tag(
1410
+ conversation,
1411
+ {
1412
+ tags: ['MENTION_NOTIFICATIONS_ON'],
1413
+ },
1414
+ activity
1415
+ );
1416
+ },
1417
+
1418
+ /**
1419
+ * Mutes the messages of a conversation
1420
+ * @param {Conversation~ConversationObject} conversation
1421
+ * @param {Conversation~ActivityObject} activity
1422
+ * @returns {Promise} Resolves with the created activity
1423
+ */
1424
+ unmuteMessages(conversation, activity) {
1425
+ return this.tag(
1426
+ conversation,
1427
+ {
1428
+ tags: ['MESSAGE_NOTIFICATIONS_ON'],
1429
+ },
1430
+ activity
1431
+ );
1432
+ },
1433
+
1434
+ /**
1435
+ * Stops ignoring conversation
1436
+ * @param {Conversation~ConversationObject} conversation
1437
+ * @param {Conversation~ActivityObject} activity
1438
+ * @returns {Promise} Resolves with the created activity
1439
+ */
1440
+ unignore(conversation, activity) {
1441
+ return this.untag(
1442
+ conversation,
1443
+ {
1444
+ tags: ['IGNORED'],
1445
+ },
1446
+ activity
1447
+ );
1448
+ },
1449
+
1450
+ /**
1451
+ * Update an existing activity
1452
+ * @param {Object} conversation
1453
+ * @param {Object} object
1454
+ * @param {Object} activity
1455
+ * @returns {Promise}
1456
+ */
1457
+ update(conversation, object, activity) {
1458
+ if (!isObject(object)) {
1459
+ return Promise.reject(new Error('`object` must be an object'));
1460
+ }
1461
+
1462
+ const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
1463
+
1464
+ return this.prepare(activity, {
1465
+ verb: 'update',
1466
+ target: this.prepareConversation(convoWithUrl),
1467
+ object,
1468
+ }).then((a) => this.submit(a));
1469
+ },
1470
+
1471
+ /**
1472
+ * Sets a new key for the conversation
1473
+ * @param {Object} conversation
1474
+ * @param {Key|string} key (optional)
1475
+ * @param {Object} activity Reference to the activity that will eventually be
1476
+ * posted. Use this to (a) pass in e.g. clientTempId and (b) render a
1477
+ * provisional activity
1478
+ * @returns {Promise<Activity>}
1479
+ */
1480
+ updateKey(conversation, key, activity) {
1481
+ const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
1482
+
1483
+ return this.get(convoWithUrl, {
1484
+ activitiesLimit: 0,
1485
+ includeParticipants: true,
1486
+ }).then((c) => this._updateKey(c, key, activity));
1487
+ },
1488
+
1489
+ /**
1490
+ * Sets a new key for the conversation
1491
+ * @param {Object} conversation
1492
+ * @param {Key|string} key (optional)
1493
+ * @param {Object} activity Reference to the activity that will eventually be
1494
+ * posted. Use this to (a) pass in e.g. clientTempId and (b) render a
1495
+ * provisional activity
1496
+ * @private
1497
+ * @returns {Promise<Activity>}
1498
+ */
1499
+ _updateKey(conversation, key, activity) {
1500
+ const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
1501
+
1502
+ return Promise.resolve(
1503
+ key || this.webex.internal.encryption.kms.createUnboundKeys({count: 1})
1504
+ ).then((keys) => {
1505
+ const k = isArray(keys) ? keys[0] : keys;
1506
+ const params = {
1507
+ verb: 'updateKey',
1508
+ target: this.prepareConversation(convoWithUrl),
1509
+ object: {
1510
+ defaultActivityEncryptionKeyUrl: k.uri,
1511
+ objectType: 'conversation',
1512
+ },
1513
+ };
1514
+
1515
+ // Reminder: the kmsResourceObjectUrl is only usable if there is
1516
+ // defaultActivityEncryptionKeyUrl.
1517
+ // Valid defaultActivityEncryptionKeyUrl start with 'kms:'
1518
+ if (
1519
+ convoWithUrl.kmsResourceObjectUrl &&
1520
+ convoWithUrl.kmsResourceObjectUrl.startsWith('kms:')
1521
+ ) {
1522
+ params.kmsMessage = {
1523
+ method: 'update',
1524
+ resourceUri: '<KRO>',
1525
+ uri: k.uri,
1526
+ };
1527
+ } else {
1528
+ params.kmsMessage = {
1529
+ method: 'create',
1530
+ uri: '/resources',
1531
+ userIds: map(convoWithUrl.participants.items, 'id'),
1532
+ keyUris: [k.uri],
1533
+ };
1534
+ }
1535
+
1536
+ return this.prepare(activity, params).then((a) => this.submit(a));
1537
+ });
1538
+ },
1539
+
1540
+ /**
1541
+ * @param {Object} payload
1542
+ * @param {Object} options
1543
+ * @private
1544
+ * @returns {Promise<Activity>}
1545
+ */
1546
+ _create(payload, options = {}) {
1547
+ return this.request({
1548
+ method: 'POST',
1549
+ service: 'conversation',
1550
+ resource: 'conversations',
1551
+ body: payload,
1552
+ qs: {
1553
+ forceCreate: options.allowPartialCreation,
1554
+ },
1555
+ }).then((res) => res.body);
1556
+ },
1557
+
1558
+ /**
1559
+ * @param {Object} params
1560
+ * @param {Object} options
1561
+ * @private
1562
+ * @returns {Promise}
1563
+ */
1564
+ _createGrouped(params, options) {
1565
+ return this._create(this._prepareConversationForCreation(params), options);
1566
+ },
1567
+
1568
+ /**
1569
+ * @param {Object} params
1570
+ * @param {Object} options
1571
+ * @private
1572
+ * @returns {Promise}
1573
+ */
1574
+ _createOneOnOne(params) {
1575
+ const payload = this._prepareConversationForCreation(params);
1576
+
1577
+ payload.tags = ['ONE_ON_ONE'];
1578
+
1579
+ return this._create(payload);
1580
+ },
1581
+
1582
+ /**
1583
+ * Get the current conversation url.
1584
+ *
1585
+ * @returns {Promise<string>} - conversation url
1586
+ */
1587
+ getConversationUrl() {
1588
+ this.logger.info('conversation: getting the conversation service url');
1589
+
1590
+ const convoUrl = this.webex.internal.services.get('conversation');
1591
+
1592
+ // Validate if the conversation url exists in the services plugin and
1593
+ // resolve with its value.
1594
+ if (convoUrl) {
1595
+ return Promise.resolve(convoUrl);
1596
+ }
1597
+
1598
+ // Wait for the postauth catalog to update and then try to retrieve the
1599
+ // conversation service url again.
1600
+ return this.webex.internal
1601
+ .waitForCatalog('postauth')
1602
+ .then(() => this.webex.internal.services.get('conversation'))
1603
+ .catch((error) => {
1604
+ this.logger.warn('conversation: unable to get conversation url', error.message);
1605
+
1606
+ return Promise.reject(error);
1607
+ });
1608
+ },
1609
+
1610
+ /**
1611
+ * @param {Object} conversation
1612
+ * @private
1613
+ * @returns {Promise}
1614
+ */
1615
+ _inferConversationUrl(conversation) {
1616
+ if (conversation.id) {
1617
+ return this.webex.internal.feature
1618
+ .getFeature('developer', 'web-high-availability')
1619
+ .then((haMessagingEnabled) => {
1620
+ if (haMessagingEnabled) {
1621
+ // recompute conversation URL each time as the host may have changed
1622
+ // since last usage
1623
+ return this.getConversationUrl().then((url) => {
1624
+ conversation.url = `${url}/conversations/${conversation.id}`;
1625
+
1626
+ return conversation;
1627
+ });
1628
+ }
1629
+ if (!conversation.url) {
1630
+ return this.getConversationUrl().then((url) => {
1631
+ conversation.url = `${url}/conversations/${conversation.id}`;
1632
+ /* istanbul ignore else */
1633
+ if (process.env.NODE_ENV !== 'production') {
1634
+ this.logger.warn(
1635
+ 'conversation: inferred conversation url from conversation id; please pass whole conversation objects to Conversation methods'
1636
+ );
1637
+ }
1638
+
1639
+ return conversation;
1640
+ });
1641
+ }
1642
+
1643
+ return Promise.resolve(conversation);
1644
+ });
1645
+ }
1646
+
1647
+ return Promise.resolve(conversation);
1648
+ },
1649
+
1650
+ /**
1651
+ * @param {Object} options
1652
+ * @param {String} options.conversationUrl URL to the conversation
1653
+ * @param {String} options.resource The URL resource to hit for a list of objects
1654
+ * @private
1655
+ * @returns {Promise<Array<Activity>>}
1656
+ */
1657
+ _listActivities(options) {
1658
+ const id = options.conversationId;
1659
+ const url = this.getConvoUrl({url: options.conversationUrl, id});
1660
+ const {resource} = options;
1661
+
1662
+ return this._list({
1663
+ qs: omit(options, 'resource'),
1664
+ url: `${url}/${resource}`,
1665
+ });
1666
+ },
1667
+
1668
+ /**
1669
+ * common interface for facade of generator functions
1670
+ * @typedef {object} IGeneratorResponse
1671
+ * @param {boolean} done whether there is more to fetch
1672
+ * @param {any} value the value yielded or returned by generator
1673
+ */
1674
+
1675
+ /**
1676
+ * @param {object} options
1677
+ * @param {string} options.conversationId
1678
+ * @param {string} options.conversationUrl,
1679
+ * @param {boolean} options.includeChildren, If set to true, parent activities will be enhanced with child objects
1680
+ * @param {number} options.minActivities how many activities to return in first batch
1681
+ * @param {?string} [options.queryType] one of older, newer, mid. defines which direction to fetch
1682
+ * @param {?object} [options.search] server activity to use as search middle date
1683
+ *
1684
+ * @returns {object}
1685
+ * returns three functions:
1686
+ *
1687
+ * getOlder - gets older activities than oldest fetched
1688
+ *
1689
+ * getNewer - gets newer activities than newest fetched
1690
+ *
1691
+ * jumpToActivity - gets searched-for activity and surrounding activities
1692
+ */
1693
+ listActivitiesThreadOrdered(options) {
1694
+ const {conversationUrl, conversationId} = options;
1695
+
1696
+ if (!conversationUrl && !conversationId) {
1697
+ throw new Error('must provide a conversation URL or conversation ID');
1698
+ }
1699
+
1700
+ const url = this.getConvoUrl({url: conversationUrl, id: conversationId});
1701
+
1702
+ const baseOptions = {...omit(options, ['conversationUrl', 'conversationId']), url};
1703
+
1704
+ const olderOptions = {...baseOptions, queryType: OLDER};
1705
+
1706
+ let threadOrderer = this._listActivitiesThreadOrdered(baseOptions);
1707
+
1708
+ /**
1709
+ * gets queried activity and surrounding activities
1710
+ * calling this function creates a new generator instance, losing the previous instance's internal state
1711
+ * this ensures that jumping to older and newer activities is relative to a single set of timestamps, not many
1712
+ * @param {object} searchObject activity object from convo
1713
+ * @returns {IGeneratorResponse}
1714
+ */
1715
+ const jumpToActivity = async (searchObject) => {
1716
+ if (!searchObject) {
1717
+ throw new Error('Search must be an activity object from conversation service');
1718
+ }
1719
+ const newUrl = searchObject.target && searchObject.target.url;
1720
+
1721
+ if (!newUrl) {
1722
+ throw new Error('Search object must have a target url!');
1723
+ }
1724
+
1725
+ const searchOptions = {
1726
+ ...baseOptions,
1727
+ url: newUrl,
1728
+ queryType: MID,
1729
+ search: searchObject,
1730
+ };
1731
+
1732
+ threadOrderer = this._listActivitiesThreadOrdered(searchOptions);
1733
+
1734
+ const {value: searchResults} = await threadOrderer.next(searchOptions);
1735
+
1736
+ return {
1737
+ done: true,
1738
+ value: searchResults,
1739
+ };
1740
+ };
1741
+
1742
+ /**
1743
+ * gets older activities than oldest fetched
1744
+ * @returns {IGeneratorResponse}
1745
+ */
1746
+ const getOlder = async () => {
1747
+ const {value = []} = await threadOrderer.next(olderOptions);
1748
+
1749
+ const oldestInBatch = value[0] && value[0].activity;
1750
+ const moreActivitiesExist =
1751
+ oldestInBatch && getActivityType(oldestInBatch) !== ACTIVITY_TYPES.CREATE;
1752
+
1753
+ return {
1754
+ done: !moreActivitiesExist,
1755
+ value,
1756
+ };
1757
+ };
1758
+
1759
+ /**
1760
+ * gets newer activities than newest fetched
1761
+ * @returns {IGeneratorResponse}
1762
+ */
1763
+ const getNewer = async () => {
1764
+ const newerOptions = {...baseOptions, queryType: NEWER};
1765
+
1766
+ const {value} = await threadOrderer.next(newerOptions);
1767
+
1768
+ return {
1769
+ done: !value.length,
1770
+ value,
1771
+ };
1772
+ };
1773
+
1774
+ return {
1775
+ jumpToActivity,
1776
+ getNewer,
1777
+ getOlder,
1778
+ };
1779
+ },
1780
+
1781
+ /**
1782
+ * Represents reactions to messages
1783
+ * @typedef {object} Reaction
1784
+ * @property {object} activity reaction2summary server activity object
1785
+ */
1786
+
1787
+ /**
1788
+ * Represents a root (parent, with or without children) activity, along with any replies and reactions
1789
+ * @typedef {object} Activity
1790
+ * @property {object} activity server activity object
1791
+ * @property {Reaction} reactions
1792
+ * @property {Reaction} reactionSelf
1793
+ */
1794
+
1795
+ /**
1796
+ * @generator
1797
+ * @method
1798
+ * @async
1799
+ * @private
1800
+ * @param {object} options
1801
+ * @param {string} options.url
1802
+ * @param {boolean} options.includeChildren, If set to true, parent activities will be enhanced with child objects
1803
+ * @param {string} [options.minActivities] how many activities to return in first batch
1804
+ * @param {string} [options.queryType] one of older, newer, mid. defines which direction to fetch
1805
+ * @param {object} [options.search] server activity to use as search middle date
1806
+ *
1807
+ * @yields {Activity[]}
1808
+ *
1809
+ * @returns {void}
1810
+ */
1811
+ async *_listActivitiesThreadOrdered(options = {}) {
1812
+ // ***********************************************
1813
+ // INSTANCE STATE VARIABLES
1814
+ // variables that will be used for the life of the generator
1815
+ // ***********************************************
1816
+
1817
+ let {minActivities = defaultMinDisplayableActivities, queryType = INITIAL} = options;
1818
+
1819
+ // must fetch initially before getting newer activities!
1820
+ if (queryType === NEWER) {
1821
+ queryType = INITIAL;
1822
+ }
1823
+
1824
+ const {url: convoUrl, search = {}, includeChildren} = options;
1825
+
1826
+ // manage oldest, newest activities (ie bookends)
1827
+ const {setBookends, getNewestAct, getOldestAct} = bookendManager();
1828
+
1829
+ // default batch should be equal to minActivities when fetching back in time, but halved when fetching newer due to subsequent child fetches filling up the minActivities count
1830
+ // reduces server RTs when fetching older activities
1831
+ const defaultBatchSize =
1832
+ queryType === INITIAL || queryType === OLDER
1833
+ ? minActivities
1834
+ : Math.max(minBatchSize, Math.ceil(minActivities / 2));
1835
+ let batchSize = defaultBatchSize;
1836
+
1837
+ // exposes activity states and handlers with simple getters
1838
+ const {getActivityHandlerByKey, getActivityByTypeAndParentId} = activityManager();
1839
+
1840
+ // set initial query
1841
+ let query = getQuery(queryType, {activityToSearch: search, batchSize});
1842
+
1843
+ /* eslint-disable no-await-in-loop */
1844
+ /* eslint-disable no-loop-func */
1845
+ while (true) {
1846
+ // ***********************************************
1847
+ // EXECUTION STATE VARIABLES
1848
+ // variables that will be used for each "batch" of activities asked for
1849
+ // ***********************************************
1850
+
1851
+ // stores all "root" activities (activities that are, or could be, thread parents)
1852
+ const {getRootActivityHash, addNewRoot} = rootActivityManager();
1853
+
1854
+ // used to determine if we should continue to fetch older activities
1855
+ // must be set per iteration, as querying newer activities is still valid when all end of convo has been reached
1856
+ const {getNoMoreActs, checkAndSetNoMoreActs, checkAndSetNoOlderActs, checkAndSetNoNewerActs} =
1857
+ noMoreActivitiesManager();
1858
+
1859
+ const getActivityHandlerByType = (type) =>
1860
+ ({
1861
+ [ACTIVITY_TYPES.ROOT]: addNewRoot,
1862
+ [ACTIVITY_TYPES.REPLY]: getActivityHandlerByKey(ACTIVITY_TYPES.REPLY),
1863
+ [ACTIVITY_TYPES.EDIT]: getActivityHandlerByKey(ACTIVITY_TYPES.EDIT),
1864
+ [ACTIVITY_TYPES.REACTION]: getActivityHandlerByKey(ACTIVITY_TYPES.REACTION),
1865
+ [ACTIVITY_TYPES.REACTION_SELF]: getActivityHandlerByKey(ACTIVITY_TYPES.REACTION_SELF),
1866
+ [ACTIVITY_TYPES.TOMBSTONE]: addNewRoot,
1867
+ [ACTIVITY_TYPES.CREATE]: addNewRoot,
1868
+ }[type]);
1869
+
1870
+ const handleNewActivity = (activity) => {
1871
+ const actType = getActivityType(activity);
1872
+
1873
+ // ignore deletes
1874
+ if (isDeleteActivity(activity)) {
1875
+ return;
1876
+ }
1877
+
1878
+ const activityHandler = getActivityHandlerByType(actType);
1879
+
1880
+ activityHandler(activity);
1881
+ };
1882
+
1883
+ const handleNewActivities = (activities) => {
1884
+ activities.forEach((act) => {
1885
+ handleNewActivity(act);
1886
+ checkAndSetNoOlderActs(act);
1887
+ });
1888
+ };
1889
+
1890
+ const handleOlderQuery = (activities) => {
1891
+ setBookends(activities, OLDER);
1892
+ handleNewActivities(activities);
1893
+ };
1894
+ const handleNewerQuery = (activities) => {
1895
+ checkAndSetNoNewerActs(activities);
1896
+ if (activities.length) {
1897
+ setBookends(activities, NEWER);
1898
+ handleNewActivities(activities);
1899
+ }
1900
+ };
1901
+ const handleSearch = (activities) => {
1902
+ setBookends(activities, MID);
1903
+ handleNewActivities(activities);
1904
+ };
1905
+
1906
+ const getQueryResponseHandler = (type) =>
1907
+ ({
1908
+ [OLDER]: handleOlderQuery,
1909
+ [NEWER]: handleNewerQuery,
1910
+ [MID]: handleSearch,
1911
+ [INITIAL]: handleOlderQuery,
1912
+ }[type]);
1913
+
1914
+ // ***********************************************
1915
+ // INNER LOOP
1916
+ // responsible for fetching and building our maps of activities
1917
+ // fetch until minActivities is reached, or no more acts to fetch, or we hit our max fetch count
1918
+ // ***********************************************
1919
+
1920
+ const incrementLoopCounter = getLoopCounterFailsafe();
1921
+
1922
+ while (!getNoMoreActs()) {
1923
+ // count loops and throw if we detect infinite loop
1924
+ incrementLoopCounter();
1925
+
1926
+ // configure fetch request. Use a smaller limit when fetching newer or mids to account for potential children fetches
1927
+ const allBatchActivitiesConfig = {
1928
+ conversationUrl: convoUrl,
1929
+ limit: batchSize,
1930
+ includeChildren,
1931
+ ...query,
1932
+ };
1933
+
1934
+ // request activities in batches
1935
+ const $allBatchActivitiesFetch = this.listActivities(allBatchActivitiesConfig);
1936
+
1937
+ // contain fetches in array to parallelize fetching as needed
1938
+ const $fetchRequests = [$allBatchActivitiesFetch];
1939
+
1940
+ // if query requires recursive fetches for children acts, add the additional fetch
1941
+ if (queryType === MID || queryType === NEWER) {
1942
+ const params = {activityType: null};
1943
+
1944
+ if (query.sinceDate) {
1945
+ params.sinceDate = query.sinceDate;
1946
+ }
1947
+
1948
+ const $parentsFetch = this.listParentActivityIds(convoUrl, params);
1949
+
1950
+ $fetchRequests.push($parentsFetch);
1951
+ }
1952
+
1953
+ // we dont always need to fetch for parents
1954
+ const [allBatchActivities, parents = {}] = await Promise.all($fetchRequests);
1955
+
1956
+ // use query type to decide how to handle response
1957
+ const handler = getQueryResponseHandler(queryType);
1958
+
1959
+ handler(allBatchActivities);
1960
+
1961
+ /*
1962
+ next we must selectively fetch the children of each of the parents to ensure completeness
1963
+ do this by checking the hash for each of the above parent IDs
1964
+ fetch children when we have a parent whose ID is represented in the parent ID lists
1965
+ */
1966
+ const {reply: replyIds = [], edit: editIds = [], reaction: reactionIds = []} = parents;
1967
+
1968
+ // if no parent IDs returned, do nothing
1969
+ if (replyIds.length || editIds.length || reactionIds.length) {
1970
+ const $reactionFetches = [];
1971
+ const $replyFetches = [];
1972
+ const $editFetches = [];
1973
+
1974
+ for (const activity of allBatchActivities) {
1975
+ const actId = activity.id;
1976
+
1977
+ const childFetchOptions = {
1978
+ conversationUrl: convoUrl,
1979
+ activityParentId: actId,
1980
+ };
1981
+
1982
+ if (reactionIds.includes(actId)) {
1983
+ $reactionFetches.push(
1984
+ this.getReactionSummaryByParentId(convoUrl, actId, {
1985
+ activityType: 'reactionSummary',
1986
+ includeChildren: true,
1987
+ })
1988
+ );
1989
+ }
1990
+ if (replyIds.includes(actId)) {
1991
+ childFetchOptions.query = {activityType: 'reply'};
1992
+ $replyFetches.push(this.listAllChildActivitiesByParentId(childFetchOptions));
1993
+ }
1994
+ if (editIds.includes(actId)) {
1995
+ childFetchOptions.query = {activityType: 'edit'};
1996
+ $editFetches.push(this.listAllChildActivitiesByParentId(childFetchOptions));
1997
+ }
1998
+ }
1999
+
2000
+ // parallelize fetch for speeedz
2001
+ const [reactions, replies, edits] = await Promise.all([
2002
+ Promise.all($reactionFetches),
2003
+ Promise.all($replyFetches),
2004
+ Promise.all($editFetches),
2005
+ ]);
2006
+
2007
+ // new reactions may have come in that also need their reactions fetched
2008
+ const newReplyReactions = await Promise.all(
2009
+ replies
2010
+ .filter((reply) => replyIds.includes(reply.id))
2011
+ .map((reply) =>
2012
+ this.getReactionSummaryByParentId(convoUrl, reply.id, {
2013
+ activityType: 'reactionSummary',
2014
+ includeChildren: true,
2015
+ })
2016
+ )
2017
+ );
2018
+
2019
+ const allReactions = [...reactions, ...newReplyReactions];
2020
+
2021
+ // stick them into activity hashes
2022
+ replies.forEach((replyArr) => handleNewActivities(replyArr));
2023
+ edits.forEach((editArr) => handleNewActivities(editArr));
2024
+ allReactions.forEach((reactionArr) => handleNewActivities(reactionArr));
2025
+ }
2026
+
2027
+ const rootActivityHash = getRootActivityHash();
2028
+ let visibleActivitiesCount = rootActivityHash.size;
2029
+
2030
+ for (const rootActivity of rootActivityHash.values()) {
2031
+ const {id: rootId} = rootActivity;
2032
+ const repliesByRootId = getActivityByTypeAndParentId(ACTIVITY_TYPES.REPLY, rootId);
2033
+
2034
+ if (repliesByRootId && repliesByRootId.size) {
2035
+ visibleActivitiesCount += repliesByRootId.size || 0;
2036
+ }
2037
+ }
2038
+
2039
+ // stop fetching if we've reached desired count of visible activities
2040
+ if (visibleActivitiesCount >= minActivities) {
2041
+ break;
2042
+ }
2043
+
2044
+ checkAndSetNoMoreActs(queryType, visibleActivitiesCount, batchSize);
2045
+
2046
+ // batchSize should be equal to minimum activities when fetching older activities
2047
+ // covers "best case" when we reach minActivities on the first fetch
2048
+ if (queryType === OLDER) {
2049
+ batchSize = minActivities;
2050
+ }
2051
+
2052
+ // since a MID query can bump the batchSize, we need to reset it _after_ a potential MID query
2053
+ // reset batchSize in case of MID queries bumping it up
2054
+ if (queryType === NEWER) {
2055
+ batchSize = defaultBatchSize;
2056
+ }
2057
+
2058
+ const currentOldestPublishedDate = getPublishedDate(getOldestAct());
2059
+ const currentNewestPublishedDate = getPublishedDate(getNewestAct());
2060
+
2061
+ // we're still building our activity list - derive new query from prior query and start loop again
2062
+ if (queryType === INITIAL) {
2063
+ query = getQuery(OLDER, {oldestPublishedDate: currentOldestPublishedDate, batchSize});
2064
+ } else {
2065
+ query = getQuery(queryType, {
2066
+ batchSize,
2067
+ activityToSearch: search,
2068
+ oldestPublishedDate: currentOldestPublishedDate,
2069
+ newestPublishedDate: currentNewestPublishedDate,
2070
+ });
2071
+ }
2072
+
2073
+ // if we're still building out the midDate search, bump the search limit to include activities on both sides
2074
+ if (queryType === MID) {
2075
+ batchSize += batchSizeIncrementCount;
2076
+ }
2077
+ }
2078
+
2079
+ const orderedActivities = [];
2080
+
2081
+ const getRepliesByParentId = (replyParentId) => {
2082
+ const replies = [];
2083
+
2084
+ const repliesByParentId = getActivityByTypeAndParentId(ACTIVITY_TYPES.REPLY, replyParentId);
2085
+
2086
+ if (!repliesByParentId) {
2087
+ return replies;
2088
+ }
2089
+
2090
+ const sortedReplies = sortActivitiesByPublishedDate(
2091
+ getActivityObjectsFromMap(repliesByParentId)
2092
+ );
2093
+
2094
+ sortedReplies.forEach((replyActivity) => {
2095
+ const replyId = replyActivity.id;
2096
+ const edit = getActivityByTypeAndParentId(ACTIVITY_TYPES.EDIT, replyId);
2097
+ const reaction = getActivityByTypeAndParentId(ACTIVITY_TYPES.REACTION, replyId);
2098
+ const reactionSelf = getActivityByTypeAndParentId(ACTIVITY_TYPES.REACTION_SELF, replyId);
2099
+
2100
+ const latestActivity = edit || replyActivity;
2101
+ // hash of root activities (in case of plain reply) and the reply activity (in case of edit)
2102
+ const allRelevantActivitiesArr = [
2103
+ ...getActivityObjectsFromMap(getRootActivityHash()),
2104
+ ...getActivityObjectsFromMap(repliesByParentId),
2105
+ ];
2106
+ const allRelevantActivities = allRelevantActivitiesArr.reduce((hashMap, act) => {
2107
+ hashMap[act.id] = act;
2108
+
2109
+ return hashMap;
2110
+ }, {});
2111
+
2112
+ const finalReply = this._createParsedServerActivity(
2113
+ latestActivity,
2114
+ allRelevantActivities
2115
+ );
2116
+
2117
+ const fullReply = {
2118
+ id: replyId,
2119
+ activity: finalReply,
2120
+ reaction,
2121
+ reactionSelf,
2122
+ };
2123
+
2124
+ const sanitizedFullReply = sanitizeActivity(fullReply);
2125
+
2126
+ replies.push(sanitizedFullReply);
2127
+ });
2128
+
2129
+ return replies;
2130
+ };
2131
+
2132
+ const orderedRoots = sortActivitiesByPublishedDate(
2133
+ getActivityObjectsFromMap(getRootActivityHash())
2134
+ );
2135
+
2136
+ orderedRoots.forEach((rootActivity) => {
2137
+ const rootId = rootActivity.id;
2138
+ const replies = getRepliesByParentId(rootId);
2139
+ const edit = getActivityByTypeAndParentId(ACTIVITY_TYPES.EDIT, rootId);
2140
+ const reaction = getActivityByTypeAndParentId(ACTIVITY_TYPES.REACTION, rootId);
2141
+ const reactionSelf = getActivityByTypeAndParentId(ACTIVITY_TYPES.REACTION_SELF, rootId);
2142
+
2143
+ const latestActivity = edit || rootActivity;
2144
+ const finalActivity = this._createParsedServerActivity(latestActivity, {
2145
+ [rootId]: rootActivity,
2146
+ });
2147
+
2148
+ const fullRoot = {
2149
+ id: rootId,
2150
+ activity: finalActivity,
2151
+ reaction,
2152
+ reactionSelf,
2153
+ };
2154
+
2155
+ const sanitizedFullRoot = sanitizeActivity(fullRoot);
2156
+
2157
+ orderedActivities.push(sanitizedFullRoot);
2158
+ replies.forEach((reply) => orderedActivities.push(reply));
2159
+ });
2160
+
2161
+ const nextOptions = yield orderedActivities;
2162
+
2163
+ if (nextOptions) {
2164
+ minActivities = nextOptions.minActivities || minActivities;
2165
+
2166
+ const currentOldestPublishedDate = getPublishedDate(getOldestAct());
2167
+ const currentNewestPublishedDate = getPublishedDate(getNewestAct());
2168
+
2169
+ queryType = nextOptions.queryType;
2170
+ query = getQuery(queryType, {
2171
+ activityToSearch: search,
2172
+ oldestPublishedDate: currentOldestPublishedDate,
2173
+ newestPublishedDate: currentNewestPublishedDate,
2174
+ batchSize,
2175
+ });
2176
+ } else {
2177
+ return;
2178
+ }
2179
+ }
2180
+ },
2181
+
2182
+ /**
2183
+ * @typedef {object} EditActivity
2184
+ * @property {object} editParent
2185
+ *
2186
+ * @typedef {object} ReplyActivity
2187
+ * @property {object} replyParent
2188
+ *
2189
+ * @typedef {object} EditedReplyActivity
2190
+ * @property {object} replyParent
2191
+ * @property {object} editParent
2192
+ *
2193
+ * @typedef {EditActivity | ReplyActivity | EditedReplyActivity} ParsedServerActivity
2194
+ */
2195
+
2196
+ /**
2197
+ * hashmap of server activities, keyed by id
2198
+ * @typedef {object} ActivityHash
2199
+ * @property {Object}
2200
+ */
2201
+
2202
+ /**
2203
+ * extends a given server object with fields that point to their parent activities from the hashmap passed in
2204
+ * @param {object} activity server activity
2205
+ * @param {ActivityHash} allActivitiesHash hashmap of all server activities caller would like to pass in
2206
+ * @returns {ParsedServerActivity} server activity extended with edit and reply parent fields
2207
+ */
2208
+ _createParsedServerActivity(activity, allActivitiesHash = {}) {
2209
+ const isOrphan = getIsActivityOrphaned(activity, allActivitiesHash);
2210
+
2211
+ if (isOrphan) {
2212
+ throw new Error(
2213
+ 'activity has a parent that cannot be found in allActivitiesHash! please handle this as necessary'
2214
+ );
2215
+ }
2216
+
2217
+ const activityType = determineActivityType(activity, allActivitiesHash);
2218
+
2219
+ switch (activityType) {
2220
+ case ACTIVITY_TYPES.ROOT: {
2221
+ return createRootActivity(activity);
2222
+ }
2223
+ case ACTIVITY_TYPES.EDIT: {
2224
+ // `activities` must also have the original activity
2225
+ return createEditActivity(activity, allActivitiesHash);
2226
+ }
2227
+ case ACTIVITY_TYPES.REPLY: {
2228
+ return createReplyActivity(activity);
2229
+ }
2230
+ case ACTIVITY_TYPES.REPLY_EDIT: {
2231
+ // `activities` must also have the reply activity
2232
+ return createReplyEditActivity(activity, allActivitiesHash);
2233
+ }
2234
+ default: {
2235
+ return activity;
2236
+ }
2237
+ }
2238
+ },
2239
+
2240
+ /**
2241
+ * @param {Object} options
2242
+ * @private
2243
+ * @returns {Promise<Array<Conversation>>}
2244
+ */
2245
+ async _list(options) {
2246
+ options.qs = {
2247
+ personRefresh: true,
2248
+ uuidEntryFormat: true,
2249
+ activitiesLimit: 0,
2250
+ participantsLimit: 0,
2251
+ ...options.qs,
2252
+ };
2253
+
2254
+ const res = await this.request(options);
2255
+
2256
+ let list;
2257
+
2258
+ if (!res.body || !res.body.items || res.body.items.length === 0) {
2259
+ list = [];
2260
+ } else {
2261
+ list = res.body.items.slice(0);
2262
+ if (last(list).published < list[0].published) {
2263
+ list.reverse();
2264
+ }
2265
+ }
2266
+
2267
+ // The user has more data in another cluster.
2268
+ // Follow the 'additionalUrls' for that data.
2269
+ if (res.body.additionalUrls) {
2270
+ let limit = 0;
2271
+
2272
+ // If the user asked for a specific amount of data,
2273
+ // don't fetch more than what was asked.
2274
+ // Here we figure out how much is left from the original request.
2275
+ // Divide that by the number of additional URLS.
2276
+ // This won't get us the exact limit but it will retrieve something
2277
+ // from every cluster listed.
2278
+ if (options.limit) {
2279
+ limit = Math.floor((options.limit.value - list.length) / res.body.additionalUrls.length);
2280
+ }
2281
+
2282
+ // If the limit is 0 for some reason,
2283
+ // don't bother requesting from other clusters
2284
+ if (!options.limit || limit !== 0) {
2285
+ const results = await Promise.all(
2286
+ res.body.additionalUrls.map((host) => {
2287
+ const url = `${host}/${options.resource}`;
2288
+ const newOptions = {...options, uri: url, url};
2289
+
2290
+ if (options.limit) {
2291
+ newOptions.qs[newOptions.limit.name] = limit;
2292
+ }
2293
+
2294
+ return this.request(newOptions);
2295
+ })
2296
+ );
2297
+
2298
+ for (const result of results) {
2299
+ if (result.body && result.body.items && result.body.items.length) {
2300
+ const {items} = result.body;
2301
+
2302
+ if (last(items).published < items[0].published) {
2303
+ items.reverse();
2304
+ }
2305
+ list = list.concat(items);
2306
+ }
2307
+ }
2308
+ }
2309
+ }
2310
+
2311
+ await Promise.all(list.map((item) => this._recordUUIDs(item)));
2312
+
2313
+ return list;
2314
+ },
2315
+
2316
+ /**
2317
+ * @param {Object} params
2318
+ * @param {Object} options
2319
+ * @private
2320
+ * @returns {Promise<Conversation>}
2321
+ */
2322
+ _maybeCreateOneOnOneThenPost(params, options) {
2323
+ return this.get(
2324
+ defaults({
2325
+ // the use of uniq in Conversation#create guarantees participant[1] will
2326
+ // always be the other user
2327
+ user: params.participants[1],
2328
+ }),
2329
+ Object.assign(options, {includeConvWithDeletedUserUUID: true, includeParticipants: true})
2330
+ )
2331
+ .then((conversation) => {
2332
+ if (params.comment || params.html) {
2333
+ return this.post(conversation, {content: params.html, displayName: params.comment}).then(
2334
+ (activity) => {
2335
+ conversation.activities.items.push(activity);
2336
+
2337
+ return conversation;
2338
+ }
2339
+ );
2340
+ }
2341
+
2342
+ return conversation;
2343
+ })
2344
+ .catch((reason) => {
2345
+ if (reason.statusCode !== 404) {
2346
+ return Promise.reject(reason);
2347
+ }
2348
+
2349
+ return this._createOneOnOne(params);
2350
+ });
2351
+ },
2352
+
2353
+ /**
2354
+ * @param {Object} params
2355
+ * @private
2356
+ * @returns {Object}
2357
+ */
2358
+ _prepareConversationForCreation(params) {
2359
+ const payload = {
2360
+ activities: {
2361
+ items: [this.expand('create')],
2362
+ },
2363
+ objectType: 'conversation',
2364
+ kmsMessage: {
2365
+ method: 'create',
2366
+ uri: '/resources',
2367
+ userIds: cloneDeep(params.participants),
2368
+ keyUris: [],
2369
+ },
2370
+ };
2371
+
2372
+ if (params.displayName) {
2373
+ payload.displayName = params.displayName;
2374
+ }
2375
+
2376
+ if (params.tags) {
2377
+ payload.tags = params.tags;
2378
+ }
2379
+
2380
+ params.participants.forEach((participant) => {
2381
+ payload.activities.items.push(
2382
+ this.expand('add', {
2383
+ objectType: 'person',
2384
+ id: participant,
2385
+ })
2386
+ );
2387
+ });
2388
+
2389
+ if (params.comment) {
2390
+ payload.activities.items.push(
2391
+ this.expand('post', {
2392
+ objectType: 'comment',
2393
+ content: params.html,
2394
+ displayName: params.comment,
2395
+ })
2396
+ );
2397
+ }
2398
+
2399
+ if (!params.isDefaultClassification && params.classificationId) {
2400
+ payload.activities.items.push(
2401
+ this.expand('update', {
2402
+ objectType: 'classification',
2403
+ classificationId: params.classificationId,
2404
+ effectiveDate: params.effectiveDate,
2405
+ })
2406
+ );
2407
+ }
2408
+
2409
+ if (params.favorite) {
2410
+ payload.activities.items.push(
2411
+ this.expand('favorite', {
2412
+ objectType: 'conversation',
2413
+ })
2414
+ );
2415
+ }
2416
+
2417
+ return payload;
2418
+ },
2419
+
2420
+ /**
2421
+ * @param {Object} conversation
2422
+ * @private
2423
+ * @returns {Promise}
2424
+ */
2425
+ _recordUUIDs(conversation) {
2426
+ if (!conversation.participants || !conversation.participants.items) {
2427
+ return Promise.resolve(conversation);
2428
+ }
2429
+
2430
+ return Promise.all(
2431
+ conversation.participants.items.map((participant) => {
2432
+ // ROOMs or LYRA_SPACEs do not have email addresses, so there's no point attempting to
2433
+ // record their UUIDs.
2434
+ if (participant.type === 'ROOM' || participant.type === 'LYRA_SPACE') {
2435
+ return Promise.resolve();
2436
+ }
2437
+
2438
+ return this.webex.internal.user
2439
+ .recordUUID(participant)
2440
+ .catch((err) => this.logger.warn('Could not record uuid', err));
2441
+ })
2442
+ );
2443
+ },
2444
+ });
2445
+
2446
+ ['favorite', 'hide', 'lock', 'mute', 'unfavorite', 'unhide', 'unlock', 'unmute'].forEach((verb) => {
2447
+ Conversation.prototype[verb] = function submitSimpleActivity(conversation, activity) {
2448
+ const convoWithUrl = this.prepareConversation({
2449
+ ...conversation,
2450
+ url: this.getConvoUrl(conversation),
2451
+ });
2452
+
2453
+ return this.prepare(activity, {
2454
+ verb,
2455
+ object: convoWithUrl,
2456
+ target: convoWithUrl,
2457
+ }).then((a) => this.submit(a));
2458
+ };
2459
+ });
2460
+
2461
+ ['assignModerator', 'unassignModerator'].forEach((verb) => {
2462
+ Conversation.prototype[verb] = function submitModerationChangeActivity(
2463
+ conversation,
2464
+ moderator,
2465
+ activity
2466
+ ) {
2467
+ const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
2468
+
2469
+ return Promise.all([
2470
+ convoWithUrl,
2471
+ moderator ? this.webex.internal.user.asUUID(moderator) : this.webex.internal.device.userId,
2472
+ ])
2473
+ .then(([c, userId]) =>
2474
+ this.prepare(activity, {
2475
+ verb,
2476
+ target: this.prepareConversation(c),
2477
+ object: {
2478
+ id: userId,
2479
+ objectType: 'person',
2480
+ },
2481
+ })
2482
+ )
2483
+ .then((a) => this.submit(a));
2484
+ };
2485
+ });
2486
+
2487
+ /**
2488
+ * Sets/unsets space property for convo
2489
+ * @param {Object} conversation
2490
+ * @param {string} tag
2491
+ * @param {Activity} activity
2492
+ * @returns {Promise<Activity>}
2493
+ */
2494
+ ['setSpaceProperty', 'unsetSpaceProperty'].forEach((fnName) => {
2495
+ const verb = fnName.startsWith('set') ? 'set' : 'unset';
2496
+
2497
+ Conversation.prototype[fnName] = function submitSpacePropertyActivity(
2498
+ conversation,
2499
+ tag,
2500
+ activity
2501
+ ) {
2502
+ if (!isString(tag)) {
2503
+ return Promise.reject(new Error('`tag` must be a string'));
2504
+ }
2505
+
2506
+ const convoWithUrl = {...conversation, url: this.getConvoUrl(conversation)};
2507
+
2508
+ return this.prepare(activity, {
2509
+ verb,
2510
+ target: this.prepareConversation(convoWithUrl),
2511
+ object: {
2512
+ tags: [tag],
2513
+ objectType: 'spaceProperty',
2514
+ },
2515
+ }).then((a) => this.submit(a));
2516
+ };
2517
+ });
2518
+
2519
+ ['tag', 'untag'].forEach((verb) => {
2520
+ Conversation.prototype[verb] = function submitObjectActivity(conversation, object, activity) {
2521
+ if (!isObject(object)) {
2522
+ return Promise.reject(new Error('`object` must be an object'));
2523
+ }
2524
+
2525
+ const c = this.prepareConversation({...conversation, url: this.getConvoUrl(conversation)});
2526
+
2527
+ return this.prepare(activity, {
2528
+ verb,
2529
+ target: c,
2530
+ object: Object.assign(c, object),
2531
+ }).then((a) => this.submit(a));
2532
+ };
2533
+ });
2534
+
2535
+ export default Conversation;