PageSourceSearch

https://www.marktplaats.nl/i/chatbot-mara/js/webchatService.js

js marktplaats.nl collected 2026-09-24 08:26:53 UTC 12,357 bytes, 342 lines download raw bytes

1/**
2 * WebchatService (Cognigy Webchat v3)
3 *
4 * Strict, LWC-safe wrapper targeting Cognigy Webchat v3.
5 * - Loads bundle (script tag or provided loader)
6 * - Initializes chat into a container (no auth at init)
7 * - Injects per-message token into data: sendMessage(text, data, options)
8 * - Optional, controlled monkey-patch of SDK client's sendMessage
9 *
10 * Principles: minimal surface, deterministic behavior, explicit errors, small methods.
11 */
12
13export class WebchatService {
14    /**
15     * @param {Object} opts
16     * @param {string} opts.scriptUrl - URL to the Cognigy v3 webchat bundle (required)
17     * @param {string} [opts.globalInitName='initWebchat'] - global init function name
18     * @param {(ev:object)=>void} [opts.emit] - optional event emitter callback
19     * @param {(url:string)=>Promise<any>} [opts.loadScriptFn] - optional loader (for LWC loadScript)
20     */
21    constructor({ scriptUrl, globalInitName = 'initWebchat', emit = null, loadScriptFn = null } = {}) {
22        if (!scriptUrl) throw new Error('WebchatService requires scriptUrl');
23
24        this.scriptUrl = scriptUrl;
25        this.globalInitName = globalInitName;
26        this.emit = typeof emit === 'function' ? emit : () => {};
27        this.loadScriptFn = loadScriptFn || this._defaultLoadScript.bind(this);
28
29        this.bundleLoaded = false;
30        this.instance = null;
31
32        // token state: undefined = never fetched, null = fetched & no token, string = token
33        this._previousToken = undefined;
34
35        // patch bookkeeping
36        this._patchedClient = null;
37        this._origClientSend = null;
38
39        // Default sendMessage warns if called before init
40        this.sendMessage = this._uninitializedSendMessage.bind(this);
41    }
42
43    /* ===========================
44       Loader
45       =========================== */
46
47    /**
48     * Default script loader using a script tag.
49     * @private
50     */
51    _defaultLoadScript(url) {
52        return new Promise((resolve, reject) => {
53            if (window[this.globalInitName]) return resolve(window[this.globalInitName]);
54
55            const existing = Array.from(document.getElementsByTagName('script')).find(s => s.src === url);
56            if (existing) {
57                existing.addEventListener('load', () => resolve(window[this.globalInitName]));
58                existing.addEventListener('error', (e) => reject(e));
59                return;
60            }
61
62            const s = document.createElement('script');
63            s.src = url;
64            s.async = true;
65            s.onload = () => resolve(window[this.globalInitName]);
66            s.onerror = () => reject(new Error(`Failed to load ${url}`));
67            document.head.appendChild(s);
68        });
69    }
70
71    /**
72     * Load the webchat bundle (idempotent).
73     */
74    async loadBundle() {
75        if (this.bundleLoaded) return;
76        await this.loadScriptFn(this.scriptUrl);
77        this.bundleLoaded = true;
78    }
79
80    /* ===========================
81       Init
82       =========================== */
83
84    /**
85     * Initialize webchat into a DOM container.
86     * No authentication is passed at init — auth happens per-message via token injection.
87     *
88     * @param {Object} cfg
89     * @param {Element} cfg.container - Required; use this.template.querySelector in LWC.
90     * @param {Object} [cfg.context={}] - Optional context passed to webchat.
91     * @param {string|null} [cfg.endpoint=null] - Optional Cognigy endpoint URL.
92     */
93    async init({ container, context = {}, endpoint = null } = {}) {
94        if (!container) throw new Error('WebchatService.init requires a container DOM element');
95
96        await this.loadBundle();
97
98        const cfg = {
99            ...(endpoint ? { endpoint } : {}),
100            data: context || {},
101            ui: { container }
102        };
103
104        if (typeof window[this.globalInitName] === 'function') {
105            this.instance = await this._invokeInitFn(endpoint, cfg);
106        } else {
107            console.warn(`[WebchatService] ${this.globalInitName} not found after loading bundle`);
108            this.instance = { client: null, cfg };
109        }
110
111        this._resetSendMessageToDefault();
112        this.emit({ type: 'webchat-initialized' });
113    }
114
115    /**
116     * Tries the two-arg signature first, falls back to single-arg.
117     * Cognigy v3 uses (endpoint, config) but some bundles accept (config) only.
118     * @private
119     */
120    async _invokeInitFn(endpoint, cfg) {
121        try {
122            return await window[this.globalInitName](endpoint, cfg);
123        } catch {
124            try {
125                return await window[this.globalInitName](cfg);
126            } catch (e) {
127                console.warn('[WebchatService] initWebchat failed; storing cfg for debugging', e);
128                return { client: null, cfg };
129            }
130        }
131    }
132
133    /* ===========================
134       SDK Accessor & Default Forwarder
135       =========================== */
136
137    /**
138     * Returns the SDK client. Some bundles expose it at `.client`, others are the client.
139     * @private
140     */
141    _sdkClient() {
142        if (!this.instance) return null;
143        return this.instance.client || this.instance;
144    }
145
146    /**
147     * Safe placeholder used before init() is called.
148     * @private
149     */
150    _uninitializedSendMessage() {
151        const err = new Error('[WebchatService] sendMessage called before init()');
152        this.emit({ type: 'webchat-send-error', error: err });
153        throw err;
154    }
155
156    /**
157     * Resets sendMessage to a direct forwarder to the SDK client.
158     * @private
159     */
160    _resetSendMessageToDefault() {
161        this.sendMessage = async (text, data, options) => {
162            const client = this._sdkClient();
163            if (!client || typeof client.sendMessage !== 'function') {
164                const err = new Error('Webchat client not available or sendMessage missing');
165                this.emit({ type: 'webchat-send-error', error: err });
166                throw err;
167            }
168            return client.sendMessage.call(client, text, data, options);
169        };
170    }
171
172    /* ===========================
173       Token Injection
174       =========================== */
175
176    /**
177     * Attach a TokenService so that a fresh token is injected into every outgoing message.
178     *
179     * @param {Object} tokenService - Must implement fetchWebchatToken(forceRefresh:boolean):Promise<string|null>.
180     * @param {Object} [opts]
181     * @param {string} [opts.fieldPath='_meta.token'] - Dot-path inside data to place the token.
182     * @param {boolean} [opts.patchSdkIfNeeded=false] - If true, also patches SDK client's sendMessage.
183     */
184    setTokenInjection(tokenService, { fieldPath = '_meta.token', patchSdkIfNeeded = false } = {}) {
185        if (!tokenService || typeof tokenService.fetchWebchatToken !== 'function') {
186            throw new Error('tokenService must implement fetchWebchatToken(forceRefresh:boolean):Promise<string|null>');
187        }
188
189        this.sendMessage = async (text, data = undefined, options = undefined) => {
190            if (typeof text !== 'string') {
191                const err = new Error('sendMessage requires first argument to be a string');
192                this.emit({ type: 'webchat-send-error', error: err });
193                throw err;
194            }
195
196            data = this._ensureDataObject(data);
197            data = await this._fetchAndInjectToken(data, tokenService, fieldPath);
198
199            const client = this._sdkClient();
200            if (!client || typeof client.sendMessage !== 'function') {
201                const err = new Error('Webchat client not available or sendMessage missing');
202                this.emit({ type: 'webchat-send-error', error: err });
203                throw err;
204            }
205
206            return client.sendMessage.call(client, text, data, options);
207        };
208
209        if (patchSdkIfNeeded) {
210            try {
211                this._patchClientSendMessage(tokenService, { fieldPath });
212            } catch (err) {
213                console.warn('[WebchatService] failed to patch SDK sendMessage', err);
214            }
215        }
216    }
217
218    /**
219     * Fetches a token from the service and injects it into the data object at fieldPath.
220     * Updates internal token state for smart force-refresh logic.
221     * @private
222     */
223    async _fetchAndInjectToken(data, tokenService, fieldPath) {
224        const shouldForce = this._previousToken === null;
225        let token = null;
226        try {
227            token = await tokenService.fetchWebchatToken(shouldForce);
228        } catch (err) {
229            this.emit({ type: 'token-fetch-error', error: err });
230        }
231        this._previousToken = token;
232        this._setAtPath(data, fieldPath, token);
233        return data;
234    }
235
236    /**
237     * Ensures `data` is a plain object, making a shallow copy to avoid mutating caller's data.
238     * @private
239     */
240    _ensureDataObject(data) {
241        if (data == null || typeof data !== 'object') return {};
242        return { ...data };
243    }
244
245    /**
246     * Sets a value at a dot-notation path on an object, creating intermediate objects as needed.
247     * @private
248     */
249    _setAtPath(obj, path, value) {
250        const parts = path.split('.');
251        let cur = obj;
252        for (let i = 0; i < parts.length - 1; i++) {
253            const p = parts[i];
254            if (!Object.prototype.hasOwnProperty.call(cur, p) || typeof cur[p] !== 'object') {
255                cur[p] = {};
256            }
257            cur = cur[p];
258        }
259        cur[parts[parts.length - 1]] = value;
260    }
261
262    /* ===========================
263       Optional Controlled Monkey-Patch
264       =========================== */
265
266    /**
267     * Monkey-patches the SDK client's sendMessage to inject tokens at the SDK level.
268     * Use when the Cognigy SDK sends messages internally (e.g. from UI buttons) bypassing our wrapper.
269     * @private
270     */
271    _patchClientSendMessage(tokenService, { fieldPath = '_meta.token' } = {}) {
272        const client = this._sdkClient();
273        if (!client || typeof client.sendMessage !== 'function') {
274            throw new Error('client.sendMessage not available to patch');
275        }
276        if (client._sendMessagePatched) return;
277
278        client._origSendMessage = client.sendMessage;
279        const self = this;
280
281        client.sendMessage = async function patchedSendMessage(text, data = undefined, options = undefined) {
282            if (typeof text !== 'string') {
283                return client._origSendMessage.apply(client, [text, data, options]);
284            }
285
286            data = self._ensureDataObject(data);
287            data = await self._fetchAndInjectToken(data, tokenService, fieldPath);
288
289            try {
290                return await client._origSendMessage.call(client, text, data, options);
291            } catch (err) {
292                self.emit({ type: 'webchat-send-error', error: err });
293                throw err;
294            }
295        };
296
297        client._sendMessagePatched = true;
298        this._patchedClient = client;
299        this._origClientSend = client._origSendMessage;
300    }
301
302    /* ===========================
303       Destroy
304       =========================== */
305
306    /**
307     * Tears down the webchat instance and restores any patched SDK methods.
308     */
309    async destroy() {
310        try {
311            this._restoreClientSendMessage();
312            await this._destroyInstance();
313        } finally {
314            this.instance = null;
315            this.bundleLoaded = false;
316            this._patchedClient = null;
317            this._origClientSend = null;
318            this._previousToken = undefined;
319            this.sendMessage = this._uninitializedSendMessage.bind(this);
320            this.emit({ type: 'webchat-destroyed' });
321        }
322    }
323
324    /** @private */
325    _restoreClientSendMessage() {
326        if (!this._patchedClient || !this._origClientSend) return;
327        try {
328            this._patchedClient.sendMessage = this._origClientSend;
329            delete this._patchedClient._origSendMessage;
330            delete this._patchedClient._sendMessagePatched;
331        } catch (_) { /* ignore */ }
332    }
333
334    /** @private */
335    async _destroyInstance() {
336        if (this.instance && typeof this.instance.destroy === 'function') {
337            try {
338                await this.instance.destroy();
339            } catch (_) { /* ignore */ }
340        }
341    }
342}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.