@pocketping/sdk-node 0.1.0 → 1.0.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 +305 -0
- package/dist/index.cjs +884 -0
- package/dist/index.d.cts +421 -0
- package/dist/index.d.ts +202 -3
- package/dist/index.js +408 -48
- package/package.json +33 -5
- package/dist/index.d.mts +0 -222
- package/dist/index.mjs +0 -468
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
MemoryStorage: () => MemoryStorage,
|
|
24
|
+
PocketPing: () => PocketPing
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/pocketping.ts
|
|
29
|
+
var import_crypto = require("crypto");
|
|
30
|
+
var import_ws = require("ws");
|
|
31
|
+
|
|
32
|
+
// src/storage/memory.ts
|
|
33
|
+
var MemoryStorage = class {
|
|
34
|
+
constructor() {
|
|
35
|
+
this.sessions = /* @__PURE__ */ new Map();
|
|
36
|
+
this.messages = /* @__PURE__ */ new Map();
|
|
37
|
+
this.messageById = /* @__PURE__ */ new Map();
|
|
38
|
+
}
|
|
39
|
+
async createSession(session) {
|
|
40
|
+
this.sessions.set(session.id, session);
|
|
41
|
+
this.messages.set(session.id, []);
|
|
42
|
+
}
|
|
43
|
+
async getSession(sessionId) {
|
|
44
|
+
return this.sessions.get(sessionId) ?? null;
|
|
45
|
+
}
|
|
46
|
+
async getSessionByVisitorId(visitorId) {
|
|
47
|
+
const visitorSessions = Array.from(this.sessions.values()).filter(
|
|
48
|
+
(s) => s.visitorId === visitorId
|
|
49
|
+
);
|
|
50
|
+
if (visitorSessions.length === 0) return null;
|
|
51
|
+
return visitorSessions.reduce(
|
|
52
|
+
(latest, s) => s.lastActivity > latest.lastActivity ? s : latest
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
async updateSession(session) {
|
|
56
|
+
this.sessions.set(session.id, session);
|
|
57
|
+
}
|
|
58
|
+
async deleteSession(sessionId) {
|
|
59
|
+
this.sessions.delete(sessionId);
|
|
60
|
+
this.messages.delete(sessionId);
|
|
61
|
+
}
|
|
62
|
+
async saveMessage(message) {
|
|
63
|
+
const sessionMessages = this.messages.get(message.sessionId) ?? [];
|
|
64
|
+
sessionMessages.push(message);
|
|
65
|
+
this.messages.set(message.sessionId, sessionMessages);
|
|
66
|
+
this.messageById.set(message.id, message);
|
|
67
|
+
}
|
|
68
|
+
async getMessages(sessionId, after, limit = 50) {
|
|
69
|
+
const sessionMessages = this.messages.get(sessionId) ?? [];
|
|
70
|
+
let startIndex = 0;
|
|
71
|
+
if (after) {
|
|
72
|
+
const afterIndex = sessionMessages.findIndex((m) => m.id === after);
|
|
73
|
+
if (afterIndex !== -1) {
|
|
74
|
+
startIndex = afterIndex + 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return sessionMessages.slice(startIndex, startIndex + limit);
|
|
78
|
+
}
|
|
79
|
+
async getMessage(messageId) {
|
|
80
|
+
return this.messageById.get(messageId) ?? null;
|
|
81
|
+
}
|
|
82
|
+
async cleanupOldSessions(olderThan) {
|
|
83
|
+
let count = 0;
|
|
84
|
+
for (const [id, session] of this.sessions) {
|
|
85
|
+
if (session.lastActivity < olderThan) {
|
|
86
|
+
this.sessions.delete(id);
|
|
87
|
+
this.messages.delete(id);
|
|
88
|
+
count++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return count;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// src/pocketping.ts
|
|
96
|
+
function getClientIp(req) {
|
|
97
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
98
|
+
if (forwarded) {
|
|
99
|
+
const ip = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0];
|
|
100
|
+
return ip?.trim() ?? "unknown";
|
|
101
|
+
}
|
|
102
|
+
const realIp = req.headers["x-real-ip"];
|
|
103
|
+
if (realIp) {
|
|
104
|
+
return Array.isArray(realIp) ? realIp[0] ?? "unknown" : realIp;
|
|
105
|
+
}
|
|
106
|
+
return req.socket?.remoteAddress ?? "unknown";
|
|
107
|
+
}
|
|
108
|
+
function parseUserAgent(userAgent) {
|
|
109
|
+
if (!userAgent) {
|
|
110
|
+
return { deviceType: void 0, browser: void 0, os: void 0 };
|
|
111
|
+
}
|
|
112
|
+
const ua = userAgent.toLowerCase();
|
|
113
|
+
let deviceType;
|
|
114
|
+
if (["mobile", "android", "iphone", "ipod"].some((x) => ua.includes(x))) {
|
|
115
|
+
deviceType = "mobile";
|
|
116
|
+
} else if (["ipad", "tablet"].some((x) => ua.includes(x))) {
|
|
117
|
+
deviceType = "tablet";
|
|
118
|
+
} else {
|
|
119
|
+
deviceType = "desktop";
|
|
120
|
+
}
|
|
121
|
+
let browser;
|
|
122
|
+
if (ua.includes("firefox")) browser = "Firefox";
|
|
123
|
+
else if (ua.includes("edg")) browser = "Edge";
|
|
124
|
+
else if (ua.includes("chrome")) browser = "Chrome";
|
|
125
|
+
else if (ua.includes("safari")) browser = "Safari";
|
|
126
|
+
else if (ua.includes("opera") || ua.includes("opr")) browser = "Opera";
|
|
127
|
+
let os;
|
|
128
|
+
if (ua.includes("windows")) os = "Windows";
|
|
129
|
+
else if (ua.includes("mac os") || ua.includes("macos")) os = "macOS";
|
|
130
|
+
else if (ua.includes("linux")) os = "Linux";
|
|
131
|
+
else if (ua.includes("android")) os = "Android";
|
|
132
|
+
else if (ua.includes("iphone") || ua.includes("ipad")) os = "iOS";
|
|
133
|
+
return { deviceType, browser, os };
|
|
134
|
+
}
|
|
135
|
+
function parseVersion(version) {
|
|
136
|
+
return version.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
137
|
+
}
|
|
138
|
+
function compareVersions(a, b) {
|
|
139
|
+
const vA = parseVersion(a);
|
|
140
|
+
const vB = parseVersion(b);
|
|
141
|
+
const len = Math.max(vA.length, vB.length);
|
|
142
|
+
for (let i = 0; i < len; i++) {
|
|
143
|
+
const numA = vA[i] ?? 0;
|
|
144
|
+
const numB = vB[i] ?? 0;
|
|
145
|
+
if (numA < numB) return -1;
|
|
146
|
+
if (numA > numB) return 1;
|
|
147
|
+
}
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
var PocketPing = class {
|
|
151
|
+
constructor(config = {}) {
|
|
152
|
+
this.wss = null;
|
|
153
|
+
this.sessionSockets = /* @__PURE__ */ new Map();
|
|
154
|
+
this.operatorOnline = false;
|
|
155
|
+
this.eventHandlers = /* @__PURE__ */ new Map();
|
|
156
|
+
this.config = config;
|
|
157
|
+
this.storage = this.initStorage(config.storage);
|
|
158
|
+
this.bridges = config.bridges ?? [];
|
|
159
|
+
}
|
|
160
|
+
initStorage(storage) {
|
|
161
|
+
if (!storage || storage === "memory") {
|
|
162
|
+
return new MemoryStorage();
|
|
163
|
+
}
|
|
164
|
+
return storage;
|
|
165
|
+
}
|
|
166
|
+
// ─────────────────────────────────────────────────────────────────
|
|
167
|
+
// Express/Connect Middleware
|
|
168
|
+
// ─────────────────────────────────────────────────────────────────
|
|
169
|
+
middleware() {
|
|
170
|
+
return async (req, res, next) => {
|
|
171
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
172
|
+
const path = url.pathname.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
173
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
174
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
175
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-PocketPing-Version");
|
|
176
|
+
res.setHeader("Access-Control-Expose-Headers", "X-PocketPing-Version-Status, X-PocketPing-Min-Version, X-PocketPing-Latest-Version, X-PocketPing-Version-Message");
|
|
177
|
+
if (req.method === "OPTIONS") {
|
|
178
|
+
res.statusCode = 204;
|
|
179
|
+
res.end();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const widgetVersion = req.headers["x-pocketping-version"];
|
|
183
|
+
const versionCheck = this.checkWidgetVersion(widgetVersion);
|
|
184
|
+
this.setVersionHeaders(res, versionCheck);
|
|
185
|
+
if (!versionCheck.canContinue) {
|
|
186
|
+
res.statusCode = 426;
|
|
187
|
+
res.setHeader("Content-Type", "application/json");
|
|
188
|
+
res.end(JSON.stringify({
|
|
189
|
+
error: "Widget version unsupported",
|
|
190
|
+
message: versionCheck.message,
|
|
191
|
+
minVersion: versionCheck.minVersion,
|
|
192
|
+
upgradeUrl: this.config.versionUpgradeUrl || "https://docs.pocketping.io/widget/installation"
|
|
193
|
+
}));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const body = await this.parseBody(req);
|
|
198
|
+
const query = Object.fromEntries(url.searchParams);
|
|
199
|
+
let result;
|
|
200
|
+
let sessionId;
|
|
201
|
+
switch (path) {
|
|
202
|
+
case "connect": {
|
|
203
|
+
const connectReq = body;
|
|
204
|
+
const clientIp = getClientIp(req);
|
|
205
|
+
const userAgent = req.headers["user-agent"];
|
|
206
|
+
const uaInfo = parseUserAgent(connectReq.metadata?.userAgent ?? userAgent);
|
|
207
|
+
if (connectReq.metadata) {
|
|
208
|
+
connectReq.metadata.ip = clientIp;
|
|
209
|
+
connectReq.metadata.deviceType = connectReq.metadata.deviceType ?? uaInfo.deviceType;
|
|
210
|
+
connectReq.metadata.browser = connectReq.metadata.browser ?? uaInfo.browser;
|
|
211
|
+
connectReq.metadata.os = connectReq.metadata.os ?? uaInfo.os;
|
|
212
|
+
} else {
|
|
213
|
+
connectReq.metadata = {
|
|
214
|
+
ip: clientIp,
|
|
215
|
+
userAgent,
|
|
216
|
+
...uaInfo
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const connectResult = await this.handleConnect(connectReq);
|
|
220
|
+
sessionId = connectResult.sessionId;
|
|
221
|
+
result = connectResult;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case "message":
|
|
225
|
+
result = await this.handleMessage(body);
|
|
226
|
+
break;
|
|
227
|
+
case "messages":
|
|
228
|
+
result = await this.handleGetMessages(query);
|
|
229
|
+
break;
|
|
230
|
+
case "typing":
|
|
231
|
+
result = await this.handleTyping(body);
|
|
232
|
+
break;
|
|
233
|
+
case "presence":
|
|
234
|
+
result = await this.handlePresence();
|
|
235
|
+
break;
|
|
236
|
+
case "read":
|
|
237
|
+
result = await this.handleRead(body);
|
|
238
|
+
break;
|
|
239
|
+
case "identify":
|
|
240
|
+
result = await this.handleIdentify(body);
|
|
241
|
+
break;
|
|
242
|
+
default:
|
|
243
|
+
if (next) {
|
|
244
|
+
next();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
res.statusCode = 404;
|
|
248
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
res.setHeader("Content-Type", "application/json");
|
|
252
|
+
res.statusCode = 200;
|
|
253
|
+
res.end(JSON.stringify(result));
|
|
254
|
+
if (sessionId && versionCheck.status !== "ok") {
|
|
255
|
+
setTimeout(() => {
|
|
256
|
+
this.sendVersionWarning(sessionId, versionCheck);
|
|
257
|
+
}, 500);
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.error("[PocketPing] Error:", error);
|
|
261
|
+
res.statusCode = 500;
|
|
262
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
async parseBody(req) {
|
|
267
|
+
if (req.body) return req.body;
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
let data = "";
|
|
270
|
+
req.on("data", (chunk) => data += chunk);
|
|
271
|
+
req.on("end", () => {
|
|
272
|
+
try {
|
|
273
|
+
resolve(data ? JSON.parse(data) : {});
|
|
274
|
+
} catch {
|
|
275
|
+
reject(new Error("Invalid JSON"));
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
req.on("error", reject);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// ─────────────────────────────────────────────────────────────────
|
|
282
|
+
// WebSocket
|
|
283
|
+
// ─────────────────────────────────────────────────────────────────
|
|
284
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
285
|
+
attachWebSocket(server) {
|
|
286
|
+
this.wss = new import_ws.WebSocketServer({
|
|
287
|
+
server,
|
|
288
|
+
path: "/pocketping/stream"
|
|
289
|
+
});
|
|
290
|
+
this.wss.on("connection", (ws, req) => {
|
|
291
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
292
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
293
|
+
if (!sessionId) {
|
|
294
|
+
ws.close(4e3, "sessionId required");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (!this.sessionSockets.has(sessionId)) {
|
|
298
|
+
this.sessionSockets.set(sessionId, /* @__PURE__ */ new Set());
|
|
299
|
+
}
|
|
300
|
+
this.sessionSockets.get(sessionId).add(ws);
|
|
301
|
+
ws.on("close", () => {
|
|
302
|
+
this.sessionSockets.get(sessionId)?.delete(ws);
|
|
303
|
+
});
|
|
304
|
+
ws.on("message", async (data) => {
|
|
305
|
+
try {
|
|
306
|
+
const event = JSON.parse(data.toString());
|
|
307
|
+
await this.handleWebSocketMessage(sessionId, event);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.error("[PocketPing] WS message error:", err);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
async handleWebSocketMessage(sessionId, event) {
|
|
315
|
+
switch (event.type) {
|
|
316
|
+
case "typing":
|
|
317
|
+
this.broadcastToSession(sessionId, {
|
|
318
|
+
type: "typing",
|
|
319
|
+
data: event.data
|
|
320
|
+
});
|
|
321
|
+
break;
|
|
322
|
+
case "event":
|
|
323
|
+
const customEvent = event.data;
|
|
324
|
+
customEvent.sessionId = sessionId;
|
|
325
|
+
await this.handleCustomEvent(sessionId, customEvent);
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async handleCustomEvent(sessionId, event) {
|
|
330
|
+
const session = await this.storage.getSession(sessionId);
|
|
331
|
+
if (!session) {
|
|
332
|
+
console.warn(`[PocketPing] Event received for unknown session: ${sessionId}`);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const handlers = this.eventHandlers.get(event.name);
|
|
336
|
+
if (handlers) {
|
|
337
|
+
for (const handler of handlers) {
|
|
338
|
+
try {
|
|
339
|
+
await handler(event, session);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.error(`[PocketPing] Event handler error for '${event.name}':`, err);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const wildcardHandlers = this.eventHandlers.get("*");
|
|
346
|
+
if (wildcardHandlers) {
|
|
347
|
+
for (const handler of wildcardHandlers) {
|
|
348
|
+
try {
|
|
349
|
+
await handler(event, session);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
console.error(`[PocketPing] Wildcard event handler error:`, err);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
await this.config.onEvent?.(event, session);
|
|
356
|
+
await this.notifyBridgesEvent(event, session);
|
|
357
|
+
this.forwardToWebhook(event, session);
|
|
358
|
+
}
|
|
359
|
+
broadcastToSession(sessionId, event) {
|
|
360
|
+
const sockets = this.sessionSockets.get(sessionId);
|
|
361
|
+
if (!sockets) return;
|
|
362
|
+
const message = JSON.stringify(event);
|
|
363
|
+
for (const ws of sockets) {
|
|
364
|
+
if (ws.readyState === import_ws.WebSocket.OPEN) {
|
|
365
|
+
ws.send(message);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// ─────────────────────────────────────────────────────────────────
|
|
370
|
+
// Protocol Handlers
|
|
371
|
+
// ─────────────────────────────────────────────────────────────────
|
|
372
|
+
async handleConnect(request) {
|
|
373
|
+
let session = null;
|
|
374
|
+
if (request.sessionId) {
|
|
375
|
+
session = await this.storage.getSession(request.sessionId);
|
|
376
|
+
}
|
|
377
|
+
if (!session && this.storage.getSessionByVisitorId) {
|
|
378
|
+
session = await this.storage.getSessionByVisitorId(request.visitorId);
|
|
379
|
+
}
|
|
380
|
+
if (!session) {
|
|
381
|
+
session = {
|
|
382
|
+
id: this.generateId(),
|
|
383
|
+
visitorId: request.visitorId,
|
|
384
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
385
|
+
lastActivity: /* @__PURE__ */ new Date(),
|
|
386
|
+
operatorOnline: this.operatorOnline,
|
|
387
|
+
aiActive: false,
|
|
388
|
+
metadata: request.metadata,
|
|
389
|
+
identity: request.identity
|
|
390
|
+
};
|
|
391
|
+
await this.storage.createSession(session);
|
|
392
|
+
await this.notifyBridges("new_session", session);
|
|
393
|
+
await this.config.onNewSession?.(session);
|
|
394
|
+
} else {
|
|
395
|
+
let needsUpdate = false;
|
|
396
|
+
if (request.metadata) {
|
|
397
|
+
if (session.metadata) {
|
|
398
|
+
request.metadata.ip = session.metadata.ip ?? request.metadata.ip;
|
|
399
|
+
request.metadata.country = session.metadata.country ?? request.metadata.country;
|
|
400
|
+
request.metadata.city = session.metadata.city ?? request.metadata.city;
|
|
401
|
+
}
|
|
402
|
+
session.metadata = request.metadata;
|
|
403
|
+
needsUpdate = true;
|
|
404
|
+
}
|
|
405
|
+
if (request.identity) {
|
|
406
|
+
session.identity = request.identity;
|
|
407
|
+
needsUpdate = true;
|
|
408
|
+
}
|
|
409
|
+
if (needsUpdate) {
|
|
410
|
+
session.lastActivity = /* @__PURE__ */ new Date();
|
|
411
|
+
await this.storage.updateSession(session);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const messages = await this.storage.getMessages(session.id);
|
|
415
|
+
return {
|
|
416
|
+
sessionId: session.id,
|
|
417
|
+
visitorId: session.visitorId,
|
|
418
|
+
operatorOnline: this.operatorOnline,
|
|
419
|
+
welcomeMessage: this.config.welcomeMessage,
|
|
420
|
+
messages
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
async handleMessage(request) {
|
|
424
|
+
const session = await this.storage.getSession(request.sessionId);
|
|
425
|
+
if (!session) {
|
|
426
|
+
throw new Error("Session not found");
|
|
427
|
+
}
|
|
428
|
+
const message = {
|
|
429
|
+
id: this.generateId(),
|
|
430
|
+
sessionId: request.sessionId,
|
|
431
|
+
content: request.content,
|
|
432
|
+
sender: request.sender,
|
|
433
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
434
|
+
replyTo: request.replyTo
|
|
435
|
+
};
|
|
436
|
+
await this.storage.saveMessage(message);
|
|
437
|
+
session.lastActivity = /* @__PURE__ */ new Date();
|
|
438
|
+
await this.storage.updateSession(session);
|
|
439
|
+
if (request.sender === "visitor") {
|
|
440
|
+
await this.notifyBridges("message", message, session);
|
|
441
|
+
}
|
|
442
|
+
this.broadcastToSession(request.sessionId, {
|
|
443
|
+
type: "message",
|
|
444
|
+
data: message
|
|
445
|
+
});
|
|
446
|
+
await this.config.onMessage?.(message, session);
|
|
447
|
+
return {
|
|
448
|
+
messageId: message.id,
|
|
449
|
+
timestamp: message.timestamp.toISOString()
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
async handleGetMessages(request) {
|
|
453
|
+
const limit = Math.min(request.limit ?? 50, 100);
|
|
454
|
+
const messages = await this.storage.getMessages(request.sessionId, request.after, limit + 1);
|
|
455
|
+
return {
|
|
456
|
+
messages: messages.slice(0, limit),
|
|
457
|
+
hasMore: messages.length > limit
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
async handleTyping(request) {
|
|
461
|
+
this.broadcastToSession(request.sessionId, {
|
|
462
|
+
type: "typing",
|
|
463
|
+
data: {
|
|
464
|
+
sessionId: request.sessionId,
|
|
465
|
+
sender: request.sender,
|
|
466
|
+
isTyping: request.isTyping ?? true
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
return { ok: true };
|
|
470
|
+
}
|
|
471
|
+
async handlePresence() {
|
|
472
|
+
return {
|
|
473
|
+
online: this.operatorOnline,
|
|
474
|
+
aiEnabled: !!this.config.ai,
|
|
475
|
+
aiActiveAfter: this.config.aiTakeoverDelay ?? 300
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
async handleRead(request) {
|
|
479
|
+
const session = await this.storage.getSession(request.sessionId);
|
|
480
|
+
if (!session) {
|
|
481
|
+
throw new Error("Session not found");
|
|
482
|
+
}
|
|
483
|
+
const status = request.status ?? "read";
|
|
484
|
+
const now = /* @__PURE__ */ new Date();
|
|
485
|
+
let updated = 0;
|
|
486
|
+
const messages = await this.storage.getMessages(request.sessionId);
|
|
487
|
+
for (const msg of messages) {
|
|
488
|
+
if (request.messageIds.includes(msg.id)) {
|
|
489
|
+
msg.status = status;
|
|
490
|
+
if (status === "delivered") {
|
|
491
|
+
msg.deliveredAt = now;
|
|
492
|
+
} else if (status === "read") {
|
|
493
|
+
msg.deliveredAt = msg.deliveredAt ?? now;
|
|
494
|
+
msg.readAt = now;
|
|
495
|
+
}
|
|
496
|
+
await this.storage.saveMessage(msg);
|
|
497
|
+
updated++;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
this.broadcastToSession(request.sessionId, {
|
|
501
|
+
type: "read",
|
|
502
|
+
data: {
|
|
503
|
+
messageIds: request.messageIds,
|
|
504
|
+
status,
|
|
505
|
+
deliveredAt: status === "delivered" ? now.toISOString() : void 0,
|
|
506
|
+
readAt: status === "read" ? now.toISOString() : void 0
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
await this.notifyBridgesRead(request.sessionId, request.messageIds, status, session);
|
|
510
|
+
return { updated };
|
|
511
|
+
}
|
|
512
|
+
// ─────────────────────────────────────────────────────────────────
|
|
513
|
+
// User Identity
|
|
514
|
+
// ─────────────────────────────────────────────────────────────────
|
|
515
|
+
/**
|
|
516
|
+
* Handle user identification from widget
|
|
517
|
+
* Called when visitor calls PocketPing.identify()
|
|
518
|
+
*/
|
|
519
|
+
async handleIdentify(request) {
|
|
520
|
+
if (!request.identity?.id) {
|
|
521
|
+
throw new Error("identity.id is required");
|
|
522
|
+
}
|
|
523
|
+
const session = await this.storage.getSession(request.sessionId);
|
|
524
|
+
if (!session) {
|
|
525
|
+
throw new Error("Session not found");
|
|
526
|
+
}
|
|
527
|
+
session.identity = request.identity;
|
|
528
|
+
session.lastActivity = /* @__PURE__ */ new Date();
|
|
529
|
+
await this.storage.updateSession(session);
|
|
530
|
+
await this.notifyBridgesIdentity(session);
|
|
531
|
+
await this.config.onIdentify?.(session);
|
|
532
|
+
this.forwardIdentityToWebhook(session);
|
|
533
|
+
return { ok: true };
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Get a session by ID
|
|
537
|
+
*/
|
|
538
|
+
async getSession(sessionId) {
|
|
539
|
+
return this.storage.getSession(sessionId);
|
|
540
|
+
}
|
|
541
|
+
// ─────────────────────────────────────────────────────────────────
|
|
542
|
+
// Operator Actions (for bridges)
|
|
543
|
+
// ─────────────────────────────────────────────────────────────────
|
|
544
|
+
async sendOperatorMessage(sessionId, content) {
|
|
545
|
+
const response = await this.handleMessage({
|
|
546
|
+
sessionId,
|
|
547
|
+
content,
|
|
548
|
+
sender: "operator"
|
|
549
|
+
});
|
|
550
|
+
const message = {
|
|
551
|
+
id: response.messageId,
|
|
552
|
+
sessionId,
|
|
553
|
+
content,
|
|
554
|
+
sender: "operator",
|
|
555
|
+
timestamp: new Date(response.timestamp)
|
|
556
|
+
};
|
|
557
|
+
return message;
|
|
558
|
+
}
|
|
559
|
+
setOperatorOnline(online) {
|
|
560
|
+
this.operatorOnline = online;
|
|
561
|
+
for (const sessionId of this.sessionSockets.keys()) {
|
|
562
|
+
this.broadcastToSession(sessionId, {
|
|
563
|
+
type: "presence",
|
|
564
|
+
data: { online }
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// ─────────────────────────────────────────────────────────────────
|
|
569
|
+
// Custom Events (bidirectional)
|
|
570
|
+
// ─────────────────────────────────────────────────────────────────
|
|
571
|
+
/**
|
|
572
|
+
* Subscribe to custom events from widgets
|
|
573
|
+
* @param eventName - The name of the event to listen for, or '*' for all events
|
|
574
|
+
* @param handler - Callback function when event is received
|
|
575
|
+
* @returns Unsubscribe function
|
|
576
|
+
* @example
|
|
577
|
+
* // Listen for specific event
|
|
578
|
+
* pp.onEvent('clicked_pricing', async (event, session) => {
|
|
579
|
+
* console.log(`User ${session.visitorId} clicked pricing: ${event.data?.plan}`)
|
|
580
|
+
* })
|
|
581
|
+
*
|
|
582
|
+
* // Listen for all events
|
|
583
|
+
* pp.onEvent('*', async (event, session) => {
|
|
584
|
+
* console.log(`Event: ${event.name}`, event.data)
|
|
585
|
+
* })
|
|
586
|
+
*/
|
|
587
|
+
onEvent(eventName, handler) {
|
|
588
|
+
if (!this.eventHandlers.has(eventName)) {
|
|
589
|
+
this.eventHandlers.set(eventName, /* @__PURE__ */ new Set());
|
|
590
|
+
}
|
|
591
|
+
this.eventHandlers.get(eventName).add(handler);
|
|
592
|
+
return () => {
|
|
593
|
+
this.eventHandlers.get(eventName)?.delete(handler);
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Unsubscribe from a custom event
|
|
598
|
+
* @param eventName - The name of the event
|
|
599
|
+
* @param handler - The handler to remove
|
|
600
|
+
*/
|
|
601
|
+
offEvent(eventName, handler) {
|
|
602
|
+
this.eventHandlers.get(eventName)?.delete(handler);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Send a custom event to a specific widget/session
|
|
606
|
+
* @param sessionId - The session ID to send the event to
|
|
607
|
+
* @param eventName - The name of the event
|
|
608
|
+
* @param data - Optional payload to send with the event
|
|
609
|
+
* @example
|
|
610
|
+
* // Send a promotion offer to a specific user
|
|
611
|
+
* pp.emitEvent('session-123', 'show_offer', {
|
|
612
|
+
* discount: 20,
|
|
613
|
+
* code: 'SAVE20',
|
|
614
|
+
* message: 'Special offer just for you!'
|
|
615
|
+
* })
|
|
616
|
+
*/
|
|
617
|
+
emitEvent(sessionId, eventName, data) {
|
|
618
|
+
const event = {
|
|
619
|
+
name: eventName,
|
|
620
|
+
data,
|
|
621
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
622
|
+
sessionId
|
|
623
|
+
};
|
|
624
|
+
this.broadcastToSession(sessionId, {
|
|
625
|
+
type: "event",
|
|
626
|
+
data: event
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Broadcast a custom event to all connected widgets
|
|
631
|
+
* @param eventName - The name of the event
|
|
632
|
+
* @param data - Optional payload to send with the event
|
|
633
|
+
* @example
|
|
634
|
+
* // Notify all users about maintenance
|
|
635
|
+
* pp.broadcastEvent('maintenance_warning', {
|
|
636
|
+
* message: 'Site will be down for maintenance in 5 minutes'
|
|
637
|
+
* })
|
|
638
|
+
*/
|
|
639
|
+
broadcastEvent(eventName, data) {
|
|
640
|
+
const event = {
|
|
641
|
+
name: eventName,
|
|
642
|
+
data,
|
|
643
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
644
|
+
};
|
|
645
|
+
for (const sessionId of this.sessionSockets.keys()) {
|
|
646
|
+
event.sessionId = sessionId;
|
|
647
|
+
this.broadcastToSession(sessionId, {
|
|
648
|
+
type: "event",
|
|
649
|
+
data: event
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Process a custom event server-side (runs handlers, bridges, webhooks)
|
|
655
|
+
* Useful for server-side automation or triggering events programmatically
|
|
656
|
+
* @param sessionId - The session ID to associate with the event
|
|
657
|
+
* @param eventName - The name of the event
|
|
658
|
+
* @param data - Optional payload for the event
|
|
659
|
+
* @example
|
|
660
|
+
* // Trigger event from backend logic (e.g., after purchase)
|
|
661
|
+
* await pp.triggerEvent('session-123', 'purchase_completed', {
|
|
662
|
+
* orderId: 'order-456',
|
|
663
|
+
* amount: 99.99
|
|
664
|
+
* })
|
|
665
|
+
*/
|
|
666
|
+
async triggerEvent(sessionId, eventName, data) {
|
|
667
|
+
const event = {
|
|
668
|
+
name: eventName,
|
|
669
|
+
data,
|
|
670
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
671
|
+
sessionId
|
|
672
|
+
};
|
|
673
|
+
await this.handleCustomEvent(sessionId, event);
|
|
674
|
+
}
|
|
675
|
+
// ─────────────────────────────────────────────────────────────────
|
|
676
|
+
// Bridges
|
|
677
|
+
// ─────────────────────────────────────────────────────────────────
|
|
678
|
+
async notifyBridges(event, ...args) {
|
|
679
|
+
for (const bridge of this.bridges) {
|
|
680
|
+
try {
|
|
681
|
+
switch (event) {
|
|
682
|
+
case "new_session":
|
|
683
|
+
await bridge.onNewSession?.(args[0]);
|
|
684
|
+
break;
|
|
685
|
+
case "message":
|
|
686
|
+
await bridge.onVisitorMessage?.(args[0], args[1]);
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
} catch (err) {
|
|
690
|
+
console.error(`[PocketPing] Bridge ${bridge.name} error:`, err);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
async notifyBridgesRead(sessionId, messageIds, status, session) {
|
|
695
|
+
for (const bridge of this.bridges) {
|
|
696
|
+
try {
|
|
697
|
+
await bridge.onMessageRead?.(sessionId, messageIds, status, session);
|
|
698
|
+
} catch (err) {
|
|
699
|
+
console.error(`[PocketPing] Bridge ${bridge.name} read notification error:`, err);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async notifyBridgesEvent(event, session) {
|
|
704
|
+
for (const bridge of this.bridges) {
|
|
705
|
+
try {
|
|
706
|
+
await bridge.onCustomEvent?.(event, session);
|
|
707
|
+
} catch (err) {
|
|
708
|
+
console.error(`[PocketPing] Bridge ${bridge.name} event notification error:`, err);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
async notifyBridgesIdentity(session) {
|
|
713
|
+
for (const bridge of this.bridges) {
|
|
714
|
+
try {
|
|
715
|
+
await bridge.onIdentityUpdate?.(session);
|
|
716
|
+
} catch (err) {
|
|
717
|
+
console.error(`[PocketPing] Bridge ${bridge.name} identity notification error:`, err);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
// ─────────────────────────────────────────────────────────────────
|
|
722
|
+
// Webhook Forwarding
|
|
723
|
+
// ─────────────────────────────────────────────────────────────────
|
|
724
|
+
/**
|
|
725
|
+
* Forward custom event to configured webhook URL (non-blocking)
|
|
726
|
+
* Used for integrations with Zapier, Make, n8n, or custom backends
|
|
727
|
+
*/
|
|
728
|
+
forwardToWebhook(event, session) {
|
|
729
|
+
if (!this.config.webhookUrl) return;
|
|
730
|
+
const payload = {
|
|
731
|
+
event,
|
|
732
|
+
session: {
|
|
733
|
+
id: session.id,
|
|
734
|
+
visitorId: session.visitorId,
|
|
735
|
+
metadata: session.metadata,
|
|
736
|
+
identity: session.identity
|
|
737
|
+
},
|
|
738
|
+
sentAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
739
|
+
};
|
|
740
|
+
const body = JSON.stringify(payload);
|
|
741
|
+
const headers = {
|
|
742
|
+
"Content-Type": "application/json"
|
|
743
|
+
};
|
|
744
|
+
if (this.config.webhookSecret) {
|
|
745
|
+
const signature = (0, import_crypto.createHmac)("sha256", this.config.webhookSecret).update(body).digest("hex");
|
|
746
|
+
headers["X-PocketPing-Signature"] = `sha256=${signature}`;
|
|
747
|
+
}
|
|
748
|
+
const timeout = this.config.webhookTimeout ?? 5e3;
|
|
749
|
+
const controller = new AbortController();
|
|
750
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
751
|
+
fetch(this.config.webhookUrl, {
|
|
752
|
+
method: "POST",
|
|
753
|
+
headers,
|
|
754
|
+
body,
|
|
755
|
+
signal: controller.signal
|
|
756
|
+
}).then((response) => {
|
|
757
|
+
clearTimeout(timeoutId);
|
|
758
|
+
if (!response.ok) {
|
|
759
|
+
console.error(`[PocketPing] Webhook returned ${response.status}: ${response.statusText}`);
|
|
760
|
+
}
|
|
761
|
+
}).catch((err) => {
|
|
762
|
+
clearTimeout(timeoutId);
|
|
763
|
+
if (err.name === "AbortError") {
|
|
764
|
+
console.error(`[PocketPing] Webhook timed out after ${timeout}ms`);
|
|
765
|
+
} else {
|
|
766
|
+
console.error(`[PocketPing] Webhook error:`, err.message);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Forward identity update to webhook as a special event
|
|
772
|
+
*/
|
|
773
|
+
forwardIdentityToWebhook(session) {
|
|
774
|
+
if (!this.config.webhookUrl || !session.identity) return;
|
|
775
|
+
const event = {
|
|
776
|
+
name: "identify",
|
|
777
|
+
data: session.identity,
|
|
778
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
779
|
+
sessionId: session.id
|
|
780
|
+
};
|
|
781
|
+
this.forwardToWebhook(event, session);
|
|
782
|
+
}
|
|
783
|
+
addBridge(bridge) {
|
|
784
|
+
this.bridges.push(bridge);
|
|
785
|
+
bridge.init?.(this);
|
|
786
|
+
}
|
|
787
|
+
// ─────────────────────────────────────────────────────────────────
|
|
788
|
+
// Utilities
|
|
789
|
+
// ─────────────────────────────────────────────────────────────────
|
|
790
|
+
generateId() {
|
|
791
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
|
|
792
|
+
}
|
|
793
|
+
getStorage() {
|
|
794
|
+
return this.storage;
|
|
795
|
+
}
|
|
796
|
+
// ─────────────────────────────────────────────────────────────────
|
|
797
|
+
// Version Management
|
|
798
|
+
// ─────────────────────────────────────────────────────────────────
|
|
799
|
+
/**
|
|
800
|
+
* Check widget version against configured min/latest versions
|
|
801
|
+
* @param widgetVersion - Version from X-PocketPing-Version header
|
|
802
|
+
* @returns Version check result with status and headers to set
|
|
803
|
+
*/
|
|
804
|
+
checkWidgetVersion(widgetVersion) {
|
|
805
|
+
if (!widgetVersion) {
|
|
806
|
+
return {
|
|
807
|
+
status: "ok",
|
|
808
|
+
canContinue: true
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
const { minWidgetVersion, latestWidgetVersion } = this.config;
|
|
812
|
+
if (!minWidgetVersion && !latestWidgetVersion) {
|
|
813
|
+
return {
|
|
814
|
+
status: "ok",
|
|
815
|
+
canContinue: true
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
let status = "ok";
|
|
819
|
+
let message;
|
|
820
|
+
let canContinue = true;
|
|
821
|
+
if (minWidgetVersion && compareVersions(widgetVersion, minWidgetVersion) < 0) {
|
|
822
|
+
status = "unsupported";
|
|
823
|
+
message = this.config.versionWarningMessage || `Widget version ${widgetVersion} is no longer supported. Minimum version: ${minWidgetVersion}`;
|
|
824
|
+
canContinue = false;
|
|
825
|
+
} else if (latestWidgetVersion && compareVersions(widgetVersion, latestWidgetVersion) < 0) {
|
|
826
|
+
const majorDiff = parseVersion(latestWidgetVersion)[0] - parseVersion(widgetVersion)[0];
|
|
827
|
+
if (majorDiff >= 1) {
|
|
828
|
+
status = "deprecated";
|
|
829
|
+
message = this.config.versionWarningMessage || `Widget version ${widgetVersion} is deprecated. Please update to ${latestWidgetVersion}`;
|
|
830
|
+
} else {
|
|
831
|
+
status = "outdated";
|
|
832
|
+
message = `A newer widget version ${latestWidgetVersion} is available`;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
status,
|
|
837
|
+
message,
|
|
838
|
+
minVersion: minWidgetVersion,
|
|
839
|
+
latestVersion: latestWidgetVersion,
|
|
840
|
+
canContinue
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Set version warning headers on HTTP response
|
|
845
|
+
*/
|
|
846
|
+
setVersionHeaders(res, versionCheck) {
|
|
847
|
+
if (versionCheck.status !== "ok") {
|
|
848
|
+
res.setHeader("X-PocketPing-Version-Status", versionCheck.status);
|
|
849
|
+
if (versionCheck.minVersion) {
|
|
850
|
+
res.setHeader("X-PocketPing-Min-Version", versionCheck.minVersion);
|
|
851
|
+
}
|
|
852
|
+
if (versionCheck.latestVersion) {
|
|
853
|
+
res.setHeader("X-PocketPing-Latest-Version", versionCheck.latestVersion);
|
|
854
|
+
}
|
|
855
|
+
if (versionCheck.message) {
|
|
856
|
+
res.setHeader("X-PocketPing-Version-Message", versionCheck.message);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Send version warning via WebSocket to a session
|
|
862
|
+
*/
|
|
863
|
+
sendVersionWarning(sessionId, versionCheck) {
|
|
864
|
+
if (versionCheck.status === "ok") return;
|
|
865
|
+
this.broadcastToSession(sessionId, {
|
|
866
|
+
type: "version_warning",
|
|
867
|
+
data: {
|
|
868
|
+
severity: versionCheck.status === "unsupported" ? "error" : versionCheck.status === "deprecated" ? "warning" : "info",
|
|
869
|
+
message: versionCheck.message,
|
|
870
|
+
currentVersion: "unknown",
|
|
871
|
+
// Will be filled by widget
|
|
872
|
+
minVersion: versionCheck.minVersion,
|
|
873
|
+
latestVersion: versionCheck.latestVersion,
|
|
874
|
+
canContinue: versionCheck.canContinue,
|
|
875
|
+
upgradeUrl: this.config.versionUpgradeUrl || "https://docs.pocketping.io/widget/installation"
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
881
|
+
0 && (module.exports = {
|
|
882
|
+
MemoryStorage,
|
|
883
|
+
PocketPing
|
|
884
|
+
});
|