wldx-admin-cli 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +404 -2
- package/bin/wx-admin.mjs +5 -0
- package/package.json +36 -3
- package/src/cli.mjs +71 -0
- package/src/commands/acl.mjs +376 -0
- package/src/commands/api.mjs +66 -0
- package/src/commands/config.mjs +280 -0
- package/src/commands/device.mjs +275 -0
- package/src/commands/dialplan.mjs +488 -0
- package/src/commands/index.mjs +85 -0
- package/src/commands/pbx.mjs +197 -0
- package/src/commands/phonebook.mjs +558 -0
- package/src/commands/queue.mjs +456 -0
- package/src/commands/records.mjs +623 -0
- package/src/commands/routing.mjs +1059 -0
- package/src/commands/sound.mjs +232 -0
- package/src/commands/system.mjs +668 -0
- package/src/commands/trunk.mjs +808 -0
- package/src/commands/user.mjs +574 -0
- package/src/match.mjs +15 -0
- package/src/pbx.mjs +369 -0
- package/src/secret.mjs +42 -0
- package/src/version.mjs +3 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
// wx-admin records: what calls leave behind on a PBX: the call history (CDR), call recordings, voicemails.
|
|
2
|
+
// Operations (PBX REST API):
|
|
3
|
+
// GET /PBX/CallHistory/ the whole call history (admin; callhistory:read); filters filter[field]=…,
|
|
4
|
+
// filter[start][from|to]="YYYY-MM-DD HH:MM:SS" in the time zone of the PBX
|
|
5
|
+
// GET /User/{extension}/CallHistory/ the history of one user (admin; pbx:read)
|
|
6
|
+
// GET /PBX/recordings/ the recordings: {items: [{path, created, size}], nextPage} (recordings:read)
|
|
7
|
+
// POST /PBX/recordings/download/ a task that packs the recordings of a time range ({from, to}, unix seconds)
|
|
8
|
+
// GET /PBX/recordings/download/{id} the status of that task: {status, path}
|
|
9
|
+
// GET /VoiceMail/ every voicemail of the PBX (pbx:read)
|
|
10
|
+
// DELETE /VoiceMail/{msg_id,…}/ delete voicemails (pbx:*)
|
|
11
|
+
// GET /spoolview/recordings/{path}, /spoolview/voicemail/{path}: the files. Not under /api/v1 but at the root of the
|
|
12
|
+
// PBX; a file kept in the Cloud Data Storage answers 302 to a short-lived storage link (followed without the
|
|
13
|
+
// credentials of the PBX).
|
|
14
|
+
// A call record has its time in the PBX's own zone ("2026-11-05 15:23:18" and timezone "+0200"); text shows it in
|
|
15
|
+
// the local time of this machine. GET /CallHistory/{id} is the history of the signed-in user only: `call` looks a
|
|
16
|
+
// call up in the whole history (filter[id]).
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import {UsageError} from 'wldx-core/errors';
|
|
20
|
+
import {refuseUnsafeWrite} from 'wldx-core/files';
|
|
21
|
+
import {duration, localTime, oneLine, paint, table} from 'wldx-core/format';
|
|
22
|
+
import {query} from 'wldx-core/http';
|
|
23
|
+
import {pbxClient} from '../pbx.mjs';
|
|
24
|
+
import {userAgent} from 'wldx-core/program';
|
|
25
|
+
|
|
26
|
+
// ---- helpers shared with the other admin groups (phonebook, sound) ----
|
|
27
|
+
|
|
28
|
+
// A path of several segments (a sound in a folder, a recording file): each segment is checked and encoded, so input
|
|
29
|
+
// cannot climb out of the folder or add a query.
|
|
30
|
+
export function segmentsPath(value, what = 'path') {
|
|
31
|
+
const parts = String(value ?? '')
|
|
32
|
+
.split('/')
|
|
33
|
+
.filter((s) => s !== '');
|
|
34
|
+
if (!parts.length) throw new UsageError(`An empty ${what}`);
|
|
35
|
+
for (const p of parts) {
|
|
36
|
+
if (p === '.' || p === '..' || /[\x00-\x1f\x7f\\?#%]/.test(p)) throw new UsageError(`Not a ${what}: ${value}`);
|
|
37
|
+
}
|
|
38
|
+
return parts.map(encodeURIComponent).join('/');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Checks a file to write before anything is fetched: a new file, not in a hidden or startup folder.
|
|
42
|
+
export function checkNewFile(ctx, to) {
|
|
43
|
+
const file = path.resolve(ctx.cwd ?? process.cwd(), to);
|
|
44
|
+
refuseUnsafeWrite(file, ctx.env);
|
|
45
|
+
if (fs.existsSync(file)) throw new UsageError(`${to} exists: choose another file (nothing was overwritten)`);
|
|
46
|
+
return file;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Writes data to a new file (never overwrites; never into hidden or startup folders). Returns the real path.
|
|
50
|
+
export function writeNewFile(ctx, to, data) {
|
|
51
|
+
const file = path.resolve(ctx.cwd ?? process.cwd(), to);
|
|
52
|
+
const real = refuseUnsafeWrite(file, ctx.env);
|
|
53
|
+
try {
|
|
54
|
+
fs.writeFileSync(real, data, {flag: 'wx'});
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (e.code === 'EEXIST') throw new UsageError(`${to} exists: choose another file (nothing was overwritten)`);
|
|
57
|
+
if (e.code === 'ENOENT') throw new UsageError(`No such folder for ${to}`);
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
return real;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fail = (message, status) => Object.assign(new Error(message), {status, exitCode: status === 401 ? 2 : 1});
|
|
64
|
+
|
|
65
|
+
// GET a link the PBX handed out (a short-lived storage link): over https only, without the credentials of the PBX,
|
|
66
|
+
// no redirects.
|
|
67
|
+
export async function fetchLink(ctx, href, what) {
|
|
68
|
+
const url = new URL(href);
|
|
69
|
+
if (url.protocol !== 'https:') throw fail(`The PBX gave a link that is not https for ${what}`);
|
|
70
|
+
let res;
|
|
71
|
+
try {
|
|
72
|
+
res = await (ctx.fetch ?? globalThis.fetch)(url.href, {
|
|
73
|
+
method: 'GET',
|
|
74
|
+
headers: {'user-agent': userAgent(), accept: '*/*'},
|
|
75
|
+
redirect: 'error',
|
|
76
|
+
signal: AbortSignal.timeout(600_000),
|
|
77
|
+
});
|
|
78
|
+
} catch (e) {
|
|
79
|
+
throw fail(`The storage of ${what} did not answer (${e.cause?.code ?? e.message})`);
|
|
80
|
+
}
|
|
81
|
+
if (!res.ok)
|
|
82
|
+
throw fail(`HTTP ${res.status} from the storage of ${what}: the link may have expired, try again`, res.status);
|
|
83
|
+
return Buffer.from(await res.arrayBuffer());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// GET a file of the PBX outside /api/v1 (/spoolview/…): the bytes. The credentials go only to the PBX; a redirect
|
|
87
|
+
// (Cloud Data Storage) is followed once with fetchLink(), without them.
|
|
88
|
+
export async function spoolDownload(ctx, client, spoolPath, scope) {
|
|
89
|
+
const {target} = client;
|
|
90
|
+
const custom = ctx.env.WILDIX_PBX_URL && ctx.env.WILDIX_ALLOW_CUSTOM_URL === '1';
|
|
91
|
+
const base = custom
|
|
92
|
+
? ctx.env.WILDIX_PBX_URL.replace(/\/+$/, '')
|
|
93
|
+
: `https://${target.host}${target.port && target.port !== 443 ? `:${target.port}` : ''}`;
|
|
94
|
+
let res;
|
|
95
|
+
try {
|
|
96
|
+
res = await (ctx.fetch ?? globalThis.fetch)(base + spoolPath, {
|
|
97
|
+
method: 'GET',
|
|
98
|
+
headers: {'user-agent': userAgent(), accept: '*/*', authorization: await target.header()},
|
|
99
|
+
redirect: 'manual',
|
|
100
|
+
signal: AbortSignal.timeout(600_000),
|
|
101
|
+
});
|
|
102
|
+
} catch (e) {
|
|
103
|
+
throw fail(`The PBX ${target.host} did not answer (${e.cause?.code ?? e.message})`);
|
|
104
|
+
}
|
|
105
|
+
if (res.status >= 300 && res.status < 400) {
|
|
106
|
+
const location = res.headers.get('location');
|
|
107
|
+
if (!location) throw fail(`HTTP ${res.status} from ${target.host} without a link to the file`, res.status);
|
|
108
|
+
return fetchLink(ctx, new URL(location, base).href, `${target.host} (Cloud Data Storage)`);
|
|
109
|
+
}
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const why =
|
|
112
|
+
{
|
|
113
|
+
401: 'The PBX did not accept the access (wx-admin pbx check).',
|
|
114
|
+
403: `The access may not read this file${scope ? `: an API key needs the scope ${scope}:read (or pbx:read)` : ''}.`,
|
|
115
|
+
404: 'No such file on this PBX.',
|
|
116
|
+
}[res.status] ?? '';
|
|
117
|
+
throw fail(
|
|
118
|
+
`HTTP ${res.status} from ${target.host} (GET ${spoolPath.split('?')[0]})${why ? `\n${why}` : ''}`,
|
|
119
|
+
res.status,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return Buffer.from(await res.arrayBuffer());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---- dates and call records ----
|
|
126
|
+
|
|
127
|
+
const DATE = /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2})(:\d{2})?)?$/;
|
|
128
|
+
|
|
129
|
+
// "YYYY-MM-DD[ HH:MM[:SS]]" as the PBX takes it; a date alone is the start (or, with end, the end) of that day.
|
|
130
|
+
function pbxDate(value, name, end = false) {
|
|
131
|
+
const m = DATE.exec(String(value).trim());
|
|
132
|
+
if (!m) throw new UsageError(`--${name} is a date: YYYY-MM-DD or "YYYY-MM-DD HH:MM"`);
|
|
133
|
+
return `${m[1]} ${m[2] ? `${m[2]}${m[3] ?? (end ? ':59' : ':00')}` : end ? '23:59:59' : '00:00:00'}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Unix seconds of a date in the local time of this machine (the recordings API takes them).
|
|
137
|
+
function unixSeconds(value, name, end = false) {
|
|
138
|
+
const t = new Date(pbxDate(value, name, end).replace(' ', 'T')).getTime();
|
|
139
|
+
if (Number.isNaN(t)) throw new UsageError(`--${name}: not a date: ${value}`);
|
|
140
|
+
return Math.floor(t / 1000);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The time of a call record (PBX time and its zone) in ms.
|
|
144
|
+
export function callTime(value, zone) {
|
|
145
|
+
if (!value) return undefined;
|
|
146
|
+
const z = /^([+-])(\d{2}):?(\d{2})$/.exec(String(zone ?? ''));
|
|
147
|
+
const t = new Date(`${String(value).replace(' ', 'T')}${z ? `${z[1]}${z[2]}:${z[3]}` : ''}`).getTime();
|
|
148
|
+
return Number.isNaN(t) ? undefined : t;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --disposition values (no spaces, for the shell) and what the PBX stores.
|
|
152
|
+
const DISPOSITIONS = {answered: 'ANSWERED', 'no-answer': 'NO ANSWER', busy: 'BUSY', failed: 'FAILED'};
|
|
153
|
+
const CALL_TYPES = ['call', 'fax', 'voicemail'];
|
|
154
|
+
const SORT_FIELDS = [
|
|
155
|
+
'id',
|
|
156
|
+
'start',
|
|
157
|
+
'answer',
|
|
158
|
+
'end',
|
|
159
|
+
'src',
|
|
160
|
+
'dst',
|
|
161
|
+
'from_number',
|
|
162
|
+
'to_number',
|
|
163
|
+
'from_name',
|
|
164
|
+
'to_name',
|
|
165
|
+
'billsec',
|
|
166
|
+
'disposition',
|
|
167
|
+
'duration',
|
|
168
|
+
'callclass',
|
|
169
|
+
'dest_type',
|
|
170
|
+
'type',
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const HISTORY_OPTIONS = {
|
|
174
|
+
since: {
|
|
175
|
+
type: 'string',
|
|
176
|
+
placeholder: 'DATE',
|
|
177
|
+
description: 'calls from this time on: YYYY-MM-DD or "YYYY-MM-DD HH:MM", in the time zone of the PBX',
|
|
178
|
+
},
|
|
179
|
+
until: {
|
|
180
|
+
type: 'string',
|
|
181
|
+
placeholder: 'DATE',
|
|
182
|
+
description: 'calls up to this time (a date alone: to the end of that day), in the time zone of the PBX',
|
|
183
|
+
},
|
|
184
|
+
disposition: {type: 'string', choices: Object.keys(DISPOSITIONS), description: 'only calls that ended so'},
|
|
185
|
+
type: {type: 'string', choices: CALL_TYPES, description: 'only calls, faxes or voicemails'},
|
|
186
|
+
search: {type: 'string', placeholder: 'TEXT', description: 'calls with this text in a number or a name'},
|
|
187
|
+
filter: {
|
|
188
|
+
type: 'string',
|
|
189
|
+
multiple: true,
|
|
190
|
+
placeholder: 'FIELD=VALUE',
|
|
191
|
+
description: 'an exact filter of the API, e.g. src=201 or dst=+390212345 (filter[FIELD]=VALUE)',
|
|
192
|
+
},
|
|
193
|
+
count: {type: 'string', placeholder: 'N', description: 'how many calls (default 50, at most 1000)'},
|
|
194
|
+
offset: {type: 'string', placeholder: 'N', description: 'skip this many calls (the next page)'},
|
|
195
|
+
sort: {type: 'string', choices: SORT_FIELDS, description: 'sort by this field (default: the newest first)'},
|
|
196
|
+
asc: {type: 'boolean', description: 'sort ascending (oldest first)'},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function wholeNumber(value, name, {min = 0, max = Infinity, fallback} = {}) {
|
|
200
|
+
if (value === undefined) return fallback;
|
|
201
|
+
const n = Number(value);
|
|
202
|
+
if (!/^\d+$/.test(String(value)) || n < min || n > max)
|
|
203
|
+
throw new UsageError(`--${name} is a whole number from ${min}${max < Infinity ? ` to ${max}` : ''}: ${value}`);
|
|
204
|
+
return n;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The query of a call-history request from the options.
|
|
208
|
+
function historyQuery(values) {
|
|
209
|
+
const q = {
|
|
210
|
+
count: wholeNumber(values.count, 'count', {min: 1, max: 1000, fallback: 50}),
|
|
211
|
+
start: wholeNumber(values.offset, 'offset', {fallback: undefined}),
|
|
212
|
+
sort: values.sort ?? 'id',
|
|
213
|
+
dir: values.asc ? 'ASC' : 'DESC',
|
|
214
|
+
search: values.search,
|
|
215
|
+
'filter[start][from]': values.since === undefined ? undefined : pbxDate(values.since, 'since'),
|
|
216
|
+
'filter[start][to]': values.until === undefined ? undefined : pbxDate(values.until, 'until', true),
|
|
217
|
+
'filter[disposition]': DISPOSITIONS[values.disposition],
|
|
218
|
+
'filter[type]': values.type,
|
|
219
|
+
};
|
|
220
|
+
for (const spec of values.filter ?? []) {
|
|
221
|
+
const at = spec.indexOf('=');
|
|
222
|
+
const key = at > 0 ? spec.slice(0, at) : '';
|
|
223
|
+
if (!/^[a-z_]+$/.test(key))
|
|
224
|
+
throw new UsageError(
|
|
225
|
+
`--filter is FIELD=VALUE with a field of the call history (src, dst, from_number…): ${spec}`,
|
|
226
|
+
);
|
|
227
|
+
q[`filter[${key}]`] = spec.slice(at + 1);
|
|
228
|
+
}
|
|
229
|
+
return query(q);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const party = (number, name) => {
|
|
233
|
+
const n = String(number ?? '');
|
|
234
|
+
const label = String(name ?? '');
|
|
235
|
+
return label && label !== n ? `${n} ${oneLine(label, 24)}` : n;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const callRow = (r, c) => [
|
|
239
|
+
r.id,
|
|
240
|
+
localTime(callTime(r.start, r.timezone)),
|
|
241
|
+
party(r.from_number || r.src, r.from_name),
|
|
242
|
+
'→',
|
|
243
|
+
party(r.to_number || r.dst, r.to_name),
|
|
244
|
+
r.disposition === 'ANSWERED' ? c.green(r.disposition) : (r.disposition ?? ''),
|
|
245
|
+
r.billsec === undefined ? '' : duration(Number(r.billsec)),
|
|
246
|
+
r.type && r.type !== 'call' ? r.type : '',
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
function historyText(r, ctx) {
|
|
250
|
+
const c = paint(ctx);
|
|
251
|
+
const records = r?.records ?? [];
|
|
252
|
+
if (!records.length) return 'No calls';
|
|
253
|
+
const shown = `${records.length} of ${r.total ?? records.length} calls`;
|
|
254
|
+
return [
|
|
255
|
+
...table(records.map((x) => callRow(x, c))),
|
|
256
|
+
c.dim(r.total > records.length ? `${shown} (--offset for more)` : shown),
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const recordingPath = (value) =>
|
|
261
|
+
segmentsPath(String(value).replace(/^\/?spoolview\/recordings\//, ''), 'recording path');
|
|
262
|
+
|
|
263
|
+
const recordingsOf = (call) => (call?.attachments?.recordings ?? []).filter((x) => x?.url);
|
|
264
|
+
|
|
265
|
+
async function findCall(client, id) {
|
|
266
|
+
if (!/^\d+$/.test(String(id))) throw new UsageError(`A call id is a number: ${id}`);
|
|
267
|
+
const r = await client.call('GET', `/PBX/CallHistory/${query({'filter[id]': id})}`, undefined, {
|
|
268
|
+
scope: 'callhistory',
|
|
269
|
+
});
|
|
270
|
+
const call = (r?.records ?? []).find((x) => String(x.id) === String(id));
|
|
271
|
+
if (!call) throw fail(`No call ${id} in the call history of this PBX`, 404);
|
|
272
|
+
return call;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const sizeText = (bytes) =>
|
|
276
|
+
bytes >= 1048576
|
|
277
|
+
? `${(bytes / 1048576).toFixed(1)} MB`
|
|
278
|
+
: bytes >= 1024
|
|
279
|
+
? `${Math.round(bytes / 1024)} KB`
|
|
280
|
+
: `${bytes} B`;
|
|
281
|
+
|
|
282
|
+
const downloaded = (r) => `Saved ${r.source} (${sizeText(r.bytes)}) to ${r.file}`;
|
|
283
|
+
|
|
284
|
+
const voicemailId = (id) => {
|
|
285
|
+
if (!/^[A-Za-z0-9_.-]{1,80}$/.test(String(id)))
|
|
286
|
+
throw new UsageError(`Not a voicemail id (msg_id of wx-admin records voicemails): ${id}`);
|
|
287
|
+
return id;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
async function voicemailList(client) {
|
|
291
|
+
return (await client.call('GET', '/VoiceMail/', undefined, {scope: 'pbx'})) ?? {records: []};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const sleep = (ms, signal) =>
|
|
295
|
+
new Promise((resolve) => {
|
|
296
|
+
const t = setTimeout(resolve, ms);
|
|
297
|
+
signal?.addEventListener(
|
|
298
|
+
'abort',
|
|
299
|
+
() => {
|
|
300
|
+
clearTimeout(t);
|
|
301
|
+
resolve();
|
|
302
|
+
},
|
|
303
|
+
{once: true},
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
export const groups = {
|
|
308
|
+
records: {
|
|
309
|
+
summary: 'what calls leave behind: the call history, call recordings, voicemails',
|
|
310
|
+
commands: {
|
|
311
|
+
history: {
|
|
312
|
+
summary: 'the call history of the whole PBX, the newest first',
|
|
313
|
+
usage:
|
|
314
|
+
'wx-admin records history [--since DATE] [--until DATE] [--disposition D] [--type T] [--search TEXT] [--filter FIELD=VALUE]... [--count N] [--offset N] [--sort FIELD] [--asc]',
|
|
315
|
+
details:
|
|
316
|
+
'Times of --since and --until are in the time zone of the PBX; the text shows them in the local time of this computer. The duration is the talk time (billsec).',
|
|
317
|
+
options: HISTORY_OPTIONS,
|
|
318
|
+
args: [0, 0],
|
|
319
|
+
async run(ctx) {
|
|
320
|
+
return (await pbxClient(ctx)).call('GET', `/PBX/CallHistory/${historyQuery(ctx.values)}`, undefined, {
|
|
321
|
+
scope: 'callhistory',
|
|
322
|
+
});
|
|
323
|
+
},
|
|
324
|
+
text: historyText,
|
|
325
|
+
},
|
|
326
|
+
'history-user': {
|
|
327
|
+
summary: 'the call history of one user (extension), the newest first',
|
|
328
|
+
usage: 'wx-admin records history-user <extension> [the options of records history]',
|
|
329
|
+
options: HISTORY_OPTIONS,
|
|
330
|
+
args: [1, 1],
|
|
331
|
+
async run(ctx) {
|
|
332
|
+
const ext = ctx.positionals[0];
|
|
333
|
+
if (!/^\d{1,20}$/.test(ext)) throw new UsageError(`An extension is digits: ${ext}`);
|
|
334
|
+
return (await pbxClient(ctx)).call('GET', `/User/${ext}/CallHistory/${historyQuery(ctx.values)}`, undefined, {
|
|
335
|
+
scope: 'pbx',
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
text: historyText,
|
|
339
|
+
},
|
|
340
|
+
call: {
|
|
341
|
+
summary: 'one call of the history: times, parties, result, its recordings and voicemail',
|
|
342
|
+
usage: 'wx-admin records call <callId>',
|
|
343
|
+
options: {},
|
|
344
|
+
args: [1, 1],
|
|
345
|
+
async run(ctx) {
|
|
346
|
+
return findCall(await pbxClient(ctx), ctx.positionals[0]);
|
|
347
|
+
},
|
|
348
|
+
text(r, ctx) {
|
|
349
|
+
const c = paint(ctx);
|
|
350
|
+
const at = (v) => localTime(callTime(v, r.timezone), {seconds: true});
|
|
351
|
+
const lines = [
|
|
352
|
+
`${c.bold(`call ${r.id}`)} ${r.type ?? ''} ${r.disposition ?? ''}`,
|
|
353
|
+
`from ${party(r.from_number || r.src, r.from_name)}`,
|
|
354
|
+
`to ${party(r.to_number || r.dst, r.to_name)}`,
|
|
355
|
+
`start ${at(r.start)}`,
|
|
356
|
+
...(r.answer ? [`answer ${at(r.answer)}`] : []),
|
|
357
|
+
...(r.end ? [`end ${at(r.end)}`] : []),
|
|
358
|
+
`duration ${duration(Number(r.duration))} (talk ${duration(Number(r.billsec))})`,
|
|
359
|
+
...(r.lastapp ? [`last app ${r.lastapp}`] : []),
|
|
360
|
+
];
|
|
361
|
+
for (const x of recordingsOf(r))
|
|
362
|
+
lines.push(`recording ${String(x.url).replace(/^\/?spoolview\/recordings\//, '')}`);
|
|
363
|
+
for (const x of r.attachments?.vm ?? []) if (x?.url) lines.push(`voicemail ${x.url}`);
|
|
364
|
+
for (const x of r.attachments?.faxes ?? []) if (x?.url) lines.push(`fax ${x.url}`);
|
|
365
|
+
return lines;
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
recordings: {
|
|
369
|
+
summary: 'the call recordings of the PBX, the newest first',
|
|
370
|
+
usage: 'wx-admin records recordings [--since DATE] [--until DATE] [--count N] [--page TOKEN]',
|
|
371
|
+
details:
|
|
372
|
+
'Dates are in the local time of this computer. When there are more, the text ends with the --page token of the next page.',
|
|
373
|
+
options: {
|
|
374
|
+
since: {
|
|
375
|
+
type: 'string',
|
|
376
|
+
placeholder: 'DATE',
|
|
377
|
+
description: 'recordings made from this time on (YYYY-MM-DD or "YYYY-MM-DD HH:MM", local time)',
|
|
378
|
+
},
|
|
379
|
+
until: {
|
|
380
|
+
type: 'string',
|
|
381
|
+
placeholder: 'DATE',
|
|
382
|
+
description: 'recordings made up to this time (a date alone: to the end of that day)',
|
|
383
|
+
},
|
|
384
|
+
count: {type: 'string', placeholder: 'N', description: 'how many (default 50, at most 1000)'},
|
|
385
|
+
page: {
|
|
386
|
+
type: 'string',
|
|
387
|
+
placeholder: 'TOKEN',
|
|
388
|
+
description: 'the next page: the token the previous page ended with',
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
args: [0, 0],
|
|
392
|
+
async run(ctx) {
|
|
393
|
+
const {values} = ctx;
|
|
394
|
+
if (values.page !== undefined && !/^[A-Za-z0-9+/=_-]{1,2000}$/.test(values.page))
|
|
395
|
+
throw new UsageError('--page is the token a previous page gave');
|
|
396
|
+
const q = query({
|
|
397
|
+
count: wholeNumber(values.count, 'count', {min: 1, max: 1000, fallback: 50}),
|
|
398
|
+
start: values.page,
|
|
399
|
+
from: values.since === undefined ? undefined : unixSeconds(values.since, 'since'),
|
|
400
|
+
to: values.until === undefined ? undefined : unixSeconds(values.until, 'until', true),
|
|
401
|
+
});
|
|
402
|
+
const r = await (await pbxClient(ctx)).call('GET', `/PBX/recordings/${q}`, undefined, {scope: 'recordings'});
|
|
403
|
+
const next = r?.nextPage ? new URL(r.nextPage, 'https://pbx').searchParams.get('start') : undefined;
|
|
404
|
+
return {...r, ...(next ? {nextStart: next} : {})};
|
|
405
|
+
},
|
|
406
|
+
text(r, ctx) {
|
|
407
|
+
const c = paint(ctx);
|
|
408
|
+
const items = r?.items ?? [];
|
|
409
|
+
if (!items.length) return 'No recordings';
|
|
410
|
+
return [
|
|
411
|
+
...table(
|
|
412
|
+
items.map((i) => [
|
|
413
|
+
localTime(i.created * 1000),
|
|
414
|
+
sizeText(i.size ?? 0),
|
|
415
|
+
String(i.path ?? '').replace(/^\/?spoolview\/recordings\//, ''),
|
|
416
|
+
]),
|
|
417
|
+
),
|
|
418
|
+
...(r.nextStart ? [c.dim(`more: --page ${r.nextStart}`)] : []),
|
|
419
|
+
];
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
'recording-download': {
|
|
423
|
+
summary: 'save a call recording to a new file (by its path, or by the id of its call)',
|
|
424
|
+
usage: 'wx-admin records recording-download <path|callId> --to FILE [--index N]',
|
|
425
|
+
details:
|
|
426
|
+
'The path is one of wx-admin records recordings (or the recording of wx-admin records call). A call id takes the recording of that call; --index chooses one when it has several.',
|
|
427
|
+
options: {
|
|
428
|
+
to: {type: 'string', placeholder: 'FILE', description: 'the file to write (must not exist)'},
|
|
429
|
+
index: {type: 'string', placeholder: 'N', description: 'which recording of the call (1 = the first)'},
|
|
430
|
+
},
|
|
431
|
+
args: [1, 1],
|
|
432
|
+
async run(ctx) {
|
|
433
|
+
if (!ctx.values.to) throw new UsageError('--to FILE is needed', ctx.usage());
|
|
434
|
+
checkNewFile(ctx, ctx.values.to);
|
|
435
|
+
const ref = ctx.positionals[0];
|
|
436
|
+
const client = await pbxClient(ctx);
|
|
437
|
+
let source = ref;
|
|
438
|
+
if (/^\d+$/.test(ref)) {
|
|
439
|
+
const list = recordingsOf(await findCall(client, ref));
|
|
440
|
+
if (!list.length) throw fail(`The call ${ref} has no recording`, 404);
|
|
441
|
+
const i = wholeNumber(ctx.values.index, 'index', {min: 1, max: list.length, fallback: 1});
|
|
442
|
+
if (list.length > 1 && ctx.values.index === undefined)
|
|
443
|
+
ctx.info(`The call has ${list.length} recordings: saving the first (--index N for another)`);
|
|
444
|
+
source = list[i - 1].url;
|
|
445
|
+
}
|
|
446
|
+
const bytes = await spoolDownload(
|
|
447
|
+
ctx,
|
|
448
|
+
client,
|
|
449
|
+
`/spoolview/recordings/${recordingPath(source)}`,
|
|
450
|
+
'recordings',
|
|
451
|
+
);
|
|
452
|
+
const file = writeNewFile(ctx, ctx.values.to, bytes);
|
|
453
|
+
return {file, source: String(source).replace(/^\/?spoolview\/recordings\//, ''), bytes: bytes.length};
|
|
454
|
+
},
|
|
455
|
+
text: downloaded,
|
|
456
|
+
},
|
|
457
|
+
'recordings-batch': {
|
|
458
|
+
summary: 'pack the recordings of a time range into one archive on the PBX, wait for it, optionally save it',
|
|
459
|
+
usage:
|
|
460
|
+
'wx-admin records recordings-batch (--since DATE --until DATE | --task ID) [--to FILE] [--wait-seconds N]',
|
|
461
|
+
details: [
|
|
462
|
+
'The PBX packs every recording made in the range (local time of this computer): a long range makes a big archive.',
|
|
463
|
+
'Without --to it says where the archive is; --task checks (or waits for) a task made before.',
|
|
464
|
+
].join('\n'),
|
|
465
|
+
options: {
|
|
466
|
+
since: {
|
|
467
|
+
type: 'string',
|
|
468
|
+
placeholder: 'DATE',
|
|
469
|
+
description: 'recordings made from this time on (YYYY-MM-DD or "YYYY-MM-DD HH:MM", local time)',
|
|
470
|
+
},
|
|
471
|
+
until: {
|
|
472
|
+
type: 'string',
|
|
473
|
+
placeholder: 'DATE',
|
|
474
|
+
description: 'recordings made up to this time (a date alone: to the end of that day)',
|
|
475
|
+
},
|
|
476
|
+
task: {type: 'string', placeholder: 'ID', description: 'a task made before: check it instead of making one'},
|
|
477
|
+
to: {type: 'string', placeholder: 'FILE', description: 'save the archive to this file (must not exist)'},
|
|
478
|
+
'wait-seconds': {
|
|
479
|
+
type: 'string',
|
|
480
|
+
placeholder: 'N',
|
|
481
|
+
description: 'wait at most this long for the archive (default 300; 0: do not wait)',
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
args: [0, 0],
|
|
485
|
+
async run(ctx) {
|
|
486
|
+
const {values} = ctx;
|
|
487
|
+
const range = values.since !== undefined || values.until !== undefined;
|
|
488
|
+
if (range === Boolean(values.task)) throw new UsageError('Give --since and --until, or --task', ctx.usage());
|
|
489
|
+
if (range && (values.since === undefined || values.until === undefined))
|
|
490
|
+
throw new UsageError('A range needs both --since and --until', ctx.usage());
|
|
491
|
+
const wait = wholeNumber(values['wait-seconds'], 'wait-seconds', {max: 3600, fallback: 300});
|
|
492
|
+
if (values.to) checkNewFile(ctx, values.to);
|
|
493
|
+
const client = await pbxClient(ctx);
|
|
494
|
+
let task = {id: values.task};
|
|
495
|
+
if (range) {
|
|
496
|
+
const from = unixSeconds(values.since, 'since');
|
|
497
|
+
const to = unixSeconds(values.until, 'until', true);
|
|
498
|
+
if (to < from) throw new UsageError('--until is before --since');
|
|
499
|
+
task = await client.call('POST', '/PBX/recordings/download/', {from, to}, {scope: 'recordings'});
|
|
500
|
+
ctx.info(`Task ${task.id}: the PBX packs the recordings`);
|
|
501
|
+
}
|
|
502
|
+
if (!/^[A-Za-z0-9_.-]{1,200}$/.test(String(task?.id ?? '')))
|
|
503
|
+
throw new UsageError(`Not a task id: ${task?.id}`);
|
|
504
|
+
const deadline = ctx.clock() + wait * 1000;
|
|
505
|
+
let status;
|
|
506
|
+
for (;;) {
|
|
507
|
+
status = await client.call('GET', `/PBX/recordings/download/${task.id}`, undefined, {scope: 'recordings'});
|
|
508
|
+
if (
|
|
509
|
+
status?.path ||
|
|
510
|
+
/fail|error/i.test(String(status?.status ?? '')) ||
|
|
511
|
+
ctx.clock() >= deadline ||
|
|
512
|
+
ctx.signal.aborted
|
|
513
|
+
)
|
|
514
|
+
break;
|
|
515
|
+
await sleep(2000, ctx.signal);
|
|
516
|
+
}
|
|
517
|
+
const result = {...task, ...status, id: task.id};
|
|
518
|
+
if (!status?.path) return {...result, ready: false};
|
|
519
|
+
if (!values.to) return {...result, ready: true};
|
|
520
|
+
// The archive is a short-lived storage link (seen live), or a path on the PBX.
|
|
521
|
+
const bytes = /^https?:\/\//i.test(status.path)
|
|
522
|
+
? await fetchLink(ctx, status.path, 'the archive')
|
|
523
|
+
: await spoolDownload(
|
|
524
|
+
ctx,
|
|
525
|
+
client,
|
|
526
|
+
`/${segmentsPath(String(status.path).replace(/^\/+/, ''), 'archive path')}`,
|
|
527
|
+
'recordings',
|
|
528
|
+
);
|
|
529
|
+
return {...result, ready: true, file: writeNewFile(ctx, values.to, bytes), bytes: bytes.length};
|
|
530
|
+
},
|
|
531
|
+
// The link of the archive is a short-lived bearer link: only --output json shows it.
|
|
532
|
+
text: (r) =>
|
|
533
|
+
r.file
|
|
534
|
+
? `Saved the archive of task ${r.id} (${sizeText(r.bytes)}) to ${r.file}`
|
|
535
|
+
: r.ready
|
|
536
|
+
? `Task ${r.id}: the archive is ready for a short time (--task ${r.id} --to FILE saves it; --output json shows its link)`
|
|
537
|
+
: `Task ${r.id}: ${r.status || 'not ready yet'} (check again with --task ${r.id})`,
|
|
538
|
+
},
|
|
539
|
+
voicemails: {
|
|
540
|
+
summary: 'the voicemails of the PBX, the newest first',
|
|
541
|
+
usage: 'wx-admin records voicemails [--mailbox EXT] [--folder NAME]',
|
|
542
|
+
options: {
|
|
543
|
+
mailbox: {type: 'string', placeholder: 'EXT', description: 'only the voicemails of this mailbox (extension)'},
|
|
544
|
+
folder: {type: 'string', placeholder: 'NAME', description: 'only this folder: INBOX (new), Old…'},
|
|
545
|
+
},
|
|
546
|
+
args: [0, 0],
|
|
547
|
+
async run(ctx) {
|
|
548
|
+
const r = await voicemailList(await pbxClient(ctx));
|
|
549
|
+
const {mailbox, folder} = ctx.values;
|
|
550
|
+
const records = (r.records ?? [])
|
|
551
|
+
.filter(
|
|
552
|
+
(v) =>
|
|
553
|
+
mailbox === undefined ||
|
|
554
|
+
String(v.origmailbox ?? v.exten) === mailbox ||
|
|
555
|
+
String(v.path ?? '').startsWith(`${mailbox}/`),
|
|
556
|
+
)
|
|
557
|
+
.filter((v) => folder === undefined || String(v.folder ?? '').toLowerCase() === folder.toLowerCase())
|
|
558
|
+
.sort((a, b) => Number(b.origtime ?? 0) - Number(a.origtime ?? 0));
|
|
559
|
+
return {total: records.length, records};
|
|
560
|
+
},
|
|
561
|
+
text(r) {
|
|
562
|
+
if (!r.records.length) return 'No voicemails';
|
|
563
|
+
return table(
|
|
564
|
+
r.records.map((v) => [
|
|
565
|
+
localTime(Number(v.origtime) * 1000),
|
|
566
|
+
String(v.path ?? '').split('/')[0] || v.origmailbox || '',
|
|
567
|
+
v.folder ?? '',
|
|
568
|
+
duration(Number(v.duration)),
|
|
569
|
+
oneLine(v.callerid ?? '', 40),
|
|
570
|
+
v.msg_id ?? '',
|
|
571
|
+
]),
|
|
572
|
+
);
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
'voicemail-download': {
|
|
576
|
+
summary: 'save a voicemail message to a new file',
|
|
577
|
+
usage: 'wx-admin records voicemail-download <msg_id|path> --to FILE',
|
|
578
|
+
details: 'msg_id and path are those of wx-admin records voicemails.',
|
|
579
|
+
options: {to: {type: 'string', placeholder: 'FILE', description: 'the file to write (must not exist)'}},
|
|
580
|
+
args: [1, 1],
|
|
581
|
+
async run(ctx) {
|
|
582
|
+
if (!ctx.values.to) throw new UsageError('--to FILE is needed', ctx.usage());
|
|
583
|
+
checkNewFile(ctx, ctx.values.to);
|
|
584
|
+
const ref = ctx.positionals[0];
|
|
585
|
+
const client = await pbxClient(ctx);
|
|
586
|
+
let source = ref.replace(/^\/?spoolview\/voicemail\//, '');
|
|
587
|
+
if (!source.includes('/')) {
|
|
588
|
+
const found = ((await voicemailList(client)).records ?? []).find((v) => v.msg_id === ref);
|
|
589
|
+
if (!found?.path) throw fail(`No voicemail ${ref} on this PBX (wx-admin records voicemails)`, 404);
|
|
590
|
+
source = found.path;
|
|
591
|
+
}
|
|
592
|
+
const bytes = await spoolDownload(
|
|
593
|
+
ctx,
|
|
594
|
+
client,
|
|
595
|
+
`/spoolview/voicemail/${segmentsPath(source, 'voicemail path')}`,
|
|
596
|
+
'pbx',
|
|
597
|
+
);
|
|
598
|
+
return {file: writeNewFile(ctx, ctx.values.to, bytes), source, bytes: bytes.length};
|
|
599
|
+
},
|
|
600
|
+
text: downloaded,
|
|
601
|
+
},
|
|
602
|
+
'voicemail-delete': {
|
|
603
|
+
summary: 'delete voicemails for good (needs --yes)',
|
|
604
|
+
usage: 'wx-admin records voicemail-delete <msg_id>... --yes',
|
|
605
|
+
options: {yes: {type: 'boolean', description: 'yes, delete them (they cannot be brought back)'}},
|
|
606
|
+
args: [1, 100],
|
|
607
|
+
async run(ctx) {
|
|
608
|
+
if (!ctx.values.yes)
|
|
609
|
+
throw new UsageError('A deleted voicemail cannot be brought back: confirm with --yes', ctx.usage());
|
|
610
|
+
const ids = ctx.positionals.map(voicemailId);
|
|
611
|
+
return (await pbxClient(ctx)).call('DELETE', `/VoiceMail/${ids.join(',')}/`, undefined, {scope: 'pbx'});
|
|
612
|
+
},
|
|
613
|
+
text(r) {
|
|
614
|
+
const errors = Array.isArray(r?.errors) ? r.errors : [];
|
|
615
|
+
return [
|
|
616
|
+
`Deleted ${r?.changed ?? 0} voicemail${r?.changed === 1 ? '' : 's'}`,
|
|
617
|
+
...errors.map((e) => ` ${oneLine(typeof e === 'string' ? e : JSON.stringify(e), 160)}`),
|
|
618
|
+
];
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
};
|