@hellotext/hellotext 2.3.5 → 2.3.7

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/src/hellotext.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Configuration, Event } from './core'
2
2
 
3
3
  import API, { Response } from './api'
4
- import { Business, FormCollection, Page, Query, Session, User, Webchat } from './models'
4
+ import { Business, Fingerprint, FormCollection, Page, Query, Session, User, Webchat } from './models'
5
5
 
6
6
  import { NotInitializedError } from './errors'
7
7
 
@@ -17,11 +17,12 @@ class Hellotext {
17
17
  * @param { Configuration } config
18
18
  */
19
19
  static async initialize(business, config) {
20
+ this.business = new Business(business)
21
+
20
22
  Configuration.assign(config)
21
23
  Session.initialize()
22
24
 
23
25
  this.page = new Page()
24
- this.business = new Business(business)
25
26
  this.forms = new FormCollection()
26
27
 
27
28
  this.query = new Query()
@@ -82,17 +83,21 @@ class Hellotext {
82
83
  * @property { String } [name] - the name of the user
83
84
  * @property { String } [source] - the platform specific identifier where this pixel is running on.
84
85
  *
85
- * Identifies a user and attaches the hello_session to the user ID
86
+ * Identifies a user and attaches the hello_session to the user ID.
87
+ * Repeated calls are skipped only when the last successful identify payload
88
+ * for the current session remains unchanged.
86
89
  * @param { String } externalId - the user ID
87
90
  * @param { IdentificationOptions } options - the options for the identification
88
91
  * @returns {Promise<Response>}
89
92
  */
90
93
  static async identify(externalId, options = {}) {
91
- if (User.id === externalId) {
94
+ const fingerprint = await Fingerprint.generate(this.session, externalId, options)
95
+
96
+ if (Fingerprint.matches(User.fingerprint, fingerprint)) {
92
97
  return new Response(true, {
93
- json: async () => {
94
- already_identified: true
95
- },
98
+ json: async () => ({
99
+ already_identified: true,
100
+ }),
96
101
  })
97
102
  }
98
103
 
@@ -102,7 +107,11 @@ class Hellotext {
102
107
  })
103
108
 
104
109
  if (response.succeeded) {
105
- User.remember(externalId, options.source)
110
+ User.remember(
111
+ externalId,
112
+ options.source,
113
+ fingerprint,
114
+ )
106
115
  }
107
116
 
108
117
  return response
@@ -154,7 +163,7 @@ class Hellotext {
154
163
  // private
155
164
 
156
165
  static get notInitialized() {
157
- return this.business.id === undefined
166
+ return !this.business || this.business.id === undefined
158
167
  }
159
168
 
160
169
  static get headers() {
@@ -0,0 +1,98 @@
1
+ function normalizeValue(value) {
2
+ // Collapse "missing" values so callers can add optional fields incrementally
3
+ // without changing the fingerprint when the effective payload is the same.
4
+ if (value === null || value === undefined) {
5
+ return undefined
6
+ }
7
+
8
+ if (typeof value === 'string') {
9
+ const trimmedValue = value.trim()
10
+
11
+ // Treat blank strings as absent values so "" and an omitted field compare equally.
12
+ return trimmedValue === '' ? undefined : trimmedValue
13
+ }
14
+
15
+ if (Array.isArray(value)) {
16
+ // Preserve array order because, unlike object keys, caller-provided sequence can be meaningful.
17
+ return value
18
+ .map(item => normalizeValue(item))
19
+ .filter(item => item !== undefined)
20
+ }
21
+
22
+ if (value instanceof Date) {
23
+ // Serialize dates into a stable primitive so equivalent timestamps fingerprint the same way.
24
+ return value.toISOString()
25
+ }
26
+
27
+ if (typeof value === 'object') {
28
+ // Canonicalize object shape by sorting keys recursively, so key placement never affects equality.
29
+ const normalizedObject = Object.keys(value)
30
+ .sort((leftKey, rightKey) => leftKey.localeCompare(rightKey))
31
+ .reduce((result, key) => {
32
+ const normalizedChild = normalizeValue(value[key])
33
+
34
+ if (normalizedChild !== undefined) {
35
+ result[key] = normalizedChild
36
+ }
37
+
38
+ return result
39
+ }, {})
40
+
41
+ return Object.keys(normalizedObject).length > 0 ? normalizedObject : undefined
42
+ }
43
+
44
+ if (typeof value === 'number' || typeof value === 'boolean') {
45
+ return value
46
+ }
47
+
48
+ return undefined
49
+ }
50
+
51
+ function serializePayload(session, userId, options = {}) {
52
+ const normalizedPayload = normalizeValue({
53
+ session,
54
+ user_id: userId,
55
+ ...options,
56
+ }) || {}
57
+
58
+ return JSON.stringify(normalizedPayload)
59
+ }
60
+
61
+ function fallbackHash(value) {
62
+ let hash = 5381
63
+
64
+ for (let index = 0; index < value.length; index += 1) {
65
+ hash = (hash * 33) ^ value.charCodeAt(index)
66
+ }
67
+
68
+ return `v1:${(hash >>> 0).toString(16)}`
69
+ }
70
+
71
+ async function sha256(value) {
72
+ if (!globalThis.crypto?.subtle || typeof TextEncoder === 'undefined') {
73
+ return fallbackHash(value)
74
+ }
75
+
76
+ const digest = await globalThis.crypto.subtle.digest(
77
+ 'SHA-256',
78
+ new TextEncoder().encode(value),
79
+ )
80
+
81
+ const hex = Array.from(new Uint8Array(digest))
82
+ .map(byte => byte.toString(16).padStart(2, '0'))
83
+ .join('')
84
+
85
+ return `v1:${hex}`
86
+ }
87
+
88
+ class Fingerprint {
89
+ static matches(storedFingerprint, fingerprint) {
90
+ return !!storedFingerprint && storedFingerprint === fingerprint
91
+ }
92
+
93
+ static async generate(session, userId, options = {}) {
94
+ return await sha256(serializePayload(session, userId, options))
95
+ }
96
+ }
97
+
98
+ export { Fingerprint }
@@ -1,5 +1,6 @@
1
1
  export { Business } from './business'
2
2
  export { Cookies } from './cookies'
3
+ export { Fingerprint } from './fingerprint'
3
4
  export { Form } from './form'
4
5
  export { FormCollection } from './form_collection'
5
6
  export { Page } from './page'
@@ -1,6 +1,7 @@
1
1
  import { Configuration } from '../core'
2
2
  import { Cookies } from './cookies'
3
3
  import { Query } from './query'
4
+ import API from '../api'
4
5
 
5
6
  class Session {
6
7
  static #session
@@ -11,8 +12,21 @@ class Session {
11
12
  }
12
13
 
13
14
  static set session(value) {
15
+ const oldSession = Cookies.get('hello_session')
16
+
14
17
  this.#session = value
15
18
  Cookies.set('hello_session', value)
19
+
20
+ if (oldSession !== value) {
21
+ Cookies.delete('hello_session_ack_at')
22
+ }
23
+
24
+ if (!Cookies.get('hello_session_ack_at')) {
25
+ API.acks.send()
26
+ Cookies.set('hello_session_ack_at', new Date().toISOString())
27
+ }
28
+
29
+ return this.#session
16
30
  }
17
31
 
18
32
  static initialize() {
@@ -9,17 +9,26 @@ class User {
9
9
  return Cookies.get('hello_user_source')
10
10
  }
11
11
 
12
- static remember(id, source) {
12
+ static get fingerprint() {
13
+ return Cookies.get('hello_user_identification_hash')
14
+ }
15
+
16
+ static remember(id, source, fingerprint) {
13
17
  if (source) {
14
18
  Cookies.set('hello_user_source', source)
15
19
  }
16
20
 
21
+ if (fingerprint) {
22
+ Cookies.set('hello_user_identification_hash', fingerprint)
23
+ }
24
+
17
25
  Cookies.set('hello_user_id', id)
18
26
  }
19
27
 
20
28
  static forget() {
21
29
  Cookies.delete('hello_user_id')
22
30
  Cookies.delete('hello_user_source')
31
+ Cookies.delete('hello_user_identification_hash')
23
32
  }
24
33
 
25
34
  static get identificationData() {