wldx-admin-cli 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +445 -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 +527 -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/transfer/apply.mjs +109 -0
- package/src/transfer/folder.mjs +77 -0
- package/src/transfer/kinds.mjs +259 -0
- package/src/transfer/log.mjs +35 -0
- package/src/transfer/next-steps.mjs +251 -0
- package/src/transfer/plan.mjs +153 -0
- package/src/transfer/refs.mjs +251 -0
- package/src/transfer/run.mjs +308 -0
- package/src/transfer/selection.mjs +124 -0
- package/src/version.mjs +3 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
// wx-admin user: the users (colleagues) of a PBX, their presence and SIP registrations, the user groups and the
|
|
2
|
+
// departments, over the PBX REST API. Operations used:
|
|
3
|
+
// GET/POST /PBX/Colleagues/, GET/PUT/DELETE /PBX/Colleagues/{id}/, PUT /PBX/Colleagues/{id}/preferences/ (colleagues)
|
|
4
|
+
// PUT /PBX/presence/ (colleagues: the batch of the administrators), GET /User/{extension}/Presence/ (pbx)
|
|
5
|
+
// GET /PBX/Users/Sip/Registrations, GET /PBX/Users/{extensions}/Sip/Registrations (pbx)
|
|
6
|
+
// GET /Groups/ (pbx), GET /Departments/ (info)
|
|
7
|
+
// The model of the API: a user is {id, extension, name, email, officePhone, mobilePhone, faxNumber, role, licenseType,
|
|
8
|
+
// groupDn, groupName, department, language, dialplan, faxDialplan, login, jid, …}; id is the LDAP uid (a number as a
|
|
9
|
+
// string), groupDn names the user group (an ACL group: gid=…). Filters of the list are exact matches on a field
|
|
10
|
+
// (filter[field][]=value); `search` looks into every field. The PBX never returns passwords; a password given in a
|
|
11
|
+
// --from file is sent and never printed.
|
|
12
|
+
//
|
|
13
|
+
// Live checks (WMS 7.x test PBX): GET /Groups/ answers {total, records} (the OpenAPI says an array);
|
|
14
|
+
// GET /PBX/Users/…/Sip/Registrations answers [] when nobody is registered (an object by extension otherwise);
|
|
15
|
+
// fields=groupName works although the OpenAPI does not list it; filter[email][] ignores the case.
|
|
16
|
+
import {readJsonInput as readJsonFile} from 'wldx-core/files';
|
|
17
|
+
import {UsageError} from 'wldx-core/errors';
|
|
18
|
+
import {localTime, oneLine, paint, table} from 'wldx-core/format';
|
|
19
|
+
import {apiPath, query} from 'wldx-core/http';
|
|
20
|
+
import {pbxClient} from '../pbx.mjs';
|
|
21
|
+
|
|
22
|
+
const SCOPE = 'colleagues';
|
|
23
|
+
export const ROLES = ['admin', 'user', 'fax', 'park_orbit', 'room'];
|
|
24
|
+
export const LICENSES = ['basic', 'essential', 'business', 'premium', 'wizyconf'];
|
|
25
|
+
const SORTS = ['extension', 'name', 'email', 'role', 'department', 'licenseType', 'groupDn', 'id'];
|
|
26
|
+
const PRESENCES = ['available', 'away', 'dnd', 'mur'];
|
|
27
|
+
// What `user delete --also` may remove with the user (data[] of DELETE /PBX/Colleagues/{id}/).
|
|
28
|
+
export const DELETE_DATA = [
|
|
29
|
+
'userAvatar',
|
|
30
|
+
'personalData',
|
|
31
|
+
'voicemails',
|
|
32
|
+
'voicemailGreetings',
|
|
33
|
+
'calls',
|
|
34
|
+
'chatHistory',
|
|
35
|
+
'phonebooks',
|
|
36
|
+
'callGroupMember',
|
|
37
|
+
'missedCallManager',
|
|
38
|
+
'pagingGroupMember',
|
|
39
|
+
'applications',
|
|
40
|
+
'devices',
|
|
41
|
+
];
|
|
42
|
+
// The fields a list asks for without --full: no e-mail, phone or picture of other people.
|
|
43
|
+
const LIST_FIELDS = 'id,extension,name,role,licenseType,groupName,groupDn,department,language,dialplan';
|
|
44
|
+
const SECRETS = ['password', 'sipPassword'];
|
|
45
|
+
|
|
46
|
+
const withoutSecrets = (u) =>
|
|
47
|
+
u && typeof u === 'object' ? Object.fromEntries(Object.entries(u).filter(([k]) => !SECRETS.includes(k))) : u;
|
|
48
|
+
|
|
49
|
+
async function readJsonInput(ctx, from) {
|
|
50
|
+
const data = await readJsonFile(ctx, from); // no credentials, nothing hidden in the home folder
|
|
51
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
52
|
+
throw new UsageError('--from holds one JSON object of user fields');
|
|
53
|
+
return data;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A user by extension, id, e-mail or exact name: the record of the PBX (all its fields).
|
|
57
|
+
async function findUser(client, ref) {
|
|
58
|
+
const s = String(ref).trim();
|
|
59
|
+
const by = (field) =>
|
|
60
|
+
client.call('GET', `/PBX/Colleagues/${query({[`filter[${field}][]`]: s})}`, undefined, {scope: SCOPE});
|
|
61
|
+
let found = [];
|
|
62
|
+
if (s.includes('@')) found = (await by('email'))?.records ?? [];
|
|
63
|
+
else if (/^\d+$/.test(s)) {
|
|
64
|
+
found = (await by('extension'))?.records ?? [];
|
|
65
|
+
if (!found.length) {
|
|
66
|
+
try {
|
|
67
|
+
found = [await client.call('GET', apiPath`/PBX/Colleagues/${s}/`, undefined, {scope: SCOPE})];
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (e.status !== 404) throw e;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} else if (s) found = (await by('name'))?.records ?? [];
|
|
73
|
+
if (!found.length) throw new UsageError(`No user ${s} on this PBX (wx-admin user list --search TEXT)`);
|
|
74
|
+
if (found.length > 1)
|
|
75
|
+
throw new UsageError(
|
|
76
|
+
`${found.length} users match ${s}: give the extension (${found.map((u) => u.extension).join(', ')})`,
|
|
77
|
+
);
|
|
78
|
+
return found[0];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The DN of a user group from its name, id or DN.
|
|
82
|
+
async function groupDn(client, ref) {
|
|
83
|
+
const s = String(ref).trim();
|
|
84
|
+
if (/^gid=\d+,/.test(s)) return s;
|
|
85
|
+
const r = await client.call('GET', '/Groups/', undefined, {scope: 'pbx'});
|
|
86
|
+
const groups = Array.isArray(r) ? r : (r?.records ?? []);
|
|
87
|
+
const g =
|
|
88
|
+
groups.find((x) => x.id === s || x.name === s) ??
|
|
89
|
+
groups.find((x) => String(x.name).toLowerCase() === s.toLowerCase());
|
|
90
|
+
if (!g) throw new UsageError(`No user group ${s} (wx-admin user groups)`);
|
|
91
|
+
return g.dn;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The fields of create and update: the option, the API field, a check.
|
|
95
|
+
const FIELDS = {
|
|
96
|
+
extension: {field: 'extension', check: /^\d{2,50}$/, says: 'digits (2 to 50)'},
|
|
97
|
+
name: {field: 'name', check: /^[\p{L}\p{M}\p{N} _-]{2,50}$/u, says: 'letters, digits, spaces, - and _ (2 to 50)'},
|
|
98
|
+
email: {field: 'email', check: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, says: 'an e-mail address'},
|
|
99
|
+
role: {field: 'role'},
|
|
100
|
+
license: {field: 'licenseType'},
|
|
101
|
+
department: {field: 'department', check: /^[^@&<>]{1,250}$/, says: 'up to 250 characters without @ & < >'},
|
|
102
|
+
language: {field: 'language', check: /^[a-z]{2}(-[a-z]{2})?$/i, says: 'a language code: en, it, fr, en-us…'},
|
|
103
|
+
dialplan: {field: 'dialplan', check: /^[A-Za-z0-9_-]{1,64}$/, says: 'a dialplan name'},
|
|
104
|
+
'fax-dialplan': {field: 'faxDialplan', check: /^[A-Za-z0-9_-]{1,64}$/, says: 'a dialplan name'},
|
|
105
|
+
'office-phone': {field: 'officePhone', check: /^\+?[\d ()-]{2,50}$/, says: 'a phone number'},
|
|
106
|
+
'mobile-phone': {field: 'mobilePhone', check: /^\+?[\d ()-]{2,50}$/, says: 'a phone number'},
|
|
107
|
+
'fax-number': {field: 'faxNumber', check: /^\+?[\d ()-]{2,50}$/, says: 'a phone number'},
|
|
108
|
+
login: {field: 'login', check: /^[A-Za-z0-9._-]{2,50}$/, says: 'letters, digits, . _ - (2 to 50)'},
|
|
109
|
+
};
|
|
110
|
+
const BY_FIELD = Object.fromEntries(Object.entries(FIELDS).map(([opt, f]) => [f.field, {...f, opt}]));
|
|
111
|
+
|
|
112
|
+
const fieldOptions = (verb) => ({
|
|
113
|
+
extension: {type: 'string', placeholder: 'N', description: `${verb} extension (digits)`},
|
|
114
|
+
name: {type: 'string', placeholder: 'NAME', description: `${verb} full name`},
|
|
115
|
+
email: {type: 'string', placeholder: 'EMAIL', description: `${verb} e-mail (notifications, x-bees login)`},
|
|
116
|
+
role: {type: 'string', choices: ROLES, description: `${verb} role`},
|
|
117
|
+
license: {type: 'string', choices: LICENSES, description: `${verb} license type`},
|
|
118
|
+
group: {
|
|
119
|
+
type: 'string',
|
|
120
|
+
placeholder: 'GROUP',
|
|
121
|
+
description: `${verb} user group: its name, id or DN (wx-admin user groups)`,
|
|
122
|
+
},
|
|
123
|
+
department: {type: 'string', placeholder: 'NAME', description: `${verb} department`},
|
|
124
|
+
language: {type: 'string', placeholder: 'CODE', description: `${verb} language: en, it, fr, de, en-us…`},
|
|
125
|
+
dialplan: {type: 'string', placeholder: 'NAME', description: `${verb} dialplan of the calls the user makes`},
|
|
126
|
+
'fax-dialplan': {type: 'string', placeholder: 'NAME', description: `${verb} dialplan of the faxes`},
|
|
127
|
+
'office-phone': {type: 'string', placeholder: 'NUMBER', description: `${verb} office phone number`},
|
|
128
|
+
'mobile-phone': {type: 'string', placeholder: 'NUMBER', description: `${verb} mobile phone number`},
|
|
129
|
+
'fax-number': {type: 'string', placeholder: 'NUMBER', description: `${verb} fax number`},
|
|
130
|
+
login: {type: 'string', placeholder: 'NAME', description: `${verb} login name`},
|
|
131
|
+
from: {
|
|
132
|
+
type: 'string',
|
|
133
|
+
placeholder: 'FILE',
|
|
134
|
+
description: 'more fields as JSON, as the API names them (a file, or - for stdin); options win',
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// The body of POST/PUT /PBX/Colleagues from --from and the options, checked; the group resolved to its DN.
|
|
139
|
+
async function userBody(ctx, client) {
|
|
140
|
+
const src = ctx.values.from ? await readJsonInput(ctx, ctx.values.from) : {};
|
|
141
|
+
const body = {...src};
|
|
142
|
+
for (const [opt, f] of Object.entries(FIELDS)) if (ctx.values[opt] !== undefined) body[f.field] = ctx.values[opt];
|
|
143
|
+
for (const [field, value] of Object.entries(body)) {
|
|
144
|
+
if (SECRETS.includes(field) || field === 'groupDn') continue;
|
|
145
|
+
const f = BY_FIELD[field];
|
|
146
|
+
if (typeof value !== 'string') throw new UsageError(`${field} is a string`);
|
|
147
|
+
if (field === 'role' && !ROLES.includes(value))
|
|
148
|
+
throw new UsageError(`role is one of ${ROLES.join(', ')}: ${value}`);
|
|
149
|
+
if (field === 'licenseType' && !LICENSES.includes(value))
|
|
150
|
+
throw new UsageError(`licenseType is one of ${LICENSES.join(', ')}: ${value}`);
|
|
151
|
+
if (f?.check && !f.check.test(value)) throw new UsageError(`--${f.opt} is ${f.says}: ${value}`);
|
|
152
|
+
}
|
|
153
|
+
for (const k of SECRETS)
|
|
154
|
+
if (body[k] !== undefined && (typeof body[k] !== 'string' || body[k].length < 8))
|
|
155
|
+
throw new UsageError(`${k} in --from is too short`);
|
|
156
|
+
const group = ctx.values.group ?? (body.groupDn && !/^gid=/.test(body.groupDn) ? body.groupDn : undefined);
|
|
157
|
+
if (group !== undefined) body.groupDn = await groupDn(client, group);
|
|
158
|
+
return body;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const userTitle = (u) => `${u.name ?? ''} (ext ${u.extension ?? '?'}, id ${u.id ?? '?'})`;
|
|
162
|
+
|
|
163
|
+
function userLines(u) {
|
|
164
|
+
const pairs = [
|
|
165
|
+
['role', u.role],
|
|
166
|
+
['license', u.licenseType],
|
|
167
|
+
['group', u.groupName],
|
|
168
|
+
['department', u.department],
|
|
169
|
+
['e-mail', u.email],
|
|
170
|
+
['office phone', u.officePhone],
|
|
171
|
+
['mobile phone', u.mobilePhone],
|
|
172
|
+
['fax number', u.faxNumber],
|
|
173
|
+
['language', u.language],
|
|
174
|
+
['dialplan', u.dialplan],
|
|
175
|
+
['fax dialplan', u.faxDialplan],
|
|
176
|
+
['login', u.login],
|
|
177
|
+
].filter(([, v]) => v !== undefined && v !== null && v !== '');
|
|
178
|
+
return [userTitle(u), ...table(pairs.map(([k, v]) => [` ${k}`, v]))];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// "19/09/2017 11:30" in UTC, as the presence API wants it, from "YYYY-MM-DD HH:MM" (local time) or "+30m", "+2h", "+1d".
|
|
182
|
+
export function presenceUntil(value, now) {
|
|
183
|
+
const s = String(value).trim();
|
|
184
|
+
let t;
|
|
185
|
+
const rel = /^\+(\d+)([mhd])$/.exec(s);
|
|
186
|
+
if (rel) t = new Date(now + Number(rel[1]) * {m: 60_000, h: 3_600_000, d: 86_400_000}[rel[2]]);
|
|
187
|
+
else if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}$/.test(s)) t = new Date(s.replace(' ', 'T'));
|
|
188
|
+
if (!t || Number.isNaN(t.getTime()))
|
|
189
|
+
throw new UsageError(`--until is "YYYY-MM-DD HH:MM" (local time) or +30m, +2h, +1d: ${value}`);
|
|
190
|
+
if (t.getTime() <= now) throw new UsageError(`--until is in the past: ${value}`);
|
|
191
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
192
|
+
return `${p(t.getUTCDate())}/${p(t.getUTCMonth() + 1)}/${t.getUTCFullYear()} ${p(t.getUTCHours())}:${p(t.getUTCMinutes())}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const EXTENSION = /^\d{1,50}$/;
|
|
196
|
+
const checkExtensions = (list) => {
|
|
197
|
+
for (const e of list) if (!EXTENSION.test(e)) throw new UsageError(`Not an extension: ${e}`);
|
|
198
|
+
return list;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// The extension of a user ref: digits are taken as an extension, anything else is looked up.
|
|
202
|
+
const extensionOf = async (client, ref) =>
|
|
203
|
+
EXTENSION.test(String(ref)) ? String(ref) : (await findUser(client, ref)).extension;
|
|
204
|
+
|
|
205
|
+
// A PBX answer {type: 'warning', reason, warnings: {extension: reason}} (copy-preferences, presence): the warnings.
|
|
206
|
+
const warningsOf = (r) =>
|
|
207
|
+
r && typeof r === 'object' && r.type === 'warning' ? {reason: r.reason, warnings: r.warnings ?? {}} : undefined;
|
|
208
|
+
|
|
209
|
+
const warningLines = (w, ctx) => {
|
|
210
|
+
if (!w) return [];
|
|
211
|
+
const c = paint(ctx);
|
|
212
|
+
return [
|
|
213
|
+
c.yellow(`Warning: ${w.reason ?? 'not everything was done'}`),
|
|
214
|
+
...Object.entries(w.warnings ?? {}).map(([k, v]) => c.yellow(` ${k}: ${oneLine(v, 100)}`)),
|
|
215
|
+
];
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const deviceState = (s) => (s ? String(s).replace(/_/g, ' ') : '');
|
|
219
|
+
|
|
220
|
+
export const groups = {
|
|
221
|
+
user: {
|
|
222
|
+
summary:
|
|
223
|
+
'users (colleagues): list, show, create, change, delete, copy preferences, presence, SIP registrations, groups, departments',
|
|
224
|
+
commands: {
|
|
225
|
+
list: {
|
|
226
|
+
summary: 'the users of the PBX, with filters',
|
|
227
|
+
usage:
|
|
228
|
+
'wx-admin user list [--search TEXT] [--group GROUP] [--department NAME] [--role ROLE] [--license TYPE] [--sort FIELD [--desc]] [--limit N] [--start N] [--full]',
|
|
229
|
+
details:
|
|
230
|
+
'Filters are exact (--department "Level 1"); --search looks into every field. E-mails and phones only with --full.',
|
|
231
|
+
options: {
|
|
232
|
+
search: {type: 'string', placeholder: 'TEXT', description: 'users with this text in any field'},
|
|
233
|
+
group: {type: 'string', placeholder: 'GROUP', description: 'users of this user group: its name, id or DN'},
|
|
234
|
+
department: {type: 'string', placeholder: 'NAME', description: 'users of this department (exact name)'},
|
|
235
|
+
role: {type: 'string', choices: ROLES, description: 'users with this role'},
|
|
236
|
+
license: {type: 'string', choices: LICENSES, description: 'users with this license type'},
|
|
237
|
+
sort: {type: 'string', choices: SORTS, description: 'sort by this field (default: as the PBX gives them)'},
|
|
238
|
+
desc: {type: 'boolean', description: 'sort in descending order'},
|
|
239
|
+
limit: {type: 'string', placeholder: 'N', description: 'at most N users (default: all)'},
|
|
240
|
+
start: {type: 'string', placeholder: 'N', description: 'skip the first N users (paging)'},
|
|
241
|
+
full: {type: 'boolean', description: 'every field, e-mails and phones included'},
|
|
242
|
+
},
|
|
243
|
+
args: [0, 0],
|
|
244
|
+
readOnly: true,
|
|
245
|
+
async run(ctx) {
|
|
246
|
+
const v = ctx.values;
|
|
247
|
+
for (const k of ['limit', 'start'])
|
|
248
|
+
if (v[k] !== undefined && !/^\d+$/.test(v[k])) throw new UsageError(`--${k} is a whole number: ${v[k]}`);
|
|
249
|
+
if (v.desc && !v.sort) throw new UsageError('--desc goes with --sort');
|
|
250
|
+
const client = await pbxClient(ctx);
|
|
251
|
+
const q = {
|
|
252
|
+
'filter[groupDn][]': v.group === undefined ? undefined : await groupDn(client, v.group),
|
|
253
|
+
'filter[department][]': v.department,
|
|
254
|
+
'filter[role][]': v.role,
|
|
255
|
+
'filter[licenseType][]': v.license,
|
|
256
|
+
search: v.search,
|
|
257
|
+
sort: v.sort,
|
|
258
|
+
dir: v.sort ? (v.desc ? 'desc' : 'asc') : undefined,
|
|
259
|
+
count: v.limit,
|
|
260
|
+
start: v.start,
|
|
261
|
+
fields: v.full ? undefined : LIST_FIELDS,
|
|
262
|
+
};
|
|
263
|
+
const r = await client.call('GET', `/PBX/Colleagues/${query(q)}`, undefined, {scope: SCOPE});
|
|
264
|
+
return {...r, records: (r?.records ?? []).map(withoutSecrets)};
|
|
265
|
+
},
|
|
266
|
+
text(r, ctx) {
|
|
267
|
+
const records = r.records ?? [];
|
|
268
|
+
if (!records.length) return 'No users';
|
|
269
|
+
const full = ctx.values.full;
|
|
270
|
+
const rows = records.map((u) => [
|
|
271
|
+
u.extension,
|
|
272
|
+
u.name,
|
|
273
|
+
u.role,
|
|
274
|
+
u.licenseType,
|
|
275
|
+
u.groupName ?? '',
|
|
276
|
+
u.department ?? '',
|
|
277
|
+
...(full ? [u.email ?? '', u.mobilePhone || u.officePhone || ''] : []),
|
|
278
|
+
]);
|
|
279
|
+
const start = Number(ctx.values.start ?? 0);
|
|
280
|
+
const more =
|
|
281
|
+
r.total > start + records.length
|
|
282
|
+
? [
|
|
283
|
+
paint(ctx).dim(
|
|
284
|
+
`${records.length} of ${r.total} users: --start ${start + records.length} for the next ones`,
|
|
285
|
+
),
|
|
286
|
+
]
|
|
287
|
+
: [];
|
|
288
|
+
return [...table(rows), ...more];
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
get: {
|
|
292
|
+
summary: 'one user: role, license, group, department, e-mail, phones, dialplan',
|
|
293
|
+
usage: 'wx-admin user get <user>',
|
|
294
|
+
details: 'A user is an extension, an id, an e-mail or the exact name.',
|
|
295
|
+
options: {},
|
|
296
|
+
args: [1, 1],
|
|
297
|
+
readOnly: true,
|
|
298
|
+
async run(ctx) {
|
|
299
|
+
return withoutSecrets(await findUser(await pbxClient(ctx), ctx.positionals[0]));
|
|
300
|
+
},
|
|
301
|
+
text: (u) => userLines(u),
|
|
302
|
+
},
|
|
303
|
+
create: {
|
|
304
|
+
summary: 'create a user (it takes a license; the PBX may send the user a welcome e-mail)',
|
|
305
|
+
usage:
|
|
306
|
+
'wx-admin user create --extension N --name NAME --license TYPE [--email EMAIL] [--role ROLE] [--group GROUP] [--department NAME] [... | --from FILE|-]',
|
|
307
|
+
details: [
|
|
308
|
+
'Needs an extension, a name and a license type (from the options or --from).',
|
|
309
|
+
'A new user takes a license of that type from the PBX, and with an e-mail the PBX may send the user a welcome',
|
|
310
|
+
'e-mail with the access to x-bees/Collaboration. Passwords are never options: put password and sipPassword in',
|
|
311
|
+
'a --from file if you must set them (else the PBX generates them).',
|
|
312
|
+
].join('\n'),
|
|
313
|
+
options: fieldOptions('the'),
|
|
314
|
+
args: [0, 0],
|
|
315
|
+
async run(ctx) {
|
|
316
|
+
const client = await pbxClient(ctx);
|
|
317
|
+
const body = await userBody(ctx, client);
|
|
318
|
+
const missing = ['extension', 'name', 'licenseType'].filter((k) => !body[k]);
|
|
319
|
+
if (missing.length)
|
|
320
|
+
throw new UsageError(
|
|
321
|
+
`A new user needs ${missing.map((k) => `--${BY_FIELD[k].opt}`).join(', ')}`,
|
|
322
|
+
ctx.usage(),
|
|
323
|
+
);
|
|
324
|
+
return withoutSecrets(await client.call('POST', '/PBX/Colleagues/', body, {scope: SCOPE}));
|
|
325
|
+
},
|
|
326
|
+
text: (u) => [`Created ${userTitle(u)}`],
|
|
327
|
+
},
|
|
328
|
+
update: {
|
|
329
|
+
summary: 'change fields of a user (only the ones given)',
|
|
330
|
+
usage:
|
|
331
|
+
'wx-admin user update <user> [--name NAME] [--email EMAIL] [--role ROLE] [--license TYPE] [--group GROUP] [... | --from FILE|-] [--transfer-chats]',
|
|
332
|
+
details:
|
|
333
|
+
'A user is an extension, an id, an e-mail or the exact name. --transfer-chats moves the x-bees/Collaboration chats to the new e-mail.',
|
|
334
|
+
options: {
|
|
335
|
+
...fieldOptions('the new'),
|
|
336
|
+
'transfer-chats': {type: 'boolean', description: 'with a new e-mail: move the chats of the user to it'},
|
|
337
|
+
},
|
|
338
|
+
args: [1, 1],
|
|
339
|
+
async run(ctx) {
|
|
340
|
+
const client = await pbxClient(ctx);
|
|
341
|
+
const body = await userBody(ctx, client);
|
|
342
|
+
if (!Object.keys(body).length) throw new UsageError('Nothing to change: give a field to change', ctx.usage());
|
|
343
|
+
const user = await findUser(client, ctx.positionals[0]);
|
|
344
|
+
const q = query({transferRnaChats: ctx.values['transfer-chats'] ? 'true' : undefined});
|
|
345
|
+
return withoutSecrets(
|
|
346
|
+
await client.call('PUT', `${apiPath`/PBX/Colleagues/${user.id}/`}${q}`, body, {scope: SCOPE}),
|
|
347
|
+
);
|
|
348
|
+
},
|
|
349
|
+
text: (u) => [`Updated ${userTitle(u)}`],
|
|
350
|
+
},
|
|
351
|
+
delete: {
|
|
352
|
+
summary: 'delete users (needs --yes: it cannot be undone); --also removes their data too',
|
|
353
|
+
usage: 'wx-admin user delete <user>... --yes [--also ITEM]...',
|
|
354
|
+
details: `--also takes: ${DELETE_DATA.join(', ')}.`,
|
|
355
|
+
options: {
|
|
356
|
+
yes: {type: 'boolean', description: 'yes, delete them'},
|
|
357
|
+
also: {
|
|
358
|
+
type: 'string',
|
|
359
|
+
multiple: true,
|
|
360
|
+
choices: DELETE_DATA,
|
|
361
|
+
description: 'remove this data of the users too',
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
args: [1, 50],
|
|
365
|
+
async run(ctx) {
|
|
366
|
+
if (!ctx.values.yes)
|
|
367
|
+
throw new UsageError(
|
|
368
|
+
'Deleting a user cannot be undone: their calls stop and the license is freed. Confirm with --yes',
|
|
369
|
+
ctx.usage(),
|
|
370
|
+
);
|
|
371
|
+
const client = await pbxClient(ctx);
|
|
372
|
+
const users = [];
|
|
373
|
+
for (const ref of ctx.positionals) users.push(await findUser(client, ref));
|
|
374
|
+
// data[] names what goes: the user always, and what --also adds (as the WMS dialog sends it).
|
|
375
|
+
const q = query({'data[]': ['user', ...new Set(ctx.values.also ?? [])]});
|
|
376
|
+
const deleted = [];
|
|
377
|
+
for (const u of users) {
|
|
378
|
+
await client.call('DELETE', `${apiPath`/PBX/Colleagues/${u.id}/`}${q}`, undefined, {scope: SCOPE});
|
|
379
|
+
deleted.push({id: u.id, extension: u.extension, name: u.name});
|
|
380
|
+
}
|
|
381
|
+
return {deleted};
|
|
382
|
+
},
|
|
383
|
+
text: (r) => r.deleted.map((u) => `Deleted ${userTitle(u)}`),
|
|
384
|
+
},
|
|
385
|
+
'copy-preferences': {
|
|
386
|
+
summary: 'copy the preferences of one user to others (needs --yes: theirs are overwritten)',
|
|
387
|
+
usage: 'wx-admin user copy-preferences <from> <to>... --yes',
|
|
388
|
+
details: 'Users are extensions, ids, e-mails or exact names.',
|
|
389
|
+
options: {yes: {type: 'boolean', description: 'yes, overwrite their preferences'}},
|
|
390
|
+
args: [2, 50],
|
|
391
|
+
async run(ctx) {
|
|
392
|
+
if (!ctx.values.yes)
|
|
393
|
+
throw new UsageError(
|
|
394
|
+
'The preferences of the target users are overwritten and cannot be restored: confirm with --yes',
|
|
395
|
+
ctx.usage(),
|
|
396
|
+
);
|
|
397
|
+
const client = await pbxClient(ctx);
|
|
398
|
+
const [fromRef, ...toRefs] = ctx.positionals;
|
|
399
|
+
const from = await findUser(client, fromRef);
|
|
400
|
+
const targets = [];
|
|
401
|
+
for (const ref of toRefs) targets.push(await findUser(client, ref));
|
|
402
|
+
if (targets.some((t) => t.id === from.id))
|
|
403
|
+
throw new UsageError(`${fromRef} is the source: it cannot be a target too`);
|
|
404
|
+
const copied = [];
|
|
405
|
+
for (const t of targets) {
|
|
406
|
+
const r = await client.call(
|
|
407
|
+
'PUT',
|
|
408
|
+
apiPath`/PBX/Colleagues/${t.id}/preferences/`,
|
|
409
|
+
{from: String(from.id)},
|
|
410
|
+
{scope: SCOPE},
|
|
411
|
+
);
|
|
412
|
+
copied.push({
|
|
413
|
+
id: t.id,
|
|
414
|
+
extension: t.extension,
|
|
415
|
+
name: t.name,
|
|
416
|
+
...(warningsOf(r) ? {warning: warningsOf(r)} : {}),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return {from: {id: from.id, extension: from.extension, name: from.name}, to: copied};
|
|
420
|
+
},
|
|
421
|
+
text: (r, ctx) =>
|
|
422
|
+
r.to.flatMap((t) => [
|
|
423
|
+
`Copied the preferences of ${userTitle(r.from)} to ${userTitle(t)}`,
|
|
424
|
+
...warningLines(t.warning, ctx),
|
|
425
|
+
]),
|
|
426
|
+
},
|
|
427
|
+
presence: {
|
|
428
|
+
summary: 'the presence of users: online, status, message, until when, what their phone does',
|
|
429
|
+
usage: 'wx-admin user presence <user>... [--full]',
|
|
430
|
+
details: 'A user is an extension (or an id, an e-mail, a name, looked up). Their location only with --full.',
|
|
431
|
+
options: {full: {type: 'boolean', description: 'the location the users shared too'}},
|
|
432
|
+
args: [1, 50],
|
|
433
|
+
readOnly: true,
|
|
434
|
+
async run(ctx) {
|
|
435
|
+
const client = await pbxClient(ctx);
|
|
436
|
+
const out = [];
|
|
437
|
+
for (const ref of ctx.positionals) {
|
|
438
|
+
const extension = await extensionOf(client, ref);
|
|
439
|
+
const p = await client.call('GET', apiPath`/User/${extension}/Presence/`, undefined, {scope: 'pbx'});
|
|
440
|
+
const {location, ...rest} = p ?? {};
|
|
441
|
+
out.push({extension, ...rest, ...(ctx.values.full && location ? {location} : {})});
|
|
442
|
+
}
|
|
443
|
+
return {presence: out};
|
|
444
|
+
},
|
|
445
|
+
text(r, ctx) {
|
|
446
|
+
const c = paint(ctx);
|
|
447
|
+
return table(
|
|
448
|
+
r.presence.map((p) => [
|
|
449
|
+
p.extension,
|
|
450
|
+
p.online ? c.green('online') : c.dim('offline'),
|
|
451
|
+
p.show || (p.online ? 'available' : ''),
|
|
452
|
+
deviceState(p.device_show),
|
|
453
|
+
p.status ? oneLine(p.status, 50) : '',
|
|
454
|
+
p.until
|
|
455
|
+
? `until ${localTime(
|
|
456
|
+
String(p.until)
|
|
457
|
+
.replace(' ', 'T')
|
|
458
|
+
.replace(/\+00:00$/, 'Z'),
|
|
459
|
+
)}`
|
|
460
|
+
: '',
|
|
461
|
+
...(p.location?.address ? [oneLine(p.location.address, 60)] : []),
|
|
462
|
+
]),
|
|
463
|
+
);
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
'presence-set': {
|
|
467
|
+
summary: 'set the presence of users (what their colleagues see): available, away, dnd, mur',
|
|
468
|
+
usage:
|
|
469
|
+
'wx-admin user presence-set <extension>... --status STATUS [--message TEXT] [--until "YYYY-MM-DD HH:MM"|+2h]',
|
|
470
|
+
details:
|
|
471
|
+
'mur: "make up room" (hotels). --until is local time, or +30m, +2h, +1d from now; without it the status stays until changed.',
|
|
472
|
+
options: {
|
|
473
|
+
status: {type: 'string', choices: PRESENCES, description: 'the new status'},
|
|
474
|
+
message: {type: 'string', placeholder: 'TEXT', description: 'the status message'},
|
|
475
|
+
until: {
|
|
476
|
+
type: 'string',
|
|
477
|
+
placeholder: 'TIME',
|
|
478
|
+
description: 'until when: "YYYY-MM-DD HH:MM" (local) or +30m, +2h, +1d',
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
args: [1, 100],
|
|
482
|
+
async run(ctx) {
|
|
483
|
+
const v = ctx.values;
|
|
484
|
+
if (!v.status) throw new UsageError('--status is needed', ctx.usage());
|
|
485
|
+
const extensions = checkExtensions(ctx.positionals.map(String));
|
|
486
|
+
const until = v.until === undefined ? undefined : presenceUntil(v.until, ctx.clock());
|
|
487
|
+
const items = extensions.map((extension) => ({
|
|
488
|
+
extension,
|
|
489
|
+
status: v.status,
|
|
490
|
+
...(v.message !== undefined ? {message: v.message} : {}),
|
|
491
|
+
...(until ? {until} : {}),
|
|
492
|
+
}));
|
|
493
|
+
const r = await (await pbxClient(ctx)).call('PUT', '/PBX/presence/', {items}, {scope: SCOPE});
|
|
494
|
+
return {items, ...(warningsOf(r) ? {warning: warningsOf(r)} : {})};
|
|
495
|
+
},
|
|
496
|
+
text: (r, ctx) => [
|
|
497
|
+
`Presence of ${r.items.map((i) => i.extension).join(', ')}: ${r.items[0].status}${r.items[0].message ? ` (${oneLine(r.items[0].message, 60)})` : ''}${r.items[0].until ? ` until ${r.items[0].until} UTC` : ''}`,
|
|
498
|
+
...warningLines(r.warning, ctx),
|
|
499
|
+
],
|
|
500
|
+
},
|
|
501
|
+
registrations: {
|
|
502
|
+
summary: 'the SIP registrations of users (all, or the extensions given): devices online, user agents',
|
|
503
|
+
usage: 'wx-admin user registrations [<extension>...] [--full]',
|
|
504
|
+
details: 'The addresses (contact, received) of the devices only with --full.',
|
|
505
|
+
options: {full: {type: 'boolean', description: 'the addresses of the devices too'}},
|
|
506
|
+
args: [0, 100],
|
|
507
|
+
readOnly: true,
|
|
508
|
+
async run(ctx) {
|
|
509
|
+
const extensions = checkExtensions(ctx.positionals.map(String));
|
|
510
|
+
const apiPathOf = extensions.length
|
|
511
|
+
? `/PBX/Users/${extensions.join(',')}/Sip/Registrations`
|
|
512
|
+
: '/PBX/Users/Sip/Registrations';
|
|
513
|
+
const r = await (await pbxClient(ctx)).call('GET', apiPathOf, undefined, {scope: 'pbx'});
|
|
514
|
+
// An object by extension; [] when nobody is registered (PHP).
|
|
515
|
+
const byExt = r && typeof r === 'object' && !Array.isArray(r) ? r : {};
|
|
516
|
+
const strip = ({contact, received, ...rest}) => rest;
|
|
517
|
+
return Object.fromEntries(
|
|
518
|
+
Object.entries(byExt).map(([ext, v]) => [
|
|
519
|
+
ext,
|
|
520
|
+
{...v, registrations: (v?.registrations ?? []).map((x) => (ctx.values.full ? x : strip(x)))},
|
|
521
|
+
]),
|
|
522
|
+
);
|
|
523
|
+
},
|
|
524
|
+
text(r, ctx) {
|
|
525
|
+
const c = paint(ctx);
|
|
526
|
+
const rows = Object.entries(r).flatMap(([ext, v]) =>
|
|
527
|
+
(v.registrations ?? []).map((x) => [
|
|
528
|
+
ext,
|
|
529
|
+
x.online === '1' || x.online === 1 || x.online === true ? c.green('online') : c.dim('offline'),
|
|
530
|
+
x.useragent ?? '',
|
|
531
|
+
...(ctx.values.full ? [x.contact ?? '', x.received ?? ''] : []),
|
|
532
|
+
]),
|
|
533
|
+
);
|
|
534
|
+
return rows.length ? table(rows) : 'No SIP registrations';
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
groups: {
|
|
538
|
+
summary: 'the user groups (ACL groups) of the PBX',
|
|
539
|
+
usage: 'wx-admin user groups',
|
|
540
|
+
options: {},
|
|
541
|
+
args: [0, 0],
|
|
542
|
+
readOnly: true,
|
|
543
|
+
async run(ctx) {
|
|
544
|
+
const r = await (await pbxClient(ctx)).call('GET', '/Groups/', undefined, {scope: 'pbx'});
|
|
545
|
+
return Array.isArray(r) ? {total: r.length, records: r} : r;
|
|
546
|
+
},
|
|
547
|
+
text: (r) => (r?.records?.length ? table(r.records.map((g) => [g.id, g.name])) : 'No user groups'),
|
|
548
|
+
},
|
|
549
|
+
departments: {
|
|
550
|
+
summary: 'the departments of the PBX, as a tree, with the number of users',
|
|
551
|
+
usage: 'wx-admin user departments',
|
|
552
|
+
options: {},
|
|
553
|
+
args: [0, 0],
|
|
554
|
+
readOnly: true,
|
|
555
|
+
async run(ctx) {
|
|
556
|
+
return (await pbxClient(ctx)).call('GET', '/Departments/', undefined, {scope: 'info'});
|
|
557
|
+
},
|
|
558
|
+
text(r, ctx) {
|
|
559
|
+
const c = paint(ctx);
|
|
560
|
+
const lines = [];
|
|
561
|
+
const walk = (items, depth) => {
|
|
562
|
+
for (const d of items ?? []) {
|
|
563
|
+
const n = d.users?.length ?? 0;
|
|
564
|
+
lines.push(`${' '.repeat(depth)}${d.name}${c.dim(` ${n} ${n === 1 ? 'user' : 'users'}`)}`);
|
|
565
|
+
walk(d.items, depth + 1);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
walk(r?.records, 0);
|
|
569
|
+
return lines.length ? lines : 'No departments';
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
};
|
package/src/match.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Choosing an object by its name: the exact name first, else the name without case; more than one match at either
|
|
2
|
+
// step is an error that asks for the id (picking one silently could send a change to the wrong object).
|
|
3
|
+
import {UsageError} from 'wldx-core/errors';
|
|
4
|
+
|
|
5
|
+
export function pickByName(list, ref, nameOf, noun, hint) {
|
|
6
|
+
const want = String(ref);
|
|
7
|
+
const exact = list.filter((r) => String(nameOf(r) ?? '') === want);
|
|
8
|
+
const pool = exact.length ? exact : list.filter((r) => String(nameOf(r) ?? '').toLowerCase() === want.toLowerCase());
|
|
9
|
+
if (pool.length > 1)
|
|
10
|
+
throw new UsageError(
|
|
11
|
+
`Several ${noun}s are named ${ref} (ids ${pool.map((r) => r.id).join(', ')}): give the id${hint ? ` (${hint})` : ''}`,
|
|
12
|
+
);
|
|
13
|
+
if (!pool.length) throw new UsageError(`No ${noun} named ${ref}${hint ? ` (${hint})` : ''}`);
|
|
14
|
+
return pool[0];
|
|
15
|
+
}
|