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 +7 -1
- package/package.json +1 -1
- package/src/BotApp.js +34 -0
- package/src/Processor.js +85 -8
- package/src/analytics/GA4.js +272 -0
- package/src/analytics/onInteractionHandler.js +391 -0
- package/src/utils/stateVariables.js +14 -4
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
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 {
|
|
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 {
|
|
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 {
|
|
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.
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
};
|