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
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
// wx meetings: conferences (x-bees meetings) after the fact: the record, a readable card, the transcript, the AI
|
|
2
2
|
// summary and insights, who spoke when, the recording link. Read only, except schedule-summary (asks the service
|
|
3
3
|
// to generate the summary again). Data of WDA History and WDA Insights.
|
|
4
|
-
import {UsageError} from '
|
|
5
|
-
import {localTime, oneLine, table} from '
|
|
6
|
-
import {apiPath, query} from '
|
|
4
|
+
import {UsageError} from 'wldx-core/errors';
|
|
5
|
+
import {localTime, oneLine, table} from 'wldx-core/format';
|
|
6
|
+
import {apiPath, query} from 'wldx-core/http';
|
|
7
7
|
import {insightLines, millis, sectionLines, summaryLines} from '../history/text.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
NoDataError,
|
|
10
|
+
alt,
|
|
11
|
+
compactInsights,
|
|
12
|
+
conferenceId,
|
|
13
|
+
isoSeconds,
|
|
14
|
+
optional,
|
|
15
|
+
participantNames,
|
|
16
|
+
plural,
|
|
17
|
+
wdaClient,
|
|
18
|
+
writePrivate,
|
|
19
|
+
} from '../history/wda.mjs';
|
|
9
20
|
import {annotationLines, insightsText, stateText, structuredLines} from './calls.mjs';
|
|
10
21
|
|
|
11
22
|
const CONF = '<conference>';
|
|
@@ -45,7 +56,11 @@ export function briefConference(id, rec, sum, ins) {
|
|
|
45
56
|
hasRecording: (c.recordings ?? []).length > 0,
|
|
46
57
|
summary:
|
|
47
58
|
sum?.status === 'SUCCEEDED'
|
|
48
|
-
? {
|
|
59
|
+
? {
|
|
60
|
+
title: s.title ?? null,
|
|
61
|
+
brief: s.brief ?? null,
|
|
62
|
+
...Object.fromEntries(SUMMARY_KEYS.map((k) => [k, s.json?.[k] ?? []])),
|
|
63
|
+
}
|
|
49
64
|
: {status: sum?.status ?? 'UNKNOWN'},
|
|
50
65
|
insights: compactInsights(ins),
|
|
51
66
|
};
|
|
@@ -53,7 +68,11 @@ export function briefConference(id, rec, sum, ins) {
|
|
|
53
68
|
|
|
54
69
|
async function briefOne(wda, id, info) {
|
|
55
70
|
const base = apiPath`/${id}`;
|
|
56
|
-
const [rec, ins, sum] = await Promise.all([
|
|
71
|
+
const [rec, ins, sum] = await Promise.all([
|
|
72
|
+
wda.get(`/history/conferences${base}`),
|
|
73
|
+
optional(wda.get(`/insights/conferences${base}/insights`), info),
|
|
74
|
+
optional(wda.get(`/insights/conferences${base}/summary`), info),
|
|
75
|
+
]);
|
|
57
76
|
return briefConference(id, rec, sum, ins);
|
|
58
77
|
}
|
|
59
78
|
|
|
@@ -76,9 +95,22 @@ export function transcriptHeader(id, c, lines) {
|
|
|
76
95
|
const NO_TRANSCRIPT = 'This conference has no transcription, or there is no such conference.';
|
|
77
96
|
|
|
78
97
|
function briefLines(b) {
|
|
79
|
-
const head = [
|
|
98
|
+
const head = [
|
|
99
|
+
localTime(b.start),
|
|
100
|
+
`${b.durationMin} min`,
|
|
101
|
+
b.status,
|
|
102
|
+
b.direction,
|
|
103
|
+
b.transcriptionStatus &&
|
|
104
|
+
`transcript ${b.transcriptionStatus}${b.transcriptionLanguage ? ` (${b.transcriptionLanguage})` : ''}`,
|
|
105
|
+
b.hasRecording && 'recording',
|
|
106
|
+
]
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join(' · ');
|
|
80
109
|
const lines = [oneLine(b.subject || 'Conference', 200), ` ${head}`, ` ${b.id}`];
|
|
81
|
-
if (b.participants.length)
|
|
110
|
+
if (b.participants.length)
|
|
111
|
+
lines.push(
|
|
112
|
+
` Participants: ${b.participants.map((p) => `${p.name}${p.speakMin ? ` (${p.speakMin} min)` : ''}`).join(', ')}`,
|
|
113
|
+
);
|
|
82
114
|
if (b.summary.status) lines.push(` Summary: ${b.summary.status}`);
|
|
83
115
|
else {
|
|
84
116
|
if (b.summary.title) lines.push(` Summary: ${oneLine(b.summary.title, 200)}`);
|
|
@@ -91,7 +123,8 @@ function briefLines(b) {
|
|
|
91
123
|
}
|
|
92
124
|
|
|
93
125
|
const conf = (ctx) => conferenceId(ctx.positionals[0]);
|
|
94
|
-
const get = (ctx, suffix) =>
|
|
126
|
+
const get = (ctx, suffix) =>
|
|
127
|
+
ctx.client(wdaClient).get(`/${suffix[0]}/conferences${apiPath`/${conf(ctx)}`}${suffix[1] ?? ''}`);
|
|
95
128
|
|
|
96
129
|
const group = {
|
|
97
130
|
summary: 'meetings (conferences) after the fact: record, transcript, summary, insights, recording',
|
|
@@ -146,8 +179,15 @@ const group = {
|
|
|
146
179
|
usage: `wx meetings transcript ${CONF} [--to FILE] [--structured]`,
|
|
147
180
|
details: `${CONF_HINT} Text: a header (subject, id, link, start in UTC, duration, language, participants, lines) and the dialogue "[MM:SS] Name: text". --output json: the header fields under "conference", and the text and its chunks as the service gives them. --structured: the transcription record with the interaction data. --to writes the header and the dialogue to a new private file (an existing file only when it is the transcript of the same meeting) and prints its path.`,
|
|
148
181
|
options: {
|
|
149
|
-
to: {
|
|
150
|
-
|
|
182
|
+
to: {
|
|
183
|
+
type: 'string',
|
|
184
|
+
placeholder: 'FILE',
|
|
185
|
+
description: 'write the transcript to this file (private) and print its path',
|
|
186
|
+
},
|
|
187
|
+
structured: {
|
|
188
|
+
type: 'boolean',
|
|
189
|
+
description: 'the transcription record: chunks with timing, sentiment, the interaction',
|
|
190
|
+
},
|
|
151
191
|
},
|
|
152
192
|
args: [1, 1],
|
|
153
193
|
async run(ctx) {
|
|
@@ -156,26 +196,47 @@ const group = {
|
|
|
156
196
|
const base = apiPath`/${id}`;
|
|
157
197
|
if (ctx.values.structured) {
|
|
158
198
|
if (ctx.values.to) throw new UsageError('--to writes the dialogue: leave out --structured');
|
|
159
|
-
const [r, rec] = await Promise.all([
|
|
199
|
+
const [r, rec] = await Promise.all([
|
|
200
|
+
wda.soft(`/history/conferences${base}/transcription`),
|
|
201
|
+
ctx.output === 'text' ? optional(wda.get(`/history/conferences${base}`), ctx.info) : null,
|
|
202
|
+
]);
|
|
160
203
|
if (!r) throw new NoDataError(NO_TRANSCRIPT);
|
|
161
|
-
if (rec)
|
|
204
|
+
if (rec)
|
|
205
|
+
Object.defineProperty(r, 'names', {
|
|
206
|
+
value: Object.fromEntries(
|
|
207
|
+
(rec.conference?.participants ?? []).map((p) => [p.jid, alt(p.info?.name, p.info?.email, p.jid)]),
|
|
208
|
+
),
|
|
209
|
+
enumerable: false,
|
|
210
|
+
});
|
|
162
211
|
return r;
|
|
163
212
|
}
|
|
164
|
-
const [text, rec] = await Promise.allSettled([
|
|
213
|
+
const [text, rec] = await Promise.allSettled([
|
|
214
|
+
wda.soft(`/history/conferences${base}/transcription/text`),
|
|
215
|
+
wda.get(`/history/conferences${base}`),
|
|
216
|
+
]);
|
|
165
217
|
if (text.status === 'rejected') throw text.reason;
|
|
166
218
|
if (!text.value) throw new NoDataError(NO_TRANSCRIPT);
|
|
167
219
|
if (rec.status === 'rejected') throw rec.reason;
|
|
168
220
|
const c = rec.value.conference ?? {};
|
|
169
221
|
const body = text.value;
|
|
170
222
|
const dialogue = (body.text ?? '').trim();
|
|
171
|
-
if (c.transcriptionStatus === 'UNAVAILABLE')
|
|
223
|
+
if (c.transcriptionStatus === 'UNAVAILABLE')
|
|
224
|
+
throw new NoDataError(
|
|
225
|
+
"Transcription is not available for this conference (transcriptionStatus 'UNAVAILABLE'): it was most likely never enabled on the call.",
|
|
226
|
+
);
|
|
172
227
|
if (!dialogue) {
|
|
173
|
-
if (c.transcriptionStatus === 'POST_TRANSCRIPTION')
|
|
228
|
+
if (c.transcriptionStatus === 'POST_TRANSCRIPTION')
|
|
229
|
+
throw new NoDataError(
|
|
230
|
+
"The transcription is still being processed (transcriptionStatus 'POST_TRANSCRIPTION'): try again later.",
|
|
231
|
+
);
|
|
174
232
|
throw new NoDataError('The transcription is empty: nothing was transcribed on this call.');
|
|
175
233
|
}
|
|
176
234
|
const lines = (body.chunks ?? []).length;
|
|
177
235
|
const header = transcriptHeader(id, c, lines);
|
|
178
|
-
if (ctx.values.to)
|
|
236
|
+
if (ctx.values.to)
|
|
237
|
+
return {
|
|
238
|
+
file: writePrivate(ctx.values.to, `${header}${dialogue}\n`, {cwd: ctx.cwd, conferenceId: id, env: ctx.env}),
|
|
239
|
+
};
|
|
179
240
|
const out = {
|
|
180
241
|
conference: {
|
|
181
242
|
id,
|
|
@@ -195,7 +256,9 @@ const group = {
|
|
|
195
256
|
text(r, ctx) {
|
|
196
257
|
if (r.file) return r.file;
|
|
197
258
|
if (ctx.values.structured) {
|
|
198
|
-
return (r.transcription?.transcriptions ?? []).flatMap((t) =>
|
|
259
|
+
return (r.transcription?.transcriptions ?? []).flatMap((t) =>
|
|
260
|
+
structuredLines(t.chunks ?? [], t.start, r.names ?? {}),
|
|
261
|
+
);
|
|
199
262
|
}
|
|
200
263
|
return `${r.header}${(r.text ?? '').trim()}`;
|
|
201
264
|
},
|
|
@@ -243,10 +306,15 @@ const group = {
|
|
|
243
306
|
async run(ctx) {
|
|
244
307
|
const wda = ctx.client(wdaClient);
|
|
245
308
|
const base = apiPath`/${conf(ctx)}`;
|
|
246
|
-
const [r, rec] = await Promise.all([
|
|
309
|
+
const [r, rec] = await Promise.all([
|
|
310
|
+
wda.get(`/insights/conferences${base}/timeframes`),
|
|
311
|
+
ctx.output === 'text' ? optional(wda.get(`/history/conferences${base}`), ctx.info) : null,
|
|
312
|
+
]);
|
|
247
313
|
if (rec) {
|
|
248
314
|
const c = rec.conference ?? {};
|
|
249
|
-
const names = Object.fromEntries(
|
|
315
|
+
const names = Object.fromEntries(
|
|
316
|
+
(c.participants ?? []).map((p) => [p.jid, alt(p.info?.name, p.info?.email, p.jid)]),
|
|
317
|
+
);
|
|
250
318
|
Object.defineProperty(r, 'names', {value: names, enumerable: false});
|
|
251
319
|
Object.defineProperty(r, 'startTime', {value: c.startTime, enumerable: false});
|
|
252
320
|
}
|
|
@@ -261,8 +329,16 @@ const group = {
|
|
|
261
329
|
list.map((t) => {
|
|
262
330
|
const frames = t.frames ?? [];
|
|
263
331
|
const total = frames.reduce((n, f) => n + Math.max(0, (f.end ?? f.start) - f.start), 0);
|
|
264
|
-
const spans = frames
|
|
265
|
-
|
|
332
|
+
const spans = frames
|
|
333
|
+
.slice(0, 12)
|
|
334
|
+
.map((f) => `${at(f.start)}-${at(f.end ?? f.start)}`)
|
|
335
|
+
.join(' ');
|
|
336
|
+
return [
|
|
337
|
+
oneLine(r.names?.[t.jid] ?? t.jid, 40),
|
|
338
|
+
millis(total),
|
|
339
|
+
plural(frames.length, 'time'),
|
|
340
|
+
spans + (frames.length > 12 ? ' …' : ''),
|
|
341
|
+
];
|
|
266
342
|
}),
|
|
267
343
|
);
|
|
268
344
|
},
|
|
@@ -270,18 +346,28 @@ const group = {
|
|
|
270
346
|
'recording-url': {
|
|
271
347
|
summary: 'a link to download or play a recording of a meeting',
|
|
272
348
|
usage: 'wx meetings recording-url --file-id ID --category channels|recordings|wizyconfRecordings [--download]',
|
|
273
|
-
details:
|
|
349
|
+
details:
|
|
350
|
+
'The file id is the identifier of the recording file as the record gives it. The link is presigned: whoever has it can open the file until it expires.',
|
|
274
351
|
options: {
|
|
275
352
|
'file-id': {type: 'string', placeholder: 'ID', description: 'the identifier of the recording file'},
|
|
276
|
-
category: {
|
|
353
|
+
category: {
|
|
354
|
+
type: 'string',
|
|
355
|
+
choices: ['channels', 'recordings', 'wizyconfRecordings'],
|
|
356
|
+
description: 'the category of the file',
|
|
357
|
+
},
|
|
277
358
|
download: {type: 'boolean', description: 'a link that always downloads (else audio and video play)'},
|
|
278
359
|
},
|
|
279
360
|
args: [0, 0],
|
|
280
361
|
async run(ctx) {
|
|
281
362
|
const v = ctx.values;
|
|
282
363
|
if (!v['file-id'] || !v.category) throw new UsageError('--file-id and --category are required', ctx.usage());
|
|
283
|
-
if (v['file-id'].length > 1024 || /[\s\0]/.test(v['file-id']))
|
|
284
|
-
|
|
364
|
+
if (v['file-id'].length > 1024 || /[\s\0]/.test(v['file-id']))
|
|
365
|
+
throw new UsageError(`not a file id: ${v['file-id']}`);
|
|
366
|
+
return ctx
|
|
367
|
+
.client(wdaClient)
|
|
368
|
+
.get(
|
|
369
|
+
`/insights/conferences/recording/download_url${query({fileId: v['file-id'], category: v.category, download: v.download ? '1' : undefined})}`,
|
|
370
|
+
);
|
|
285
371
|
},
|
|
286
372
|
text: (r) => r.presignedDownloadUrl ?? '',
|
|
287
373
|
},
|
|
@@ -290,7 +376,8 @@ const group = {
|
|
|
290
376
|
usage: `wx meetings schedule-summary ${CONF}`,
|
|
291
377
|
options: {},
|
|
292
378
|
args: [1, 1],
|
|
293
|
-
run: (ctx) =>
|
|
379
|
+
run: (ctx) =>
|
|
380
|
+
ctx.client(wdaClient).post(`/insights/conferences${apiPath`/${conf(ctx)}`}/schedule_summary_generation`),
|
|
294
381
|
text: () => 'The summary was scheduled: read it later with wx meetings summary.',
|
|
295
382
|
},
|
|
296
383
|
},
|
package/src/commands/phone.mjs
CHANGED
|
@@ -5,13 +5,26 @@
|
|
|
5
5
|
// - SIP over a WebSocket (wss://<pbx>/sip/): a headless SIP user of the extension (no media; it turns every call
|
|
6
6
|
// offered to it down) for the live view and what only SIP has: recording and the transfer through a conference.
|
|
7
7
|
// The user acts on their own calls and devices only; the admin-only `user` parameter of the API is left out.
|
|
8
|
-
import {jwtClaims} from '
|
|
9
|
-
import {UsageError} from '
|
|
10
|
-
import {duration, localTime, paint, table} from '
|
|
11
|
-
import {pbxUrl} from '
|
|
8
|
+
import {jwtClaims} from 'wldx-core/auth';
|
|
9
|
+
import {UsageError} from 'wldx-core/errors';
|
|
10
|
+
import {duration, localTime, paint, table} from 'wldx-core/format';
|
|
11
|
+
import {pbxUrl} from 'wldx-core/services';
|
|
12
12
|
import {CallControl, RECORD_ACTIONS} from '../phone/control.mjs';
|
|
13
13
|
import {deviceName} from '../phone/events.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
PbxError,
|
|
16
|
+
WATCHER_USER_AGENT,
|
|
17
|
+
checkCallId,
|
|
18
|
+
checkContact,
|
|
19
|
+
checkDigits,
|
|
20
|
+
extensionOf,
|
|
21
|
+
isWatcher,
|
|
22
|
+
normNumber,
|
|
23
|
+
pbxAction,
|
|
24
|
+
pbxCall,
|
|
25
|
+
sipEndpoint,
|
|
26
|
+
sipPassword,
|
|
27
|
+
} from '../phone/pbx.mjs';
|
|
15
28
|
|
|
16
29
|
// Tests replace the WebSocket (a fake PBX) and the timings of the SIP channel here.
|
|
17
30
|
export const sipIo = {WebSocket: undefined, options: {}};
|
|
@@ -35,10 +48,27 @@ const party = (number, name) => [number, name].filter((v) => v !== undefined &&
|
|
|
35
48
|
// A call of the REST list or of the SIP view, as table cells.
|
|
36
49
|
function callRow(call, c) {
|
|
37
50
|
if (call.sipCallId !== undefined) {
|
|
38
|
-
return [
|
|
51
|
+
return [
|
|
52
|
+
callColor(c, call.state),
|
|
53
|
+
duration(Number(call.duration)),
|
|
54
|
+
party(call.callerNumber, call.callerName),
|
|
55
|
+
'→',
|
|
56
|
+
party(call.calleeNumber, call.calleeName),
|
|
57
|
+
c.dim(call.sipCallId),
|
|
58
|
+
];
|
|
39
59
|
}
|
|
40
|
-
const extras = [
|
|
41
|
-
|
|
60
|
+
const extras = [
|
|
61
|
+
call.recording && call.recording !== 'off' && `recording ${call.recording}`,
|
|
62
|
+
call.tags?.length && `[${call.tags.join(', ')}]`,
|
|
63
|
+
call.endReason && `(${call.endReason})`,
|
|
64
|
+
];
|
|
65
|
+
return [
|
|
66
|
+
callColor(c, call.state),
|
|
67
|
+
call.direction === 'outbound' ? 'to' : 'from',
|
|
68
|
+
party(call.remoteNumber, call.remoteName),
|
|
69
|
+
extras.filter(Boolean).join(' '),
|
|
70
|
+
c.dim(call.id),
|
|
71
|
+
];
|
|
42
72
|
}
|
|
43
73
|
|
|
44
74
|
// table() without the columns that are empty in every row.
|
|
@@ -56,15 +86,35 @@ function callsText(calls, ctx) {
|
|
|
56
86
|
function devicesText(devices, ctx) {
|
|
57
87
|
if (!devices?.length) return 'No device of yours is registered now.';
|
|
58
88
|
const c = paint(ctx);
|
|
59
|
-
return compactTable(
|
|
89
|
+
return compactTable(
|
|
90
|
+
devices.map((d) => [
|
|
91
|
+
[d.active && c.green('active'), d.watcher && c.dim('watcher')].filter(Boolean).join(' '),
|
|
92
|
+
d.name ?? deviceName(d.userAgent),
|
|
93
|
+
d.contact ?? '',
|
|
94
|
+
]),
|
|
95
|
+
);
|
|
60
96
|
}
|
|
61
97
|
|
|
62
98
|
const actionText = (r) => (typeof r.message === 'string' ? r.message : `OK ${JSON.stringify(r.message ?? {})}`);
|
|
63
99
|
|
|
64
100
|
// ---- the SIP channel
|
|
65
101
|
|
|
66
|
-
const callView = (c) => ({
|
|
67
|
-
|
|
102
|
+
const callView = (c) => ({
|
|
103
|
+
id: c.id,
|
|
104
|
+
direction: c.direction,
|
|
105
|
+
state: c.state,
|
|
106
|
+
remoteNumber: c.remoteNumber,
|
|
107
|
+
remoteName: c.remoteName,
|
|
108
|
+
tags: c.tags,
|
|
109
|
+
recording: c.recording,
|
|
110
|
+
endReason: c.endReason,
|
|
111
|
+
});
|
|
112
|
+
const deviceView = (d) => ({
|
|
113
|
+
contact: d.uri,
|
|
114
|
+
name: d.deviceName,
|
|
115
|
+
userAgent: d.userAgent,
|
|
116
|
+
...(isWatcher(d.userAgent) || d.isLocalRegistration ? {watcher: true} : {}),
|
|
117
|
+
});
|
|
68
118
|
|
|
69
119
|
// Connects as a SIP user of the extension and waits for the first call state. setup(cc, extension) adds the
|
|
70
120
|
// listeners before the connection. The registration asks for 120 s, so a watcher killed instead of stopped leaves
|
|
@@ -73,7 +123,16 @@ async function openSip(ctx, setup) {
|
|
|
73
123
|
const extension = extensionOf(ctx.token);
|
|
74
124
|
const {wsUrl, domain} = sipEndpoint(ctx.token, ctx.env);
|
|
75
125
|
const password = await sipPassword(ctx, extension);
|
|
76
|
-
const cc = new CallControl({
|
|
126
|
+
const cc = new CallControl({
|
|
127
|
+
domain,
|
|
128
|
+
wsUrl,
|
|
129
|
+
extension,
|
|
130
|
+
password,
|
|
131
|
+
userAgent: WATCHER_USER_AGENT,
|
|
132
|
+
expires: 120,
|
|
133
|
+
WebSocketImpl: sipIo.WebSocket ?? globalThis.WebSocket,
|
|
134
|
+
...sipIo.options,
|
|
135
|
+
});
|
|
77
136
|
cc.on('error', () => {}); // a one-shot command reports only its own failure
|
|
78
137
|
setup?.(cc, extension);
|
|
79
138
|
try {
|
|
@@ -90,7 +149,10 @@ async function openSip(ctx, setup) {
|
|
|
90
149
|
async function sipCommand(ctx, command, callId, action) {
|
|
91
150
|
const cc = await openSip(ctx);
|
|
92
151
|
try {
|
|
93
|
-
if (!cc.getActiveCalls().some((call) => call.id === callId))
|
|
152
|
+
if (!cc.getActiveCalls().some((call) => call.id === callId))
|
|
153
|
+
throw new PbxError(
|
|
154
|
+
`no current call of this user has the id ${callId} (see: wx phone list --sip or wx phone list)`,
|
|
155
|
+
);
|
|
94
156
|
try {
|
|
95
157
|
await action(cc);
|
|
96
158
|
} catch (e) {
|
|
@@ -130,7 +192,11 @@ function eventText(e, ctx) {
|
|
|
130
192
|
if (d.presence?.status) parts.push(`presence ${d.presence.status}`);
|
|
131
193
|
if (d.devices) parts.push(`${d.devices.filter((x) => !x.watcher).length} device(s)`);
|
|
132
194
|
if (d.activeDevice) parts.push(`ringing ${activeDeviceText(d.activeDevice)}`);
|
|
133
|
-
line = [
|
|
195
|
+
line = [
|
|
196
|
+
c.bold(e.event === 'READY' ? 'ready' : 'reconnected'),
|
|
197
|
+
parts.join(', '),
|
|
198
|
+
...(d.calls ?? []).map((call) => `\n ${compactTable([callRow(call, c)])[0]}`),
|
|
199
|
+
].join(' ');
|
|
134
200
|
break;
|
|
135
201
|
}
|
|
136
202
|
case 'CALL_START':
|
|
@@ -202,7 +268,14 @@ const phone = {
|
|
|
202
268
|
};
|
|
203
269
|
},
|
|
204
270
|
text(r) {
|
|
205
|
-
const rows = [
|
|
271
|
+
const rows = [
|
|
272
|
+
['Extension', r.extension],
|
|
273
|
+
['Name', r.name],
|
|
274
|
+
['E-mail', r.email],
|
|
275
|
+
['Role', r.role],
|
|
276
|
+
['License', r.license],
|
|
277
|
+
['PBX', r.pbx],
|
|
278
|
+
];
|
|
206
279
|
return table(rows.filter(([, v]) => v !== null && v !== undefined && v !== ''));
|
|
207
280
|
},
|
|
208
281
|
},
|
|
@@ -212,7 +285,9 @@ const phone = {
|
|
|
212
285
|
details:
|
|
213
286
|
'REST (default): {calls: [{sipCallId, callerNumber, callerName, calleeNumber, calleeName, state, duration}], total}; state is the PBX channel state (Up also when held).\n' +
|
|
214
287
|
'--sip: {calls: [{id, direction, state ringing|answered|held, remoteNumber, remoteName, tags, recording}]} over a short SIP registration (it shows hold and the number dialled).',
|
|
215
|
-
options: {
|
|
288
|
+
options: {
|
|
289
|
+
sip: {type: 'boolean', description: 'the calls as SIP sees them (held, direction, recording, number dialled)'},
|
|
290
|
+
},
|
|
216
291
|
args: [0, 0],
|
|
217
292
|
async run(ctx) {
|
|
218
293
|
if (!ctx.values.sip) return pbxCall(ctx, 'GET', '/list-calls/');
|
|
@@ -224,7 +299,8 @@ const phone = {
|
|
|
224
299
|
text: (r, ctx) => callsText(r.calls, ctx),
|
|
225
300
|
},
|
|
226
301
|
call: {
|
|
227
|
-
summary:
|
|
302
|
+
summary:
|
|
303
|
+
'place a call from your device: it rings first and dials when you pick up, or starts at once on --device',
|
|
228
304
|
usage: 'wx phone call <number> [--mobile | --any | --device CONTACT]',
|
|
229
305
|
details:
|
|
230
306
|
'Click-to-call. A number is an extension, a phone number (spaces, dashes, dots and brackets are dropped) or a code like *76.\n' +
|
|
@@ -234,30 +310,43 @@ const phone = {
|
|
|
234
310
|
options: {
|
|
235
311
|
mobile: {type: 'boolean', description: 'ring your mobile number (from the PBX settings) first'},
|
|
236
312
|
any: {type: 'boolean', description: 'ring all your devices first'},
|
|
237
|
-
device: {
|
|
313
|
+
device: {
|
|
314
|
+
type: 'string',
|
|
315
|
+
placeholder: 'CONTACT',
|
|
316
|
+
description: 'place the call on this device (contact from wx phone devices); on the active one at once',
|
|
317
|
+
},
|
|
238
318
|
},
|
|
239
319
|
args: [1, 1],
|
|
240
320
|
async run(ctx) {
|
|
241
321
|
const {values} = ctx;
|
|
242
322
|
const destination = normNumber(ctx.positionals[0]);
|
|
243
|
-
if ([values.mobile, values.any, values.device !== undefined].filter(Boolean).length > 1)
|
|
323
|
+
if ([values.mobile, values.any, values.device !== undefined].filter(Boolean).length > 1)
|
|
324
|
+
throw new UsageError('give one of --mobile, --any, --device', ctx.usage());
|
|
244
325
|
const body = {destination};
|
|
245
326
|
if (values.mobile) body.callType = 'mobility';
|
|
246
327
|
else if (values.any) body.callType = 'any';
|
|
247
|
-
else if (values.device !== undefined)
|
|
328
|
+
else if (values.device !== undefined)
|
|
329
|
+
Object.assign(body, {callType: 'device', deviceContact: checkContact(values.device)});
|
|
248
330
|
else {
|
|
249
331
|
// With no active device the PBX takes the first registered one, which may be a SIP watcher (it turns every
|
|
250
332
|
// call down). Pick the device here: the active one, else the first real one.
|
|
251
333
|
const list = await pbxCall(ctx, 'GET', '/list-devices/');
|
|
252
334
|
const real = (list?.devices ?? []).filter((d) => !isWatcher(d.userAgent));
|
|
253
335
|
const device = [...real.filter((d) => d.active), ...real][0]?.contact;
|
|
254
|
-
if (!device)
|
|
336
|
+
if (!device)
|
|
337
|
+
throw new PbxError(
|
|
338
|
+
'no device of this user is online to place the call from: open x-bees or a Wildix phone, or call through the mobile (--mobile)',
|
|
339
|
+
);
|
|
255
340
|
body.deviceContact = device;
|
|
256
341
|
}
|
|
257
342
|
return pbxAction(ctx, 'POST', '/make-call/', body);
|
|
258
343
|
},
|
|
259
344
|
text(r, ctx) {
|
|
260
|
-
if (ctx.values.device !== undefined)
|
|
345
|
+
if (ctx.values.device !== undefined)
|
|
346
|
+
return [
|
|
347
|
+
actionText(r),
|
|
348
|
+
'The call is placed on the device you named (on your active device it starts at once).',
|
|
349
|
+
];
|
|
261
350
|
const where = ctx.values.mobile ? 'your mobile' : ctx.values.any ? 'your devices' : 'your device';
|
|
262
351
|
return [actionText(r), `The PBX rings ${where} first; it dials the number when you pick up.`];
|
|
263
352
|
},
|
|
@@ -265,7 +354,13 @@ const phone = {
|
|
|
265
354
|
answer: {
|
|
266
355
|
summary: 'answer a ringing call (on a device that can answer remotely: a Wildix phone, x-bees)',
|
|
267
356
|
usage: 'wx phone answer <callId> [--device CONTACT]',
|
|
268
|
-
options: {
|
|
357
|
+
options: {
|
|
358
|
+
device: {
|
|
359
|
+
type: 'string',
|
|
360
|
+
placeholder: 'CONTACT',
|
|
361
|
+
description: 'answer on this device (its contact from wx phone devices)',
|
|
362
|
+
},
|
|
363
|
+
},
|
|
269
364
|
...callIdArg,
|
|
270
365
|
async run(ctx) {
|
|
271
366
|
const body = {sipCallId: checkCallId(ctx.positionals[0])};
|
|
@@ -284,7 +379,10 @@ const phone = {
|
|
|
284
379
|
args: [2, 2],
|
|
285
380
|
async run(ctx) {
|
|
286
381
|
const [id, number] = ctx.positionals;
|
|
287
|
-
return pbxAction(ctx, 'POST', '/blind-transfer/', {
|
|
382
|
+
return pbxAction(ctx, 'POST', '/blind-transfer/', {
|
|
383
|
+
sipCallId: checkCallId(id),
|
|
384
|
+
destination: normNumber(number),
|
|
385
|
+
});
|
|
288
386
|
},
|
|
289
387
|
text: actionText,
|
|
290
388
|
},
|
|
@@ -309,7 +407,8 @@ const phone = {
|
|
|
309
407
|
const id = checkCallId(ctx.positionals[0]);
|
|
310
408
|
const digits = checkDigits(ctx.positionals[1]);
|
|
311
409
|
let out;
|
|
312
|
-
for (let i = 0; i < digits.length; i += DTMF_PART)
|
|
410
|
+
for (let i = 0; i < digits.length; i += DTMF_PART)
|
|
411
|
+
out = await pbxAction(ctx, 'POST', '/dtmf/', {sipCallId: id, digits: digits.slice(i, i + DTMF_PART)});
|
|
313
412
|
return out;
|
|
314
413
|
},
|
|
315
414
|
text: actionText,
|
|
@@ -325,9 +424,11 @@ const phone = {
|
|
|
325
424
|
async run(ctx) {
|
|
326
425
|
const {values} = ctx;
|
|
327
426
|
const body = {sipCallId: checkCallId(ctx.positionals[0])};
|
|
328
|
-
if (values.name === undefined && values.number === undefined)
|
|
427
|
+
if (values.name === undefined && values.number === undefined)
|
|
428
|
+
throw new UsageError('give --name, --number or both', ctx.usage());
|
|
329
429
|
if (values.name !== undefined) {
|
|
330
|
-
if (!values.name || values.name.length > NAME_MAX)
|
|
430
|
+
if (!values.name || values.name.length > NAME_MAX)
|
|
431
|
+
throw new UsageError(`--name takes 1 to ${NAME_MAX} characters`);
|
|
331
432
|
body.name = values.name;
|
|
332
433
|
}
|
|
333
434
|
if (values.number !== undefined) body.number = normNumber(values.number);
|
|
@@ -342,7 +443,8 @@ const phone = {
|
|
|
342
443
|
args: [0, 0],
|
|
343
444
|
async run(ctx) {
|
|
344
445
|
const r = await pbxCall(ctx, 'GET', '/list-devices/');
|
|
345
|
-
if (Array.isArray(r?.devices))
|
|
446
|
+
if (Array.isArray(r?.devices))
|
|
447
|
+
r.devices = r.devices.map((d) => (isWatcher(d.userAgent) ? {...d, watcher: true} : d));
|
|
346
448
|
return r;
|
|
347
449
|
},
|
|
348
450
|
text: (r, ctx) => devicesText(r.devices, ctx),
|
|
@@ -357,7 +459,8 @@ const phone = {
|
|
|
357
459
|
args: [0, 1],
|
|
358
460
|
async run(ctx) {
|
|
359
461
|
const {values, positionals} = ctx;
|
|
360
|
-
if ([values.mobile, values.any, positionals.length > 0].filter(Boolean).length !== 1)
|
|
462
|
+
if ([values.mobile, values.any, positionals.length > 0].filter(Boolean).length !== 1)
|
|
463
|
+
throw new UsageError('give one device contact, --mobile or --any', ctx.usage());
|
|
361
464
|
let body;
|
|
362
465
|
if (values.mobile) body = {deviceType: 'mobility'};
|
|
363
466
|
else if (values.any) body = {deviceType: 'any'};
|
|
@@ -366,7 +469,8 @@ const phone = {
|
|
|
366
469
|
// The PBX wants the device's user agent too: take it from the registration of that contact.
|
|
367
470
|
const list = await pbxCall(ctx, 'GET', '/list-devices/');
|
|
368
471
|
const ua = (list?.devices ?? []).find((d) => d.contact === contact)?.userAgent;
|
|
369
|
-
if (!ua)
|
|
472
|
+
if (!ua)
|
|
473
|
+
throw new PbxError(`no registered device of this user has the contact ${contact} (see: wx phone devices)`);
|
|
370
474
|
if (isWatcher(ua)) throw new PbxError('this is a SIP watcher, not a phone: it cannot carry a call');
|
|
371
475
|
body = {deviceType: 'device', contact, userAgent: ua};
|
|
372
476
|
}
|
|
@@ -389,7 +493,8 @@ const phone = {
|
|
|
389
493
|
args: [0, 0],
|
|
390
494
|
async run(ctx) {
|
|
391
495
|
const {values} = ctx;
|
|
392
|
-
if (values.for !== undefined && !/^\d{1,7}$/.test(values.for))
|
|
496
|
+
if (values.for !== undefined && !/^\d{1,7}$/.test(values.for))
|
|
497
|
+
throw new UsageError(`--for takes whole seconds: ${values.for}`);
|
|
393
498
|
const seconds = Number(values.for ?? 0);
|
|
394
499
|
const all = !values['calls-only'];
|
|
395
500
|
const emit = (event, data) => ctx.emit({event, data: data ?? {}});
|
|
@@ -404,7 +509,13 @@ const phone = {
|
|
|
404
509
|
emit(readyOnce ? 'RECONNECTED' : 'READY', {
|
|
405
510
|
extension,
|
|
406
511
|
calls: cc.getActiveCalls().map(callView),
|
|
407
|
-
...(all
|
|
512
|
+
...(all
|
|
513
|
+
? {
|
|
514
|
+
devices: cc.getDevices().map(deviceView),
|
|
515
|
+
presence: cc.getPresence(),
|
|
516
|
+
activeDevice: cc.getActiveDevice(),
|
|
517
|
+
}
|
|
518
|
+
: {}),
|
|
408
519
|
});
|
|
409
520
|
readyOnce = true;
|
|
410
521
|
});
|
|
@@ -427,7 +538,8 @@ const phone = {
|
|
|
427
538
|
async run(ctx) {
|
|
428
539
|
const id = checkCallId(ctx.positionals[0]);
|
|
429
540
|
const action = ctx.positionals[1];
|
|
430
|
-
if (!RECORD_ACTIONS.includes(action))
|
|
541
|
+
if (!RECORD_ACTIONS.includes(action))
|
|
542
|
+
throw new UsageError(`record takes ${RECORD_ACTIONS.join(', ')}: ${action}`, ctx.usage());
|
|
431
543
|
return sipCommand(ctx, 'record', id, (cc) => cc.record(id, action));
|
|
432
544
|
},
|
|
433
545
|
text: (r, ctx) => `record ${ctx.positionals[1]}: the PBX took it (call ${r.callId})`,
|