wldx-cli 0.1.1 → 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/README.md +281 -37
- package/package.json +9 -6
- package/src/chat/format.mjs +129 -0
- package/src/chat/lib.mjs +210 -0
- package/src/chat/watch.mjs +282 -0
- package/src/cli.mjs +13 -132
- package/src/commands/calls.mjs +470 -0
- package/src/commands/channel.mjs +404 -0
- package/src/commands/chat.mjs +444 -61
- package/src/commands/conversations.mjs +368 -0
- package/src/commands/file.mjs +80 -0
- package/src/commands/inbox.mjs +108 -0
- package/src/commands/index.mjs +33 -12
- package/src/commands/live.mjs +462 -0
- package/src/commands/meetings.mjs +386 -0
- package/src/commands/phone.mjs +564 -0
- package/src/commands/reports.mjs +606 -0
- package/src/commands/section.mjs +101 -0
- package/src/commands/user.mjs +159 -0
- package/src/history/text.mjs +86 -0
- package/src/history/wda.mjs +171 -0
- package/src/live/socket.mjs +139 -0
- package/src/live/stats.mjs +243 -0
- package/src/live/text.mjs +318 -0
- package/src/live/views.mjs +391 -0
- package/src/phone/control.mjs +437 -0
- package/src/phone/events.mjs +220 -0
- package/src/phone/pbx.mjs +158 -0
- package/src/phone/sip.mjs +386 -0
- package/src/phone/xml.mjs +57 -0
- package/src/stream.mjs +12 -14
- package/src/version.mjs +0 -1
- package/src/auth.mjs +0 -128
- package/src/commands/auth.mjs +0 -32
- package/src/completion.mjs +0 -105
- package/src/errors.mjs +0 -30
- package/src/http.mjs +0 -47
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The Call Control REST API of the user's PBX (https://<pbx>/api/v2/call-control/), as the wildix-call-control
|
|
2
|
+
// skill calls it (scripts/lib.sh pbx_call): the PBX from the claims of the login token, the same token as Bearer,
|
|
3
|
+
// answers {"type":"result","result":X} unwrapped, {"type":"error","reason":…} (or a status other than 2xx) turned
|
|
4
|
+
// into an error with a hint per status. Also the checks of call ids, numbers and device contacts of the skill.
|
|
5
|
+
import {jwtClaims} from 'wldx-core/auth';
|
|
6
|
+
import {AuthError, UsageError} from 'wldx-core/errors';
|
|
7
|
+
import {request} from 'wldx-core/http';
|
|
8
|
+
import {pbxUrl} from 'wldx-core/services';
|
|
9
|
+
|
|
10
|
+
// The SIP watchers that register as a device of the user: wx's own and the one of the skill (sip.sh). They are
|
|
11
|
+
// marked in the device lists and never chosen to ring or to place a call.
|
|
12
|
+
export const WATCHER_USER_AGENT = 'wx watcher';
|
|
13
|
+
const WATCHERS = new Set([WATCHER_USER_AGENT, 'wildix-agent-skills watcher']);
|
|
14
|
+
export const isWatcher = (userAgent) => WATCHERS.has(userAgent);
|
|
15
|
+
|
|
16
|
+
export class PbxError extends Error {
|
|
17
|
+
constructor(message, status) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'PbxError';
|
|
20
|
+
this.status = status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const HINTS = {
|
|
25
|
+
401: 'The PBX rejected the login: log in again (wx login <e-mail>). A PBX accepts only the login of its own environment (a production login works only on a production PBX).',
|
|
26
|
+
403: 'The PBX does not allow this to this user: an ordinary user controls only their own calls and devices.',
|
|
27
|
+
503: 'The PBX could not reach its call engine: try again in a moment.',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function hint(status, reason) {
|
|
31
|
+
if (status === 404) {
|
|
32
|
+
return reason.includes('404 Not Found')
|
|
33
|
+
? 'This PBX has no Call Control API: it needs a newer WMS version.'
|
|
34
|
+
: "The PBX does not see this call among the user's current calls: list them again (wx phone list).";
|
|
35
|
+
}
|
|
36
|
+
return HINTS[status];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// pbxCall(ctx, METHOD, '/list-calls/', body?): the `result` of the answer (or the whole answer without one).
|
|
40
|
+
export async function pbxCall(ctx, method, path, body) {
|
|
41
|
+
if (!/^\/[a-z-]+\/$/.test(path)) throw new Error(`not a call-control path: ${path}`);
|
|
42
|
+
const base = pbxUrl(ctx.token, ctx.env);
|
|
43
|
+
let res;
|
|
44
|
+
try {
|
|
45
|
+
res = await request(`${base}/api/v2/call-control${path}`, {
|
|
46
|
+
method,
|
|
47
|
+
token: ctx.token,
|
|
48
|
+
body,
|
|
49
|
+
fetchImpl: ctx.fetch,
|
|
50
|
+
responseType: 'response',
|
|
51
|
+
timeoutMs: 30_000,
|
|
52
|
+
service: 'the PBX',
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
throw new PbxError(`request failed: ${method} ${path} (the PBX ${base} did not answer)`);
|
|
56
|
+
}
|
|
57
|
+
const text = await res.text().catch(() => '');
|
|
58
|
+
let data;
|
|
59
|
+
try {
|
|
60
|
+
data = text ? JSON.parse(text) : undefined;
|
|
61
|
+
} catch {
|
|
62
|
+
data = undefined;
|
|
63
|
+
}
|
|
64
|
+
if (!res.ok || data?.type === 'error') {
|
|
65
|
+
let reason;
|
|
66
|
+
if (data === undefined) reason = text.slice(0, 500);
|
|
67
|
+
else reason = JSON.stringify(data && typeof data === 'object' && data.reason ? data.reason : data);
|
|
68
|
+
const lines = [`HTTP ${res.status} from the PBX (${method} ${path}): ${reason}`];
|
|
69
|
+
const h = hint(res.status, reason);
|
|
70
|
+
if (h) lines.push(h);
|
|
71
|
+
throw new PbxError(lines.join('\n'), res.status);
|
|
72
|
+
}
|
|
73
|
+
if (data === undefined) return text ? {text} : {};
|
|
74
|
+
return data?.result ?? data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// An action: {ok: true, message} (the message of the PBX, or its whole answer).
|
|
78
|
+
export async function pbxAction(ctx, method, path, body) {
|
|
79
|
+
const r = await pbxCall(ctx, method, path, body);
|
|
80
|
+
return {ok: true, message: r?.message ?? r};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A SIP call id as list-calls gives it (the PBX checks the same pattern).
|
|
84
|
+
export function checkCallId(id) {
|
|
85
|
+
const v = String(id ?? '');
|
|
86
|
+
if (!/^[A-Za-z0-9.!%*_'+~-]+(@[A-Za-z0-9.-]+)?$/.test(v) || v.length > 100)
|
|
87
|
+
throw new UsageError(`not a call id (take sipCallId from: wx phone list): ${id}`);
|
|
88
|
+
return v;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A number to call or to transfer to: an extension, a phone number, a feature code like *76. Spaces, dashes, dots
|
|
92
|
+
// and brackets that people write in phone numbers are dropped; what is left must be +, digits, * and #.
|
|
93
|
+
export function normNumber(input) {
|
|
94
|
+
const v = String(input ?? '').replace(/[\s\-.()]/g, '');
|
|
95
|
+
if (!/^\+?[0-9*#]+$/.test(v) || v.length > 40) throw new UsageError(`not a phone number or extension: ${input}`);
|
|
96
|
+
return v;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function checkContact(contact) {
|
|
100
|
+
const v = String(contact ?? '');
|
|
101
|
+
if (!/^sip:\S+$/.test(v) || v.length > 255)
|
|
102
|
+
throw new UsageError(`a device is its SIP contact, as wx phone devices gives it (sip:…): ${contact}`);
|
|
103
|
+
return v;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function checkDigits(digits) {
|
|
107
|
+
const v = String(digits ?? '');
|
|
108
|
+
if (!/^[0-9*#]+$/.test(v)) throw new UsageError(`DTMF digits are 0-9, * and #: ${digits}`);
|
|
109
|
+
return v;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The extension of the login (custom:pbx_user_extension).
|
|
113
|
+
export function extensionOf(token) {
|
|
114
|
+
const ext = jwtClaims(token)['custom:pbx_user_extension'];
|
|
115
|
+
if (!/^[0-9]+$/.test(String(ext ?? '')))
|
|
116
|
+
throw new UsageError('this login has no PBX extension (custom:pbx_user_extension)');
|
|
117
|
+
return String(ext);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The SIP password of the user: the PBX gives it to the user's own login token. Never printed.
|
|
121
|
+
export async function sipPassword(ctx, extension) {
|
|
122
|
+
const base = pbxUrl(ctx.token, ctx.env);
|
|
123
|
+
let res;
|
|
124
|
+
try {
|
|
125
|
+
// Without `Connection: close` the socket of this request may be reused for the SIP WebSocket to the same host,
|
|
126
|
+
// which the PBX then closes at once (seen with the skill).
|
|
127
|
+
res = await request(`${base}/api/v1/personal/info?fields=extension%2CsipPassword`, {
|
|
128
|
+
token: ctx.token,
|
|
129
|
+
fetchImpl: ctx.fetch,
|
|
130
|
+
headers: {connection: 'close'},
|
|
131
|
+
responseType: 'response',
|
|
132
|
+
timeoutMs: 30_000,
|
|
133
|
+
});
|
|
134
|
+
} catch {
|
|
135
|
+
throw new PbxError(`request failed: GET /api/v1/personal/info (the PBX ${base} did not answer)`);
|
|
136
|
+
}
|
|
137
|
+
if (res.status === 401) throw new AuthError('the PBX rejected the login: log in again (wx login <e-mail>)');
|
|
138
|
+
if (!res.ok) throw new PbxError(`the PBX gave no personal info (HTTP ${res.status})`, res.status);
|
|
139
|
+
let body;
|
|
140
|
+
try {
|
|
141
|
+
body = await res.json();
|
|
142
|
+
} catch {
|
|
143
|
+
body = undefined;
|
|
144
|
+
}
|
|
145
|
+
const info = body?.result ?? body;
|
|
146
|
+
if (!info?.sipPassword) throw new PbxError('the PBX did not give the SIP password of this user');
|
|
147
|
+
if (String(info.extension) !== String(extension))
|
|
148
|
+
throw new PbxError(`the PBX answered for extension ${info.extension}, not ${extension}`);
|
|
149
|
+
return String(info.sipPassword);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The SIP WebSocket of the PBX: wss://<host>/sip/ (the host and port of the PBX address; ws:// only for a custom
|
|
153
|
+
// http:// address in tests) and the SIP domain (the host name).
|
|
154
|
+
export function sipEndpoint(token, env) {
|
|
155
|
+
const base = new URL(pbxUrl(token, env));
|
|
156
|
+
const scheme = base.protocol === 'http:' ? 'ws:' : 'wss:';
|
|
157
|
+
return {wsUrl: `${scheme}//${base.host}/sip/`, domain: base.hostname};
|
|
158
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// A minimal SIP user agent over a WebSocket (RFC 7118), with Node built-ins only: the global WebSocket and
|
|
2
|
+
// node:crypto (MD5 digest). It carries no media and makes no calls: it registers, subscribes, publishes and answers
|
|
3
|
+
// what the PBX sends. Only what the call control of the Wildix PBX needs: non-INVITE client transactions (a WebSocket
|
|
4
|
+
// is reliable: no retransmissions), digest authentication (RFC 2617/3261, MD5, qop=auth), incoming requests answered
|
|
5
|
+
// at once (NOTIFY to the subscriptions, INVITE turned down with 480, OPTIONS 200). The wire format follows sip.js
|
|
6
|
+
// 0.21 (the library the wildix-call-control skill runs), so the PBX sees the same kind of client.
|
|
7
|
+
//
|
|
8
|
+
// Security: the SIP password is used only to compute the digest; it is never sent, printed or put in an error.
|
|
9
|
+
import {EventEmitter} from 'node:events';
|
|
10
|
+
import {createHash, randomBytes} from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
13
|
+
export const randomToken = (n) => [...randomBytes(n)].map((b) => ALPHABET[b % ALPHABET.length]).join('');
|
|
14
|
+
const md5 = (s) => createHash('md5').update(s).digest('hex');
|
|
15
|
+
|
|
16
|
+
export const ALLOWED = [
|
|
17
|
+
'ACK',
|
|
18
|
+
'BYE',
|
|
19
|
+
'CANCEL',
|
|
20
|
+
'INFO',
|
|
21
|
+
'INVITE',
|
|
22
|
+
'MESSAGE',
|
|
23
|
+
'NOTIFY',
|
|
24
|
+
'OPTIONS',
|
|
25
|
+
'PRACK',
|
|
26
|
+
'REFER',
|
|
27
|
+
'REGISTER',
|
|
28
|
+
'SUBSCRIBE',
|
|
29
|
+
];
|
|
30
|
+
const COMPACT = {
|
|
31
|
+
i: 'call-id',
|
|
32
|
+
f: 'from',
|
|
33
|
+
t: 'to',
|
|
34
|
+
v: 'via',
|
|
35
|
+
m: 'contact',
|
|
36
|
+
l: 'content-length',
|
|
37
|
+
c: 'content-type',
|
|
38
|
+
o: 'event',
|
|
39
|
+
k: 'supported',
|
|
40
|
+
u: 'allow-events',
|
|
41
|
+
e: 'content-encoding',
|
|
42
|
+
s: 'subject',
|
|
43
|
+
r: 'refer-to',
|
|
44
|
+
b: 'referred-by',
|
|
45
|
+
x: 'session-expires',
|
|
46
|
+
};
|
|
47
|
+
const TRANSACTION_TIMEOUT_MS = 32_000; // Timer F of RFC 3261
|
|
48
|
+
|
|
49
|
+
// One SIP message from its text: {method, uri} for a request or {status, reason} for a response; header(name) gives
|
|
50
|
+
// the first value of a header, headers(name) all of them (names without case; compact forms understood).
|
|
51
|
+
export function parseMessage(raw) {
|
|
52
|
+
const s = String(raw);
|
|
53
|
+
let split = s.indexOf('\r\n\r\n');
|
|
54
|
+
let sep = 4;
|
|
55
|
+
if (split < 0) {
|
|
56
|
+
split = s.indexOf('\n\n');
|
|
57
|
+
sep = 2;
|
|
58
|
+
}
|
|
59
|
+
const head = split < 0 ? s : s.slice(0, split);
|
|
60
|
+
const body = split < 0 ? '' : s.slice(split + sep);
|
|
61
|
+
const lines = head.split(/\r?\n/).reduce((out, l) => {
|
|
62
|
+
if (/^[ \t]/.test(l) && out.length) out[out.length - 1] += ` ${l.trim()}`;
|
|
63
|
+
else out.push(l);
|
|
64
|
+
return out;
|
|
65
|
+
}, []);
|
|
66
|
+
const first = lines.shift() ?? '';
|
|
67
|
+
const msg = {headerList: [], body};
|
|
68
|
+
let m;
|
|
69
|
+
if ((m = /^SIP\/2\.0 (\d{3})(?: (.*))?$/.exec(first))) Object.assign(msg, {status: Number(m[1]), reason: m[2] ?? ''});
|
|
70
|
+
else if ((m = /^([A-Za-z]+) (\S+) SIP\/2\.0$/.exec(first)))
|
|
71
|
+
Object.assign(msg, {method: m[1].toUpperCase(), uri: m[2]});
|
|
72
|
+
else throw new Error('not a SIP message');
|
|
73
|
+
for (const l of lines) {
|
|
74
|
+
const c = l.indexOf(':');
|
|
75
|
+
if (c <= 0) continue;
|
|
76
|
+
let name = l.slice(0, c).trim().toLowerCase();
|
|
77
|
+
name = COMPACT[name] ?? name;
|
|
78
|
+
msg.headerList.push([name, l.slice(c + 1).trim()]);
|
|
79
|
+
}
|
|
80
|
+
msg.headers = (name) => msg.headerList.filter(([n]) => n === name.toLowerCase()).map(([, v]) => v);
|
|
81
|
+
msg.header = (name) => msg.headers(name)[0];
|
|
82
|
+
return msg;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Parts of header values.
|
|
86
|
+
export const tagOf = (value) => paramOf(value, 'tag');
|
|
87
|
+
export const uriOf = (value) => {
|
|
88
|
+
const v = String(value ?? '');
|
|
89
|
+
const a = /<([^>]*)>/.exec(v);
|
|
90
|
+
return a ? a[1] : v.split(';')[0].trim();
|
|
91
|
+
};
|
|
92
|
+
export function paramOf(value, name) {
|
|
93
|
+
// The display name and the URI may hold ";tag=" themselves: only the parameters after them count.
|
|
94
|
+
const v = String(value ?? '')
|
|
95
|
+
.replace(/^\s*"(?:[^"\\]|\\.)*"/, '')
|
|
96
|
+
.replace(/<[^>]*>/, '');
|
|
97
|
+
const m = new RegExp(`;\\s*${name}\\s*=\\s*(?:"([^"]*)"|([^;,\\s]+))`, 'i').exec(v);
|
|
98
|
+
return m ? (m[1] ?? m[2]) : undefined;
|
|
99
|
+
}
|
|
100
|
+
const branchOf = (via) => /;\s*branch=([^;,\s]+)/i.exec(via ?? '')?.[1];
|
|
101
|
+
const splitList = (values) =>
|
|
102
|
+
values.flatMap((v) => v.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)(?![^<]*>)/).map((x) => x.trim())).filter(Boolean);
|
|
103
|
+
|
|
104
|
+
// A digest challenge (WWW-Authenticate / Proxy-Authenticate) as {realm, nonce, qop, opaque, algorithm, stale}.
|
|
105
|
+
export function parseChallenge(value) {
|
|
106
|
+
const v = String(value ?? '');
|
|
107
|
+
if (!/^\s*Digest\s/i.test(v)) return undefined;
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const m of v.replace(/^\s*Digest\s+/i, '').matchAll(/([A-Za-z-]+)\s*=\s*(?:"([^"]*)"|([^,\s]+))/g))
|
|
110
|
+
out[m[1].toLowerCase()] = m[2] ?? m[3];
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The Authorization value for a challenge (MD5; qop auth when offered), as sip.js computes it.
|
|
115
|
+
export function digestAuthorization({username, password, method, uri, challenge, nc = 1, cnonce = randomToken(12)}) {
|
|
116
|
+
const algorithm = challenge.algorithm ?? 'MD5';
|
|
117
|
+
if (algorithm.toUpperCase() !== 'MD5')
|
|
118
|
+
throw new Error(`the PBX asks for a digest algorithm other than MD5: ${algorithm}`);
|
|
119
|
+
if (!challenge.realm || !challenge.nonce) throw new Error('the PBX sent a digest challenge without realm or nonce');
|
|
120
|
+
const qops = (challenge.qop ?? '').split(',').map((q) => q.trim());
|
|
121
|
+
const qop = challenge.qop === undefined ? undefined : qops.includes('auth') ? 'auth' : undefined;
|
|
122
|
+
if (challenge.qop !== undefined && !qop)
|
|
123
|
+
throw new Error(`the PBX asks for a digest qop other than auth: ${challenge.qop}`);
|
|
124
|
+
const ha1 = md5(`${username}:${challenge.realm}:${password}`);
|
|
125
|
+
const ha2 = md5(`${method}:${uri}`);
|
|
126
|
+
const ncHex = nc.toString(16).padStart(8, '0');
|
|
127
|
+
const response = qop
|
|
128
|
+
? md5(`${ha1}:${challenge.nonce}:${ncHex}:${cnonce}:${qop}:${ha2}`)
|
|
129
|
+
: md5(`${ha1}:${challenge.nonce}:${ha2}`);
|
|
130
|
+
const parts = [
|
|
131
|
+
`algorithm=MD5`,
|
|
132
|
+
`username="${username}"`,
|
|
133
|
+
`realm="${challenge.realm}"`,
|
|
134
|
+
`nonce="${challenge.nonce}"`,
|
|
135
|
+
`uri="${uri}"`,
|
|
136
|
+
`response="${response}"`,
|
|
137
|
+
];
|
|
138
|
+
if (challenge.opaque !== undefined) parts.push(`opaque="${challenge.opaque}"`);
|
|
139
|
+
if (qop) parts.push(`qop=${qop}`, `cnonce="${cnonce}"`, `nc=${ncHex}`);
|
|
140
|
+
return `Digest ${parts.join(', ')}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The user agent: one WebSocket to the PBX. Events: 'request' (an incoming request already answered: NOTIFYs go to
|
|
144
|
+
// onNotify), 'disconnect' (the socket closed). onNotify(request) returns true when the NOTIFY belongs to a
|
|
145
|
+
// subscription (answered 200), false otherwise (481).
|
|
146
|
+
export class SipUserAgent extends EventEmitter {
|
|
147
|
+
constructor({
|
|
148
|
+
wsUrl,
|
|
149
|
+
domain,
|
|
150
|
+
extension,
|
|
151
|
+
password,
|
|
152
|
+
userAgent,
|
|
153
|
+
WebSocketImpl = globalThis.WebSocket,
|
|
154
|
+
keepAliveSeconds = 30,
|
|
155
|
+
timeoutMs = TRANSACTION_TIMEOUT_MS,
|
|
156
|
+
}) {
|
|
157
|
+
super();
|
|
158
|
+
this.wsUrl = wsUrl;
|
|
159
|
+
this.domain = domain;
|
|
160
|
+
this.extension = extension;
|
|
161
|
+
this.password = password;
|
|
162
|
+
this.userAgent = userAgent;
|
|
163
|
+
this.WebSocketImpl = WebSocketImpl;
|
|
164
|
+
this.keepAliveSeconds = keepAliveSeconds;
|
|
165
|
+
this.timeoutMs = timeoutMs;
|
|
166
|
+
this.aor = `sip:${extension}@${domain}`;
|
|
167
|
+
this.viaHost = `${randomToken(12)}.invalid`;
|
|
168
|
+
this.contact = `<sip:${extension}@${this.viaHost};transport=ws>`;
|
|
169
|
+
this.transactions = new Map();
|
|
170
|
+
this.nc = new Map(); // nonce -> count
|
|
171
|
+
this.onNotify = () => false;
|
|
172
|
+
this.closed = false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Opens the WebSocket (subprotocol "sip"); rejects when it cannot.
|
|
176
|
+
open() {
|
|
177
|
+
if (!this.WebSocketImpl)
|
|
178
|
+
return Promise.reject(new Error('this Node.js has no WebSocket (Node 22 or newer is needed)'));
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
let opened = false;
|
|
181
|
+
const ws = new this.WebSocketImpl(this.wsUrl, 'sip');
|
|
182
|
+
this.ws = ws;
|
|
183
|
+
ws.binaryType = 'arraybuffer';
|
|
184
|
+
ws.addEventListener('open', () => {
|
|
185
|
+
opened = true;
|
|
186
|
+
if (this.keepAliveSeconds > 0) {
|
|
187
|
+
this.keepAlive = setInterval(() => this.write('\r\n\r\n'), this.keepAliveSeconds * 1000);
|
|
188
|
+
this.keepAlive.unref?.();
|
|
189
|
+
}
|
|
190
|
+
resolve();
|
|
191
|
+
});
|
|
192
|
+
ws.addEventListener('message', (e) =>
|
|
193
|
+
this.receive(typeof e.data === 'string' ? e.data : Buffer.from(e.data).toString('utf8')),
|
|
194
|
+
);
|
|
195
|
+
ws.addEventListener('error', () => {
|
|
196
|
+
if (!opened) reject(new Error(`the PBX did not open the SIP WebSocket (${new URL(this.wsUrl).host})`));
|
|
197
|
+
});
|
|
198
|
+
ws.addEventListener('close', () => {
|
|
199
|
+
if (!opened) reject(new Error(`the PBX closed the SIP WebSocket (${new URL(this.wsUrl).host})`));
|
|
200
|
+
this.dropped();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
dropped() {
|
|
206
|
+
clearInterval(this.keepAlive);
|
|
207
|
+
for (const t of this.transactions.values()) t.fail(new Error('the SIP connection closed'));
|
|
208
|
+
this.transactions.clear();
|
|
209
|
+
if (!this.closed) {
|
|
210
|
+
this.closed = true;
|
|
211
|
+
this.emit('disconnect');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
close() {
|
|
216
|
+
this.closed = true;
|
|
217
|
+
clearInterval(this.keepAlive);
|
|
218
|
+
for (const t of this.transactions.values()) t.fail(new Error('the SIP connection closed'));
|
|
219
|
+
this.transactions.clear();
|
|
220
|
+
try {
|
|
221
|
+
this.ws?.close();
|
|
222
|
+
} catch {
|
|
223
|
+
// already closed
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
write(text) {
|
|
228
|
+
if (this.ws?.readyState !== 1) throw new Error('the SIP connection is not open');
|
|
229
|
+
this.ws.send(text);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Sends a request and resolves with its final response. A digest challenge is answered once (twice when stale).
|
|
233
|
+
// o: {ruri, to, from, callId, seq: {cseq}, routes?, headers?: [lines], body?, contentType?}
|
|
234
|
+
// seq.cseq is incremented for every request sent (the caller keeps it per registration or dialog).
|
|
235
|
+
async request(method, o) {
|
|
236
|
+
let auth;
|
|
237
|
+
for (let round = 0; ; round += 1) {
|
|
238
|
+
o.seq.cseq += 1;
|
|
239
|
+
const res = await this.transaction(method, o, auth);
|
|
240
|
+
if (res.status !== 401 && res.status !== 407) return res;
|
|
241
|
+
const challenge = parseChallenge(res.header(res.status === 401 ? 'www-authenticate' : 'proxy-authenticate'));
|
|
242
|
+
const stale = String(challenge?.stale).toLowerCase() === 'true';
|
|
243
|
+
if (!challenge || round >= (stale ? 2 : 1)) return res;
|
|
244
|
+
const nc = (this.nc.get(challenge.nonce) ?? 0) + 1;
|
|
245
|
+
this.nc.set(challenge.nonce, nc);
|
|
246
|
+
const value = digestAuthorization({
|
|
247
|
+
username: this.extension,
|
|
248
|
+
password: this.password,
|
|
249
|
+
method,
|
|
250
|
+
uri: o.ruri,
|
|
251
|
+
challenge,
|
|
252
|
+
nc,
|
|
253
|
+
});
|
|
254
|
+
auth = `${res.status === 401 ? 'Authorization' : 'Proxy-Authorization'}: ${value}`;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
transaction(method, o, auth) {
|
|
259
|
+
const branch = `z9hG4bK${randomToken(23)}`;
|
|
260
|
+
const body = o.body ?? '';
|
|
261
|
+
const lines = [
|
|
262
|
+
`${method} ${o.ruri} SIP/2.0`,
|
|
263
|
+
...(o.routes ?? []).map((r) => `Route: ${r}`),
|
|
264
|
+
`Via: SIP/2.0/WSS ${this.viaHost};branch=${branch}`,
|
|
265
|
+
`To: ${o.to}`,
|
|
266
|
+
`From: ${o.from}`,
|
|
267
|
+
`CSeq: ${o.seq.cseq} ${method}`,
|
|
268
|
+
`Call-ID: ${o.callId}`,
|
|
269
|
+
'Max-Forwards: 70',
|
|
270
|
+
...(auth ? [auth] : []),
|
|
271
|
+
...(o.headers ?? []),
|
|
272
|
+
`Supported: ${method === 'REGISTER' ? 'outbound, path, gruu' : 'outbound'}`,
|
|
273
|
+
`User-Agent: ${this.userAgent}`,
|
|
274
|
+
...(body ? [`Content-Type: ${o.contentType}`] : []),
|
|
275
|
+
`Content-Length: ${Buffer.byteLength(body)}`,
|
|
276
|
+
'',
|
|
277
|
+
body,
|
|
278
|
+
];
|
|
279
|
+
return new Promise((resolve, reject) => {
|
|
280
|
+
const timer = setTimeout(() => {
|
|
281
|
+
this.transactions.delete(branch);
|
|
282
|
+
reject(new Error(`the PBX did not answer ${method} in time`));
|
|
283
|
+
}, this.timeoutMs);
|
|
284
|
+
timer.unref?.();
|
|
285
|
+
const done = () => {
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
this.transactions.delete(branch);
|
|
288
|
+
};
|
|
289
|
+
this.transactions.set(branch, {
|
|
290
|
+
method,
|
|
291
|
+
ok: (res) => {
|
|
292
|
+
done();
|
|
293
|
+
resolve(res);
|
|
294
|
+
},
|
|
295
|
+
fail: (e) => {
|
|
296
|
+
done();
|
|
297
|
+
reject(e);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
try {
|
|
301
|
+
this.write(lines.join('\r\n'));
|
|
302
|
+
} catch (e) {
|
|
303
|
+
done();
|
|
304
|
+
reject(e);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
receive(text) {
|
|
310
|
+
if (!text.trim()) return; // a keep-alive answer
|
|
311
|
+
let msg;
|
|
312
|
+
try {
|
|
313
|
+
msg = parseMessage(text);
|
|
314
|
+
} catch {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (msg.status !== undefined) {
|
|
318
|
+
const t = this.transactions.get(branchOf(msg.header('via')));
|
|
319
|
+
if (t && msg.status >= 200) t.ok(msg);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
this.answerRequest(msg);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
answerRequest(req) {
|
|
326
|
+
switch (req.method) {
|
|
327
|
+
case 'ACK':
|
|
328
|
+
return;
|
|
329
|
+
case 'NOTIFY': {
|
|
330
|
+
let known = false;
|
|
331
|
+
try {
|
|
332
|
+
known = this.onNotify(req);
|
|
333
|
+
} catch (e) {
|
|
334
|
+
this.emit('error', e);
|
|
335
|
+
known = true;
|
|
336
|
+
}
|
|
337
|
+
this.respond(req, known ? 200 : 481, known ? 'OK' : 'Subscription does not exist');
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case 'INVITE':
|
|
341
|
+
// Not a phone: every call offered to this registration is turned down, the user's phones keep ringing.
|
|
342
|
+
this.respond(req, 480, 'Not a call-handling device');
|
|
343
|
+
break;
|
|
344
|
+
case 'OPTIONS':
|
|
345
|
+
this.respond(req, 200, 'OK', [`Allow: ${ALLOWED.join(',')}`]);
|
|
346
|
+
break;
|
|
347
|
+
case 'CANCEL':
|
|
348
|
+
this.respond(req, 200, 'OK');
|
|
349
|
+
break;
|
|
350
|
+
case 'BYE':
|
|
351
|
+
this.respond(req, 481, 'Call/Transaction Does Not Exist');
|
|
352
|
+
break;
|
|
353
|
+
default:
|
|
354
|
+
this.respond(req, 405, 'Method Not Allowed', [`Allow: ${ALLOWED.join(',')}`]);
|
|
355
|
+
}
|
|
356
|
+
this.emit('request', req);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
respond(req, status, reason, headers = []) {
|
|
360
|
+
const to = req.header('to') ?? '';
|
|
361
|
+
const lines = [
|
|
362
|
+
`SIP/2.0 ${status} ${reason}`,
|
|
363
|
+
...req.headers('via').map((v) => `Via: ${v}`),
|
|
364
|
+
`To: ${tagOf(to) ? to : `${to};tag=${randomToken(10)}`}`,
|
|
365
|
+
`From: ${req.header('from') ?? ''}`,
|
|
366
|
+
`Call-ID: ${req.header('call-id') ?? ''}`,
|
|
367
|
+
`CSeq: ${req.header('cseq') ?? ''}`,
|
|
368
|
+
...headers,
|
|
369
|
+
'Supported: outbound',
|
|
370
|
+
`User-Agent: ${this.userAgent}`,
|
|
371
|
+
'Content-Length: 0',
|
|
372
|
+
'',
|
|
373
|
+
'',
|
|
374
|
+
];
|
|
375
|
+
try {
|
|
376
|
+
this.write(lines.join('\r\n'));
|
|
377
|
+
} catch {
|
|
378
|
+
// the socket is gone: the PBX retries or drops it
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Record-Route values of a message, one route per entry.
|
|
383
|
+
static routes(msg) {
|
|
384
|
+
return splitList(msg.headers('record-route'));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// A small XML reader for the bodies of the SIP event packages of the PBX (dialog-info, reginfo, active-device):
|
|
2
|
+
// elements, attributes, text. No DTD, no external entities (a DOCTYPE is refused). Element names are matched
|
|
3
|
+
// without their namespace prefix, as ltx does.
|
|
4
|
+
|
|
5
|
+
const TOKEN =
|
|
6
|
+
/<!--[\s\S]*?-->|<!\[CDATA\[([\s\S]*?)\]\]>|<\?[\s\S]*?\?>|<\/\s*([^\s>]+)\s*>|<([^\s/>!?]+)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)/y;
|
|
7
|
+
const ATTR = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
8
|
+
const NAMED = {lt: '<', gt: '>', amp: '&', quot: '"', apos: "'"};
|
|
9
|
+
|
|
10
|
+
function decode(s) {
|
|
11
|
+
return s.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-z]+);/g, (m, e) => {
|
|
12
|
+
if (e[0] === '#') {
|
|
13
|
+
const code = e[1] === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
14
|
+
return code <= 0x10ffff ? String.fromCodePoint(code) : m;
|
|
15
|
+
}
|
|
16
|
+
return NAMED[e] ?? m;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const local = (name) => name.slice(name.indexOf(':') + 1);
|
|
21
|
+
|
|
22
|
+
// Parses a document and returns its root element: {name, attrs, children, text}. Throws on what is not XML.
|
|
23
|
+
export function parseXml(text) {
|
|
24
|
+
const s = String(text ?? '').trim();
|
|
25
|
+
if (/<!DOCTYPE/i.test(s)) throw new Error('XML with a DOCTYPE is not accepted');
|
|
26
|
+
const root = {name: '#document', attrs: {}, children: [], text: ''};
|
|
27
|
+
const stack = [root];
|
|
28
|
+
TOKEN.lastIndex = 0;
|
|
29
|
+
while (TOKEN.lastIndex < s.length) {
|
|
30
|
+
const at = TOKEN.lastIndex;
|
|
31
|
+
const m = TOKEN.exec(s);
|
|
32
|
+
if (!m || m.index !== at) throw new Error(`not XML at character ${at}`);
|
|
33
|
+
const top = stack[stack.length - 1];
|
|
34
|
+
const [whole, cdata, closing, opening, attrText, selfClosing, chars] = m;
|
|
35
|
+
if (whole.startsWith('<!--') || whole.startsWith('<?')) continue;
|
|
36
|
+
if (cdata !== undefined) top.text += cdata;
|
|
37
|
+
else if (chars !== undefined) top.text += decode(chars);
|
|
38
|
+
else if (closing !== undefined) {
|
|
39
|
+
if (stack.length < 2 || top.name !== local(closing)) throw new Error(`unexpected </${closing}>`);
|
|
40
|
+
stack.pop();
|
|
41
|
+
} else if (opening !== undefined) {
|
|
42
|
+
const attrs = {};
|
|
43
|
+
for (const a of (attrText ?? '').matchAll(ATTR)) attrs[a[1]] = decode(a[2] ?? a[3] ?? '');
|
|
44
|
+
const el = {name: local(opening), attrs, children: [], text: ''};
|
|
45
|
+
top.children.push(el);
|
|
46
|
+
if (!selfClosing) stack.push(el);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (stack.length !== 1) throw new Error(`<${stack[stack.length - 1].name}> is not closed`);
|
|
50
|
+
const [el] = root.children;
|
|
51
|
+
if (!el || root.children.length > 1) throw new Error('an XML document has one root element');
|
|
52
|
+
return el;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const child = (el, name) => el?.children.find((c) => c.name === name);
|
|
56
|
+
export const children = (el, name) => el?.children.filter((c) => c.name === name) ?? [];
|
|
57
|
+
export const text = (el) => (el ? el.text.trim() : undefined);
|
package/src/stream.mjs
CHANGED
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
// The Wildix stream service (chats): https://<region>.stream.wildix.com/api/v1, region eu-1 by default.
|
|
2
|
-
import {UsageError} from '
|
|
3
|
-
import {request} from '
|
|
2
|
+
import {UsageError} from 'wldx-core/errors';
|
|
3
|
+
import {request} from 'wldx-core/http';
|
|
4
|
+
import {streamUrl} from 'wldx-core/services';
|
|
4
5
|
|
|
5
|
-
const WILDIX_HOST = /^https:\/\/[a-z0-9-]+(\.[a-z0-9-]+)*\.wildix\.com(:[0-9]+)?$/;
|
|
6
6
|
const ID = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
|
|
7
7
|
|
|
8
|
-
// The base address
|
|
9
|
-
|
|
10
|
-
export function streamBase(env = process.env) {
|
|
11
|
-
const base = (env.WILDIX_STREAM_URL || `https://${env.WILDIX_STREAM_REGION || 'eu-1'}.stream.wildix.com`).replace(/\/+$/, '');
|
|
12
|
-
if (env.WILDIX_ALLOW_CUSTOM_URL !== '1' && !WILDIX_HOST.test(base)) {
|
|
13
|
-
throw new UsageError(`The stream service address must be https://<host>.wildix.com, got: ${base}`);
|
|
14
|
-
}
|
|
15
|
-
return base;
|
|
16
|
-
}
|
|
8
|
+
// The base address (see services.mjs for the overrides).
|
|
9
|
+
export const streamBase = (env = process.env) => streamUrl(env);
|
|
17
10
|
|
|
18
11
|
const valid = (v) => ID.test(v) && !v.includes('..');
|
|
19
12
|
|
|
@@ -30,7 +23,11 @@ export function channelId(input) {
|
|
|
30
23
|
|
|
31
24
|
// A message id from an id or from an inbox link to the message (the last path segment).
|
|
32
25
|
export function messageId(input) {
|
|
33
|
-
const v = String(input)
|
|
26
|
+
const v = String(input)
|
|
27
|
+
.replace(/[?#].*$/s, '')
|
|
28
|
+
.replace(/\/+$/, '')
|
|
29
|
+
.split('/')
|
|
30
|
+
.pop();
|
|
34
31
|
if (!valid(v)) throw new UsageError(`Not a message id: ${input}`);
|
|
35
32
|
return v;
|
|
36
33
|
}
|
|
@@ -38,6 +35,7 @@ export function messageId(input) {
|
|
|
38
35
|
export function streamClient({token, env = process.env, fetchImpl}) {
|
|
39
36
|
const api = `${streamBase(env)}/api/v1`;
|
|
40
37
|
return {
|
|
41
|
-
call: (method, path, body
|
|
38
|
+
call: (method, path, body, options = {}) =>
|
|
39
|
+
request(api + path, {method, token, body, fetchImpl, service: 'the stream service', ...options}),
|
|
42
40
|
};
|
|
43
41
|
}
|
package/src/version.mjs
CHANGED