@xema/omni-protocol 0.1.32 → 0.1.34
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/dist/index.d.ts +96 -16
- package/dist/index.js +18 -0
- package/dist/testing.d.ts +12 -8
- package/dist/testing.js +53 -14
- package/dist/validation.d.ts +3 -1
- package/dist/validation.js +130 -18
- package/guide.md +210 -68
- package/package.json +1 -1
package/dist/validation.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Each list is pinned to its type both ways -- a member the type lacks, or a member the list
|
|
10
10
|
// lacks, fails to compile -- so what the validators accept cannot drift from what the
|
|
11
11
|
// declarations say.
|
|
12
|
-
import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
|
|
12
|
+
import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HANDLING_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
|
|
13
13
|
export class ProtocolConformanceError extends Error {
|
|
14
14
|
violations;
|
|
15
15
|
constructor(violations, summary = "Adapter violates the Omni protocol") {
|
|
@@ -67,6 +67,10 @@ const ACCESS_APPLIES_TO = membersOf({
|
|
|
67
67
|
"initial-url": true, "all-navigation": true,
|
|
68
68
|
});
|
|
69
69
|
const DIAL_DESTINATION_POLICIES = membersOf({ "contacts-only": true, "any-number": true });
|
|
70
|
+
const DIAL_OUTCOMES = membersOf({ answered: true, busy: true, "no-answer": true, unreachable: true, rejected: true, cancelled: true });
|
|
71
|
+
const ON_CALL_ROLES = membersOf({ party: true, agent: true, consulted: true, conferenced: true });
|
|
72
|
+
/** The task capabilities under which a command dials, and so need the manifest to say how a dial ends. */
|
|
73
|
+
const DIALLING_CAPABILITIES = ["callback", "blindTransfer", "consultTransfer", "conference"];
|
|
70
74
|
const SNAPSHOT_REASONS = membersOf({
|
|
71
75
|
reconnected: true, "provider-requested": true,
|
|
72
76
|
});
|
|
@@ -92,6 +96,8 @@ const TASK_CAPABILITIES = {
|
|
|
92
96
|
chat: membersOf({ browsers: true, dispositions: true, custom: true, reject: true, hold: true }),
|
|
93
97
|
email: membersOf({ browsers: true, dispositions: true, custom: true, reject: true }),
|
|
94
98
|
};
|
|
99
|
+
// The published list and the type's keys are the same set, or one of them is wrong.
|
|
100
|
+
true;
|
|
95
101
|
/** Idle capabilities each channel may declare. Only voice may dial, and runtime has to say so too. */
|
|
96
102
|
const IDLE_CAPABILITIES_BY_CHANNEL = {
|
|
97
103
|
voice: IDLE_CAPABILITIES,
|
|
@@ -266,6 +272,36 @@ function validateIdleCapabilities(value, channel, path, into) {
|
|
|
266
272
|
}
|
|
267
273
|
}
|
|
268
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* A provider that dials says how a dial can end, both ways: `answered`, or the desk cannot tell
|
|
277
|
+
* reached from still ringing, and at least one way of not reaching, or it can never say failed.
|
|
278
|
+
* Off voice nothing dials, and the idle dialpad is a dial, so declaring it is declaring this.
|
|
279
|
+
*/
|
|
280
|
+
function validateDialOutcomes(manifest, path, into) {
|
|
281
|
+
const declared = manifest.dialOutcomes;
|
|
282
|
+
const dials = isPlainObject(manifest.idleCapabilities) && manifest.idleCapabilities.dial !== undefined;
|
|
283
|
+
if (declared === undefined) {
|
|
284
|
+
into.require(!dials, "manifest.dialOutcomes.required", path, "a manifest that declares dial says how a dial ends: dialOutcomes with answered and at least one way of not reaching");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (!into.require(manifest.channel === "voice", "manifest.dialOutcomes.channel", path, `a ${String(manifest.channel)} provider dials nothing and declares no dial outcomes`))
|
|
288
|
+
return;
|
|
289
|
+
if (!Array.isArray(declared) || declared.length === 0) {
|
|
290
|
+
into.add("manifest.dialOutcomes.shape", path, "dialOutcomes is a non-empty array of the outcomes the platform distinguishes");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
declared.forEach((outcome, index) => {
|
|
294
|
+
if (into.oneOf(outcome, DIAL_OUTCOMES, "manifest.dialOutcome", `${path}[${index}]`) && declared.indexOf(outcome) !== index) {
|
|
295
|
+
into.add("manifest.dialOutcome.unique", `${path}[${index}]`, `duplicate dial outcome: ${String(outcome)}`);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
into.require(declared.includes("answered"), "manifest.dialOutcomes.answered", path, "a dial that reached its destination is stated, never inferred: dialOutcomes includes answered");
|
|
299
|
+
into.require(declared.some((outcome) => outcome !== "answered"), "manifest.dialOutcomes.failure", path, "a provider that can only say answered can never say a destination was not reached: declare at least one other outcome");
|
|
300
|
+
}
|
|
301
|
+
/** Whether a manifest says how a dial ends, and so may publish tasks that dial. `undefined` where there is no manifest to ask. */
|
|
302
|
+
function manifestDials(manifest) {
|
|
303
|
+
return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
|
|
304
|
+
}
|
|
269
305
|
export function validateManifest(manifest, path = "manifest") {
|
|
270
306
|
const into = new Collector();
|
|
271
307
|
if (!isPlainObject(manifest)) {
|
|
@@ -301,6 +337,7 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
301
337
|
}
|
|
302
338
|
if (channelValid)
|
|
303
339
|
validateIdleCapabilities(manifest.idleCapabilities, manifest.channel, `${path}.idleCapabilities`, into);
|
|
340
|
+
validateDialOutcomes(manifest, `${path}.dialOutcomes`, into);
|
|
304
341
|
if (manifest.phaseLabels !== undefined) {
|
|
305
342
|
if (!isPlainObject(manifest.phaseLabels)) {
|
|
306
343
|
into.add("manifest.phaseLabels.shape", `${path}.phaseLabels`, "phaseLabels must be an object when present");
|
|
@@ -675,6 +712,15 @@ function validateHandlingHistory(value, path, into) {
|
|
|
675
712
|
// On `queued` nobody takes part, so there is nothing to name.
|
|
676
713
|
into.require(entry.step !== "queued", "task.handlingHistory.by.unexpected", `${at}.by`, "a queued step names nobody");
|
|
677
714
|
}
|
|
715
|
+
// A step that dialled says where to and, when a host placed it, which dial; no other step dialled.
|
|
716
|
+
const dialled = typeof entry.step === "string" && HANDLING_STEPS_THAT_DIAL.includes(entry.step);
|
|
717
|
+
for (const field of ["dialId", "destination"]) {
|
|
718
|
+
if (entry[field] === undefined)
|
|
719
|
+
continue;
|
|
720
|
+
if (into.filled(entry[field], `task.handlingHistory.${field}`, `${at}.${field}`, `${field} must not be empty when present`)) {
|
|
721
|
+
into.require(dialled, `task.handlingHistory.${field}.unexpected`, `${at}.${field}`, `${String(entry.step)} dialled nothing; ${field} belongs on transferred, conferenced, or unanswered`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
678
724
|
});
|
|
679
725
|
}
|
|
680
726
|
/** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
|
|
@@ -687,21 +733,57 @@ function validateTaskMedia(value, channel, path, into) {
|
|
|
687
733
|
return;
|
|
688
734
|
into.oneOf(value, TASK_MEDIA_STATES, "task.media", path);
|
|
689
735
|
}
|
|
690
|
-
|
|
736
|
+
/**
|
|
737
|
+
* Who is on the call, as the provider states it. Voice only. Each entry is a role with what that
|
|
738
|
+
* role needs and nothing another role would: a party or an agent dialled nowhere, so neither
|
|
739
|
+
* carries a destination; somebody consulted or conferenced came from one, so both do.
|
|
740
|
+
*/
|
|
741
|
+
function validateOnCall(value, channel, path, into) {
|
|
691
742
|
if (value === undefined)
|
|
692
743
|
return;
|
|
693
|
-
if (!into.require(channel === "voice", "task.
|
|
744
|
+
if (!into.require(channel === "voice", "task.onCall.channel", path, `a ${channel} task has nobody on a call`))
|
|
694
745
|
return;
|
|
695
|
-
if (!
|
|
696
|
-
into.add("task.
|
|
746
|
+
if (!Array.isArray(value)) {
|
|
747
|
+
into.add("task.onCall.shape", path, "onCall is an array of who is on the call, when the provider knows");
|
|
697
748
|
return;
|
|
698
749
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
750
|
+
const counted = { party: 0, consulted: 0 };
|
|
751
|
+
value.forEach((entry, index) => {
|
|
752
|
+
const at = `${path}[${index}]`;
|
|
753
|
+
if (!isPlainObject(entry)) {
|
|
754
|
+
into.add("task.onCall.entry", at, "each entry on the call must be an object");
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (!into.oneOf(entry.role, ON_CALL_ROLES, "task.onCall.role", `${at}.role`))
|
|
758
|
+
return;
|
|
759
|
+
const role = entry.role;
|
|
760
|
+
into.timestamp(entry.since, "task.onCall.since", `${at}.since`);
|
|
761
|
+
if (entry.held !== undefined) {
|
|
762
|
+
into.require(entry.held === true, "task.onCall.held", `${at}.held`, "held is stated by presence: send true or omit it");
|
|
763
|
+
}
|
|
764
|
+
if (role === "party" || role === "consulted")
|
|
765
|
+
counted[role] += 1;
|
|
766
|
+
if (role === "agent") {
|
|
767
|
+
into.require(isUserId(entry.userId), "task.onCall.userId", `${at}.userId`, "an agent on the call is named by their user id");
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
into.require(entry.userId === undefined, "task.onCall.userId.unexpected", `${at}.userId`, `a ${role} is not named by a user id`);
|
|
771
|
+
}
|
|
772
|
+
if (role === "consulted" || role === "conferenced") {
|
|
773
|
+
into.filled(entry.destination, "task.onCall.destination", `${at}.destination`, `a ${role} entry names the destination that was dialled`);
|
|
774
|
+
if (entry.dialId !== undefined)
|
|
775
|
+
into.filled(entry.dialId, "task.onCall.dialId", `${at}.dialId`, "dialId must not be empty when present");
|
|
776
|
+
if (entry.label !== undefined)
|
|
777
|
+
into.filled(entry.label, "task.onCall.label", `${at}.label`, "a label must not be empty when present");
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
for (const field of ["destination", "dialId", "label"]) {
|
|
781
|
+
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
into.require(counted.party <= 1, "task.onCall.party.single", path, "a call has one party; a second is somebody else's role");
|
|
786
|
+
into.require(counted.consulted <= 1, "task.onCall.consulted.single", path, "one consultation at a time: transfer complete and cancel name no destination because there is exactly one");
|
|
705
787
|
}
|
|
706
788
|
const LEAD_STAGES = membersOf({ requested: true, joined: true });
|
|
707
789
|
/** The agent's request for a lead. Voice only; `joined` names the lead, `requested` cannot. */
|
|
@@ -790,7 +872,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
790
872
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
791
873
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into);
|
|
792
874
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
|
|
793
|
-
|
|
875
|
+
validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
|
|
794
876
|
validateTaskMedia(task.media, context.channel, `${path}.media`, into);
|
|
795
877
|
validateLead(task.lead, context.channel, `${path}.lead`, into);
|
|
796
878
|
validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
|
|
@@ -814,6 +896,10 @@ function validateTaskInto(task, context, path, into) {
|
|
|
814
896
|
continue;
|
|
815
897
|
if (!into.require(allowed.includes(name), "task.capability.channel", `${path}.capabilities.${name}`, `a ${context.channel} task may not declare ${name}`))
|
|
816
898
|
continue;
|
|
899
|
+
// A control that dials needs the manifest to have said how a dial ends, or its outcome has no words.
|
|
900
|
+
if (DIALLING_CAPABILITIES.includes(name) && context.dialOutcomesDeclared === false) {
|
|
901
|
+
into.add("task.capability.dialOutcomes.required", `${path}.capabilities.${name}`, `${name} dials, and the manifest declares no dialOutcomes to say how a dial ends`);
|
|
902
|
+
}
|
|
817
903
|
// A control the queue could allow may stand locked in its place, saying whose. What the
|
|
818
904
|
// queue provides -- browsers, dispositions, custom controls -- is content, not a control.
|
|
819
905
|
if (isLocked(declared)) {
|
|
@@ -1049,7 +1135,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1049
1135
|
const seen = new Set();
|
|
1050
1136
|
let assisting;
|
|
1051
1137
|
snapshot.tasks.forEach((task, index) => {
|
|
1052
|
-
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks }, `${path}.tasks[${index}]`, into);
|
|
1138
|
+
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${path}.tasks[${index}]`, into);
|
|
1053
1139
|
// A lead assists one call at a time.
|
|
1054
1140
|
if (isPlainObject(task) && task.assisting !== undefined) {
|
|
1055
1141
|
if (assisting !== undefined)
|
|
@@ -1236,7 +1322,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1236
1322
|
validateBreakState(event.break, `${at}.break`, into);
|
|
1237
1323
|
break;
|
|
1238
1324
|
case "task-offered":
|
|
1239
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks }, `${at}.task`, into);
|
|
1325
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
|
|
1240
1326
|
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1241
1327
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1242
1328
|
into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
|
|
@@ -1247,7 +1333,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1247
1333
|
}
|
|
1248
1334
|
break;
|
|
1249
1335
|
case "task-updated":
|
|
1250
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks }, `${at}.task`, into);
|
|
1336
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
|
|
1251
1337
|
break;
|
|
1252
1338
|
case "task-media-started":
|
|
1253
1339
|
into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
|
|
@@ -1259,6 +1345,21 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1259
1345
|
into.require(isTaskId(event.taskId), "event.taskEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
1260
1346
|
validateTaskOutcome(event.outcome, `${at}.outcome`, into);
|
|
1261
1347
|
break;
|
|
1348
|
+
case "dial-outcome": {
|
|
1349
|
+
into.filled(event.dialId, "event.dialOutcome.dialId", `${at}.dialId`, "an outcome names the dial it ends, by the host's dialId");
|
|
1350
|
+
// Only a distinction the manifest declared: an undeclared word is a guess dressed as a code.
|
|
1351
|
+
const declared = isPlainObject(manifest) && Array.isArray(manifest.dialOutcomes) ? manifest.dialOutcomes : undefined;
|
|
1352
|
+
if (into.oneOf(event.outcome, DIAL_OUTCOMES, "event.dialOutcome.outcome", `${at}.outcome`)) {
|
|
1353
|
+
into.require(declared !== undefined && declared.includes(event.outcome), "event.dialOutcome.undeclared", `${at}.outcome`, `the manifest does not declare ${String(event.outcome)} among its dialOutcomes`);
|
|
1354
|
+
}
|
|
1355
|
+
if (event.taskId !== undefined)
|
|
1356
|
+
into.require(isTaskId(event.taskId), "event.dialOutcome.taskId", `${at}.taskId`, "taskId must be a task id when present");
|
|
1357
|
+
if (event.destination !== undefined)
|
|
1358
|
+
into.filled(event.destination, "event.dialOutcome.destination", `${at}.destination`, "a destination must not be empty when present");
|
|
1359
|
+
if (event.reason !== undefined)
|
|
1360
|
+
into.filled(event.reason, "event.dialOutcome.reason", `${at}.reason`, "a reason must not be empty when present");
|
|
1361
|
+
break;
|
|
1362
|
+
}
|
|
1262
1363
|
case "announcement":
|
|
1263
1364
|
into.filled(event.text, "event.announcement.text", `${at}.text`, "an announcement needs text");
|
|
1264
1365
|
into.timestamp(event.announcedAt, "event.announcement.announcedAt", `${at}.announcedAt`);
|
|
@@ -1501,7 +1602,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1501
1602
|
// failure. A method added to `Connection` without a row here is a compile error at the call site.
|
|
1502
1603
|
const RESULT_STATUSES = {
|
|
1503
1604
|
execute: { success: "applied", failure: "failed" },
|
|
1504
|
-
dial: { success: "
|
|
1605
|
+
dial: { success: "dialling", failure: "failed" },
|
|
1505
1606
|
setCapacity: { success: "applied", failure: "failed" },
|
|
1506
1607
|
requestBreak: { success: "requested", failure: "failed" },
|
|
1507
1608
|
commitBreak: { success: "committed", failure: "failed" },
|
|
@@ -1520,9 +1621,12 @@ const RESULT_STATUSES = {
|
|
|
1520
1621
|
* shows the agent what it says. A status the method does not answer, a failure status without a
|
|
1521
1622
|
* failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
|
|
1522
1623
|
*/
|
|
1523
|
-
export function validateResult(result, method, path = "result") {
|
|
1624
|
+
export function validateResult(result, method, path = "result", dialId) {
|
|
1524
1625
|
const into = new Collector();
|
|
1525
|
-
|
|
1626
|
+
// A command that dials -- `dial`, or `execute` with a command carrying a `dialId` -- answers
|
|
1627
|
+
// `dialling` and restates the host's identity, so the host compares and confirms.
|
|
1628
|
+
const dials = method === "dial" || (method === "execute" && dialId !== undefined);
|
|
1629
|
+
const statuses = dials ? { success: "dialling", failure: RESULT_STATUSES[method].failure } : RESULT_STATUSES[method];
|
|
1526
1630
|
if (!isPlainObject(result)) {
|
|
1527
1631
|
into.add("result.shape", path, `${method} must answer an object`);
|
|
1528
1632
|
return into.violations;
|
|
@@ -1532,6 +1636,14 @@ export function validateResult(result, method, path = "result") {
|
|
|
1532
1636
|
if (method === "openMedia") {
|
|
1533
1637
|
into.require(isPlainObject(result.session), "result.session", `${path}.session`, "opened carries the media session");
|
|
1534
1638
|
}
|
|
1639
|
+
if (dials) {
|
|
1640
|
+
if (into.filled(result.dialId, "result.dialId", `${path}.dialId`, "dialling restates the dialId the host sent") && dialId !== undefined) {
|
|
1641
|
+
into.require(result.dialId === dialId, "result.dialId.mismatch", `${path}.dialId`, `the host dialled ${dialId} and the provider answered for ${String(result.dialId)}`);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
else {
|
|
1645
|
+
into.require(result.dialId === undefined, "result.dialId.unexpected", `${path}.dialId`, `${statuses.success} dialled nothing and names no dial`);
|
|
1646
|
+
}
|
|
1535
1647
|
}
|
|
1536
1648
|
else if (result.status === statuses.failure) {
|
|
1537
1649
|
if (result.failure === undefined) {
|