@coventia/softphone 0.1.0 → 0.2.1
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/dist/{call-BTYlT2SU.d.cts → call-VKq1I8yP.d.cts} +158 -5
- package/dist/{call-BTYlT2SU.d.ts → call-VKq1I8yP.d.ts} +158 -5
- package/dist/{chunk-N2N2EQQJ.js → chunk-7R6LOOAI.js} +4 -2
- package/dist/chunk-7R6LOOAI.js.map +1 -0
- package/dist/{chunk-5BGEHMPP.js → chunk-EYEDPD7Y.js} +146 -20
- package/dist/chunk-EYEDPD7Y.js.map +1 -0
- package/dist/{chunk-3V2L6UTK.js → chunk-LMJXX4YM.js} +17 -3
- package/dist/chunk-LMJXX4YM.js.map +1 -0
- package/dist/element/index.d.ts +162 -7
- package/dist/element/index.js +866 -421
- package/dist/element/index.js.map +1 -1
- package/dist/{engine-D9nbeY08.d.cts → engine-BlQBo2Ys.d.cts} +1 -1
- package/dist/{engine-Doa6Brkm.d.ts → engine-C0BOkIcW.d.ts} +1 -1
- package/dist/index.cjs +586 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +335 -8
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +15 -0
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +14 -3
- package/dist/react/index.d.ts +14 -3
- package/dist/react/index.js +2 -2
- package/dist/{softphone-DhuhcKCy.d.cts → softphone-Cyq3hiIM.d.ts} +17 -2
- package/dist/{softphone-DHmhotbf.d.ts → softphone-DlGV12Xp.d.cts} +17 -2
- package/dist/softphone.css +477 -35
- package/dist/{sources-N4BOEDRL.js → sources-IRKNQPWW.js} +108 -5
- package/dist/sources-IRKNQPWW.js.map +1 -0
- package/dist/testing/index.cjs +41 -5
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.d.cts +8 -2
- package/dist/testing/index.d.ts +8 -2
- package/dist/testing/index.js +41 -5
- package/dist/testing/index.js.map +1 -1
- package/dist/ui/index.cjs +846 -410
- package/dist/ui/index.cjs.map +1 -1
- package/dist/ui/index.d.cts +17 -6
- package/dist/ui/index.d.ts +17 -6
- package/dist/ui/index.js +878 -456
- package/dist/ui/index.js.map +1 -1
- package/dist/zadarma/index.cjs +144 -18
- package/dist/zadarma/index.cjs.map +1 -1
- package/dist/zadarma/index.d.cts +2 -2
- package/dist/zadarma/index.d.ts +2 -2
- package/dist/zadarma/index.js +1 -1
- package/dist/zadarma-engine-L5DEFXUS.js +5 -0
- package/dist/{zadarma-engine-DN2SZSQ3.js.map → zadarma-engine-L5DEFXUS.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-3V2L6UTK.js.map +0 -1
- package/dist/chunk-5BGEHMPP.js.map +0 -1
- package/dist/chunk-N2N2EQQJ.js.map +0 -1
- package/dist/sources-N4BOEDRL.js.map +0 -1
- package/dist/zadarma-engine-DN2SZSQ3.js +0 -5
package/dist/index.cjs
CHANGED
|
@@ -92,7 +92,7 @@ var init_token_cache = __esm({
|
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
// src/coventia/api.ts
|
|
95
|
-
function
|
|
95
|
+
function createRequest(config, method) {
|
|
96
96
|
const doFetch = config.fetch ? config.fetch : (...args) => globalThis.fetch(...args);
|
|
97
97
|
const base = config.baseUrl.replace(/\/+$/, "");
|
|
98
98
|
async function attempt(path, params, signal) {
|
|
@@ -103,6 +103,7 @@ function createGet(config) {
|
|
|
103
103
|
}
|
|
104
104
|
const query = qs.toString();
|
|
105
105
|
const res = await doFetch(`${base}${path}${query ? `?${query}` : ""}`, {
|
|
106
|
+
method,
|
|
106
107
|
// The RAW token, no `Bearer ` prefix — see `VoiceToken.token` (SPEC §6.1).
|
|
107
108
|
headers: { Authorization: token },
|
|
108
109
|
...signal ? { signal } : {}
|
|
@@ -115,7 +116,7 @@ function createGet(config) {
|
|
|
115
116
|
}
|
|
116
117
|
return body;
|
|
117
118
|
}
|
|
118
|
-
return async function
|
|
119
|
+
return async function request(path, params = {}, signal) {
|
|
119
120
|
try {
|
|
120
121
|
return await attempt(path, params, signal);
|
|
121
122
|
} catch (error) {
|
|
@@ -125,6 +126,12 @@ function createGet(config) {
|
|
|
125
126
|
}
|
|
126
127
|
};
|
|
127
128
|
}
|
|
129
|
+
function createGet(config) {
|
|
130
|
+
return createRequest(config, "GET");
|
|
131
|
+
}
|
|
132
|
+
function createPost(config) {
|
|
133
|
+
return createRequest(config, "POST");
|
|
134
|
+
}
|
|
128
135
|
var ApiError;
|
|
129
136
|
var init_api = __esm({
|
|
130
137
|
"src/coventia/api.ts"() {
|
|
@@ -167,6 +174,7 @@ function resolverFromContacts(contacts) {
|
|
|
167
174
|
}
|
|
168
175
|
function coventiaSources(config) {
|
|
169
176
|
const get = createGet(config);
|
|
177
|
+
const post = createPost(config);
|
|
170
178
|
const base = config.basePath ?? "/phone";
|
|
171
179
|
const org = config.organizationId ? { organization_id: config.organizationId } : {};
|
|
172
180
|
const coventiaContacts = async ({ search, skip, limit }) => {
|
|
@@ -251,12 +259,93 @@ function coventiaSources(config) {
|
|
|
251
259
|
people: (body.people ?? []).map(toAgent)
|
|
252
260
|
};
|
|
253
261
|
};
|
|
262
|
+
const coventiaTransferOutcomes = async ({ since }) => {
|
|
263
|
+
const body = await get(
|
|
264
|
+
`${base}/transfers/outcomes`,
|
|
265
|
+
{ ...org, since }
|
|
266
|
+
);
|
|
267
|
+
return {
|
|
268
|
+
cursor: body.cursor ?? "0",
|
|
269
|
+
outcomes: (body.outcomes ?? []).map(toTransferOutcome)
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
const coventiaPresence = (callbacks) => {
|
|
273
|
+
const WS = config.WebSocket ?? globalThis.WebSocket;
|
|
274
|
+
return new Promise((resolve2, reject) => {
|
|
275
|
+
void (async () => {
|
|
276
|
+
let ticket;
|
|
277
|
+
try {
|
|
278
|
+
const body = await post(`${base}/presence/ticket`, org);
|
|
279
|
+
if (!body.ticket) throw new Error("presence ticket mint returned no ticket");
|
|
280
|
+
ticket = body.ticket;
|
|
281
|
+
} catch (error) {
|
|
282
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const wsUrl = `${toWebSocketUrl(config.baseUrl)}${base}/presence?ticket=${encodeURIComponent(ticket)}`;
|
|
286
|
+
const ws = new WS(wsUrl);
|
|
287
|
+
let settled = false;
|
|
288
|
+
ws.onopen = () => {
|
|
289
|
+
if (settled) return;
|
|
290
|
+
settled = true;
|
|
291
|
+
resolve2({
|
|
292
|
+
reportCallState: (onCall) => {
|
|
293
|
+
if (ws.readyState !== WS.OPEN) return;
|
|
294
|
+
ws.send(JSON.stringify({ type: "call-state", onCall }));
|
|
295
|
+
},
|
|
296
|
+
bye: () => {
|
|
297
|
+
if (ws.readyState !== WS.OPEN) return;
|
|
298
|
+
ws.send(JSON.stringify({ type: "bye" }));
|
|
299
|
+
},
|
|
300
|
+
close: () => {
|
|
301
|
+
ws.onclose = null;
|
|
302
|
+
ws.onerror = null;
|
|
303
|
+
ws.close();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
};
|
|
307
|
+
ws.onmessage = (event) => {
|
|
308
|
+
let data;
|
|
309
|
+
try {
|
|
310
|
+
data = JSON.parse(String(event.data));
|
|
311
|
+
} catch {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const msg = data;
|
|
315
|
+
if (msg.type === "snapshot") {
|
|
316
|
+
callbacks.onSnapshot((msg.agents ?? []).map(toPresenceEntry));
|
|
317
|
+
} else if (msg.type === "delta" && msg.agent) {
|
|
318
|
+
callbacks.onDelta(toPresenceEntry(msg.agent));
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
ws.onclose = () => {
|
|
322
|
+
if (!settled) {
|
|
323
|
+
settled = true;
|
|
324
|
+
reject(new Error("presence socket closed before opening"));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
callbacks.onClose();
|
|
328
|
+
};
|
|
329
|
+
ws.onerror = () => {
|
|
330
|
+
};
|
|
331
|
+
})();
|
|
332
|
+
});
|
|
333
|
+
};
|
|
254
334
|
const contacts = config.contacts ?? coventiaContacts;
|
|
255
335
|
const resolve = config.resolveContact ?? (config.contacts ? resolverFromContacts(config.contacts) : coventiaResolve);
|
|
256
336
|
const history = withNameResolution(rawHistory, resolve);
|
|
257
|
-
|
|
337
|
+
const transferOutcomes = config.transferOutcomes ?? coventiaTransferOutcomes;
|
|
338
|
+
const presence = config.presence ?? coventiaPresence;
|
|
339
|
+
return {
|
|
340
|
+
contacts,
|
|
341
|
+
history,
|
|
342
|
+
resolveContact: resolve,
|
|
343
|
+
listAgents: coventiaListAgents,
|
|
344
|
+
transferOutcomes,
|
|
345
|
+
presence
|
|
346
|
+
};
|
|
258
347
|
}
|
|
259
|
-
var toContact, toDirection, otherParty, isMissed, toOutcome, toDate;
|
|
348
|
+
var toContact, toDirection, otherParty, isMissed, toOutcome, toDate, toTransferOutcome, toPresenceState, toPresenceEntry, toWebSocketUrl;
|
|
260
349
|
var init_sources2 = __esm({
|
|
261
350
|
"src/coventia/sources.ts"() {
|
|
262
351
|
init_sources();
|
|
@@ -291,6 +380,20 @@ var init_sources2 = __esm({
|
|
|
291
380
|
const d = new Date(raw);
|
|
292
381
|
return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date(0) : d;
|
|
293
382
|
};
|
|
383
|
+
toTransferOutcome = (row) => ({
|
|
384
|
+
id: row.id ?? "",
|
|
385
|
+
target: row.target ?? "",
|
|
386
|
+
status: row.status === "connected" ? "connected" : "not-connected",
|
|
387
|
+
at: toDate(row.at)
|
|
388
|
+
});
|
|
389
|
+
toPresenceState = (raw) => raw === "disponible" || raw === "en_llamada" ? raw : "desconectado";
|
|
390
|
+
toPresenceEntry = (row) => ({
|
|
391
|
+
userId: row.userId ?? "",
|
|
392
|
+
extension: row.extension ?? "",
|
|
393
|
+
name: row.name ?? null,
|
|
394
|
+
state: toPresenceState(row.state)
|
|
395
|
+
});
|
|
396
|
+
toWebSocketUrl = (httpUrl) => httpUrl.replace(/^http/, "ws").replace(/\/+$/, "");
|
|
294
397
|
}
|
|
295
398
|
});
|
|
296
399
|
|
|
@@ -746,6 +849,14 @@ var init_ua = __esm({
|
|
|
746
849
|
#uaInFlight = null;
|
|
747
850
|
#domain = "";
|
|
748
851
|
#activeCall = null;
|
|
852
|
+
/**
|
|
853
|
+
* The consult leg from `startConsult` (SPEC §5.6), if one is live on this UA right now.
|
|
854
|
+
*
|
|
855
|
+
* Tracked separately from `#activeCall` so `#maybeTeardownUA` can tell "both legs are actually gone"
|
|
856
|
+
* apart from "the main leg ended, but a consult is still up" — see that method's own comment for the
|
|
857
|
+
* bug this exists to prevent.
|
|
858
|
+
*/
|
|
859
|
+
#consultCall = null;
|
|
749
860
|
/** The chosen microphone, or `null` for the browser default. Read fresh by every new leg's
|
|
750
861
|
* `mediaConstraints`, and pushed live to `#activeCall` by `setInputDevice`. */
|
|
751
862
|
#inputDeviceId = null;
|
|
@@ -864,9 +975,7 @@ var init_ua = __esm({
|
|
|
864
975
|
const ms = this.provider.quirks?.noInviteTeardownMs;
|
|
865
976
|
if (!ms) return;
|
|
866
977
|
this.#clearNoInviteTimer();
|
|
867
|
-
this.#noInviteTimer = setTimeout(() =>
|
|
868
|
-
if (!this.#activeCall) this.#teardownUA();
|
|
869
|
-
}, ms);
|
|
978
|
+
this.#noInviteTimer = setTimeout(() => this.#maybeTeardownUA(), ms);
|
|
870
979
|
}
|
|
871
980
|
/**
|
|
872
981
|
* Rebuild the incoming-signal channel with fresh credentials, after a registration failure.
|
|
@@ -988,6 +1097,7 @@ var init_ua = __esm({
|
|
|
988
1097
|
}
|
|
989
1098
|
this.#ua = null;
|
|
990
1099
|
this.#activeCall = null;
|
|
1100
|
+
this.#consultCall = null;
|
|
991
1101
|
}
|
|
992
1102
|
/** Place a call once the UA is registered (its 200 OK for REGISTER has arrived). */
|
|
993
1103
|
/**
|
|
@@ -1030,7 +1140,7 @@ var init_ua = __esm({
|
|
|
1030
1140
|
const code = missed ? "call/missed" : CALL_END_CODES[event.reason];
|
|
1031
1141
|
if (code) this.#reportError(code, event.cause);
|
|
1032
1142
|
if (this.#activeCall === call) this.#activeCall = null;
|
|
1033
|
-
|
|
1143
|
+
this.#maybeTeardownUA();
|
|
1034
1144
|
}
|
|
1035
1145
|
});
|
|
1036
1146
|
return call;
|
|
@@ -1041,12 +1151,38 @@ var init_ua = __esm({
|
|
|
1041
1151
|
mediaConstraints: buildAudioConstraints(this.#inputDeviceId),
|
|
1042
1152
|
pcConfig: PC_CONFIG
|
|
1043
1153
|
});
|
|
1044
|
-
|
|
1154
|
+
const consultCall = new SipEngineCall(consultSession, {
|
|
1045
1155
|
id: nextCallId(),
|
|
1046
1156
|
buildTarget: (x) => this.#buildTarget(x),
|
|
1047
1157
|
placeConsult: () => Promise.reject(new Error("cannot consult from a consult leg")),
|
|
1048
1158
|
iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS
|
|
1049
1159
|
});
|
|
1160
|
+
this.#consultCall = consultCall;
|
|
1161
|
+
consultCall.onEvent((event) => {
|
|
1162
|
+
if (event.type === "ended") {
|
|
1163
|
+
if (this.#consultCall === consultCall) this.#consultCall = null;
|
|
1164
|
+
this.#maybeTeardownUA();
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
return consultCall;
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* On-demand registration (SPEC §3.3): the UA is disposable once NOTHING is left on it — but "the
|
|
1171
|
+
* main leg ended" (or "no INVITE followed this push") is not the same fact as "nothing is left."
|
|
1172
|
+
* `completeTransfer()` (SPEC §5.6) ends the main leg via a REFER…Replaces BYE *while the consult leg
|
|
1173
|
+
* is still what the PBX needs to bridge the caller onto* — tearing the UA down (and with it, EVERY
|
|
1174
|
+
* session on it, via `terminateSessions`) the instant the main leg's `ended` fires killed that
|
|
1175
|
+
* consult leg out from under the transfer before the bridge could use it, and the caller was simply
|
|
1176
|
+
* gone. Two real call sites hit this independently (found live, 2026-08-04): the main leg's own
|
|
1177
|
+
* `ended` handler, and `#armNoInviteTeardown`'s 5s timer — the latter re-arms on EVERY push,
|
|
1178
|
+
* including unrelated "answered" pushes Zadarma sends around other account activity, so it can fire
|
|
1179
|
+
* minutes after a transfer with no `#activeCall` and no idea a consult leg is mid-handoff. Both now
|
|
1180
|
+
* route through here rather than checking `#activeCall` alone.
|
|
1181
|
+
*/
|
|
1182
|
+
#maybeTeardownUA() {
|
|
1183
|
+
if (this.provider.registrationMode !== "on-demand") return;
|
|
1184
|
+
if (this.#activeCall || this.#consultCall) return;
|
|
1185
|
+
this.#teardownUA();
|
|
1050
1186
|
}
|
|
1051
1187
|
#startRejectGuard() {
|
|
1052
1188
|
const ms = this.provider.quirks?.rejectGuardMs;
|
|
@@ -1090,11 +1226,13 @@ function buildPushUrl(key, sip) {
|
|
|
1090
1226
|
const params = new URLSearchParams({ key, integrationType: "site", sipId: sip });
|
|
1091
1227
|
return `wss://ws.zadarma.com/webrtc_pushes?${params.toString()}`;
|
|
1092
1228
|
}
|
|
1093
|
-
var PING, KEY_REFRESH_BUFFER_MS, ZADARMA_QUIRKS, ZadarmaPushSignal, ZadarmaProvider;
|
|
1229
|
+
var PING, KEY_REFRESH_BUFFER_MS, REBUILD_CONNECT_TIMEOUT_MS, REBUILD_RETRY_MS, ZADARMA_QUIRKS, ZadarmaPushSignal, ZadarmaProvider;
|
|
1094
1230
|
var init_zadarma = __esm({
|
|
1095
1231
|
"src/transports/providers/zadarma.ts"() {
|
|
1096
1232
|
PING = { localMs: 3e4, globalMs: 6e4 };
|
|
1097
1233
|
KEY_REFRESH_BUFFER_MS = 2 * 60 * 60 * 1e3;
|
|
1234
|
+
REBUILD_CONNECT_TIMEOUT_MS = 1e4;
|
|
1235
|
+
REBUILD_RETRY_MS = 3e4;
|
|
1098
1236
|
ZADARMA_QUIRKS = {
|
|
1099
1237
|
// Zadarma's registrar REFUSES to register a WebRTC client with any other User-Agent — a clean 401
|
|
1100
1238
|
// after a valid digest challenge, indistinguishable from a wrong password (§3.1). The old CRM
|
|
@@ -1118,6 +1256,12 @@ var init_zadarma = __esm({
|
|
|
1118
1256
|
#connected = false;
|
|
1119
1257
|
#pingTimers = [];
|
|
1120
1258
|
#refreshTimer;
|
|
1259
|
+
/** The replacement socket of a rebuild in flight, not yet current. */
|
|
1260
|
+
#candidate = null;
|
|
1261
|
+
/** Abandons the rebuild in flight (closing its candidate). */
|
|
1262
|
+
#abortRebuild = null;
|
|
1263
|
+
#rebuilding = false;
|
|
1264
|
+
#retryTimer;
|
|
1121
1265
|
#unwatchBackground;
|
|
1122
1266
|
#closed = false;
|
|
1123
1267
|
#listeners = /* @__PURE__ */ new Set();
|
|
@@ -1133,20 +1277,35 @@ var init_zadarma = __esm({
|
|
|
1133
1277
|
if (this.#closed) return;
|
|
1134
1278
|
const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
|
|
1135
1279
|
this.#socket = socket;
|
|
1280
|
+
this.#attach(socket);
|
|
1281
|
+
this.#armRefresh(keyExpiresAt);
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Wires one socket's events. `this.#socket` is read when an event FIRES, not when it is wired: a socket that
|
|
1285
|
+
* has been replaced must stop driving connectivity (our own `close()` of it emits `disconnect`, which would
|
|
1286
|
+
* otherwise flip the state to offline right after a perfectly healthy swap). `update` is the exception — it is
|
|
1287
|
+
* forwarded from any socket not yet closed, because a push seen twice is harmless (the engine ignores a
|
|
1288
|
+
* repeated signal mid-call and reuses a UA that already exists) while one seen by neither is a missed call.
|
|
1289
|
+
*
|
|
1290
|
+
* With `hooks`, the socket is a rebuild's candidate: its first `connect` or failure goes to the rebuild instead
|
|
1291
|
+
* of changing the connection state (it is not current yet).
|
|
1292
|
+
*/
|
|
1293
|
+
#attach(socket, hooks) {
|
|
1136
1294
|
socket.on("connect", () => {
|
|
1295
|
+
if (hooks && socket === this.#candidate) return hooks.onConnect();
|
|
1296
|
+
if (socket !== this.#socket) return;
|
|
1137
1297
|
this.#setConnected(true);
|
|
1138
1298
|
this.#startPings(socket);
|
|
1139
1299
|
});
|
|
1140
1300
|
socket.on("update", () => this.#emit());
|
|
1141
|
-
|
|
1142
|
-
this.#
|
|
1143
|
-
this.#
|
|
1144
|
-
});
|
|
1145
|
-
socket.on("connect_error", () => {
|
|
1301
|
+
const lost = () => {
|
|
1302
|
+
if (hooks && socket === this.#candidate) return hooks.onFailure();
|
|
1303
|
+
if (socket !== this.#socket) return;
|
|
1146
1304
|
this.#stopPings();
|
|
1147
1305
|
this.#setConnected(false);
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1306
|
+
};
|
|
1307
|
+
socket.on("disconnect", lost);
|
|
1308
|
+
socket.on("connect_error", lost);
|
|
1150
1309
|
}
|
|
1151
1310
|
/**
|
|
1152
1311
|
* Presence pings — **the reason an idle tab keeps receiving calls**.
|
|
@@ -1185,22 +1344,92 @@ var init_zadarma = __esm({
|
|
|
1185
1344
|
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
1186
1345
|
this.#refreshTimer = setTimeout(() => void this.#reopen(), ms);
|
|
1187
1346
|
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Replaces the push socket with a fresh one — MAKE-BEFORE-BREAK (presence-client-reporting.md §4.1, T8).
|
|
1349
|
+
*
|
|
1350
|
+
* Why it must happen at all: Zadarma's gateway decides the extension is alive from the raw Engine.IO ping, which
|
|
1351
|
+
* a hidden tab's throttled timers silently stop sending, so while the tab is hidden the socket is rebuilt every
|
|
1352
|
+
* 20 s (`push-socket-reliability.md` §5.3), and once before the key expires. Why it must not leave a gap: the
|
|
1353
|
+
* old close-then-reopen reported `connected = false` for the ~0.5 s in between, which took the softphone
|
|
1354
|
+
* `offline`, tore down the presence socket and left Zadarma with no socket to push an incoming call to.
|
|
1355
|
+
*
|
|
1356
|
+
* Now the OLD socket stays up and stays current until the NEW one has actually connected; only then do we swap,
|
|
1357
|
+
* move the pings and close the old one. A healthy rebuild never changes `connected`. A failed one — the key fetch
|
|
1358
|
+
* throws, the socket errors, or it has not connected within `REBUILD_CONNECT_TIMEOUT_MS` — keeps the old socket
|
|
1359
|
+
* and retries, instead of leaving the tab disconnected. Zadarma does not evict the older of two sockets for one
|
|
1360
|
+
* sip (multi-tab-leader-election.md §1), so the overlap is safe.
|
|
1361
|
+
*/
|
|
1188
1362
|
async #reopen() {
|
|
1189
|
-
if (this.#closed) return;
|
|
1190
|
-
this.#
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1363
|
+
if (this.#closed || this.#rebuilding) return;
|
|
1364
|
+
if (!this.#socket) {
|
|
1365
|
+
try {
|
|
1366
|
+
await this.#open();
|
|
1367
|
+
} catch {
|
|
1368
|
+
}
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
this.#rebuilding = true;
|
|
1194
1372
|
try {
|
|
1195
|
-
await this
|
|
1373
|
+
const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
|
|
1374
|
+
if (this.#closed) return;
|
|
1375
|
+
const swapped = await new Promise((resolve) => {
|
|
1376
|
+
const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
|
|
1377
|
+
this.#candidate = socket;
|
|
1378
|
+
let settled = false;
|
|
1379
|
+
const finish = (ok) => {
|
|
1380
|
+
if (settled) return;
|
|
1381
|
+
settled = true;
|
|
1382
|
+
clearTimeout(timer);
|
|
1383
|
+
this.#abortRebuild = null;
|
|
1384
|
+
if (ok && socket.connected) {
|
|
1385
|
+
this.#swapIn(socket, keyExpiresAt);
|
|
1386
|
+
} else {
|
|
1387
|
+
this.#candidate = null;
|
|
1388
|
+
socket.close();
|
|
1389
|
+
}
|
|
1390
|
+
resolve(ok && socket.connected);
|
|
1391
|
+
};
|
|
1392
|
+
const timer = setTimeout(() => finish(false), REBUILD_CONNECT_TIMEOUT_MS);
|
|
1393
|
+
this.#abortRebuild = () => finish(false);
|
|
1394
|
+
this.#attach(socket, { onConnect: () => finish(true), onFailure: () => finish(false) });
|
|
1395
|
+
});
|
|
1396
|
+
if (!swapped) this.#armRetry();
|
|
1196
1397
|
} catch {
|
|
1398
|
+
this.#armRetry();
|
|
1399
|
+
} finally {
|
|
1400
|
+
this.#rebuilding = false;
|
|
1197
1401
|
}
|
|
1198
1402
|
}
|
|
1403
|
+
/**
|
|
1404
|
+
* The moment of the swap — synchronous, called from the candidate's own `connect` event so nothing can run
|
|
1405
|
+
* between "the new socket is up" and "it is the current one". Order matters: `#socket` moves first, so the old
|
|
1406
|
+
* socket's `disconnect` (our own `close()` of it, just below) is ignored by `#attach`.
|
|
1407
|
+
*/
|
|
1408
|
+
#swapIn(socket, keyExpiresAt) {
|
|
1409
|
+
const previous = this.#socket;
|
|
1410
|
+
this.#socket = socket;
|
|
1411
|
+
this.#candidate = null;
|
|
1412
|
+
clearTimeout(this.#retryTimer);
|
|
1413
|
+
this.#retryTimer = void 0;
|
|
1414
|
+
this.#stopPings();
|
|
1415
|
+
this.#startPings(socket);
|
|
1416
|
+
this.#setConnected(true);
|
|
1417
|
+
previous?.close();
|
|
1418
|
+
this.#armRefresh(keyExpiresAt);
|
|
1419
|
+
}
|
|
1420
|
+
#armRetry() {
|
|
1421
|
+
if (this.#closed) return;
|
|
1422
|
+
clearTimeout(this.#retryTimer);
|
|
1423
|
+
this.#retryTimer = setTimeout(() => void this.#reopen(), REBUILD_RETRY_MS);
|
|
1424
|
+
}
|
|
1199
1425
|
disconnect() {
|
|
1200
1426
|
this.#closed = true;
|
|
1201
1427
|
this.#stopPings();
|
|
1202
1428
|
clearTimeout(this.#refreshTimer);
|
|
1203
1429
|
this.#refreshTimer = void 0;
|
|
1430
|
+
clearTimeout(this.#retryTimer);
|
|
1431
|
+
this.#retryTimer = void 0;
|
|
1432
|
+
this.#abortRebuild?.();
|
|
1204
1433
|
this.#unwatchBackground?.();
|
|
1205
1434
|
this.#unwatchBackground = void 0;
|
|
1206
1435
|
this.#socket?.close();
|
|
@@ -1517,6 +1746,8 @@ var SoftphoneEvent = /* @__PURE__ */ ((SoftphoneEvent2) => {
|
|
|
1517
1746
|
SoftphoneEvent2["DeviceChange"] = "deviceChange";
|
|
1518
1747
|
SoftphoneEvent2["Error"] = "error";
|
|
1519
1748
|
SoftphoneEvent2["LeaderChanged"] = "leaderChanged";
|
|
1749
|
+
SoftphoneEvent2["TransferOutcome"] = "transferOutcome";
|
|
1750
|
+
SoftphoneEvent2["PresenceChanged"] = "presenceChanged";
|
|
1520
1751
|
return SoftphoneEvent2;
|
|
1521
1752
|
})(SoftphoneEvent || {});
|
|
1522
1753
|
var CallEvent = /* @__PURE__ */ ((CallEvent2) => {
|
|
@@ -1555,13 +1786,17 @@ function canTransition(from, to) {
|
|
|
1555
1786
|
function isTerminal(state) {
|
|
1556
1787
|
return CALL_TRANSITIONS[state].length === 0;
|
|
1557
1788
|
}
|
|
1789
|
+
function isOnCallState(state) {
|
|
1790
|
+
return state !== "ringing" && !isTerminal(state);
|
|
1791
|
+
}
|
|
1558
1792
|
|
|
1559
1793
|
// src/core/call-impl.ts
|
|
1560
1794
|
var CallImpl = class _CallImpl extends Emitter {
|
|
1561
|
-
constructor(engineCall, onListenerError, onInternalError) {
|
|
1795
|
+
constructor(engineCall, onListenerError, onInternalError, onTransfer) {
|
|
1562
1796
|
super(onListenerError);
|
|
1563
1797
|
this.engineCall = engineCall;
|
|
1564
1798
|
this.onInternalError = onInternalError;
|
|
1799
|
+
this.onTransfer = onTransfer;
|
|
1565
1800
|
this.#onListenerError = onListenerError;
|
|
1566
1801
|
this.#state = INITIAL_STATES[engineCall.direction];
|
|
1567
1802
|
this.#remote = {
|
|
@@ -1573,6 +1808,7 @@ var CallImpl = class _CallImpl extends Emitter {
|
|
|
1573
1808
|
}
|
|
1574
1809
|
engineCall;
|
|
1575
1810
|
onInternalError;
|
|
1811
|
+
onTransfer;
|
|
1576
1812
|
#state;
|
|
1577
1813
|
#remote;
|
|
1578
1814
|
#answeredAt = null;
|
|
@@ -1658,7 +1894,10 @@ var CallImpl = class _CallImpl extends Emitter {
|
|
|
1658
1894
|
}
|
|
1659
1895
|
async hangup() {
|
|
1660
1896
|
if (isTerminal(this.#state)) return;
|
|
1661
|
-
|
|
1897
|
+
const consult = this.#consultCall;
|
|
1898
|
+
this.#consultCall = null;
|
|
1899
|
+
this.#consultEngineCall = null;
|
|
1900
|
+
await Promise.all([consult?.hangup() ?? Promise.resolve(), this.engineCall.hangup()]);
|
|
1662
1901
|
}
|
|
1663
1902
|
mute(muted) {
|
|
1664
1903
|
if (this.#muted === muted) return;
|
|
@@ -1680,6 +1919,7 @@ var CallImpl = class _CallImpl extends Emitter {
|
|
|
1680
1919
|
}
|
|
1681
1920
|
async transfer(target) {
|
|
1682
1921
|
await this.engineCall.transfer(target);
|
|
1922
|
+
this.onTransfer?.(target, this.#remote.number.trim() || null);
|
|
1683
1923
|
}
|
|
1684
1924
|
async startConsult(target) {
|
|
1685
1925
|
await this.hold();
|
|
@@ -1715,6 +1955,12 @@ var CallImpl = class _CallImpl extends Emitter {
|
|
|
1715
1955
|
}
|
|
1716
1956
|
#end(reason) {
|
|
1717
1957
|
if (isTerminal(this.#state)) return;
|
|
1958
|
+
if (this.#consultCall) {
|
|
1959
|
+
const consult = this.#consultCall;
|
|
1960
|
+
this.#consultCall = null;
|
|
1961
|
+
this.#consultEngineCall = null;
|
|
1962
|
+
void consult.hangup();
|
|
1963
|
+
}
|
|
1718
1964
|
const from = this.#state;
|
|
1719
1965
|
this.#state = "ended";
|
|
1720
1966
|
this.emit("stateChanged" /* StateChanged */, { state: "ended", previous: from });
|
|
@@ -1758,7 +2004,9 @@ function coventiaBackend(config, tokenSource, fallbackBaseUrl) {
|
|
|
1758
2004
|
contacts: async (params) => (await load()).contacts(params),
|
|
1759
2005
|
history: async (params) => (await load()).history(params),
|
|
1760
2006
|
resolveContact: async (number) => (await load()).resolveContact(number),
|
|
1761
|
-
listAgents: async () => (await load()).listAgents()
|
|
2007
|
+
listAgents: async () => (await load()).listAgents(),
|
|
2008
|
+
transferOutcomes: async (params) => (await load()).transferOutcomes(params),
|
|
2009
|
+
presence: async (callbacks) => (await load()).presence(callbacks)
|
|
1762
2010
|
};
|
|
1763
2011
|
}
|
|
1764
2012
|
|
|
@@ -1806,9 +2054,176 @@ function webLocksTabLock(lockName = LOCK_NAME) {
|
|
|
1806
2054
|
};
|
|
1807
2055
|
}
|
|
1808
2056
|
|
|
2057
|
+
// src/core/transfer-watch.ts
|
|
2058
|
+
var FAST_INTERVAL_MS = 2e3;
|
|
2059
|
+
var FAST_WINDOW_MS = 1e4;
|
|
2060
|
+
var SLOW_INTERVAL_MS = 5e3;
|
|
2061
|
+
var DEADLINE_MS = 9e4;
|
|
2062
|
+
var EXTENSION_LIKE = /^\d{1,6}$/;
|
|
2063
|
+
function startTransferWatch(params) {
|
|
2064
|
+
const {
|
|
2065
|
+
target,
|
|
2066
|
+
callerNumber,
|
|
2067
|
+
source,
|
|
2068
|
+
getIsLeader,
|
|
2069
|
+
emit,
|
|
2070
|
+
setTimeout: scheduleTimeout = globalThis.setTimeout,
|
|
2071
|
+
clearTimeout: cancelTimeout = globalThis.clearTimeout,
|
|
2072
|
+
now = () => Date.now()
|
|
2073
|
+
} = params;
|
|
2074
|
+
if (!EXTENSION_LIKE.test(target)) return () => {
|
|
2075
|
+
};
|
|
2076
|
+
if (!getIsLeader()) return () => {
|
|
2077
|
+
};
|
|
2078
|
+
let stopped = false;
|
|
2079
|
+
let timer = null;
|
|
2080
|
+
let cursor = null;
|
|
2081
|
+
const startedAt = now();
|
|
2082
|
+
const stop = () => {
|
|
2083
|
+
stopped = true;
|
|
2084
|
+
if (timer !== null) {
|
|
2085
|
+
cancelTimeout(timer);
|
|
2086
|
+
timer = null;
|
|
2087
|
+
}
|
|
2088
|
+
};
|
|
2089
|
+
const finish = (status) => {
|
|
2090
|
+
stop();
|
|
2091
|
+
emit({ status, target, caller: callerNumber, at: new Date(now()) });
|
|
2092
|
+
};
|
|
2093
|
+
const scheduleNext = () => {
|
|
2094
|
+
if (stopped) return;
|
|
2095
|
+
const elapsed = now() - startedAt;
|
|
2096
|
+
if (elapsed >= DEADLINE_MS) {
|
|
2097
|
+
stop();
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
timer = scheduleTimeout(() => void poll(), elapsed < FAST_WINDOW_MS ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS);
|
|
2101
|
+
};
|
|
2102
|
+
const poll = async () => {
|
|
2103
|
+
if (stopped) return;
|
|
2104
|
+
if (!getIsLeader()) {
|
|
2105
|
+
stop();
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
let page;
|
|
2109
|
+
try {
|
|
2110
|
+
page = await source(cursor === null ? {} : { since: cursor });
|
|
2111
|
+
} catch {
|
|
2112
|
+
if (!stopped) scheduleNext();
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
if (stopped) return;
|
|
2116
|
+
if (cursor === null) {
|
|
2117
|
+
cursor = page.cursor;
|
|
2118
|
+
} else {
|
|
2119
|
+
cursor = page.cursor;
|
|
2120
|
+
const match = page.outcomes.find((o) => o.target === target);
|
|
2121
|
+
if (match) {
|
|
2122
|
+
finish(match.status);
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
scheduleNext();
|
|
2127
|
+
};
|
|
2128
|
+
void poll();
|
|
2129
|
+
return stop;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// src/core/presence-socket.ts
|
|
2133
|
+
var RECONNECT_BASE_MS = 1e3;
|
|
2134
|
+
var RECONNECT_MAX_MS = 3e4;
|
|
2135
|
+
var NOOP_WATCH = { reportCallState: () => {
|
|
2136
|
+
}, stop: () => {
|
|
2137
|
+
} };
|
|
2138
|
+
function startPresenceSocket(params) {
|
|
2139
|
+
const {
|
|
2140
|
+
source,
|
|
2141
|
+
getIsLeader,
|
|
2142
|
+
onAgentsChanged,
|
|
2143
|
+
setTimeout: scheduleTimeout = globalThis.setTimeout,
|
|
2144
|
+
clearTimeout: cancelTimeout = globalThis.clearTimeout,
|
|
2145
|
+
random = Math.random
|
|
2146
|
+
} = params;
|
|
2147
|
+
if (!getIsLeader()) return NOOP_WATCH;
|
|
2148
|
+
let stopped = false;
|
|
2149
|
+
let attempt = 0;
|
|
2150
|
+
let reconnectTimer = null;
|
|
2151
|
+
let agents = /* @__PURE__ */ new Map();
|
|
2152
|
+
let lastReportedOnCall = null;
|
|
2153
|
+
let connection = null;
|
|
2154
|
+
let byeOnArrival = false;
|
|
2155
|
+
const stop = (options) => {
|
|
2156
|
+
if (stopped) return;
|
|
2157
|
+
stopped = true;
|
|
2158
|
+
if (reconnectTimer !== null) {
|
|
2159
|
+
cancelTimeout(reconnectTimer);
|
|
2160
|
+
reconnectTimer = null;
|
|
2161
|
+
}
|
|
2162
|
+
if (options?.bye) {
|
|
2163
|
+
byeOnArrival = true;
|
|
2164
|
+
connection?.bye?.();
|
|
2165
|
+
}
|
|
2166
|
+
connection?.close();
|
|
2167
|
+
connection = null;
|
|
2168
|
+
};
|
|
2169
|
+
const scheduleReconnect = () => {
|
|
2170
|
+
if (stopped) return;
|
|
2171
|
+
attempt += 1;
|
|
2172
|
+
const capped = Math.min(RECONNECT_BASE_MS * 2 ** (attempt - 1), RECONNECT_MAX_MS);
|
|
2173
|
+
const delay = capped * random();
|
|
2174
|
+
reconnectTimer = scheduleTimeout(() => void connect(), delay);
|
|
2175
|
+
};
|
|
2176
|
+
const connect = async () => {
|
|
2177
|
+
if (stopped) return;
|
|
2178
|
+
if (!getIsLeader()) {
|
|
2179
|
+
stop();
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
let opened;
|
|
2183
|
+
try {
|
|
2184
|
+
opened = await source({
|
|
2185
|
+
onSnapshot: (snapshot) => {
|
|
2186
|
+
agents = new Map(snapshot.map((agent) => [agent.userId, agent]));
|
|
2187
|
+
onAgentsChanged([...agents.values()]);
|
|
2188
|
+
},
|
|
2189
|
+
onDelta: (agent) => {
|
|
2190
|
+
agents.set(agent.userId, agent);
|
|
2191
|
+
onAgentsChanged([...agents.values()]);
|
|
2192
|
+
},
|
|
2193
|
+
onClose: () => {
|
|
2194
|
+
if (connection === null) return;
|
|
2195
|
+
connection = null;
|
|
2196
|
+
scheduleReconnect();
|
|
2197
|
+
}
|
|
2198
|
+
});
|
|
2199
|
+
} catch {
|
|
2200
|
+
if (!stopped) scheduleReconnect();
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
if (stopped) {
|
|
2204
|
+
if (byeOnArrival) opened.bye?.();
|
|
2205
|
+
opened.close();
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
connection = opened;
|
|
2209
|
+
attempt = 0;
|
|
2210
|
+
if (lastReportedOnCall !== null) connection.reportCallState(lastReportedOnCall);
|
|
2211
|
+
};
|
|
2212
|
+
void connect();
|
|
2213
|
+
return {
|
|
2214
|
+
reportCallState: (onCall) => {
|
|
2215
|
+
if (onCall === lastReportedOnCall) return;
|
|
2216
|
+
lastReportedOnCall = onCall;
|
|
2217
|
+
connection?.reportCallState(onCall);
|
|
2218
|
+
},
|
|
2219
|
+
stop
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
|
|
1809
2223
|
// src/core/softphone-impl.ts
|
|
1810
2224
|
init_errors();
|
|
1811
2225
|
var liveInstance = false;
|
|
2226
|
+
var PRESENCE_TEARDOWN_GRACE_MS = 1e4;
|
|
1812
2227
|
var BrowserAudioDevices = class {
|
|
1813
2228
|
inputs = [];
|
|
1814
2229
|
outputs = [];
|
|
@@ -1869,6 +2284,23 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
1869
2284
|
/** Contacts + history, whenever there is a Coventia backend to reach. The widget reads these. */
|
|
1870
2285
|
sources;
|
|
1871
2286
|
audio;
|
|
2287
|
+
/**
|
|
2288
|
+
* Every in-flight transfer-outcome watch (Phase A), so `destroy()` can stop all of them. Never
|
|
2289
|
+
* pruned as individual watches finish on their own — `stop()` is idempotent and cheap to call again,
|
|
2290
|
+
* and a session accumulating a handful of closures for however many transfers it makes is not worth
|
|
2291
|
+
* the bookkeeping a self-removing set would add.
|
|
2292
|
+
*/
|
|
2293
|
+
#activeWatches = /* @__PURE__ */ new Set();
|
|
2294
|
+
/**
|
|
2295
|
+
* Phase B (presence-gated transfer picker). ONE long-lived watch, not a member of `#activeWatches`
|
|
2296
|
+
* — that set is per-transfer instances a live call spawns and forgets; this is a single background
|
|
2297
|
+
* connection tied to the softphone's own lifecycle, started and stopped by `#syncPresenceWatch`
|
|
2298
|
+
* rather than by any one call.
|
|
2299
|
+
*/
|
|
2300
|
+
#presenceWatch = null;
|
|
2301
|
+
/** A pending state-driven teardown of the watch — see `PRESENCE_TEARDOWN_GRACE_MS`. */
|
|
2302
|
+
#presenceTeardownTimer = null;
|
|
2303
|
+
#presenceAgents = [];
|
|
1872
2304
|
/**
|
|
1873
2305
|
* `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
|
|
1874
2306
|
* internal seam, not something an integrator tunes). Tests construct this class directly with a
|
|
@@ -1901,6 +2333,9 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
1901
2333
|
get activeCall() {
|
|
1902
2334
|
return this.#activeCall;
|
|
1903
2335
|
}
|
|
2336
|
+
get presence() {
|
|
2337
|
+
return this.#presenceAgents;
|
|
2338
|
+
}
|
|
1904
2339
|
get canPlayAudio() {
|
|
1905
2340
|
return true;
|
|
1906
2341
|
}
|
|
@@ -1924,6 +2359,7 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
1924
2359
|
}
|
|
1925
2360
|
async stop() {
|
|
1926
2361
|
if (!this.#startedHere) return;
|
|
2362
|
+
this.#sayGoodbye();
|
|
1927
2363
|
this.#tabLock.release();
|
|
1928
2364
|
if (this.#isLeader) {
|
|
1929
2365
|
await this.#engine.stop();
|
|
@@ -1937,6 +2373,9 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
1937
2373
|
async destroy() {
|
|
1938
2374
|
if (this.#destroyed) return;
|
|
1939
2375
|
this.#destroyed = true;
|
|
2376
|
+
for (const stopWatch of this.#activeWatches) stopWatch();
|
|
2377
|
+
this.#activeWatches.clear();
|
|
2378
|
+
this.#sayGoodbye();
|
|
1940
2379
|
await this.stop().catch(() => {
|
|
1941
2380
|
});
|
|
1942
2381
|
this.#setState("destroyed");
|
|
@@ -1967,14 +2406,19 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
1967
2406
|
const call = new CallImpl(
|
|
1968
2407
|
engineCall,
|
|
1969
2408
|
(event, error) => this.#reportListenerError("call", event, error),
|
|
1970
|
-
(message) => this.#reportInternalError(message)
|
|
2409
|
+
(message) => this.#reportInternalError(message),
|
|
2410
|
+
(target, callerNumber) => this.#onTransfer(target, callerNumber)
|
|
1971
2411
|
);
|
|
1972
2412
|
this.#activeCall = call;
|
|
2413
|
+
const offState = call.on("stateChanged" /* StateChanged */, () => this.#syncCallState());
|
|
2414
|
+
this.#syncCallState();
|
|
1973
2415
|
const off = call.on("ended" /* Ended */, () => {
|
|
1974
2416
|
off();
|
|
2417
|
+
offState();
|
|
1975
2418
|
if (this.#activeCall === call) {
|
|
1976
2419
|
this.#activeCall = null;
|
|
1977
2420
|
this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
|
|
2421
|
+
this.#syncCallState();
|
|
1978
2422
|
}
|
|
1979
2423
|
});
|
|
1980
2424
|
if (this.#resolveContact) {
|
|
@@ -1992,11 +2436,125 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
1992
2436
|
this.#state = state;
|
|
1993
2437
|
if (state === "online") this.#lastError = null;
|
|
1994
2438
|
this.emit("stateChanged" /* StateChanged */, { state, previous });
|
|
2439
|
+
this.#syncPresenceWatch();
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* Whether this softphone is on a call right now, as presence means it (`isOnCallState`). There is a single
|
|
2443
|
+
* active-call slot, and a consult leg is a child of that call, so the parent staying alive covers a consult.
|
|
2444
|
+
*/
|
|
2445
|
+
#isOnCall() {
|
|
2446
|
+
return this.#activeCall !== null && isOnCallState(this.#activeCall.state);
|
|
2447
|
+
}
|
|
2448
|
+
/**
|
|
2449
|
+
* Reports the current on-a-call value to the backend. The watch de-duplicates (it only sends on change) and
|
|
2450
|
+
* remembers the latest value to replay after a reconnect, so this can be called on every transition without
|
|
2451
|
+
* ceremony. A no-op while there is no presence watch (not online, not the leader, no backend configured).
|
|
2452
|
+
*/
|
|
2453
|
+
#syncCallState() {
|
|
2454
|
+
this.#presenceWatch?.reportCallState(this.#isOnCall());
|
|
1995
2455
|
}
|
|
1996
2456
|
#setLeader(isLeader) {
|
|
1997
2457
|
if (isLeader === this.#isLeader) return;
|
|
1998
2458
|
this.#isLeader = isLeader;
|
|
1999
2459
|
this.emit("leaderChanged" /* LeaderChanged */, { isLeader });
|
|
2460
|
+
this.#syncPresenceWatch();
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Phase B (presence-gated transfer picker, `docs/specs/transfer-presence-picker.md` plan T6/T7).
|
|
2464
|
+
* The ONE place that decides whether the presence connection should be running right now — called
|
|
2465
|
+
* after every `#setState`/`#setLeader` transition rather than left to either alone, since either
|
|
2466
|
+
* one flipping can change the answer. No-ops entirely when no Coventia backend is configured
|
|
2467
|
+
* (`sources` is null) — same guard `#onTransfer` uses, and for the same reason: nothing to open.
|
|
2468
|
+
*
|
|
2469
|
+
* `presence-socket.ts`'s own `getIsLeader` is checked at start and before each reconnect attempt,
|
|
2470
|
+
* not on a poll tick the way `startTransferWatch`'s is — see that module's own doc comment for why.
|
|
2471
|
+
* This still passes `getIsLeader: () => this.#isLeader` for that narrower, defensive recheck; the
|
|
2472
|
+
* BROAD online/leader gate — whether to be connected AT ALL — is owned entirely here, the same
|
|
2473
|
+
* split `#onTransfer` already has with `startTransferWatch`.
|
|
2474
|
+
*
|
|
2475
|
+
* One exception to "tear down the moment `online` is lost": a leader that is merely `offline` or
|
|
2476
|
+
* `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a blip shorter than that never touches
|
|
2477
|
+
* the presence socket at all. Anything else (stopped, starting, destroyed, or no longer the leader) still
|
|
2478
|
+
* tears it down at once: those are not blips, and a follower must never hold a connection.
|
|
2479
|
+
*/
|
|
2480
|
+
#syncPresenceWatch() {
|
|
2481
|
+
const eligible = Boolean(this.sources) && this.#isLeader;
|
|
2482
|
+
const shouldRun = eligible && this.#state === "online";
|
|
2483
|
+
const blip = eligible && (this.#state === "offline" || this.#state === "reconnecting");
|
|
2484
|
+
if (!blip) this.#cancelPresenceTeardown();
|
|
2485
|
+
if (blip && this.#presenceWatch !== null) {
|
|
2486
|
+
this.#schedulePresenceTeardown();
|
|
2487
|
+
return;
|
|
2488
|
+
}
|
|
2489
|
+
if (shouldRun === (this.#presenceWatch !== null)) return;
|
|
2490
|
+
if (shouldRun) {
|
|
2491
|
+
this.#presenceWatch = startPresenceSocket({
|
|
2492
|
+
source: this.sources.presence,
|
|
2493
|
+
getIsLeader: () => this.#isLeader,
|
|
2494
|
+
onAgentsChanged: (agents) => {
|
|
2495
|
+
this.#presenceAgents = agents;
|
|
2496
|
+
this.emit("presenceChanged" /* PresenceChanged */, { agents });
|
|
2497
|
+
}
|
|
2498
|
+
});
|
|
2499
|
+
this.#syncCallState();
|
|
2500
|
+
} else {
|
|
2501
|
+
this.#stopPresence();
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
/**
|
|
2505
|
+
* Starts the grace period before an `offline` / `reconnecting` leader loses its presence socket. Idempotent
|
|
2506
|
+
* on purpose: a second notification inside the same blip must NOT push the deadline back.
|
|
2507
|
+
*/
|
|
2508
|
+
#schedulePresenceTeardown() {
|
|
2509
|
+
if (this.#presenceTeardownTimer !== null) return;
|
|
2510
|
+
this.#presenceTeardownTimer = setTimeout(() => {
|
|
2511
|
+
this.#presenceTeardownTimer = null;
|
|
2512
|
+
if (this.#state === "online" && this.#isLeader) return;
|
|
2513
|
+
this.#stopPresence();
|
|
2514
|
+
}, PRESENCE_TEARDOWN_GRACE_MS);
|
|
2515
|
+
}
|
|
2516
|
+
#cancelPresenceTeardown() {
|
|
2517
|
+
if (this.#presenceTeardownTimer === null) return;
|
|
2518
|
+
clearTimeout(this.#presenceTeardownTimer);
|
|
2519
|
+
this.#presenceTeardownTimer = null;
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* Stops the presence watch and forgets its roster. A STATE-DRIVEN teardown (the softphone left `online`
|
|
2523
|
+
* for good, or lost leadership) passes nothing and says no goodbye; only a deliberate stop passes `bye`.
|
|
2524
|
+
* Any pending grace-period teardown is cancelled — there is nothing left for it to do.
|
|
2525
|
+
*/
|
|
2526
|
+
#stopPresence(options) {
|
|
2527
|
+
this.#cancelPresenceTeardown();
|
|
2528
|
+
if (!this.#presenceWatch) return;
|
|
2529
|
+
this.#presenceWatch.stop(options);
|
|
2530
|
+
this.#presenceWatch = null;
|
|
2531
|
+
this.#presenceAgents = [];
|
|
2532
|
+
this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
|
|
2533
|
+
}
|
|
2534
|
+
/**
|
|
2535
|
+
* A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
|
|
2536
|
+
* this phone at once instead of waiting out the close grace. Idempotent — a second call finds no watch.
|
|
2537
|
+
*/
|
|
2538
|
+
#sayGoodbye() {
|
|
2539
|
+
this.#presenceWatch?.reportCallState(false);
|
|
2540
|
+
this.#stopPresence({ bye: true });
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
|
|
2544
|
+
* internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
|
|
2545
|
+
* null): there is then nothing to poll, and behaviour is exactly what it was before this feature
|
|
2546
|
+
* existed (spec §5.4).
|
|
2547
|
+
*/
|
|
2548
|
+
#onTransfer(target, callerNumber) {
|
|
2549
|
+
if (!this.sources) return;
|
|
2550
|
+
const stopWatch = startTransferWatch({
|
|
2551
|
+
target,
|
|
2552
|
+
callerNumber,
|
|
2553
|
+
source: this.sources.transferOutcomes,
|
|
2554
|
+
getIsLeader: () => this.#isLeader,
|
|
2555
|
+
emit: (payload) => this.emit("transferOutcome" /* TransferOutcome */, payload)
|
|
2556
|
+
});
|
|
2557
|
+
this.#activeWatches.add(stopWatch);
|
|
2000
2558
|
}
|
|
2001
2559
|
/**
|
|
2002
2560
|
* Re-emit an engine failure to the host, and remember it if it is fatal.
|
|
@@ -2116,7 +2674,7 @@ function isCallEngine(value) {
|
|
|
2116
2674
|
|
|
2117
2675
|
// src/index.ts
|
|
2118
2676
|
init_errors();
|
|
2119
|
-
var VERSION = "0.
|
|
2677
|
+
var VERSION = "0.2.1";
|
|
2120
2678
|
|
|
2121
2679
|
exports.CALL_TRANSITIONS = CALL_TRANSITIONS;
|
|
2122
2680
|
exports.CallEvent = CallEvent;
|