@tiledesk/tiledesk-server 2.27.2 → 2.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,9 +5,17 @@
5
5
  🚀 IN PRODUCTION 🚀
6
6
  (https://www.npmjs.com/package/@tiledesk/tiledesk-server/v/2.3.77)
7
7
 
8
+ # 2.28.1
9
+ - Bumped whatsapp-connector version to 1.0.30
10
+
11
+ # 2.28.0
12
+ - Group participant updates (setMembers, join, leave) for the same request are now queued and executed sequentially to prevent race conditions.
13
+ - setMembers now reloads participants from the database at the time of execution, ensuring consistency with the actual request state.
14
+ - Automatic retries for setMembers on error, with configurable max retries and delay via CHAT21_SET_MEMBERS_MAX_RETRIES and CHAT21_SET_MEMBERS_RETRY_DELAY_MS.
15
+ - Removed participants’ conversations are now archived only after leave/setMembers completes, inside the same queue, to avoid premature archival while the participant is still in the Chat21 group.
16
+
8
17
  # 2.27.2
9
18
  - Improved chat21 sync tracing: traces are buffered in memory and saved in batches (insertMany) instead of one insert per trace
10
- - Added env variables CHAT21_SYNC_TRACE_FLUSH_INTERVAL_MS (default 1000), CHAT21_SYNC_TRACE_BATCH_SIZE (default 200) and CHAT21_SYNC_TRACE_MAX_BUFFER_SIZE (default 10000)
11
19
 
12
20
  # 2.27.1
13
21
  - Added tracing for chat21 events
@@ -42,7 +42,7 @@ function archiveConversation(event, request, user_id) {
42
42
 
43
43
  return chat21SyncTracer.traceCall(event, request, 'archive', { user_id: user_id }, () => {
44
44
  return chat21.conversations.archive(request.request_id, user_id);
45
- }).then(function (data) {
45
+ }, chat21Outcome).then(function (data) {
46
46
  winston.verbose("[Chat21Handler] - " + event + " - Chat21 conversation archived for " + user_id + " result " + JSON.stringify(data));
47
47
  chat21Event.emit('conversation.archived', data);
48
48
  }).catch(function (err) {
@@ -61,6 +61,103 @@ function runTraced(event, request, fn) {
61
61
  }
62
62
  }
63
63
 
64
+ function nonNegativeNumber(value, defaultValue) {
65
+ const parsed = parseInt(value, 10);
66
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultValue;
67
+ }
68
+
69
+ const SET_MEMBERS_MAX_RETRIES = nonNegativeNumber(process.env.CHAT21_SET_MEMBERS_MAX_RETRIES, 2);
70
+ const SET_MEMBERS_RETRY_DELAY_MS = nonNegativeNumber(process.env.CHAT21_SET_MEMBERS_RETRY_DELAY_MS, 1000);
71
+
72
+ function isSameMembersResponse(response) {
73
+ const message = response && response.err && response.err.message;
74
+ return typeof message === 'string' && message.startsWith('Same members');
75
+ }
76
+
77
+ // chat21-http-server answers {success: true} on success: any other body is an error, even with a resolved sdk promise
78
+ function chat21Outcome(response) {
79
+ if (response && response.success === true) {
80
+ return 'success';
81
+ }
82
+ if (isSameMembersResponse(response)) {
83
+ return 'skipped';
84
+ }
85
+ return 'error';
86
+ }
87
+
88
+ const groupSyncQueues = new Map();
89
+
90
+ // Chains the chat21 group updates of the same request, so that this process sends them one at a time and in emission order
91
+ function enqueueGroupSync(groupId, fn) {
92
+ const previous = groupSyncQueues.get(groupId) || Promise.resolve();
93
+ const current = previous.then(fn);
94
+ const tail = current.catch(() => { });
95
+ groupSyncQueues.set(groupId, tail);
96
+ tail.then(() => {
97
+ if (groupSyncQueues.get(groupId) === tail) {
98
+ groupSyncQueues.delete(groupId);
99
+ }
100
+ });
101
+ return current;
102
+ }
103
+
104
+ // The participants are read when the call is sent, not taken from the event: a later update,
105
+ // possibly saved by another pod, would otherwise be overwritten on chat21 by stale members
106
+ async function currentGroupMembers(request) {
107
+ let participants = request.participants;
108
+ try {
109
+ const current = await Request.findOne({ request_id: request.request_id, id_project: request.id_project })
110
+ .select('participants')
111
+ .lean()
112
+ .exec();
113
+ if (current) {
114
+ participants = current.participants;
115
+ }
116
+ } catch (err) {
117
+ winston.error("[Chat21Handler] - Error reading the current participants, using the event ones for request " + request.request_id, err);
118
+ }
119
+
120
+ const members = ["system", ...participants];
121
+ if (request.lead) {
122
+ members.push(request.lead.lead_id);
123
+ }
124
+ return members;
125
+ }
126
+
127
+ function delay(ms) {
128
+ return new Promise((resolve) => setTimeout(resolve, ms));
129
+ }
130
+
131
+ async function syncGroupMembers(event, request) {
132
+ const groupId = request.request_id;
133
+
134
+ for (let attempt = 0; ; attempt++) {
135
+ const members = await currentGroupMembers(request);
136
+ winston.verbose("[Chat21Handler] - " + event + " - Chat21 group " + groupId + " members (attempt " + attempt + "): ", members);
137
+
138
+ let error;
139
+ try {
140
+ chat21.auth.setAdminToken(adminToken);
141
+ const data = await chat21SyncTracer.traceCall(event, request, 'group.setMembers', { members: members, attempt: attempt }, () => {
142
+ return chat21.groups.setMembers(members, groupId);
143
+ }, chat21Outcome);
144
+
145
+ if (chat21Outcome(data) !== 'error') {
146
+ return data;
147
+ }
148
+ error = { message: "Chat21 setMembers failed", response: data };
149
+ } catch (err) {
150
+ error = err;
151
+ }
152
+
153
+ if (attempt >= SET_MEMBERS_MAX_RETRIES) {
154
+ throw error;
155
+ }
156
+ winston.warn("[Chat21Handler] - " + event + " - Chat21 setMembers failed for group " + groupId + " (attempt " + attempt + "), retrying: ", error);
157
+ await delay(SET_MEMBERS_RETRY_DELAY_MS * (attempt + 1));
158
+ }
159
+ }
160
+
64
161
 
65
162
  class Chat21Handler {
66
163
 
@@ -494,26 +591,19 @@ class Chat21Handler {
494
591
  }
495
592
 
496
593
  chat21.auth.setAdminToken(adminToken);
497
- let requestObj = request.toJSON();
498
-
499
- winston.verbose("[Chat21Handler] - request.participants.update - Setting chat21 group for request with id: " + requestObj._id);
500
-
501
- const groupId = request.request_id;
502
-
503
- let members = [];
504
- members.push("system");
505
-
506
- request.participants.forEach(function (participant, index) {
507
- members.push(participant);
508
- });
509
594
 
510
- if (request.lead) {
511
- members.push(request.lead.lead_id);
512
- }
513
- winston.verbose("[Chat21Handler] - request.participants.update - Chat21 group with members for request.participants.update: ", members);
595
+ winston.verbose("[Chat21Handler] - request.participants.update - Setting chat21 group for request with id: " + request._id);
514
596
 
515
- chat21SyncTracer.traceCall(traceEvent, request, 'group.setMembers', { members: members }, () => {
516
- return chat21.groups.setMembers(members, groupId);
597
+ enqueueGroupSync(request.request_id, async () => {
598
+ try {
599
+ return await syncGroupMembers(traceEvent, request);
600
+ } finally {
601
+ // Archived only after leaving the chat21 group: until then the group messages (e.g. the
602
+ // TOUCHING_OPERATOR info sent on the same event) reach their timeline and set archived: false
603
+ removedParticipants.forEach(function (removedParticipant) {
604
+ archiveConversation(traceEvent, request, removedParticipant);
605
+ });
606
+ }
517
607
  }).then(function (data) {
518
608
  winston.verbose("[Chat21Handler] - request.participants.update - Chat21 group set for request.participants.update : " + JSON.stringify(data));
519
609
  chat21Event.emit('group.setMembers', data);
@@ -522,10 +612,6 @@ class Chat21Handler {
522
612
  chat21Event.emit('group.setMembers.error', err);
523
613
  });
524
614
 
525
- removedParticipants.forEach(function (removedParticipant) {
526
- archiveConversation(traceEvent, request, removedParticipant);
527
- });
528
-
529
615
  }));
530
616
  });
531
617
 
@@ -550,9 +636,10 @@ class Chat21Handler {
550
636
  const groupId = request.request_id;
551
637
  winston.verbose("[Chat21Handler] - request.participants.join - Joining member " + member + " for chat21 group with request : " + groupId);
552
638
 
553
- chat21SyncTracer.traceCall(traceEvent, request, 'group.join', { member: member }, () => {
639
+ enqueueGroupSync(groupId, () => chat21SyncTracer.traceCall(traceEvent, request, 'group.join', { member: member }, () => {
640
+ chat21.auth.setAdminToken(adminToken);
554
641
  return chat21.groups.join(member, groupId);
555
- }).then(function (data) {
642
+ }, chat21Outcome)).then(function (data) {
556
643
  winston.verbose("[Chat21Handler] - request.participants.join - Chat21 group joined: " + JSON.stringify(data));
557
644
  chat21Event.emit('group.join', data);
558
645
  }).catch(function (err) {
@@ -584,8 +671,16 @@ class Chat21Handler {
584
671
 
585
672
  winston.verbose("[Chat21Handler] - request.participants.leave - Leaving " + member + " for chat21 group for request with id: " + groupId);
586
673
 
587
- chat21SyncTracer.traceCall(traceEvent, request, 'group.leave', { member: member }, () => {
588
- return chat21.groups.leave(member, groupId);
674
+ enqueueGroupSync(groupId, async () => {
675
+ try {
676
+ return await chat21SyncTracer.traceCall(traceEvent, request, 'group.leave', { member: member }, () => {
677
+ chat21.auth.setAdminToken(adminToken);
678
+ return chat21.groups.leave(member, groupId);
679
+ }, chat21Outcome);
680
+ } finally {
681
+ // Archived only after leaving the chat21 group, see request.participants.update
682
+ archiveConversation(traceEvent, request, member);
683
+ }
589
684
  }).then(function (data) {
590
685
  winston.verbose("[Chat21Handler] - request.participants.leave - Chat21 group left: " + JSON.stringify(data));
591
686
  chat21Event.emit('group.leave', data);
@@ -594,8 +689,6 @@ class Chat21Handler {
594
689
  chat21Event.emit('group.leave.error', err);
595
690
  });
596
691
 
597
- archiveConversation(traceEvent, request, member);
598
-
599
692
  }));
600
693
  })
601
694
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiledesk/tiledesk-server",
3
3
  "description": "The Tiledesk server module",
4
- "version": "2.27.2",
4
+ "version": "2.28.1",
5
5
  "scripts": {
6
6
  "start": "node ./bin/www",
7
7
  "pretest": "mongodb-runner start",
@@ -52,7 +52,7 @@
52
52
  "@tiledesk/tiledesk-tybot-connector": "^2.1.15",
53
53
  "@tiledesk/tiledesk-voice-twilio-connector": "^0.3.2",
54
54
  "@tiledesk/tiledesk-vxml-connector": "^0.1.91",
55
- "@tiledesk/tiledesk-whatsapp-connector": "1.0.26",
55
+ "@tiledesk/tiledesk-whatsapp-connector": "1.0.30",
56
56
  "@tiledesk/tiledesk-whatsapp-jobworker": "^0.0.13",
57
57
  "amqplib": "^0.5.5",
58
58
  "app-root-path": "^3.0.0",
@@ -134,11 +134,14 @@ function trace(event, ref, step, outcome, data) {
134
134
  /**
135
135
  * Traces a chat21 sdk call as <action>.sent, then <action>.response or <action>.error.
136
136
  * The returned promise settles exactly like the one returned by callFn.
137
+ * @param {function} [outcomeOf] maps the response body to the outcome of <action>.response, default 'success'.
138
+ * The chat21 sdk resolves on any HTTP status code, so a resolved call is not necessarily successful.
137
139
  */
138
- function traceCall(event, ref, action, data, callFn) {
140
+ function traceCall(event, ref, action, data, callFn, outcomeOf) {
139
141
  trace(event, ref, action + '.sent', 'info', data);
140
142
  return callFn().then((response) => {
141
- trace(event, ref, action + '.response', 'success', { ...data, response: describeResponse(response) });
143
+ const outcome = outcomeOf ? outcomeOf(response) : 'success';
144
+ trace(event, ref, action + '.response', outcome, { ...data, response: describeResponse(response) });
142
145
  return response;
143
146
  }, (err) => {
144
147
  trace(event, ref, action + '.error', 'error', { ...data, error: describeError(err) });
@@ -271,6 +271,15 @@ class RequestService {
271
271
  winston.debug("nobot:" + nobot);
272
272
  //winston.info("main_flow_cache_3 route");
273
273
 
274
+ const traceRef = { request_id: request_id, id_project: id_project };
275
+ chat21SyncTracer.trace('request.participants.update', traceRef, 'service.called', 'info', {
276
+ method: 'route',
277
+ departmentid: departmentid,
278
+ nobot: nobot,
279
+ assignmentType: options?.assignmentType,
280
+ source: options?.source
281
+ });
282
+
274
283
  // Find request
275
284
  // Cache disabled: a stale cached document can have an outdated __v and cause
276
285
  // Mongoose VersionError on save ("No matching document found for id ... version N").
@@ -304,7 +313,12 @@ class RequestService {
304
313
  ) {
305
314
 
306
315
  winston.verbose(`Request ${request.request_id} routed to same status/participants`);
307
-
316
+ chat21SyncTracer.trace('request.participants.update', traceRef, 'service.unchanged', 'skipped', {
317
+ method: 'route',
318
+ ...chat21SyncTracer.describeRequest(request)
319
+ });
320
+
321
+ let abandonedStatusSaved = false;
308
322
  if (routedRequest.attributes?.fully_abandoned === true) {
309
323
  request.status = RequestConstants.ABANDONED;
310
324
  request.attributes.fully_abandoned = true;
@@ -313,6 +327,7 @@ class RequestService {
313
327
 
314
328
  try {
315
329
  await request.save();
330
+ abandonedStatusSaved = true;
316
331
  winston.verbose(`Status set to ABANDONED for request ${request._id}`);
317
332
  } catch (err) {
318
333
  winston.error("Error updating request to ABANDONED", err);
@@ -337,18 +352,13 @@ class RequestService {
337
352
  .populate("participatingAgents")
338
353
  .populate({ path: "requester", populate: { path: "id_user" } })
339
354
  .execPopulate();
340
-
341
- this.emitParticipantsEvents(
342
- request,
343
- requestComplete,
344
- beforeParticipants,
345
- options,
346
- beforeDepartmentId !== afterDepartmentId ? {
347
- previousDepartmentId: beforeDepartmentId,
348
- departmentId: afterDepartmentId,
349
- departmentName: requestComplete.department && requestComplete.department.name
350
- } : undefined
351
- );
355
+
356
+ // No participants events here: queues call route every few seconds on unassigned requests and each
357
+ // request.participants.update resends the chat21 members, the agent emails and the welcome message
358
+ if (abandonedStatusSaved) {
359
+ requestEvent.emit("request.update", requestComplete);
360
+ requestEvent.emit("request.updated", { comment: "STATUS_CHANGE", request: requestComplete, patch: { status: requestComplete.status } });
361
+ }
352
362
 
353
363
  return requestComplete;
354
364
  }
@@ -2627,6 +2637,7 @@ class RequestService {
2627
2637
  patch: { removedParticipants, addedParticipants }
2628
2638
  });
2629
2639
 
2640
+ traceEventEmitted('request.participants.update', requestComplete, requestComplete, { removedParticipants, addedParticipants });
2630
2641
  requestEvent.emit("request.participants.update", {
2631
2642
  beforeRequest,
2632
2643
  removedParticipants,