wingbot 3.43.1 → 3.44.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/index.js CHANGED
@@ -45,6 +45,8 @@ const { disambiguationQuickReply, quickReplyAction } = require('./src/utils/quic
45
45
  const { getUpdate, getValue, getSetState } = require('./src/utils/getUpdate');
46
46
  const { vars } = require('./src/utils/stateVariables');
47
47
  const compileWithState = require('./src/utils/compileWithState');
48
+ const onInteractionHandler = require('./src/analytics/onInteractionHandler');
49
+ const GA4 = require('./src/analytics/GA4');
48
50
  const plugins = require('./plugins/plugins.json');
49
51
  const {
50
52
  bufferloader,
@@ -123,5 +125,9 @@ module.exports = {
123
125
  // flags
124
126
  ...flags,
125
127
 
126
- wingbotVersion
128
+ wingbotVersion,
129
+
130
+ // ANALYTICS
131
+ onInteractionHandler,
132
+ GA4
127
133
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wingbot",
3
- "version": "3.43.1",
3
+ "version": "3.44.0",
4
4
  "description": "Enterprise Messaging Bot Conversation Engine",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/src/BotApp.js CHANGED
@@ -10,6 +10,7 @@ const BotAppSender = require('./BotAppSender');
10
10
  const Processor = require('./Processor');
11
11
  const ReturnSender = require('./ReturnSender');
12
12
  const headersToAuditMeta = require('./utils/headersToAuditMeta');
13
+ const onInteractionHandler = require('./analytics/onInteractionHandler');
13
14
 
14
15
  const DEFAULT_API_URL = 'https://orchestrator-api.wingbot.ai';
15
16
 
@@ -25,6 +26,9 @@ const DEFAULT_API_URL = 'https://orchestrator-api.wingbot.ai';
25
26
  /** @typedef {import('./BotAppSender').TlsOptions} TlsOptions */
26
27
  /** @typedef {import('./ReturnSender').ReturnSenderOptions} ReturnSenderOptions */
27
28
 
29
+ /** @typedef {import('./analytics/onInteractionHandler').IAnalyticsStorage} IAnalyticsStorage */
30
+ /** @typedef {import('./analytics/onInteractionHandler').HandlerConfig} HandlerConfig */
31
+
28
32
  /**
29
33
  * @typedef {object} BotAppOptions
30
34
  * @prop {string|Promise<string>} secret
@@ -95,6 +99,8 @@ class BotApp {
95
99
  this._appId = appId;
96
100
  this._auditLog = auditLog;
97
101
  this._tls = tls;
102
+ this._logger = options.log || console;
103
+ this._textFilter = options.textFilter;
98
104
 
99
105
  let { apiUrl } = options;
100
106
 
@@ -171,6 +177,34 @@ class BotApp {
171
177
  return this._processor;
172
178
  }
173
179
 
180
+ /**
181
+ *
182
+ * @param {IAnalyticsStorage} analyticsStorage
183
+ * @param {HandlerConfig} [options]
184
+ * @returns {this}
185
+ * @example
186
+ * const { GA4 } = require('wingbot');
187
+ *
188
+ * botApp.registerAnalyticsStorage(new GA4({
189
+ * measurementId: 'G-123456,
190
+ * apiSecret: 'apisecret'
191
+ * }))
192
+ */
193
+ registerAnalyticsStorage (analyticsStorage, options = {}) {
194
+ const log = this._logger || options.log;
195
+
196
+ analyticsStorage.setDefaultLogger(log);
197
+
198
+ const handler = onInteractionHandler({
199
+ log,
200
+ anonymize: this._textFilter,
201
+ ...options
202
+ }, analyticsStorage);
203
+
204
+ this.processor.on('interaction', handler);
205
+ return this;
206
+ }
207
+
174
208
  _errorResponse (message, status) {
175
209
  return {
176
210
  statusCode: status,
package/src/Processor.js CHANGED
@@ -4,12 +4,13 @@
4
4
  'use strict';
5
5
 
6
6
  const EventEmitter = require('events');
7
+ const crypto = require('crypto');
7
8
  const { MemoryStateStorage } = require('./tools');
8
9
  const Responder = require('./Responder');
9
10
  const Request = require('./Request');
10
11
  const Ai = require('./Ai');
11
12
  const ReturnSender = require('./ReturnSender');
12
- const { mergeState } = require('./utils/stateVariables');
13
+ const { mergeState, isUserInteraction } = require('./utils/stateVariables');
13
14
 
14
15
  /** @typedef {import('./wingbot/CustomEntityDetectionModel').Intent} Intent */
15
16
  /** @typedef {import('./ReducerWrapper')} ReducerWrapper */
@@ -64,11 +65,18 @@ const { mergeState } = require('./utils/stateVariables');
64
65
  * @type {InteractionEvent}
65
66
  */
66
67
 
68
+ /**
69
+ * @typedef {object} ILogger
70
+ * @prop {Function} log
71
+ * @prop {Function} warn
72
+ * @prop {Function} error
73
+ */
74
+
67
75
  /**
68
76
  *
69
77
  * @typedef {object} ProcessorOptions
70
78
  * @prop {string} [appUrl] - url basepath for relative links
71
- * @prop {object} [stateStorage] - chatbot state storage
79
+ * @prop {IStateStorage} [stateStorage] - chatbot state storage
72
80
  * @prop {object} [tokenStorage] - frontend token storage
73
81
  * @prop {Function} [translator] - text translate function
74
82
  * @prop {number} [timeout] - chat sesstion lock duration (30000)
@@ -77,13 +85,14 @@ const { mergeState } = require('./utils/stateVariables');
77
85
  * @prop {number} [retriesWhenWaiting] - number of attampts (6)
78
86
  * @prop {Function} [nameFromState] - override the name translator
79
87
  * @prop {boolean|AutoTypingConfig} [autoTyping] - enable or disable automatic typing
80
- * @prop {Function} [log] - console like error logger
88
+ * @prop {ILogger} [log] - console like error logger
81
89
  * @prop {object} [defaultState] - default chat state
82
90
  * @prop {boolean} [autoSeen] - send seen automatically
83
91
  * @prop {number} [redirectLimit] - maximum number of redirects at single request
84
92
  * @prop {string} [secret] - Secret for calling orchestrator API
85
93
  * @prop {string} [apiUrl] - Url for calling orchestrator API
86
94
  * @prop {Function} [fetch] - Fetch function for calling orchestrator API
95
+ * @prop {number} [sessionDuration] - Session duration for analytic purposes
87
96
  */
88
97
 
89
98
  /**
@@ -101,6 +110,13 @@ const { mergeState } = require('./utils/stateVariables');
101
110
  * @prop {string|null} [meta.targetAction]
102
111
  */
103
112
 
113
+ /**
114
+ * @typedef {object} IStateStorage
115
+ * @prop {Function} saveState
116
+ * @prop {Function} getState
117
+ * @prop {Function} getOrCreateAndLock
118
+ */
119
+
104
120
  function NAME_FROM_STATE (state) {
105
121
  if (state.user && state.user.firstName) {
106
122
  return `${state.user.firstName} ${state.user.lastName}`;
@@ -111,6 +127,8 @@ function NAME_FROM_STATE (state) {
111
127
  return null;
112
128
  }
113
129
 
130
+ const MAX_TS = 9999999999999;
131
+
114
132
  /**
115
133
  * Messaging event processor
116
134
  *
@@ -144,7 +162,8 @@ class Processor extends EventEmitter {
144
162
  autoTyping: false,
145
163
  autoSeen: false,
146
164
  redirectLimit: 20,
147
- nameFromState: NAME_FROM_STATE
165
+ nameFromState: NAME_FROM_STATE,
166
+ sessionDuration: 1800000 // 30 minutes
148
167
  };
149
168
 
150
169
  Object.assign(this.options, options);
@@ -152,7 +171,7 @@ class Processor extends EventEmitter {
152
171
  this.reducer = reducer;
153
172
 
154
173
  /**
155
- * @type {StateStorage}
174
+ * @type {IStateStorage}
156
175
  */
157
176
  this.stateStorage = this.options.stateStorage;
158
177
 
@@ -231,7 +250,7 @@ class Processor extends EventEmitter {
231
250
 
232
251
  async _reportError (pageId, err, event, senderId = null) {
233
252
  if (err.code === 204) {
234
- this.options.log.info(`nothing sent: ${err.message}`, event);
253
+ this.options.log.log(`nothing sent: ${err.message}`, event);
235
254
  return;
236
255
  }
237
256
  if (err.code !== 403) {
@@ -483,9 +502,10 @@ class Processor extends EventEmitter {
483
502
 
484
503
  try {
485
504
  // ensure the request was not processed
505
+ const timestamp = message.timestamp || Date.now();
486
506
  if (fromEvent
487
507
  && stateObject.lastTimestamps && message.timestamp
488
- && stateObject.lastTimestamps.indexOf(message.timestamp) !== -1) {
508
+ && stateObject.lastTimestamps.indexOf(timestamp) !== -1) {
489
509
  throw Object.assign(new Error('Message has been already processed'), { code: 204 });
490
510
  }
491
511
 
@@ -523,6 +543,40 @@ class Processor extends EventEmitter {
523
543
  configuration
524
544
  );
525
545
 
546
+ // process session
547
+ if (fromEvent) {
548
+ let {
549
+ _sct: sessionCount = 0,
550
+ _sid: sessionId = null,
551
+ _segStamp: ts = 0,
552
+ _snew: sessionCreated
553
+ } = state;
554
+
555
+ if ((isUserInteraction(req)
556
+ && (ts + this.options.sessionDuration) < Date.now())
557
+ || !sessionId) {
558
+
559
+ sessionId = Processor._createSessionId(req.pageId, req.senderId, timestamp);
560
+ sessionCount++;
561
+ sessionCreated = true;
562
+ } else {
563
+ sessionCreated = false;
564
+ }
565
+
566
+ ts = timestamp;
567
+
568
+ Object.assign(state, {
569
+ _sct: sessionCount,
570
+ _sid: sessionId,
571
+ _segStamp: ts,
572
+ _snew: sessionCreated
573
+ });
574
+ } else {
575
+ Object.assign(state, {
576
+ _snew: false
577
+ });
578
+ }
579
+
526
580
  const features = [
527
581
  ...(this.options.features || []),
528
582
  ...req.features
@@ -650,7 +704,7 @@ class Processor extends EventEmitter {
650
704
  let lastTimestamps = stateObject.lastTimestamps || [];
651
705
  if (message.timestamp) {
652
706
  lastTimestamps = lastTimestamps.slice(-9);
653
- lastTimestamps.push(message.timestamp);
707
+ lastTimestamps.push(timestamp);
654
708
  }
655
709
 
656
710
  Object.assign(stateObject, {
@@ -698,6 +752,27 @@ class Processor extends EventEmitter {
698
752
  }
699
753
  }
700
754
 
755
+ static _createSessionId (pageId, senderId, timestamp = Date.now()) {
756
+ const senderHash = crypto.createHash('shake256', { outputLength: 6 })
757
+ .update(`${senderId}|${pageId}`)
758
+ .digest('hex');
759
+
760
+ const senderShort = parseInt(senderHash, 16).toString(36);
761
+
762
+ const rand = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
763
+ .toString(36);
764
+
765
+ const randTS = Math.floor(Date.now() % 1000)
766
+ .toString(36);
767
+
768
+ const ts = Math.floor(MAX_TS - timestamp)
769
+ .toString(36);
770
+
771
+ return `${ts}.${senderShort}`
772
+ .padEnd(21, randTS)
773
+ .padEnd(28, rand);
774
+ }
775
+
701
776
  /**
702
777
  *
703
778
  * @private
@@ -840,4 +915,6 @@ class Processor extends EventEmitter {
840
915
 
841
916
  }
842
917
 
918
+ Processor._createSessionId('p', 's');
919
+
843
920
  module.exports = Processor;
@@ -0,0 +1,272 @@
1
+ /**
2
+ * @author David Menger
3
+ */
4
+ 'use strict';
5
+
6
+ const fetch = require('node-fetch').default;
7
+
8
+ /** @typedef {import('./onInteractionHandler').Event} Event */
9
+ /** @typedef {import('./onInteractionHandler').IAnalyticsStorage} IAnalyticsStorage */
10
+ /** @typedef {import('./onInteractionHandler').GAUser} GAUser */
11
+ /** @typedef {import('./onInteractionHandler').SessionMetadata} SessionMetadata */
12
+ /** @typedef {import('./onInteractionHandler').IGALogger} IGALogger */
13
+
14
+ /** @typedef {import('node-fetch').RequestInit} RequestInit */
15
+
16
+ /**
17
+ * @typedef {object} FetchResult
18
+ * @param {number} status
19
+ * @param {string} [statusText]
20
+ * @param {Promise<object>} json
21
+ */
22
+
23
+ /**
24
+ * @callback MockFetch
25
+ * @param {string} url
26
+ * @param {RequestInit} [options]
27
+ * @returns {Promise<FetchResult>}
28
+ */
29
+
30
+ /**
31
+ * @typedef {object} GAOptions
32
+ * @prop {string} measurementId
33
+ * @prop {string} apiSecret
34
+ * @prop {boolean} [debug]
35
+ * @prop {IGALogger} [log]
36
+ * @prop {MockFetch} [fetch]
37
+ */
38
+ /**
39
+ * @class GA4
40
+ * @implements {IAnalyticsStorage}
41
+ */
42
+ class GA4 {
43
+
44
+ /**
45
+ *
46
+ * @param {GAOptions} options
47
+ */
48
+ constructor (options) {
49
+ this._options = options;
50
+
51
+ /** @type {IGALogger} */
52
+ this._logger = options.log || console;
53
+
54
+ this._urlQuery = `measurement_id=${encodeURIComponent(options.measurementId)}&api_secret=${encodeURIComponent(options.apiSecret)}`;
55
+ this._url = `https://www.google-analytics.com/mp/collect?${this._urlQuery}`;
56
+
57
+ this.hasExtendedEvents = true;
58
+
59
+ this._fetch = options.fetch || fetch;
60
+ }
61
+
62
+ /**
63
+ * @param {IGALogger} logger
64
+ */
65
+ setDefaultLogger (logger) {
66
+ if (this._logger === console) {
67
+ this._logger = logger;
68
+ }
69
+ }
70
+
71
+ /**
72
+ *
73
+ * @param {string} pageId
74
+ * @param {string} senderId
75
+ * @param {string} sessionId
76
+ * @param {SessionMetadata} [metadata]
77
+ * @param {number} [ts]
78
+ * @param {boolean} [nonInteractive]
79
+ * @returns {Promise}
80
+ */
81
+ async createUserSession (
82
+ pageId,
83
+ senderId,
84
+ sessionId,
85
+ metadata = {},
86
+ ts = Date.now(),
87
+ nonInteractive = false
88
+ ) {
89
+ const uafvl = 'wingbot';
90
+
91
+ const { lang = '', sessionCount = 1, action = '/' } = metadata;
92
+
93
+ const event = {
94
+ v: 2,
95
+ tid: this._options.measurementId,
96
+ _p: Math.round(2147483647 * Math.random()),
97
+ sr: '1x1',
98
+ _dbg: this._options.debug ? 1 : 0,
99
+ ul: lang, // language
100
+ cid: this._conversationId(pageId, senderId),
101
+
102
+ // dl: 'https://wingbot-web-staging.flyto.cloud/dp',
103
+ dp: action,
104
+ dr: '', // referral (url)
105
+ dt: action === '/' ? '(none)' : action.replace(/-/g, ' '),
106
+
107
+ // en: 'page_view', // event name
108
+ en: 'scroll',
109
+ 'epn.percent_scrolled': 100,
110
+
111
+ uafvl, // must have
112
+
113
+ sct: sessionCount, // session count (int)
114
+ seg: nonInteractive ? 0 : 1, // session engagement (boolean)
115
+ sid: sessionId, // session id (string)
116
+
117
+ // this was sent during the first visit
118
+ _fv: sessionCount === 1, // first visit (bool)
119
+ _nsi: 1, // new session id (bool)
120
+ _ss: 1, // session start (bool)
121
+
122
+ _ee: 1, // ? page_view event parameter (??? event engagement ??)
123
+
124
+ _s: 1, // session hit count (was 1 every request),
125
+ _et: ts // event time (number)
126
+ };
127
+
128
+ if (this._options.debug) {
129
+ this._logger.log('GA4: starting session', event);
130
+ }
131
+
132
+ const query = Object.entries(event)
133
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
134
+ .join('&');
135
+
136
+ // const url = 'https://region1.google-analytics.com/g/collect';
137
+ const url = 'https://www.google-analytics.com/g/collect';
138
+
139
+ const res = await this._fetch(`${url}?${query}`, {
140
+ method: 'POST',
141
+ headers: {
142
+ 'user-agent': uafvl
143
+ }
144
+ });
145
+
146
+ if (res.status >= 400) {
147
+ this._logger.error('GA4: failed to create session', {
148
+ url,
149
+ query,
150
+ status: res.status
151
+ });
152
+ }
153
+ }
154
+
155
+ _conversationId (pageId, senderId) {
156
+ return `${pageId}.${senderId}`;
157
+ }
158
+
159
+ /**
160
+ *
161
+ * @param {string} pageId
162
+ * @param {string} senderId
163
+ * @param {string} sessionId
164
+ * @param {Event[]} events
165
+ * @param {GAUser} [user]
166
+ * @param {number} [ts]
167
+ * @returns {Promise}
168
+ */
169
+ async storeEvents (
170
+ pageId,
171
+ senderId,
172
+ sessionId,
173
+ events,
174
+ user = null,
175
+ ts = Date.now()
176
+ ) {
177
+ if (events.length === 0) {
178
+ return;
179
+ }
180
+
181
+ const body = {
182
+ client_id: this._conversationId(pageId, senderId),
183
+ timestamp_micros: ts * 1000,
184
+ non_personalized_ads: false,
185
+ events: events.map((e) => {
186
+ const { type: name, ...params } = e;
187
+ Object.entries(params)
188
+ .forEach(([k, v]) => {
189
+ if (v === null) {
190
+ params[k] = '(none)';
191
+ }
192
+ });
193
+ // Object.assign(params, { session_id: sessionId });
194
+ switch (name) {
195
+ case 'page_view':
196
+ return {
197
+ name,
198
+ params: {
199
+ page_path: e.action,
200
+ page_title: e.action
201
+ .replace(/^\/+/, '')
202
+ .replace(/[-]+/g, ' ')
203
+ .replace(/[/]+/g, ' - '),
204
+ ...params
205
+ }
206
+ };
207
+ default:
208
+ return { name, params };
209
+ }
210
+ })
211
+ };
212
+
213
+ if (user) {
214
+ const { id, ...other } = user;
215
+
216
+ Object.assign(body, {
217
+ user_id: id,
218
+ user_properties: Object.fromEntries(
219
+ Object.entries(other)
220
+ .map(([key, value]) => [key, { value }])
221
+ )
222
+ });
223
+ }
224
+
225
+ const params = {
226
+ method: 'POST',
227
+ body: JSON.stringify(body)
228
+ };
229
+
230
+ let err;
231
+ let res;
232
+ try {
233
+ res = await this._fetch(this._url, params);
234
+
235
+ if (res.status >= 400) {
236
+ throw new Error(`${res.statusText} [${res.status}]`);
237
+ }
238
+ if (!this._options.debug) {
239
+ return;
240
+ }
241
+ } catch (e) {
242
+ err = e;
243
+ }
244
+
245
+ let { message = 'GENERIC FAIL' } = err || { message: null };
246
+ let validationMessages = [];
247
+ try {
248
+ const dbg = await this._fetch(`https://www.google-analytics.com/debug/mp/collect?${this._urlQuery}`, params);
249
+
250
+ if (dbg.status >= 300) {
251
+ throw new Error(`${dbg.statusText} [${dbg.status}]`);
252
+ }
253
+
254
+ ({ validationMessages = [] } = await dbg.json());
255
+
256
+ message = (validationMessages[0] || { description: message }).description;
257
+ } catch (e) {
258
+ this._logger.log('GA4 debug failed', e);
259
+ message += ` +(${e.message})`;
260
+ }
261
+
262
+ if (validationMessages.length || message) {
263
+ this._logger.log('GA4: validationMessages', validationMessages);
264
+ this._logger.error(`GA4: fail: ${message} [${res ? res.status : 0}]`, params.body);
265
+ } else {
266
+ this._logger.log(`GA4: debug [${res ? res.status : 0}]`, params.body);
267
+ }
268
+ }
269
+
270
+ }
271
+
272
+ module.exports = GA4;
@@ -0,0 +1,391 @@
1
+ /**
2
+ * @author wingbot.ai
3
+ */
4
+ 'use strict';
5
+
6
+ const { replaceDiacritics } = require('webalize');
7
+ const Ai = require('../Ai');
8
+
9
+ /** @typedef {import('../Processor').InteractionEvent} InteractionEvent */
10
+ /** @typedef {import('../Request')} Request */
11
+
12
+ /**
13
+ * @typedef {object} GAUser
14
+ * @prop {string} id
15
+ */
16
+
17
+ /**
18
+ * @typedef IGALogger
19
+ * @prop {Function} log
20
+ * @prop {Function} error
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} Event
25
+ * @prop {'conversation'|'page_view'|string} type
26
+ * @prop {string} [category]
27
+ * @prop {string} [action]
28
+ * @prop {string} [label]
29
+ * @prop {number} [value]
30
+ */
31
+
32
+ /**
33
+ * @typedef {object} SessionMetadata
34
+ * @prop {number} [sessionCount]
35
+ * @prop {string} [lang]
36
+ * @prop {string} [action]
37
+ */
38
+
39
+ /**
40
+ * @callback CreateUserSession
41
+ * @param {string} pageId
42
+ * @param {string} senderId
43
+ * @param {string} sessionId
44
+ * @param {SessionMetadata} [metadata]
45
+ * @param {number} [ts]
46
+ * @param {boolean} [nonInteractive]
47
+ * @returns {Promise}
48
+ */
49
+
50
+ /**
51
+ * @callback StoreEvents
52
+ * @param {string} pageId
53
+ * @param {string} senderId
54
+ * @param {string} sessionId
55
+ * @param {Event[]} events
56
+ * @param {GAUser} [user]
57
+ * @param {number} [ts]
58
+ * @returns {Promise}
59
+ */
60
+
61
+ /**
62
+ * @callback LoggerSetter
63
+ * @param {IGALogger} logger
64
+ */
65
+
66
+ /**
67
+ * @typedef {object} IAnalyticsStorage
68
+ * @prop {LoggerSetter} setDefaultLogger - console like logger
69
+ * @prop {StoreEvents} storeEvents
70
+ * @prop {CreateUserSession} createUserSession
71
+ * @prop {boolean} [hasExtendedEvents]
72
+ */
73
+
74
+ /**
75
+ * @callback MetadataExtractor
76
+ * @param {Request} req
77
+ * @returns {object}
78
+ */
79
+
80
+ /**
81
+ * @callback Anonymizer
82
+ * @param {string} text
83
+ * @returns {string}
84
+ */
85
+
86
+ /**
87
+ * @typedef {object} TrackingEvents
88
+ * @prop {Event[]} events
89
+ */
90
+
91
+ /**
92
+ * @typedef {object} IConfidenceProvider
93
+ * @prop {number} confidence
94
+ */
95
+
96
+ /**
97
+ * @typedef {object} HandlerConfig
98
+ * @prop {boolean} [enabled] - default true
99
+ * @prop {boolean} [throwException] - default false
100
+ * @prop {IGALogger} [log] - console like logger
101
+ * @prop {Anonymizer} [anonymize] - text anonymization function
102
+ * @prop {MetadataExtractor} [extractMetadata] - text anonymization function
103
+ */
104
+
105
+ /**
106
+ * @callback IInteractionHandler
107
+ * @param {InteractionEvent} params
108
+ * @returns {Promise}
109
+ */
110
+
111
+ /**
112
+ *
113
+ * @param {HandlerConfig} config
114
+ * @param {IAnalyticsStorage} analyticsStorage
115
+ * @param {IConfidenceProvider} [ai]
116
+ * @returns {IInteractionHandler}
117
+ */
118
+ function onInteractionHandler (
119
+ {
120
+ enabled = true,
121
+ throwException = false,
122
+ log = console,
123
+ anonymize = (x) => x,
124
+ extractMetadata = (req) => ({}) // eslint-disable-line no-unused-vars
125
+ },
126
+ analyticsStorage,
127
+ ai = Ai.ai
128
+ ) {
129
+
130
+ /**
131
+ * @param {InteractionEvent} params
132
+ */
133
+ async function onInteraction ({
134
+ req,
135
+ actions,
136
+ lastAction,
137
+ // state,
138
+ // data,
139
+ skill,
140
+ tracking
141
+ }) {
142
+ if (!enabled) {
143
+ return;
144
+ }
145
+ try {
146
+ const nonInteractive = !!req.campaign;
147
+ const {
148
+ pageId,
149
+ senderId,
150
+ timestamp
151
+ } = req;
152
+
153
+ const {
154
+ _snew: createSession,
155
+ _sct: sessionCount,
156
+ _sid: sessionId,
157
+ lang
158
+ } = req.state;
159
+
160
+ const [action = '(none)', ...otherActions] = actions;
161
+
162
+ if (createSession) {
163
+ const metadata = {
164
+ sessionCount,
165
+ lang,
166
+ action,
167
+ cd1: (req.state.user && req.state.user.department) || 'unknown',
168
+ ...extractMetadata(req)
169
+ };
170
+
171
+ await analyticsStorage.createUserSession(
172
+ pageId,
173
+ senderId,
174
+ sessionId,
175
+ metadata,
176
+ timestamp,
177
+ nonInteractive
178
+ );
179
+ }
180
+
181
+ const [{
182
+ intent = '',
183
+ score = 0
184
+ } = {}] = req.intents;
185
+
186
+ const text = req.isConfidentInput()
187
+ ? '*****'
188
+ : anonymize(
189
+ replaceDiacritics(req.text()).replace(/\s+/g, ' ').toLowerCase().trim()
190
+ );
191
+
192
+ let winnerAction = '';
193
+ let winnerScore = 0;
194
+ let winnerIntent = '';
195
+ let winnerEntities = [];
196
+ let winnerTaken = false;
197
+
198
+ const winners = req.aiActions();
199
+
200
+ if (winners.length > 0) {
201
+ [{
202
+ action: winnerAction = '(none)',
203
+ sort: winnerScore = 0,
204
+ intent: { intent: winnerIntent, entities: winnerEntities = [] }
205
+ }] = winners;
206
+
207
+ winnerTaken = action === winnerAction;
208
+ }
209
+
210
+ const expected = req.expected() ? req.expected().action : '';
211
+
212
+ const isContextUpdate = req.isSetContext();
213
+ const isNotification = !!req.campaign;
214
+ const isAttachment = req.isAttachment();
215
+ const isQuickReply = req.isQuickReply();
216
+ const isPassThread = !!req.event.pass_thread_control;
217
+ const isText = !isQuickReply && req.isText();
218
+ const isPostback = req.isPostBack();
219
+
220
+ const allActions = actions.join(',');
221
+ const requestAction = req.action();
222
+
223
+ const events = [];
224
+
225
+ const actionMeta = {
226
+ requestAction: req.action() || '(none)',
227
+ expected,
228
+ expectedTaken: requestAction === expected,
229
+ isContextUpdate,
230
+ isAttachment,
231
+ isNotification,
232
+ isQuickReply,
233
+ isPassThread,
234
+ isText,
235
+ isPostback,
236
+ winnerAction,
237
+ winnerIntent,
238
+ winnerEntities: winnerEntities.map((e) => e.entity).join(','),
239
+ winnerScore,
240
+ winnerTaken,
241
+ intent,
242
+ intentScore: score,
243
+ entities: req.entities.map((e) => e.entity).join(','),
244
+ text,
245
+ allActions
246
+ };
247
+
248
+ events.push({
249
+ type: 'page_view',
250
+ action,
251
+ allActions,
252
+ nonInteractive,
253
+ lastAction,
254
+ prevAction: lastAction,
255
+ skill,
256
+ lang,
257
+ cd1: req.state.lang,
258
+ ...(analyticsStorage.hasExtendedEvents ? {} : actionMeta)
259
+ });
260
+
261
+ let prevAction = action;
262
+
263
+ events.push(
264
+ ...otherActions.map((a) => {
265
+ const r = {
266
+ type: 'page_view',
267
+ action: a,
268
+ allActions,
269
+ nonInteractive: false,
270
+ lastAction,
271
+ prevAction,
272
+ skill,
273
+ isGoto: true,
274
+ ...(analyticsStorage.hasExtendedEvents
275
+ ? { lang }
276
+ : { cd1: lang })
277
+ };
278
+
279
+ prevAction = a;
280
+ return r;
281
+ })
282
+ );
283
+
284
+ events.push(
285
+ ...tracking.events.map(({
286
+ type, category, action: eventAction, label, value
287
+ }) => ({
288
+ lastAction,
289
+ type,
290
+ category,
291
+ action: eventAction,
292
+ label,
293
+ value,
294
+ ...(analyticsStorage.hasExtendedEvents
295
+ ? { lang }
296
+ : { cd1: lang })
297
+ }))
298
+ );
299
+
300
+ if (!nonInteractive) {
301
+
302
+ if (req.isText()) {
303
+ events.push({
304
+ type: 'ai',
305
+ // @ts-ignore
306
+ lastAction,
307
+ category: 'Intent: Detection',
308
+ intent,
309
+ action,
310
+ label: text,
311
+ value: score >= ai.confidence ? 0 : 1,
312
+ ...(analyticsStorage.hasExtendedEvents
313
+ ? { lang }
314
+ : { cd1: lang })
315
+ });
316
+ }
317
+
318
+ const notHandled = actions.some((a) => a.match(/\*$/)) && !req.isQuickReply();
319
+
320
+ let actionCategory = 'User: ';
321
+ let label = '(none)';
322
+ const value = notHandled ? 1 : 0;
323
+
324
+ if (req.isSticker()) {
325
+ actionCategory += 'Sticker';
326
+ label = req.attachmentUrl(0);
327
+ } else if (req.isImage()) {
328
+ actionCategory += 'Image';
329
+ label = req.attachmentUrl(0);
330
+ } else if (req.hasLocation()) {
331
+ actionCategory += 'Location';
332
+ const { lat, long } = req.getLocation();
333
+ label = `${lat}, ${long}`;
334
+ } else if (isAttachment) {
335
+ actionCategory += 'Attachement';
336
+ label = req.attachment(0).type;
337
+ } else if (isText) {
338
+ actionCategory += 'Text';
339
+ label = text;
340
+ } else if (isQuickReply) {
341
+ actionCategory += 'Quick reply';
342
+ label = text;
343
+ } else if (req.isReferral() || req.isOptin()) {
344
+ actionCategory = req.isOptin()
345
+ ? 'Entry: Optin'
346
+ : 'Entry: Referral';
347
+ } else if (isPostback) {
348
+ actionCategory += 'Button - bot';
349
+ label = req.data.postback.title || '(unknown)';
350
+ } else {
351
+ actionCategory += 'Other';
352
+ }
353
+
354
+ events.push({
355
+ ...(analyticsStorage.hasExtendedEvents ? actionMeta : {}),
356
+ type: 'conversation',
357
+ // @ts-ignore
358
+ lastAction,
359
+ category: actionCategory,
360
+ action,
361
+ label,
362
+ value,
363
+ ...(analyticsStorage.hasExtendedEvents
364
+ ? { lang }
365
+ : { cd1: lang })
366
+ });
367
+ }
368
+
369
+ const user = null;
370
+
371
+ await analyticsStorage.storeEvents(
372
+ pageId,
373
+ senderId,
374
+ sessionId,
375
+ // @ts-ignore
376
+ events,
377
+ user,
378
+ timestamp
379
+ );
380
+ } catch (e) {
381
+ if (throwException) {
382
+ throw e;
383
+ }
384
+ log.error('failed sending logs', e);
385
+ }
386
+ }
387
+
388
+ return onInteraction;
389
+ }
390
+
391
+ module.exports = onInteractionHandler;
@@ -102,6 +102,17 @@ function checkSetState (setState, newState) {
102
102
  }
103
103
  }
104
104
 
105
+ /**
106
+ *
107
+ * @param {Request} req
108
+ * @returns {boolean}
109
+ */
110
+ function isUserInteraction (req) {
111
+ return req.isMessage() || req.isPostBack()
112
+ || req.isReferral() || req.isAttachment()
113
+ || req.isTextOrIntent();
114
+ }
115
+
105
116
  /**
106
117
  *
107
118
  * @private
@@ -115,9 +126,7 @@ function checkSetState (setState, newState) {
115
126
  function mergeState (previousState, req, res, senderStateUpdate, firstInTurnover, lastInTurnover) {
116
127
  const state = { ...previousState, ...res.newState };
117
128
 
118
- const isUserEvent = req.isMessage() || req.isPostBack()
119
- || req.isReferral() || req.isAttachment()
120
- || req.isTextOrIntent();
129
+ const isUserEvent = isUserInteraction(req);
121
130
 
122
131
  // reset expectations
123
132
  if (isUserEvent && !res.newState._expected) {
@@ -224,5 +233,6 @@ module.exports = {
224
233
  VAR_TYPES,
225
234
  mergeState,
226
235
  vars,
227
- checkSetState
236
+ checkSetState,
237
+ isUserInteraction
228
238
  };