wldx-cli 0.1.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +272 -23
- package/package.json +1 -1
- package/src/auth.mjs +181 -35
- package/src/chat/format.mjs +97 -0
- package/src/chat/lib.mjs +162 -0
- package/src/chat/watch.mjs +251 -0
- package/src/cli.mjs +167 -47
- package/src/commands/calls.mjs +367 -0
- package/src/commands/channel.mjs +326 -0
- package/src/commands/chat.mjs +388 -60
- package/src/commands/conversations.mjs +251 -0
- package/src/commands/file.mjs +72 -0
- package/src/commands/inbox.mjs +101 -0
- package/src/commands/index.mjs +54 -9
- package/src/commands/live.mjs +404 -0
- package/src/commands/login.mjs +126 -0
- package/src/commands/meetings.mjs +299 -0
- package/src/commands/phone.mjs +452 -0
- package/src/commands/profile.mjs +51 -0
- package/src/commands/reports.mjs +431 -0
- package/src/commands/section.mjs +60 -0
- package/src/commands/status.mjs +95 -0
- package/src/commands/user.mjs +130 -0
- package/src/completion.mjs +47 -20
- package/src/files.mjs +98 -0
- package/src/format.mjs +82 -0
- package/src/history/text.mjs +60 -0
- package/src/history/wda.mjs +161 -0
- package/src/http.mjs +30 -13
- package/src/live/socket.mjs +121 -0
- package/src/live/stats.mjs +211 -0
- package/src/live/text.mjs +219 -0
- package/src/live/views.mjs +321 -0
- package/src/phone/control.mjs +377 -0
- package/src/phone/events.mjs +189 -0
- package/src/phone/pbx.mjs +140 -0
- package/src/phone/sip.mjs +316 -0
- package/src/phone/xml.mjs +56 -0
- package/src/services.mjs +58 -0
- package/src/stream.mjs +4 -11
- package/src/commands/auth.mjs +0 -32
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// What the live commands print, from the items of the WDA live stream: short views by default, the raw items with
|
|
2
|
+
// `full`. Ported from live.mjs of the wildix-live-monitor skill (same fields, same order, same filters), as pure
|
|
3
|
+
// functions: the commands in src/commands/live.mjs only connect, subscribe and hand the items over.
|
|
4
|
+
import {agentFigures, queueFigures, sumStats} from './stats.mjs';
|
|
5
|
+
|
|
6
|
+
export const drop = (o) => JSON.parse(JSON.stringify(o)); // removes the undefined fields
|
|
7
|
+
|
|
8
|
+
const isPaused = (a) => a?.paused === 'true' || a?.paused === true;
|
|
9
|
+
|
|
10
|
+
// The view functions bound to one run: full (raw items), company and extension of the login, the clock.
|
|
11
|
+
export function makeViews({full = false, company, myExtension, now = () => Date.now()} = {}) {
|
|
12
|
+
// Left out unless full: e-mail, public IP address, geographic location and user agent of people.
|
|
13
|
+
function party(p) {
|
|
14
|
+
if (!p) return undefined;
|
|
15
|
+
if (full) return p;
|
|
16
|
+
return drop({
|
|
17
|
+
name: p.name,
|
|
18
|
+
phone: p.phone,
|
|
19
|
+
extension: p.userExtension,
|
|
20
|
+
department: p.userDepartment,
|
|
21
|
+
group: p.groupName,
|
|
22
|
+
type: p.type,
|
|
23
|
+
role: p.role,
|
|
24
|
+
device: p.userDevice,
|
|
25
|
+
sipCallId: p.sipCallId,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function flowView(f) {
|
|
30
|
+
return drop({
|
|
31
|
+
flowIndex: f.flowIndex,
|
|
32
|
+
status: f.status,
|
|
33
|
+
direction: f.direction,
|
|
34
|
+
caller: party(f.caller),
|
|
35
|
+
callee: party(f.callee),
|
|
36
|
+
destination: f.destination,
|
|
37
|
+
queue: f.queue ?? f.service,
|
|
38
|
+
start: f.startTime ? new Date(f.startTime).toISOString() : undefined,
|
|
39
|
+
talkSeconds: f.talkTime ? Math.round(f.talkTime / 1000) : undefined,
|
|
40
|
+
waitSeconds: f.waitTime ? Math.round(f.waitTime / 1000) : undefined,
|
|
41
|
+
holdSeconds: f.holdTime ? Math.round(f.holdTime / 1000) : undefined,
|
|
42
|
+
recording: f.recordings?.length ? true : undefined,
|
|
43
|
+
transcription: f.transcriptionStatus,
|
|
44
|
+
tags: f.tags?.length ? f.tags : undefined,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function insights(a) {
|
|
49
|
+
if (!a) return undefined;
|
|
50
|
+
const v = (k) => a[`copilot.${k}`]?.payload?.metric?.value;
|
|
51
|
+
return drop({sentiment: v('sentiment'), csat: v('csat'), topics: v('topics'), signals: v('signals')});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function conversationView(c) {
|
|
55
|
+
if (full) return c;
|
|
56
|
+
const base = {id: c.id, type: c.event, trigger: c.eventTrigger, start: c.start};
|
|
57
|
+
if (c.event === 'call') return drop({...base, flows: (Array.isArray(c.flows) ? c.flows : []).map(flowView)});
|
|
58
|
+
if (c.event === 'chat') {
|
|
59
|
+
return drop({
|
|
60
|
+
...base,
|
|
61
|
+
status: c.status,
|
|
62
|
+
subject: c.subject,
|
|
63
|
+
direction: c.direction,
|
|
64
|
+
channelId: c.channelId,
|
|
65
|
+
customers: c.customers?.map((p) => drop({name: p.name, company: p.company})),
|
|
66
|
+
agents: c.agents?.map((p) => drop({name: p.name, bot: p.bot || undefined})),
|
|
67
|
+
tags: c.tags?.length ? c.tags : undefined,
|
|
68
|
+
insights: insights(c.annotationsByKey),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (c.event === 'conference') {
|
|
72
|
+
return drop({
|
|
73
|
+
...base,
|
|
74
|
+
subject: c.subject,
|
|
75
|
+
participants: c.participants?.map((p) => drop({name: p.name, role: p.role, type: p.type})),
|
|
76
|
+
insights: insights(c.annotationsByKey),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// completions and annotations: say what ended or changed, not more
|
|
80
|
+
return drop({...base, flows: typeof c.flows === 'number' ? c.flows : undefined, key: c.key, insights: insights(c.annotationsByKey)});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function agentView(a, stats) {
|
|
84
|
+
if (full) return stats ? {...a, stats: agentFigures(stats)} : a;
|
|
85
|
+
return drop({
|
|
86
|
+
name: a.name,
|
|
87
|
+
extension: a.userExtension,
|
|
88
|
+
department: a.department,
|
|
89
|
+
status: a.status,
|
|
90
|
+
paused: isPaused(a) || undefined,
|
|
91
|
+
reason: a.reason || undefined,
|
|
92
|
+
message: a.message || undefined,
|
|
93
|
+
stats: stats ? agentFigures(stats) : undefined,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function queueView(s) {
|
|
98
|
+
const agentStats = s.stats?.agentStats ?? {};
|
|
99
|
+
if (full) return s.stats ? {...s, figures: queueFigures(s.stats, now())} : s;
|
|
100
|
+
return drop({
|
|
101
|
+
id: s.id,
|
|
102
|
+
name: s.name,
|
|
103
|
+
pbx: s.pbx,
|
|
104
|
+
strategy: s.strategy,
|
|
105
|
+
maxCalls: s.maxCalls ? Number(s.maxCalls) : undefined,
|
|
106
|
+
calls: s.calls?.length ?? 0,
|
|
107
|
+
callIds: s.calls?.length ? s.calls : undefined,
|
|
108
|
+
stats: queueFigures(s.stats, now()),
|
|
109
|
+
agents: s.agents?.map((a) => agentView(a, s.stats ? agentStats[a.id] : undefined)),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function serviceCallView(c) {
|
|
114
|
+
if (full) return c;
|
|
115
|
+
return drop({
|
|
116
|
+
queueId: c.id,
|
|
117
|
+
trigger: c.eventTrigger,
|
|
118
|
+
callId: c.callId,
|
|
119
|
+
direction: c.direction,
|
|
120
|
+
customer: c.customer?.phone ?? c.customer?.name,
|
|
121
|
+
start: c.startTime ? new Date(c.startTime).toISOString() : undefined,
|
|
122
|
+
agents: c.relatedAgents?.length,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function presenceView(p) {
|
|
127
|
+
if (full) return p;
|
|
128
|
+
return drop({
|
|
129
|
+
extension: p.extension,
|
|
130
|
+
company: p.company !== company ? p.company : undefined,
|
|
131
|
+
status: p.status,
|
|
132
|
+
message: p.message || undefined,
|
|
133
|
+
until: p.until || undefined,
|
|
134
|
+
phone: p.telephony,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function textView(chunk, call) {
|
|
139
|
+
const flow = Array.isArray(call?.flows) ? call.flows.find((f) => f.flowIndex === chunk.flowIndex) : undefined;
|
|
140
|
+
const who = chunk.speaker === 'caller' ? flow?.caller : chunk.speaker === 'callee' ? flow?.callee : undefined;
|
|
141
|
+
return drop({
|
|
142
|
+
flow: chunk.flowIndex,
|
|
143
|
+
speaker: chunk.speaker ?? chunk.participant?.name,
|
|
144
|
+
name: who?.name ?? chunk.participant?.name ?? chunk.name,
|
|
145
|
+
text: chunk.text,
|
|
146
|
+
final: chunk.isFinal,
|
|
147
|
+
at: chunk.start ?? chunk.time,
|
|
148
|
+
language: chunk.language,
|
|
149
|
+
sentiment: chunk.sentiment,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isMineCall = (c) =>
|
|
154
|
+
Boolean(myExtension) && Array.isArray(c.flows) && c.flows.some((f) => f.caller?.userExtension === myExtension || f.callee?.userExtension === myExtension);
|
|
155
|
+
|
|
156
|
+
return {full, company, myExtension, now, party, flowView, insights, conversationView, agentView, queueView, serviceCallView, presenceView, textView, isMineCall};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The last item of every id, in the order of their first appearance.
|
|
160
|
+
export const lastById = (items) => {
|
|
161
|
+
const m = new Map();
|
|
162
|
+
for (const it of items) if (it?.id) m.set(it.id, it);
|
|
163
|
+
return [...m.values()];
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Transcription items: a stored transcript ({flowIndex, chunks: {id: chunk}}), a live line ({chunk}), or a
|
|
167
|
+
// conference transcript; each becomes chunks with the flow index kept.
|
|
168
|
+
export function chunksOf(item) {
|
|
169
|
+
if (item?.chunk) return [{...item.chunk, flowIndex: item.flowIndex, participant: item.participant}];
|
|
170
|
+
if (item?.chunks && typeof item.chunks === 'object') return Object.values(item.chunks).map((c) => ({...c, flowIndex: item.flowIndex}));
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// `queues`: the snapshot of the services topic, filtered and sorted, the queues with calls first.
|
|
175
|
+
// filters: {pbx, name, busy, paused, active}; stats: whether the statistics were asked for
|
|
176
|
+
export function queuesResult(items, {pbx, name, busy, paused, active, stats} = {}, views = makeViews()) {
|
|
177
|
+
let qs = lastById(items.filter((i) => i?.event === 'service'));
|
|
178
|
+
if (pbx) qs = qs.filter((q) => q.pbx === pbx);
|
|
179
|
+
const text = name?.toLowerCase();
|
|
180
|
+
if (text) qs = qs.filter((q) => q.name?.toLowerCase().includes(text));
|
|
181
|
+
if (busy) qs = qs.filter((q) => q.calls?.length);
|
|
182
|
+
if (paused) qs = qs.filter((q) => q.agents?.some(isPaused));
|
|
183
|
+
if (active) qs = qs.filter((q) => q.calls?.length || q.stats?.offered || q.stats?.outbound);
|
|
184
|
+
qs.sort((a, b) => (b.calls?.length ?? 0) - (a.calls?.length ?? 0) || (b.stats?.offered ?? 0) - (a.stats?.offered ?? 0) || String(a.name).localeCompare(String(b.name)));
|
|
185
|
+
const pbxs = [...new Set(qs.map((q) => q.pbx))];
|
|
186
|
+
return drop({
|
|
187
|
+
total: qs.length,
|
|
188
|
+
callsWaitingOrTalking: qs.reduce((n, q) => n + (q.calls?.length ?? 0), 0),
|
|
189
|
+
pbxs: pbxs.length > 1 ? Object.fromEntries(pbxs.map((p) => [p, qs.filter((q) => q.pbx === p).length])) : undefined,
|
|
190
|
+
stats: stats ? queueFigures(sumStats(qs.map((q) => q.stats)), views.now()) : undefined,
|
|
191
|
+
queues: qs.map(views.queueView),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// `conversations`: the calls, chats and conferences of the snapshot, newest first.
|
|
196
|
+
// kinds: ['call', 'chat', 'conference'] to keep (none: all); mine; status; limit (50)
|
|
197
|
+
export function conversationsResult(items, {kinds = [], mine, status, limit = 50} = {}, views = makeViews()) {
|
|
198
|
+
let cs = lastById(items.filter((i) => ['call', 'chat', 'conference'].includes(i?.event)));
|
|
199
|
+
const byType = {};
|
|
200
|
+
for (const c of cs) byType[c.event] = (byType[c.event] ?? 0) + 1;
|
|
201
|
+
if (kinds.length) cs = cs.filter((c) => kinds.includes(c.event));
|
|
202
|
+
if (mine) cs = cs.filter(views.isMineCall);
|
|
203
|
+
const want = status?.toUpperCase();
|
|
204
|
+
if (want) cs = cs.filter((c) => String(c.status ?? (Array.isArray(c.flows) ? c.flows.at(-1)?.status : undefined) ?? '').toUpperCase() === want);
|
|
205
|
+
cs.sort((a, b) => String(b.start).localeCompare(String(a.start)));
|
|
206
|
+
return {total: cs.length, byType, shown: Math.min(limit, cs.length), items: cs.slice(0, limit).map(views.conversationView)};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// `presence`: one entry per extension asked for, in the order given; `status: unknown` for nobody.
|
|
210
|
+
export function presenceResult(items, extensions, views = makeViews()) {
|
|
211
|
+
const found = new Map(items.filter(Boolean).map((p) => [String(p.extension), p]));
|
|
212
|
+
return {presence: extensions.map((ext) => (found.has(ext) ? views.presenceView(found.get(ext)) : {extension: ext, status: 'unknown'}))};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The WDA id of a call from the conversations snapshot, by the SIP call id of one of its parties.
|
|
216
|
+
export function callIdBySip(items, sipCallId) {
|
|
217
|
+
const call = items.find((c) => c?.event === 'call' && Array.isArray(c.flows) && c.flows.some((f) => f.caller?.sipCallId === sipCallId || f.callee?.sipCallId === sipCallId));
|
|
218
|
+
return call?.id;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// `transcript`: what was said so far (final lines only), the call and its annotations.
|
|
222
|
+
export function transcriptSnapshot(items, id, kind, views = makeViews()) {
|
|
223
|
+
const call = items.find((i) => i?.event === 'call' || i?.event === 'conference');
|
|
224
|
+
const lines = items.flatMap(chunksOf).filter((c) => c.text).sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
|
|
225
|
+
const annotations = items.filter((i) => String(i?.event).endsWith('_annotation'));
|
|
226
|
+
return {
|
|
227
|
+
call,
|
|
228
|
+
snapshot: drop({
|
|
229
|
+
id,
|
|
230
|
+
type: kind,
|
|
231
|
+
call: call ? views.conversationView(call) : undefined,
|
|
232
|
+
transcript: lines.filter((c) => c.isFinal !== false).map((c) => views.textView(c, call)),
|
|
233
|
+
annotations: annotations.length ? annotations.map((a) => drop({key: a.key, payload: a.payload})) : undefined,
|
|
234
|
+
}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// `transcript --follow`: the events of one broadcast item. state.call is the call last seen (names the speakers).
|
|
239
|
+
// Returns {events: [{event, data}], end: boolean}.
|
|
240
|
+
export function transcriptEvents(it, id, state, views = makeViews()) {
|
|
241
|
+
if (!it) return {events: [], end: false};
|
|
242
|
+
if (it.event === 'call' || it.event === 'conference') {
|
|
243
|
+
state.call = it;
|
|
244
|
+
return {events: [{event: 'CALL', data: views.conversationView(it)}], end: false};
|
|
245
|
+
}
|
|
246
|
+
if (/_complete$|_interrupted$/.test(String(it.event))) return {events: [{event: 'END', data: {id, event: it.event}}], end: true};
|
|
247
|
+
if (String(it.event).endsWith('_annotation')) return {events: [{event: 'ANNOTATION', data: drop({key: it.key, payload: it.payload})}], end: false};
|
|
248
|
+
return {events: chunksOf(it).filter((c) => c.text).map((c) => ({event: 'TEXT', data: views.textView(c, state.call)})), end: false};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// `watch`: turns the frames of the connection into events. o: {views, wantConv, wantQueues, wantStats, calls, mine,
|
|
252
|
+
// statsOpts(), explain(topic, d)}. frame(d) returns {events: [{event, data}], fatal?: message}; reset() is called
|
|
253
|
+
// on every (re)connection (with stats the queues are read again before QUEUE_STATS lines).
|
|
254
|
+
export function watchFrames(o) {
|
|
255
|
+
const {views} = o;
|
|
256
|
+
const full = views.full;
|
|
257
|
+
const queueNames = new Map();
|
|
258
|
+
const mineIds = new Set();
|
|
259
|
+
let queuesReady = !o.wantStats;
|
|
260
|
+
const ev = (event, data) => ({event, data: data ?? {}});
|
|
261
|
+
|
|
262
|
+
function frame(d) {
|
|
263
|
+
const events = [];
|
|
264
|
+
if (d.event === 'error') {
|
|
265
|
+
events.push(ev('ERROR', {message: o.explain(d.source?.topic ?? 'subscription', d), code: d.code}));
|
|
266
|
+
return d.code === 403 ? {events, fatal: events[0].data.message} : {events};
|
|
267
|
+
}
|
|
268
|
+
if (d.event === 'subscribed' && d.topic === 'services' && !queuesReady) {
|
|
269
|
+
queuesReady = true;
|
|
270
|
+
events.push(ev('QUEUES', {queues: queueNames.size, stats: o.statsOpts()}));
|
|
271
|
+
return {events};
|
|
272
|
+
}
|
|
273
|
+
if (d.event !== 'broadcast') return {events};
|
|
274
|
+
for (const it of Array.isArray(d.data) ? d.data : [d.data]) {
|
|
275
|
+
if (!it || typeof it !== 'object') continue;
|
|
276
|
+
if (d.topic === 'presence') events.push(ev('PRESENCE', views.presenceView(it)));
|
|
277
|
+
else if (d.topic === 'services') {
|
|
278
|
+
if (it.event === 'service' && it.name) queueNames.set(it.id, it.name);
|
|
279
|
+
if (!queuesReady) continue;
|
|
280
|
+
if (it.event === 'service_stats_update') {
|
|
281
|
+
if (!o.wantStats) continue;
|
|
282
|
+
const agentStats = it.stats?.agentStats ?? {};
|
|
283
|
+
events.push(ev('QUEUE_STATS', full ? it : drop({
|
|
284
|
+
id: it.id,
|
|
285
|
+
name: queueNames.get(it.id),
|
|
286
|
+
...queueFigures(it.stats, views.now()),
|
|
287
|
+
agents: Object.keys(agentStats).length
|
|
288
|
+
? Object.fromEntries(Object.entries(agentStats).map(([k, v]) => [k.startsWith(`${it.id}_`) ? k.slice(String(it.id).length + 1) : k, agentFigures(v)]))
|
|
289
|
+
: undefined,
|
|
290
|
+
})));
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
// A change names what changed (eventTrigger) and carries only that: usually one agent, no queue name.
|
|
294
|
+
if (it.event === 'service') events.push(ev('QUEUE', full ? it : drop({trigger: it.eventTrigger, ...views.queueView(it)})));
|
|
295
|
+
else if (it.event === 'serviceCall' || it.event === 'service_call') events.push(ev('QUEUE_CALL', views.serviceCallView(it)));
|
|
296
|
+
else events.push(ev(String(it.event).toUpperCase(), full ? it : drop({id: it.id, trigger: it.eventTrigger})));
|
|
297
|
+
} else if (d.topic === 'conversations') {
|
|
298
|
+
const type = String(it.event);
|
|
299
|
+
if (o.calls && !type.startsWith('call')) continue;
|
|
300
|
+
if (o.mine) {
|
|
301
|
+
if (type === 'call' && views.isMineCall(it)) mineIds.add(it.id);
|
|
302
|
+
if (!mineIds.has(it.id)) continue;
|
|
303
|
+
// The skill forgot the call at any other event (an annotation too); here only when it ends.
|
|
304
|
+
if (/_complete$|_interrupted$/.test(type)) mineIds.delete(it.id);
|
|
305
|
+
}
|
|
306
|
+
events.push(ev(type.toUpperCase(), views.conversationView(it)));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return {events};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
frame,
|
|
314
|
+
reset() {
|
|
315
|
+
queuesReady = !o.wantStats;
|
|
316
|
+
},
|
|
317
|
+
get queueNames() {
|
|
318
|
+
return queueNames;
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// The SIP channel of the PBX, as the wildix-call-control skill uses it (through @gorets/wildix-callcontrol): a
|
|
2
|
+
// headless SIP user of the logged-in extension that registers, subscribes to the event packages `dialog` (calls and
|
|
3
|
+
// presence), `reg` (devices) and `active-device` (ringing mode), and sends call commands as Wildix TAPI PUBLISH
|
|
4
|
+
// requests (event `wildixtsp/action`), the way Wildix clients do. Same behaviour as that library: the same events,
|
|
5
|
+
// reconnect and resubscribe with the same backoff, a registration that lingers at most `expires` seconds when the
|
|
6
|
+
// process is killed.
|
|
7
|
+
//
|
|
8
|
+
// Events: registered, ready (the first dialog state after each registration), callStart, callUpdate, callEnd,
|
|
9
|
+
// devicesChanged, presenceChanged, activeDeviceChanged, reconnecting {attempt}, error (an Error).
|
|
10
|
+
import {EventEmitter} from 'node:events';
|
|
11
|
+
import {CallTracker, ValueTracker, deviceName, devicesEqual, parseActiveDevice, parseDialogInfo, parseRegInfo, toActiveDevice} from './events.mjs';
|
|
12
|
+
import {ALLOWED, SipUserAgent, paramOf, randomToken, tagOf, uriOf} from './sip.mjs';
|
|
13
|
+
|
|
14
|
+
export const TAPI_EVENT = 'wildixtsp/action';
|
|
15
|
+
export const RECORD_ACTIONS = ['on', 'off', 'pause', 'unpause'];
|
|
16
|
+
const PACKAGES = ['dialog', 'reg', 'active-device'];
|
|
17
|
+
const SUBSCRIPTION_DISPOSE_GRACE_MS = 500;
|
|
18
|
+
|
|
19
|
+
// 3 s, then up to 30 s over five attempts (quadratic), as the library.
|
|
20
|
+
export function backoffMs(attempt) {
|
|
21
|
+
const a = Math.min(Math.max(attempt, 1), 5);
|
|
22
|
+
return Math.round(((30_000 - 3_000) / 16) * (a - 1) ** 2 + 3_000);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const bounded = (work, ms) => {
|
|
26
|
+
let timer;
|
|
27
|
+
return Promise.race([work.catch(() => undefined), new Promise((r) => (timer = setTimeout(r, ms)))]).finally(() => clearTimeout(timer));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class CallControl extends EventEmitter {
|
|
31
|
+
// {domain, wsUrl, extension, password, userAgent, expires, WebSocketImpl, keepAliveSeconds, disconnectTimeoutMs, backoff}
|
|
32
|
+
constructor(config) {
|
|
33
|
+
super();
|
|
34
|
+
this.config = config;
|
|
35
|
+
this.expires = config.expires ?? 3600;
|
|
36
|
+
if (!Number.isInteger(this.expires) || this.expires <= 0) throw new Error(`expires must be a positive integer: ${config.expires}`);
|
|
37
|
+
this.disconnectTimeoutMs = config.disconnectTimeoutMs ?? 10_000;
|
|
38
|
+
this.backoff = config.backoff ?? backoffMs;
|
|
39
|
+
this.aor = `sip:${config.extension}@${config.domain}`;
|
|
40
|
+
this.stopping = false;
|
|
41
|
+
this.ready = false;
|
|
42
|
+
this.generation = 0;
|
|
43
|
+
this.reconnectAttempt = 0;
|
|
44
|
+
this.subs = new Map(); // Call-ID -> subscription
|
|
45
|
+
this.timers = new Set();
|
|
46
|
+
this.calls = new CallTracker();
|
|
47
|
+
this.devices = new ValueTracker();
|
|
48
|
+
this.presence = new ValueTracker();
|
|
49
|
+
this.activeDevice = new ValueTracker();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
later(fn, ms) {
|
|
53
|
+
const t = setTimeout(() => {
|
|
54
|
+
this.timers.delete(t);
|
|
55
|
+
fn();
|
|
56
|
+
}, ms);
|
|
57
|
+
this.timers.add(t);
|
|
58
|
+
return t;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
cancel(t) {
|
|
62
|
+
if (t) {
|
|
63
|
+
clearTimeout(t);
|
|
64
|
+
this.timers.delete(t);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async connect() {
|
|
69
|
+
this.stopping = false;
|
|
70
|
+
await this.start();
|
|
71
|
+
this.reconnectAttempt = 0;
|
|
72
|
+
this.emit('registered');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async start() {
|
|
76
|
+
const c = this.config;
|
|
77
|
+
const ua = new SipUserAgent({
|
|
78
|
+
wsUrl: c.wsUrl,
|
|
79
|
+
domain: c.domain,
|
|
80
|
+
extension: c.extension,
|
|
81
|
+
password: c.password,
|
|
82
|
+
userAgent: c.userAgent,
|
|
83
|
+
WebSocketImpl: c.WebSocketImpl,
|
|
84
|
+
keepAliveSeconds: c.keepAliveSeconds ?? 30,
|
|
85
|
+
timeoutMs: c.timeoutMs,
|
|
86
|
+
});
|
|
87
|
+
ua.onNotify = (req) => this.notify(req);
|
|
88
|
+
ua.on('error', (e) => this.emit('error', e));
|
|
89
|
+
// Only a connection that was up reconnects; one that fails to come up is the caller's error.
|
|
90
|
+
let live = false;
|
|
91
|
+
ua.on('disconnect', () => {
|
|
92
|
+
if (live && this.ua === ua && !this.stopping) this.scheduleReconnect();
|
|
93
|
+
});
|
|
94
|
+
this.ua = ua;
|
|
95
|
+
this.generation += 1;
|
|
96
|
+
this.ready = false;
|
|
97
|
+
try {
|
|
98
|
+
await ua.open();
|
|
99
|
+
await this.register(this.generation);
|
|
100
|
+
live = true;
|
|
101
|
+
if (ua.closed) throw new Error('the PBX closed the SIP connection right after the registration');
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (this.ua === ua) this.ua = undefined;
|
|
104
|
+
ua.close();
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
107
|
+
for (const pkg of PACKAGES) this.subscribe(pkg, this.generation, 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// REGISTER, refreshed before it runs out. A refresh that fails reconnects.
|
|
111
|
+
async register(generation, expires = this.expires) {
|
|
112
|
+
const ua = this.ua;
|
|
113
|
+
this.registration ??= {callId: randomToken(22), fromTag: randomToken(10), seq: {cseq: 0}};
|
|
114
|
+
const r = this.registration;
|
|
115
|
+
const res = await ua.request('REGISTER', {
|
|
116
|
+
ruri: `sip:${this.config.domain}`,
|
|
117
|
+
to: `<${this.aor}>`,
|
|
118
|
+
from: `<${this.aor}>;tag=${r.fromTag}`,
|
|
119
|
+
callId: r.callId,
|
|
120
|
+
seq: r.seq,
|
|
121
|
+
headers: ['X-Disable-DirectRTP: yes', `Contact: ${ua.contact};expires=${expires}`, `Allow: ACK,CANCEL,INVITE,MESSAGE,BYE,OPTIONS,INFO,NOTIFY,REFER`],
|
|
122
|
+
});
|
|
123
|
+
if (res.status === 423 && expires > 0) {
|
|
124
|
+
const min = Number(res.header('min-expires'));
|
|
125
|
+
if (Number.isInteger(min) && min > expires) return this.register(generation, min);
|
|
126
|
+
}
|
|
127
|
+
if (res.status < 200 || res.status >= 300) throw new Error(`REGISTER rejected: ${res.status} ${res.reason}`);
|
|
128
|
+
if (expires === 0) return;
|
|
129
|
+
const ours = res.headers('contact').flatMap((v) => v.split(/,(?![^<]*>)/)).find((v) => uriOf(v).includes(ua.viaHost));
|
|
130
|
+
const granted = Number(paramOf(ours, 'expires') ?? res.header('expires') ?? expires) || expires;
|
|
131
|
+
this.cancel(this.registerTimer);
|
|
132
|
+
this.registerTimer = this.later(() => {
|
|
133
|
+
if (this.stopping || generation !== this.generation) return;
|
|
134
|
+
this.register(generation).catch((e) => {
|
|
135
|
+
if (generation !== this.generation || this.stopping) return;
|
|
136
|
+
this.emit('error', e);
|
|
137
|
+
this.scheduleReconnect();
|
|
138
|
+
});
|
|
139
|
+
}, Math.max(1, granted * 0.9) * 1000);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
subscribe(pkg, generation, attempt) {
|
|
143
|
+
if (!this.ua || this.stopping || generation !== this.generation) return;
|
|
144
|
+
const sub = {pkg, generation, attempt, callId: randomToken(22), fromTag: randomToken(10), seq: {cseq: 0}, remoteTag: undefined, target: undefined, routes: []};
|
|
145
|
+
this.subs.set(sub.callId, sub);
|
|
146
|
+
this.sendSubscribe(sub, this.expires).then(
|
|
147
|
+
(res) => {
|
|
148
|
+
if (this.subs.get(sub.callId) !== sub) return;
|
|
149
|
+
if (res.status >= 200 && res.status < 300) {
|
|
150
|
+
sub.attempt = 0;
|
|
151
|
+
if (!sub.remoteTag) {
|
|
152
|
+
sub.remoteTag = tagOf(res.header('to'));
|
|
153
|
+
sub.target = res.header('contact') ? uriOf(res.header('contact')) : undefined;
|
|
154
|
+
sub.routes = SipUserAgent.routes(res).reverse();
|
|
155
|
+
}
|
|
156
|
+
this.scheduleRefresh(sub, Number(res.header('expires')) || this.expires);
|
|
157
|
+
} else {
|
|
158
|
+
this.emit('error', new Error(`SUBSCRIBE ${pkg} rejected: ${res.status} ${res.reason}`));
|
|
159
|
+
this.terminated(sub);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
(e) => {
|
|
163
|
+
if (this.subs.get(sub.callId) !== sub) return;
|
|
164
|
+
this.emit('error', e);
|
|
165
|
+
this.terminated(sub);
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
sendSubscribe(sub, expires) {
|
|
171
|
+
const inDialog = Boolean(sub.remoteTag);
|
|
172
|
+
return this.ua.request('SUBSCRIBE', {
|
|
173
|
+
ruri: inDialog && sub.target ? sub.target : this.aor,
|
|
174
|
+
routes: inDialog ? sub.routes : [],
|
|
175
|
+
to: `<${this.aor}>${inDialog ? `;tag=${sub.remoteTag}` : ''}`,
|
|
176
|
+
from: `<${this.aor}>;tag=${sub.fromTag}`,
|
|
177
|
+
callId: sub.callId,
|
|
178
|
+
seq: sub.seq,
|
|
179
|
+
headers: [`Allow: ${ALLOWED.join(',')}`, `Event: ${sub.pkg}`, `Expires: ${expires}`, `Contact: ${this.ua.contact}`],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
scheduleRefresh(sub, seconds) {
|
|
184
|
+
this.cancel(sub.timer);
|
|
185
|
+
sub.timer = this.later(() => {
|
|
186
|
+
if (this.stopping || this.subs.get(sub.callId) !== sub) return;
|
|
187
|
+
this.sendSubscribe(sub, this.expires).then(
|
|
188
|
+
(res) => {
|
|
189
|
+
if (this.subs.get(sub.callId) !== sub) return;
|
|
190
|
+
if (res.status >= 200 && res.status < 300) this.scheduleRefresh(sub, Number(res.header('expires')) || this.expires);
|
|
191
|
+
else this.terminated(sub);
|
|
192
|
+
},
|
|
193
|
+
() => this.subs.get(sub.callId) === sub && this.terminated(sub),
|
|
194
|
+
);
|
|
195
|
+
}, Math.max(1, seconds * 0.9) * 1000);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A subscription that ended: subscribe again, after a backoff, while connected.
|
|
199
|
+
terminated(sub) {
|
|
200
|
+
this.cancel(sub.timer);
|
|
201
|
+
this.subs.delete(sub.callId);
|
|
202
|
+
if (this.stopping || sub.generation !== this.generation) return;
|
|
203
|
+
const attempt = sub.attempt + 1;
|
|
204
|
+
this.later(() => this.subscribe(sub.pkg, sub.generation, attempt), this.backoff(attempt));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A NOTIFY: true when it belongs to one of our subscriptions.
|
|
208
|
+
notify(req) {
|
|
209
|
+
const sub = this.subs.get(req.header('call-id'));
|
|
210
|
+
if (!sub || tagOf(req.header('to')) !== sub.fromTag) return false;
|
|
211
|
+
if (!sub.remoteTag) {
|
|
212
|
+
sub.remoteTag = tagOf(req.header('from'));
|
|
213
|
+
sub.target = req.header('contact') ? uriOf(req.header('contact')) : undefined;
|
|
214
|
+
sub.routes = SipUserAgent.routes(req);
|
|
215
|
+
}
|
|
216
|
+
const state = req.header('subscription-state') ?? '';
|
|
217
|
+
const body = req.body ?? '';
|
|
218
|
+
try {
|
|
219
|
+
if (sub.pkg === 'dialog') this.onDialog(body);
|
|
220
|
+
else if (sub.pkg === 'reg') this.onReg(body);
|
|
221
|
+
else this.onActiveDevice(body);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
this.emit('error', new Error(`could not read the ${sub.pkg} state from the PBX: ${e.message}`));
|
|
224
|
+
}
|
|
225
|
+
if (/^\s*terminated/i.test(state)) {
|
|
226
|
+
sub.attempt = 0;
|
|
227
|
+
this.terminated(sub);
|
|
228
|
+
} else {
|
|
229
|
+
const expires = Number(paramOf(state, 'expires'));
|
|
230
|
+
if (Number.isFinite(expires) && expires > 0) this.scheduleRefresh(sub, expires);
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
onDialog(body) {
|
|
236
|
+
if (body.trim()) {
|
|
237
|
+
const {dialogs, presence} = parseDialogInfo(body);
|
|
238
|
+
const {started, updated, ended} = this.calls.apply(dialogs);
|
|
239
|
+
for (const c of started) this.emit('callStart', c);
|
|
240
|
+
for (const c of updated) this.emit('callUpdate', c);
|
|
241
|
+
for (const c of ended) this.emit('callEnd', c);
|
|
242
|
+
const next = presence ? {...presence} : undefined;
|
|
243
|
+
if (this.presence.apply(next) && next) this.emit('presenceChanged', next);
|
|
244
|
+
}
|
|
245
|
+
// The PBX answers the unsubscribe of disconnect() with a last NOTIFY: that is not a new start.
|
|
246
|
+
if (!this.ready && !this.stopping) {
|
|
247
|
+
this.ready = true;
|
|
248
|
+
this.emit('ready');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
onReg(body) {
|
|
253
|
+
if (!body.trim() || !this.ua) return;
|
|
254
|
+
const via = this.ua.viaHost;
|
|
255
|
+
const devices = parseRegInfo(body).map((d) => ({uri: d.uri, userAgent: d.userAgent, deviceName: deviceName(d.userAgent), isLocalRegistration: d.uri.includes(via)}));
|
|
256
|
+
if (this.devices.apply(devices, devicesEqual)) this.emit('devicesChanged', devices);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
onActiveDevice(body) {
|
|
260
|
+
if (!body.trim()) return;
|
|
261
|
+
const next = toActiveDevice(parseActiveDevice(body));
|
|
262
|
+
if (this.activeDevice.apply(next) && next) this.emit('activeDeviceChanged', next);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
isReady() {
|
|
266
|
+
return this.ready;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
waitUntilReady(timeoutMs = 10_000) {
|
|
270
|
+
if (this.ready) return Promise.resolve();
|
|
271
|
+
return new Promise((resolve, reject) => {
|
|
272
|
+
const onReady = () => {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
resolve();
|
|
275
|
+
};
|
|
276
|
+
const timer = setTimeout(() => {
|
|
277
|
+
this.off('ready', onReady);
|
|
278
|
+
reject(new Error(`no call state from the PBX within ${timeoutMs} ms`));
|
|
279
|
+
}, timeoutMs);
|
|
280
|
+
this.once('ready', onReady);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
getActiveCalls() {
|
|
285
|
+
return this.calls.list();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
getDevices() {
|
|
289
|
+
return this.devices.value ?? [];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
getPresence() {
|
|
293
|
+
return this.presence.value;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
getActiveDevice() {
|
|
297
|
+
return this.activeDevice.value;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// A TAPI command: PUBLISH to the user's own address with W-TapiCommand and its parameters. Resolves when the PBX
|
|
301
|
+
// takes it (2xx): taken, not necessarily done.
|
|
302
|
+
async tapi(command, ...params) {
|
|
303
|
+
if (!this.ua) throw new Error('not connected to the PBX');
|
|
304
|
+
const headers = [`W-TapiCommand: ${command}`];
|
|
305
|
+
params.forEach((p, i) => p !== undefined && headers.push(`W-TapiParam${i + 1}: ${p}`));
|
|
306
|
+
headers.push(`Event: ${TAPI_EVENT}`, 'Expires: 1');
|
|
307
|
+
for (const h of headers) if (/[\r\n]/.test(h)) throw new Error('a SIP header value cannot hold a line break');
|
|
308
|
+
const res = await this.ua.request('PUBLISH', {ruri: this.aor, to: `<${this.aor}>`, from: `<${this.aor}>;tag=${randomToken(10)}`, callId: randomToken(22), seq: {cseq: 0}, headers});
|
|
309
|
+
if (res.status < 200 || res.status >= 300) throw new Error(`TAPI command "${command}" rejected: ${res.status} ${res.reason}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
record(callId, action) {
|
|
313
|
+
if (!RECORD_ACTIONS.includes(action)) return Promise.reject(new Error(`record action must be one of ${RECORD_ACTIONS.join(', ')}`));
|
|
314
|
+
return this.tapi('record', callId, action);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
conferenceTransfer(callId, destination) {
|
|
318
|
+
return this.tapi('conftransfer', callId, destination);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
scheduleReconnect() {
|
|
322
|
+
if (this.stopping || this.reconnectTimer) return;
|
|
323
|
+
this.reconnectAttempt += 1;
|
|
324
|
+
this.emit('reconnecting', {attempt: this.reconnectAttempt});
|
|
325
|
+
this.reconnectTimer = this.later(() => {
|
|
326
|
+
this.reconnectTimer = undefined;
|
|
327
|
+
this.reconnect();
|
|
328
|
+
}, this.backoff(this.reconnectAttempt));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async reconnect() {
|
|
332
|
+
if (this.stopping) return;
|
|
333
|
+
this.dropState();
|
|
334
|
+
const old = this.ua;
|
|
335
|
+
this.ua = undefined;
|
|
336
|
+
old?.close();
|
|
337
|
+
try {
|
|
338
|
+
await this.start();
|
|
339
|
+
this.reconnectAttempt = 0;
|
|
340
|
+
this.emit('registered');
|
|
341
|
+
} catch (e) {
|
|
342
|
+
if (this.stopping) return;
|
|
343
|
+
this.emit('error', e);
|
|
344
|
+
this.scheduleReconnect();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Forgets the registration and the subscriptions of the connection that is gone (the calls stay known, so the
|
|
349
|
+
// state after the reconnect is told as changes).
|
|
350
|
+
dropState() {
|
|
351
|
+
this.cancel(this.registerTimer);
|
|
352
|
+
for (const s of this.subs.values()) this.cancel(s.timer);
|
|
353
|
+
this.subs.clear();
|
|
354
|
+
this.registration = undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Ends the registration and the subscriptions (sent together; the registration waited for at most
|
|
358
|
+
// disconnectTimeoutMs, the subscriptions 0.5 s), then closes the socket. Never rejects.
|
|
359
|
+
async disconnect() {
|
|
360
|
+
this.stopping = true;
|
|
361
|
+
this.ready = false;
|
|
362
|
+
for (const t of this.timers) clearTimeout(t);
|
|
363
|
+
this.timers.clear();
|
|
364
|
+
this.reconnectTimer = undefined;
|
|
365
|
+
const ua = this.ua;
|
|
366
|
+
if (ua && !ua.closed) {
|
|
367
|
+
const subs = [...this.subs.values()].filter((s) => s.remoteTag);
|
|
368
|
+
const unregistered = this.registration ? this.register(this.generation, 0).catch(() => undefined) : Promise.resolve();
|
|
369
|
+
const unsubscribed = Promise.all(subs.map((s) => this.sendSubscribe(s, 0).catch(() => undefined)));
|
|
370
|
+
await bounded(Promise.all([unregistered, bounded(unsubscribed, SUBSCRIPTION_DISPOSE_GRACE_MS)]), this.disconnectTimeoutMs);
|
|
371
|
+
}
|
|
372
|
+
this.subs.clear();
|
|
373
|
+
this.registration = undefined;
|
|
374
|
+
ua?.close();
|
|
375
|
+
this.ua = undefined;
|
|
376
|
+
}
|
|
377
|
+
}
|