@xema/omni-protocol 0.1.31 → 0.1.33
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 +146 -17
- package/dist/index.js +18 -0
- package/dist/testing.d.ts +12 -8
- package/dist/testing.js +72 -16
- package/dist/validation.d.ts +10 -2
- package/dist/validation.js +181 -22
- package/guide.md +279 -79
- 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 });
|
|
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");
|
|
@@ -312,6 +349,9 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
312
349
|
}
|
|
313
350
|
}
|
|
314
351
|
}
|
|
352
|
+
if (manifest.runningStepReports !== undefined) {
|
|
353
|
+
into.require(manifest.runningStepReports === true, "manifest.runningStepReports", `${path}.runningStepReports`, "runningStepReports is declared by presence, as true; a provider that takes begin and end only omits it");
|
|
354
|
+
}
|
|
315
355
|
if (manifest.orgLevels !== undefined) {
|
|
316
356
|
if (!Array.isArray(manifest.orgLevels)) {
|
|
317
357
|
into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
|
|
@@ -628,13 +668,27 @@ function validateTaskAttributes(value, path, into) {
|
|
|
628
668
|
function validateHandlingHistory(value, path, into) {
|
|
629
669
|
if (value === undefined)
|
|
630
670
|
return;
|
|
631
|
-
if (!
|
|
632
|
-
into.add("task.handlingHistory.shape", path, "handlingHistory must be an
|
|
671
|
+
if (!isPlainObject(value)) {
|
|
672
|
+
into.add("task.handlingHistory.shape", path, "handlingHistory must be an object with its steps when present");
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
// What the record adds up to before this agent: each total present when the provider knows it
|
|
676
|
+
// and absent when it does not, never a plausible nought.
|
|
677
|
+
for (const field of ["handleSeconds", "holdSeconds", "queueSeconds"]) {
|
|
678
|
+
if (value[field] !== undefined) {
|
|
679
|
+
into.require(isDurationSeconds(value[field]), `task.handlingHistory.${field}`, `${path}.${field}`, `${field} must be a whole number of seconds, zero or more, or omitted when unknown`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (value.transfers !== undefined) {
|
|
683
|
+
into.require(typeof value.transfers === "number" && Number.isInteger(value.transfers) && value.transfers >= 0, "task.handlingHistory.transfers", `${path}.transfers`, "transfers must be a whole number, zero or more, or omitted when unknown");
|
|
684
|
+
}
|
|
685
|
+
if (!Array.isArray(value.steps)) {
|
|
686
|
+
into.add("task.handlingHistory.steps.shape", `${path}.steps`, "handlingHistory carries its steps as an array, empty when the task has had none");
|
|
633
687
|
return;
|
|
634
688
|
}
|
|
635
689
|
let previous;
|
|
636
|
-
value.forEach((entry, index) => {
|
|
637
|
-
const at = `${path}[${index}]`;
|
|
690
|
+
value.steps.forEach((entry, index) => {
|
|
691
|
+
const at = `${path}.steps[${index}]`;
|
|
638
692
|
if (!isPlainObject(entry)) {
|
|
639
693
|
into.add("task.handlingHistory.entry", at, "each handling step must be an object");
|
|
640
694
|
return;
|
|
@@ -658,6 +712,15 @@ function validateHandlingHistory(value, path, into) {
|
|
|
658
712
|
// On `queued` nobody takes part, so there is nothing to name.
|
|
659
713
|
into.require(entry.step !== "queued", "task.handlingHistory.by.unexpected", `${at}.by`, "a queued step names nobody");
|
|
660
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
|
+
}
|
|
661
724
|
});
|
|
662
725
|
}
|
|
663
726
|
/** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
|
|
@@ -670,21 +733,57 @@ function validateTaskMedia(value, channel, path, into) {
|
|
|
670
733
|
return;
|
|
671
734
|
into.oneOf(value, TASK_MEDIA_STATES, "task.media", path);
|
|
672
735
|
}
|
|
673
|
-
|
|
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) {
|
|
674
742
|
if (value === undefined)
|
|
675
743
|
return;
|
|
676
|
-
if (!into.require(channel === "voice", "task.
|
|
744
|
+
if (!into.require(channel === "voice", "task.onCall.channel", path, `a ${channel} task has nobody on a call`))
|
|
677
745
|
return;
|
|
678
|
-
if (!
|
|
679
|
-
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");
|
|
680
748
|
return;
|
|
681
749
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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");
|
|
688
787
|
}
|
|
689
788
|
const LEAD_STAGES = membersOf({ requested: true, joined: true });
|
|
690
789
|
/** The agent's request for a lead. Voice only; `joined` names the lead, `requested` cannot. */
|
|
@@ -773,7 +872,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
773
872
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
774
873
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into);
|
|
775
874
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
|
|
776
|
-
|
|
875
|
+
validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
|
|
777
876
|
validateTaskMedia(task.media, context.channel, `${path}.media`, into);
|
|
778
877
|
validateLead(task.lead, context.channel, `${path}.lead`, into);
|
|
779
878
|
validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
|
|
@@ -797,6 +896,10 @@ function validateTaskInto(task, context, path, into) {
|
|
|
797
896
|
continue;
|
|
798
897
|
if (!into.require(allowed.includes(name), "task.capability.channel", `${path}.capabilities.${name}`, `a ${context.channel} task may not declare ${name}`))
|
|
799
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
|
+
}
|
|
800
903
|
// A control the queue could allow may stand locked in its place, saying whose. What the
|
|
801
904
|
// queue provides -- browsers, dispositions, custom controls -- is content, not a control.
|
|
802
905
|
if (isLocked(declared)) {
|
|
@@ -1032,7 +1135,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1032
1135
|
const seen = new Set();
|
|
1033
1136
|
let assisting;
|
|
1034
1137
|
snapshot.tasks.forEach((task, index) => {
|
|
1035
|
-
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);
|
|
1036
1139
|
// A lead assists one call at a time.
|
|
1037
1140
|
if (isPlainObject(task) && task.assisting !== undefined) {
|
|
1038
1141
|
if (assisting !== undefined)
|
|
@@ -1219,7 +1322,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1219
1322
|
validateBreakState(event.break, `${at}.break`, into);
|
|
1220
1323
|
break;
|
|
1221
1324
|
case "task-offered":
|
|
1222
|
-
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);
|
|
1223
1326
|
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1224
1327
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1225
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}`);
|
|
@@ -1230,7 +1333,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1230
1333
|
}
|
|
1231
1334
|
break;
|
|
1232
1335
|
case "task-updated":
|
|
1233
|
-
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);
|
|
1234
1337
|
break;
|
|
1235
1338
|
case "task-media-started":
|
|
1236
1339
|
into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
|
|
@@ -1242,6 +1345,21 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1242
1345
|
into.require(isTaskId(event.taskId), "event.taskEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
1243
1346
|
validateTaskOutcome(event.outcome, `${at}.outcome`, into);
|
|
1244
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
|
+
}
|
|
1245
1363
|
case "announcement":
|
|
1246
1364
|
into.filled(event.text, "event.announcement.text", `${at}.text`, "an announcement needs text");
|
|
1247
1365
|
into.timestamp(event.announcedAt, "event.announcement.announcedAt", `${at}.announcedAt`);
|
|
@@ -1400,6 +1518,35 @@ export function validateHostGuarantees(guarantees, path = "host.guarantees") {
|
|
|
1400
1518
|
}
|
|
1401
1519
|
return into.violations;
|
|
1402
1520
|
}
|
|
1521
|
+
/**
|
|
1522
|
+
* What the host reports of a leg it performed, as an adapter may validate it before forwarding:
|
|
1523
|
+
* a task, a step, when it began, how long so far if the host says, and an explicit end that
|
|
1524
|
+
* carries the final duration.
|
|
1525
|
+
*/
|
|
1526
|
+
export function validateHandlingReport(report, path = "handlingReport", manifest) {
|
|
1527
|
+
const into = new Collector();
|
|
1528
|
+
if (!isPlainObject(report)) {
|
|
1529
|
+
into.add("handlingReport.shape", path, "a handling report must be an object");
|
|
1530
|
+
return into.violations;
|
|
1531
|
+
}
|
|
1532
|
+
into.require(isTaskId(report.taskId), "handlingReport.taskId", `${path}.taskId`, "a report names the task");
|
|
1533
|
+
into.oneOf(report.step, HANDLING_STEPS, "handlingReport.step", `${path}.step`);
|
|
1534
|
+
into.timestamp(report.at, "handlingReport.at", `${path}.at`);
|
|
1535
|
+
if (report.seconds !== undefined) {
|
|
1536
|
+
into.require(isDurationSeconds(report.seconds) && report.seconds > 0, "handlingReport.seconds", `${path}.seconds`, "seconds must be a positive whole number; omit it rather than report nought");
|
|
1537
|
+
}
|
|
1538
|
+
if (report.ended !== undefined) {
|
|
1539
|
+
if (into.require(report.ended === true, "handlingReport.ended", `${path}.ended`, "ended is declared by presence, as true; a running leg omits it")) {
|
|
1540
|
+
into.require(report.seconds !== undefined, "handlingReport.ended.seconds", `${path}.seconds`, "an ended leg states its final duration");
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
else if (report.seconds !== undefined && manifest !== undefined) {
|
|
1544
|
+
// A running report crosses only to a provider that asked for one: begin and end are the whole
|
|
1545
|
+
// of what the rest receive.
|
|
1546
|
+
into.require(isPlainObject(manifest) && manifest.runningStepReports === true, "handlingReport.running.unexpected", `${path}.seconds`, "this provider takes begin and end only; a running report was never asked for");
|
|
1547
|
+
}
|
|
1548
|
+
return into.violations;
|
|
1549
|
+
}
|
|
1403
1550
|
export function validateHostReport(report, path = "host") {
|
|
1404
1551
|
const into = new Collector();
|
|
1405
1552
|
if (!isPlainObject(report)) {
|
|
@@ -1455,7 +1602,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1455
1602
|
// failure. A method added to `Connection` without a row here is a compile error at the call site.
|
|
1456
1603
|
const RESULT_STATUSES = {
|
|
1457
1604
|
execute: { success: "applied", failure: "failed" },
|
|
1458
|
-
dial: { success: "
|
|
1605
|
+
dial: { success: "dialling", failure: "failed" },
|
|
1459
1606
|
setCapacity: { success: "applied", failure: "failed" },
|
|
1460
1607
|
requestBreak: { success: "requested", failure: "failed" },
|
|
1461
1608
|
commitBreak: { success: "committed", failure: "failed" },
|
|
@@ -1465,6 +1612,7 @@ const RESULT_STATUSES = {
|
|
|
1465
1612
|
executeTeamConsult: { success: "applied", failure: "failed" },
|
|
1466
1613
|
openMedia: { success: "opened", failure: "unavailable" },
|
|
1467
1614
|
setPreference: { success: "applied", failure: "failed" },
|
|
1615
|
+
recordStep: { success: "recorded", failure: "failed" },
|
|
1468
1616
|
executeTeamPolicy: { success: "applied", failure: "failed" },
|
|
1469
1617
|
};
|
|
1470
1618
|
/**
|
|
@@ -1473,9 +1621,12 @@ const RESULT_STATUSES = {
|
|
|
1473
1621
|
* shows the agent what it says. A status the method does not answer, a failure status without a
|
|
1474
1622
|
* failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
|
|
1475
1623
|
*/
|
|
1476
|
-
export function validateResult(result, method, path = "result") {
|
|
1624
|
+
export function validateResult(result, method, path = "result", dialId) {
|
|
1477
1625
|
const into = new Collector();
|
|
1478
|
-
|
|
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];
|
|
1479
1630
|
if (!isPlainObject(result)) {
|
|
1480
1631
|
into.add("result.shape", path, `${method} must answer an object`);
|
|
1481
1632
|
return into.violations;
|
|
@@ -1485,6 +1636,14 @@ export function validateResult(result, method, path = "result") {
|
|
|
1485
1636
|
if (method === "openMedia") {
|
|
1486
1637
|
into.require(isPlainObject(result.session), "result.session", `${path}.session`, "opened carries the media session");
|
|
1487
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
|
+
}
|
|
1488
1647
|
}
|
|
1489
1648
|
else if (result.status === statuses.failure) {
|
|
1490
1649
|
if (result.failure === undefined) {
|