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.
- package/README.md +19 -22
- package/package.json +9 -6
- package/src/chat/format.mjs +40 -8
- package/src/chat/lib.mjs +71 -23
- package/src/chat/watch.mjs +46 -15
- package/src/cli.mjs +13 -252
- package/src/commands/calls.mjs +135 -32
- package/src/commands/channel.mjs +99 -21
- package/src/commands/chat.mjs +77 -22
- package/src/commands/conversations.mjs +146 -29
- package/src/commands/file.mjs +13 -5
- package/src/commands/inbox.mjs +20 -13
- package/src/commands/index.mjs +5 -29
- package/src/commands/live.mjs +82 -24
- package/src/commands/meetings.mjs +114 -27
- package/src/commands/phone.mjs +145 -33
- package/src/commands/reports.mjs +219 -44
- package/src/commands/section.mjs +49 -8
- package/src/commands/user.mjs +42 -13
- package/src/history/text.mjs +30 -4
- package/src/history/wda.mjs +20 -10
- package/src/live/socket.mjs +25 -7
- package/src/live/stats.mjs +41 -9
- package/src/live/text.mjs +122 -23
- package/src/live/views.mjs +93 -23
- package/src/phone/control.mjs +93 -33
- package/src/phone/events.mjs +38 -7
- package/src/phone/pbx.mjs +29 -11
- package/src/phone/sip.mjs +87 -17
- package/src/phone/xml.mjs +2 -1
- package/src/stream.mjs +10 -5
- package/src/version.mjs +0 -1
- package/src/auth.mjs +0 -274
- package/src/commands/login.mjs +0 -126
- package/src/commands/profile.mjs +0 -51
- package/src/commands/status.mjs +0 -95
- package/src/completion.mjs +0 -132
- package/src/errors.mjs +0 -30
- package/src/files.mjs +0 -98
- package/src/format.mjs +0 -82
- package/src/http.mjs +0 -64
- package/src/services.mjs +0 -58
package/src/live/views.mjs
CHANGED
|
@@ -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({
|
|
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) &&
|
|
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 {
|
|
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')
|
|
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(
|
|
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)
|
|
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 {
|
|
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 {
|
|
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(
|
|
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
|
|
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)))
|
|
247
|
-
|
|
248
|
-
|
|
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(
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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')
|
|
295
|
-
|
|
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);
|
package/src/phone/control.mjs
CHANGED
|
@@ -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 {
|
|
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(() =>
|
|
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)
|
|
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: [
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 = {
|
|
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: [
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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) => ({
|
|
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.
|
|
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', {
|
|
309
|
-
|
|
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))
|
|
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
|
|
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(
|
|
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;
|
package/src/phone/events.mjs
CHANGED
|
@@ -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)
|
|
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 {
|
|
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
|
|
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 =
|
|
124
|
-
|
|
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 ||
|
|
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
|
|
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 '
|
|
6
|
-
import {AuthError, UsageError} from '
|
|
7
|
-
import {request} from '
|
|
8
|
-
import {pbxUrl} from '
|
|
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
|
-
:
|
|
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}`, {
|
|
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)
|
|
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)
|
|
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 ?? '')))
|
|
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`, {
|
|
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))
|
|
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
|
|