wldx-cli 1.0.0 → 1.2.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.
Files changed (42) hide show
  1. package/README.md +19 -22
  2. package/package.json +9 -6
  3. package/src/chat/format.mjs +40 -8
  4. package/src/chat/lib.mjs +71 -23
  5. package/src/chat/watch.mjs +46 -15
  6. package/src/cli.mjs +13 -252
  7. package/src/commands/calls.mjs +135 -32
  8. package/src/commands/channel.mjs +99 -21
  9. package/src/commands/chat.mjs +77 -22
  10. package/src/commands/conversations.mjs +146 -29
  11. package/src/commands/file.mjs +13 -5
  12. package/src/commands/inbox.mjs +20 -13
  13. package/src/commands/index.mjs +5 -29
  14. package/src/commands/live.mjs +82 -24
  15. package/src/commands/meetings.mjs +114 -27
  16. package/src/commands/phone.mjs +145 -33
  17. package/src/commands/reports.mjs +219 -44
  18. package/src/commands/section.mjs +49 -8
  19. package/src/commands/user.mjs +42 -13
  20. package/src/history/text.mjs +30 -4
  21. package/src/history/wda.mjs +20 -10
  22. package/src/live/socket.mjs +25 -7
  23. package/src/live/stats.mjs +41 -9
  24. package/src/live/text.mjs +122 -23
  25. package/src/live/views.mjs +93 -23
  26. package/src/phone/control.mjs +93 -33
  27. package/src/phone/events.mjs +38 -7
  28. package/src/phone/pbx.mjs +29 -11
  29. package/src/phone/sip.mjs +87 -17
  30. package/src/phone/xml.mjs +2 -1
  31. package/src/stream.mjs +10 -5
  32. package/src/version.mjs +0 -1
  33. package/src/auth.mjs +0 -274
  34. package/src/commands/login.mjs +0 -126
  35. package/src/commands/profile.mjs +0 -51
  36. package/src/commands/status.mjs +0 -95
  37. package/src/completion.mjs +0 -132
  38. package/src/errors.mjs +0 -30
  39. package/src/files.mjs +0 -98
  40. package/src/format.mjs +0 -82
  41. package/src/http.mjs +0 -64
  42. package/src/services.mjs +0 -58
@@ -77,7 +77,12 @@ export function makeViews({full = false, company, myExtension, now = () => Date.
77
77
  });
78
78
  }
79
79
  // completions and annotations: say what ended or changed, not more
80
- return drop({...base, flows: typeof c.flows === 'number' ? c.flows : undefined, key: c.key, insights: insights(c.annotationsByKey)});
80
+ return drop({
81
+ ...base,
82
+ flows: typeof c.flows === 'number' ? c.flows : undefined,
83
+ key: c.key,
84
+ insights: insights(c.annotationsByKey),
85
+ });
81
86
  }
82
87
 
83
88
  function agentView(a, stats) {
@@ -151,9 +156,26 @@ export function makeViews({full = false, company, myExtension, now = () => Date.
151
156
  }
152
157
 
153
158
  const isMineCall = (c) =>
154
- Boolean(myExtension) && Array.isArray(c.flows) && c.flows.some((f) => f.caller?.userExtension === myExtension || f.callee?.userExtension === myExtension);
159
+ Boolean(myExtension) &&
160
+ Array.isArray(c.flows) &&
161
+ c.flows.some((f) => f.caller?.userExtension === myExtension || f.callee?.userExtension === myExtension);
155
162
 
156
- return {full, company, myExtension, now, party, flowView, insights, conversationView, agentView, queueView, serviceCallView, presenceView, textView, isMineCall};
163
+ return {
164
+ full,
165
+ company,
166
+ myExtension,
167
+ now,
168
+ party,
169
+ flowView,
170
+ insights,
171
+ conversationView,
172
+ agentView,
173
+ queueView,
174
+ serviceCallView,
175
+ presenceView,
176
+ textView,
177
+ isMineCall,
178
+ };
157
179
  }
158
180
 
159
181
  // The last item of every id, in the order of their first appearance.
@@ -167,7 +189,8 @@ export const lastById = (items) => {
167
189
  // conference transcript; each becomes chunks with the flow index kept.
168
190
  export function chunksOf(item) {
169
191
  if (item?.chunk) return [{...item.chunk, flowIndex: item.flowIndex, participant: item.participant}];
170
- if (item?.chunks && typeof item.chunks === 'object') return Object.values(item.chunks).map((c) => ({...c, flowIndex: item.flowIndex}));
192
+ if (item?.chunks && typeof item.chunks === 'object')
193
+ return Object.values(item.chunks).map((c) => ({...c, flowIndex: item.flowIndex}));
171
194
  return [];
172
195
  }
173
196
 
@@ -181,7 +204,12 @@ export function queuesResult(items, {pbx, name, busy, paused, active, stats} = {
181
204
  if (busy) qs = qs.filter((q) => q.calls?.length);
182
205
  if (paused) qs = qs.filter((q) => q.agents?.some(isPaused));
183
206
  if (active) qs = qs.filter((q) => q.calls?.length || q.stats?.offered || q.stats?.outbound);
184
- qs.sort((a, b) => (b.calls?.length ?? 0) - (a.calls?.length ?? 0) || (b.stats?.offered ?? 0) - (a.stats?.offered ?? 0) || String(a.name).localeCompare(String(b.name)));
207
+ qs.sort(
208
+ (a, b) =>
209
+ (b.calls?.length ?? 0) - (a.calls?.length ?? 0) ||
210
+ (b.stats?.offered ?? 0) - (a.stats?.offered ?? 0) ||
211
+ String(a.name).localeCompare(String(b.name)),
212
+ );
185
213
  const pbxs = [...new Set(qs.map((q) => q.pbx))];
186
214
  return drop({
187
215
  total: qs.length,
@@ -201,27 +229,48 @@ export function conversationsResult(items, {kinds = [], mine, status, limit = 50
201
229
  if (kinds.length) cs = cs.filter((c) => kinds.includes(c.event));
202
230
  if (mine) cs = cs.filter(views.isMineCall);
203
231
  const want = status?.toUpperCase();
204
- if (want) cs = cs.filter((c) => String(c.status ?? (Array.isArray(c.flows) ? c.flows.at(-1)?.status : undefined) ?? '').toUpperCase() === want);
232
+ if (want)
233
+ cs = cs.filter(
234
+ (c) =>
235
+ String(c.status ?? (Array.isArray(c.flows) ? c.flows.at(-1)?.status : undefined) ?? '').toUpperCase() === want,
236
+ );
205
237
  cs.sort((a, b) => String(b.start).localeCompare(String(a.start)));
206
- return {total: cs.length, byType, shown: Math.min(limit, cs.length), items: cs.slice(0, limit).map(views.conversationView)};
238
+ return {
239
+ total: cs.length,
240
+ byType,
241
+ shown: Math.min(limit, cs.length),
242
+ items: cs.slice(0, limit).map(views.conversationView),
243
+ };
207
244
  }
208
245
 
209
246
  // `presence`: one entry per extension asked for, in the order given; `status: unknown` for nobody.
210
247
  export function presenceResult(items, extensions, views = makeViews()) {
211
248
  const found = new Map(items.filter(Boolean).map((p) => [String(p.extension), p]));
212
- return {presence: extensions.map((ext) => (found.has(ext) ? views.presenceView(found.get(ext)) : {extension: ext, status: 'unknown'}))};
249
+ return {
250
+ presence: extensions.map((ext) =>
251
+ found.has(ext) ? views.presenceView(found.get(ext)) : {extension: ext, status: 'unknown'},
252
+ ),
253
+ };
213
254
  }
214
255
 
215
256
  // The WDA id of a call from the conversations snapshot, by the SIP call id of one of its parties.
216
257
  export function callIdBySip(items, sipCallId) {
217
- const call = items.find((c) => c?.event === 'call' && Array.isArray(c.flows) && c.flows.some((f) => f.caller?.sipCallId === sipCallId || f.callee?.sipCallId === sipCallId));
258
+ const call = items.find(
259
+ (c) =>
260
+ c?.event === 'call' &&
261
+ Array.isArray(c.flows) &&
262
+ c.flows.some((f) => f.caller?.sipCallId === sipCallId || f.callee?.sipCallId === sipCallId),
263
+ );
218
264
  return call?.id;
219
265
  }
220
266
 
221
267
  // `transcript`: what was said so far (final lines only), the call and its annotations.
222
268
  export function transcriptSnapshot(items, id, kind, views = makeViews()) {
223
269
  const call = items.find((i) => i?.event === 'call' || i?.event === 'conference');
224
- const lines = items.flatMap(chunksOf).filter((c) => c.text).sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
270
+ const lines = items
271
+ .flatMap(chunksOf)
272
+ .filter((c) => c.text)
273
+ .sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
225
274
  const annotations = items.filter((i) => String(i?.event).endsWith('_annotation'));
226
275
  return {
227
276
  call,
@@ -243,9 +292,16 @@ export function transcriptEvents(it, id, state, views = makeViews()) {
243
292
  state.call = it;
244
293
  return {events: [{event: 'CALL', data: views.conversationView(it)}], end: false};
245
294
  }
246
- if (/_complete$|_interrupted$/.test(String(it.event))) return {events: [{event: 'END', data: {id, event: it.event}}], end: true};
247
- if (String(it.event).endsWith('_annotation')) return {events: [{event: 'ANNOTATION', data: drop({key: it.key, payload: it.payload})}], end: false};
248
- return {events: chunksOf(it).filter((c) => c.text).map((c) => ({event: 'TEXT', data: views.textView(c, state.call)})), end: false};
295
+ if (/_complete$|_interrupted$/.test(String(it.event)))
296
+ return {events: [{event: 'END', data: {id, event: it.event}}], end: true};
297
+ if (String(it.event).endsWith('_annotation'))
298
+ return {events: [{event: 'ANNOTATION', data: drop({key: it.key, payload: it.payload})}], end: false};
299
+ return {
300
+ events: chunksOf(it)
301
+ .filter((c) => c.text)
302
+ .map((c) => ({event: 'TEXT', data: views.textView(c, state.call)})),
303
+ end: false,
304
+ };
249
305
  }
250
306
 
251
307
  // `watch`: turns the frames of the connection into events. o: {views, wantConv, wantQueues, wantStats, calls, mine,
@@ -280,19 +336,33 @@ export function watchFrames(o) {
280
336
  if (it.event === 'service_stats_update') {
281
337
  if (!o.wantStats) continue;
282
338
  const agentStats = it.stats?.agentStats ?? {};
283
- events.push(ev('QUEUE_STATS', full ? it : drop({
284
- id: it.id,
285
- name: queueNames.get(it.id),
286
- ...queueFigures(it.stats, views.now()),
287
- agents: Object.keys(agentStats).length
288
- ? Object.fromEntries(Object.entries(agentStats).map(([k, v]) => [k.startsWith(`${it.id}_`) ? k.slice(String(it.id).length + 1) : k, agentFigures(v)]))
289
- : undefined,
290
- })));
339
+ events.push(
340
+ ev(
341
+ 'QUEUE_STATS',
342
+ full
343
+ ? it
344
+ : drop({
345
+ id: it.id,
346
+ name: queueNames.get(it.id),
347
+ ...queueFigures(it.stats, views.now()),
348
+ agents: Object.keys(agentStats).length
349
+ ? Object.fromEntries(
350
+ Object.entries(agentStats).map(([k, v]) => [
351
+ k.startsWith(`${it.id}_`) ? k.slice(String(it.id).length + 1) : k,
352
+ agentFigures(v),
353
+ ]),
354
+ )
355
+ : undefined,
356
+ }),
357
+ ),
358
+ );
291
359
  continue;
292
360
  }
293
361
  // A change names what changed (eventTrigger) and carries only that: usually one agent, no queue name.
294
- if (it.event === 'service') events.push(ev('QUEUE', full ? it : drop({trigger: it.eventTrigger, ...views.queueView(it)})));
295
- else if (it.event === 'serviceCall' || it.event === 'service_call') events.push(ev('QUEUE_CALL', views.serviceCallView(it)));
362
+ if (it.event === 'service')
363
+ events.push(ev('QUEUE', full ? it : drop({trigger: it.eventTrigger, ...views.queueView(it)})));
364
+ else if (it.event === 'serviceCall' || it.event === 'service_call')
365
+ events.push(ev('QUEUE_CALL', views.serviceCallView(it)));
296
366
  else events.push(ev(String(it.event).toUpperCase(), full ? it : drop({id: it.id, trigger: it.eventTrigger})));
297
367
  } else if (d.topic === 'conversations') {
298
368
  const type = String(it.event);
@@ -8,7 +8,16 @@
8
8
  // Events: registered, ready (the first dialog state after each registration), callStart, callUpdate, callEnd,
9
9
  // devicesChanged, presenceChanged, activeDeviceChanged, reconnecting {attempt}, error (an Error).
10
10
  import {EventEmitter} from 'node:events';
11
- import {CallTracker, ValueTracker, deviceName, devicesEqual, parseActiveDevice, parseDialogInfo, parseRegInfo, toActiveDevice} from './events.mjs';
11
+ import {
12
+ CallTracker,
13
+ ValueTracker,
14
+ deviceName,
15
+ devicesEqual,
16
+ parseActiveDevice,
17
+ parseDialogInfo,
18
+ parseRegInfo,
19
+ toActiveDevice,
20
+ } from './events.mjs';
12
21
  import {ALLOWED, SipUserAgent, paramOf, randomToken, tagOf, uriOf} from './sip.mjs';
13
22
 
14
23
  export const TAPI_EVENT = 'wildixtsp/action';
@@ -24,7 +33,9 @@ export function backoffMs(attempt) {
24
33
 
25
34
  const bounded = (work, ms) => {
26
35
  let timer;
27
- return Promise.race([work.catch(() => undefined), new Promise((r) => (timer = setTimeout(r, ms)))]).finally(() => clearTimeout(timer));
36
+ return Promise.race([work.catch(() => undefined), new Promise((r) => (timer = setTimeout(r, ms)))]).finally(() =>
37
+ clearTimeout(timer),
38
+ );
28
39
  };
29
40
 
30
41
  export class CallControl extends EventEmitter {
@@ -33,7 +44,8 @@ export class CallControl extends EventEmitter {
33
44
  super();
34
45
  this.config = config;
35
46
  this.expires = config.expires ?? 3600;
36
- if (!Number.isInteger(this.expires) || this.expires <= 0) throw new Error(`expires must be a positive integer: ${config.expires}`);
47
+ if (!Number.isInteger(this.expires) || this.expires <= 0)
48
+ throw new Error(`expires must be a positive integer: ${config.expires}`);
37
49
  this.disconnectTimeoutMs = config.disconnectTimeoutMs ?? 10_000;
38
50
  this.backoff = config.backoff ?? backoffMs;
39
51
  this.aor = `sip:${config.extension}@${config.domain}`;
@@ -118,7 +130,11 @@ export class CallControl extends EventEmitter {
118
130
  from: `<${this.aor}>;tag=${r.fromTag}`,
119
131
  callId: r.callId,
120
132
  seq: r.seq,
121
- headers: ['X-Disable-DirectRTP: yes', `Contact: ${ua.contact};expires=${expires}`, `Allow: ACK,CANCEL,INVITE,MESSAGE,BYE,OPTIONS,INFO,NOTIFY,REFER`],
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
+ ],
122
138
  });
123
139
  if (res.status === 423 && expires > 0) {
124
140
  const min = Number(res.header('min-expires'));
@@ -126,22 +142,38 @@ export class CallControl extends EventEmitter {
126
142
  }
127
143
  if (res.status < 200 || res.status >= 300) throw new Error(`REGISTER rejected: ${res.status} ${res.reason}`);
128
144
  if (expires === 0) return;
129
- const ours = res.headers('contact').flatMap((v) => v.split(/,(?![^<]*>)/)).find((v) => uriOf(v).includes(ua.viaHost));
145
+ const ours = res
146
+ .headers('contact')
147
+ .flatMap((v) => v.split(/,(?![^<]*>)/))
148
+ .find((v) => uriOf(v).includes(ua.viaHost));
130
149
  const granted = Number(paramOf(ours, 'expires') ?? res.header('expires') ?? expires) || expires;
131
150
  this.cancel(this.registerTimer);
132
- this.registerTimer = this.later(() => {
133
- if (this.stopping || generation !== this.generation) return;
134
- this.register(generation).catch((e) => {
135
- if (generation !== this.generation || this.stopping) return;
136
- this.emit('error', e);
137
- this.scheduleReconnect();
138
- });
139
- }, Math.max(1, granted * 0.9) * 1000);
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
+ );
140
162
  }
141
163
 
142
164
  subscribe(pkg, generation, attempt) {
143
165
  if (!this.ua || this.stopping || generation !== this.generation) return;
144
- const sub = {pkg, generation, attempt, callId: randomToken(22), fromTag: randomToken(10), seq: {cseq: 0}, remoteTag: undefined, target: undefined, routes: []};
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
+ };
145
177
  this.subs.set(sub.callId, sub);
146
178
  this.sendSubscribe(sub, this.expires).then(
147
179
  (res) => {
@@ -176,23 +208,32 @@ export class CallControl extends EventEmitter {
176
208
  from: `<${this.aor}>;tag=${sub.fromTag}`,
177
209
  callId: sub.callId,
178
210
  seq: sub.seq,
179
- headers: [`Allow: ${ALLOWED.join(',')}`, `Event: ${sub.pkg}`, `Expires: ${expires}`, `Contact: ${this.ua.contact}`],
211
+ headers: [
212
+ `Allow: ${ALLOWED.join(',')}`,
213
+ `Event: ${sub.pkg}`,
214
+ `Expires: ${expires}`,
215
+ `Contact: ${this.ua.contact}`,
216
+ ],
180
217
  });
181
218
  }
182
219
 
183
220
  scheduleRefresh(sub, seconds) {
184
221
  this.cancel(sub.timer);
185
- sub.timer = this.later(() => {
186
- if (this.stopping || this.subs.get(sub.callId) !== sub) return;
187
- this.sendSubscribe(sub, this.expires).then(
188
- (res) => {
189
- if (this.subs.get(sub.callId) !== sub) return;
190
- if (res.status >= 200 && res.status < 300) this.scheduleRefresh(sub, Number(res.header('expires')) || this.expires);
191
- else this.terminated(sub);
192
- },
193
- () => this.subs.get(sub.callId) === sub && this.terminated(sub),
194
- );
195
- }, Math.max(1, seconds * 0.9) * 1000);
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
+ );
196
237
  }
197
238
 
198
239
  // A subscription that ended: subscribe again, after a backoff, while connected.
@@ -252,7 +293,12 @@ export class CallControl extends EventEmitter {
252
293
  onReg(body) {
253
294
  if (!body.trim() || !this.ua) return;
254
295
  const via = this.ua.viaHost;
255
- const devices = parseRegInfo(body).map((d) => ({uri: d.uri, userAgent: d.userAgent, deviceName: deviceName(d.userAgent), isLocalRegistration: d.uri.includes(via)}));
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
+ }));
256
302
  if (this.devices.apply(devices, devicesEqual)) this.emit('devicesChanged', devices);
257
303
  }
258
304
 
@@ -302,15 +348,24 @@ export class CallControl extends EventEmitter {
302
348
  async tapi(command, ...params) {
303
349
  if (!this.ua) throw new Error('not connected to the PBX');
304
350
  const headers = [`W-TapiCommand: ${command}`];
305
- params.forEach((p, i) => p !== undefined && headers.push(`W-TapiParam${i + 1}: ${p}`));
351
+ for (const [i, p] of params.entries()) if (p !== undefined) headers.push(`W-TapiParam${i + 1}: ${p}`);
306
352
  headers.push(`Event: ${TAPI_EVENT}`, 'Expires: 1');
307
353
  for (const h of headers) if (/[\r\n]/.test(h)) throw new Error('a SIP header value cannot hold a line break');
308
- const res = await this.ua.request('PUBLISH', {ruri: this.aor, to: `<${this.aor}>`, from: `<${this.aor}>;tag=${randomToken(10)}`, callId: randomToken(22), seq: {cseq: 0}, headers});
309
- if (res.status < 200 || res.status >= 300) throw new Error(`TAPI command "${command}" rejected: ${res.status} ${res.reason}`);
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}`);
310
364
  }
311
365
 
312
366
  record(callId, action) {
313
- if (!RECORD_ACTIONS.includes(action)) return Promise.reject(new Error(`record action must be one of ${RECORD_ACTIONS.join(', ')}`));
367
+ if (!RECORD_ACTIONS.includes(action))
368
+ return Promise.reject(new Error(`record action must be one of ${RECORD_ACTIONS.join(', ')}`));
314
369
  return this.tapi('record', callId, action);
315
370
  }
316
371
 
@@ -365,9 +420,14 @@ export class CallControl extends EventEmitter {
365
420
  const ua = this.ua;
366
421
  if (ua && !ua.closed) {
367
422
  const subs = [...this.subs.values()].filter((s) => s.remoteTag);
368
- const unregistered = this.registration ? this.register(this.generation, 0).catch(() => undefined) : Promise.resolve();
423
+ const unregistered = this.registration
424
+ ? this.register(this.generation, 0).catch(() => undefined)
425
+ : Promise.resolve();
369
426
  const unsubscribed = Promise.all(subs.map((s) => this.sendSubscribe(s, 0).catch(() => undefined)));
370
- await bounded(Promise.all([unregistered, bounded(unsubscribed, SUBSCRIPTION_DISPOSE_GRACE_MS)]), this.disconnectTimeoutMs);
427
+ await bounded(
428
+ Promise.all([unregistered, bounded(unsubscribed, SUBSCRIPTION_DISPOSE_GRACE_MS)]),
429
+ this.disconnectTimeoutMs,
430
+ );
371
431
  }
372
432
  this.subs.clear();
373
433
  this.registration = undefined;
@@ -54,7 +54,11 @@ export function parseDialogInfo(xml) {
54
54
  presence = applyPresence(targetParams, presence);
55
55
  let tags;
56
56
  for (const p of targetParams) {
57
- if (p.attrs.pname === PARAM.TAGS && p.attrs.pval) tags = String(p.attrs.pval).split(',').map((t) => t.trim()).filter(Boolean);
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);
58
62
  }
59
63
  const id = d.attrs['call-id'];
60
64
  if (!id) continue;
@@ -104,7 +108,11 @@ export function parseActiveDevice(xml) {
104
108
  const root = parseXml(xml);
105
109
  const type = text(child(root, 'type'));
106
110
  if (!type) return null;
107
- return {type, userAgent: text(child(root, 'user-agent')) || undefined, contactUri: text(child(root, 'contact')) || undefined};
111
+ return {
112
+ type,
113
+ userAgent: text(child(root, 'user-agent')) || undefined,
114
+ contactUri: text(child(root, 'contact')) || undefined,
115
+ };
108
116
  }
109
117
 
110
118
  // A readable device name from a user agent: version numbers and UUIDs dropped.
@@ -113,19 +121,36 @@ const UUID_LIKE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
113
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;
114
122
  export function deviceName(userAgent) {
115
123
  if (!userAgent) return 'Unknown device';
116
- const words = userAgent.split(' ').map((w) => w.replace(TRAILING_UUID, '')).filter((w) => w && !VERSION_LIKE.test(w) && !UUID_LIKE.test(w));
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));
117
128
  return words.length ? words.join(' ') : userAgent;
118
129
  }
119
130
 
120
131
  const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
121
132
 
122
133
  function toCall(d) {
123
- const state = d.rawState === 'confirmed' ? (d.hold ? 'held' : 'answered') : d.rawState === 'terminated' ? 'ended' : 'ringing';
124
- return {id: d.id, direction: d.direction, state, remoteNumber: d.remoteNumber, remoteName: d.remoteName, tags: d.tags, recording: d.recording, endReason: d.rawStateReason};
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
+ };
125
146
  }
126
147
 
127
148
  const callChanged = (a, b) =>
128
- a.state !== b.state || a.remoteNumber !== b.remoteNumber || a.remoteName !== b.remoteName || a.recording !== b.recording || !same(a.tags ?? [], b.tags ?? []);
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 ?? []);
129
154
 
130
155
  // The calls: each dialog NOTIFY is the full list; this returns what started, changed and ended since the last one.
131
156
  export class CallTracker {
@@ -186,4 +211,10 @@ export function devicesEqual(a = [], b = []) {
186
211
  }
187
212
 
188
213
  export const toActiveDevice = (parsed) =>
189
- parsed ? {type: ACTIVE_DEVICE_TYPES.includes(parsed.type) ? parsed.type : 'unknown', contactUri: parsed.contactUri, userAgent: parsed.userAgent} : undefined;
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;
package/src/phone/pbx.mjs CHANGED
@@ -2,10 +2,10 @@
2
2
  // skill calls it (scripts/lib.sh pbx_call): the PBX from the claims of the login token, the same token as Bearer,
3
3
  // answers {"type":"result","result":X} unwrapped, {"type":"error","reason":…} (or a status other than 2xx) turned
4
4
  // into an error with a hint per status. Also the checks of call ids, numbers and device contacts of the skill.
5
- import {jwtClaims} from '../auth.mjs';
6
- import {AuthError, UsageError} from '../errors.mjs';
7
- import {request} from '../http.mjs';
8
- import {pbxUrl} from '../services.mjs';
5
+ import {jwtClaims} from 'wldx-core/auth';
6
+ import {AuthError, UsageError} from 'wldx-core/errors';
7
+ import {request} from 'wldx-core/http';
8
+ import {pbxUrl} from 'wldx-core/services';
9
9
 
10
10
  // The SIP watchers that register as a device of the user: wx's own and the one of the skill (sip.sh). They are
11
11
  // marked in the device lists and never chosen to ring or to place a call.
@@ -31,7 +31,7 @@ function hint(status, reason) {
31
31
  if (status === 404) {
32
32
  return reason.includes('404 Not Found')
33
33
  ? 'This PBX has no Call Control API: it needs a newer WMS version.'
34
- : 'The PBX does not see this call among the user\'s current calls: list them again (wx phone list).';
34
+ : "The PBX does not see this call among the user's current calls: list them again (wx phone list).";
35
35
  }
36
36
  return HINTS[status];
37
37
  }
@@ -42,7 +42,15 @@ export async function pbxCall(ctx, method, path, body) {
42
42
  const base = pbxUrl(ctx.token, ctx.env);
43
43
  let res;
44
44
  try {
45
- res = await request(`${base}/api/v2/call-control${path}`, {method, token: ctx.token, body, fetchImpl: ctx.fetch, responseType: 'response', timeoutMs: 30_000, service: 'the PBX'});
45
+ res = await request(`${base}/api/v2/call-control${path}`, {
46
+ method,
47
+ token: ctx.token,
48
+ body,
49
+ fetchImpl: ctx.fetch,
50
+ responseType: 'response',
51
+ timeoutMs: 30_000,
52
+ service: 'the PBX',
53
+ });
46
54
  } catch {
47
55
  throw new PbxError(`request failed: ${method} ${path} (the PBX ${base} did not answer)`);
48
56
  }
@@ -75,7 +83,8 @@ export async function pbxAction(ctx, method, path, body) {
75
83
  // A SIP call id as list-calls gives it (the PBX checks the same pattern).
76
84
  export function checkCallId(id) {
77
85
  const v = String(id ?? '');
78
- if (!/^[A-Za-z0-9.!%*_'+~-]+(@[A-Za-z0-9.-]+)?$/.test(v) || v.length > 100) throw new UsageError(`not a call id (take sipCallId from: wx phone list): ${id}`);
86
+ if (!/^[A-Za-z0-9.!%*_'+~-]+(@[A-Za-z0-9.-]+)?$/.test(v) || v.length > 100)
87
+ throw new UsageError(`not a call id (take sipCallId from: wx phone list): ${id}`);
79
88
  return v;
80
89
  }
81
90
 
@@ -89,7 +98,8 @@ export function normNumber(input) {
89
98
 
90
99
  export function checkContact(contact) {
91
100
  const v = String(contact ?? '');
92
- if (!/^sip:\S+$/.test(v) || v.length > 255) throw new UsageError(`a device is its SIP contact, as wx phone devices gives it (sip:…): ${contact}`);
101
+ if (!/^sip:\S+$/.test(v) || v.length > 255)
102
+ throw new UsageError(`a device is its SIP contact, as wx phone devices gives it (sip:…): ${contact}`);
93
103
  return v;
94
104
  }
95
105
 
@@ -102,7 +112,8 @@ export function checkDigits(digits) {
102
112
  // The extension of the login (custom:pbx_user_extension).
103
113
  export function extensionOf(token) {
104
114
  const ext = jwtClaims(token)['custom:pbx_user_extension'];
105
- if (!/^[0-9]+$/.test(String(ext ?? ''))) throw new UsageError('this login has no PBX extension (custom:pbx_user_extension)');
115
+ if (!/^[0-9]+$/.test(String(ext ?? '')))
116
+ throw new UsageError('this login has no PBX extension (custom:pbx_user_extension)');
106
117
  return String(ext);
107
118
  }
108
119
 
@@ -113,7 +124,13 @@ export async function sipPassword(ctx, extension) {
113
124
  try {
114
125
  // Without `Connection: close` the socket of this request may be reused for the SIP WebSocket to the same host,
115
126
  // which the PBX then closes at once (seen with the skill).
116
- res = await request(`${base}/api/v1/personal/info?fields=extension%2CsipPassword`, {token: ctx.token, fetchImpl: ctx.fetch, headers: {connection: 'close'}, responseType: 'response', timeoutMs: 30_000});
127
+ res = await request(`${base}/api/v1/personal/info?fields=extension%2CsipPassword`, {
128
+ token: ctx.token,
129
+ fetchImpl: ctx.fetch,
130
+ headers: {connection: 'close'},
131
+ responseType: 'response',
132
+ timeoutMs: 30_000,
133
+ });
117
134
  } catch {
118
135
  throw new PbxError(`request failed: GET /api/v1/personal/info (the PBX ${base} did not answer)`);
119
136
  }
@@ -127,7 +144,8 @@ export async function sipPassword(ctx, extension) {
127
144
  }
128
145
  const info = body?.result ?? body;
129
146
  if (!info?.sipPassword) throw new PbxError('the PBX did not give the SIP password of this user');
130
- if (String(info.extension) !== String(extension)) throw new PbxError(`the PBX answered for extension ${info.extension}, not ${extension}`);
147
+ if (String(info.extension) !== String(extension))
148
+ throw new PbxError(`the PBX answered for extension ${info.extension}, not ${extension}`);
131
149
  return String(info.sipPassword);
132
150
  }
133
151