@tiledesk/tiledesk-server 2.26.0 → 2.27.0

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,6 +5,9 @@
5
5
  🚀 IN PRODUCTION 🚀
6
6
  (https://www.npmjs.com/package/@tiledesk/tiledesk-server/v/2.3.77)
7
7
 
8
+ # 2.27.0
9
+ - Added worker to manage events coming from Chat21
10
+
8
11
  # 2.26.0
9
12
  - Added pubmodules proxy route for docsparse
10
13
 
package/jobs.js CHANGED
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Entry point of the jobs worker (Dockerfile-jobs: `node jobs.js`).
3
+ * It runs the jobs that the server (app.js) skips when JOB_WORKER_ENABLED=true.
4
+ *
5
+ * Worker-specific env variables:
6
+ * - INCLUDE_QUEUE / EXCLUDE_QUEUE: routing keys consumed from RabbitMQ (see pubmodules/queue/reconnect.js)
7
+ * - TASK_SCHEDULER_ENABLED=false: disables the cron tasks (see pubmodules/scheduler/taskRunner.js)
8
+ * - MULTI_WORKER_ENABLED=false: disables @tiledesk/tiledesk-multi-worker
9
+ */
10
+
11
+ // ---------- Env ----------
1
12
 
2
13
  var dotenvPath = undefined;
3
14
 
@@ -6,43 +17,35 @@ if (process.env.DOTENV_PATH) {
6
17
  console.log("load dotenv form DOTENV_PATH", dotenvPath);
7
18
  }
8
19
 
9
- if (process.env.LOAD_DOTENV_SUBFOLDER ) {
20
+ if (process.env.LOAD_DOTENV_SUBFOLDER) {
10
21
  console.log("load dotenv form LOAD_DOTENV_SUBFOLDER");
11
- dotenvPath = __dirname+'/confenv/.env';
22
+ dotenvPath = __dirname + '/confenv/.env';
12
23
  }
13
24
 
14
- require('dotenv').config({ path: dotenvPath});
25
+ require('dotenv').config({ path: dotenvPath });
15
26
 
27
+ // This process is the worker: modules reading JOB_WORKER_ENABLED must run their jobs here.
28
+ process.env.JOB_WORKER_ENABLED = false;
16
29
 
17
30
  var mongoose = require('mongoose');
18
-
19
31
  let winston = require('./config/winston');
32
+ var config = require('./config/database');
33
+ const { TdCache } = require('./utils/TdCache');
20
34
  let JobsManager = require('./jobsManager');
21
35
 
22
-
23
36
  let geoService = require('./services/geoService');
24
- // let subscriptionNotifier = require('./services/subscriptionNotifier');
25
37
  var subscriptionNotifierQueued = require('./services/subscriptionNotifierQueued');
26
38
  var botSubscriptionNotifier = require('./services/BotSubscriptionNotifier');
27
-
28
- const botEvent = require('./event/botEvent');
29
- var channelManager = require('./channels/channelManager');
30
-
31
39
  var updateLeadQueued = require('./services/updateLeadQueued');
32
40
  var updateRequestSnapshotQueued = require('./services/updateRequestSnapshotQueued');
33
-
41
+ const botEvent = require('./event/botEvent');
42
+ var channelManager = require('./channels/channelManager');
34
43
 
35
44
  require('./services/mongoose-cache-fn')(mongoose);
36
45
 
37
-
38
- var config = require('./config/database');
39
-
40
-
41
- //override JOB_WORKER_ENABLED to false when you start jobs.js
42
- process.env.JOB_WORKER_ENABLED=false
46
+ // ---------- MongoDB ----------
43
47
 
44
48
  var databaseUri = process.env.DATABASE_URI || process.env.MONGODB_URI || config.database;
45
- var autoIndex = true;
46
49
 
47
50
  if (!databaseUri) { //TODO??
48
51
  winston.warn('DATABASE_URI not specified, falling back to localhost.');
@@ -51,103 +54,102 @@ if (!databaseUri) { //TODO??
51
54
  let useUnifiedTopology = process.env.MONGOOSE_UNIFIED_TOPOLOGY === 'true';
52
55
  winston.info("DB useUnifiedTopology: ", useUnifiedTopology, typeof useUnifiedTopology);
53
56
 
54
- var connection = mongoose.connect(databaseUri, { "useNewUrlParser": true, "autoIndex": autoIndex, "useUnifiedTopology": useUnifiedTopology }, function(err) {
55
- if (err) {
57
+ mongoose.connect(databaseUri, { "useNewUrlParser": true, "autoIndex": true, "useUnifiedTopology": useUnifiedTopology }, function (err) {
58
+ if (err) {
56
59
  winston.error('Failed to connect to MongoDB on ' + databaseUri + " ", err);
57
60
  process.exit(1);
58
61
  }
59
- winston.info("Mongoose connection done on host: "+mongoose.connection.host + " on port: " + mongoose.connection.port + " with name: "+ mongoose.connection.name)// , mongoose.connection.db);
62
+ winston.info("Mongoose connection done on host: " + mongoose.connection.host + " on port: " + mongoose.connection.port + " with name: " + mongoose.connection.name);
60
63
  });
61
- // winston.info("mongoose.connection",mongoose.connection);
62
- // module.exports = jobsManager;
63
64
 
65
+ // ---------- Modules ----------
64
66
 
67
+ /**
68
+ * Modules started directly by the worker, regardless of JobsManager.
69
+ */
70
+ function startCoreModules() {
71
+ // Mongoose query cache.
72
+ require('./pubmodules/cache').cachegoose(mongoose);
65
73
 
66
- async function main()
67
- {
74
+ // Concierge bot.
75
+ require('./pubmodules/rules/appRules').start();
68
76
 
69
- ////************* LOAD QUEUE ************ //
70
- require('./pubmodules/cache').cachegoose(mongoose);
71
-
77
+ // RabbitMQ publisher and consumers: the jobs queue and the chat21 webhook queue,
78
+ // filtered by INCLUDE_QUEUE / EXCLUDE_QUEUE.
79
+ require('./pubmodules/queue');
72
80
 
73
- ////************* LOAD CONCIERGE BOT ************ //
74
- require('./pubmodules/rules/appRules').start();
81
+ // chat21Handler listens to the standard events (request.create, not request.create.queue)
82
+ // because the worker emits them when it closes a request.
83
+ channelManager.listen();
84
+ }
85
+
86
+ /**
87
+ * Job listeners started through JobsManager.
88
+ * Most of them only react to the *.queue events emitted by pubmodules/queue, so they do nothing
89
+ * when their routing keys are excluded. The scheduler and the multi-worker run on their own.
90
+ */
91
+ function startJobListeners(tdCache) {
92
+ let jobsManager = new JobsManager(undefined, geoService, botEvent, subscriptionNotifierQueued, botSubscriptionNotifier, updateLeadQueued, updateRequestSnapshotQueued);
75
93
 
94
+ jobsManager.listen();
76
95
 
77
- // require('./pubmodules/trigger/rulesTrigger').listen(); request.close trigger event is not triggered by anyone now?
78
-
96
+ jobsManager.listenEmailNotification(require('./pubmodules/emailNotification'));
79
97
 
80
- //************* LOAD QUEUE ************ //
81
- require('./pubmodules/queue');
82
- // require('@tiledesk-ent/tiledesk-server-queue');
98
+ jobsManager.listenActivityArchiver(require('./pubmodules/activities').activityArchiver);
83
99
 
84
- //************* LOAD CHAT21 ************ //
85
- channelManager.listen(); // chat21Handler is loaded with stadard events like request.create and NOT request.create.queue because it is used internally by the worker when the request is closed by ChatUnhandledRequestScheduler
100
+ let routingQueueQueued = require('./pubmodules/routing-queue').listenerQueued;
101
+ routingQueueQueued.setTdCache(tdCache);
102
+ jobsManager.listenRoutingQueue(routingQueueQueued);
86
103
 
87
- // Same Redis client as app.js — required by routing-queue (flow attributes cache delete)
88
- const { TdCache } = require('./utils/TdCache');
89
- let tdCache = new TdCache({
90
- host: process.env.CACHE_REDIS_HOST,
91
- port: process.env.CACHE_REDIS_PORT,
92
- password: process.env.CACHE_REDIS_PASSWORD
93
- });
94
- tdCache.connect();
104
+ jobsManager.listenWhatsappQueue(require('@tiledesk/tiledesk-whatsapp-jobworker'));
95
105
 
106
+ // Cron tasks, disabled by TASK_SCHEDULER_ENABLED=false.
107
+ jobsManager.listenScheduler(require('./pubmodules/scheduler'));
96
108
 
97
- let jobsManager = new JobsManager(undefined, geoService, botEvent, subscriptionNotifierQueued, botSubscriptionNotifier, updateLeadQueued, updateRequestSnapshotQueued);
109
+ // Consumes its own RabbitMQ queues on AMQP_MANAGER_URL, disabled by MULTI_WORKER_ENABLED=false.
110
+ jobsManager.listenMultiWorker(require('@tiledesk/tiledesk-multi-worker'));
111
+ }
98
112
 
99
- jobsManager.listen();
113
+ function startAnalyticsPublisher() {
114
+ try {
115
+ require('./pubmodules/analytics-publisher').listen();
116
+ winston.info("AnalyticsPublisher initialized.");
117
+ } catch (err) {
118
+ if (err.code === 'MODULE_NOT_FOUND') {
119
+ winston.info("PubModulesManager init analyticsPublisher module not found");
120
+ } else {
121
+ winston.info("PubModulesManager error initializing analyticsPublisher module", err);
122
+ }
123
+ }
124
+ }
100
125
 
126
+ // ---------- Main ----------
101
127
 
102
- let emailNotification = require('./pubmodules/emailNotification');
103
- jobsManager.listenEmailNotification(emailNotification);
128
+ async function main() {
129
+ startCoreModules();
104
130
 
105
-
106
- let activityArchiver = require('./pubmodules/activities').activityArchiver;
107
- console.log("\n\n (jobs) activityArchiver: ", activityArchiver);
108
- jobsManager.listenActivityArchiver(activityArchiver);
131
+ // Same Redis client as app.js, required by routing-queue (flow attributes cache delete).
132
+ let tdCache = new TdCache({
133
+ host: process.env.CACHE_REDIS_HOST,
134
+ port: process.env.CACHE_REDIS_PORT,
135
+ password: process.env.CACHE_REDIS_PASSWORD
136
+ });
137
+ tdCache.connect();
109
138
 
139
+ startJobListeners(tdCache);
110
140
 
111
- let routingQueueQueued = require('./pubmodules/routing-queue').listenerQueued;
112
- winston.debug("routingQueueQueued");
113
- if (tdCache) {
114
- routingQueueQueued.setTdCache(tdCache);
115
- }
116
- jobsManager.listenRoutingQueue(routingQueueQueued);
117
-
118
- let whatsappQueue = require('@tiledesk/tiledesk-whatsapp-jobworker');
119
- winston.info("whatsappQueue");
120
- jobsManager.listenWhatsappQueue(whatsappQueue);
121
-
122
- let scheduler = require('./pubmodules/scheduler');
123
- jobsManager.listenScheduler(scheduler);
124
-
125
- let multiWorkerQueue = require('@tiledesk/tiledesk-multi-worker');
126
- jobsManager.listenMultiWorker(multiWorkerQueue);
127
-
128
- try {
129
- this.analyticsPublisher = require('./pubmodules/analytics-publisher');
130
- this.analyticsPublisher.listen();
131
- winston.info("AnalyticsPublisher initialized.");
132
- } catch(err) {
133
- if (err.code === 'MODULE_NOT_FOUND') {
134
- winston.info("PubModulesManager init analyticsPublisher module not found");
135
- } else {
136
- winston.info("PubModulesManager error initializing analyticsPublisher module", err);
137
- }
138
- }
141
+ startAnalyticsPublisher();
139
142
 
140
- winston.info("Jobs started");
143
+ winston.info("Jobs started");
141
144
 
142
- await new Promise(function () {});
143
- console.log('This text will never be printed');
145
+ // Keeps the process alive.
146
+ await new Promise(function () {});
144
147
  }
145
148
 
146
- function panic(error)
147
- {
148
- console.error(error);
149
- process.exit(1);
149
+ function panic(error) {
150
+ console.error(error);
151
+ process.exit(1);
150
152
  }
151
153
 
152
154
  // https://stackoverflow.com/a/46916601/1478566
153
- main().catch(panic).finally(clearInterval.bind(null, setInterval(a=>a, 1E9)));
155
+ main().catch(panic).finally(clearInterval.bind(null, setInterval(a => a, 1E9)));
package/jobsManager.js CHANGED
@@ -1,118 +1,90 @@
1
-
2
1
  var winston = require('./config/winston');
3
2
 
3
+ /**
4
+ * Starts the queued job listeners, shared by the two processes:
5
+ * - app.js (server): jobWorkerEnabled = JOB_WORKER_ENABLED. When true every listener is skipped,
6
+ * because the jobs are run by the external worker.
7
+ * - jobs.js (worker): jobWorkerEnabled = undefined, so every listener is started.
8
+ */
4
9
  class JobsManager {
5
10
  constructor(jobWorkerEnabled, geoService, botEvent, subscriptionNotifierQueued, botSubscriptionNotifier, updateLeadQueued, updateRequestSnapshotQueued) {
6
- this.geoService = geoService;
7
- this.botEvent = botEvent;
8
- // this.subscriptionNotifier = subscriptionNotifier;
9
- this.subscriptionNotifierQueued = subscriptionNotifierQueued;
10
- this.botSubscriptionNotifier = botSubscriptionNotifier;
11
-
12
- this.emailNotificatio = undefined;
13
- this.activityArchiver = undefined;
14
- this.whatsappWorker = undefined;
15
- this.multiWorkerQueue = undefined;
16
-
17
11
  this.jobWorkerEnabled = jobWorkerEnabled;
18
- // this.jobWorkerEnabled = false;
19
- // if (process.env.JOB_WORKER_ENABLED=="true" || process.env.JOB_WORKER_ENABLED == true) {
20
- // this.jobWorkerEnabled = true;
21
- // }
22
- // winston.info("JobsManager jobWorkerEnabled: "+ this.jobWorkerEnabled);
23
12
 
13
+ this.geoService = geoService;
14
+ this.subscriptionNotifierQueued = subscriptionNotifierQueued;
24
15
  this.updateLeadQueued = updateLeadQueued;
25
16
  this.updateRequestSnapshotQueued = updateRequestSnapshotQueued;
26
- }
27
-
28
-
29
- listen() {
30
- winston.info("JobsManager listener started");
31
- if ( this.jobWorkerEnabled == true) {
32
- return winston.info("JobsManager jobWorkerEnabled is enabled. Skipping listeners");
33
- }
34
- this.geoService.listen();
35
-
36
- // this.botEvent.listen(); // disabled
37
-
38
- // this.subscriptionNotifier.start();
39
- this.subscriptionNotifierQueued.start();
40
17
 
41
- this.updateLeadQueued.listen();
18
+ // Kept for the constructor signature, their listeners are disabled.
19
+ this.botEvent = botEvent;
20
+ this.botSubscriptionNotifier = botSubscriptionNotifier;
21
+ }
42
22
 
43
- if (this.updateRequestSnapshotQueued) {
44
- this.updateRequestSnapshotQueued.listen();
23
+ /**
24
+ * Runs startFn unless the jobs are delegated to the external worker.
25
+ */
26
+ start(name, startFn) {
27
+ if (this.jobWorkerEnabled == true) {
28
+ return winston.info("JobsManager jobWorkerEnabled is enabled. Skipping " + name);
45
29
  }
30
+ winston.info("JobsManager starting " + name);
31
+ startFn();
32
+ }
46
33
 
47
- // this.botSubscriptionNotifier.start(); // disabled
34
+ listen() {
35
+ this.start("geo, subscription notifier, lead and request snapshot listeners", () => {
36
+ this.geoService.listen();
37
+ this.subscriptionNotifierQueued.start();
38
+ this.updateLeadQueued.listen();
39
+ if (this.updateRequestSnapshotQueued) {
40
+ this.updateRequestSnapshotQueued.listen();
41
+ }
42
+ });
48
43
  }
49
44
 
50
- listenEmailNotification(emailNotification) {
51
- winston.info("JobsManager listenEmailNotification started");
52
- if ( this.jobWorkerEnabled == true) {
53
- return winston.info("JobsManager jobWorkerEnabled is enabled. Skipping listener for Email Notification");
54
- }
55
- this.emailNotification = emailNotification;
56
- this.emailNotification.requestNotification.listen();
45
+ listenEmailNotification(emailNotification) {
46
+ this.start("Email Notification", () => {
47
+ this.emailNotification = emailNotification;
48
+ this.emailNotification.requestNotification.listen();
49
+ });
57
50
  }
58
51
 
59
52
  listenRoutingQueue(routingQueue) {
60
- winston.info("JobsManager routingQueue started");
61
- if ( this.jobWorkerEnabled == true) {
62
- return winston.info("JobsManager jobWorkerEnabled is enabled. Skipping listener for routingQueue");
63
- }
64
- this.routingQueue = routingQueue;
65
- this.routingQueue.listen();
53
+ this.start("Routing Queue", () => {
54
+ this.routingQueue = routingQueue;
55
+ this.routingQueue.listen();
56
+ });
66
57
  }
67
58
 
68
59
  listenScheduler(scheduler) {
69
- winston.info("JobsManager scheduler started");
70
- if ( this.jobWorkerEnabled == true) {
71
- return winston.info("JobsManager jobWorkerEnabled is enabled. Skipping listener for scheduler");
72
- }
73
- this.scheduler = scheduler;
74
- this.scheduler.taskRunner.start();
60
+ this.start("Scheduler", () => {
61
+ this.scheduler = scheduler;
62
+ this.scheduler.taskRunner.start();
63
+ });
75
64
  }
76
65
 
77
66
  listenActivityArchiver(activityArchiver) {
78
- winston.info("JobsManager listenActivityArchiver started");
79
- console.log("\n\n (jobsManager) listenActivityArchiver: ", activityArchiver);
80
- console.log("\n\n (jobsManager) jobWorkerEnabled: ", this.jobWorkerEnabled);
81
- if ( this.jobWorkerEnabled == true) {
82
- return winston.info("JobsManager jobWorkerEnabled is enabled. Skipping listener for Activity Archiver");
83
- }
84
- this.activityArchiver = activityArchiver;
85
- this.activityArchiver.listen();
67
+ this.start("Activity Archiver", () => {
68
+ this.activityArchiver = activityArchiver;
69
+ this.activityArchiver.listen();
70
+ });
86
71
  }
87
72
 
88
73
  listenWhatsappQueue(whatsappQueue) {
89
- console.log("JobsManager listenWhatsappQueue started");
90
- console.log("whatsappQueue is: ", whatsappQueue)
91
- if ( this.jobWorkerEnabled == true) {
92
- return winston.info("JobsManager jobWorkerEnabled is enabled. Skipping listener for Whatsapp Queue");
93
- }
94
- // this.whatsappWorker = whatsappQueue;
95
- // this.whatsappQueue.listen(); // oppure codice
74
+ this.start("Whatsapp Queue", () => {
75
+ // No listener to start yet.
76
+ });
96
77
  }
97
78
 
98
79
  listenMultiWorker(multiWorkerQueue) {
99
- console.log("JobsManager multiWorkerQueue started");
100
- console.log("multiWorkerQueue is: ", multiWorkerQueue)
101
- if (this.jobWorkerEnabled == true) {
102
- return winston.info("JobsManager jobWorkerEnabled is enabled. Skipping listener for MultiWorker Queue");
103
- }
104
- this.multiWorkerQueue = multiWorkerQueue;
105
- this.multiWorkerQueue.startJobsWorker();
80
+ this.start("MultiWorker Queue", () => {
81
+ if (process.env.MULTI_WORKER_ENABLED === "false") {
82
+ return winston.info("JobsManager MULTI_WORKER_ENABLED is false. Skipping MultiWorker Queue");
83
+ }
84
+ this.multiWorkerQueue = multiWorkerQueue;
85
+ this.multiWorkerQueue.startJobsWorker();
86
+ });
106
87
  }
107
-
108
- // listenTrainingQueue(trainingQueue) {
109
- // console.log("JobsManager listenTrainingQueue started");
110
- // console.log("trainingQueue is: ", trainingQueue)
111
- // if (this.jobWorkerEnabled == true) {
112
- // return winston.info("JobsManager jobWorkerEnabled is enabled. Skipping listener for Training Queue");
113
- // }
114
- // }
115
88
  }
116
89
 
117
-
118
- module.exports = JobsManager;
90
+ module.exports = JobsManager;
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.26.0",
4
+ "version": "2.27.0",
5
5
  "scripts": {
6
6
  "start": "node ./bin/www",
7
7
  "pretest": "mongodb-runner start",
@@ -149,11 +149,10 @@ class ActivityArchiver {
149
149
  listen() {
150
150
 
151
151
  winston.debug('ActivityArchiver listen');
152
- console.log("\n\nActivityArchiver listen");
153
152
 
154
153
  const enabled = process.env.ACTIVITY_HISTORY_ENABLED || "false";
155
154
  winston.debug('ActivityArchiver enabled:' + enabled);
156
- console.log("\n\nActivityArchiver enabled:" + enabled);
155
+
157
156
  if (enabled === "true") {
158
157
  winston.verbose('ActivityArchiver enabled');
159
158
  } else {
@@ -0,0 +1,145 @@
1
+ const axios = require("axios").default;
2
+ var winston = require('../../config/winston');
3
+
4
+ // Exchange and routing key are fixed by chat21-server (WEBHOOK_MODE=queue).
5
+ const CHAT21_WEBHOOK_EXCHANGE = 'chat21.webhooks';
6
+ const CHAT21_WEBHOOK_ROUTING_KEY = 'chat21.webhook.event';
7
+
8
+ const port = process.env.PORT || '3000';
9
+ const apiEndpoint = process.env.API_ENDPOINT || ("http://localhost:" + port);
10
+
11
+ function positiveNumber(value, defaultValue) {
12
+ const n = Number(value);
13
+ return Number.isFinite(n) && n > 0 ? n : defaultValue;
14
+ }
15
+
16
+ const config = {
17
+ queueName: process.env.CHAT21_WEBHOOK_QUEUE_NAME || 'chat21-webhooks',
18
+ // Max messages per second sent to the webhook API. 0 = unlimited.
19
+ rateLimit: positiveNumber(process.env.CHAT21_WEBHOOK_RATE_LIMIT, 0),
20
+ prefetch: positiveNumber(process.env.CHAT21_WEBHOOK_PREFETCH, 10),
21
+ apiUrl: process.env.CHAT21_WEBHOOK_API_URL || (apiEndpoint + "/chat21/requests"),
22
+ timeoutMs: positiveNumber(process.env.CHAT21_WEBHOOK_TIMEOUT_MS, 30000),
23
+ requeueDelayMs: positiveNumber(process.env.CHAT21_WEBHOOK_REQUEUE_DELAY_MS, 1000)
24
+ };
25
+
26
+ /**
27
+ * Spreads the executions so that at most ratePerSecond functions start every second.
28
+ */
29
+ function createRateLimiter(ratePerSecond) {
30
+ const interval = ratePerSecond > 0 ? 1000 / ratePerSecond : 0;
31
+ let nextSlot = 0;
32
+ return {
33
+ schedule: function (fn) {
34
+ if (!interval) {
35
+ return fn();
36
+ }
37
+ const now = Date.now();
38
+ const at = Math.max(now, nextSlot);
39
+ nextSlot = at + interval;
40
+ setTimeout(fn, at - now);
41
+ }
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Posts the chat21 event to the webhook API, the same call chat21-server makes in http mode.
47
+ * Resolves to 'ack' or 'requeue'.
48
+ */
49
+ function handleMessage(msg, options) {
50
+ const opts = options || {};
51
+ const apiUrl = opts.apiUrl || config.apiUrl;
52
+ const timeoutMs = opts.timeoutMs || config.timeoutMs;
53
+ const post = opts.post || function (url, body) {
54
+ return axios.post(url, body, { timeout: timeoutMs, headers: { 'Content-Type': 'application/json' } });
55
+ };
56
+
57
+ let body;
58
+ try {
59
+ body = JSON.parse(msg.content.toString());
60
+ } catch (e) {
61
+ winston.error("[Chat21WebhookWorker] Discarding malformed message", e);
62
+ return Promise.resolve('ack');
63
+ }
64
+
65
+ return post(apiUrl, body).then(function (response) {
66
+ winston.debug("[Chat21WebhookWorker] event " + body.event_type + " delivered, status " + response.status);
67
+ return 'ack';
68
+ }).catch(function (err) {
69
+ const response = err && err.response;
70
+ if (!response) {
71
+ winston.error("[Chat21WebhookWorker] webhook API unreachable, requeueing event " + body.event_type + ": " + (err && err.message));
72
+ return 'requeue';
73
+ }
74
+ if (response.status < 500) {
75
+ // e.g. 400 'Not a support message': the event is not meant for tiledesk-server.
76
+ winston.debug("[Chat21WebhookWorker] event " + body.event_type + " rejected with status " + response.status);
77
+ return 'ack';
78
+ }
79
+ if (msg.fields && msg.fields.redelivered) {
80
+ winston.error("[Chat21WebhookWorker] event " + body.event_type + " failed again with status " + response.status + ", discarding it");
81
+ return 'ack';
82
+ }
83
+ winston.warn("[Chat21WebhookWorker] event " + body.event_type + " failed with status " + response.status + ", requeueing it");
84
+ return 'requeue';
85
+ });
86
+ }
87
+
88
+ function start(amqpConn, closeOnErr) {
89
+ const limiter = createRateLimiter(config.rateLimit);
90
+ winston.info("[Chat21WebhookWorker] queue: " + config.queueName + " exchange: " + CHAT21_WEBHOOK_EXCHANGE +
91
+ " routing key: " + CHAT21_WEBHOOK_ROUTING_KEY + " api: " + config.apiUrl +
92
+ " rate limit (msg/s): " + (config.rateLimit || "unlimited") + " prefetch: " + config.prefetch);
93
+
94
+ amqpConn.createChannel(function (err, ch) {
95
+ if (closeOnErr(err)) return;
96
+ ch.on("error", function (err) {
97
+ winston.error("[Chat21WebhookWorker] channel error", err);
98
+ });
99
+ ch.on("close", function () {
100
+ winston.info("[Chat21WebhookWorker] channel closed");
101
+ });
102
+ ch.prefetch(config.prefetch);
103
+ ch.assertExchange(CHAT21_WEBHOOK_EXCHANGE, 'topic', { durable: true });
104
+ ch.assertQueue(config.queueName, { durable: true }, function (err, _ok) {
105
+ if (closeOnErr(err)) return;
106
+ ch.bindQueue(_ok.queue, CHAT21_WEBHOOK_EXCHANGE, CHAT21_WEBHOOK_ROUTING_KEY, {}, function (err3, oka) {
107
+ winston.info("Queue bind: " + _ok.queue + " err: " + err3 + " key: " + CHAT21_WEBHOOK_ROUTING_KEY);
108
+ });
109
+
110
+ ch.consume(_ok.queue, function (msg) {
111
+ if (!msg) return;
112
+ limiter.schedule(function () {
113
+ handleMessage(msg).then(function (outcome) {
114
+ if (outcome === 'ack') {
115
+ safely(function () { ch.ack(msg); });
116
+ } else {
117
+ setTimeout(function () {
118
+ safely(function () { ch.nack(msg, false, true); });
119
+ }, config.requeueDelayMs);
120
+ }
121
+ });
122
+ });
123
+ }, { noAck: false });
124
+ winston.info("[Chat21WebhookWorker] Worker is started");
125
+ });
126
+ });
127
+ }
128
+
129
+ // ack/nack throw if the channel has been closed meanwhile: the broker requeues the message anyway.
130
+ function safely(fn) {
131
+ try {
132
+ fn();
133
+ } catch (e) {
134
+ winston.warn("[Chat21WebhookWorker] ack/nack failed", e.message);
135
+ }
136
+ }
137
+
138
+ module.exports = {
139
+ CHAT21_WEBHOOK_EXCHANGE: CHAT21_WEBHOOK_EXCHANGE,
140
+ CHAT21_WEBHOOK_ROUTING_KEY: CHAT21_WEBHOOK_ROUTING_KEY,
141
+ config: config,
142
+ createRateLimiter: createRateLimiter,
143
+ handleMessage: handleMessage,
144
+ start: start
145
+ };
@@ -0,0 +1,36 @@
1
+
2
+ function parseList(value) {
3
+ if (!value) {
4
+ return [];
5
+ }
6
+ return value.split(",").map(function (t) { return t.trim(); }).filter(function (t) { return t.length > 0; });
7
+ }
8
+
9
+ /**
10
+ * Selects the routing keys the worker binds to, based on INCLUDE_QUEUE / EXCLUDE_QUEUE
11
+ * (comma separated routing keys, mutually exclusive).
12
+ * Returns { enabled, disabled, unknown, filtered }.
13
+ */
14
+ function resolveTopics(allTopics, includeValue, excludeValue) {
15
+ const include = parseList(includeValue);
16
+ const exclude = parseList(excludeValue);
17
+
18
+ if (include.length > 0 && exclude.length > 0) {
19
+ throw new Error("INCLUDE_QUEUE and EXCLUDE_QUEUE are mutually exclusive. Set only one of them.");
20
+ }
21
+
22
+ const selection = include.length > 0 ? include : exclude;
23
+ const unknown = selection.filter(function (t) { return allTopics.indexOf(t) === -1; });
24
+
25
+ let enabled;
26
+ if (include.length > 0) {
27
+ enabled = allTopics.filter(function (t) { return include.indexOf(t) !== -1; });
28
+ } else {
29
+ enabled = allTopics.filter(function (t) { return exclude.indexOf(t) === -1; });
30
+ }
31
+ const disabled = allTopics.filter(function (t) { return enabled.indexOf(t) === -1; });
32
+
33
+ return { enabled: enabled, disabled: disabled, unknown: unknown, filtered: selection.length > 0 };
34
+ }
35
+
36
+ module.exports = { parseList: parseList, resolveTopics: resolveTopics };
@@ -8,6 +8,8 @@ const botEvent = require('../../event/botEvent');
8
8
  const authEvent = require('../../event/authEvent');
9
9
  const kbEvent = require('../../event/kbEvent');
10
10
  const projectUserUpdateContextUtil = require('../../utils/projectUserUpdateContextUtil');
11
+ const queueTopicsFilter = require('./queueTopicsFilter');
12
+ const chat21WebhookWorker = require('./chat21WebhookWorker');
11
13
  // https://elements.heroku.com/addons/cloudamqp
12
14
  // https://gist.github.com/carlhoerberg/006b01ac17a0a94859ba#file-reconnect-js
13
15
  // http://www.rabbitmq.com/tutorials/tutorial-one-javascript.html
@@ -36,6 +38,63 @@ var exchange = process.env.QUEUE_EXCHANGE_TOPIC || 'amq.topic';
36
38
  var queueName = process.env.QUEUE_NAME || 'jobs';
37
39
  winston.info("Durable queue: " + durable + " Persistent queue: " + persistent + " Exchange topic: " + exchange+ " Queue name: " + queueName);
38
40
 
41
+ // Routing keys bound to the jobs queue (QUEUE_NAME) on the tiledesk exchange.
42
+ const JOBS_TOPICS = [
43
+ "request_create",
44
+ "request_update_preflight",
45
+ "request_participants_update",
46
+ "request_participants_join",
47
+ "request_participants_leave",
48
+ "request_assigned",
49
+ "request_update",
50
+ "request_close",
51
+ "request_close_extended",
52
+ "request_workingStatus_update",
53
+ "message_create",
54
+ "project_user_update",
55
+ "project_user_invite",
56
+ "project_user_invite_pending",
57
+ "project_user_delete",
58
+ "faqbot_update",
59
+ "faqbot_created",
60
+ "faqbot_deleted",
61
+ "faqbot_publish",
62
+ "kb_namespace_create",
63
+ "kb_namespace_delete",
64
+ "kb_contents_add",
65
+ "kb_contents_delete",
66
+ "kb_content_delete",
67
+ "lead_create",
68
+ "lead_update",
69
+ "lead_fullname_email_update",
70
+ "request_snapshot_update"
71
+ ];
72
+ // Consumed from its own queue (CHAT21_WEBHOOK_QUEUE_NAME), see chat21WebhookWorker.js.
73
+ const CHAT21_WEBHOOK_TOPIC = chat21WebhookWorker.CHAT21_WEBHOOK_ROUTING_KEY;
74
+ const ALL_WORKER_TOPICS = JOBS_TOPICS.concat([CHAT21_WEBHOOK_TOPIC]);
75
+
76
+ var topicsSelection = { enabled: ALL_WORKER_TOPICS, disabled: [], unknown: [], filtered: false };
77
+ var enabledJobsTopics = JOBS_TOPICS;
78
+ var disabledJobsTopics = [];
79
+ var chat21WebhookEnabled = true;
80
+
81
+ // Applies INCLUDE_QUEUE / EXCLUDE_QUEUE so that dedicated worker replicas can be deployed.
82
+ // Replicas with different filters on the jobs topics must use different QUEUE_NAME values:
83
+ // bindings belong to the queue, so replicas sharing a queue share its bindings.
84
+ function resolveWorkerTopics() {
85
+ topicsSelection = queueTopicsFilter.resolveTopics(ALL_WORKER_TOPICS, process.env.INCLUDE_QUEUE, process.env.EXCLUDE_QUEUE);
86
+ if (topicsSelection.unknown.length > 0) {
87
+ winston.warn("INCLUDE_QUEUE/EXCLUDE_QUEUE contain unknown routing keys: " + topicsSelection.unknown.join(","));
88
+ }
89
+ enabledJobsTopics = JOBS_TOPICS.filter(function(t) { return topicsSelection.enabled.indexOf(t) !== -1; });
90
+ disabledJobsTopics = JOBS_TOPICS.filter(function(t) { return topicsSelection.enabled.indexOf(t) === -1; });
91
+ chat21WebhookEnabled = topicsSelection.enabled.indexOf(CHAT21_WEBHOOK_TOPIC) !== -1;
92
+ winston.info("Worker topics enabled: " + topicsSelection.enabled.join(","));
93
+ if (topicsSelection.disabled.length > 0) {
94
+ winston.info("Worker topics disabled: " + topicsSelection.disabled.join(","));
95
+ }
96
+ }
97
+
39
98
 
40
99
  function start() {
41
100
  amqp.connect(url, function(err, conn) {
@@ -122,6 +181,13 @@ function publish(exchange, routingKey, content) {
122
181
  // A worker that acks messages only if processed succesfully
123
182
  // var channel;
124
183
  function startWorker() {
184
+ if (chat21WebhookEnabled) {
185
+ chat21WebhookWorker.start(amqpConn, closeOnErr);
186
+ }
187
+ if (enabledJobsTopics.length === 0) {
188
+ winston.info("Queue " + queueName + " not consumed: no jobs topic enabled by INCLUDE_QUEUE/EXCLUDE_QUEUE");
189
+ return;
190
+ }
125
191
  amqpConn.createChannel(function(err, ch) {
126
192
  if (closeOnErr(err)) return;
127
193
  ch.on("error", function(err) {
@@ -139,143 +205,21 @@ function startWorker() {
139
205
  ch.assertQueue(queueName, { durable: durable }, function(err, _ok) {
140
206
  // ch.assertQueue("jobs", { durable: true }, function(err, _ok) {
141
207
  if (closeOnErr(err)) return;
142
- ch.bindQueue(_ok.queue, exchange, "request_create", {}, function(err3, oka) {
143
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: request_create");
144
- winston.info("Data queue", oka)
145
- });
146
- ch.bindQueue(_ok.queue, exchange, "request_update_preflight", {}, function(err3, oka) {
147
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: request_update_preflight");
208
+ enabledJobsTopics.forEach(function(topic) {
209
+ ch.bindQueue(_ok.queue, exchange, topic, {}, function(err3, oka) {
210
+ winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: "+topic);
148
211
  winston.info("Data queue", oka)
149
- });
150
- ch.bindQueue(_ok.queue, exchange, "request_participants_update", {}, function(err3, oka) {
151
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: request_participants_update");
152
- winston.info("Data queue", oka)
153
- });
154
-
155
- ch.bindQueue(_ok.queue, exchange, "request_participants_join", {}, function(err3, oka) {
156
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: request_participants_join");
157
- winston.info("Data queue", oka)
158
- });
159
-
160
- ch.bindQueue(_ok.queue, exchange, "request_participants_leave", {}, function(err3, oka) {
161
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: request_participants_leave");
162
- winston.info("Data queue", oka)
163
- });
164
-
165
- ch.bindQueue(_ok.queue, exchange, "request_assigned", {}, function(err3, oka) {
166
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: request_assigned");
167
- winston.info("Data queue", oka)
168
- });
169
-
170
- ch.bindQueue(_ok.queue, exchange, "request_update", {}, function(err3, oka) {
171
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: request_update");
172
- winston.info("Data queue", oka)
173
- });
174
-
175
- ch.bindQueue(_ok.queue, exchange, "request_close", {}, function(err3, oka) {
176
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: request_close");
177
- winston.info("Data queue", oka)
178
- });
179
-
180
- ch.bindQueue(_ok.queue, exchange, "request_close_extended", {}, function(err3, oka) {
181
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: request_close_extended");
182
- winston.info("Data queue", oka)
183
- });
184
-
185
- ch.bindQueue(_ok.queue, exchange, "request_workingStatus_update", {}, function(err3, oka) {
186
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: request_workingStatus_update");
187
- winston.info("Data queue", oka)
188
- });
189
-
190
- ch.bindQueue(_ok.queue, exchange, "message_create", {}, function(err3, oka) {
191
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: message_create");
192
- winston.info("Data queue", oka)
193
- });
194
- ch.bindQueue(_ok.queue, exchange, "project_user_update", {}, function(err3, oka) {
195
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: project_user_update");
196
- winston.info("Data queue", oka)
197
- });
198
-
199
- ch.bindQueue(_ok.queue, exchange, "project_user_invite", {}, function(err3, oka) {
200
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: project_user_invite");
201
- winston.info("Data queue", oka)
202
- });
203
-
204
- ch.bindQueue(_ok.queue, exchange, "project_user_invite_pending", {}, function(err3, oka) {
205
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: project_user_invite_pending");
206
- winston.info("Data queue", oka)
207
- });
208
-
209
- ch.bindQueue(_ok.queue, exchange, "project_user_delete", {}, function(err3, oka) {
210
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: project_user_delete");
211
- winston.info("Data queue", oka)
212
- });
213
-
214
- ch.bindQueue(_ok.queue, exchange, "faqbot_update", {}, function(err3, oka) {
215
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: faqbot_update");
216
- winston.info("Data queue", oka)
217
- });
218
-
219
- ch.bindQueue(_ok.queue, exchange, "faqbot_created", {}, function(err3, oka) {
220
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: faqbot_created");
221
- winston.info("Data queue", oka)
222
- });
223
-
224
- ch.bindQueue(_ok.queue, exchange, "faqbot_deleted", {}, function(err3, oka) {
225
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: faqbot_deleted");
226
- winston.info("Data queue", oka)
227
- });
228
-
229
- ch.bindQueue(_ok.queue, exchange, "faqbot_publish", {}, function(err3, oka) {
230
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: faqbot_publish");
231
- winston.info("Data queue", oka)
232
- });
233
-
234
- ch.bindQueue(_ok.queue, exchange, "kb_namespace_create", {}, function(err3, oka) {
235
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: kb_namespace_create");
236
- winston.info("Data queue", oka)
237
- });
238
-
239
- ch.bindQueue(_ok.queue, exchange, "kb_namespace_delete", {}, function(err3, oka) {
240
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: kb_namespace_delete");
241
- winston.info("Data queue", oka)
242
- });
243
-
244
- ch.bindQueue(_ok.queue, exchange, "kb_contents_add", {}, function(err3, oka) {
245
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: kb_contents_add");
246
- winston.info("Data queue", oka)
247
- });
248
-
249
- ch.bindQueue(_ok.queue, exchange, "kb_contents_delete", {}, function(err3, oka) {
250
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: kb_contents_delete");
251
- winston.info("Data queue", oka)
252
- });
253
-
254
- ch.bindQueue(_ok.queue, exchange, "kb_content_delete", {}, function(err3, oka) {
255
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: kb_content_delete");
256
- winston.info("Data queue", oka)
257
- });
258
-
259
- ch.bindQueue(_ok.queue, exchange, "lead_create", {}, function(err3, oka) {
260
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: lead_create");
261
- winston.info("Data queue", oka)
262
- });
263
-
264
- ch.bindQueue(_ok.queue, exchange, "lead_update", {}, function(err3, oka) {
265
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: lead_update");
266
- winston.info("Data queue", oka)
267
- });
268
-
269
- ch.bindQueue(_ok.queue, exchange, "lead_fullname_email_update", {}, function(err3, oka) {
270
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: lead_fullname_email_update");
271
- winston.info("Data queue", oka)
272
- });
273
-
274
- ch.bindQueue(_ok.queue, exchange, "request_snapshot_update", {}, function(err3, oka) {
275
- winston.info("Queue bind: "+_ok.queue+ " err: "+err3+ " key: request_snapshot_update");
276
- winston.info("Data queue", oka)
212
+ });
277
213
  });
278
214
 
215
+ if (topicsSelection.filtered) {
216
+ // Remove bindings left on the queue by a previous configuration.
217
+ disabledJobsTopics.forEach(function(topic) {
218
+ ch.unbindQueue(_ok.queue, exchange, topic, {}, function(err3) {
219
+ winston.info("Queue unbind: "+_ok.queue+ " err: "+err3+ " key: "+topic);
220
+ });
221
+ });
222
+ }
279
223
 
280
224
  ch.consume(queueName, processMsg, { noAck: false });
281
225
  winston.info("Worker is started");
@@ -804,6 +748,12 @@ function listen() {
804
748
  }
805
749
 
806
750
  if (process.env.QUEUE_ENABLED === "true") {
751
+ try {
752
+ resolveWorkerTopics();
753
+ } catch (e) {
754
+ winston.error("Invalid worker queue configuration: " + e.message);
755
+ process.exit(1);
756
+ }
807
757
  requestEvent.queueEnabled = true;
808
758
  messageEvent.queueEnabled = true;
809
759
  authEvent.queueEnabled = true;
@@ -0,0 +1,131 @@
1
+ // During the test the env variable is set to test
2
+ process.env.NODE_ENV = 'test';
3
+
4
+ const { expect } = require('chai');
5
+ const queueTopicsFilter = require('../queueTopicsFilter');
6
+ const chat21WebhookWorker = require('../chat21WebhookWorker');
7
+
8
+ const TOPICS = ['request_create', 'message_create', 'chat21.webhook.event'];
9
+
10
+ function makeMsg(body, redelivered) {
11
+ return {
12
+ content: Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)),
13
+ fields: { redelivered: !!redelivered }
14
+ };
15
+ }
16
+
17
+ function httpError(status) {
18
+ const err = new Error('Request failed with status code ' + status);
19
+ err.response = { status: status };
20
+ return err;
21
+ }
22
+
23
+ describe('queue worker', function () {
24
+
25
+ describe('INCLUDE_QUEUE / EXCLUDE_QUEUE', function () {
26
+ it('enables every topic when no filter is set', function () {
27
+ const res = queueTopicsFilter.resolveTopics(TOPICS, undefined, '');
28
+ expect(res.enabled).to.deep.equal(TOPICS);
29
+ expect(res.disabled).to.deep.equal([]);
30
+ expect(res.filtered).to.equal(false);
31
+ });
32
+
33
+ it('INCLUDE_QUEUE enables only the listed topics', function () {
34
+ const res = queueTopicsFilter.resolveTopics(TOPICS, ' chat21.webhook.event ', undefined);
35
+ expect(res.enabled).to.deep.equal(['chat21.webhook.event']);
36
+ expect(res.disabled).to.deep.equal(['request_create', 'message_create']);
37
+ expect(res.filtered).to.equal(true);
38
+ });
39
+
40
+ it('EXCLUDE_QUEUE disables the listed topics', function () {
41
+ const res = queueTopicsFilter.resolveTopics(TOPICS, undefined, 'chat21.webhook.event,request_create');
42
+ expect(res.enabled).to.deep.equal(['message_create']);
43
+ expect(res.disabled).to.deep.equal(['request_create', 'chat21.webhook.event']);
44
+ });
45
+
46
+ it('reports unknown routing keys', function () {
47
+ const res = queueTopicsFilter.resolveTopics(TOPICS, 'message_create,not_a_topic', undefined);
48
+ expect(res.enabled).to.deep.equal(['message_create']);
49
+ expect(res.unknown).to.deep.equal(['not_a_topic']);
50
+ });
51
+
52
+ it('rejects INCLUDE_QUEUE and EXCLUDE_QUEUE set together', function () {
53
+ expect(function () {
54
+ queueTopicsFilter.resolveTopics(TOPICS, 'message_create', 'request_create');
55
+ }).to.throw(/mutually exclusive/);
56
+ });
57
+ });
58
+
59
+ describe('chat21 webhook event handling', function () {
60
+ const event = { event_type: 'message-sent', app_id: 'tilechat', data: { text: 'hello' } };
61
+
62
+ it('posts the event body to the chat21 webhook API and acks', async function () {
63
+ const calls = [];
64
+ const outcome = await chat21WebhookWorker.handleMessage(makeMsg(event), {
65
+ apiUrl: 'http://tiledesk/chat21/requests',
66
+ post: function (url, body) {
67
+ calls.push({ url: url, body: body });
68
+ return Promise.resolve({ status: 200 });
69
+ }
70
+ });
71
+ expect(outcome).to.equal('ack');
72
+ expect(calls).to.deep.equal([{ url: 'http://tiledesk/chat21/requests', body: event }]);
73
+ });
74
+
75
+ it('acks events rejected with a 4xx (e.g. not a support message)', async function () {
76
+ const outcome = await chat21WebhookWorker.handleMessage(makeMsg(event), {
77
+ post: function () { return Promise.reject(httpError(400)); }
78
+ });
79
+ expect(outcome).to.equal('ack');
80
+ });
81
+
82
+ it('requeues on 5xx the first time and discards on redelivery', async function () {
83
+ const post = function () { return Promise.reject(httpError(500)); };
84
+ expect(await chat21WebhookWorker.handleMessage(makeMsg(event, false), { post: post })).to.equal('requeue');
85
+ expect(await chat21WebhookWorker.handleMessage(makeMsg(event, true), { post: post })).to.equal('ack');
86
+ });
87
+
88
+ it('requeues when the API is unreachable', async function () {
89
+ const outcome = await chat21WebhookWorker.handleMessage(makeMsg(event, true), {
90
+ post: function () { return Promise.reject(new Error('connect ECONNREFUSED')); }
91
+ });
92
+ expect(outcome).to.equal('requeue');
93
+ });
94
+
95
+ it('discards malformed messages', async function () {
96
+ let called = false;
97
+ const outcome = await chat21WebhookWorker.handleMessage(makeMsg('not json'), {
98
+ post: function () { called = true; return Promise.resolve({ status: 200 }); }
99
+ });
100
+ expect(outcome).to.equal('ack');
101
+ expect(called).to.equal(false);
102
+ });
103
+ });
104
+
105
+ describe('rate limiter', function () {
106
+ it('runs immediately when unlimited', function () {
107
+ let runs = 0;
108
+ const limiter = chat21WebhookWorker.createRateLimiter(0);
109
+ for (let i = 0; i < 5; i++) limiter.schedule(function () { runs++; });
110
+ expect(runs).to.equal(5);
111
+ });
112
+
113
+ it('spreads executions according to the rate', function (done) {
114
+ const starts = [];
115
+ const limiter = chat21WebhookWorker.createRateLimiter(20); // one every 50ms
116
+ const begin = Date.now();
117
+ for (let i = 0; i < 5; i++) {
118
+ limiter.schedule(function () {
119
+ starts.push(Date.now() - begin);
120
+ if (starts.length === 5) {
121
+ expect(starts[4]).to.be.at.least(190);
122
+ for (let j = 1; j < starts.length; j++) {
123
+ expect(starts[j] - starts[j - 1]).to.be.at.least(40);
124
+ }
125
+ done();
126
+ }
127
+ });
128
+ }
129
+ });
130
+ });
131
+ });