wldx-cli 0.1.1 → 1.1.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.
@@ -0,0 +1,437 @@
1
+ // The SIP channel of the PBX, as the wildix-call-control skill uses it (through @gorets/wildix-callcontrol): a
2
+ // headless SIP user of the logged-in extension that registers, subscribes to the event packages `dialog` (calls and
3
+ // presence), `reg` (devices) and `active-device` (ringing mode), and sends call commands as Wildix TAPI PUBLISH
4
+ // requests (event `wildixtsp/action`), the way Wildix clients do. Same behaviour as that library: the same events,
5
+ // reconnect and resubscribe with the same backoff, a registration that lingers at most `expires` seconds when the
6
+ // process is killed.
7
+ //
8
+ // Events: registered, ready (the first dialog state after each registration), callStart, callUpdate, callEnd,
9
+ // devicesChanged, presenceChanged, activeDeviceChanged, reconnecting {attempt}, error (an Error).
10
+ import {EventEmitter} from 'node:events';
11
+ import {
12
+ CallTracker,
13
+ ValueTracker,
14
+ deviceName,
15
+ devicesEqual,
16
+ parseActiveDevice,
17
+ parseDialogInfo,
18
+ parseRegInfo,
19
+ toActiveDevice,
20
+ } from './events.mjs';
21
+ import {ALLOWED, SipUserAgent, paramOf, randomToken, tagOf, uriOf} from './sip.mjs';
22
+
23
+ export const TAPI_EVENT = 'wildixtsp/action';
24
+ export const RECORD_ACTIONS = ['on', 'off', 'pause', 'unpause'];
25
+ const PACKAGES = ['dialog', 'reg', 'active-device'];
26
+ const SUBSCRIPTION_DISPOSE_GRACE_MS = 500;
27
+
28
+ // 3 s, then up to 30 s over five attempts (quadratic), as the library.
29
+ export function backoffMs(attempt) {
30
+ const a = Math.min(Math.max(attempt, 1), 5);
31
+ return Math.round(((30_000 - 3_000) / 16) * (a - 1) ** 2 + 3_000);
32
+ }
33
+
34
+ const bounded = (work, ms) => {
35
+ let timer;
36
+ return Promise.race([work.catch(() => undefined), new Promise((r) => (timer = setTimeout(r, ms)))]).finally(() =>
37
+ clearTimeout(timer),
38
+ );
39
+ };
40
+
41
+ export class CallControl extends EventEmitter {
42
+ // {domain, wsUrl, extension, password, userAgent, expires, WebSocketImpl, keepAliveSeconds, disconnectTimeoutMs, backoff}
43
+ constructor(config) {
44
+ super();
45
+ this.config = config;
46
+ this.expires = config.expires ?? 3600;
47
+ if (!Number.isInteger(this.expires) || this.expires <= 0)
48
+ throw new Error(`expires must be a positive integer: ${config.expires}`);
49
+ this.disconnectTimeoutMs = config.disconnectTimeoutMs ?? 10_000;
50
+ this.backoff = config.backoff ?? backoffMs;
51
+ this.aor = `sip:${config.extension}@${config.domain}`;
52
+ this.stopping = false;
53
+ this.ready = false;
54
+ this.generation = 0;
55
+ this.reconnectAttempt = 0;
56
+ this.subs = new Map(); // Call-ID -> subscription
57
+ this.timers = new Set();
58
+ this.calls = new CallTracker();
59
+ this.devices = new ValueTracker();
60
+ this.presence = new ValueTracker();
61
+ this.activeDevice = new ValueTracker();
62
+ }
63
+
64
+ later(fn, ms) {
65
+ const t = setTimeout(() => {
66
+ this.timers.delete(t);
67
+ fn();
68
+ }, ms);
69
+ this.timers.add(t);
70
+ return t;
71
+ }
72
+
73
+ cancel(t) {
74
+ if (t) {
75
+ clearTimeout(t);
76
+ this.timers.delete(t);
77
+ }
78
+ }
79
+
80
+ async connect() {
81
+ this.stopping = false;
82
+ await this.start();
83
+ this.reconnectAttempt = 0;
84
+ this.emit('registered');
85
+ }
86
+
87
+ async start() {
88
+ const c = this.config;
89
+ const ua = new SipUserAgent({
90
+ wsUrl: c.wsUrl,
91
+ domain: c.domain,
92
+ extension: c.extension,
93
+ password: c.password,
94
+ userAgent: c.userAgent,
95
+ WebSocketImpl: c.WebSocketImpl,
96
+ keepAliveSeconds: c.keepAliveSeconds ?? 30,
97
+ timeoutMs: c.timeoutMs,
98
+ });
99
+ ua.onNotify = (req) => this.notify(req);
100
+ ua.on('error', (e) => this.emit('error', e));
101
+ // Only a connection that was up reconnects; one that fails to come up is the caller's error.
102
+ let live = false;
103
+ ua.on('disconnect', () => {
104
+ if (live && this.ua === ua && !this.stopping) this.scheduleReconnect();
105
+ });
106
+ this.ua = ua;
107
+ this.generation += 1;
108
+ this.ready = false;
109
+ try {
110
+ await ua.open();
111
+ await this.register(this.generation);
112
+ live = true;
113
+ if (ua.closed) throw new Error('the PBX closed the SIP connection right after the registration');
114
+ } catch (e) {
115
+ if (this.ua === ua) this.ua = undefined;
116
+ ua.close();
117
+ throw e;
118
+ }
119
+ for (const pkg of PACKAGES) this.subscribe(pkg, this.generation, 0);
120
+ }
121
+
122
+ // REGISTER, refreshed before it runs out. A refresh that fails reconnects.
123
+ async register(generation, expires = this.expires) {
124
+ const ua = this.ua;
125
+ this.registration ??= {callId: randomToken(22), fromTag: randomToken(10), seq: {cseq: 0}};
126
+ const r = this.registration;
127
+ const res = await ua.request('REGISTER', {
128
+ ruri: `sip:${this.config.domain}`,
129
+ to: `<${this.aor}>`,
130
+ from: `<${this.aor}>;tag=${r.fromTag}`,
131
+ callId: r.callId,
132
+ seq: r.seq,
133
+ headers: [
134
+ 'X-Disable-DirectRTP: yes',
135
+ `Contact: ${ua.contact};expires=${expires}`,
136
+ `Allow: ACK,CANCEL,INVITE,MESSAGE,BYE,OPTIONS,INFO,NOTIFY,REFER`,
137
+ ],
138
+ });
139
+ if (res.status === 423 && expires > 0) {
140
+ const min = Number(res.header('min-expires'));
141
+ if (Number.isInteger(min) && min > expires) return this.register(generation, min);
142
+ }
143
+ if (res.status < 200 || res.status >= 300) throw new Error(`REGISTER rejected: ${res.status} ${res.reason}`);
144
+ if (expires === 0) return;
145
+ const ours = res
146
+ .headers('contact')
147
+ .flatMap((v) => v.split(/,(?![^<]*>)/))
148
+ .find((v) => uriOf(v).includes(ua.viaHost));
149
+ const granted = Number(paramOf(ours, 'expires') ?? res.header('expires') ?? expires) || expires;
150
+ this.cancel(this.registerTimer);
151
+ this.registerTimer = this.later(
152
+ () => {
153
+ if (this.stopping || generation !== this.generation) return;
154
+ this.register(generation).catch((e) => {
155
+ if (generation !== this.generation || this.stopping) return;
156
+ this.emit('error', e);
157
+ this.scheduleReconnect();
158
+ });
159
+ },
160
+ Math.max(1, granted * 0.9) * 1000,
161
+ );
162
+ }
163
+
164
+ subscribe(pkg, generation, attempt) {
165
+ if (!this.ua || this.stopping || generation !== this.generation) return;
166
+ const sub = {
167
+ pkg,
168
+ generation,
169
+ attempt,
170
+ callId: randomToken(22),
171
+ fromTag: randomToken(10),
172
+ seq: {cseq: 0},
173
+ remoteTag: undefined,
174
+ target: undefined,
175
+ routes: [],
176
+ };
177
+ this.subs.set(sub.callId, sub);
178
+ this.sendSubscribe(sub, this.expires).then(
179
+ (res) => {
180
+ if (this.subs.get(sub.callId) !== sub) return;
181
+ if (res.status >= 200 && res.status < 300) {
182
+ sub.attempt = 0;
183
+ if (!sub.remoteTag) {
184
+ sub.remoteTag = tagOf(res.header('to'));
185
+ sub.target = res.header('contact') ? uriOf(res.header('contact')) : undefined;
186
+ sub.routes = SipUserAgent.routes(res).reverse();
187
+ }
188
+ this.scheduleRefresh(sub, Number(res.header('expires')) || this.expires);
189
+ } else {
190
+ this.emit('error', new Error(`SUBSCRIBE ${pkg} rejected: ${res.status} ${res.reason}`));
191
+ this.terminated(sub);
192
+ }
193
+ },
194
+ (e) => {
195
+ if (this.subs.get(sub.callId) !== sub) return;
196
+ this.emit('error', e);
197
+ this.terminated(sub);
198
+ },
199
+ );
200
+ }
201
+
202
+ sendSubscribe(sub, expires) {
203
+ const inDialog = Boolean(sub.remoteTag);
204
+ return this.ua.request('SUBSCRIBE', {
205
+ ruri: inDialog && sub.target ? sub.target : this.aor,
206
+ routes: inDialog ? sub.routes : [],
207
+ to: `<${this.aor}>${inDialog ? `;tag=${sub.remoteTag}` : ''}`,
208
+ from: `<${this.aor}>;tag=${sub.fromTag}`,
209
+ callId: sub.callId,
210
+ seq: sub.seq,
211
+ headers: [
212
+ `Allow: ${ALLOWED.join(',')}`,
213
+ `Event: ${sub.pkg}`,
214
+ `Expires: ${expires}`,
215
+ `Contact: ${this.ua.contact}`,
216
+ ],
217
+ });
218
+ }
219
+
220
+ scheduleRefresh(sub, seconds) {
221
+ this.cancel(sub.timer);
222
+ sub.timer = this.later(
223
+ () => {
224
+ if (this.stopping || this.subs.get(sub.callId) !== sub) return;
225
+ this.sendSubscribe(sub, this.expires).then(
226
+ (res) => {
227
+ if (this.subs.get(sub.callId) !== sub) return;
228
+ if (res.status >= 200 && res.status < 300)
229
+ this.scheduleRefresh(sub, Number(res.header('expires')) || this.expires);
230
+ else this.terminated(sub);
231
+ },
232
+ () => this.subs.get(sub.callId) === sub && this.terminated(sub),
233
+ );
234
+ },
235
+ Math.max(1, seconds * 0.9) * 1000,
236
+ );
237
+ }
238
+
239
+ // A subscription that ended: subscribe again, after a backoff, while connected.
240
+ terminated(sub) {
241
+ this.cancel(sub.timer);
242
+ this.subs.delete(sub.callId);
243
+ if (this.stopping || sub.generation !== this.generation) return;
244
+ const attempt = sub.attempt + 1;
245
+ this.later(() => this.subscribe(sub.pkg, sub.generation, attempt), this.backoff(attempt));
246
+ }
247
+
248
+ // A NOTIFY: true when it belongs to one of our subscriptions.
249
+ notify(req) {
250
+ const sub = this.subs.get(req.header('call-id'));
251
+ if (!sub || tagOf(req.header('to')) !== sub.fromTag) return false;
252
+ if (!sub.remoteTag) {
253
+ sub.remoteTag = tagOf(req.header('from'));
254
+ sub.target = req.header('contact') ? uriOf(req.header('contact')) : undefined;
255
+ sub.routes = SipUserAgent.routes(req);
256
+ }
257
+ const state = req.header('subscription-state') ?? '';
258
+ const body = req.body ?? '';
259
+ try {
260
+ if (sub.pkg === 'dialog') this.onDialog(body);
261
+ else if (sub.pkg === 'reg') this.onReg(body);
262
+ else this.onActiveDevice(body);
263
+ } catch (e) {
264
+ this.emit('error', new Error(`could not read the ${sub.pkg} state from the PBX: ${e.message}`));
265
+ }
266
+ if (/^\s*terminated/i.test(state)) {
267
+ sub.attempt = 0;
268
+ this.terminated(sub);
269
+ } else {
270
+ const expires = Number(paramOf(state, 'expires'));
271
+ if (Number.isFinite(expires) && expires > 0) this.scheduleRefresh(sub, expires);
272
+ }
273
+ return true;
274
+ }
275
+
276
+ onDialog(body) {
277
+ if (body.trim()) {
278
+ const {dialogs, presence} = parseDialogInfo(body);
279
+ const {started, updated, ended} = this.calls.apply(dialogs);
280
+ for (const c of started) this.emit('callStart', c);
281
+ for (const c of updated) this.emit('callUpdate', c);
282
+ for (const c of ended) this.emit('callEnd', c);
283
+ const next = presence ? {...presence} : undefined;
284
+ if (this.presence.apply(next) && next) this.emit('presenceChanged', next);
285
+ }
286
+ // The PBX answers the unsubscribe of disconnect() with a last NOTIFY: that is not a new start.
287
+ if (!this.ready && !this.stopping) {
288
+ this.ready = true;
289
+ this.emit('ready');
290
+ }
291
+ }
292
+
293
+ onReg(body) {
294
+ if (!body.trim() || !this.ua) return;
295
+ const via = this.ua.viaHost;
296
+ const devices = parseRegInfo(body).map((d) => ({
297
+ uri: d.uri,
298
+ userAgent: d.userAgent,
299
+ deviceName: deviceName(d.userAgent),
300
+ isLocalRegistration: d.uri.includes(via),
301
+ }));
302
+ if (this.devices.apply(devices, devicesEqual)) this.emit('devicesChanged', devices);
303
+ }
304
+
305
+ onActiveDevice(body) {
306
+ if (!body.trim()) return;
307
+ const next = toActiveDevice(parseActiveDevice(body));
308
+ if (this.activeDevice.apply(next) && next) this.emit('activeDeviceChanged', next);
309
+ }
310
+
311
+ isReady() {
312
+ return this.ready;
313
+ }
314
+
315
+ waitUntilReady(timeoutMs = 10_000) {
316
+ if (this.ready) return Promise.resolve();
317
+ return new Promise((resolve, reject) => {
318
+ const onReady = () => {
319
+ clearTimeout(timer);
320
+ resolve();
321
+ };
322
+ const timer = setTimeout(() => {
323
+ this.off('ready', onReady);
324
+ reject(new Error(`no call state from the PBX within ${timeoutMs} ms`));
325
+ }, timeoutMs);
326
+ this.once('ready', onReady);
327
+ });
328
+ }
329
+
330
+ getActiveCalls() {
331
+ return this.calls.list();
332
+ }
333
+
334
+ getDevices() {
335
+ return this.devices.value ?? [];
336
+ }
337
+
338
+ getPresence() {
339
+ return this.presence.value;
340
+ }
341
+
342
+ getActiveDevice() {
343
+ return this.activeDevice.value;
344
+ }
345
+
346
+ // A TAPI command: PUBLISH to the user's own address with W-TapiCommand and its parameters. Resolves when the PBX
347
+ // takes it (2xx): taken, not necessarily done.
348
+ async tapi(command, ...params) {
349
+ if (!this.ua) throw new Error('not connected to the PBX');
350
+ const headers = [`W-TapiCommand: ${command}`];
351
+ for (const [i, p] of params.entries()) if (p !== undefined) headers.push(`W-TapiParam${i + 1}: ${p}`);
352
+ headers.push(`Event: ${TAPI_EVENT}`, 'Expires: 1');
353
+ for (const h of headers) if (/[\r\n]/.test(h)) throw new Error('a SIP header value cannot hold a line break');
354
+ const res = await this.ua.request('PUBLISH', {
355
+ ruri: this.aor,
356
+ to: `<${this.aor}>`,
357
+ from: `<${this.aor}>;tag=${randomToken(10)}`,
358
+ callId: randomToken(22),
359
+ seq: {cseq: 0},
360
+ headers,
361
+ });
362
+ if (res.status < 200 || res.status >= 300)
363
+ throw new Error(`TAPI command "${command}" rejected: ${res.status} ${res.reason}`);
364
+ }
365
+
366
+ record(callId, action) {
367
+ if (!RECORD_ACTIONS.includes(action))
368
+ return Promise.reject(new Error(`record action must be one of ${RECORD_ACTIONS.join(', ')}`));
369
+ return this.tapi('record', callId, action);
370
+ }
371
+
372
+ conferenceTransfer(callId, destination) {
373
+ return this.tapi('conftransfer', callId, destination);
374
+ }
375
+
376
+ scheduleReconnect() {
377
+ if (this.stopping || this.reconnectTimer) return;
378
+ this.reconnectAttempt += 1;
379
+ this.emit('reconnecting', {attempt: this.reconnectAttempt});
380
+ this.reconnectTimer = this.later(() => {
381
+ this.reconnectTimer = undefined;
382
+ this.reconnect();
383
+ }, this.backoff(this.reconnectAttempt));
384
+ }
385
+
386
+ async reconnect() {
387
+ if (this.stopping) return;
388
+ this.dropState();
389
+ const old = this.ua;
390
+ this.ua = undefined;
391
+ old?.close();
392
+ try {
393
+ await this.start();
394
+ this.reconnectAttempt = 0;
395
+ this.emit('registered');
396
+ } catch (e) {
397
+ if (this.stopping) return;
398
+ this.emit('error', e);
399
+ this.scheduleReconnect();
400
+ }
401
+ }
402
+
403
+ // Forgets the registration and the subscriptions of the connection that is gone (the calls stay known, so the
404
+ // state after the reconnect is told as changes).
405
+ dropState() {
406
+ this.cancel(this.registerTimer);
407
+ for (const s of this.subs.values()) this.cancel(s.timer);
408
+ this.subs.clear();
409
+ this.registration = undefined;
410
+ }
411
+
412
+ // Ends the registration and the subscriptions (sent together; the registration waited for at most
413
+ // disconnectTimeoutMs, the subscriptions 0.5 s), then closes the socket. Never rejects.
414
+ async disconnect() {
415
+ this.stopping = true;
416
+ this.ready = false;
417
+ for (const t of this.timers) clearTimeout(t);
418
+ this.timers.clear();
419
+ this.reconnectTimer = undefined;
420
+ const ua = this.ua;
421
+ if (ua && !ua.closed) {
422
+ const subs = [...this.subs.values()].filter((s) => s.remoteTag);
423
+ const unregistered = this.registration
424
+ ? this.register(this.generation, 0).catch(() => undefined)
425
+ : Promise.resolve();
426
+ const unsubscribed = Promise.all(subs.map((s) => this.sendSubscribe(s, 0).catch(() => undefined)));
427
+ await bounded(
428
+ Promise.all([unregistered, bounded(unsubscribed, SUBSCRIPTION_DISPOSE_GRACE_MS)]),
429
+ this.disconnectTimeoutMs,
430
+ );
431
+ }
432
+ this.subs.clear();
433
+ this.registration = undefined;
434
+ ua?.close();
435
+ this.ua = undefined;
436
+ }
437
+ }
@@ -0,0 +1,220 @@
1
+ // What the PBX tells over SIP, as the user's calls, devices, presence and ringing mode: the parsers of the bodies of
2
+ // the event packages `dialog` (RFC 4235 with Wildix params), `reg` (RFC 3680) and `active-device` (Wildix), and the
3
+ // trackers that turn each full state into changes. Ported from @gorets/wildix-callcontrol 0.4.0 (the library the
4
+ // wildix-call-control skill runs), with the same rules.
5
+ import {child, children, parseXml, text} from './xml.mjs';
6
+
7
+ const PARAM = {STATE: 'X-Wildix-state', CUSTOM: 'X-Wildix-custom', TAGS: 'X-Wildix-tags', RECORD: 'X-Wildix-record'};
8
+ const SYSTEM_USER_AGENTS = ['wildixgw', 'wildix tapi'];
9
+ const RECORDING = ['off', 'personal', 'system', 'paused'];
10
+ const ACTIVE_DEVICE_TYPES = ['device', 'any_device', 'mobility'];
11
+
12
+ // The presence of the user rides on the dialog params: X-Wildix-state (online, away, dnd…) and X-Wildix-custom
13
+ // (base64 JSON with the status message and its end).
14
+ function customStatus(value) {
15
+ try {
16
+ const parsed = JSON.parse(Buffer.from(value, 'base64').toString('utf8'));
17
+ return {
18
+ message: typeof parsed.message === 'string' ? parsed.message : undefined,
19
+ until: typeof parsed.untilTime === 'string' ? parsed.untilTime : undefined,
20
+ };
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ function applyPresence(params, presence) {
27
+ for (const p of params) {
28
+ const {pname, pval} = p.attrs;
29
+ if (pname === PARAM.STATE && pval) presence = {...(presence ?? {}), status: String(pval).toLowerCase()};
30
+ if (pname === PARAM.CUSTOM && pval) presence = {...(presence ?? {}), ...customStatus(String(pval))};
31
+ }
32
+ return presence;
33
+ }
34
+
35
+ const recordParam = (params) => {
36
+ const v = params.find((p) => p.attrs.pname === PARAM.RECORD)?.attrs.pval;
37
+ return RECORDING.includes(v) ? v : undefined;
38
+ };
39
+
40
+ function recordingState(user, other) {
41
+ if (user === 'paused' || other === 'paused') return 'paused';
42
+ if (user === 'system' || other === 'system') return 'system';
43
+ return user;
44
+ }
45
+
46
+ // dialog-info: the user's dialogs (calls) and the presence carried on them.
47
+ export function parseDialogInfo(xml) {
48
+ const root = parseXml(xml);
49
+ const dialogs = [];
50
+ let presence = null;
51
+ for (const d of children(root, 'dialog')) {
52
+ presence = applyPresence(children(d, 'param'), presence);
53
+ const targetParams = children(child(child(d, 'remote'), 'target'), 'param');
54
+ presence = applyPresence(targetParams, presence);
55
+ let tags;
56
+ for (const p of targetParams) {
57
+ if (p.attrs.pname === PARAM.TAGS && p.attrs.pval)
58
+ tags = String(p.attrs.pval)
59
+ .split(',')
60
+ .map((t) => t.trim())
61
+ .filter(Boolean);
62
+ }
63
+ const id = d.attrs['call-id'];
64
+ if (!id) continue;
65
+ const stateEl = child(d, 'state');
66
+ const otherParams = children(child(child(d, 'local'), 'target'), 'param');
67
+ const identity = child(child(d, 'local'), 'identity');
68
+ let remoteName;
69
+ let remoteNumber;
70
+ if (identity) {
71
+ remoteName = identity.attrs.display || undefined;
72
+ const uri = text(identity);
73
+ remoteNumber = uri ? (uri.startsWith('sip:') ? uri.slice(4).split('@')[0] : uri) : undefined;
74
+ }
75
+ dialogs.push({
76
+ id,
77
+ direction: d.attrs.direction === 'initiator' ? 'outbound' : 'inbound',
78
+ rawState: text(stateEl),
79
+ rawStateReason: stateEl?.attrs.reason,
80
+ hold: text(child(d, 'note')) === 'On hold',
81
+ remoteName,
82
+ remoteNumber,
83
+ tags,
84
+ recording: recordingState(recordParam(targetParams), recordParam(otherParams)),
85
+ });
86
+ }
87
+ return {dialogs, presence};
88
+ }
89
+
90
+ // reginfo: the registered devices of the user (system registrations of the PBX left out).
91
+ export function parseRegInfo(xml) {
92
+ const root = parseXml(xml);
93
+ const devices = [];
94
+ for (const reg of children(root, 'registration')) {
95
+ for (const c of children(reg, 'contact')) {
96
+ const userAgent = c.attrs.user_agent ?? '';
97
+ const uri = text(child(c, 'uri'));
98
+ if (c.attrs.state !== 'active' || !uri || !userAgent) continue;
99
+ if (SYSTEM_USER_AGENTS.some((m) => userAgent.toLowerCase().includes(m))) continue;
100
+ devices.push({uri, userAgent});
101
+ }
102
+ }
103
+ return devices;
104
+ }
105
+
106
+ // active-device: the ringing mode (a device, all devices, the mobile).
107
+ export function parseActiveDevice(xml) {
108
+ const root = parseXml(xml);
109
+ const type = text(child(root, 'type'));
110
+ if (!type) return null;
111
+ return {
112
+ type,
113
+ userAgent: text(child(root, 'user-agent')) || undefined,
114
+ contactUri: text(child(root, 'contact')) || undefined,
115
+ };
116
+ }
117
+
118
+ // A readable device name from a user agent: version numbers and UUIDs dropped.
119
+ const VERSION_LIKE = /^\d+(\.\d+)*$/;
120
+ const UUID_LIKE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
121
+ const TRAILING_UUID = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
122
+ export function deviceName(userAgent) {
123
+ if (!userAgent) return 'Unknown device';
124
+ const words = userAgent
125
+ .split(' ')
126
+ .map((w) => w.replace(TRAILING_UUID, ''))
127
+ .filter((w) => w && !VERSION_LIKE.test(w) && !UUID_LIKE.test(w));
128
+ return words.length ? words.join(' ') : userAgent;
129
+ }
130
+
131
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
132
+
133
+ function toCall(d) {
134
+ const state =
135
+ d.rawState === 'confirmed' ? (d.hold ? 'held' : 'answered') : d.rawState === 'terminated' ? 'ended' : 'ringing';
136
+ return {
137
+ id: d.id,
138
+ direction: d.direction,
139
+ state,
140
+ remoteNumber: d.remoteNumber,
141
+ remoteName: d.remoteName,
142
+ tags: d.tags,
143
+ recording: d.recording,
144
+ endReason: d.rawStateReason,
145
+ };
146
+ }
147
+
148
+ const callChanged = (a, b) =>
149
+ a.state !== b.state ||
150
+ a.remoteNumber !== b.remoteNumber ||
151
+ a.remoteName !== b.remoteName ||
152
+ a.recording !== b.recording ||
153
+ !same(a.tags ?? [], b.tags ?? []);
154
+
155
+ // The calls: each dialog NOTIFY is the full list; this returns what started, changed and ended since the last one.
156
+ export class CallTracker {
157
+ calls = new Map();
158
+
159
+ apply(dialogs) {
160
+ const started = [];
161
+ const updated = [];
162
+ const ended = [];
163
+ const seen = new Set();
164
+ for (const d of dialogs) {
165
+ seen.add(d.id);
166
+ const next = toCall(d);
167
+ const existing = this.calls.get(d.id);
168
+ if (next.state === 'ended') {
169
+ if (existing) {
170
+ ended.push({...existing, state: 'ended', endReason: next.endReason});
171
+ this.calls.delete(d.id);
172
+ }
173
+ } else if (!existing) {
174
+ started.push(next);
175
+ this.calls.set(d.id, next);
176
+ } else if (callChanged(existing, next)) {
177
+ updated.push(next);
178
+ this.calls.set(d.id, next);
179
+ }
180
+ }
181
+ for (const [id, call] of this.calls) {
182
+ if (!seen.has(id)) {
183
+ ended.push({...call, state: 'ended'});
184
+ this.calls.delete(id);
185
+ }
186
+ }
187
+ return {started, updated, ended};
188
+ }
189
+
190
+ list() {
191
+ return [...this.calls.values()];
192
+ }
193
+ }
194
+
195
+ // One value (the devices, the presence, the ringing mode) that reports whether a new one differs.
196
+ export class ValueTracker {
197
+ value = undefined;
198
+
199
+ apply(next, compare = same) {
200
+ const changed = !compare(this.value, next);
201
+ this.value = next;
202
+ return changed;
203
+ }
204
+ }
205
+
206
+ export function devicesEqual(a = [], b = []) {
207
+ if (a.length !== b.length) return false;
208
+ const sort = (l) => [...l].sort((x, y) => x.uri.localeCompare(y.uri));
209
+ const [sa, sb] = [sort(a), sort(b)];
210
+ return sa.every((d, i) => same(d, sb[i]));
211
+ }
212
+
213
+ export const toActiveDevice = (parsed) =>
214
+ parsed
215
+ ? {
216
+ type: ACTIVE_DEVICE_TYPES.includes(parsed.type) ? parsed.type : 'unknown',
217
+ contactUri: parsed.contactUri,
218
+ userAgent: parsed.userAgent,
219
+ }
220
+ : undefined;