wingbot 3.27.2 → 3.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wingbot",
3
- "version": "3.27.2",
3
+ "version": "3.30.0",
4
4
  "description": "Enterprise Messaging Bot Conversation Engine",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -36,33 +36,28 @@
36
36
  },
37
37
  "homepage": "https://github.com/wingbot.ai/wingbot#readme",
38
38
  "devDependencies": {
39
- "cpy-cli": "^3.1.1",
40
- "eslint": "^7.12.1",
41
- "eslint-config-airbnb": "^18.2.0",
42
- "eslint-mocha": "^0.7.3",
43
- "eslint-plugin-import": "^2.22.1",
44
- "eslint-plugin-jsdoc": "^30.7.7",
45
- "eslint-plugin-jsx-a11y": "^6.4.1",
46
- "eslint-plugin-mocha": "^8.0.0",
47
- "eslint-plugin-react": "^7.21.5",
48
- "gitbook-cli": "^2.3.2",
49
- "graphql-markdown": "^5.2.0",
50
- "graphql-tools": "^7.0.5",
51
- "jsdoc-to-markdown": "^6.0.1",
52
- "mocha": "^8.2.1",
53
- "move-cli": "^1.2.1",
39
+ "cpy-cli": "^4.1.0",
40
+ "eslint": "^8.11.0",
41
+ "eslint-config-airbnb": "^19.0.4",
42
+ "eslint-plugin-import": "^2.25.4",
43
+ "eslint-plugin-jsdoc": "^38.0.4",
44
+ "eslint-plugin-jsx-a11y": "^6.5.1",
45
+ "eslint-plugin-mocha": "^10.0.3",
46
+ "eslint-plugin-react": "^7.29.4",
47
+ "graphql-markdown": "^6.0.0",
48
+ "jsdoc-to-markdown": "^7.1.1",
49
+ "mocha": "^9.2.2",
54
50
  "nyc": "^15.1.0",
55
51
  "rimraf": "^3.0.2",
56
- "sinon": "^9.2.1",
52
+ "sinon": "^13.0.1",
57
53
  "tsd-jsdoc": "^2.5.0"
58
54
  },
59
55
  "dependencies": {
60
- "graphql": "^15.4.0",
61
- "graphql-mongodb-projection": "^1.1.1",
56
+ "graphql": "^15.8.0",
62
57
  "jsonwebtoken": "^8.5.1",
63
- "node-fetch": "^2.6.1",
58
+ "node-fetch": "^2.6.7",
64
59
  "path-to-regexp": "^6.2.0",
65
- "uuid": "^8.3.1",
60
+ "uuid": "^8.3.2",
66
61
  "webalize": "^0.1.0"
67
62
  },
68
63
  "optionalDependencies": {
@@ -3,7 +3,7 @@
3
3
  * @param {import('../../src/Responder')} res
4
4
  * @param {Function} postback
5
5
  */
6
- module.exports = (req, res, postback) => {
6
+ module.exports = function (req, res, postback) {
7
7
  if (req.state.beforeLastInteraction
8
8
  && !req.state.beforeLastInteraction.match(/\/\*$/)
9
9
  && req.state.beforeLastInteraction !== res.currentAction()) {
@@ -3,7 +3,7 @@
3
3
  * @param {import('../../src/Responder')} res
4
4
  * @param {Function} postback
5
5
  */
6
- module.exports = (req, res, postback) => {
6
+ module.exports = function (req, res, postback) {
7
7
  let { breadcrumbs = [] } = req.state;
8
8
  let shift = req.state.breadcrumbAction === req.state._lastAction
9
9
  ? 2
@@ -3,7 +3,7 @@
3
3
  * @param {import('../../src/Responder')} res
4
4
  * @param {Function} postback
5
5
  */
6
- module.exports = (req, res, postback) => {
6
+ module.exports = function (req, res, postback) {
7
7
  let { jumpPluginStack = [] } = {
8
8
  ...req.state,
9
9
  ...res.newState
@@ -3,7 +3,7 @@
3
3
  * @param {import('../../src/Responder')} res
4
4
  * @param {Function} postback
5
5
  */
6
- module.exports = (req, res, postback) => {
6
+ module.exports = function (req, res, postback) {
7
7
  const {
8
8
  whereToJump,
9
9
  whereToJumpBack
@@ -2,4 +2,6 @@
2
2
  * @param {import('../../src/Request')} req
3
3
  * @param {import('../../src/Responder')} res
4
4
  */
5
- module.exports = (req, res) => null;
5
+ module.exports = function (req, res) {
6
+ return null;
7
+ };
package/src/Ai.js CHANGED
@@ -64,6 +64,20 @@ class Ai {
64
64
  */
65
65
  this.threshold = 0.3;
66
66
 
67
+ /**
68
+ * Upper limit for NLP resolving of STT alternatives
69
+ *
70
+ * @type {number}
71
+ */
72
+ this.sttMaxAlternatives = 3;
73
+
74
+ /**
75
+ * Minimal score to consider text as recognized well
76
+ *
77
+ * @type {number}
78
+ */
79
+ this.sttScoreThreshold = 0;
80
+
67
81
  /**
68
82
  * The logger (console by default)
69
83
  *
@@ -581,35 +595,61 @@ class Ai {
581
595
  }
582
596
  }
583
597
 
598
+ const texts = req.textAlternatives()
599
+ .filter((alt) => alt.score >= this.sttScoreThreshold)
600
+ .slice(0, this.sttMaxAlternatives);
601
+
602
+ const altMax = Math.max(0, ...texts.map((t) => t.score));
603
+ const altKoef = (1 - this.confidence);
604
+
584
605
  const results = await Promise.all(
585
- req.textAlternatives()
586
- .map(({ text, score }) => model
587
- .resolve(this.textFilter(text), req)
588
- .then((res) => ({
589
- ...res,
590
- _altScore: score
591
- })))
606
+ texts.map(({ text, score }) => model
607
+ .resolve(this.textFilter(text), req)
608
+ .then((res) => ({
609
+ ...res,
610
+ intents: res.intents
611
+ .map((i) => ({
612
+ ...i,
613
+ score: i.score - (altKoef * (altMax - score))
614
+ }))
615
+ .sort(({ score: a }, { score: z }) => z - a)
616
+ })))
592
617
  );
593
618
 
594
619
  results.sort(({
595
- intents: aIntents = [], _altScore: aAlt
620
+ intents: [aIntent = { score: 0 }]
596
621
  }, {
597
- intents: zIntents = [], _altScore: zAlt
598
- }) => {
599
- const [{ score: aScore = 0 } = {}] = aIntents;
600
- const [{ score: zScore = 0 } = {}] = zIntents;
622
+ intents: [zIntent = { score: 0 }]
623
+ }) => zIntent.score - aIntent.score);
601
624
 
602
- const a = aAlt * aScore;
603
- const z = zAlt * zScore;
625
+ const [winner = { intents: [], entities: [] }, ...others] = results;
604
626
 
605
- if (a === z) {
606
- return zAlt - aAlt;
607
- }
627
+ if (others.length === 0) {
628
+ return winner;
629
+ }
608
630
 
609
- return z - a;
631
+ const { intents } = winner;
632
+
633
+ const known = new Set(intents.map((i) => i.intent));
634
+
635
+ others.forEach((other) => {
636
+ intents.push(
637
+ ...other.intents.filter(({ score, intent }) => {
638
+ if (score >= this.confidence || known.has(intent)) {
639
+ return false;
640
+ }
641
+ known.add(intent);
642
+ return true;
643
+ })
644
+ );
610
645
  });
611
646
 
612
- return results[0];
647
+ intents.sort(({ score: a }, { score: z }) => z - a);
648
+
649
+ return {
650
+ ...winner,
651
+ intents
652
+ };
613
653
  }
614
654
 
615
655
  /**
package/src/AiMatching.js CHANGED
@@ -53,12 +53,16 @@ const COMPARE = {
53
53
  * @param {Entity[]} [entities]
54
54
  */
55
55
 
56
+ /**
57
+ * @typedef {string|number|Function} Comparable
58
+ */
59
+
56
60
  /**
57
61
  * @typedef {object} EntityExpression
58
62
  * @prop {string} entity - the requested entity
59
63
  * @prop {boolean} [optional] - the match is optional
60
64
  * @prop {Compare} [op] - comparison operation
61
- * @prop {(string|number|Function)[]} [compare] - value to compare
65
+ * @prop {Comparable[]} [compare] - value to compare
62
66
  */
63
67
 
64
68
  /**
@@ -578,10 +582,12 @@ class AiMatching {
578
582
  score: 1
579
583
  };
580
584
 
581
- matching = this
582
- ._entityIsMatching(
583
- wanted.op, wanted.compare, requestEntity.value, requestState
584
- );
585
+ matching = this._entityIsMatching(
586
+ wanted.op,
587
+ wanted.compare,
588
+ requestEntity.value,
589
+ requestState
590
+ );
585
591
  }
586
592
  } else if (!entityExists) {
587
593
  matching = this
package/src/BotApp.js CHANGED
@@ -87,14 +87,12 @@ class BotApp {
87
87
  this._senderLogger = chatLogStorage;
88
88
  this._verify = promisify(jwt.verify);
89
89
 
90
- this._processor = new Processor(
91
- bot, {
92
- ...processorOptions,
93
- secret,
94
- fetch: this._fetch,
95
- apiUrl: gqlApiUrl
96
- }
97
- );
90
+ this._processor = new Processor(bot, {
91
+ ...processorOptions,
92
+ secret,
93
+ fetch: this._fetch,
94
+ apiUrl: gqlApiUrl
95
+ });
98
96
 
99
97
  this._processor.plugin(BotApp.plugin());
100
98
  this._preferSynchronousResponse = preferSynchronousResponse;
@@ -191,7 +189,13 @@ class BotApp {
191
189
  }
192
190
 
193
191
  async _processIncommingMessage (
194
- message, senderId, pageId, appId, secret, sync = false, headers = {}
192
+ message,
193
+ senderId,
194
+ pageId,
195
+ appId,
196
+ secret,
197
+ sync = false,
198
+ headers = {}
195
199
  ) {
196
200
  const { mid = null } = message;
197
201
 
@@ -386,7 +390,13 @@ class BotApp {
386
390
 
387
391
  for (const message of messaging) {
388
392
  const res = await this._processIncommingMessage(
389
- message, senderId, pageId, appId, secret, sync, headers
393
+ message,
394
+ senderId,
395
+ pageId,
396
+ appId,
397
+ secret,
398
+ sync,
399
+ headers
390
400
  );
391
401
 
392
402
  responses.push(res);
@@ -305,9 +305,8 @@ class ConversationTester {
305
305
  return testCases
306
306
  // @ts-ignore
307
307
  .reduce(
308
- (map, testCase) => map.set(
309
- testCase.list, [...(map.get(testCase.list) || []), testCase]
310
- ),
308
+ (map, testCase) => map
309
+ .set(testCase.list, [...(map.get(testCase.list) || []), testCase]),
311
310
  new Map()
312
311
  );
313
312
  }
@@ -77,11 +77,9 @@ class OrchestratorClient {
77
77
  */
78
78
  async addConversationTokenToUrl (url, expirationInSeconds, tokenName = 'wbchtoken') {
79
79
  const { conversationToken } = await this.getConversationToken(expirationInSeconds);
80
- return withParams(
81
- url, {
82
- query: { [tokenName]: conversationToken }
83
- }
84
- );
80
+ return withParams(url, {
81
+ query: { [tokenName]: conversationToken }
82
+ });
85
83
  }
86
84
 
87
85
  /**
package/src/Processor.js CHANGED
@@ -100,7 +100,7 @@ const { mergeState } = require('./utils/stateVariables');
100
100
  * @prop {string|null} [meta.targetAction]
101
101
  */
102
102
 
103
- const NAME_FROM_STATE = (state) => {
103
+ function NAME_FROM_STATE (state) {
104
104
  if (state.user && state.user.firstName) {
105
105
  return `${state.user.firstName} ${state.user.lastName}`;
106
106
  }
@@ -108,7 +108,7 @@ const NAME_FROM_STATE = (state) => {
108
108
  return `${state.user.name}`;
109
109
  }
110
110
  return null;
111
- };
111
+ }
112
112
 
113
113
  /**
114
114
  * Messaging event processor
package/src/Request.js CHANGED
@@ -742,18 +742,36 @@ class Request {
742
742
  if (!this.message || typeof this.message.text !== 'string') {
743
743
  return [];
744
744
  }
745
- const { text, alternatives = [] } = this.message;
745
+ const { text: messageText, alternatives = [] } = this.message;
746
+
747
+ const unique = new Set();
748
+ let max = 0;
749
+
750
+ const sorted = alternatives
751
+ .slice()
752
+ .map(({ text, score = 1 }) => ({ text, score }))
753
+ .sort(({ score: a }, { score: z }) => z - a)
754
+ .filter((a) => {
755
+ const norm = tokenize(a.text);
756
+ if (unique.has(norm) || !a.text) {
757
+ return false;
758
+ }
759
+ max = Math.max(max, a.score);
760
+ unique.add(norm);
761
+ return true;
762
+ });
763
+
764
+ const normalized = tokenize(messageText);
746
765
 
747
- if (alternatives.some((a) => a.text === text)) {
748
- return alternatives;
766
+ if (unique.has(normalized)) {
767
+ return sorted;
749
768
  }
750
769
 
770
+ max = max ? Math.min(1, max + 0.1) : 1;
771
+
751
772
  return [
752
- {
753
- text: this.message.text,
754
- score: 1
755
- },
756
- ...alternatives
773
+ { text: messageText, score: max },
774
+ ...sorted
757
775
  ];
758
776
  }
759
777
 
@@ -1268,7 +1286,9 @@ class Request {
1268
1286
  if (this._quickReplyActions === null) {
1269
1287
  if (this.state._expectedKeywords) {
1270
1288
  this._quickReplyActions = quickReplyAction(
1271
- this.state._expectedKeywords, this, Ai.ai
1289
+ this.state._expectedKeywords,
1290
+ this,
1291
+ Ai.ai
1272
1292
  );
1273
1293
  } else {
1274
1294
  this._quickReplyActions = [];
@@ -1411,7 +1431,8 @@ It looks like the bot isn't connected to class BotApp or the Processor is used w
1411
1431
  data = {},
1412
1432
  refAction = null,
1413
1433
  refData = {},
1414
- timestamp = makeTimestamp()
1434
+ timestamp = makeTimestamp(),
1435
+ features = [FEATURE_TEXT]
1415
1436
  ) {
1416
1437
  const postback = {
1417
1438
  payload: {
@@ -1429,7 +1450,8 @@ It looks like the bot isn't connected to class BotApp or the Processor is used w
1429
1450
  sender: {
1430
1451
  id: senderId
1431
1452
  },
1432
- postback
1453
+ postback,
1454
+ features
1433
1455
  };
1434
1456
  }
1435
1457
 
@@ -1725,4 +1747,24 @@ It looks like the bot isn't connected to class BotApp or the Processor is used w
1725
1747
 
1726
1748
  }
1727
1749
 
1750
+ /**
1751
+ * @constant {string} FEATURE_VOICE channel supports voice messages
1752
+ */
1753
+ Request.FEATURE_VOICE = FEATURE_VOICE;
1754
+
1755
+ /**
1756
+ * @constant {string} FEATURE_SSML channel supports SSML voice messages
1757
+ */
1758
+ Request.FEATURE_SSML = FEATURE_SSML;
1759
+
1760
+ /**
1761
+ * @constant {string} FEATURE_PHRASES channel supports expected phrases messages
1762
+ */
1763
+ Request.FEATURE_PHRASES = FEATURE_PHRASES;
1764
+
1765
+ /**
1766
+ * @constant {string} FEATURE_TEXT channel supports text communication
1767
+ */
1768
+ Request.FEATURE_TEXT = FEATURE_TEXT;
1769
+
1728
1770
  module.exports = Request;
package/src/Tester.js CHANGED
@@ -14,6 +14,8 @@ const AnyResponseAssert = require('./testTools/AnyResponseAssert');
14
14
  const ResponseAssert = require('./testTools/ResponseAssert');
15
15
 
16
16
  const Router = require('./Router'); // eslint-disable-line no-unused-vars
17
+ const ReducerWrapper = require('./ReducerWrapper'); // eslint-disable-line no-unused-vars
18
+ const { FEATURE_TEXT } = require('./features');
17
19
 
18
20
  /**
19
21
  * Utility for testing requests
@@ -118,6 +120,11 @@ class Tester {
118
120
  * @prop {console} use own loggger
119
121
  */
120
122
  this.senderLogger = undefined;
123
+
124
+ /**
125
+ * @prop {string[]}
126
+ */
127
+ this.features = null;
121
128
  }
122
129
 
123
130
  dealloc () {
@@ -148,6 +155,15 @@ class Tester {
148
155
  this._responsesMock = [];
149
156
  }
150
157
 
158
+ /**
159
+ * Set features for all messages
160
+ *
161
+ * @param {string[]} [features]
162
+ */
163
+ setFeatures (features = [FEATURE_TEXT]) {
164
+ this.features = features;
165
+ }
166
+
151
167
  /**
152
168
  * Use tester as a connector :)
153
169
  *
@@ -164,6 +180,10 @@ class Tester {
164
180
  });
165
181
  }
166
182
 
183
+ if (this.features) {
184
+ Object.assign(message, { features: this.features });
185
+ }
186
+
167
187
  const messageSender = new ReturnSender({}, senderId, message, this.senderLogger);
168
188
  messageSender.simulatesOptIn = true;
169
189
 
@@ -259,7 +279,7 @@ class Tester {
259
279
  }
260
280
 
261
281
  /**
262
- * Checks, that a plugin used a block as a responde
282
+ * Checks, that a plugin used a block as a response
263
283
  *
264
284
  * @param {string} blockName
265
285
  * @returns {this}
@@ -308,7 +328,6 @@ class Tester {
308
328
  *
309
329
  * @param {string} text
310
330
  * @returns {Promise}
311
- *
312
331
  * @memberOf Tester
313
332
  */
314
333
  text (text) {
@@ -430,12 +449,11 @@ class Tester {
430
449
  * @param {string} [refAction=null] - referred action
431
450
  * @param {object} [refData={}] - referred action data
432
451
  * @returns {Promise}
433
- *
434
452
  * @memberOf Tester
435
453
  */
436
454
  postBack (action, data = {}, refAction = null, refData = {}) {
437
455
  return this.processMessage(Request
438
- .postBack(this.senderId, action, data, refAction, refData));
456
+ .postBack(this.senderId, action, data, refAction, refData, null));
439
457
  }
440
458
 
441
459
  }
@@ -13,6 +13,7 @@ type Tags {
13
13
  type Subscribtion {
14
14
  pageId: String
15
15
  senderId: String
16
+ meta: String
16
17
  }
17
18
 
18
19
  type Subscribtions {
@@ -103,6 +103,28 @@ const DEFAULT_CAMPAIGN_DATA = {
103
103
  * @prop {number} slideRound
104
104
  */
105
105
 
106
+ /**
107
+ * @typedef {object} Subscription
108
+ * @prop {string} senderId
109
+ * @prop {string} pageId
110
+ * @prop {string} [meta]
111
+ */
112
+
113
+ /**
114
+ * @callback PreprocessSubscriptions
115
+ * @param {Subscription[]} subscriptions
116
+ * @param {string} pageId
117
+ * @returns {Promise<Subscription[]>|Subscription[]}
118
+ */
119
+
120
+ /**
121
+ * @callback PreprocessSubscribers
122
+ * @param {string[]} senderIds
123
+ * @param {string} pageId
124
+ * @param {string} tag
125
+ * @returns {Promise<string[]>|string[]} list of senderIds
126
+ */
127
+
106
128
  /**
107
129
  * Experimental notifications service
108
130
  *
@@ -121,7 +143,8 @@ class Notifications extends EventEmitter {
121
143
  * @param {console} [options.log] - logger
122
144
  * @param {number} [options.default24Clearance] - use this clearance to ensure delivery in 24h
123
145
  * @param {string} [options.allAudienceTag] - tag to mark all users
124
- * @param {Function} [options.preprocessSubscribers] - to preprocess GQL api given senderIds
146
+ * @param {PreprocessSubscribers} [options.preprocessSubscribers] - preprocess senderIds import
147
+ * @param {PreprocessSubscriptions} [options.preprocessSubscriptions]
125
148
  */
126
149
  constructor (notificationStorage = new NotificationsStorage(), options = {}) {
127
150
  super();
@@ -138,6 +161,7 @@ class Notifications extends EventEmitter {
138
161
  this._lts = new Map();
139
162
 
140
163
  this._preprocessSubscribers = options.preprocessSubscribers;
164
+ this._preprocessSubscriptions = options.preprocessSubscriptions;
141
165
  }
142
166
 
143
167
  /**
@@ -150,7 +174,8 @@ class Notifications extends EventEmitter {
150
174
  */
151
175
  api (acl = null) {
152
176
  const options = {
153
- preprocessSubscribers: this._preprocessSubscribers
177
+ preprocessSubscribers: this._preprocessSubscribers,
178
+ preprocessSubscriptions: this._preprocessSubscriptions
154
179
  };
155
180
  return api(this._storage, this, acl, options);
156
181
  }
@@ -344,7 +369,10 @@ class Notifications extends EventEmitter {
344
369
 
345
370
  await Promise.all(tasks
346
371
  .map((task) => this._messageDeliveryByMid(
347
- task.campaignId, eventType, senderId, pageId
372
+ task.campaignId,
373
+ eventType,
374
+ senderId,
375
+ pageId
348
376
  )));
349
377
 
350
378
  return {
@@ -3,9 +3,41 @@
3
3
  */
4
4
  'use strict';
5
5
 
6
- const { infoToProjection } = require('graphql-mongodb-projection');
7
6
  const apiAuthorizer = require('../../graphApi/apiAuthorizer');
8
7
 
8
+ /**
9
+ *
10
+ * @param {*} info
11
+ * @returns {object}
12
+ */
13
+
14
+ function getFields (info) {
15
+ if (info.fieldNodes) {
16
+ const fieldNodes = info.fieldNodes.filter((k) => k.kind === 'Field');
17
+
18
+ if (fieldNodes.length !== 1) {
19
+ return {};
20
+ }
21
+
22
+ return getFields(fieldNodes[0]);
23
+ }
24
+
25
+ const nodes = info.selectionSet ? info.selectionSet.selections : info.fieldNodes;
26
+
27
+ if (!info.selectionSet) {
28
+ return true;
29
+ }
30
+
31
+ return nodes.reduce((o, fieldInfo) => {
32
+ if (fieldInfo.kind !== 'Field' || !fieldInfo.name || !fieldInfo.name.value) {
33
+ return o;
34
+ }
35
+ return Object.assign(o, {
36
+ [fieldInfo.name.value]: getFields(fieldInfo)
37
+ });
38
+ }, {});
39
+ }
40
+
9
41
  function notificationsApiFactory (storage, notifications, acl, options = {}) {
10
42
  return {
11
43
  async campaigns (args, ctx) {
@@ -87,9 +119,8 @@ function notificationsApiFactory (storage, notifications, acl, options = {}) {
87
119
  };
88
120
  }
89
121
 
90
- const data = await storage.getUnsuccessfulSubscribersByCampaign(
91
- campaignId, sentWithoutReaction, pageId
92
- );
122
+ const data = await storage
123
+ .getUnsuccessfulSubscribersByCampaign(campaignId, sentWithoutReaction, pageId);
93
124
 
94
125
  return {
95
126
  data,
@@ -115,7 +146,7 @@ function notificationsApiFactory (storage, notifications, acl, options = {}) {
115
146
  return null;
116
147
  }
117
148
 
118
- const fields = infoToProjection(info);
149
+ const fields = getFields(info);
119
150
 
120
151
  const {
121
152
  include,
@@ -132,7 +163,15 @@ function notificationsApiFactory (storage, notifications, acl, options = {}) {
132
163
  }
133
164
 
134
165
  if (fields.data) {
135
- const res = storage.getSubscribtions(include, exclude, limit, pageId, lastKey);
166
+ const res = await storage
167
+ .getSubscribtions(include, exclude, limit, pageId, lastKey);
168
+
169
+ if (fields.data.meta && typeof options.preprocessSubscriptions === 'function') {
170
+ res.data = await Promise.resolve(
171
+ options.preprocessSubscriptions(res.data, pageId)
172
+ );
173
+ }
174
+
136
175
  return Object.assign(res, { count });
137
176
  }
138
177
 
@@ -159,8 +198,12 @@ function notificationsApiFactory (storage, notifications, acl, options = {}) {
159
198
  );
160
199
  }
161
200
 
162
- for (const senderId of senderIds) {
163
- await storage.subscribe(`${senderId}`, pageId, tag);
201
+ // make it little bit faster
202
+ while (senderIds.length > 0) {
203
+ await Promise.all(
204
+ senderIds.splice(0, 5)
205
+ .map((senderId) => storage.subscribe(`${senderId}`, pageId, tag))
206
+ );
164
207
  }
165
208
 
166
209
  return true;
@@ -4,9 +4,14 @@
4
4
  'use strict';
5
5
 
6
6
  const Router = require('../Router');
7
+ const getCondition = require('../utils/getCondition');
7
8
  const { stateData, cachedTranslatedCompilator } = require('./utils');
8
9
 
9
- function media (params, { isLastIndex }) {
10
+ function media (
11
+ params,
12
+ // @ts-ignore
13
+ { isLastIndex } = {}
14
+ ) {
10
15
  const { type, url } = params;
11
16
 
12
17
  const urlString = url || '';
@@ -19,7 +24,13 @@ function media (params, { isLastIndex }) {
19
24
  throw new Error(`Unsupported media type: ${type}`);
20
25
  }
21
26
 
27
+ const condition = getCondition(params, 'Media condition');
28
+
22
29
  return (req, res) => {
30
+ if (condition && !condition(req, res)) {
31
+ return ret;
32
+ }
33
+
23
34
  const data = stateData(req, res);
24
35
  const sendUrl = urlTemplate(data);
25
36
 
@@ -2,10 +2,40 @@
2
2
  * @author David Menger
3
3
  */
4
4
  'use strict';
5
-
5
+ // eslint-disable-next-line no-unused-vars
6
+ const Responder = require('../Responder');
7
+ // eslint-disable-next-line no-unused-vars
8
+ const Request = require('../Request');
6
9
  const Router = require('../Router');
7
10
  const getCondition = require('../utils/getCondition');
8
- const { cachedTranslatedCompilator, stateData } = require('./utils');
11
+ const {
12
+ stateData, getLanguageText, getLanguageTextObjects, randomizedCompiler
13
+ } = require('./utils');
14
+ const {
15
+ FEATURE_SSML, FEATURE_TEXT, FEATURE_VOICE
16
+ } = require('../features');
17
+
18
+ /**
19
+ * Returns voice control props from params
20
+ *
21
+ * @param {any} params
22
+ * @param {string} lang
23
+ * @returns {null | import('../Responder').VoiceControl}
24
+ */
25
+ function getVoiceControl (params, lang = null) {
26
+ const voiceControl = {};
27
+
28
+ const voiceControlProps = ['speed', 'pitch', 'volume', 'voice', 'style', 'language'];
29
+
30
+ voiceControlProps.forEach((prop) => {
31
+ if (params[prop]) {
32
+ voiceControl[prop] = getLanguageText(params[prop], lang);
33
+ }
34
+ });
35
+
36
+ // if voiceControl is empty, return null
37
+ return Object.keys(voiceControl).length > 0 ? voiceControl : null;
38
+ }
9
39
 
10
40
  function parseReplies (replies, linksMap, allowForbiddenSnippetWords) {
11
41
  return replies.map((reply) => {
@@ -45,7 +75,7 @@ function parseReplies (replies, linksMap, allowForbiddenSnippetWords) {
45
75
  action,
46
76
  condition,
47
77
  title: reply.title
48
- ? cachedTranslatedCompilator(reply.title)
78
+ ? randomizedCompiler(reply.title)
49
79
  : null,
50
80
  data: {}
51
81
  };
@@ -70,43 +100,108 @@ function parseReplies (replies, linksMap, allowForbiddenSnippetWords) {
70
100
  });
71
101
  }
72
102
 
103
+ /**
104
+ * @param {import('./utils').Translations} text
105
+ * @param {string[]} features
106
+ * @param {string} lang
107
+ * @returns {{translations: import('./utils').TextObject[],ssmlAlternatives:string[] | null}}
108
+ */
109
+ function findSupportedMessages (text, features, lang = null) {
110
+ let translations = getLanguageTextObjects(text, lang);
111
+
112
+ const useSSML = features.includes(FEATURE_SSML);
113
+ const useText = features.includes(FEATURE_TEXT);
114
+ const useVoice = features.includes(FEATURE_VOICE);
115
+
116
+ // filter out SSML alternatives - they will be used in voice control
117
+ let ssmlAlternatives = translations.filter((t) => t.p === 's').map((t) => t.t);
118
+ if (ssmlAlternatives.length === 0 || !useSSML) {
119
+ ssmlAlternatives = null;
120
+ }
121
+
122
+ translations = translations.filter((t) => t.p !== 's');
123
+
124
+ // find supported text alternatives
125
+ translations = translations.filter((translation) => {
126
+ // always use text+voice
127
+ if (!translation.p) {
128
+ return true;
129
+ }
130
+
131
+ // text only
132
+ if (useText && translation.p === 't') {
133
+ return true;
134
+ }
135
+
136
+ // voice (& SSML) only
137
+ if (useVoice && translation.p === 'v') {
138
+ return true;
139
+ }
140
+
141
+ return false;
142
+ });
143
+
144
+ return {
145
+ translations,
146
+ ssmlAlternatives
147
+ };
148
+ }
149
+
73
150
  function message (params, {
151
+ // @ts-ignore
74
152
  isLastIndex, isLastMessage, linksMap, allowForbiddenSnippetWords
75
- }) {
153
+ } = {}) {
154
+
76
155
  if (typeof params.text !== 'string' && !Array.isArray(params.text)) {
77
156
  throw new Error('Message should be a text!');
78
157
  }
79
158
 
80
- const textTemplate = cachedTranslatedCompilator(params.text);
81
-
159
+ // parse quick replies
82
160
  let quickReplies = null;
83
-
84
161
  if (params.replies && !Array.isArray(params.replies)) {
85
162
  throw new Error('Replies should be an array');
86
163
  } else if (params.replies && params.replies.length > 0) {
87
164
  quickReplies = parseReplies(params.replies, linksMap, allowForbiddenSnippetWords);
88
165
  }
89
166
 
167
+ // compile condition
90
168
  const condition = getCondition(params, 'Message condition', allowForbiddenSnippetWords);
91
169
 
92
170
  const ret = isLastIndex ? Router.END : Router.CONTINUE;
93
171
 
172
+ /**
173
+ * @param {Request} req
174
+ * @param {Responder} res
175
+ */
94
176
  return (req, res) => {
95
- if (condition !== null) {
96
- if (!condition(req, res)) {
97
- return ret;
98
- }
177
+ if (condition && !condition(req, res)) {
178
+ return ret;
99
179
  }
100
-
101
180
  const data = stateData(req, res);
181
+
182
+ // filter supported messages
183
+ /** @type {{translations:import('./utils').TextObject[],ssmlAlternatives:string[]}} */
184
+ const supportedText = findSupportedMessages(
185
+ params.text,
186
+ req.features,
187
+ data.lang
188
+ );
189
+
190
+ // find random alternative
191
+ const textTemplate = randomizedCompiler([{
192
+ l: data.lang,
193
+ t: supportedText.translations.map((x) => x.t)
194
+ }]);
195
+
102
196
  const text = textTemplate(data)
103
197
  .trim();
104
198
 
199
+ let sendReplies;
105
200
  if (quickReplies) {
106
201
  const okQuickReplies = quickReplies
107
202
  .filter((reply) => reply.condition(req, res));
108
203
 
109
- const sendReplies = okQuickReplies
204
+ sendReplies = okQuickReplies
110
205
  .filter((reply) => reply.title
111
206
  || reply.isLocation
112
207
  || reply.isEmail
@@ -123,8 +218,6 @@ function message (params, {
123
218
  return rep;
124
219
  });
125
220
 
126
- res.text(text, sendReplies);
127
-
128
221
  okQuickReplies
129
222
  .filter((reply) => !reply.title && reply.match)
130
223
  .forEach(({
@@ -134,10 +227,27 @@ function message (params, {
134
227
  });
135
228
  } else {
136
229
  // replies on last index will be present, so the addQuickReply will be working
137
- const sendReplies = isLastMessage ? [] : undefined;
138
- res.text(text, sendReplies);
230
+ sendReplies = isLastMessage ? [] : undefined;
139
231
  }
140
232
 
233
+ // generate voice control
234
+ let voiceControl = getVoiceControl(params, data.lang);
235
+ if (supportedText.ssmlAlternatives) {
236
+ // find SSML alternative
237
+ const ssmlAlternativeTemplate = randomizedCompiler([{
238
+ l: data.lang,
239
+ t: supportedText.ssmlAlternatives
240
+ }]);
241
+
242
+ voiceControl = {
243
+ ...voiceControl,
244
+ ssml: ssmlAlternativeTemplate(data)
245
+ .trim()
246
+ };
247
+ }
248
+
249
+ res.text(text, sendReplies, voiceControl);
250
+
141
251
  return ret;
142
252
  };
143
253
  }
@@ -19,6 +19,28 @@ const WEBVIEW_FULL = 'full';
19
19
  const WEBVIEW_TALL = 'tall';
20
20
  const WEBVIEW_COMPACT = 'compact';
21
21
 
22
+ /**
23
+ * @typedef {Translation[] | Translation | string[] | string} Translations
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} TextObject
28
+ * @prop {string} t - text
29
+ * @prop {string} l - lang
30
+ * @prop {string|null} p - purpose
31
+ */
32
+
33
+ /**
34
+ * @typedef Translation
35
+ * @property {string|string[]} t - text alternatives
36
+ * @property {string | null} l - language
37
+ * @property {string|string[]} [p] - purposes
38
+ * null = default + voice
39
+ * t = text
40
+ * v = voice
41
+ * s = ssml
42
+ */
43
+
22
44
  function isArrayOfObjects (translations) {
23
45
  return Array.isArray(translations)
24
46
  && typeof translations[0] === 'object'
@@ -36,18 +58,20 @@ function isTextObjectEmpty (text) {
36
58
  }
37
59
 
38
60
  /**
39
- *
40
- * @param {{t:string,l:string}[]|string} translations
61
+ * @param {Translations} translations
41
62
  * @param {string} [lang]
42
63
  * @returns {null|string}
43
64
  */
44
65
  function getLanguageText (translations, lang = null) {
45
66
  let foundText;
67
+
46
68
  if (isArrayOfObjects(translations)) {
47
69
  if (lang) {
70
+ // @ts-ignore
48
71
  foundText = translations.find((t) => t.l === lang);
49
72
  }
50
73
  if (isTextObjectEmpty(foundText)) {
74
+ // @ts-ignore
51
75
  foundText = translations.find((t) => !isTextObjectEmpty(t));
52
76
  }
53
77
  foundText = foundText ? foundText.t : null;
@@ -63,6 +87,58 @@ function getLanguageText (translations, lang = null) {
63
87
  }
64
88
  return foundText || '';
65
89
  }
90
+ /**
91
+ *
92
+ * @param {Translations} translations
93
+ * @param {string} [lang]
94
+ * @returns {TextObject[]}
95
+ */
96
+ function getLanguageTextObjects (translations, lang = null) {
97
+ /** @type {{t:string|string[],l?:string,p?:string|string[]}[]} */
98
+ let foundTexts;
99
+
100
+ if (!Array.isArray(translations) || typeof translations[0] === 'string') {
101
+ // @ts-ignore
102
+ foundTexts = [{ t: translations, l: lang }];
103
+ } else { // is array of objects
104
+ foundTexts = translations
105
+ // @ts-ignore
106
+ .filter(({ l = null, ...to }) => l === lang && !isTextObjectEmpty(to));
107
+
108
+ if (foundTexts.length === 0) {
109
+ const { l: firstNonEmptyLang = null } = translations
110
+ // @ts-ignore
111
+ .find((to) => !isTextObjectEmpty(to)) || {};
112
+
113
+ foundTexts = translations
114
+ // @ts-ignore
115
+ .filter(({ l = null, ...to }) => l === firstNonEmptyLang && !isTextObjectEmpty(to));
116
+ }
117
+ }
118
+
119
+ return foundTexts
120
+ .reduce((a, to) => {
121
+ if (Array.isArray(to.t)) {
122
+ const purposes = Array.isArray(to.p)
123
+ ? to.p
124
+ : [to.p];
125
+ a.push(
126
+ ...to.t.map((t, i) => ({
127
+ ...to, t, p: purposes[i] || null
128
+ }))
129
+ );
130
+ } else {
131
+ a.push(to);
132
+ }
133
+ return a;
134
+ }, [])
135
+ .filter((to) => !!to.t)
136
+ .map(({
137
+ t, l = null, p = null, ...rest
138
+ }) => ({
139
+ t, l, p, ...rest
140
+ }));
141
+ }
66
142
 
67
143
  function randomizedCompiler (text, lang) {
68
144
  const texts = getLanguageText(text, lang);
@@ -100,6 +176,13 @@ function randomizedCompiler (text, lang) {
100
176
  };
101
177
  }
102
178
 
179
+ /**
180
+ * l - language
181
+ * t - alternatives
182
+ *
183
+ * @param {{l:string,t:string[]}[] | string} text
184
+ * @returns {(state:any)=>string}
185
+ */
103
186
  function cachedTranslatedCompilator (text) {
104
187
  const cache = new Map();
105
188
 
@@ -119,18 +202,21 @@ function getText (text, state) {
119
202
  return renderer(state);
120
203
  }
121
204
 
205
+ // eslint-disable-next-line no-unused-vars
206
+ const DEFAULT_LINK_TRANSLATOR = (senderId, defaultText, urlText, isExtUrl, reqState) => urlText;
207
+
122
208
  function processButtons (
123
209
  buttons,
124
210
  state,
125
211
  elem,
126
212
  linksMap,
127
213
  senderId,
128
- // eslint-disable-next-line no-unused-vars
129
- linksTranslator = (sndr, defaultText, urlText, isExtUrl, reqState) => urlText,
214
+ linksTranslator,
130
215
  allowForbiddenSnippetWords,
131
216
  req,
132
217
  res
133
218
  ) {
219
+ const translateLinks = linksTranslator || DEFAULT_LINK_TRANSLATOR;
134
220
 
135
221
  buttons.forEach(({
136
222
  title: btnTitle,
@@ -168,7 +254,7 @@ function processButtons (
168
254
  case TYPE_URL_WITH_EXT: {
169
255
  const hasExtention = type === TYPE_URL_WITH_EXT;
170
256
  let urlText = getText(url, state);
171
- urlText = linksTranslator(senderId, defaultText, urlText, isExtUrl, state);
257
+ urlText = translateLinks(senderId, defaultText, urlText, isExtUrl, state);
172
258
  elem.urlButton(btnTitleText, urlText, hasExtention, webviewHeight);
173
259
  break;
174
260
  }
@@ -194,7 +280,9 @@ function processButtons (
194
280
 
195
281
  module.exports = {
196
282
  getLanguageText,
283
+ getLanguageTextObjects,
197
284
  cachedTranslatedCompilator,
285
+ randomizedCompiler,
198
286
  getText,
199
287
  stateData,
200
288
 
@@ -22,7 +22,7 @@ class MemoryBotConfigStorage {
22
22
  * @param {Function|string[]} [acl] - acl configuration
23
23
  * @returns {{updateBot:Function}}
24
24
  */
25
- api (onUpdate = () => Promise.resolve(), acl) {
25
+ api (onUpdate = () => Promise.resolve(), acl = []) {
26
26
  const storage = this;
27
27
  return {
28
28
  async updateBot (args, ctx) {
@@ -42,7 +42,7 @@ const isSimpleWord = (string) => {
42
42
  const isStringNumber = (string) => {
43
43
  if (typeof string !== 'string') return false;
44
44
  if (string.length === 0) return false;
45
- return !!new RegExp(/^([0-9]*[, ]{0,1})*[., ]{0,1}[0-9]*$/)
45
+ return !!/^([0-9]*[, ]{0,1})*[., ]{0,1}[0-9]*$/
46
46
  .test((string).replace(/\s+/g, ''));
47
47
  };
48
48
 
@@ -55,7 +55,7 @@ const stringToNumber = (string) => {
55
55
  if (typeof string !== 'string') return string;
56
56
  if (!isStringNumber(string)) throw new Error('String not a number');
57
57
  let trimmed;
58
- if (new RegExp(/^[^,.]+,[^,.]+$/).test(string)) {
58
+ if (/^[^,.]+,[^,.]+$/.test(string)) {
59
59
  trimmed = string.replace(',', '.');
60
60
  } else {
61
61
  trimmed = string.replace(/(\s|,)+/g, '').replace(',', '.');
@@ -88,8 +88,10 @@ function toArray (previousValue) {
88
88
  const ENTITY_HBS_REGEXP = /^\s*\{\{\[?@([^@[\]{}\s]+)(\])?\}\}\s*$/;
89
89
  const VARIABLE_HBS_REGEXP = /^\s*\{\{\[?([^@[\]{}\s]+)\]?\}\}\s*$/;
90
90
 
91
- function getSetState (setState = {}, req, res = null, useState = null) {
92
-
91
+ function getSetState (setState, req, res = null, useState = null) {
92
+ if (!setState) {
93
+ return {};
94
+ }
93
95
  const keys = Object.keys(setState)
94
96
  .filter((k) => k !== '_');
95
97
 
@@ -32,7 +32,12 @@ const { checkSetState } = require('./stateVariables');
32
32
  * @returns {ExpectedKeyword}
33
33
  */
34
34
  function makeExpectedKeyword (
35
- action, title, matcher = null, payloadData = {}, setState = null, aiTitle = null
35
+ action,
36
+ title,
37
+ matcher = null,
38
+ payloadData = {},
39
+ setState = null,
40
+ aiTitle = null
36
41
  ) {
37
42
  let match = null;
38
43
 
@@ -226,7 +231,12 @@ function makeQuickReplies (replies, path = '', translate = (w) => w, quickReplyC
226
231
  const translatedTitle = translate(title);
227
232
  const translatedAiTitle = typeof aiTitle === 'string' ? translate(aiTitle) : aiTitle;
228
233
  const expect = makeExpectedKeyword(
229
- absoluteAction, translatedTitle, match, data, setState, translatedAiTitle
234
+ absoluteAction,
235
+ translatedTitle,
236
+ match,
237
+ data,
238
+ setState,
239
+ translatedAiTitle
230
240
  );
231
241
  expectedKeywords.push(expect);
232
242