@chaterafrikang/sdk 0.1.0-beta.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/README.md +118 -0
- package/dist/index.cjs +2119 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1072 -0
- package/dist/index.d.ts +1072 -0
- package/dist/index.js +2098 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var eventemitter3 = require('eventemitter3');
|
|
4
|
+
var uuid = require('uuid');
|
|
5
|
+
|
|
6
|
+
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
7
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
8
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
9
|
+
}) : x)(function(x) {
|
|
10
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
11
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// src/core/ConnectionState.ts
|
|
15
|
+
var ConnectionState = /* @__PURE__ */ ((ConnectionState2) => {
|
|
16
|
+
ConnectionState2["DISCONNECTED"] = "disconnected";
|
|
17
|
+
ConnectionState2["CONNECTING"] = "connecting";
|
|
18
|
+
ConnectionState2["CONNECTED"] = "connected";
|
|
19
|
+
ConnectionState2["RECONNECTING"] = "reconnecting";
|
|
20
|
+
ConnectionState2["ERROR"] = "error";
|
|
21
|
+
return ConnectionState2;
|
|
22
|
+
})(ConnectionState || {});
|
|
23
|
+
|
|
24
|
+
// src/utils/logger.ts
|
|
25
|
+
var Logger = class {
|
|
26
|
+
constructor(enabled = false) {
|
|
27
|
+
this.enabled = enabled;
|
|
28
|
+
}
|
|
29
|
+
setEnabled(enabled) {
|
|
30
|
+
this.enabled = enabled;
|
|
31
|
+
}
|
|
32
|
+
debug(...args) {
|
|
33
|
+
if (this.enabled) {
|
|
34
|
+
console.debug("[ChatAfrika SDK]", ...args);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
info(...args) {
|
|
38
|
+
if (this.enabled) {
|
|
39
|
+
console.info("[ChatAfrika SDK]", ...args);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
warn(...args) {
|
|
43
|
+
if (this.enabled) {
|
|
44
|
+
console.warn("[ChatAfrika SDK]", ...args);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
error(...args) {
|
|
48
|
+
if (this.enabled) {
|
|
49
|
+
console.error("[ChatAfrika SDK]", ...args);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// src/websocket/WebSocketClient.ts
|
|
55
|
+
var isNodeEnv = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
|
|
56
|
+
var wsModule = null;
|
|
57
|
+
var wsLoading = null;
|
|
58
|
+
async function getWsModule() {
|
|
59
|
+
if (!isNodeEnv) return null;
|
|
60
|
+
if (wsModule) return wsModule;
|
|
61
|
+
if (wsLoading) return wsLoading;
|
|
62
|
+
wsLoading = (async () => {
|
|
63
|
+
try {
|
|
64
|
+
try {
|
|
65
|
+
const ws = await import('ws');
|
|
66
|
+
wsModule = ws;
|
|
67
|
+
return wsModule;
|
|
68
|
+
} catch (esmErr) {
|
|
69
|
+
if (typeof __require !== "undefined") {
|
|
70
|
+
const { createRequire } = await import('module');
|
|
71
|
+
const requireFn = createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)) || __filename);
|
|
72
|
+
wsModule = requireFn("ws");
|
|
73
|
+
return wsModule;
|
|
74
|
+
}
|
|
75
|
+
throw esmErr;
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return null;
|
|
79
|
+
} finally {
|
|
80
|
+
wsLoading = null;
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
return wsLoading;
|
|
84
|
+
}
|
|
85
|
+
var WebSocketClient = class extends eventemitter3.EventEmitter {
|
|
86
|
+
constructor(url, options = {}) {
|
|
87
|
+
super();
|
|
88
|
+
this.ws = null;
|
|
89
|
+
this.token = null;
|
|
90
|
+
this.reconnectAttempts = 0;
|
|
91
|
+
this.reconnectTimer = null;
|
|
92
|
+
this.isManuallyDisconnected = false;
|
|
93
|
+
this.isTokenExpired = false;
|
|
94
|
+
this.url = url;
|
|
95
|
+
this.autoReconnect = options.autoReconnect ?? true;
|
|
96
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
97
|
+
this.logger = new Logger(options.debug ?? false);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Connect to the WebSocket server with authentication token
|
|
101
|
+
*/
|
|
102
|
+
connect(token) {
|
|
103
|
+
if (!token || token.length === 0) {
|
|
104
|
+
const error = new Error("Token is required for connection");
|
|
105
|
+
this.emit("error", error);
|
|
106
|
+
return Promise.reject(error);
|
|
107
|
+
}
|
|
108
|
+
this.token = token;
|
|
109
|
+
this.isManuallyDisconnected = false;
|
|
110
|
+
this.isTokenExpired = false;
|
|
111
|
+
return this.attemptConnection();
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Attempt a WebSocket connection
|
|
115
|
+
*/
|
|
116
|
+
async attemptConnection() {
|
|
117
|
+
const wsUrl = new URL(this.url);
|
|
118
|
+
const token = this.token ?? "";
|
|
119
|
+
if (isNodeEnv) {
|
|
120
|
+
const ws = await getWsModule();
|
|
121
|
+
this.logger.debug("ws module loaded:", !!ws);
|
|
122
|
+
if (ws && ws.WebSocket) {
|
|
123
|
+
this.logger.debug("Using ws package with Authorization header");
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
try {
|
|
126
|
+
const wsInstance = new ws.WebSocket(wsUrl.toString(), {
|
|
127
|
+
headers: {
|
|
128
|
+
"Authorization": `Bearer ${token}`
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
this.ws = wsInstance;
|
|
132
|
+
let handshakeErrorHandled = false;
|
|
133
|
+
const errorHandler = (error) => {
|
|
134
|
+
if (handshakeErrorHandled) return;
|
|
135
|
+
handshakeErrorHandled = true;
|
|
136
|
+
const errorMsg = String(error?.message || error || "").toLowerCase();
|
|
137
|
+
if (errorMsg.includes("401") || errorMsg.includes("unauthorized") || error?.code && String(error.code).includes("401")) {
|
|
138
|
+
this.handleTokenExpiration();
|
|
139
|
+
const tokenError = new Error("Token expired or invalid. Please refresh your token and reconnect.");
|
|
140
|
+
tokenError.name = "TokenExpiredError";
|
|
141
|
+
this.emit("error", tokenError);
|
|
142
|
+
reject(tokenError);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
if (typeof wsInstance.addEventListener === "function") {
|
|
147
|
+
wsInstance.addEventListener("error", errorHandler);
|
|
148
|
+
wsInstance.addEventListener("open", () => {
|
|
149
|
+
wsInstance.removeEventListener("error", errorHandler);
|
|
150
|
+
});
|
|
151
|
+
} else if (typeof wsInstance.once === "function") {
|
|
152
|
+
wsInstance.once("error", errorHandler);
|
|
153
|
+
wsInstance.once("open", () => {
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
this.setupEventHandlers(resolve, reject);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
this.logger.error("Failed to create ws.WebSocket:", error);
|
|
159
|
+
reject(error);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
} else {
|
|
163
|
+
this.logger.warn("ws package not available, falling back to native WebSocket");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
this.logger.debug("Using native WebSocket with query parameter", { url: wsUrl.toString(), hasToken: !!token });
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
try {
|
|
169
|
+
if (!token || token.length === 0) {
|
|
170
|
+
const error = new Error("Token is required for WebSocket connection");
|
|
171
|
+
this.logger.error(error.message);
|
|
172
|
+
reject(error);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const urlWithToken = new URL(wsUrl.toString());
|
|
176
|
+
urlWithToken.searchParams.set("token", token);
|
|
177
|
+
this.ws = new globalThis.WebSocket(urlWithToken.toString());
|
|
178
|
+
this.logger.debug("WebSocket instance created", {
|
|
179
|
+
readyState: this.ws.readyState,
|
|
180
|
+
url: this.ws.url,
|
|
181
|
+
protocol: this.ws.protocol
|
|
182
|
+
});
|
|
183
|
+
const wsRef = this.ws;
|
|
184
|
+
this.setupEventHandlers(resolve, reject);
|
|
185
|
+
setTimeout(() => {
|
|
186
|
+
if (wsRef.readyState === WebSocket.CLOSED || wsRef.readyState === WebSocket.CLOSING) {
|
|
187
|
+
this.logger.warn("WebSocket closed immediately after creation", {
|
|
188
|
+
readyState: wsRef.readyState,
|
|
189
|
+
url: wsRef.url
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}, 100);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
this.logger.error("Failed to create native WebSocket:", error);
|
|
195
|
+
reject(error);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Set up WebSocket event handlers
|
|
201
|
+
*/
|
|
202
|
+
setupEventHandlers(resolve, reject) {
|
|
203
|
+
if (!this.ws) return;
|
|
204
|
+
this.ws.onopen = () => {
|
|
205
|
+
this.reconnectAttempts = 0;
|
|
206
|
+
this.isTokenExpired = false;
|
|
207
|
+
const ws = this.ws;
|
|
208
|
+
if (!ws) {
|
|
209
|
+
this.logger.error("WebSocket is null in onopen handler");
|
|
210
|
+
reject(new Error("WebSocket instance lost"));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.logger.debug("WebSocket connected", {
|
|
214
|
+
readyState: ws.readyState,
|
|
215
|
+
url: ws.url,
|
|
216
|
+
protocol: ws.protocol,
|
|
217
|
+
extensions: ws.extensions
|
|
218
|
+
});
|
|
219
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
220
|
+
this.logger.error("WebSocket readyState is not OPEN after onopen", { readyState: ws.readyState });
|
|
221
|
+
reject(new Error(`WebSocket not open: readyState=${ws.readyState}`));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
this.emit("connected");
|
|
225
|
+
resolve();
|
|
226
|
+
};
|
|
227
|
+
this.ws.onmessage = (event) => {
|
|
228
|
+
try {
|
|
229
|
+
const message = JSON.parse(event.data);
|
|
230
|
+
this.logger.debug("Received message:", message);
|
|
231
|
+
this.emit("message", message);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const parseError = error instanceof Error ? error : new Error("Failed to parse message");
|
|
234
|
+
this.logger.error("Failed to parse message:", parseError);
|
|
235
|
+
this.emit("error", parseError);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
this.ws.onerror = (error) => {
|
|
239
|
+
this.logger.error("WebSocket error:", error);
|
|
240
|
+
let errorToEmit;
|
|
241
|
+
if (error instanceof Error) {
|
|
242
|
+
errorToEmit = error;
|
|
243
|
+
} else if (error && error.message) {
|
|
244
|
+
errorToEmit = error instanceof Error ? error : new Error(String(error.message));
|
|
245
|
+
} else {
|
|
246
|
+
errorToEmit = new Error("WebSocket connection error");
|
|
247
|
+
}
|
|
248
|
+
if (isNodeEnv && error && error.message) {
|
|
249
|
+
const errorMsg = String(error.message).toLowerCase();
|
|
250
|
+
if (errorMsg.includes("401") || errorMsg.includes("unauthorized")) {
|
|
251
|
+
this.handleTokenExpiration();
|
|
252
|
+
const tokenError = new Error("Token expired or invalid. Please refresh your token and reconnect.");
|
|
253
|
+
tokenError.name = "TokenExpiredError";
|
|
254
|
+
this.emit("error", tokenError);
|
|
255
|
+
reject(tokenError);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
this.emit("error", errorToEmit);
|
|
260
|
+
};
|
|
261
|
+
this.ws.onclose = (event) => {
|
|
262
|
+
this.logger.debug("WebSocket closed:", {
|
|
263
|
+
code: event.code,
|
|
264
|
+
reason: event.reason,
|
|
265
|
+
wasClean: event.wasClean
|
|
266
|
+
});
|
|
267
|
+
if (event.code === 1006) {
|
|
268
|
+
this.logger.warn("Abnormal closure (1006) - connection closed without proper handshake. Possible causes: network issue, server closed connection, CORS issue, or protocol mismatch.");
|
|
269
|
+
}
|
|
270
|
+
const isAuthFailure = event.code === 1008 || event.code === 1002 || event.reason && event.reason.toLowerCase().includes("unauthorized") || event.reason && event.reason.toLowerCase().includes("401");
|
|
271
|
+
if (isAuthFailure) {
|
|
272
|
+
this.handleTokenExpiration();
|
|
273
|
+
const tokenError = new Error("Token expired or invalid. Please refresh your token and reconnect.");
|
|
274
|
+
tokenError.name = "TokenExpiredError";
|
|
275
|
+
this.emit("error", tokenError);
|
|
276
|
+
this.emit("disconnected", { code: event.code, reason: event.reason, tokenExpired: true });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
this.emit("disconnected", { code: event.code, reason: event.reason });
|
|
280
|
+
if (!this.isManuallyDisconnected && !this.isTokenExpired && this.autoReconnect) {
|
|
281
|
+
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
282
|
+
this.scheduleReconnect();
|
|
283
|
+
} else {
|
|
284
|
+
this.logger.warn("Max reconnection attempts reached");
|
|
285
|
+
this.emit("error", new Error("Max reconnection attempts reached"));
|
|
286
|
+
}
|
|
287
|
+
} else if (this.isTokenExpired) {
|
|
288
|
+
this.logger.warn("Not reconnecting: token is expired");
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Handle token expiration - stop reconnection attempts
|
|
294
|
+
*/
|
|
295
|
+
handleTokenExpiration() {
|
|
296
|
+
this.isTokenExpired = true;
|
|
297
|
+
this.autoReconnect = false;
|
|
298
|
+
if (this.reconnectTimer) {
|
|
299
|
+
clearTimeout(this.reconnectTimer);
|
|
300
|
+
this.reconnectTimer = null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Disconnect from the WebSocket server
|
|
305
|
+
*/
|
|
306
|
+
disconnect() {
|
|
307
|
+
this.isManuallyDisconnected = true;
|
|
308
|
+
this.autoReconnect = false;
|
|
309
|
+
if (this.reconnectTimer) {
|
|
310
|
+
clearTimeout(this.reconnectTimer);
|
|
311
|
+
this.reconnectTimer = null;
|
|
312
|
+
}
|
|
313
|
+
if (this.ws) {
|
|
314
|
+
this.ws.close();
|
|
315
|
+
this.ws = null;
|
|
316
|
+
}
|
|
317
|
+
this.logger.debug("WebSocket disconnected");
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Send a message to the server
|
|
321
|
+
*/
|
|
322
|
+
send(message) {
|
|
323
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
324
|
+
const error = new Error("WebSocket is not connected");
|
|
325
|
+
this.logger.error(error.message);
|
|
326
|
+
this.emit("error", error);
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const messageStr = JSON.stringify(message);
|
|
331
|
+
this.logger.debug("Sending message:", message);
|
|
332
|
+
this.ws.send(messageStr);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
const sendError = error instanceof Error ? error : new Error("Failed to send message");
|
|
335
|
+
this.logger.error("Send error:", sendError);
|
|
336
|
+
this.emit("error", sendError);
|
|
337
|
+
throw sendError;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Check if the WebSocket is connected
|
|
342
|
+
*/
|
|
343
|
+
isConnected() {
|
|
344
|
+
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Get current reconnection attempt count
|
|
348
|
+
*/
|
|
349
|
+
getReconnectAttempts() {
|
|
350
|
+
return this.reconnectAttempts;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Schedule a reconnection attempt with exponential backoff
|
|
354
|
+
* Backoff: 1s → 2s → 5s → 10s (max)
|
|
355
|
+
*/
|
|
356
|
+
scheduleReconnect() {
|
|
357
|
+
if (this.reconnectTimer || !this.token) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
this.reconnectAttempts++;
|
|
361
|
+
const backoffDelays = [1e3, 2e3, 5e3, 1e4];
|
|
362
|
+
const delay = backoffDelays[Math.min(this.reconnectAttempts - 1, backoffDelays.length - 1)];
|
|
363
|
+
this.logger.debug(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
|
|
364
|
+
this.emit("reconnecting", { attempt: this.reconnectAttempts, delay });
|
|
365
|
+
this.reconnectTimer = setTimeout(() => {
|
|
366
|
+
this.reconnectTimer = null;
|
|
367
|
+
this.attemptConnection().catch(() => {
|
|
368
|
+
});
|
|
369
|
+
}, delay);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// src/auth/TokenManager.ts
|
|
374
|
+
var TokenManager = class {
|
|
375
|
+
constructor() {
|
|
376
|
+
this.token = null;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Set the SDK token
|
|
380
|
+
*/
|
|
381
|
+
setToken(token) {
|
|
382
|
+
if (!this.isValidFormat(token)) {
|
|
383
|
+
throw new Error("Invalid token format");
|
|
384
|
+
}
|
|
385
|
+
this.token = token;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Get the current SDK token
|
|
389
|
+
*/
|
|
390
|
+
getToken() {
|
|
391
|
+
return this.token;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Check if a token is set
|
|
395
|
+
*/
|
|
396
|
+
hasToken() {
|
|
397
|
+
return this.token !== null && this.token.length > 0;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Clear the token
|
|
401
|
+
*/
|
|
402
|
+
clearToken() {
|
|
403
|
+
this.token = null;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Replace the current token with a new one (token rotation)
|
|
407
|
+
*/
|
|
408
|
+
rotateToken(newToken) {
|
|
409
|
+
if (!this.isValidFormat(newToken)) {
|
|
410
|
+
throw new Error("Invalid token format");
|
|
411
|
+
}
|
|
412
|
+
this.token = newToken;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Validate token format (basic validation)
|
|
416
|
+
*/
|
|
417
|
+
isValidFormat(token) {
|
|
418
|
+
return typeof token === "string" && token.length > 0;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Validate that a token is present before operations
|
|
422
|
+
*/
|
|
423
|
+
validateTokenPresence() {
|
|
424
|
+
if (!this.hasToken()) {
|
|
425
|
+
throw new Error("Token is required but not set");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// src/messages/Message.ts
|
|
431
|
+
var MessageFactory = class {
|
|
432
|
+
/**
|
|
433
|
+
* Create an optimistic message (client-generated, not yet confirmed by server)
|
|
434
|
+
*/
|
|
435
|
+
static createOptimistic(conversationId, messageId, content, messageType = "text", metadata) {
|
|
436
|
+
return {
|
|
437
|
+
id: messageId,
|
|
438
|
+
conversationId,
|
|
439
|
+
content,
|
|
440
|
+
messageType,
|
|
441
|
+
optimistic: true,
|
|
442
|
+
receiptState: "sent",
|
|
443
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
444
|
+
metadata
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Create a message from server payload (confirmed message)
|
|
449
|
+
*/
|
|
450
|
+
static fromServerPayload(conversationId, payload) {
|
|
451
|
+
return {
|
|
452
|
+
id: payload.message_id,
|
|
453
|
+
conversationId,
|
|
454
|
+
content: payload.content,
|
|
455
|
+
senderId: payload.sender_id,
|
|
456
|
+
senderType: payload.sender_type,
|
|
457
|
+
messageType: payload.message_type === "system" ? "system" : "text",
|
|
458
|
+
optimistic: false,
|
|
459
|
+
receiptState: "sent",
|
|
460
|
+
createdAt: payload.sent_at ? new Date(payload.sent_at) : /* @__PURE__ */ new Date()
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Convert an optimistic message to confirmed (reconciliation)
|
|
465
|
+
*/
|
|
466
|
+
static confirmOptimistic(optimisticMessage, serverPayload) {
|
|
467
|
+
return {
|
|
468
|
+
...optimisticMessage,
|
|
469
|
+
optimistic: false,
|
|
470
|
+
senderId: serverPayload.sender_id,
|
|
471
|
+
senderType: serverPayload.sender_type,
|
|
472
|
+
receiptState: optimisticMessage.receiptState ?? "sent",
|
|
473
|
+
createdAt: serverPayload.sent_at ? new Date(serverPayload.sent_at) : optimisticMessage.createdAt
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Update receipt state on a message (immutable update)
|
|
478
|
+
*/
|
|
479
|
+
static updateReceiptState(message, receiptState) {
|
|
480
|
+
return {
|
|
481
|
+
...message,
|
|
482
|
+
receiptState
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
var ReceiptManager = class extends eventemitter3.EventEmitter {
|
|
487
|
+
constructor(debug = false) {
|
|
488
|
+
super();
|
|
489
|
+
/**
|
|
490
|
+
* Track receipt state per message
|
|
491
|
+
* Map<messageId, ReceiptState>
|
|
492
|
+
*/
|
|
493
|
+
this.receiptState = /* @__PURE__ */ new Map();
|
|
494
|
+
this.logger = new Logger(debug);
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Get receipt state for a message
|
|
498
|
+
*/
|
|
499
|
+
getReceiptState(messageId) {
|
|
500
|
+
return this.receiptState.get(messageId);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Handle a delivered receipt
|
|
504
|
+
*
|
|
505
|
+
* Rules:
|
|
506
|
+
* - Only update if current state < delivered
|
|
507
|
+
* - Ignore duplicate or regressive updates
|
|
508
|
+
*/
|
|
509
|
+
handleDelivered(messageId, userId) {
|
|
510
|
+
if (!messageId) {
|
|
511
|
+
this.logger.warn("Invalid delivered receipt: missing messageId");
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
const currentState = this.receiptState.get(messageId) ?? "sent";
|
|
515
|
+
if (this.compareStates(currentState, "delivered") < 0) {
|
|
516
|
+
this.receiptState.set(messageId, "delivered");
|
|
517
|
+
this.emit("receipt", {
|
|
518
|
+
messageId,
|
|
519
|
+
state: "delivered",
|
|
520
|
+
userId
|
|
521
|
+
});
|
|
522
|
+
this.logger.debug("Receipt delivered:", messageId);
|
|
523
|
+
return true;
|
|
524
|
+
} else {
|
|
525
|
+
this.logger.debug("Delivered receipt ignored (already delivered or read):", messageId);
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Handle a read receipt
|
|
531
|
+
*
|
|
532
|
+
* Rules:
|
|
533
|
+
* - Only update if current state < read
|
|
534
|
+
* - Ignore duplicate or regressive updates
|
|
535
|
+
*/
|
|
536
|
+
handleRead(messageId, userId) {
|
|
537
|
+
if (!messageId) {
|
|
538
|
+
this.logger.warn("Invalid read receipt: missing messageId");
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
const currentState = this.receiptState.get(messageId) ?? "sent";
|
|
542
|
+
if (this.compareStates(currentState, "read") < 0) {
|
|
543
|
+
this.receiptState.set(messageId, "read");
|
|
544
|
+
this.emit("receipt", {
|
|
545
|
+
messageId,
|
|
546
|
+
state: "read",
|
|
547
|
+
userId
|
|
548
|
+
});
|
|
549
|
+
this.logger.debug("Receipt read:", messageId);
|
|
550
|
+
return true;
|
|
551
|
+
} else {
|
|
552
|
+
this.logger.debug("Read receipt ignored (already read):", messageId);
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Compare two receipt states
|
|
558
|
+
* Returns: -1 if a < b, 0 if a === b, 1 if a > b
|
|
559
|
+
*/
|
|
560
|
+
compareStates(a, b) {
|
|
561
|
+
const order = ["sent", "delivered", "read"];
|
|
562
|
+
const aIndex = order.indexOf(a);
|
|
563
|
+
const bIndex = order.indexOf(b);
|
|
564
|
+
if (aIndex === -1 || bIndex === -1) {
|
|
565
|
+
return 0;
|
|
566
|
+
}
|
|
567
|
+
return aIndex - bIndex;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Clear receipt state for a conversation
|
|
571
|
+
* Note: We track by messageId, so we need to clear all messages for a conversation
|
|
572
|
+
* This is called when leaving a conversation
|
|
573
|
+
*/
|
|
574
|
+
clearConversation(conversationId, messageIds) {
|
|
575
|
+
let cleared = 0;
|
|
576
|
+
for (const messageId of messageIds) {
|
|
577
|
+
if (this.receiptState.has(messageId)) {
|
|
578
|
+
this.receiptState.delete(messageId);
|
|
579
|
+
cleared++;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (cleared > 0) {
|
|
583
|
+
this.logger.debug("Cleared receipt state for conversation:", conversationId, `(${cleared} messages)`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Clear receipt state for a specific message
|
|
588
|
+
*/
|
|
589
|
+
clearMessage(messageId) {
|
|
590
|
+
if (this.receiptState.delete(messageId)) {
|
|
591
|
+
this.logger.debug("Cleared receipt state for message:", messageId);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Clear all receipt state
|
|
596
|
+
*/
|
|
597
|
+
clear() {
|
|
598
|
+
this.receiptState.clear();
|
|
599
|
+
this.logger.debug("Cleared all receipt state");
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// src/messages/MessageManager.ts
|
|
604
|
+
var MessageManager = class extends eventemitter3.EventEmitter {
|
|
605
|
+
constructor(debug = false) {
|
|
606
|
+
super();
|
|
607
|
+
/**
|
|
608
|
+
* Track seen message IDs per conversation to prevent duplicates
|
|
609
|
+
* Map<conversationId, Set<message_id>>
|
|
610
|
+
*/
|
|
611
|
+
this.seenMessageIds = /* @__PURE__ */ new Map();
|
|
612
|
+
/**
|
|
613
|
+
* Track optimistic messages that are waiting for server confirmation
|
|
614
|
+
* Map<conversationId, Map<message_id, Message>>
|
|
615
|
+
*/
|
|
616
|
+
this.optimisticMessages = /* @__PURE__ */ new Map();
|
|
617
|
+
/**
|
|
618
|
+
* Track confirmed messages for receipt updates
|
|
619
|
+
* Map<conversationId, Map<message_id, Message>>
|
|
620
|
+
*/
|
|
621
|
+
this.confirmedMessages = /* @__PURE__ */ new Map();
|
|
622
|
+
this.logger = new Logger(debug);
|
|
623
|
+
this.receiptManager = new ReceiptManager(debug);
|
|
624
|
+
this.receiptManager.on("receipt", (event) => {
|
|
625
|
+
this.updateMessageReceiptState(event);
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Create an optimistic message and emit it immediately
|
|
630
|
+
*
|
|
631
|
+
* @returns The created optimistic message
|
|
632
|
+
*/
|
|
633
|
+
createOptimisticMessage(conversationId, messageId, content, messageType = "text", metadata) {
|
|
634
|
+
const message = MessageFactory.createOptimistic(
|
|
635
|
+
conversationId,
|
|
636
|
+
messageId,
|
|
637
|
+
content,
|
|
638
|
+
messageType,
|
|
639
|
+
metadata
|
|
640
|
+
);
|
|
641
|
+
this.trackOptimisticMessage(conversationId, messageId, message);
|
|
642
|
+
this.trackMessageId(conversationId, messageId);
|
|
643
|
+
this.emit("message", message);
|
|
644
|
+
this.logger.debug("Created optimistic message:", messageId);
|
|
645
|
+
return message;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Handle an incoming message from the server
|
|
649
|
+
*
|
|
650
|
+
* This method:
|
|
651
|
+
* - Checks for duplicates
|
|
652
|
+
* - Reconciles optimistic messages if message_id matches
|
|
653
|
+
* - Emits confirmed messages
|
|
654
|
+
*
|
|
655
|
+
* @returns The message (reconciled if optimistic, new if not), or null if duplicate
|
|
656
|
+
*/
|
|
657
|
+
handleIncomingMessage(conversationId, serverPayload) {
|
|
658
|
+
const messageId = serverPayload.message_id;
|
|
659
|
+
const optimisticMessage = this.getOptimisticMessage(conversationId, messageId);
|
|
660
|
+
if (optimisticMessage) {
|
|
661
|
+
const reconciledMessage = this.reconcileOptimisticMessage(optimisticMessage, serverPayload);
|
|
662
|
+
this.removeOptimisticMessage(conversationId, messageId);
|
|
663
|
+
this.trackMessageId(conversationId, messageId);
|
|
664
|
+
this.trackConfirmedMessage(conversationId, messageId, reconciledMessage);
|
|
665
|
+
return reconciledMessage;
|
|
666
|
+
}
|
|
667
|
+
if (this.hasSeenMessage(conversationId, messageId)) {
|
|
668
|
+
this.logger.debug("Duplicate message ignored:", messageId);
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
this.trackMessageId(conversationId, messageId);
|
|
672
|
+
const message = MessageFactory.fromServerPayload(conversationId, serverPayload);
|
|
673
|
+
this.trackConfirmedMessage(conversationId, messageId, message);
|
|
674
|
+
this.emit("message", message);
|
|
675
|
+
this.logger.debug("Handled incoming message:", messageId);
|
|
676
|
+
return message;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Handle a receipt update
|
|
680
|
+
*
|
|
681
|
+
* This method:
|
|
682
|
+
* - Routes receipt to ReceiptManager
|
|
683
|
+
* - Updates message receipt state if message exists
|
|
684
|
+
* - Emits updated message
|
|
685
|
+
*/
|
|
686
|
+
handleReceipt(_conversationId, messageId, receiptState, userId) {
|
|
687
|
+
if (receiptState === "delivered") {
|
|
688
|
+
this.receiptManager.handleDelivered(messageId, userId);
|
|
689
|
+
} else if (receiptState === "read") {
|
|
690
|
+
this.receiptManager.handleRead(messageId, userId);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Update message receipt state and emit updated message
|
|
695
|
+
*/
|
|
696
|
+
updateMessageReceiptState(event) {
|
|
697
|
+
for (const [, messages] of this.confirmedMessages.entries()) {
|
|
698
|
+
const message = messages.get(event.messageId);
|
|
699
|
+
if (message) {
|
|
700
|
+
const updatedMessage = MessageFactory.updateReceiptState(
|
|
701
|
+
message,
|
|
702
|
+
event.state
|
|
703
|
+
);
|
|
704
|
+
messages.set(event.messageId, updatedMessage);
|
|
705
|
+
this.emit("message", updatedMessage);
|
|
706
|
+
this.logger.debug("Updated message receipt state:", event.messageId, event.state);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
for (const [, messages] of this.optimisticMessages.entries()) {
|
|
711
|
+
const message = messages.get(event.messageId);
|
|
712
|
+
if (message) {
|
|
713
|
+
const updatedMessage = MessageFactory.updateReceiptState(
|
|
714
|
+
message,
|
|
715
|
+
event.state
|
|
716
|
+
);
|
|
717
|
+
messages.set(event.messageId, updatedMessage);
|
|
718
|
+
this.emit("message", updatedMessage);
|
|
719
|
+
this.logger.debug("Updated optimistic message receipt state:", event.messageId, event.state);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
this.logger.warn("Receipt for unknown message:", event.messageId);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Reconcile an optimistic message with a server confirmation
|
|
727
|
+
*
|
|
728
|
+
* This is called when we receive a server message with the same message_id
|
|
729
|
+
* as an optimistic message we previously created.
|
|
730
|
+
*
|
|
731
|
+
* @returns The reconciled (confirmed) message
|
|
732
|
+
*/
|
|
733
|
+
reconcileOptimisticMessage(optimisticMessage, serverPayload) {
|
|
734
|
+
const confirmedMessage = MessageFactory.confirmOptimistic(optimisticMessage, serverPayload);
|
|
735
|
+
this.emit("message", confirmedMessage);
|
|
736
|
+
this.logger.debug("Reconciled optimistic message:", optimisticMessage.id);
|
|
737
|
+
return confirmedMessage;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Check if we've already seen a message ID for a conversation
|
|
741
|
+
*/
|
|
742
|
+
hasSeenMessage(conversationId, messageId) {
|
|
743
|
+
const seenIds = this.seenMessageIds.get(conversationId);
|
|
744
|
+
return seenIds?.has(messageId) ?? false;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Track a message ID for a conversation (for deduplication)
|
|
748
|
+
*/
|
|
749
|
+
trackMessageId(conversationId, messageId) {
|
|
750
|
+
let seenIds = this.seenMessageIds.get(conversationId);
|
|
751
|
+
if (!seenIds) {
|
|
752
|
+
seenIds = /* @__PURE__ */ new Set();
|
|
753
|
+
this.seenMessageIds.set(conversationId, seenIds);
|
|
754
|
+
}
|
|
755
|
+
seenIds.add(messageId);
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Track an optimistic message for later reconciliation
|
|
759
|
+
*/
|
|
760
|
+
trackOptimisticMessage(conversationId, messageId, message) {
|
|
761
|
+
let conversationOptimistic = this.optimisticMessages.get(conversationId);
|
|
762
|
+
if (!conversationOptimistic) {
|
|
763
|
+
conversationOptimistic = /* @__PURE__ */ new Map();
|
|
764
|
+
this.optimisticMessages.set(conversationId, conversationOptimistic);
|
|
765
|
+
}
|
|
766
|
+
conversationOptimistic.set(messageId, message);
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Get an optimistic message by ID
|
|
770
|
+
*/
|
|
771
|
+
getOptimisticMessage(conversationId, messageId) {
|
|
772
|
+
const conversationOptimistic = this.optimisticMessages.get(conversationId);
|
|
773
|
+
return conversationOptimistic?.get(messageId);
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Remove an optimistic message from tracking
|
|
777
|
+
*/
|
|
778
|
+
removeOptimisticMessage(conversationId, messageId) {
|
|
779
|
+
const conversationOptimistic = this.optimisticMessages.get(conversationId);
|
|
780
|
+
if (conversationOptimistic) {
|
|
781
|
+
conversationOptimistic.delete(messageId);
|
|
782
|
+
if (conversationOptimistic.size === 0) {
|
|
783
|
+
this.optimisticMessages.delete(conversationId);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Track a confirmed message for receipt updates
|
|
789
|
+
*/
|
|
790
|
+
trackConfirmedMessage(conversationId, messageId, message) {
|
|
791
|
+
let conversationMessages = this.confirmedMessages.get(conversationId);
|
|
792
|
+
if (!conversationMessages) {
|
|
793
|
+
conversationMessages = /* @__PURE__ */ new Map();
|
|
794
|
+
this.confirmedMessages.set(conversationId, conversationMessages);
|
|
795
|
+
}
|
|
796
|
+
conversationMessages.set(messageId, message);
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Get all message IDs for a conversation (for receipt clearing)
|
|
800
|
+
*/
|
|
801
|
+
getMessageIds(conversationId) {
|
|
802
|
+
const confirmed = this.confirmedMessages.get(conversationId);
|
|
803
|
+
const optimistic = this.optimisticMessages.get(conversationId);
|
|
804
|
+
const ids = /* @__PURE__ */ new Set();
|
|
805
|
+
if (confirmed) {
|
|
806
|
+
for (const id of confirmed.keys()) {
|
|
807
|
+
ids.add(id);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (optimistic) {
|
|
811
|
+
for (const id of optimistic.keys()) {
|
|
812
|
+
ids.add(id);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return Array.from(ids);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Clear tracked message IDs and optimistic messages for a conversation
|
|
819
|
+
*/
|
|
820
|
+
clearConversation(conversationId) {
|
|
821
|
+
this.seenMessageIds.delete(conversationId);
|
|
822
|
+
this.optimisticMessages.delete(conversationId);
|
|
823
|
+
const messageIds = this.getMessageIds(conversationId);
|
|
824
|
+
this.receiptManager.clearConversation(conversationId, messageIds);
|
|
825
|
+
this.confirmedMessages.delete(conversationId);
|
|
826
|
+
this.logger.debug("Cleared message tracking for conversation:", conversationId);
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Clear all tracked message IDs and optimistic messages
|
|
830
|
+
*/
|
|
831
|
+
clear() {
|
|
832
|
+
this.seenMessageIds.clear();
|
|
833
|
+
this.optimisticMessages.clear();
|
|
834
|
+
this.confirmedMessages.clear();
|
|
835
|
+
this.receiptManager.clear();
|
|
836
|
+
this.logger.debug("Cleared all message tracking");
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
var TypingManager = class extends eventemitter3.EventEmitter {
|
|
840
|
+
// 5 seconds
|
|
841
|
+
constructor(debug = false) {
|
|
842
|
+
super();
|
|
843
|
+
/**
|
|
844
|
+
* Track active typing timers per conversation per user
|
|
845
|
+
* Map<conversationId, Map<userId, timeout>>
|
|
846
|
+
*/
|
|
847
|
+
this.typingTimers = /* @__PURE__ */ new Map();
|
|
848
|
+
/**
|
|
849
|
+
* Track who is currently typing per conversation
|
|
850
|
+
* Map<conversationId, Set<userId>>
|
|
851
|
+
*/
|
|
852
|
+
this.activeTyping = /* @__PURE__ */ new Map();
|
|
853
|
+
this.TYPING_TIMEOUT_MS = 5e3;
|
|
854
|
+
this.logger = new Logger(debug);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Handle a typing_start event
|
|
858
|
+
*
|
|
859
|
+
* Rules:
|
|
860
|
+
* - Emit only once per user until stopped
|
|
861
|
+
* - Restart timeout if typing_start repeats
|
|
862
|
+
*/
|
|
863
|
+
handleTypingStart(conversationId, userId) {
|
|
864
|
+
if (!conversationId || !userId) {
|
|
865
|
+
this.logger.warn("Invalid typing_start: missing conversationId or userId");
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
let conversationTyping = this.activeTyping.get(conversationId);
|
|
869
|
+
if (!conversationTyping) {
|
|
870
|
+
conversationTyping = /* @__PURE__ */ new Set();
|
|
871
|
+
this.activeTyping.set(conversationId, conversationTyping);
|
|
872
|
+
}
|
|
873
|
+
let conversationTimers = this.typingTimers.get(conversationId);
|
|
874
|
+
if (!conversationTimers) {
|
|
875
|
+
conversationTimers = /* @__PURE__ */ new Map();
|
|
876
|
+
this.typingTimers.set(conversationId, conversationTimers);
|
|
877
|
+
}
|
|
878
|
+
const wasTyping = conversationTyping.has(userId);
|
|
879
|
+
const existingTimer = conversationTimers.get(userId);
|
|
880
|
+
if (existingTimer) {
|
|
881
|
+
clearTimeout(existingTimer);
|
|
882
|
+
}
|
|
883
|
+
conversationTyping.add(userId);
|
|
884
|
+
const timer = setTimeout(() => {
|
|
885
|
+
this.handleTypingStop(conversationId, userId);
|
|
886
|
+
}, this.TYPING_TIMEOUT_MS);
|
|
887
|
+
conversationTimers.set(userId, timer);
|
|
888
|
+
if (!wasTyping) {
|
|
889
|
+
this.emit("typing", {
|
|
890
|
+
conversationId,
|
|
891
|
+
userId,
|
|
892
|
+
state: "start"
|
|
893
|
+
});
|
|
894
|
+
this.logger.debug("Typing started:", conversationId, userId);
|
|
895
|
+
} else {
|
|
896
|
+
this.logger.debug("Typing timer restarted:", conversationId, userId);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Handle a typing_stop event
|
|
901
|
+
*
|
|
902
|
+
* Rules:
|
|
903
|
+
* - Emit only if user was typing
|
|
904
|
+
*/
|
|
905
|
+
handleTypingStop(conversationId, userId) {
|
|
906
|
+
if (!conversationId || !userId) {
|
|
907
|
+
this.logger.warn("Invalid typing_stop: missing conversationId or userId");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const conversationTyping = this.activeTyping.get(conversationId);
|
|
911
|
+
const conversationTimers = this.typingTimers.get(conversationId);
|
|
912
|
+
if (!conversationTyping || !conversationTyping.has(userId)) {
|
|
913
|
+
this.logger.debug("Typing stop ignored (user not typing):", conversationId, userId);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const timer = conversationTimers?.get(userId);
|
|
917
|
+
if (timer) {
|
|
918
|
+
clearTimeout(timer);
|
|
919
|
+
conversationTimers?.delete(userId);
|
|
920
|
+
}
|
|
921
|
+
conversationTyping.delete(userId);
|
|
922
|
+
if (conversationTyping.size === 0) {
|
|
923
|
+
this.activeTyping.delete(conversationId);
|
|
924
|
+
}
|
|
925
|
+
if (conversationTimers && conversationTimers.size === 0) {
|
|
926
|
+
this.typingTimers.delete(conversationId);
|
|
927
|
+
}
|
|
928
|
+
this.emit("typing", {
|
|
929
|
+
conversationId,
|
|
930
|
+
userId,
|
|
931
|
+
state: "stop"
|
|
932
|
+
});
|
|
933
|
+
this.logger.debug("Typing stopped:", conversationId, userId);
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Clear all typing state for a conversation
|
|
937
|
+
*/
|
|
938
|
+
clearConversation(conversationId) {
|
|
939
|
+
const conversationTyping = this.activeTyping.get(conversationId);
|
|
940
|
+
const conversationTimers = this.typingTimers.get(conversationId);
|
|
941
|
+
if (conversationTyping) {
|
|
942
|
+
for (const userId of conversationTyping) {
|
|
943
|
+
this.emit("typing", {
|
|
944
|
+
conversationId,
|
|
945
|
+
userId,
|
|
946
|
+
state: "stop"
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
this.activeTyping.delete(conversationId);
|
|
950
|
+
}
|
|
951
|
+
if (conversationTimers) {
|
|
952
|
+
for (const timer of conversationTimers.values()) {
|
|
953
|
+
clearTimeout(timer);
|
|
954
|
+
}
|
|
955
|
+
this.typingTimers.delete(conversationId);
|
|
956
|
+
}
|
|
957
|
+
this.logger.debug("Cleared typing state for conversation:", conversationId);
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Clear all typing state
|
|
961
|
+
*/
|
|
962
|
+
clear() {
|
|
963
|
+
for (const conversationTimers of this.typingTimers.values()) {
|
|
964
|
+
for (const timer of conversationTimers.values()) {
|
|
965
|
+
clearTimeout(timer);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
this.typingTimers.clear();
|
|
969
|
+
this.activeTyping.clear();
|
|
970
|
+
this.logger.debug("Cleared all typing state");
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Get currently typing users for a conversation
|
|
974
|
+
*/
|
|
975
|
+
getTypingUsers(conversationId) {
|
|
976
|
+
const conversationTyping = this.activeTyping.get(conversationId);
|
|
977
|
+
return conversationTyping ? Array.from(conversationTyping) : [];
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
// src/conversations/Conversation.ts
|
|
982
|
+
var Conversation = class extends eventemitter3.EventEmitter {
|
|
983
|
+
constructor(conversationId, debug = false) {
|
|
984
|
+
super();
|
|
985
|
+
this._isJoined = false;
|
|
986
|
+
this.id = conversationId;
|
|
987
|
+
this.messageManager = new MessageManager(debug);
|
|
988
|
+
this.typingManager = new TypingManager(debug);
|
|
989
|
+
this.messageManager.on("message", (message) => {
|
|
990
|
+
if (message.conversationId === this.id) {
|
|
991
|
+
this.emit("message", message);
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
this.messageManager.on("receipt", (event) => {
|
|
995
|
+
this.emit("receipt", event);
|
|
996
|
+
});
|
|
997
|
+
this.typingManager.on("typing", (event) => {
|
|
998
|
+
if (event.conversationId === this.id) {
|
|
999
|
+
this.emit("typing", {
|
|
1000
|
+
userId: event.userId,
|
|
1001
|
+
state: event.state
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Check if this conversation is currently joined
|
|
1008
|
+
*/
|
|
1009
|
+
get isJoined() {
|
|
1010
|
+
return this._isJoined;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Set the send message handler (called by ConversationManager)
|
|
1014
|
+
*/
|
|
1015
|
+
setSendMessageHandler(handler) {
|
|
1016
|
+
this.sendMessageHandler = handler;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Set the send typing handler (called by ConversationManager)
|
|
1020
|
+
*/
|
|
1021
|
+
setSendTypingHandler(handler) {
|
|
1022
|
+
this.sendTypingHandler = handler;
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Mark conversation as joined
|
|
1026
|
+
*/
|
|
1027
|
+
markJoined() {
|
|
1028
|
+
if (!this._isJoined) {
|
|
1029
|
+
this._isJoined = true;
|
|
1030
|
+
this.emit("joined", { conversation_id: this.id });
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Mark conversation as left
|
|
1035
|
+
*/
|
|
1036
|
+
markLeft() {
|
|
1037
|
+
if (this._isJoined) {
|
|
1038
|
+
this._isJoined = false;
|
|
1039
|
+
this.emit("left", { conversation_id: this.id });
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Send a message to this conversation
|
|
1044
|
+
*
|
|
1045
|
+
* This method:
|
|
1046
|
+
* 1. Generates a message_id (UUID v4)
|
|
1047
|
+
* 2. Creates an optimistic Message
|
|
1048
|
+
* 3. Emits the optimistic message immediately
|
|
1049
|
+
* 4. Sends the message via transport
|
|
1050
|
+
*/
|
|
1051
|
+
sendMessage(content, messageType = "text", metadata) {
|
|
1052
|
+
if (!this.sendMessageHandler) {
|
|
1053
|
+
const error = new Error("Conversation is not connected. Call join() first.");
|
|
1054
|
+
this.emit("error", error);
|
|
1055
|
+
throw error;
|
|
1056
|
+
}
|
|
1057
|
+
if (!this._isJoined) {
|
|
1058
|
+
const error = new Error("Conversation is not joined. Call join() first.");
|
|
1059
|
+
this.emit("error", error);
|
|
1060
|
+
throw error;
|
|
1061
|
+
}
|
|
1062
|
+
const messageId = uuid.v4();
|
|
1063
|
+
this.messageManager.createOptimisticMessage(
|
|
1064
|
+
this.id,
|
|
1065
|
+
messageId,
|
|
1066
|
+
content,
|
|
1067
|
+
messageType,
|
|
1068
|
+
metadata
|
|
1069
|
+
);
|
|
1070
|
+
const protocolMessage = {
|
|
1071
|
+
type: "message",
|
|
1072
|
+
conversation_id: this.id,
|
|
1073
|
+
payload: {
|
|
1074
|
+
message_id: messageId,
|
|
1075
|
+
content,
|
|
1076
|
+
message_type: messageType
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
try {
|
|
1080
|
+
this.sendMessageHandler(protocolMessage);
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
const sendError = error instanceof Error ? error : new Error("Failed to send message");
|
|
1083
|
+
this.emit("error", sendError);
|
|
1084
|
+
throw sendError;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Handle an incoming message from the server
|
|
1089
|
+
* Called by ConversationManager when routing messages
|
|
1090
|
+
*/
|
|
1091
|
+
handleIncomingMessage(payload) {
|
|
1092
|
+
this.messageManager.handleIncomingMessage(this.id, payload);
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Handle a receipt update
|
|
1096
|
+
* Called by ConversationManager when routing receipt messages
|
|
1097
|
+
*/
|
|
1098
|
+
handleReceipt(messageId, receiptState, userId) {
|
|
1099
|
+
this.messageManager.handleReceipt(this.id, messageId, receiptState, userId);
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Handle a typing event from the server
|
|
1103
|
+
* Called by ConversationManager when routing typing messages
|
|
1104
|
+
*/
|
|
1105
|
+
handleTyping(payload) {
|
|
1106
|
+
if (payload.state === "start") {
|
|
1107
|
+
this.typingManager.handleTypingStart(this.id, payload.user_id);
|
|
1108
|
+
} else if (payload.state === "stop") {
|
|
1109
|
+
this.typingManager.handleTypingStop(this.id, payload.user_id);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Emit a presence event (called by ConversationManager when routing)
|
|
1114
|
+
* Note: Presence is SDK-global, but we emit it here for convenience
|
|
1115
|
+
*/
|
|
1116
|
+
emitPresence(payload) {
|
|
1117
|
+
this.emit("presence", {
|
|
1118
|
+
userId: payload.user_id,
|
|
1119
|
+
state: payload.state
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Clear message tracking for this conversation
|
|
1124
|
+
*/
|
|
1125
|
+
clearMessages() {
|
|
1126
|
+
this.messageManager.clearConversation(this.id);
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Clear typing state for this conversation
|
|
1130
|
+
*/
|
|
1131
|
+
clearTyping() {
|
|
1132
|
+
this.typingManager.clearConversation(this.id);
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Start typing indicator
|
|
1136
|
+
*
|
|
1137
|
+
* This method sends a typing_start message to the server.
|
|
1138
|
+
* The TypingManager will handle debouncing and auto-expiry.
|
|
1139
|
+
*/
|
|
1140
|
+
startTyping() {
|
|
1141
|
+
if (!this.sendTypingHandler) {
|
|
1142
|
+
const error = new Error("Conversation is not connected. Call join() first.");
|
|
1143
|
+
this.emit("error", error);
|
|
1144
|
+
throw error;
|
|
1145
|
+
}
|
|
1146
|
+
if (!this._isJoined) {
|
|
1147
|
+
const error = new Error("Conversation is not joined. Call join() first.");
|
|
1148
|
+
this.emit("error", error);
|
|
1149
|
+
throw error;
|
|
1150
|
+
}
|
|
1151
|
+
const typingMessage = {
|
|
1152
|
+
type: "typing_start",
|
|
1153
|
+
conversation_id: this.id
|
|
1154
|
+
};
|
|
1155
|
+
try {
|
|
1156
|
+
this.sendTypingHandler(typingMessage);
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
const sendError = error instanceof Error ? error : new Error("Failed to send typing_start");
|
|
1159
|
+
this.emit("error", sendError);
|
|
1160
|
+
throw sendError;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Stop typing indicator
|
|
1165
|
+
*
|
|
1166
|
+
* This method sends a typing_stop message to the server.
|
|
1167
|
+
*/
|
|
1168
|
+
stopTyping() {
|
|
1169
|
+
if (!this.sendTypingHandler) {
|
|
1170
|
+
const error = new Error("Conversation is not connected. Call join() first.");
|
|
1171
|
+
this.emit("error", error);
|
|
1172
|
+
throw error;
|
|
1173
|
+
}
|
|
1174
|
+
if (!this._isJoined) {
|
|
1175
|
+
const error = new Error("Conversation is not joined. Call join() first.");
|
|
1176
|
+
this.emit("error", error);
|
|
1177
|
+
throw error;
|
|
1178
|
+
}
|
|
1179
|
+
const typingMessage = {
|
|
1180
|
+
type: "typing_stop",
|
|
1181
|
+
conversation_id: this.id
|
|
1182
|
+
};
|
|
1183
|
+
try {
|
|
1184
|
+
this.sendTypingHandler(typingMessage);
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
const sendError = error instanceof Error ? error : new Error("Failed to send typing_stop");
|
|
1187
|
+
this.emit("error", sendError);
|
|
1188
|
+
throw sendError;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
// src/conversations/ConversationManager.ts
|
|
1194
|
+
var ConversationManager = class extends eventemitter3.EventEmitter {
|
|
1195
|
+
constructor(sendMessageHandler, debug = false, supportMessageHandler, sendTypingHandler) {
|
|
1196
|
+
super();
|
|
1197
|
+
this.conversations = /* @__PURE__ */ new Map();
|
|
1198
|
+
this.sendMessageHandler = sendMessageHandler;
|
|
1199
|
+
this.sendTypingHandler = sendTypingHandler;
|
|
1200
|
+
this.supportMessageHandler = supportMessageHandler;
|
|
1201
|
+
this.debug = debug;
|
|
1202
|
+
this.logger = new Logger(debug);
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Set the support message handler (called by ChatAfrika)
|
|
1206
|
+
*/
|
|
1207
|
+
setSupportMessageHandler(handler) {
|
|
1208
|
+
this.supportMessageHandler = handler;
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Set the send typing handler (called by ChatAfrika)
|
|
1212
|
+
*/
|
|
1213
|
+
setSendTypingHandler(handler) {
|
|
1214
|
+
this.sendTypingHandler = handler;
|
|
1215
|
+
for (const conversation of this.conversations.values()) {
|
|
1216
|
+
conversation.setSendTypingHandler(handler);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Get or create a Conversation instance
|
|
1221
|
+
* Conversations are lazy-created
|
|
1222
|
+
*/
|
|
1223
|
+
get(conversationId) {
|
|
1224
|
+
if (!conversationId || conversationId.length === 0) {
|
|
1225
|
+
throw new Error("Conversation ID is required");
|
|
1226
|
+
}
|
|
1227
|
+
let conversation = this.conversations.get(conversationId);
|
|
1228
|
+
if (!conversation) {
|
|
1229
|
+
conversation = new Conversation(conversationId, this.debug);
|
|
1230
|
+
if (this.sendMessageHandler) {
|
|
1231
|
+
conversation.setSendMessageHandler(this.sendMessageHandler);
|
|
1232
|
+
}
|
|
1233
|
+
if (this.sendTypingHandler) {
|
|
1234
|
+
conversation.setSendTypingHandler(this.sendTypingHandler);
|
|
1235
|
+
}
|
|
1236
|
+
this.conversations.set(conversationId, conversation);
|
|
1237
|
+
this.logger.debug("Created conversation instance:", conversationId);
|
|
1238
|
+
}
|
|
1239
|
+
return conversation;
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Check if a conversation instance exists
|
|
1243
|
+
*/
|
|
1244
|
+
has(conversationId) {
|
|
1245
|
+
return this.conversations.has(conversationId);
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Join a conversation
|
|
1249
|
+
* This delegates to the transport layer (ChatAfrika)
|
|
1250
|
+
*/
|
|
1251
|
+
async join(conversationId, joinHandler) {
|
|
1252
|
+
if (!conversationId || conversationId.length === 0) {
|
|
1253
|
+
throw new Error("Conversation ID is required");
|
|
1254
|
+
}
|
|
1255
|
+
this.get(conversationId);
|
|
1256
|
+
await joinHandler(conversationId);
|
|
1257
|
+
this.logger.debug("Join requested for conversation:", conversationId);
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Leave a conversation
|
|
1261
|
+
* This delegates to the transport layer (ChatAfrika)
|
|
1262
|
+
*/
|
|
1263
|
+
async leave(conversationId, leaveHandler) {
|
|
1264
|
+
if (!conversationId || conversationId.length === 0) {
|
|
1265
|
+
throw new Error("Conversation ID is required");
|
|
1266
|
+
}
|
|
1267
|
+
const conversation = this.conversations.get(conversationId);
|
|
1268
|
+
if (conversation) {
|
|
1269
|
+
conversation.clearTyping();
|
|
1270
|
+
await leaveHandler(conversationId);
|
|
1271
|
+
conversation.markLeft();
|
|
1272
|
+
this.logger.debug("Left conversation:", conversationId);
|
|
1273
|
+
} else {
|
|
1274
|
+
await leaveHandler(conversationId);
|
|
1275
|
+
this.logger.warn("Attempted to leave unknown conversation:", conversationId);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Route an incoming protocol message to the appropriate Conversation
|
|
1280
|
+
*
|
|
1281
|
+
* Messages are routed through MessageManager for lifecycle handling.
|
|
1282
|
+
* Typing events are routed through TypingManager.
|
|
1283
|
+
* Only messages for joined conversations are processed.
|
|
1284
|
+
*/
|
|
1285
|
+
routeIncomingMessage(message) {
|
|
1286
|
+
let conversationId;
|
|
1287
|
+
switch (message.type) {
|
|
1288
|
+
case "joined":
|
|
1289
|
+
conversationId = message.payload?.conversation_id || message.conversation_id;
|
|
1290
|
+
if (conversationId) {
|
|
1291
|
+
const conversation = this.get(conversationId);
|
|
1292
|
+
conversation.markJoined();
|
|
1293
|
+
}
|
|
1294
|
+
break;
|
|
1295
|
+
case "message":
|
|
1296
|
+
conversationId = message.conversation_id;
|
|
1297
|
+
if (conversationId) {
|
|
1298
|
+
const conversation = this.conversations.get(conversationId);
|
|
1299
|
+
if (conversation) {
|
|
1300
|
+
if (conversation.isJoined) {
|
|
1301
|
+
conversation.handleIncomingMessage(message.payload);
|
|
1302
|
+
} else {
|
|
1303
|
+
this.logger.warn("Received message for conversation that is not joined:", conversationId);
|
|
1304
|
+
}
|
|
1305
|
+
} else {
|
|
1306
|
+
this.logger.warn("Received message for unknown conversation:", conversationId);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
break;
|
|
1310
|
+
case "typing":
|
|
1311
|
+
conversationId = message.conversation_id;
|
|
1312
|
+
if (conversationId) {
|
|
1313
|
+
const conversation = this.conversations.get(conversationId);
|
|
1314
|
+
if (conversation) {
|
|
1315
|
+
if (conversation.isJoined) {
|
|
1316
|
+
conversation.handleTyping(message.payload);
|
|
1317
|
+
} else {
|
|
1318
|
+
this.logger.warn("Received typing indicator for conversation that is not joined:", conversationId);
|
|
1319
|
+
}
|
|
1320
|
+
} else {
|
|
1321
|
+
this.logger.warn("Received typing indicator for unknown conversation:", conversationId);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
break;
|
|
1325
|
+
case "receipt":
|
|
1326
|
+
conversationId = message.conversation_id;
|
|
1327
|
+
if (conversationId) {
|
|
1328
|
+
const conversation = this.conversations.get(conversationId);
|
|
1329
|
+
if (conversation) {
|
|
1330
|
+
if (conversation.isJoined) {
|
|
1331
|
+
const receiptMsg = message;
|
|
1332
|
+
conversation.handleReceipt(
|
|
1333
|
+
receiptMsg.payload.message_id,
|
|
1334
|
+
receiptMsg.payload.state,
|
|
1335
|
+
receiptMsg.payload.user_id
|
|
1336
|
+
);
|
|
1337
|
+
} else {
|
|
1338
|
+
this.logger.warn("Received receipt for conversation that is not joined:", conversationId);
|
|
1339
|
+
}
|
|
1340
|
+
} else {
|
|
1341
|
+
this.logger.warn("Received receipt for unknown conversation:", conversationId);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
break;
|
|
1345
|
+
case "support":
|
|
1346
|
+
break;
|
|
1347
|
+
case "presence":
|
|
1348
|
+
conversationId = message.conversation_id;
|
|
1349
|
+
if (conversationId) {
|
|
1350
|
+
const conversation = this.conversations.get(conversationId);
|
|
1351
|
+
if (conversation && conversation.isJoined) {
|
|
1352
|
+
conversation.emitPresence(message.payload);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
break;
|
|
1356
|
+
case "error":
|
|
1357
|
+
this.logger.error("Received error message:", message.error);
|
|
1358
|
+
break;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Route a support protocol message to SupportManager
|
|
1363
|
+
*/
|
|
1364
|
+
routeSupportMessage(message) {
|
|
1365
|
+
if (!this.supportMessageHandler) {
|
|
1366
|
+
this.logger.warn("Support message handler not set, ignoring support message");
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const conversationId = message.conversation_id;
|
|
1370
|
+
if (!conversationId) {
|
|
1371
|
+
this.logger.warn("Support message missing conversation_id");
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
const eventType = message.payload?.event;
|
|
1375
|
+
if (!eventType) {
|
|
1376
|
+
this.logger.warn("Support message missing event type");
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
this.supportMessageHandler(conversationId, eventType, message.payload);
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Remove a conversation instance
|
|
1383
|
+
*/
|
|
1384
|
+
remove(conversationId) {
|
|
1385
|
+
const conversation = this.conversations.get(conversationId);
|
|
1386
|
+
if (conversation) {
|
|
1387
|
+
conversation.markLeft();
|
|
1388
|
+
conversation.clearMessages();
|
|
1389
|
+
conversation.clearTyping();
|
|
1390
|
+
this.conversations.delete(conversationId);
|
|
1391
|
+
this.logger.debug("Removed conversation instance:", conversationId);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Get all conversation IDs
|
|
1396
|
+
*/
|
|
1397
|
+
getAllIds() {
|
|
1398
|
+
return Array.from(this.conversations.keys());
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Clear all conversations
|
|
1402
|
+
*/
|
|
1403
|
+
clear() {
|
|
1404
|
+
for (const conversation of this.conversations.values()) {
|
|
1405
|
+
conversation.markLeft();
|
|
1406
|
+
conversation.clearMessages();
|
|
1407
|
+
conversation.clearTyping();
|
|
1408
|
+
}
|
|
1409
|
+
this.conversations.clear();
|
|
1410
|
+
this.logger.debug("Cleared all conversations");
|
|
1411
|
+
}
|
|
1412
|
+
};
|
|
1413
|
+
var PresenceManager = class extends eventemitter3.EventEmitter {
|
|
1414
|
+
constructor(debug = false) {
|
|
1415
|
+
super();
|
|
1416
|
+
/**
|
|
1417
|
+
* Track current presence state per user
|
|
1418
|
+
* Map<userId, "online" | "offline">
|
|
1419
|
+
*/
|
|
1420
|
+
this.presenceState = /* @__PURE__ */ new Map();
|
|
1421
|
+
this.logger = new Logger(debug);
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Handle a presence event
|
|
1425
|
+
*
|
|
1426
|
+
* Rules:
|
|
1427
|
+
* - Emit only on state change
|
|
1428
|
+
* - Ignore duplicate online/online or offline/offline events
|
|
1429
|
+
*/
|
|
1430
|
+
handlePresence(userId, state) {
|
|
1431
|
+
if (!userId) {
|
|
1432
|
+
this.logger.warn("Invalid presence event: missing userId");
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
if (state !== "online" && state !== "offline") {
|
|
1436
|
+
this.logger.warn("Invalid presence state:", state);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
const currentState = this.presenceState.get(userId);
|
|
1440
|
+
if (currentState !== state) {
|
|
1441
|
+
this.presenceState.set(userId, state);
|
|
1442
|
+
this.emit("presence", {
|
|
1443
|
+
userId,
|
|
1444
|
+
state
|
|
1445
|
+
});
|
|
1446
|
+
this.logger.debug("Presence changed:", userId, state);
|
|
1447
|
+
} else {
|
|
1448
|
+
this.logger.debug("Duplicate presence event ignored:", userId, state);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Get current presence state for a user
|
|
1453
|
+
*/
|
|
1454
|
+
getPresence(userId) {
|
|
1455
|
+
return this.presenceState.get(userId);
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Get presence state for multiple users
|
|
1459
|
+
*/
|
|
1460
|
+
getPresences(userIds) {
|
|
1461
|
+
const result = /* @__PURE__ */ new Map();
|
|
1462
|
+
for (const userId of userIds) {
|
|
1463
|
+
const state = this.presenceState.get(userId);
|
|
1464
|
+
if (state) {
|
|
1465
|
+
result.set(userId, state);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return result;
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Clear presence for a user
|
|
1472
|
+
*/
|
|
1473
|
+
clearPresence(userId) {
|
|
1474
|
+
const hadState = this.presenceState.has(userId);
|
|
1475
|
+
this.presenceState.delete(userId);
|
|
1476
|
+
if (hadState) {
|
|
1477
|
+
this.logger.debug("Cleared presence for user:", userId);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Clear all presence state
|
|
1482
|
+
*/
|
|
1483
|
+
clear() {
|
|
1484
|
+
this.presenceState.clear();
|
|
1485
|
+
this.logger.debug("Cleared all presence state");
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
var SupportSession = class extends eventemitter3.EventEmitter {
|
|
1489
|
+
constructor(conversationId, conversation, role, debug = false) {
|
|
1490
|
+
super();
|
|
1491
|
+
this.botActive = false;
|
|
1492
|
+
this.status = "queued";
|
|
1493
|
+
this.conversationId = conversationId;
|
|
1494
|
+
this.conversation = conversation;
|
|
1495
|
+
this.role = role;
|
|
1496
|
+
this.logger = new Logger(debug);
|
|
1497
|
+
this.conversation.on("message", (msg) => this.emit("message", msg));
|
|
1498
|
+
this.conversation.on("typing", (event) => this.emit("typing", event));
|
|
1499
|
+
this.conversation.on("presence", (event) => this.emit("presence", event));
|
|
1500
|
+
this.conversation.on("joined", (event) => this.emit("joined", event));
|
|
1501
|
+
this.conversation.on("left", (event) => this.emit("left", event));
|
|
1502
|
+
this.conversation.on("error", (error) => this.emit("error", error));
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Get the underlying Conversation instance
|
|
1506
|
+
*/
|
|
1507
|
+
getConversation() {
|
|
1508
|
+
return this.conversation;
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Check if this session is for a customer
|
|
1512
|
+
*/
|
|
1513
|
+
isCustomer() {
|
|
1514
|
+
return this.role === "customer";
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Check if this session is for an agent
|
|
1518
|
+
*/
|
|
1519
|
+
isAgent() {
|
|
1520
|
+
return this.role === "agent";
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Get the current role
|
|
1524
|
+
*/
|
|
1525
|
+
getRole() {
|
|
1526
|
+
return this.role;
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Check if bot is currently active
|
|
1530
|
+
*/
|
|
1531
|
+
isBotActive() {
|
|
1532
|
+
return this.botActive;
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Get the assigned agent ID (if any)
|
|
1536
|
+
*/
|
|
1537
|
+
getAssignedAgent() {
|
|
1538
|
+
return this.assignedAgentId;
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Get the current status
|
|
1542
|
+
*/
|
|
1543
|
+
getStatus() {
|
|
1544
|
+
return this.status;
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Handle assignment of an agent
|
|
1548
|
+
*/
|
|
1549
|
+
handleAssigned(agentId) {
|
|
1550
|
+
if (this.assignedAgentId === agentId) {
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
const previousAgentId = this.assignedAgentId;
|
|
1554
|
+
const wasAssigned = previousAgentId !== void 0;
|
|
1555
|
+
this.assignedAgentId = agentId;
|
|
1556
|
+
if (this.status === "queued") {
|
|
1557
|
+
this.status = "assigned";
|
|
1558
|
+
} else if (this.status === "assigned" || this.status === "active") ;
|
|
1559
|
+
if (!wasAssigned) {
|
|
1560
|
+
this.emit("assigned", { agentId });
|
|
1561
|
+
this.logger.debug("Support session assigned:", this.conversationId, agentId);
|
|
1562
|
+
} else {
|
|
1563
|
+
this.emit("assigned", { agentId, previousAgentId });
|
|
1564
|
+
this.logger.debug("Support session reassigned:", this.conversationId, agentId, "from", previousAgentId);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Handle unassignment of an agent
|
|
1569
|
+
*/
|
|
1570
|
+
handleUnassigned() {
|
|
1571
|
+
if (this.assignedAgentId === void 0) {
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
const previousAgentId = this.assignedAgentId;
|
|
1575
|
+
this.assignedAgentId = void 0;
|
|
1576
|
+
if (this.status === "assigned" || this.status === "active") {
|
|
1577
|
+
this.status = "queued";
|
|
1578
|
+
}
|
|
1579
|
+
this.emit("unassigned", { previousAgentId });
|
|
1580
|
+
this.logger.debug("Support session unassigned:", this.conversationId);
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Handle bot takeover
|
|
1584
|
+
*/
|
|
1585
|
+
handleBotTakeover() {
|
|
1586
|
+
if (this.botActive) {
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
this.botActive = true;
|
|
1590
|
+
this.emit("bot_takeover", {});
|
|
1591
|
+
this.logger.debug("Bot took over support session:", this.conversationId);
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Handle bot release
|
|
1595
|
+
*/
|
|
1596
|
+
handleBotReleased() {
|
|
1597
|
+
if (!this.botActive) {
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
this.botActive = false;
|
|
1601
|
+
this.emit("bot_released", {});
|
|
1602
|
+
this.logger.debug("Bot released support session:", this.conversationId);
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Handle session closed
|
|
1606
|
+
*/
|
|
1607
|
+
handleClosed() {
|
|
1608
|
+
if (this.status === "closed") {
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
const previousStatus = this.status;
|
|
1612
|
+
this.status = "closed";
|
|
1613
|
+
this.emit("closed", { previousStatus });
|
|
1614
|
+
this.logger.debug("Support session closed:", this.conversationId);
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Handle session reopened
|
|
1618
|
+
*/
|
|
1619
|
+
handleReopened() {
|
|
1620
|
+
if (this.status !== "closed") {
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
this.status = this.assignedAgentId ? "assigned" : "queued";
|
|
1624
|
+
this.emit("reopened", {});
|
|
1625
|
+
this.logger.debug("Support session reopened:", this.conversationId);
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Send a message (delegates to Conversation)
|
|
1629
|
+
*/
|
|
1630
|
+
sendMessage(content, messageType = "text", metadata) {
|
|
1631
|
+
this.conversation.sendMessage(content, messageType, metadata);
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Join the conversation (delegates to Conversation)
|
|
1635
|
+
*/
|
|
1636
|
+
async join() {
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Leave the conversation (delegates to Conversation)
|
|
1640
|
+
*/
|
|
1641
|
+
async leave() {
|
|
1642
|
+
}
|
|
1643
|
+
};
|
|
1644
|
+
|
|
1645
|
+
// src/support/SupportManager.ts
|
|
1646
|
+
var SupportManager = class extends eventemitter3.EventEmitter {
|
|
1647
|
+
constructor(getConversationHandler, debug = false) {
|
|
1648
|
+
super();
|
|
1649
|
+
this.sessions = /* @__PURE__ */ new Map();
|
|
1650
|
+
this.getConversationHandler = getConversationHandler;
|
|
1651
|
+
this.debug = debug;
|
|
1652
|
+
this.logger = new Logger(debug);
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Get or create a SupportSession for a conversation
|
|
1656
|
+
*
|
|
1657
|
+
* Note: This assumes the conversation is a support conversation.
|
|
1658
|
+
* The role is determined by the SDK user's context (not inferred).
|
|
1659
|
+
*
|
|
1660
|
+
* @param conversationId - The conversation ID
|
|
1661
|
+
* @param role - The role of the current user ('customer' or 'agent')
|
|
1662
|
+
* @returns SupportSession instance
|
|
1663
|
+
*/
|
|
1664
|
+
get(conversationId, role = "customer") {
|
|
1665
|
+
if (!conversationId || conversationId.length === 0) {
|
|
1666
|
+
throw new Error("Conversation ID is required");
|
|
1667
|
+
}
|
|
1668
|
+
let session = this.sessions.get(conversationId);
|
|
1669
|
+
if (!session) {
|
|
1670
|
+
const conversation = this.getConversationHandler(conversationId);
|
|
1671
|
+
session = new SupportSession(conversationId, conversation, role, this.debug);
|
|
1672
|
+
session.on("assigned", (data) => this.emit("assigned", { conversationId, ...data }));
|
|
1673
|
+
session.on("unassigned", (data) => this.emit("unassigned", { conversationId, ...data }));
|
|
1674
|
+
session.on("bot_takeover", () => this.emit("bot_takeover", { conversationId }));
|
|
1675
|
+
session.on("bot_released", () => this.emit("bot_released", { conversationId }));
|
|
1676
|
+
session.on("closed", (data) => this.emit("closed", { conversationId, ...data }));
|
|
1677
|
+
session.on("reopened", () => this.emit("reopened", { conversationId }));
|
|
1678
|
+
this.sessions.set(conversationId, session);
|
|
1679
|
+
this.logger.debug("Created support session:", conversationId, role);
|
|
1680
|
+
}
|
|
1681
|
+
return session;
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Check if a support session exists
|
|
1685
|
+
*/
|
|
1686
|
+
has(conversationId) {
|
|
1687
|
+
return this.sessions.has(conversationId);
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Get a support session if it exists
|
|
1691
|
+
*/
|
|
1692
|
+
getSession(conversationId) {
|
|
1693
|
+
return this.sessions.get(conversationId);
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Handle a support protocol event
|
|
1697
|
+
*/
|
|
1698
|
+
handleSupportEvent(conversationId, eventType, payload) {
|
|
1699
|
+
const session = this.sessions.get(conversationId);
|
|
1700
|
+
if (!session) {
|
|
1701
|
+
if (eventType === "assigned" && payload?.agent_id) {
|
|
1702
|
+
this.logger.debug("Support event for unknown session, will be handled when session is created:", conversationId);
|
|
1703
|
+
} else {
|
|
1704
|
+
this.logger.warn("Support event for unknown session:", conversationId, eventType);
|
|
1705
|
+
}
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
switch (eventType) {
|
|
1709
|
+
case "assigned":
|
|
1710
|
+
if (payload?.agent_id && typeof payload.agent_id === "string") {
|
|
1711
|
+
session.handleAssigned(payload.agent_id);
|
|
1712
|
+
}
|
|
1713
|
+
break;
|
|
1714
|
+
case "unassigned":
|
|
1715
|
+
session.handleUnassigned();
|
|
1716
|
+
break;
|
|
1717
|
+
case "bot_takeover":
|
|
1718
|
+
session.handleBotTakeover();
|
|
1719
|
+
break;
|
|
1720
|
+
case "bot_released":
|
|
1721
|
+
session.handleBotReleased();
|
|
1722
|
+
break;
|
|
1723
|
+
case "closed":
|
|
1724
|
+
session.handleClosed();
|
|
1725
|
+
break;
|
|
1726
|
+
case "reopened":
|
|
1727
|
+
session.handleReopened();
|
|
1728
|
+
break;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Remove a support session
|
|
1733
|
+
*/
|
|
1734
|
+
remove(conversationId) {
|
|
1735
|
+
const session = this.sessions.get(conversationId);
|
|
1736
|
+
if (session) {
|
|
1737
|
+
this.sessions.delete(conversationId);
|
|
1738
|
+
this.logger.debug("Removed support session:", conversationId);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Clear all support sessions
|
|
1743
|
+
*/
|
|
1744
|
+
clear() {
|
|
1745
|
+
this.sessions.clear();
|
|
1746
|
+
this.logger.debug("Cleared all support sessions");
|
|
1747
|
+
}
|
|
1748
|
+
};
|
|
1749
|
+
|
|
1750
|
+
// src/core/ChatAfrika.ts
|
|
1751
|
+
var ChatAfrika = class extends eventemitter3.EventEmitter {
|
|
1752
|
+
constructor(config) {
|
|
1753
|
+
super();
|
|
1754
|
+
this.connectionState = "disconnected" /* DISCONNECTED */;
|
|
1755
|
+
this.wsClient = null;
|
|
1756
|
+
this.config = {
|
|
1757
|
+
autoReconnect: true,
|
|
1758
|
+
maxReconnectAttempts: 5,
|
|
1759
|
+
reconnectDelay: 1e3,
|
|
1760
|
+
debug: false,
|
|
1761
|
+
apiUrl: "",
|
|
1762
|
+
...config
|
|
1763
|
+
};
|
|
1764
|
+
this.tokenManager = new TokenManager();
|
|
1765
|
+
this.tokenManager.setToken(config.token);
|
|
1766
|
+
this.logger = new Logger(this.config.debug);
|
|
1767
|
+
this.wsClient = new WebSocketClient(this.config.wsUrl, {
|
|
1768
|
+
autoReconnect: this.config.autoReconnect,
|
|
1769
|
+
maxReconnectAttempts: this.config.maxReconnectAttempts,
|
|
1770
|
+
reconnectDelay: this.config.reconnectDelay,
|
|
1771
|
+
debug: this.config.debug
|
|
1772
|
+
});
|
|
1773
|
+
this.supportManager = new SupportManager(
|
|
1774
|
+
(conversationId) => this.conversationManager.get(conversationId),
|
|
1775
|
+
this.config.debug
|
|
1776
|
+
);
|
|
1777
|
+
this.conversationManager = new ConversationManager(
|
|
1778
|
+
(message) => {
|
|
1779
|
+
if (!this.wsClient) {
|
|
1780
|
+
throw new Error("WebSocket client not initialized");
|
|
1781
|
+
}
|
|
1782
|
+
this.wsClient.send(message);
|
|
1783
|
+
},
|
|
1784
|
+
this.config.debug,
|
|
1785
|
+
void 0,
|
|
1786
|
+
// supportMessageHandler (set later)
|
|
1787
|
+
(message) => {
|
|
1788
|
+
if (!this.wsClient) {
|
|
1789
|
+
throw new Error("WebSocket client not initialized");
|
|
1790
|
+
}
|
|
1791
|
+
this.wsClient.send(message);
|
|
1792
|
+
}
|
|
1793
|
+
);
|
|
1794
|
+
this.presenceManager = new PresenceManager(this.config.debug);
|
|
1795
|
+
this.presenceManager.on("presence", (event) => {
|
|
1796
|
+
this.emit("presence", event);
|
|
1797
|
+
});
|
|
1798
|
+
this.setupWebSocketHandlers();
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* Set up WebSocket event handlers
|
|
1802
|
+
*/
|
|
1803
|
+
setupWebSocketHandlers() {
|
|
1804
|
+
if (!this.wsClient) {
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
this.wsClient.on("connected", () => {
|
|
1808
|
+
this.connectionState = "connected" /* CONNECTED */;
|
|
1809
|
+
this.emit("connected");
|
|
1810
|
+
this.emit("state", this.connectionState);
|
|
1811
|
+
this.logger.debug("SDK connected");
|
|
1812
|
+
});
|
|
1813
|
+
this.wsClient.on("disconnected", (data) => {
|
|
1814
|
+
this.connectionState = "disconnected" /* DISCONNECTED */;
|
|
1815
|
+
this.emit("disconnected", data);
|
|
1816
|
+
this.emit("state", this.connectionState);
|
|
1817
|
+
this.logger.debug("SDK disconnected");
|
|
1818
|
+
});
|
|
1819
|
+
this.wsClient.on("reconnecting", (data) => {
|
|
1820
|
+
this.connectionState = "reconnecting" /* RECONNECTING */;
|
|
1821
|
+
this.emit("reconnecting", data);
|
|
1822
|
+
this.emit("state", this.connectionState);
|
|
1823
|
+
this.logger.debug("SDK reconnecting", data);
|
|
1824
|
+
});
|
|
1825
|
+
this.wsClient.on("message", (message) => {
|
|
1826
|
+
this.emit("message", message);
|
|
1827
|
+
this.conversationManager.routeIncomingMessage(message);
|
|
1828
|
+
if (message.type === "presence") {
|
|
1829
|
+
const presenceMsg = message;
|
|
1830
|
+
if (presenceMsg.payload) {
|
|
1831
|
+
this.presenceManager.handlePresence(
|
|
1832
|
+
presenceMsg.payload.user_id,
|
|
1833
|
+
presenceMsg.payload.state
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
if (message.type === "support") {
|
|
1838
|
+
this.conversationManager.routeSupportMessage(message);
|
|
1839
|
+
}
|
|
1840
|
+
switch (message.type) {
|
|
1841
|
+
case "joined":
|
|
1842
|
+
this.emit("joined", message);
|
|
1843
|
+
break;
|
|
1844
|
+
case "message":
|
|
1845
|
+
this.emit("message", message);
|
|
1846
|
+
break;
|
|
1847
|
+
case "typing":
|
|
1848
|
+
this.emit("typing", message);
|
|
1849
|
+
break;
|
|
1850
|
+
case "receipt":
|
|
1851
|
+
break;
|
|
1852
|
+
case "support":
|
|
1853
|
+
break;
|
|
1854
|
+
case "presence":
|
|
1855
|
+
break;
|
|
1856
|
+
case "error":
|
|
1857
|
+
this.emit("error", message);
|
|
1858
|
+
break;
|
|
1859
|
+
}
|
|
1860
|
+
});
|
|
1861
|
+
this.wsClient.on("error", (error) => {
|
|
1862
|
+
this.connectionState = "error" /* ERROR */;
|
|
1863
|
+
const errorMessage = error instanceof Error ? error.message : "";
|
|
1864
|
+
const errorName = error instanceof Error ? error.name : "";
|
|
1865
|
+
if (errorName === "TokenExpiredError" || errorMessage && errorMessage.includes("Token expired")) {
|
|
1866
|
+
this.logger.error("Token expired:", errorMessage);
|
|
1867
|
+
this.logger.warn("To reconnect, call sdk.updateToken(newToken) then sdk.connect()");
|
|
1868
|
+
} else {
|
|
1869
|
+
this.logger.error("SDK error:", error);
|
|
1870
|
+
}
|
|
1871
|
+
const errorToEmit = error instanceof Error ? error : new Error("WebSocket connection error");
|
|
1872
|
+
this.emit("error", errorToEmit);
|
|
1873
|
+
this.emit("state", this.connectionState);
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Get the current connection state
|
|
1878
|
+
*/
|
|
1879
|
+
getState() {
|
|
1880
|
+
return this.connectionState;
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* Get the current configuration
|
|
1884
|
+
*/
|
|
1885
|
+
getConfig() {
|
|
1886
|
+
return { ...this.config };
|
|
1887
|
+
}
|
|
1888
|
+
/**
|
|
1889
|
+
* Connect to the ChatAfrika service
|
|
1890
|
+
*/
|
|
1891
|
+
async connect() {
|
|
1892
|
+
if (this.connectionState === "connected" /* CONNECTED */) {
|
|
1893
|
+
this.logger.warn("Already connected");
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
if (this.connectionState === "connecting" /* CONNECTING */) {
|
|
1897
|
+
this.logger.warn("Connection already in progress");
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
this.tokenManager.validateTokenPresence();
|
|
1901
|
+
const token = this.tokenManager.getToken();
|
|
1902
|
+
if (!token) {
|
|
1903
|
+
throw new Error("Token is required for connection");
|
|
1904
|
+
}
|
|
1905
|
+
this.connectionState = "connecting" /* CONNECTING */;
|
|
1906
|
+
this.emit("state", this.connectionState);
|
|
1907
|
+
this.logger.debug("Connecting...");
|
|
1908
|
+
try {
|
|
1909
|
+
if (!this.wsClient) {
|
|
1910
|
+
throw new Error("WebSocket client not initialized");
|
|
1911
|
+
}
|
|
1912
|
+
await this.wsClient.connect(token);
|
|
1913
|
+
} catch (error) {
|
|
1914
|
+
this.connectionState = "error" /* ERROR */;
|
|
1915
|
+
this.emit("state", this.connectionState);
|
|
1916
|
+
const connectionError = error instanceof Error ? error : new Error("Connection failed");
|
|
1917
|
+
this.emit("error", connectionError);
|
|
1918
|
+
throw connectionError;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Disconnect from the ChatAfrika service
|
|
1923
|
+
*/
|
|
1924
|
+
async disconnect() {
|
|
1925
|
+
if (this.connectionState === "disconnected" /* DISCONNECTED */) {
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
this.logger.debug("Disconnecting...");
|
|
1929
|
+
this.conversationManager.clear();
|
|
1930
|
+
this.presenceManager.clear();
|
|
1931
|
+
this.supportManager.clear();
|
|
1932
|
+
if (this.wsClient) {
|
|
1933
|
+
this.wsClient.disconnect();
|
|
1934
|
+
}
|
|
1935
|
+
this.connectionState = "disconnected" /* DISCONNECTED */;
|
|
1936
|
+
this.emit("disconnected");
|
|
1937
|
+
this.emit("state", this.connectionState);
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Check if the client is connected
|
|
1941
|
+
*/
|
|
1942
|
+
isConnected() {
|
|
1943
|
+
return this.connectionState === "connected" /* CONNECTED */ && this.wsClient?.isConnected() === true;
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Update the authentication token
|
|
1947
|
+
* Use this when your token expires and you need to refresh it
|
|
1948
|
+
* After updating, call connect() to reconnect with the new token
|
|
1949
|
+
*/
|
|
1950
|
+
updateToken(newToken) {
|
|
1951
|
+
if (!newToken || newToken.length === 0) {
|
|
1952
|
+
throw new Error("Token cannot be empty");
|
|
1953
|
+
}
|
|
1954
|
+
this.tokenManager.setToken(newToken);
|
|
1955
|
+
this.logger.debug("Token updated");
|
|
1956
|
+
if (this.isConnected()) {
|
|
1957
|
+
this.logger.warn("Token updated while connected. Disconnecting to reconnect with new token...");
|
|
1958
|
+
this.disconnect();
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Get the conversations manager
|
|
1963
|
+
* Exposes: sdk.conversations.get(id), sdk.conversations.join(id), etc.
|
|
1964
|
+
*/
|
|
1965
|
+
get conversations() {
|
|
1966
|
+
return {
|
|
1967
|
+
get: (id) => this.conversationManager.get(id),
|
|
1968
|
+
has: (id) => this.conversationManager.has(id),
|
|
1969
|
+
join: (id) => this.conversationManager.join(id, (conversationId) => this.joinConversation(conversationId)),
|
|
1970
|
+
leave: (id) => this.conversationManager.leave(id, (conversationId) => this.leaveConversation(conversationId))
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Get the support manager
|
|
1975
|
+
* Exposes: sdk.support.get(conversationId, role?)
|
|
1976
|
+
*/
|
|
1977
|
+
get support() {
|
|
1978
|
+
return {
|
|
1979
|
+
get: (id, role = "customer") => this.supportManager.get(id, role),
|
|
1980
|
+
has: (id) => this.supportManager.has(id)
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* Join a conversation room (internal method used by ConversationManager)
|
|
1985
|
+
*/
|
|
1986
|
+
async joinConversation(conversationId) {
|
|
1987
|
+
if (!this.isConnected()) {
|
|
1988
|
+
throw new Error("Must be connected before joining a conversation");
|
|
1989
|
+
}
|
|
1990
|
+
if (!conversationId || conversationId.length === 0) {
|
|
1991
|
+
throw new Error("Conversation ID is required");
|
|
1992
|
+
}
|
|
1993
|
+
if (!this.wsClient) {
|
|
1994
|
+
throw new Error("WebSocket client not initialized");
|
|
1995
|
+
}
|
|
1996
|
+
const message = {
|
|
1997
|
+
type: "join",
|
|
1998
|
+
conversation_id: conversationId
|
|
1999
|
+
};
|
|
2000
|
+
try {
|
|
2001
|
+
this.wsClient.send(message);
|
|
2002
|
+
this.logger.debug("Join conversation sent:", conversationId);
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
const sendError = error instanceof Error ? error : new Error("Failed to join conversation");
|
|
2005
|
+
this.emit("error", sendError);
|
|
2006
|
+
throw sendError;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* Leave a conversation room (internal method used by ConversationManager)
|
|
2011
|
+
*/
|
|
2012
|
+
async leaveConversation(conversationId) {
|
|
2013
|
+
if (!this.isConnected()) {
|
|
2014
|
+
throw new Error("Must be connected before leaving a conversation");
|
|
2015
|
+
}
|
|
2016
|
+
if (!conversationId || conversationId.length === 0) {
|
|
2017
|
+
throw new Error("Conversation ID is required");
|
|
2018
|
+
}
|
|
2019
|
+
if (!this.wsClient) {
|
|
2020
|
+
throw new Error("WebSocket client not initialized");
|
|
2021
|
+
}
|
|
2022
|
+
const message = {
|
|
2023
|
+
type: "leave",
|
|
2024
|
+
conversation_id: conversationId
|
|
2025
|
+
};
|
|
2026
|
+
try {
|
|
2027
|
+
this.wsClient.send(message);
|
|
2028
|
+
this.logger.debug("Leave conversation sent:", conversationId);
|
|
2029
|
+
} catch (error) {
|
|
2030
|
+
const sendError = error instanceof Error ? error : new Error("Failed to leave conversation");
|
|
2031
|
+
this.emit("error", sendError);
|
|
2032
|
+
throw sendError;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Rotate the authentication token
|
|
2037
|
+
*/
|
|
2038
|
+
rotateToken(newToken) {
|
|
2039
|
+
this.tokenManager.rotateToken(newToken);
|
|
2040
|
+
this.logger.debug("Token rotated");
|
|
2041
|
+
if (this.isConnected()) {
|
|
2042
|
+
this.logger.warn("Token rotated while connected. Reconnect may be required.");
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Get the token manager (for advanced use cases)
|
|
2047
|
+
*/
|
|
2048
|
+
getTokenManager() {
|
|
2049
|
+
return this.tokenManager;
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
|
|
2053
|
+
// src/utils/retry.ts
|
|
2054
|
+
async function retry(fn, options = {}) {
|
|
2055
|
+
const {
|
|
2056
|
+
maxAttempts = 3,
|
|
2057
|
+
delay = 1e3,
|
|
2058
|
+
backoff = true,
|
|
2059
|
+
onRetry
|
|
2060
|
+
} = options;
|
|
2061
|
+
let lastError = null;
|
|
2062
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2063
|
+
try {
|
|
2064
|
+
return await fn();
|
|
2065
|
+
} catch (error) {
|
|
2066
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
2067
|
+
if (attempt < maxAttempts) {
|
|
2068
|
+
const waitTime = backoff ? delay * Math.pow(2, attempt - 1) : delay;
|
|
2069
|
+
onRetry?.(attempt, lastError);
|
|
2070
|
+
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
throw lastError ?? new Error("Retry failed");
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
// src/utils/guards.ts
|
|
2078
|
+
function isNonEmptyString(value) {
|
|
2079
|
+
return typeof value === "string" && value.length > 0;
|
|
2080
|
+
}
|
|
2081
|
+
function isValidUrl(value) {
|
|
2082
|
+
if (!isNonEmptyString(value)) {
|
|
2083
|
+
return false;
|
|
2084
|
+
}
|
|
2085
|
+
try {
|
|
2086
|
+
new URL(value);
|
|
2087
|
+
return true;
|
|
2088
|
+
} catch {
|
|
2089
|
+
return false;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
function isNumber(value) {
|
|
2093
|
+
return typeof value === "number" && !isNaN(value);
|
|
2094
|
+
}
|
|
2095
|
+
function isPositiveInteger(value) {
|
|
2096
|
+
return isNumber(value) && Number.isInteger(value) && value > 0;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
exports.ChatAfrika = ChatAfrika;
|
|
2100
|
+
exports.ConnectionState = ConnectionState;
|
|
2101
|
+
exports.Conversation = Conversation;
|
|
2102
|
+
exports.ConversationManager = ConversationManager;
|
|
2103
|
+
exports.Logger = Logger;
|
|
2104
|
+
exports.MessageFactory = MessageFactory;
|
|
2105
|
+
exports.MessageManager = MessageManager;
|
|
2106
|
+
exports.PresenceManager = PresenceManager;
|
|
2107
|
+
exports.ReceiptManager = ReceiptManager;
|
|
2108
|
+
exports.SupportManager = SupportManager;
|
|
2109
|
+
exports.SupportSession = SupportSession;
|
|
2110
|
+
exports.TokenManager = TokenManager;
|
|
2111
|
+
exports.TypingManager = TypingManager;
|
|
2112
|
+
exports.WebSocketClient = WebSocketClient;
|
|
2113
|
+
exports.isNonEmptyString = isNonEmptyString;
|
|
2114
|
+
exports.isNumber = isNumber;
|
|
2115
|
+
exports.isPositiveInteger = isPositiveInteger;
|
|
2116
|
+
exports.isValidUrl = isValidUrl;
|
|
2117
|
+
exports.retry = retry;
|
|
2118
|
+
//# sourceMappingURL=index.cjs.map
|
|
2119
|
+
//# sourceMappingURL=index.cjs.map
|