wldx-cli 0.1.0 → 1.0.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 +279 -27
- package/package.json +1 -1
- package/src/auth.mjs +181 -35
- package/src/chat/format.mjs +97 -0
- package/src/chat/lib.mjs +162 -0
- package/src/chat/watch.mjs +251 -0
- package/src/cli.mjs +167 -47
- package/src/commands/calls.mjs +367 -0
- package/src/commands/channel.mjs +326 -0
- package/src/commands/chat.mjs +388 -60
- package/src/commands/conversations.mjs +251 -0
- package/src/commands/file.mjs +72 -0
- package/src/commands/inbox.mjs +101 -0
- package/src/commands/index.mjs +54 -9
- package/src/commands/live.mjs +404 -0
- package/src/commands/login.mjs +126 -0
- package/src/commands/meetings.mjs +299 -0
- package/src/commands/phone.mjs +452 -0
- package/src/commands/profile.mjs +51 -0
- package/src/commands/reports.mjs +431 -0
- package/src/commands/section.mjs +60 -0
- package/src/commands/status.mjs +95 -0
- package/src/commands/user.mjs +130 -0
- package/src/completion.mjs +47 -20
- package/src/files.mjs +98 -0
- package/src/format.mjs +82 -0
- package/src/history/text.mjs +60 -0
- package/src/history/wda.mjs +161 -0
- package/src/http.mjs +30 -13
- package/src/live/socket.mjs +121 -0
- package/src/live/stats.mjs +211 -0
- package/src/live/text.mjs +219 -0
- package/src/live/views.mjs +321 -0
- package/src/phone/control.mjs +377 -0
- package/src/phone/events.mjs +189 -0
- package/src/phone/pbx.mjs +140 -0
- package/src/phone/sip.mjs +316 -0
- package/src/phone/xml.mjs +56 -0
- package/src/services.mjs +58 -0
- package/src/stream.mjs +4 -11
- package/src/commands/auth.mjs +0 -32
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// wx phone: the live calls of the logged-in user on their Wildix PBX, as the wildix-call-control skill does it
|
|
2
|
+
// (calls.sh, devices.sh, sip.sh). Two channels of the PBX, both found from the login itself:
|
|
3
|
+
// - the Call Control REST API (https://<pbx>/api/v2/call-control/): list, place, answer, hold, transfer, DTMF,
|
|
4
|
+
// hang up, the name shown, the devices and the active one. One request per action.
|
|
5
|
+
// - SIP over a WebSocket (wss://<pbx>/sip/): a headless SIP user of the extension (no media; it turns every call
|
|
6
|
+
// offered to it down) for the live view and what only SIP has: recording and the transfer through a conference.
|
|
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 '../auth.mjs';
|
|
9
|
+
import {UsageError} from '../errors.mjs';
|
|
10
|
+
import {duration, localTime, paint, table} from '../format.mjs';
|
|
11
|
+
import {pbxUrl} from '../services.mjs';
|
|
12
|
+
import {CallControl, RECORD_ACTIONS} from '../phone/control.mjs';
|
|
13
|
+
import {deviceName} from '../phone/events.mjs';
|
|
14
|
+
import {PbxError, WATCHER_USER_AGENT, checkCallId, checkContact, checkDigits, extensionOf, isWatcher, normNumber, pbxAction, pbxCall, sipEndpoint, sipPassword} from '../phone/pbx.mjs';
|
|
15
|
+
|
|
16
|
+
// Tests replace the WebSocket (a fake PBX) and the timings of the SIP channel here.
|
|
17
|
+
export const sipIo = {WebSocket: undefined, options: {}};
|
|
18
|
+
|
|
19
|
+
const DTMF_PART = 10; // the PBX takes at most 10 digits per request
|
|
20
|
+
const NAME_MAX = 100;
|
|
21
|
+
|
|
22
|
+
// ---- text helpers
|
|
23
|
+
|
|
24
|
+
const callColor = (c, state) => {
|
|
25
|
+
const s = String(state ?? '').toLowerCase();
|
|
26
|
+
if (s === 'up' || s === 'answered') return c.green(state);
|
|
27
|
+
if (s.startsWith('ring')) return c.yellow(state);
|
|
28
|
+
if (s === 'held') return c.cyan(state);
|
|
29
|
+
if (s === 'ended') return c.dim(state);
|
|
30
|
+
return String(state ?? '');
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const party = (number, name) => [number, name].filter((v) => v !== undefined && v !== null && v !== '').join(' ');
|
|
34
|
+
|
|
35
|
+
// A call of the REST list or of the SIP view, as table cells.
|
|
36
|
+
function callRow(call, c) {
|
|
37
|
+
if (call.sipCallId !== undefined) {
|
|
38
|
+
return [callColor(c, call.state), duration(Number(call.duration)), party(call.callerNumber, call.callerName), '→', party(call.calleeNumber, call.calleeName), c.dim(call.sipCallId)];
|
|
39
|
+
}
|
|
40
|
+
const extras = [call.recording && call.recording !== 'off' && `recording ${call.recording}`, call.tags?.length && `[${call.tags.join(', ')}]`, call.endReason && `(${call.endReason})`];
|
|
41
|
+
return [callColor(c, call.state), call.direction === 'outbound' ? 'to' : 'from', party(call.remoteNumber, call.remoteName), extras.filter(Boolean).join(' '), c.dim(call.id)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// table() without the columns that are empty in every row.
|
|
45
|
+
function compactTable(rows) {
|
|
46
|
+
const keep = (rows[0] ?? []).map((_, i) => rows.some((r) => r[i] !== '' && r[i] !== undefined && r[i] !== null));
|
|
47
|
+
return table(rows.map((r) => r.filter((_, i) => keep[i])));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function callsText(calls, ctx) {
|
|
51
|
+
if (!calls?.length) return 'No calls now.';
|
|
52
|
+
const c = paint(ctx);
|
|
53
|
+
return compactTable(calls.map((call) => callRow(call, c)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function devicesText(devices, ctx) {
|
|
57
|
+
if (!devices?.length) return 'No device of yours is registered now.';
|
|
58
|
+
const c = paint(ctx);
|
|
59
|
+
return compactTable(devices.map((d) => [[d.active && c.green('active'), d.watcher && c.dim('watcher')].filter(Boolean).join(' '), d.name ?? deviceName(d.userAgent), d.contact ?? '']));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const actionText = (r) => (typeof r.message === 'string' ? r.message : `OK ${JSON.stringify(r.message ?? {})}`);
|
|
63
|
+
|
|
64
|
+
// ---- the SIP channel
|
|
65
|
+
|
|
66
|
+
const callView = (c) => ({id: c.id, direction: c.direction, state: c.state, remoteNumber: c.remoteNumber, remoteName: c.remoteName, tags: c.tags, recording: c.recording, endReason: c.endReason});
|
|
67
|
+
const deviceView = (d) => ({contact: d.uri, name: d.deviceName, userAgent: d.userAgent, ...(isWatcher(d.userAgent) || d.isLocalRegistration ? {watcher: true} : {})});
|
|
68
|
+
|
|
69
|
+
// Connects as a SIP user of the extension and waits for the first call state. setup(cc, extension) adds the
|
|
70
|
+
// listeners before the connection. The registration asks for 120 s, so a watcher killed instead of stopped leaves
|
|
71
|
+
// it on the PBX for at most two minutes.
|
|
72
|
+
async function openSip(ctx, setup) {
|
|
73
|
+
const extension = extensionOf(ctx.token);
|
|
74
|
+
const {wsUrl, domain} = sipEndpoint(ctx.token, ctx.env);
|
|
75
|
+
const password = await sipPassword(ctx, extension);
|
|
76
|
+
const cc = new CallControl({domain, wsUrl, extension, password, userAgent: WATCHER_USER_AGENT, expires: 120, WebSocketImpl: sipIo.WebSocket ?? globalThis.WebSocket, ...sipIo.options});
|
|
77
|
+
cc.on('error', () => {}); // a one-shot command reports only its own failure
|
|
78
|
+
setup?.(cc, extension);
|
|
79
|
+
try {
|
|
80
|
+
await cc.connect();
|
|
81
|
+
await cc.waitUntilReady(15_000);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
await cc.disconnect();
|
|
84
|
+
throw new PbxError(`could not connect to the PBX as extension ${extension}: ${e.message}`);
|
|
85
|
+
}
|
|
86
|
+
return cc;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Connect, check that the call is one of the user's current calls, run the command, disconnect.
|
|
90
|
+
async function sipCommand(ctx, command, callId, action) {
|
|
91
|
+
const cc = await openSip(ctx);
|
|
92
|
+
try {
|
|
93
|
+
if (!cc.getActiveCalls().some((call) => call.id === callId)) throw new PbxError(`no current call of this user has the id ${callId} (see: wx phone list --sip or wx phone list)`);
|
|
94
|
+
try {
|
|
95
|
+
await action(cc);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
throw new PbxError(`${command} failed: ${e.message}`);
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
await cc.disconnect();
|
|
101
|
+
}
|
|
102
|
+
return {ok: true, command, callId};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function waitUntil(signal, seconds) {
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
if (signal.aborted) return resolve();
|
|
108
|
+
let timer;
|
|
109
|
+
const done = () => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
signal.removeEventListener('abort', done);
|
|
112
|
+
resolve();
|
|
113
|
+
};
|
|
114
|
+
signal.addEventListener('abort', done, {once: true});
|
|
115
|
+
if (seconds > 0) timer = setTimeout(done, seconds * 1000);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- event text for watch
|
|
120
|
+
|
|
121
|
+
function eventText(e, ctx) {
|
|
122
|
+
const c = paint(ctx);
|
|
123
|
+
const time = localTime(ctx.clock(), {seconds: true}).slice(11);
|
|
124
|
+
const d = e.data ?? {};
|
|
125
|
+
let line;
|
|
126
|
+
switch (e.event) {
|
|
127
|
+
case 'READY':
|
|
128
|
+
case 'RECONNECTED': {
|
|
129
|
+
const parts = [`extension ${d.extension}`, `${d.calls?.length ?? 0} call${d.calls?.length === 1 ? '' : 's'}`];
|
|
130
|
+
if (d.presence?.status) parts.push(`presence ${d.presence.status}`);
|
|
131
|
+
if (d.devices) parts.push(`${d.devices.filter((x) => !x.watcher).length} device(s)`);
|
|
132
|
+
if (d.activeDevice) parts.push(`ringing ${activeDeviceText(d.activeDevice)}`);
|
|
133
|
+
line = [c.bold(e.event === 'READY' ? 'ready' : 'reconnected'), parts.join(', '), ...(d.calls ?? []).map((call) => `\n ${compactTable([callRow(call, c)])[0]}`)].join(' ');
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case 'CALL_START':
|
|
137
|
+
case 'CALL_UPDATE':
|
|
138
|
+
case 'CALL_END':
|
|
139
|
+
line = `${{CALL_START: c.bold('call'), CALL_UPDATE: 'call', CALL_END: c.dim('call')}[e.event]} ${compactTable([callRow(d, c)])[0]}`;
|
|
140
|
+
break;
|
|
141
|
+
case 'DEVICES':
|
|
142
|
+
line = `devices: ${(Array.isArray(d) ? d : []).map((x) => `${x.name}${x.watcher ? ' (watcher)' : ''}`).join(', ') || 'none'}`;
|
|
143
|
+
break;
|
|
144
|
+
case 'PRESENCE':
|
|
145
|
+
line = `presence: ${[d.status, d.message && `"${d.message}"`, d.until && `until ${localTime(d.until)}`].filter(Boolean).join(' ')}`;
|
|
146
|
+
break;
|
|
147
|
+
case 'ACTIVE_DEVICE':
|
|
148
|
+
line = `ringing: ${activeDeviceText(d)}`;
|
|
149
|
+
break;
|
|
150
|
+
case 'RECONNECTING':
|
|
151
|
+
line = c.yellow(`reconnecting (attempt ${d.attempt})`);
|
|
152
|
+
break;
|
|
153
|
+
case 'ERROR':
|
|
154
|
+
line = c.red(`error: ${d.message}`);
|
|
155
|
+
break;
|
|
156
|
+
default:
|
|
157
|
+
line = `${e.event} ${JSON.stringify(d)}`;
|
|
158
|
+
}
|
|
159
|
+
return `${c.dim(time)} ${line}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function activeDeviceText(a) {
|
|
163
|
+
if (a.type === 'any_device') return 'all devices';
|
|
164
|
+
if (a.type === 'mobility') return 'the mobile';
|
|
165
|
+
if (a.type === 'device') return [a.userAgent, a.contactUri].filter(Boolean).join(' ') || 'a device';
|
|
166
|
+
return a.type;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- commands
|
|
170
|
+
|
|
171
|
+
const callIdArg = {args: [1, 1]};
|
|
172
|
+
const simple = (path, summary) => ({
|
|
173
|
+
summary,
|
|
174
|
+
usage: `wx phone ${path.slice(1, -1)} <callId>`,
|
|
175
|
+
options: {},
|
|
176
|
+
...callIdArg,
|
|
177
|
+
async run(ctx) {
|
|
178
|
+
return pbxAction(ctx, 'POST', path, {sipCallId: checkCallId(ctx.positionals[0])});
|
|
179
|
+
},
|
|
180
|
+
text: actionText,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const phone = {
|
|
184
|
+
summary: 'live calls on your PBX: place, answer, hold, transfer, DTMF, record; your devices; watch live',
|
|
185
|
+
auth: true,
|
|
186
|
+
commands: {
|
|
187
|
+
me: {
|
|
188
|
+
summary: 'who the calls are made as: extension, name, e-mail, role, PBX (from the login, no request)',
|
|
189
|
+
usage: 'wx phone me',
|
|
190
|
+
options: {},
|
|
191
|
+
args: [0, 0],
|
|
192
|
+
async run(ctx) {
|
|
193
|
+
const claims = jwtClaims(ctx.token);
|
|
194
|
+
const pbx = pbxUrl(ctx.token, ctx.env);
|
|
195
|
+
return {
|
|
196
|
+
extension: claims['custom:pbx_user_extension'] ?? null,
|
|
197
|
+
name: claims.name ?? null,
|
|
198
|
+
email: claims.email ?? null,
|
|
199
|
+
role: claims['custom:pbx_user_role'] ?? null,
|
|
200
|
+
license: claims['custom:pbx_license'] ?? null,
|
|
201
|
+
pbx,
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
text(r) {
|
|
205
|
+
const rows = [['Extension', r.extension], ['Name', r.name], ['E-mail', r.email], ['Role', r.role], ['License', r.license], ['PBX', r.pbx]];
|
|
206
|
+
return table(rows.filter(([, v]) => v !== null && v !== undefined && v !== ''));
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
list: {
|
|
210
|
+
summary: 'your current calls: sipCallId, numbers, names, state, seconds (--sip: as SIP sees them)',
|
|
211
|
+
usage: 'wx phone list [--sip]',
|
|
212
|
+
details:
|
|
213
|
+
'REST (default): {calls: [{sipCallId, callerNumber, callerName, calleeNumber, calleeName, state, duration}], total}; state is the PBX channel state (Up also when held).\n' +
|
|
214
|
+
'--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: {sip: {type: 'boolean', description: 'the calls as SIP sees them (held, direction, recording, number dialled)'}},
|
|
216
|
+
args: [0, 0],
|
|
217
|
+
async run(ctx) {
|
|
218
|
+
if (!ctx.values.sip) return pbxCall(ctx, 'GET', '/list-calls/');
|
|
219
|
+
const cc = await openSip(ctx);
|
|
220
|
+
const calls = cc.getActiveCalls().map(callView);
|
|
221
|
+
await cc.disconnect();
|
|
222
|
+
return {calls};
|
|
223
|
+
},
|
|
224
|
+
text: (r, ctx) => callsText(r.calls, ctx),
|
|
225
|
+
},
|
|
226
|
+
call: {
|
|
227
|
+
summary: 'place a call from your device: it rings first and dials when you pick up, or starts at once on --device',
|
|
228
|
+
usage: 'wx phone call <number> [--mobile | --any | --device CONTACT]',
|
|
229
|
+
details:
|
|
230
|
+
'Click-to-call. A number is an extension, a phone number (spaces, dashes, dots and brackets are dropped) or a code like *76.\n' +
|
|
231
|
+
'Without an option the PBX rings the active device (else the first device online, never the SIP watcher) and dials\n' +
|
|
232
|
+
'the number when you pick up. With --device and your active device, the call starts on it at once, without\n' +
|
|
233
|
+
'ringing you first.',
|
|
234
|
+
options: {
|
|
235
|
+
mobile: {type: 'boolean', description: 'ring your mobile number (from the PBX settings) first'},
|
|
236
|
+
any: {type: 'boolean', description: 'ring all your devices first'},
|
|
237
|
+
device: {type: 'string', placeholder: 'CONTACT', description: 'place the call on this device (contact from wx phone devices); on the active one at once'},
|
|
238
|
+
},
|
|
239
|
+
args: [1, 1],
|
|
240
|
+
async run(ctx) {
|
|
241
|
+
const {values} = ctx;
|
|
242
|
+
const destination = normNumber(ctx.positionals[0]);
|
|
243
|
+
if ([values.mobile, values.any, values.device !== undefined].filter(Boolean).length > 1) throw new UsageError('give one of --mobile, --any, --device', ctx.usage());
|
|
244
|
+
const body = {destination};
|
|
245
|
+
if (values.mobile) body.callType = 'mobility';
|
|
246
|
+
else if (values.any) body.callType = 'any';
|
|
247
|
+
else if (values.device !== undefined) Object.assign(body, {callType: 'device', deviceContact: checkContact(values.device)});
|
|
248
|
+
else {
|
|
249
|
+
// With no active device the PBX takes the first registered one, which may be a SIP watcher (it turns every
|
|
250
|
+
// call down). Pick the device here: the active one, else the first real one.
|
|
251
|
+
const list = await pbxCall(ctx, 'GET', '/list-devices/');
|
|
252
|
+
const real = (list?.devices ?? []).filter((d) => !isWatcher(d.userAgent));
|
|
253
|
+
const device = [...real.filter((d) => d.active), ...real][0]?.contact;
|
|
254
|
+
if (!device) throw new PbxError('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)');
|
|
255
|
+
body.deviceContact = device;
|
|
256
|
+
}
|
|
257
|
+
return pbxAction(ctx, 'POST', '/make-call/', body);
|
|
258
|
+
},
|
|
259
|
+
text(r, ctx) {
|
|
260
|
+
if (ctx.values.device !== undefined) return [actionText(r), 'The call is placed on the device you named (on your active device it starts at once).'];
|
|
261
|
+
const where = ctx.values.mobile ? 'your mobile' : ctx.values.any ? 'your devices' : 'your device';
|
|
262
|
+
return [actionText(r), `The PBX rings ${where} first; it dials the number when you pick up.`];
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
answer: {
|
|
266
|
+
summary: 'answer a ringing call (on a device that can answer remotely: a Wildix phone, x-bees)',
|
|
267
|
+
usage: 'wx phone answer <callId> [--device CONTACT]',
|
|
268
|
+
options: {device: {type: 'string', placeholder: 'CONTACT', description: 'answer on this device (its contact from wx phone devices)'}},
|
|
269
|
+
...callIdArg,
|
|
270
|
+
async run(ctx) {
|
|
271
|
+
const body = {sipCallId: checkCallId(ctx.positionals[0])};
|
|
272
|
+
if (ctx.values.device !== undefined) body.deviceContact = checkContact(ctx.values.device);
|
|
273
|
+
return pbxAction(ctx, 'POST', '/answer/', body);
|
|
274
|
+
},
|
|
275
|
+
text: actionText,
|
|
276
|
+
},
|
|
277
|
+
hold: simple('/hold/', 'put a call on hold'),
|
|
278
|
+
unhold: simple('/unhold/', 'resume a held call'),
|
|
279
|
+
hangup: simple('/hangup/', 'hang up a call'),
|
|
280
|
+
transfer: {
|
|
281
|
+
summary: 'blind transfer: the call goes to <number> and leaves you',
|
|
282
|
+
usage: 'wx phone transfer <callId> <number>',
|
|
283
|
+
options: {},
|
|
284
|
+
args: [2, 2],
|
|
285
|
+
async run(ctx) {
|
|
286
|
+
const [id, number] = ctx.positionals;
|
|
287
|
+
return pbxAction(ctx, 'POST', '/blind-transfer/', {sipCallId: checkCallId(id), destination: normNumber(number)});
|
|
288
|
+
},
|
|
289
|
+
text: actionText,
|
|
290
|
+
},
|
|
291
|
+
'attended-transfer': {
|
|
292
|
+
summary: 'join a call to another current call of yours (consultation done), then leave both',
|
|
293
|
+
usage: 'wx phone attended-transfer <callId> <otherCallId>',
|
|
294
|
+
options: {},
|
|
295
|
+
args: [2, 2],
|
|
296
|
+
async run(ctx) {
|
|
297
|
+
const [a, b] = ctx.positionals.map(checkCallId);
|
|
298
|
+
if (a === b) throw new UsageError('a call cannot be transferred to itself');
|
|
299
|
+
return pbxAction(ctx, 'POST', '/attendant-transfer/', {sipCallId: a, destination: b});
|
|
300
|
+
},
|
|
301
|
+
text: actionText,
|
|
302
|
+
},
|
|
303
|
+
dtmf: {
|
|
304
|
+
summary: 'send DTMF tones: 0-9, * and # (longer sequences in parts of 10)',
|
|
305
|
+
usage: 'wx phone dtmf <callId> <digits>',
|
|
306
|
+
options: {},
|
|
307
|
+
args: [2, 2],
|
|
308
|
+
async run(ctx) {
|
|
309
|
+
const id = checkCallId(ctx.positionals[0]);
|
|
310
|
+
const digits = checkDigits(ctx.positionals[1]);
|
|
311
|
+
let out;
|
|
312
|
+
for (let i = 0; i < digits.length; i += DTMF_PART) out = await pbxAction(ctx, 'POST', '/dtmf/', {sipCallId: id, digits: digits.slice(i, i + DTMF_PART)});
|
|
313
|
+
return out;
|
|
314
|
+
},
|
|
315
|
+
text: actionText,
|
|
316
|
+
},
|
|
317
|
+
'show-as': {
|
|
318
|
+
summary: 'change the name and/or number your own phone shows for the other party (they see nothing)',
|
|
319
|
+
usage: 'wx phone show-as <callId> [--name NAME] [--number NUMBER]',
|
|
320
|
+
options: {
|
|
321
|
+
name: {type: 'string', placeholder: 'NAME', description: `the name to show (1 to ${NAME_MAX} characters)`},
|
|
322
|
+
number: {type: 'string', placeholder: 'NUMBER', description: 'the number to show'},
|
|
323
|
+
},
|
|
324
|
+
...callIdArg,
|
|
325
|
+
async run(ctx) {
|
|
326
|
+
const {values} = ctx;
|
|
327
|
+
const body = {sipCallId: checkCallId(ctx.positionals[0])};
|
|
328
|
+
if (values.name === undefined && values.number === undefined) throw new UsageError('give --name, --number or both', ctx.usage());
|
|
329
|
+
if (values.name !== undefined) {
|
|
330
|
+
if (!values.name || values.name.length > NAME_MAX) throw new UsageError(`--name takes 1 to ${NAME_MAX} characters`);
|
|
331
|
+
body.name = values.name;
|
|
332
|
+
}
|
|
333
|
+
if (values.number !== undefined) body.number = normNumber(values.number);
|
|
334
|
+
return pbxAction(ctx, 'POST', '/update-contact-info/', body);
|
|
335
|
+
},
|
|
336
|
+
text: actionText,
|
|
337
|
+
},
|
|
338
|
+
devices: {
|
|
339
|
+
summary: 'your registered devices: contact, user agent, which one is active (the SIP watcher marked)',
|
|
340
|
+
usage: 'wx phone devices',
|
|
341
|
+
options: {},
|
|
342
|
+
args: [0, 0],
|
|
343
|
+
async run(ctx) {
|
|
344
|
+
const r = await pbxCall(ctx, 'GET', '/list-devices/');
|
|
345
|
+
if (Array.isArray(r?.devices)) r.devices = r.devices.map((d) => (isWatcher(d.userAgent) ? {...d, watcher: true} : d));
|
|
346
|
+
return r;
|
|
347
|
+
},
|
|
348
|
+
text: (r, ctx) => devicesText(r.devices, ctx),
|
|
349
|
+
},
|
|
350
|
+
use: {
|
|
351
|
+
summary: 'choose what rings and places your calls: a registered device, the mobile, or all devices',
|
|
352
|
+
usage: 'wx phone use <contact> | --mobile | --any',
|
|
353
|
+
options: {
|
|
354
|
+
mobile: {type: 'boolean', description: 'mobility mode: calls go through your mobile number'},
|
|
355
|
+
any: {type: 'boolean', description: 'ring all your devices'},
|
|
356
|
+
},
|
|
357
|
+
args: [0, 1],
|
|
358
|
+
async run(ctx) {
|
|
359
|
+
const {values, positionals} = ctx;
|
|
360
|
+
if ([values.mobile, values.any, positionals.length > 0].filter(Boolean).length !== 1) throw new UsageError('give one device contact, --mobile or --any', ctx.usage());
|
|
361
|
+
let body;
|
|
362
|
+
if (values.mobile) body = {deviceType: 'mobility'};
|
|
363
|
+
else if (values.any) body = {deviceType: 'any'};
|
|
364
|
+
else {
|
|
365
|
+
const contact = checkContact(positionals[0]);
|
|
366
|
+
// The PBX wants the device's user agent too: take it from the registration of that contact.
|
|
367
|
+
const list = await pbxCall(ctx, 'GET', '/list-devices/');
|
|
368
|
+
const ua = (list?.devices ?? []).find((d) => d.contact === contact)?.userAgent;
|
|
369
|
+
if (!ua) throw new PbxError(`no registered device of this user has the contact ${contact} (see: wx phone devices)`);
|
|
370
|
+
if (isWatcher(ua)) throw new PbxError('this is a SIP watcher, not a phone: it cannot carry a call');
|
|
371
|
+
body = {deviceType: 'device', contact, userAgent: ua};
|
|
372
|
+
}
|
|
373
|
+
return pbxAction(ctx, 'PUT', '/set-active-device/', body);
|
|
374
|
+
},
|
|
375
|
+
text: actionText,
|
|
376
|
+
},
|
|
377
|
+
watch: {
|
|
378
|
+
summary: 'follow your calls, devices, presence and ringing mode live, one line per event, until stopped',
|
|
379
|
+
usage: 'wx phone watch [--calls-only] [--for SECONDS]',
|
|
380
|
+
details:
|
|
381
|
+
'Events ({"event": NAME, "data": …} per line with --output json): READY (extension, calls, devices, presence, activeDevice), ' +
|
|
382
|
+
'CALL_START, CALL_UPDATE, CALL_END (id, direction, state ringing|answered|held|ended, remoteNumber, remoteName, tags, recording, endReason), ' +
|
|
383
|
+
'DEVICES, PRESENCE (status, message, until), ACTIVE_DEVICE (type device|any_device|mobility, contactUri, userAgent), RECONNECTING, RECONNECTED, ERROR.\n' +
|
|
384
|
+
'It is a registered SIP user of your extension while it runs (it turns every call offered to it down); stop it with Ctrl-C.',
|
|
385
|
+
options: {
|
|
386
|
+
'calls-only': {type: 'boolean', description: 'only the calls (no devices, presence, ringing mode)'},
|
|
387
|
+
for: {type: 'string', placeholder: 'SECONDS', description: 'stop after this many seconds (0: until stopped)'},
|
|
388
|
+
},
|
|
389
|
+
args: [0, 0],
|
|
390
|
+
async run(ctx) {
|
|
391
|
+
const {values} = ctx;
|
|
392
|
+
if (values.for !== undefined && !/^\d{1,7}$/.test(values.for)) throw new UsageError(`--for takes whole seconds: ${values.for}`);
|
|
393
|
+
const seconds = Number(values.for ?? 0);
|
|
394
|
+
const all = !values['calls-only'];
|
|
395
|
+
const emit = (event, data) => ctx.emit({event, data: data ?? {}});
|
|
396
|
+
let readyOnce = false;
|
|
397
|
+
const cc = await openSip(ctx, (cc, extension) => {
|
|
398
|
+
cc.on('callStart', (c) => emit('CALL_START', callView(c)));
|
|
399
|
+
cc.on('callUpdate', (c) => emit('CALL_UPDATE', callView(c)));
|
|
400
|
+
cc.on('callEnd', (c) => emit('CALL_END', callView(c)));
|
|
401
|
+
cc.on('reconnecting', (r) => emit('RECONNECTING', r));
|
|
402
|
+
cc.on('error', (e) => emit('ERROR', {message: e?.message ?? String(e)}));
|
|
403
|
+
cc.on('ready', () => {
|
|
404
|
+
emit(readyOnce ? 'RECONNECTED' : 'READY', {
|
|
405
|
+
extension,
|
|
406
|
+
calls: cc.getActiveCalls().map(callView),
|
|
407
|
+
...(all ? {devices: cc.getDevices().map(deviceView), presence: cc.getPresence(), activeDevice: cc.getActiveDevice()} : {}),
|
|
408
|
+
});
|
|
409
|
+
readyOnce = true;
|
|
410
|
+
});
|
|
411
|
+
if (all) {
|
|
412
|
+
cc.on('devicesChanged', (d) => readyOnce && emit('DEVICES', d.map(deviceView)));
|
|
413
|
+
cc.on('presenceChanged', (p) => readyOnce && emit('PRESENCE', p));
|
|
414
|
+
cc.on('activeDeviceChanged', (a) => readyOnce && emit('ACTIVE_DEVICE', a));
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
await waitUntil(ctx.signal, seconds);
|
|
418
|
+
await cc.disconnect();
|
|
419
|
+
},
|
|
420
|
+
textEvent: eventText,
|
|
421
|
+
},
|
|
422
|
+
record: {
|
|
423
|
+
summary: 'start, stop, pause or resume the recording of a call (SIP; needs your right to record)',
|
|
424
|
+
usage: `wx phone record <callId> ${RECORD_ACTIONS.join('|')}`,
|
|
425
|
+
options: {},
|
|
426
|
+
args: [2, 2],
|
|
427
|
+
async run(ctx) {
|
|
428
|
+
const id = checkCallId(ctx.positionals[0]);
|
|
429
|
+
const action = ctx.positionals[1];
|
|
430
|
+
if (!RECORD_ACTIONS.includes(action)) throw new UsageError(`record takes ${RECORD_ACTIONS.join(', ')}: ${action}`, ctx.usage());
|
|
431
|
+
return sipCommand(ctx, 'record', id, (cc) => cc.record(id, action));
|
|
432
|
+
},
|
|
433
|
+
text: (r, ctx) => `record ${ctx.positionals[1]}: the PBX took it (call ${r.callId})`,
|
|
434
|
+
},
|
|
435
|
+
'conference-transfer': {
|
|
436
|
+
summary: 'join the call and <number> in a conference; when you hang up the other two stay (SIP)',
|
|
437
|
+
usage: 'wx phone conference-transfer <callId> <number>',
|
|
438
|
+
options: {},
|
|
439
|
+
args: [2, 2],
|
|
440
|
+
async run(ctx) {
|
|
441
|
+
const id = checkCallId(ctx.positionals[0]);
|
|
442
|
+
const destination = normNumber(ctx.positionals[1]);
|
|
443
|
+
return sipCommand(ctx, 'conference-transfer', id, (cc) => cc.conferenceTransfer(id, destination));
|
|
444
|
+
},
|
|
445
|
+
text: (r) => `conference transfer: the PBX took it (call ${r.callId})`,
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// Named exports only: test/dev.mjs would take a default export with `commands` for a list of single commands.
|
|
451
|
+
export {phone};
|
|
452
|
+
export const groups = {phone};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// wx profile: the profiles (logged-in e-mails) and which one is the default. No network; never prints a token.
|
|
2
|
+
import {defaultProfile, sessions, setDefaultProfile} from '../auth.mjs';
|
|
3
|
+
import {AuthError} from '../errors.mjs';
|
|
4
|
+
import {localTime, paint, table} from '../format.mjs';
|
|
5
|
+
|
|
6
|
+
const profiles = (ctx) => {
|
|
7
|
+
const byDefault = defaultProfile(ctx.env);
|
|
8
|
+
return sessions(ctx.env).map((s) => ({
|
|
9
|
+
email: s.email,
|
|
10
|
+
default: s.email === byDefault,
|
|
11
|
+
tokenValidUntil: s.expiresAt ? new Date(s.expiresAt).toISOString() : null,
|
|
12
|
+
expired: s.expiresAt <= ctx.now,
|
|
13
|
+
}));
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const groups = {
|
|
17
|
+
profile: {
|
|
18
|
+
summary: 'your profiles (the e-mails you are logged in with) and the default one',
|
|
19
|
+
commands: {
|
|
20
|
+
list: {
|
|
21
|
+
summary: 'your profiles; * marks the default one',
|
|
22
|
+
usage: 'wx profile list',
|
|
23
|
+
options: {},
|
|
24
|
+
args: [0, 0],
|
|
25
|
+
run: (ctx) => ({profiles: profiles(ctx)}),
|
|
26
|
+
text(r, ctx) {
|
|
27
|
+
if (!r.profiles.length) return 'Not logged in. Run: wx login <e-mail>';
|
|
28
|
+
const c = paint(ctx);
|
|
29
|
+
const lines = table(r.profiles.map((p) => [p.default ? '*' : ' ', p.email, p.expired ? c.dim('token renewed on next use') : `token valid until ${localTime(p.tokenValidUntil)}`]));
|
|
30
|
+
if (r.profiles.length > 1 && !r.profiles.some((p) => p.default)) lines.push(c.yellow('No default profile: set one with wx profile use <e-mail>'));
|
|
31
|
+
return lines;
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
use: {
|
|
35
|
+
summary: 'make a profile the default one (used when no --profile is given)',
|
|
36
|
+
usage: 'wx profile use <e-mail>',
|
|
37
|
+
options: {},
|
|
38
|
+
args: [1, 1],
|
|
39
|
+
complete: 'profiles',
|
|
40
|
+
run(ctx) {
|
|
41
|
+
const wanted = ctx.positionals[0];
|
|
42
|
+
const found = sessions(ctx.env).find((s) => s.email.toLowerCase() === wanted.toLowerCase());
|
|
43
|
+
if (!found) throw new AuthError(`No profile ${wanted}: log in first: wx login ${wanted}`);
|
|
44
|
+
setDefaultProfile(found.email, ctx.env);
|
|
45
|
+
return {defaultProfile: found.email};
|
|
46
|
+
},
|
|
47
|
+
text: (r) => `Default profile: ${r.defaultProfile}`,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|