@xema/omni-protocol 0.1.83 → 0.1.85
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 +38 -38
- package/dist/index.js +7 -7
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +40 -40
- package/dist/validation.d.ts +2 -2
- package/dist/validation.js +94 -91
- package/guide.md +204 -158
- 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 { RECORDING_ACTIONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, MONITORING_BREAK_KINDS, BROWSER_ISOLATION_SCHEMES,
|
|
12
|
+
import { RECORDING_ACTIONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, MONITORING_BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, INTERACTION_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, 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") {
|
|
@@ -57,7 +57,7 @@ const BREAK_APPROVALS = membersOf({
|
|
|
57
57
|
const TEAM_AVAILABILITIES = membersOf({
|
|
58
58
|
ready: true, "on-task": true, "on-break": true, reserved: true, "signed-out": true,
|
|
59
59
|
});
|
|
60
|
-
const
|
|
60
|
+
const INTERACTION_STEPS = membersOf({
|
|
61
61
|
queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
|
|
62
62
|
});
|
|
63
63
|
const CUSTOM_UI_CONTROLS = membersOf({ button: true, toggle: true, "menu-item": true });
|
|
@@ -98,11 +98,11 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
|
|
|
98
98
|
/** The capabilities each channel arm of `TaskCapabilities` declares, keyed off the type itself. */
|
|
99
99
|
const TASK_CAPABILITIES = {
|
|
100
100
|
voice: membersOf({
|
|
101
|
-
browsers: true,
|
|
101
|
+
browsers: true, outcomes: true, custom: true, decline: true, hold: true,
|
|
102
102
|
endCall: true, connectBack: true, coldTransfer: true, warmTransfer: true, leadAssist: true, conference: true, recording: true,
|
|
103
103
|
}),
|
|
104
|
-
chat: membersOf({ browsers: true,
|
|
105
|
-
email: membersOf({ browsers: true,
|
|
104
|
+
chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true }),
|
|
105
|
+
email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true }),
|
|
106
106
|
};
|
|
107
107
|
// The published list and the type's keys are the same set, or one of them is wrong.
|
|
108
108
|
true;
|
|
@@ -522,7 +522,8 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
522
522
|
if (manifest.runningStepReports !== undefined) {
|
|
523
523
|
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");
|
|
524
524
|
}
|
|
525
|
-
into.require(
|
|
525
|
+
into.require(!Object.hasOwn(manifest, "disposalSettleMs"), "manifest.completionSettleMs.renamed", `${path}.disposalSettleMs`, "use completionSettleMs; the former field is not accepted");
|
|
526
|
+
into.require(typeof manifest.completionSettleMs === "number" && Number.isInteger(manifest.completionSettleMs) && manifest.completionSettleMs > 0, "manifest.completionSettleMs", `${path}.completionSettleMs`, "completionSettleMs is how long after an applied completion the task-ended is owed, a positive whole number of milliseconds, stated by every provider");
|
|
526
527
|
if (manifest.orgLevels !== undefined) {
|
|
527
528
|
if (!Array.isArray(manifest.orgLevels)) {
|
|
528
529
|
into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
|
|
@@ -606,41 +607,41 @@ function validateDestinationDirectory(value, path, into) {
|
|
|
606
607
|
into.filled(destination.label, "task.destination.label", `${at}.label`, "a destination needs a label");
|
|
607
608
|
});
|
|
608
609
|
}
|
|
609
|
-
function
|
|
610
|
+
function validateOutcomes(value, path, into) {
|
|
610
611
|
if (value === true)
|
|
611
612
|
return;
|
|
612
613
|
if (!isPlainObject(value)) {
|
|
613
|
-
into.add("task.
|
|
614
|
+
into.add("task.outcomes.shape", path, "must be true or an outcome policy");
|
|
614
615
|
return;
|
|
615
616
|
}
|
|
616
617
|
if (value.required !== undefined) {
|
|
617
|
-
into.require(typeof value.required === "boolean", "task.
|
|
618
|
+
into.require(typeof value.required === "boolean", "task.outcomes.required", `${path}.required`, "required must be a boolean when present");
|
|
618
619
|
}
|
|
619
620
|
// A code must be collected before completion, so there must be one to collect.
|
|
620
621
|
if (value.required === true && !(Array.isArray(value.codes) && value.codes.length > 0)) {
|
|
621
|
-
into.add("task.
|
|
622
|
+
into.add("task.outcomes.required.codes", `${path}.codes`, "a required outcome policy must publish at least one code");
|
|
622
623
|
}
|
|
623
624
|
if (value.notes !== undefined)
|
|
624
|
-
into.oneOf(value.notes, NOTES_RULES, "task.
|
|
625
|
+
into.oneOf(value.notes, NOTES_RULES, "task.outcomes.notes", `${path}.notes`);
|
|
625
626
|
if (value.codes === undefined)
|
|
626
627
|
return;
|
|
627
628
|
if (!Array.isArray(value.codes)) {
|
|
628
|
-
into.add("task.
|
|
629
|
+
into.add("task.outcomes.codes", `${path}.codes`, "codes must be an array when present");
|
|
629
630
|
return;
|
|
630
631
|
}
|
|
631
632
|
const seen = new Set();
|
|
632
633
|
value.codes.forEach((code, index) => {
|
|
633
634
|
const at = `${path}.codes[${index}]`;
|
|
634
635
|
if (!isPlainObject(code)) {
|
|
635
|
-
into.add("task.
|
|
636
|
+
into.add("task.outcome.shape", at, "each outcome code must be an object");
|
|
636
637
|
return;
|
|
637
638
|
}
|
|
638
|
-
if (into.filled(code.id, "task.
|
|
639
|
+
if (into.filled(code.id, "task.outcome.id", `${at}.id`, "an outcome code needs an id")) {
|
|
639
640
|
if (seen.has(code.id))
|
|
640
|
-
into.add("task.
|
|
641
|
+
into.add("task.outcome.unique", `${at}.id`, `duplicate outcome code: ${code.id}`);
|
|
641
642
|
seen.add(code.id);
|
|
642
643
|
}
|
|
643
|
-
into.filled(code.label, "task.
|
|
644
|
+
into.filled(code.label, "task.outcome.label", `${at}.label`, "an outcome code needs a label");
|
|
644
645
|
});
|
|
645
646
|
}
|
|
646
647
|
function validateCustomCapabilities(value, path, into) {
|
|
@@ -696,7 +697,7 @@ const DEFAULT_LEVEL_IDS = DEFAULT_LEVELS.map(level => level.id);
|
|
|
696
697
|
const POLICY_SETTINGS = membersOf({ on: true, off: true, person: true });
|
|
697
698
|
const POLICY_KEYS = new Set([
|
|
698
699
|
...TASK_CAPABILITIES.voice, ...TASK_CAPABILITIES.chat, ...TASK_CAPABILITIES.email, "dial",
|
|
699
|
-
].filter(name => name !== "browsers" && name !== "
|
|
700
|
+
].filter(name => name !== "browsers" && name !== "outcomes" && name !== "custom"));
|
|
700
701
|
const PERSON_SETTABLE = /^(hold|skill:.+)$/;
|
|
701
702
|
const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
|
|
702
703
|
/** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
|
|
@@ -879,81 +880,82 @@ function validateTaskAttributes(value, path, into, levels) {
|
|
|
879
880
|
}
|
|
880
881
|
});
|
|
881
882
|
}
|
|
882
|
-
function
|
|
883
|
+
function validateInteractionHistory(value, path, into, task = {}) {
|
|
883
884
|
if (value === undefined)
|
|
884
885
|
return;
|
|
885
886
|
if (!isPlainObject(value)) {
|
|
886
|
-
into.add("task.
|
|
887
|
+
into.add("task.interactionHistory.shape", path, "interactionHistory must be an object with its steps when present");
|
|
887
888
|
return;
|
|
888
889
|
}
|
|
889
890
|
// What the record adds up to before this agent: each total present when the provider knows it
|
|
890
891
|
// and absent when it does not, never a plausible nought.
|
|
891
|
-
|
|
892
|
+
into.require(!Object.hasOwn(value, "handleSeconds"), "task.interactionHistory.interactionSeconds.renamed", `${path}.handleSeconds`, "use interactionSeconds; the former field is not accepted");
|
|
893
|
+
for (const field of ["interactionSeconds", "holdSeconds", "queueSeconds"]) {
|
|
892
894
|
if (value[field] !== undefined) {
|
|
893
|
-
into.require(isDurationSeconds(value[field]), `task.
|
|
895
|
+
into.require(isDurationSeconds(value[field]), `task.interactionHistory.${field}`, `${path}.${field}`, `${field} must be a whole number of seconds, zero or more, or omitted when unknown`);
|
|
894
896
|
}
|
|
895
897
|
}
|
|
896
898
|
if (value.transfers !== undefined) {
|
|
897
|
-
into.require(typeof value.transfers === "number" && Number.isInteger(value.transfers) && value.transfers >= 0, "task.
|
|
899
|
+
into.require(typeof value.transfers === "number" && Number.isInteger(value.transfers) && value.transfers >= 0, "task.interactionHistory.transfers", `${path}.transfers`, "transfers must be a whole number, zero or more, or omitted when unknown");
|
|
898
900
|
}
|
|
899
901
|
if (!Array.isArray(value.steps)) {
|
|
900
|
-
into.add("task.
|
|
902
|
+
into.add("task.interactionHistory.steps.shape", `${path}.steps`, "interactionHistory carries its steps as an array, empty when the task has had none");
|
|
901
903
|
return;
|
|
902
904
|
}
|
|
903
905
|
let previous;
|
|
904
906
|
value.steps.forEach((entry, index) => {
|
|
905
907
|
const at = `${path}.steps[${index}]`;
|
|
906
908
|
if (!isPlainObject(entry)) {
|
|
907
|
-
into.add("task.
|
|
909
|
+
into.add("task.interactionHistory.entry", at, "each interaction step must be an object");
|
|
908
910
|
return;
|
|
909
911
|
}
|
|
910
|
-
into.oneOf(entry.step,
|
|
911
|
-
if (into.timestamp(entry.at, "task.
|
|
912
|
+
into.oneOf(entry.step, INTERACTION_STEPS, "task.interactionHistory.step", `${at}.step`);
|
|
913
|
+
if (into.timestamp(entry.at, "task.interactionHistory.at", `${at}.at`)) {
|
|
912
914
|
// The record is one entry per occurrence, oldest first: a second hold is a second entry
|
|
913
915
|
// after the first, never a revision of it or an entry filed out of its turn.
|
|
914
916
|
const instant = Date.parse(entry.at);
|
|
915
917
|
if (previous !== undefined && instant < previous) {
|
|
916
|
-
into.add("task.
|
|
918
|
+
into.add("task.interactionHistory.order", `${at}.at`, "interaction steps are oldest first; this entry is earlier than the one before it");
|
|
917
919
|
}
|
|
918
920
|
previous = instant;
|
|
919
921
|
}
|
|
920
922
|
if (entry.seconds !== undefined) {
|
|
921
923
|
// Omitted while a leg is still running. Nought is a claim that it took no time.
|
|
922
|
-
into.require(isDurationSeconds(entry.seconds) && entry.seconds > 0, "task.
|
|
924
|
+
into.require(isDurationSeconds(entry.seconds) && entry.seconds > 0, "task.interactionHistory.seconds", `${at}.seconds`, "seconds must be a positive whole number; omit it while the step is still running");
|
|
923
925
|
}
|
|
924
926
|
else {
|
|
925
927
|
// An open leg is one still running, and the task says whether it can be: a hold runs only while
|
|
926
928
|
// the task is paused, and a mute only while its media is up. An open entry after that is a leg
|
|
927
929
|
// nobody closed, which reads exactly like a leg running now.
|
|
928
930
|
if (entry.step === "held") {
|
|
929
|
-
into.require(task.phase === "paused", "task.
|
|
931
|
+
into.require(task.phase === "paused", "task.interactionHistory.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${describeValue(task.phase)}: the hold has ended, and its duration is stated`);
|
|
930
932
|
}
|
|
931
933
|
if (entry.step === "muted") {
|
|
932
|
-
into.require(task.media !== "ended" && task.phase !== "completing", "task.
|
|
934
|
+
into.require(task.media !== "ended" && task.phase !== "completing", "task.interactionHistory.muted.open", `${at}.seconds`, "a muted entry without seconds is still running, but this interaction's media has ended: the provider closes its open leg and states the duration; other channels may continue");
|
|
933
935
|
}
|
|
934
936
|
}
|
|
935
937
|
// A muted entry carries whose the silence was, as the host reported it; no other step has it.
|
|
936
938
|
// And it names the agent: the host has exactly one, the provider knows who, so an unattributed
|
|
937
939
|
// muted leg is a record that dropped a fact it held rather than one it could not establish.
|
|
938
940
|
if (entry.step === "muted") {
|
|
939
|
-
into.oneOf(entry.mutedBy, MUTED_BY, "task.
|
|
940
|
-
into.require(entry.by !== undefined, "task.
|
|
941
|
+
into.oneOf(entry.mutedBy, MUTED_BY, "task.interactionHistory.mutedBy", `${at}.mutedBy`);
|
|
942
|
+
into.require(entry.by !== undefined, "task.interactionHistory.muted.by", `${at}.by`, "a muted leg is attributed to the login's agent: the host reported it, and the provider knows who the host's agent is");
|
|
941
943
|
}
|
|
942
|
-
else if (
|
|
943
|
-
into.require(entry.mutedBy === undefined, "task.
|
|
944
|
+
else if (INTERACTION_STEPS.includes(entry.step)) {
|
|
945
|
+
into.require(entry.mutedBy === undefined, "task.interactionHistory.mutedBy.unexpected", `${at}.mutedBy`, "only a muted step says who silenced the microphone");
|
|
944
946
|
}
|
|
945
947
|
if (entry.by !== undefined) {
|
|
946
|
-
into.require(isUserId(entry.by), "task.
|
|
948
|
+
into.require(isUserId(entry.by), "task.interactionHistory.by", `${at}.by`, "by must be a non-empty user id; omit it when the person cannot be identified");
|
|
947
949
|
// On `queued` nobody takes part, so there is nothing to name.
|
|
948
|
-
into.require(entry.step !== "queued", "task.
|
|
950
|
+
into.require(entry.step !== "queued", "task.interactionHistory.by.unexpected", `${at}.by`, "a queued step names nobody");
|
|
949
951
|
}
|
|
950
952
|
// A step that dialled says where to and, when a host placed it, which dial; no other step dialled.
|
|
951
|
-
const dialled = typeof entry.step === "string" &&
|
|
953
|
+
const dialled = typeof entry.step === "string" && INTERACTION_STEPS_THAT_DIAL.includes(entry.step);
|
|
952
954
|
for (const field of ["dialId", "destinationId"]) {
|
|
953
955
|
if (entry[field] === undefined)
|
|
954
956
|
continue;
|
|
955
|
-
if (into.filled(entry[field], `task.
|
|
956
|
-
into.require(dialled, `task.
|
|
957
|
+
if (into.filled(entry[field], `task.interactionHistory.${field}`, `${at}.${field}`, `${field} must not be empty when present`)) {
|
|
958
|
+
into.require(dialled, `task.interactionHistory.${field}.unexpected`, `${at}.${field}`, `${describeValue(entry.step)} dialled nothing; ${field} belongs on transferred, conferenced, or unanswered`);
|
|
957
959
|
}
|
|
958
960
|
}
|
|
959
961
|
});
|
|
@@ -1119,6 +1121,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1119
1121
|
into.add("task.shape", path, "a task must be an object");
|
|
1120
1122
|
return;
|
|
1121
1123
|
}
|
|
1124
|
+
into.require(!Object.hasOwn(task, "handlingHistory"), "task.interactionHistory.renamed", `${path}.handlingHistory`, "use interactionHistory; the former field is not accepted");
|
|
1122
1125
|
into.require(isTaskId(task.id), "task.id", `${path}.id`, "a task needs a non-empty id");
|
|
1123
1126
|
into.require(isAllocationId(task.allocationId), "task.allocationId", `${path}.allocationId`, "a task names this life of itself: an allocation id minted once per offer, never reused for the life of the login");
|
|
1124
1127
|
into.filled(task.title, "task.title", `${path}.title`, "a task needs a title");
|
|
@@ -1205,16 +1208,16 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1205
1208
|
}
|
|
1206
1209
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
1207
1210
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
|
|
1208
|
-
|
|
1211
|
+
validateInteractionHistory(task.interactionHistory, `${path}.interactionHistory`, into, { phase: task.phase, media: task.media });
|
|
1209
1212
|
validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
|
|
1210
|
-
// Media and onCall describe this agent's
|
|
1211
|
-
// Once the
|
|
1212
|
-
// channels may remain connected under another
|
|
1213
|
+
// Media and onCall describe this agent's interaction, not the continuing caller journey.
|
|
1214
|
+
// Once the interaction's media ends or it enters wrap, its live room is cleared. Other
|
|
1215
|
+
// channels may remain connected under another interaction or in IVR/queue stages.
|
|
1213
1216
|
if (task.phase === "completing" && task.media === "started") {
|
|
1214
|
-
into.add("task.media.completing", `${path}.media`, "a completing task's
|
|
1217
|
+
into.add("task.media.completing", `${path}.media`, "a completing task's interaction media has ended or never started; it cannot still be started during wrap");
|
|
1215
1218
|
}
|
|
1216
1219
|
if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
|
|
1217
|
-
into.add("task.onCall.ended", `${path}.onCall`, `this
|
|
1220
|
+
into.add("task.onCall.ended", `${path}.onCall`, `this interaction is no longer connected (${task.phase === "completing" ? "the task is completing" : "its media ended"}); clear its onCall view without implying that other channels ended`);
|
|
1218
1221
|
}
|
|
1219
1222
|
validateTaskMedia(task, task.media, context.channel, task.phase, `${path}.media`, into);
|
|
1220
1223
|
validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
|
|
@@ -1257,9 +1260,9 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1257
1260
|
into.add("task.capability.dialOutcomes.required", `${path}.capabilities.${name}`, `${name} dials, and the manifest declares no dialOutcomes to say how a dial ends`);
|
|
1258
1261
|
}
|
|
1259
1262
|
// A control the queue could allow may stand locked in its place, saying whose. What the
|
|
1260
|
-
// queue provides -- browsers,
|
|
1263
|
+
// queue provides -- browsers, outcomes, custom controls -- is content, not a control.
|
|
1261
1264
|
if (isLocked(declared)) {
|
|
1262
|
-
if (into.require(name !== "browsers" && name !== "
|
|
1265
|
+
if (into.require(name !== "browsers" && name !== "outcomes" && name !== "custom", "task.capability.locked.unexpected", `${path}.capabilities.${name}`, `${name} is what the queue provides, not a control anyone locks`)) {
|
|
1263
1266
|
validateLockedInto(declared, "task.capability.locked", `${path}.capabilities.${name}`, context.levels, into);
|
|
1264
1267
|
}
|
|
1265
1268
|
continue;
|
|
@@ -1268,8 +1271,8 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1268
1271
|
case "recording":
|
|
1269
1272
|
into.violations.push(...validateRecordingPolicy(declared, `${path}.capabilities.recording`));
|
|
1270
1273
|
break;
|
|
1271
|
-
case "
|
|
1272
|
-
|
|
1274
|
+
case "outcomes":
|
|
1275
|
+
validateOutcomes(declared, `${path}.capabilities.outcomes`, into);
|
|
1273
1276
|
break;
|
|
1274
1277
|
case "custom":
|
|
1275
1278
|
validateCustomCapabilities(declared, `${path}.capabilities.custom`, into);
|
|
@@ -2129,34 +2132,34 @@ export function validateHostGuarantees(guarantees, path = "host.guarantees") {
|
|
|
2129
2132
|
* a task, a step, when it began, how long so far if the host says, and an explicit end that
|
|
2130
2133
|
* carries the final duration.
|
|
2131
2134
|
*/
|
|
2132
|
-
export function
|
|
2135
|
+
export function validateInteractionReport(report, path = "interactionReport", manifest) {
|
|
2133
2136
|
const into = new Collector();
|
|
2134
2137
|
if (!isPlainObject(report)) {
|
|
2135
|
-
into.add("
|
|
2138
|
+
into.add("interactionReport.shape", path, "an interaction report must be an object");
|
|
2136
2139
|
return into.violations;
|
|
2137
2140
|
}
|
|
2138
|
-
into.require(isTaskId(report.taskId), "
|
|
2139
|
-
into.require(isAllocationId(report.allocationId), "
|
|
2140
|
-
into.oneOf(report.step,
|
|
2141
|
-
into.timestamp(report.at, "
|
|
2141
|
+
into.require(isTaskId(report.taskId), "interactionReport.taskId", `${path}.taskId`, "a report names the task");
|
|
2142
|
+
into.require(isAllocationId(report.allocationId), "interactionReport.allocationId", `${path}.allocationId`, "a report names the task's allocation, so a late one never lands on the next life of the id");
|
|
2143
|
+
into.oneOf(report.step, INTERACTION_STEPS, "interactionReport.step", `${path}.step`);
|
|
2144
|
+
into.timestamp(report.at, "interactionReport.at", `${path}.at`);
|
|
2142
2145
|
// A muted leg says whose the silence was; no other leg has anyone to name for it.
|
|
2143
2146
|
if (report.step === "muted")
|
|
2144
|
-
into.oneOf(report.mutedBy, MUTED_BY, "
|
|
2145
|
-
else if (
|
|
2146
|
-
into.require(report.mutedBy === undefined, "
|
|
2147
|
+
into.oneOf(report.mutedBy, MUTED_BY, "interactionReport.mutedBy", `${path}.mutedBy`);
|
|
2148
|
+
else if (INTERACTION_STEPS.includes(report.step)) {
|
|
2149
|
+
into.require(report.mutedBy === undefined, "interactionReport.mutedBy.unexpected", `${path}.mutedBy`, "only a muted leg says who silenced the microphone");
|
|
2147
2150
|
}
|
|
2148
2151
|
if (report.seconds !== undefined) {
|
|
2149
|
-
into.require(isDurationSeconds(report.seconds) && report.seconds > 0, "
|
|
2152
|
+
into.require(isDurationSeconds(report.seconds) && report.seconds > 0, "interactionReport.seconds", `${path}.seconds`, "seconds must be a positive whole number; omit it rather than report nought");
|
|
2150
2153
|
}
|
|
2151
2154
|
if (report.ended !== undefined) {
|
|
2152
|
-
if (into.require(report.ended === true, "
|
|
2153
|
-
into.require(report.seconds !== undefined, "
|
|
2155
|
+
if (into.require(report.ended === true, "interactionReport.ended", `${path}.ended`, "ended is declared by presence, as true; a running leg omits it")) {
|
|
2156
|
+
into.require(report.seconds !== undefined, "interactionReport.ended.seconds", `${path}.seconds`, "an ended leg states its final duration");
|
|
2154
2157
|
}
|
|
2155
2158
|
}
|
|
2156
2159
|
else if (report.seconds !== undefined && manifest !== undefined) {
|
|
2157
2160
|
// A running report crosses only to a provider that asked for one: begin and end are the whole
|
|
2158
2161
|
// of what the rest receive.
|
|
2159
|
-
into.require(isPlainObject(manifest) && manifest.runningStepReports === true, "
|
|
2162
|
+
into.require(isPlainObject(manifest) && manifest.runningStepReports === true, "interactionReport.running.unexpected", `${path}.seconds`, "this provider takes begin and end only; a running report was never asked for");
|
|
2160
2163
|
}
|
|
2161
2164
|
return into.violations;
|
|
2162
2165
|
}
|
|
@@ -2245,16 +2248,16 @@ export function validateHostReport(report, path = "host") {
|
|
|
2245
2248
|
const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
|
|
2246
2249
|
const LEAD_ASSIST_ACTIONS = ["request", "cancel", "take-over", "leave"];
|
|
2247
2250
|
const CONFERENCE_ACTIONS = ["add", "remove"];
|
|
2248
|
-
/** Validate the exact
|
|
2251
|
+
/** Validate the exact interaction/allocation target before provider command dispatch. */
|
|
2249
2252
|
export function validateTaskCommandRequest(request, task, path = "request", taskContext) {
|
|
2250
2253
|
const into = new Collector();
|
|
2251
2254
|
if (!isPlainObject(request) || !isPlainObject(task)) {
|
|
2252
2255
|
into.add("command.request.shape", path, "a request and the current published task are required");
|
|
2253
2256
|
return into.violations;
|
|
2254
2257
|
}
|
|
2255
|
-
into.filled(request.taskId, "command.request.taskId", `${path}.taskId`, "name the
|
|
2258
|
+
into.filled(request.taskId, "command.request.taskId", `${path}.taskId`, "name the interaction task");
|
|
2256
2259
|
into.filled(request.allocationId, "command.request.allocationId", `${path}.allocationId`, "name its allocation");
|
|
2257
|
-
into.require(request.taskId === task.id, "command.request.taskId.mismatch", `${path}.taskId`, "the command must target this exact
|
|
2260
|
+
into.require(request.taskId === task.id, "command.request.taskId.mismatch", `${path}.taskId`, "the command must target this exact interaction");
|
|
2258
2261
|
into.require(request.allocationId === task.allocationId, "command.request.allocationId.mismatch", `${path}.allocationId`, "the command must target this exact allocation");
|
|
2259
2262
|
for (const key of Object.keys(request))
|
|
2260
2263
|
into.require(["taskId", "allocationId", "command"].includes(key), "command.request.field", `${path}.${key}`, "unsupported task command request field");
|
|
@@ -2295,7 +2298,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2295
2298
|
fields.push("dialId");
|
|
2296
2299
|
break;
|
|
2297
2300
|
case "complete":
|
|
2298
|
-
fields.push("
|
|
2301
|
+
fields.push("outcome", "notes");
|
|
2299
2302
|
break;
|
|
2300
2303
|
case "transfer":
|
|
2301
2304
|
fields.push("action");
|
|
@@ -2356,8 +2359,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2356
2359
|
into.violations.push(...validateRecordingCommandShape(command, "provider", path));
|
|
2357
2360
|
break;
|
|
2358
2361
|
case "complete":
|
|
2359
|
-
if (command.
|
|
2360
|
-
into.filled(command.
|
|
2362
|
+
if (command.outcome !== undefined)
|
|
2363
|
+
into.filled(command.outcome, "command.complete.outcome", `${path}.outcome`, "an outcome must not be empty when present");
|
|
2361
2364
|
if (command.notes !== undefined)
|
|
2362
2365
|
into.require(typeof command.notes === "string", "command.complete.notes", `${path}.notes`, "notes must be a string when present");
|
|
2363
2366
|
break;
|
|
@@ -2412,9 +2415,9 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2412
2415
|
};
|
|
2413
2416
|
const inPhase = (rule, ...phases) => into.require(typeof task.phase === "string" && phases.includes(task.phase), rule, path, `${describeValue(type)} belongs to ${phases.join(" or ")}, and the task is ${describeValue(task.phase)}`);
|
|
2414
2417
|
// A control on the contact acts on a contact being handled. Before in-progress there is nothing
|
|
2415
|
-
// to act on yet, and in completing this agent's
|
|
2418
|
+
// to act on yet, and in completing this agent's interaction has ended. The caller and other
|
|
2416
2419
|
// channels may continue elsewhere; this task's phase no longer permits these controls.
|
|
2417
|
-
const
|
|
2420
|
+
const interaction = () => inPhase("command.phase.interaction", "in-progress", "paused");
|
|
2418
2421
|
// A destination is one the directory offered: the id Omni sends is the id the provider published.
|
|
2419
2422
|
const listed = (name) => {
|
|
2420
2423
|
const declared = capabilities[name];
|
|
@@ -2437,11 +2440,11 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2437
2440
|
case "resume":
|
|
2438
2441
|
case "pause":
|
|
2439
2442
|
offered("hold");
|
|
2440
|
-
|
|
2443
|
+
interaction();
|
|
2441
2444
|
break;
|
|
2442
2445
|
case "end-call":
|
|
2443
2446
|
offered("endCall");
|
|
2444
|
-
|
|
2447
|
+
interaction();
|
|
2445
2448
|
break;
|
|
2446
2449
|
case "recording": {
|
|
2447
2450
|
if (!offered("recording"))
|
|
@@ -2450,11 +2453,11 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2450
2453
|
const state = isPlainObject(task.recording) ? task.recording.provider : undefined;
|
|
2451
2454
|
into.violations.push(...validateRecordingCommandState(command, policy, state, path));
|
|
2452
2455
|
if (command.action === "start" || command.action === "resume") {
|
|
2453
|
-
|
|
2456
|
+
interaction();
|
|
2454
2457
|
into.require(task.media === "started", "recording.request.phase", path, "capture needs live media");
|
|
2455
2458
|
}
|
|
2456
2459
|
else
|
|
2457
|
-
inPhase("command.phase.
|
|
2460
|
+
inPhase("command.phase.interaction", "in-progress", "paused", "completing");
|
|
2458
2461
|
break;
|
|
2459
2462
|
}
|
|
2460
2463
|
case "connect-back":
|
|
@@ -2462,7 +2465,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2462
2465
|
inPhase("command.phase.completing", "completing");
|
|
2463
2466
|
break;
|
|
2464
2467
|
case "transfer":
|
|
2465
|
-
|
|
2468
|
+
interaction();
|
|
2466
2469
|
if (command.action === "cold") {
|
|
2467
2470
|
if (offered("coldTransfer"))
|
|
2468
2471
|
listed("coldTransfer");
|
|
@@ -2476,7 +2479,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2476
2479
|
}
|
|
2477
2480
|
break;
|
|
2478
2481
|
case "lead-assist":
|
|
2479
|
-
|
|
2482
|
+
interaction();
|
|
2480
2483
|
if (command.action === "request" || command.action === "cancel") {
|
|
2481
2484
|
offered("leadAssist");
|
|
2482
2485
|
if (command.action === "cancel") {
|
|
@@ -2488,7 +2491,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2488
2491
|
}
|
|
2489
2492
|
break;
|
|
2490
2493
|
case "conference": {
|
|
2491
|
-
|
|
2494
|
+
interaction();
|
|
2492
2495
|
const conferencing = offered("conference");
|
|
2493
2496
|
if (conferencing && command.action === "add")
|
|
2494
2497
|
listed("conference");
|
|
@@ -2506,27 +2509,27 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2506
2509
|
}
|
|
2507
2510
|
case "complete": {
|
|
2508
2511
|
into.require(task.completionMode === "agent-command", "command.complete.mode", path, "complete belongs to agent-command; a provider-automatic task completes itself");
|
|
2509
|
-
// What travels with complete is what the
|
|
2512
|
+
// What travels with complete is what the outcomes capability published: a code from its
|
|
2510
2513
|
// list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
|
|
2511
|
-
const
|
|
2512
|
-
if (
|
|
2513
|
-
into.require(command.
|
|
2514
|
-
into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task declares no
|
|
2514
|
+
const outcomes = capabilities.outcomes;
|
|
2515
|
+
if (outcomes === undefined) {
|
|
2516
|
+
into.require(command.outcome === undefined, "command.complete.outcome.unexpected", `${path}.outcome`, "the task declares no outcomes; complete travels with no code");
|
|
2517
|
+
into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task declares no outcomes; complete travels with no notes");
|
|
2515
2518
|
}
|
|
2516
|
-
else if (
|
|
2517
|
-
into.require(command.
|
|
2519
|
+
else if (outcomes === true) {
|
|
2520
|
+
into.require(command.outcome === undefined, "command.complete.outcome.unexpected", `${path}.outcome`, "a bare outcomes control publishes no code, so complete carries none");
|
|
2518
2521
|
}
|
|
2519
|
-
else if (isPlainObject(
|
|
2520
|
-
if (Array.isArray(
|
|
2521
|
-
into.require(
|
|
2522
|
+
else if (isPlainObject(outcomes)) {
|
|
2523
|
+
if (Array.isArray(outcomes.codes) && command.outcome !== undefined) {
|
|
2524
|
+
into.require(outcomes.codes.some(code => isPlainObject(code) && code.id === command.outcome), "command.complete.outcome.unknown", `${path}.outcome`, `${describeValue(command.outcome)} is not a code the task published`);
|
|
2522
2525
|
}
|
|
2523
|
-
if (
|
|
2524
|
-
into.require(command.
|
|
2526
|
+
if (outcomes.required === true) {
|
|
2527
|
+
into.require(command.outcome !== undefined, "command.complete.outcome.required", `${path}.outcome`, "the task requires an outcome and complete carries none");
|
|
2525
2528
|
}
|
|
2526
|
-
if (
|
|
2529
|
+
if (outcomes.notes === "none") {
|
|
2527
2530
|
into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task takes no notes");
|
|
2528
2531
|
}
|
|
2529
|
-
else if (
|
|
2532
|
+
else if (outcomes.notes === "required") {
|
|
2530
2533
|
into.require(typeof command.notes === "string" && command.notes.length > 0, "command.complete.notes.required", `${path}.notes`, "the task requires notes and complete carries none");
|
|
2531
2534
|
}
|
|
2532
2535
|
}
|