@xema/omni-protocol 0.1.82 → 0.1.84
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 +81 -35
- package/dist/index.js +7 -7
- package/dist/testing.d.ts +10 -7
- package/dist/testing.js +93 -86
- package/dist/validation.d.ts +34 -1
- package/dist/validation.js +368 -92
- package/guide.md +511 -172
- 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 });
|
|
@@ -85,7 +85,7 @@ const MEMBER_BREAKS = membersOf({
|
|
|
85
85
|
const OFFERABLE_PHASES = membersOf({
|
|
86
86
|
pending: true,
|
|
87
87
|
});
|
|
88
|
-
const PREVIEW_DEADLINES = membersOf({ calls: true,
|
|
88
|
+
const PREVIEW_DEADLINES = membersOf({ calls: true, "host-calls": true, waits: true });
|
|
89
89
|
const PHONES = membersOf({ softphone: true, deskPhone: true });
|
|
90
90
|
const TEAM_CAPABILITIES = membersOf({ breakControl: true, leadAssistControl: true, policyControl: true, monitorControl: true });
|
|
91
91
|
const MONITOR_MODES = membersOf({ monitor: true, whisper: true, barge: true });
|
|
@@ -395,6 +395,78 @@ export function validatePhone(value, manifest, path = "context.phone") {
|
|
|
395
395
|
function manifestDials(manifest) {
|
|
396
396
|
return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
|
|
397
397
|
}
|
|
398
|
+
/** Undefined explicitly means unavailable; a returned estimate is not a clock guarantee. */
|
|
399
|
+
export function validateProviderTimeEstimate(value, scope, path = "estimate") {
|
|
400
|
+
const into = new Collector();
|
|
401
|
+
if (!isPlainObject(scope)) {
|
|
402
|
+
into.add("timeEstimate.scope", path, "provider/login scope is required");
|
|
403
|
+
return into.violations;
|
|
404
|
+
}
|
|
405
|
+
for (const key of ["providerId", "loginId"])
|
|
406
|
+
into.filled(scope[key], "timeEstimate.scope", `scope.${key}`, "explicit provider/login scope is required");
|
|
407
|
+
if (value === undefined)
|
|
408
|
+
return into.violations;
|
|
409
|
+
if (!isPlainObject(value)) {
|
|
410
|
+
into.add("timeEstimate.shape", path, "expected a provider time estimate or undefined");
|
|
411
|
+
return into.violations;
|
|
412
|
+
}
|
|
413
|
+
for (const key of ["providerId", "loginId"])
|
|
414
|
+
into.require(value[key] === scope[key], "timeEstimate.scope", `${path}.${key}`, "estimate must match the requested provider/login");
|
|
415
|
+
into.timestamp(value.at, "timeEstimate.at", `${path}.at`);
|
|
416
|
+
into.filled(value.clockId, "timeEstimate.clock", `${path}.clockId`, "estimate must identify its clock domain");
|
|
417
|
+
if (value.uncertaintyMs !== undefined)
|
|
418
|
+
into.require(typeof value.uncertaintyMs === "number" && Number.isFinite(value.uncertaintyMs) && value.uncertaintyMs >= 0, "timeEstimate.uncertainty", `${path}.uncertaintyMs`, "estimated uncertainty must be finite and nonnegative");
|
|
419
|
+
for (const key of Object.keys(value))
|
|
420
|
+
into.require(["providerId", "loginId", "at", "clockId", "uncertaintyMs"].includes(key), "timeEstimate.field", `${path}.${key}`, "unsupported estimate field");
|
|
421
|
+
return into.violations;
|
|
422
|
+
}
|
|
423
|
+
/** Validate host-local periodic clock-check configuration. Omission disables checking. */
|
|
424
|
+
export function validateProviderTimeCheckPolicy(value, path = "timeCheck") {
|
|
425
|
+
const into = new Collector();
|
|
426
|
+
if (value === undefined)
|
|
427
|
+
return into.violations;
|
|
428
|
+
if (!isPlainObject(value)) {
|
|
429
|
+
into.add("timeCheck.policy.shape", path, "expected an explicit clock-check policy");
|
|
430
|
+
return into.violations;
|
|
431
|
+
}
|
|
432
|
+
for (const field of ["intervalMs", "timeoutMs", "maxRoundTripMs", "maxSampleAgeMs"]) {
|
|
433
|
+
into.require(typeof value[field] === "number" && Number.isSafeInteger(value[field]) && value[field] > 0, "timeCheck.policy.duration", `${path}.${field}`, "expected positive safe integer milliseconds");
|
|
434
|
+
}
|
|
435
|
+
for (const key of Object.keys(value))
|
|
436
|
+
into.require(["intervalMs", "timeoutMs", "maxRoundTripMs", "maxSampleAgeMs"].includes(key), "timeCheck.policy.field", `${path}.${key}`, "unsupported policy field");
|
|
437
|
+
if (!into.violations.length)
|
|
438
|
+
into.require(value.maxRoundTripMs <= value.timeoutMs && value.timeoutMs <= value.intervalMs, "timeCheck.policy.order", path, "maxRoundTripMs <= timeoutMs <= intervalMs is required");
|
|
439
|
+
return into.violations;
|
|
440
|
+
}
|
|
441
|
+
export function validateProviderTimeCheckRequest(value, path = "request") {
|
|
442
|
+
const into = new Collector();
|
|
443
|
+
if (!isPlainObject(value)) {
|
|
444
|
+
into.add("timeCheck.request.shape", path, "expected a clock-check request");
|
|
445
|
+
return into.violations;
|
|
446
|
+
}
|
|
447
|
+
into.filled(value.requestId, "timeCheck.request.id", `${path}.requestId`, "a check needs a fresh request ID");
|
|
448
|
+
for (const key of Object.keys(value))
|
|
449
|
+
into.require(key === "requestId", "timeCheck.request.field", `${path}.${key}`, "unsupported request field");
|
|
450
|
+
return into.violations;
|
|
451
|
+
}
|
|
452
|
+
/** Checks response shape/correlation, not source-clock accuracy, round-trip delay or freshness. */
|
|
453
|
+
export function validateProviderTimeCheckResult(value, request, loginId, path = "result") {
|
|
454
|
+
const into = new Collector();
|
|
455
|
+
into.violations.push(...validateProviderTimeCheckRequest(request));
|
|
456
|
+
into.filled(loginId, "timeCheck.login", "loginId", "current authenticated login is required");
|
|
457
|
+
if (!isPlainObject(value)) {
|
|
458
|
+
into.add("timeCheck.result.shape", path, "expected a clock-check response");
|
|
459
|
+
return into.violations;
|
|
460
|
+
}
|
|
461
|
+
into.filled(value.requestId, "timeCheck.result.id", `${path}.requestId`, "response must identify its request");
|
|
462
|
+
into.require(isPlainObject(request) && value.requestId === request.requestId, "timeCheck.result.request", `${path}.requestId`, "response must match the outstanding check");
|
|
463
|
+
into.require(value.loginId === loginId, "timeCheck.result.login", `${path}.loginId`, "response belongs to the current login");
|
|
464
|
+
into.filled(value.clockId, "timeCheck.result.clock", `${path}.clockId`, "provider clock domain/incarnation is required");
|
|
465
|
+
into.timestamp(value.providerTime, "timeCheck.result.time", `${path}.providerTime`);
|
|
466
|
+
for (const key of Object.keys(value))
|
|
467
|
+
into.require(["requestId", "loginId", "clockId", "providerTime"].includes(key), "timeCheck.result.field", `${path}.${key}`, "unsupported response field");
|
|
468
|
+
return into.violations;
|
|
469
|
+
}
|
|
398
470
|
export function validateManifest(manifest, path = "manifest") {
|
|
399
471
|
const into = new Collector();
|
|
400
472
|
if (!isPlainObject(manifest)) {
|
|
@@ -430,6 +502,10 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
430
502
|
}
|
|
431
503
|
if (channelValid)
|
|
432
504
|
validateIdleCapabilities(manifest.idleCapabilities, manifest.channel, `${path}.idleCapabilities`, into);
|
|
505
|
+
if (manifest.timestampAuthority !== undefined)
|
|
506
|
+
into.require(manifest.timestampAuthority === "provider", "manifest.timestampAuthority", `${path}.timestampAuthority`, "provider timestamp authority must be explicit; omission makes no trust promise");
|
|
507
|
+
if (manifest.timeCheck !== undefined)
|
|
508
|
+
into.require(manifest.timeCheck === true, "manifest.timeCheck", `${path}.timeCheck`, "declare true or omit unsupported clock checks");
|
|
433
509
|
validateDialOutcomes(manifest, `${path}.dialOutcomes`, into);
|
|
434
510
|
validatePhones(manifest, `${path}.phones`, into);
|
|
435
511
|
if (manifest.phaseLabels !== undefined) {
|
|
@@ -446,7 +522,8 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
446
522
|
if (manifest.runningStepReports !== undefined) {
|
|
447
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");
|
|
448
524
|
}
|
|
449
|
-
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");
|
|
450
527
|
if (manifest.orgLevels !== undefined) {
|
|
451
528
|
if (!Array.isArray(manifest.orgLevels)) {
|
|
452
529
|
into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
|
|
@@ -803,81 +880,82 @@ function validateTaskAttributes(value, path, into, levels) {
|
|
|
803
880
|
}
|
|
804
881
|
});
|
|
805
882
|
}
|
|
806
|
-
function
|
|
883
|
+
function validateInteractionHistory(value, path, into, task = {}) {
|
|
807
884
|
if (value === undefined)
|
|
808
885
|
return;
|
|
809
886
|
if (!isPlainObject(value)) {
|
|
810
|
-
into.add("task.
|
|
887
|
+
into.add("task.interactionHistory.shape", path, "interactionHistory must be an object with its steps when present");
|
|
811
888
|
return;
|
|
812
889
|
}
|
|
813
890
|
// What the record adds up to before this agent: each total present when the provider knows it
|
|
814
891
|
// and absent when it does not, never a plausible nought.
|
|
815
|
-
|
|
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"]) {
|
|
816
894
|
if (value[field] !== undefined) {
|
|
817
|
-
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`);
|
|
818
896
|
}
|
|
819
897
|
}
|
|
820
898
|
if (value.transfers !== undefined) {
|
|
821
|
-
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");
|
|
822
900
|
}
|
|
823
901
|
if (!Array.isArray(value.steps)) {
|
|
824
|
-
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");
|
|
825
903
|
return;
|
|
826
904
|
}
|
|
827
905
|
let previous;
|
|
828
906
|
value.steps.forEach((entry, index) => {
|
|
829
907
|
const at = `${path}.steps[${index}]`;
|
|
830
908
|
if (!isPlainObject(entry)) {
|
|
831
|
-
into.add("task.
|
|
909
|
+
into.add("task.interactionHistory.entry", at, "each interaction step must be an object");
|
|
832
910
|
return;
|
|
833
911
|
}
|
|
834
|
-
into.oneOf(entry.step,
|
|
835
|
-
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`)) {
|
|
836
914
|
// The record is one entry per occurrence, oldest first: a second hold is a second entry
|
|
837
915
|
// after the first, never a revision of it or an entry filed out of its turn.
|
|
838
916
|
const instant = Date.parse(entry.at);
|
|
839
917
|
if (previous !== undefined && instant < previous) {
|
|
840
|
-
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");
|
|
841
919
|
}
|
|
842
920
|
previous = instant;
|
|
843
921
|
}
|
|
844
922
|
if (entry.seconds !== undefined) {
|
|
845
923
|
// Omitted while a leg is still running. Nought is a claim that it took no time.
|
|
846
|
-
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");
|
|
847
925
|
}
|
|
848
926
|
else {
|
|
849
927
|
// An open leg is one still running, and the task says whether it can be: a hold runs only while
|
|
850
928
|
// the task is paused, and a mute only while its media is up. An open entry after that is a leg
|
|
851
929
|
// nobody closed, which reads exactly like a leg running now.
|
|
852
930
|
if (entry.step === "held") {
|
|
853
|
-
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`);
|
|
854
932
|
}
|
|
855
933
|
if (entry.step === "muted") {
|
|
856
|
-
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");
|
|
857
935
|
}
|
|
858
936
|
}
|
|
859
937
|
// A muted entry carries whose the silence was, as the host reported it; no other step has it.
|
|
860
938
|
// And it names the agent: the host has exactly one, the provider knows who, so an unattributed
|
|
861
939
|
// muted leg is a record that dropped a fact it held rather than one it could not establish.
|
|
862
940
|
if (entry.step === "muted") {
|
|
863
|
-
into.oneOf(entry.mutedBy, MUTED_BY, "task.
|
|
864
|
-
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");
|
|
865
943
|
}
|
|
866
|
-
else if (
|
|
867
|
-
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");
|
|
868
946
|
}
|
|
869
947
|
if (entry.by !== undefined) {
|
|
870
|
-
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");
|
|
871
949
|
// On `queued` nobody takes part, so there is nothing to name.
|
|
872
|
-
into.require(entry.step !== "queued", "task.
|
|
950
|
+
into.require(entry.step !== "queued", "task.interactionHistory.by.unexpected", `${at}.by`, "a queued step names nobody");
|
|
873
951
|
}
|
|
874
952
|
// A step that dialled says where to and, when a host placed it, which dial; no other step dialled.
|
|
875
|
-
const dialled = typeof entry.step === "string" &&
|
|
953
|
+
const dialled = typeof entry.step === "string" && INTERACTION_STEPS_THAT_DIAL.includes(entry.step);
|
|
876
954
|
for (const field of ["dialId", "destinationId"]) {
|
|
877
955
|
if (entry[field] === undefined)
|
|
878
956
|
continue;
|
|
879
|
-
if (into.filled(entry[field], `task.
|
|
880
|
-
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`);
|
|
881
959
|
}
|
|
882
960
|
}
|
|
883
961
|
});
|
|
@@ -885,9 +963,9 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
885
963
|
/** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
|
|
886
964
|
const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
|
|
887
965
|
/** Real-time media is a voice affair, and its state is one of two words. */
|
|
888
|
-
/**
|
|
889
|
-
function
|
|
890
|
-
return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" &&
|
|
966
|
+
/** A host dial, or explicitly provider-triggered preview dial, is ringing the party. */
|
|
967
|
+
function partyRingingBeforeWork(task) {
|
|
968
|
+
return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" && (isFilled(entry.dialId) || (task.phase === "preview" && task.atDeadline === "calls" && entry.dialId === undefined)));
|
|
891
969
|
}
|
|
892
970
|
function validateTaskMedia(task, value, channel, phase, path, into) {
|
|
893
971
|
if (value === undefined)
|
|
@@ -897,9 +975,9 @@ function validateTaskMedia(task, value, channel, phase, path, into) {
|
|
|
897
975
|
if (into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)) {
|
|
898
976
|
// Nothing is acquired while pending, and a preview has placed no call: media names a task whose
|
|
899
977
|
// work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
into.require(!WORK_NOT_BEGUN.includes(phase) ||
|
|
978
|
+
// Ring-back may precede answer for a host dial or an explicitly provider-triggered preview.
|
|
979
|
+
// A deadline alone is never evidence that media started.
|
|
980
|
+
into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingBeforeWork(task), "task.media.beforeWork", path, `a ${describeValue(phase)} task has no media: audio arrives once its work has begun, or once an evidenced host/provider preview dial is ringing its party`);
|
|
903
981
|
}
|
|
904
982
|
}
|
|
905
983
|
const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
|
|
@@ -1043,6 +1121,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1043
1121
|
into.add("task.shape", path, "a task must be an object");
|
|
1044
1122
|
return;
|
|
1045
1123
|
}
|
|
1124
|
+
into.require(!Object.hasOwn(task, "handlingHistory"), "task.interactionHistory.renamed", `${path}.handlingHistory`, "use interactionHistory; the former field is not accepted");
|
|
1046
1125
|
into.require(isTaskId(task.id), "task.id", `${path}.id`, "a task needs a non-empty id");
|
|
1047
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");
|
|
1048
1127
|
into.filled(task.title, "task.title", `${path}.title`, "a task needs a title");
|
|
@@ -1063,7 +1142,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1063
1142
|
into.timestamp(task.previewEndsAt, "task.preview.previewEndsAt", `${path}.previewEndsAt`);
|
|
1064
1143
|
}
|
|
1065
1144
|
if (task.atDeadline === undefined) {
|
|
1066
|
-
into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what
|
|
1145
|
+
into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what happens at it: provider calls, host calls, or waits for the agent -- an agent counting down has to know which");
|
|
1067
1146
|
}
|
|
1068
1147
|
else {
|
|
1069
1148
|
into.oneOf(task.atDeadline, PREVIEW_DEADLINES, "task.preview.atDeadline", `${path}.atDeadline`);
|
|
@@ -1129,19 +1208,16 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1129
1208
|
}
|
|
1130
1209
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
1131
1210
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
|
|
1132
|
-
|
|
1211
|
+
validateInteractionHistory(task.interactionHistory, `${path}.interactionHistory`, into, { phase: task.phase, media: task.media });
|
|
1133
1212
|
validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
// A completing task's call is over: its media has ended, or it never started. Audio still stated
|
|
1138
|
-
// as started on a task in wrap-up is a call whose audio never ended -- the customer heard through
|
|
1139
|
-
// the agent's notes -- and a task stating it contradicts itself.
|
|
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.
|
|
1140
1216
|
if (task.phase === "completing" && task.media === "started") {
|
|
1141
|
-
into.add("task.media.completing", `${path}.media`, "a completing task's media has ended or never started
|
|
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");
|
|
1142
1218
|
}
|
|
1143
1219
|
if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
|
|
1144
|
-
into.add("task.onCall.ended", `${path}.onCall`, `
|
|
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`);
|
|
1145
1221
|
}
|
|
1146
1222
|
validateTaskMedia(task, task.media, context.channel, task.phase, `${path}.media`, into);
|
|
1147
1223
|
validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
|
|
@@ -1235,6 +1311,184 @@ function validateImposedBreak(value, path, into) {
|
|
|
1235
1311
|
into.add("break.imposed.endsAutomatically", `${path}.endsAutomatically`, "an imposed break must say whether it ends automatically");
|
|
1236
1312
|
}
|
|
1237
1313
|
}
|
|
1314
|
+
/** Checks break status against the full currently retained task set, after a transaction. */
|
|
1315
|
+
export function validateBreakStatus(state, tasks, path = "snapshot") {
|
|
1316
|
+
const into = new Collector();
|
|
1317
|
+
validateBreakState(state, `${path}.break`, into);
|
|
1318
|
+
into.require(Array.isArray(tasks), "break.tasks.shape", `${path}.tasks`, "the complete current task list is required");
|
|
1319
|
+
if (Array.isArray(tasks))
|
|
1320
|
+
tasks.forEach((task, index) => {
|
|
1321
|
+
into.require(isPlainObject(task), "break.task.shape", `${path}.tasks[${index}]`, "each retained task must be an object");
|
|
1322
|
+
});
|
|
1323
|
+
// A break in effect begins when the work ends, so it holds no task. A snapshot reporting both
|
|
1324
|
+
// describes a state the agent cannot be in, whichever half is stale. The one exception is a
|
|
1325
|
+
// lead listening to a call during a break that is work of another sort -- coaching,
|
|
1326
|
+
// administrative, training -- and on no other.
|
|
1327
|
+
// The converse: a committed break with nothing outstanding has begun. starting-after-task beside
|
|
1328
|
+
// no task is a break waiting on work that does not exist, and an empty list reading as "still
|
|
1329
|
+
// finishing" is the plausible nought.
|
|
1330
|
+
ruleEvaluated("break.starting-after-task.tasks");
|
|
1331
|
+
if (isPlainObject(state) && state.approval === "starting-after-task"
|
|
1332
|
+
&& Array.isArray(tasks) && tasks.length === 0) {
|
|
1333
|
+
into.add("break.starting-after-task.tasks", `${path}.break.approval`, "a break starting after the task waits on a task, and the snapshot carries none: with nothing outstanding the break is in-effect");
|
|
1334
|
+
}
|
|
1335
|
+
if (isPlainObject(state) && state.approval === "in-effect"
|
|
1336
|
+
&& Array.isArray(tasks) && tasks.length > 0) {
|
|
1337
|
+
const listening = tasks.every((task) => isPlainObject(task) && task.monitoring !== undefined);
|
|
1338
|
+
if (!listening) {
|
|
1339
|
+
into.add("break.in-effect.tasks", `${path}.tasks`, "a break in effect holds no task: it begins when the work ends, and until then the state is starting-after-task");
|
|
1340
|
+
}
|
|
1341
|
+
else {
|
|
1342
|
+
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
1343
|
+
const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
|
|
1344
|
+
const kind = isPlainObject(active) ? active.kind : undefined;
|
|
1345
|
+
into.require(typeof kind === "string" && MONITORING_BREAK_KINDS.includes(kind), "snapshot.monitoring.break", `${path}.tasks`, `a lead listens during a ${MONITORING_BREAK_KINDS.join(", ")} break and no other; the active reason is ${kind === undefined ? "unstated" : describeValue(kind)}`);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
return into.violations;
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Checks lead break dispatch using current authentication, transport, roster (`team`) and,
|
|
1352
|
+
* for place/release, the target's full `memberBreak`. The provider must authorize the target
|
|
1353
|
+
* and recheck the decision atomically; a roster is not authority to act after it has changed.
|
|
1354
|
+
*/
|
|
1355
|
+
export function validateTeamBreakCommand(request, context, path = "teamBreakCommand") {
|
|
1356
|
+
const into = new Collector();
|
|
1357
|
+
if (!isPlainObject(context) || !isPlainObject(request) || !isPlainObject(request.command)) {
|
|
1358
|
+
into.add("team.break.command.shape", path, "a command and current authentication/transport/team context are required");
|
|
1359
|
+
return into.violations;
|
|
1360
|
+
}
|
|
1361
|
+
const auth = isPlainObject(context.authentication) ? context.authentication : {};
|
|
1362
|
+
into.violations.push(...validateAuthenticationState(auth, `${path}.authentication`));
|
|
1363
|
+
const caps = isPlainObject(auth.capabilities) ? auth.capabilities : {};
|
|
1364
|
+
into.require((auth.status === "authenticated" || auth.status === "refreshing") && context.transport === "active", "team.break.command.connection", path, "lead commands require a live login and active transport");
|
|
1365
|
+
into.require(isPlainObject(caps.team) && caps.team.breakControl === true, "team.break.command.capability", path, "the login must declare team.breakControl");
|
|
1366
|
+
const command = request.command;
|
|
1367
|
+
const allowed = {
|
|
1368
|
+
decide: ["type", "memberId", "decision", "reason"], policy: ["type", "policy"],
|
|
1369
|
+
place: ["type", "memberId", "reasonId", "reason"], release: ["type", "memberId"],
|
|
1370
|
+
};
|
|
1371
|
+
const fields = typeof command.type === "string" && Object.hasOwn(allowed, command.type) ? allowed[command.type] : undefined;
|
|
1372
|
+
if (!fields) {
|
|
1373
|
+
into.add("team.break.command.type", path, "unknown lead break command");
|
|
1374
|
+
return into.violations;
|
|
1375
|
+
}
|
|
1376
|
+
for (const key of Object.keys(request))
|
|
1377
|
+
into.require(key === "command", "team.break.request.field", `${path}.${key}`, "only command is supported");
|
|
1378
|
+
for (const key of Object.keys(command))
|
|
1379
|
+
into.require(fields.includes(key), "team.break.command.field", `${path}.${key}`, "field is not supported for this command");
|
|
1380
|
+
if (command.reason !== undefined)
|
|
1381
|
+
into.filled(command.reason, "team.break.command.reason", path, "reason must not be empty");
|
|
1382
|
+
if (command.type === "policy") {
|
|
1383
|
+
into.require(["ask", "auto-approve", "suspended"].includes(command.policy), "team.break.command.policy", path, "unknown break policy");
|
|
1384
|
+
return into.violations;
|
|
1385
|
+
}
|
|
1386
|
+
into.filled(command.memberId, "team.break.command.member", path, "name the target member");
|
|
1387
|
+
const team = isPlainObject(context.team) ? context.team : {};
|
|
1388
|
+
const members = Array.isArray(team.members) ? team.members : [];
|
|
1389
|
+
const member = members.find((m) => isPlainObject(m) && m.id === command.memberId);
|
|
1390
|
+
const self = isPlainObject(auth.identity) ? auth.identity.id : undefined;
|
|
1391
|
+
into.require(isPlainObject(member) && command.memberId !== self, "team.break.command.member", path, "the target must be another member of the current authorized roster");
|
|
1392
|
+
if (command.type === "decide") {
|
|
1393
|
+
into.require(command.decision === "granted" || command.decision === "denied", "team.break.command.decision", path, "decide granted or denied");
|
|
1394
|
+
into.require(isPlainObject(member) && member.break === "awaiting-decision", "team.break.command.awaiting", path, "decide only a currently awaiting-decision request");
|
|
1395
|
+
}
|
|
1396
|
+
else {
|
|
1397
|
+
validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
|
|
1398
|
+
const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
|
|
1399
|
+
if (command.type === "release")
|
|
1400
|
+
into.require(state.imposed !== undefined && (state.approval === "in-effect" || state.approval === "starting-after-task"), "team.break.command.release", path, "release a currently imposed break");
|
|
1401
|
+
if (command.type === "place") {
|
|
1402
|
+
if (command.reasonId !== undefined)
|
|
1403
|
+
into.filled(command.reasonId, "team.break.command.reasonId", path, "reasonId must not be empty");
|
|
1404
|
+
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
1405
|
+
into.require(state.reasons === undefined ? command.reasonId === undefined : reasons.some((r) => isPlainObject(r) && r.id === command.reasonId), "team.break.command.reasonId", path, "place uses the target's currently published reason codes");
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return into.violations;
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Validate immediately before dispatch against current provider state. Context must contain
|
|
1412
|
+
* the current authentication and transport. This cannot fence a concurrent backend change;
|
|
1413
|
+
* the provider must recheck atomically. Result validation never replaces authoritative state.
|
|
1414
|
+
*/
|
|
1415
|
+
export function validateBreakCommand(method, request, state, context, path = "breakCommand") {
|
|
1416
|
+
const into = new Collector();
|
|
1417
|
+
validateBreakState(state, `${path}.state`, into);
|
|
1418
|
+
if (!isPlainObject(context)) {
|
|
1419
|
+
into.add("break.command.context", path, "current authentication and transport are required");
|
|
1420
|
+
return into.violations;
|
|
1421
|
+
}
|
|
1422
|
+
into.violations.push(...validateAuthenticationState(context.authentication, `${path}.authentication`));
|
|
1423
|
+
const auth = isPlainObject(context.authentication) ? context.authentication : {};
|
|
1424
|
+
into.require(auth.status === "authenticated" || auth.status === "refreshing", "break.command.authentication", path, "break commands require a live authenticated login");
|
|
1425
|
+
into.require(isPlainObject(auth.capabilities) && auth.capabilities.breaks === true, "break.command.capability", path, "the login must declare breaks");
|
|
1426
|
+
into.require(context.transport === "active", "break.command.transport", path, "break commands require active transport");
|
|
1427
|
+
if (!["requestBreak", "commitBreak", "cancelBreak", "endBreak"].includes(method)) {
|
|
1428
|
+
into.add("break.command.method", path, "unknown break method");
|
|
1429
|
+
return into.violations;
|
|
1430
|
+
}
|
|
1431
|
+
if (!isPlainObject(state) || into.violations.length)
|
|
1432
|
+
return into.violations;
|
|
1433
|
+
const approval = state.approval;
|
|
1434
|
+
if (method === "requestBreak") {
|
|
1435
|
+
into.require(approval === "not-requested", "break.command.request.pending", path, "a new request starts only from not-requested");
|
|
1436
|
+
if (!isPlainObject(request)) {
|
|
1437
|
+
into.add("break.request.shape", path, "requestBreak takes a request object");
|
|
1438
|
+
return into.violations;
|
|
1439
|
+
}
|
|
1440
|
+
for (const key of Object.keys(request))
|
|
1441
|
+
into.require(key === "reason" || key === "reasonId", "break.request.field", `${path}.${key}`, "a break request carries only reason and reasonId");
|
|
1442
|
+
for (const key of ["reason", "reasonId"])
|
|
1443
|
+
if (request[key] !== undefined)
|
|
1444
|
+
into.filled(request[key], `break.request.${key}`, `${path}.${key}`, "a supplied reason must not be empty");
|
|
1445
|
+
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
1446
|
+
const selected = reasons.find((r) => isPlainObject(r) && r.id === request.reasonId);
|
|
1447
|
+
if (state.reasons !== undefined)
|
|
1448
|
+
into.require(selected !== undefined, "break.request.reasonId", path, "choose a currently published reasonId; free text is not a substitute");
|
|
1449
|
+
else
|
|
1450
|
+
into.require(request.reasonId === undefined, "break.request.reasonId.unexpected", path, "no reason codes were published");
|
|
1451
|
+
into.require(state.mayAsk === true || (isPlainObject(selected) && selected.alwaysAvailable === true), "break.request.mayAsk", path, "asking is disabled except for the selected alwaysAvailable reason");
|
|
1452
|
+
}
|
|
1453
|
+
else {
|
|
1454
|
+
into.require(request === undefined, "break.command.arguments", path, "this break method takes no arguments");
|
|
1455
|
+
if (method === "commitBreak")
|
|
1456
|
+
into.require(approval === "granted" || approval === "starting-after-task" || approval === "in-effect", "break.command.commit.grant", path, "commit requires a grant; repeated committed state is idempotent");
|
|
1457
|
+
if (method === "cancelBreak")
|
|
1458
|
+
into.require(approval === "awaiting-decision" || approval === "granted", "break.command.cancel.precommit", path, "cancel only a pre-commit request; a raced commit requires commit recovery");
|
|
1459
|
+
if (method === "endBreak") {
|
|
1460
|
+
into.require(approval === "starting-after-task" || approval === "in-effect", "break.command.end.started", path, "end a committed break, including a returning provider still finishing work");
|
|
1461
|
+
into.require(state.imposed === undefined, "break.command.end.imposed", path, "an agent cannot end an imposed break; an authorized lead releases it");
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
return into.violations;
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Checks two complete break states within one provider/login's ordered event stream.
|
|
1468
|
+
* Call before replacing accepted state; report violations and reconcile on failure.
|
|
1469
|
+
* This is not a freshness check: validate a fresh authoritative snapshot separately and
|
|
1470
|
+
* use it as a new baseline, never as an event transition. Source ordering and connection
|
|
1471
|
+
* fencing remain required; neither timestamps nor approval rank identify a break attempt.
|
|
1472
|
+
*/
|
|
1473
|
+
export function validateBreakTransition(before, after, path = "break") {
|
|
1474
|
+
const into = new Collector();
|
|
1475
|
+
validateBreakState(before, `${path}.before`, into);
|
|
1476
|
+
validateBreakState(after, `${path}.after`, into);
|
|
1477
|
+
if (into.violations.length || !isPlainObject(before) || !isPlainObject(after))
|
|
1478
|
+
return into.violations;
|
|
1479
|
+
const from = before.approval;
|
|
1480
|
+
const to = after.approval;
|
|
1481
|
+
const committed = to === "starting-after-task" || to === "in-effect";
|
|
1482
|
+
if (committed && (from === "not-requested" || from === "awaiting-decision") && after.imposed === undefined) {
|
|
1483
|
+
into.add("stream.breakState.commitBeforeGrant", `${path}.after.approval`, `${to} follows a commit, and a commit follows granted; the break stood at ${from}`);
|
|
1484
|
+
}
|
|
1485
|
+
const backwards = (from === "in-effect" && (to === "awaiting-decision" || to === "granted" || to === "starting-after-task")) ||
|
|
1486
|
+
(from === "starting-after-task" && (to === "awaiting-decision" || to === "granted")) ||
|
|
1487
|
+
(from === "granted" && to === "awaiting-decision");
|
|
1488
|
+
if (backwards)
|
|
1489
|
+
into.add("stream.breakState.backwards", `${path}.after.approval`, `a break does not go from ${from} back to ${to}; a new request passes through not-requested`);
|
|
1490
|
+
return into.violations;
|
|
1491
|
+
}
|
|
1238
1492
|
function validateBreakState(value, path, into) {
|
|
1239
1493
|
if (!isPlainObject(value)) {
|
|
1240
1494
|
into.add("break.shape", path, "break state must be an object");
|
|
@@ -1417,7 +1671,6 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1417
1671
|
&& context.loginId !== undefined) {
|
|
1418
1672
|
into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${describeValue(snapshot.loginId)} on a login whose session is ${context.loginId}`);
|
|
1419
1673
|
}
|
|
1420
|
-
validateBreakState(snapshot.break, `${path}.break`, into);
|
|
1421
1674
|
// The count is the provider's confirmation of how much work it answered with. Stated, never
|
|
1422
1675
|
// inferred: an unanswered or blank state lacks it, and cannot pass as a confirmed empty.
|
|
1423
1676
|
if (into.require(typeof snapshot.taskCount === "number" && Number.isInteger(snapshot.taskCount) && snapshot.taskCount >= 0, "snapshot.taskCount", `${path}.taskCount`, "a snapshot states its task count: a whole number, zero or more")
|
|
@@ -1459,32 +1712,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1459
1712
|
}
|
|
1460
1713
|
});
|
|
1461
1714
|
}
|
|
1462
|
-
|
|
1463
|
-
// describes a state the agent cannot be in, whichever half is stale. The one exception is a
|
|
1464
|
-
// lead listening to a call during a break that is work of another sort -- coaching,
|
|
1465
|
-
// administrative, training -- and on no other.
|
|
1466
|
-
// The converse: a committed break with nothing outstanding has begun. starting-after-task beside
|
|
1467
|
-
// no task is a break waiting on work that does not exist, and an empty list reading as "still
|
|
1468
|
-
// finishing" is the plausible nought.
|
|
1469
|
-
ruleEvaluated("break.starting-after-task.tasks");
|
|
1470
|
-
if (isPlainObject(snapshot.break) && snapshot.break.approval === "starting-after-task"
|
|
1471
|
-
&& Array.isArray(snapshot.tasks) && snapshot.tasks.length === 0) {
|
|
1472
|
-
into.add("break.starting-after-task.tasks", `${path}.break.approval`, "a break starting after the task waits on a task, and the snapshot carries none: with nothing outstanding the break is in-effect");
|
|
1473
|
-
}
|
|
1474
|
-
if (isPlainObject(snapshot.break) && snapshot.break.approval === "in-effect"
|
|
1475
|
-
&& Array.isArray(snapshot.tasks) && snapshot.tasks.length > 0) {
|
|
1476
|
-
const listening = snapshot.tasks.every((task) => isPlainObject(task) && task.monitoring !== undefined);
|
|
1477
|
-
if (!listening) {
|
|
1478
|
-
into.add("break.in-effect.tasks", `${path}.tasks`, "a break in effect holds no task: it begins when the work ends, and until then the state is starting-after-task");
|
|
1479
|
-
}
|
|
1480
|
-
else {
|
|
1481
|
-
const state = snapshot.break;
|
|
1482
|
-
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
1483
|
-
const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
|
|
1484
|
-
const kind = isPlainObject(active) ? active.kind : undefined;
|
|
1485
|
-
into.require(typeof kind === "string" && MONITORING_BREAK_KINDS.includes(kind), "snapshot.monitoring.break", `${path}.tasks`, `a lead listens during a ${MONITORING_BREAK_KINDS.join(", ")} break and no other; the active reason is ${kind === undefined ? "unstated" : describeValue(kind)}`);
|
|
1486
|
-
}
|
|
1487
|
-
}
|
|
1715
|
+
into.violations.push(...validateBreakStatus(snapshot.break, snapshot.tasks, path));
|
|
1488
1716
|
// Presence is the permission, and it cuts both ways: data a provider never declared a
|
|
1489
1717
|
// capability for is data Omni would show against a control the agent does not have.
|
|
1490
1718
|
const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
|
|
@@ -1904,34 +2132,34 @@ export function validateHostGuarantees(guarantees, path = "host.guarantees") {
|
|
|
1904
2132
|
* a task, a step, when it began, how long so far if the host says, and an explicit end that
|
|
1905
2133
|
* carries the final duration.
|
|
1906
2134
|
*/
|
|
1907
|
-
export function
|
|
2135
|
+
export function validateInteractionReport(report, path = "interactionReport", manifest) {
|
|
1908
2136
|
const into = new Collector();
|
|
1909
2137
|
if (!isPlainObject(report)) {
|
|
1910
|
-
into.add("
|
|
2138
|
+
into.add("interactionReport.shape", path, "an interaction report must be an object");
|
|
1911
2139
|
return into.violations;
|
|
1912
2140
|
}
|
|
1913
|
-
into.require(isTaskId(report.taskId), "
|
|
1914
|
-
into.require(isAllocationId(report.allocationId), "
|
|
1915
|
-
into.oneOf(report.step,
|
|
1916
|
-
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`);
|
|
1917
2145
|
// A muted leg says whose the silence was; no other leg has anyone to name for it.
|
|
1918
2146
|
if (report.step === "muted")
|
|
1919
|
-
into.oneOf(report.mutedBy, MUTED_BY, "
|
|
1920
|
-
else if (
|
|
1921
|
-
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");
|
|
1922
2150
|
}
|
|
1923
2151
|
if (report.seconds !== undefined) {
|
|
1924
|
-
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");
|
|
1925
2153
|
}
|
|
1926
2154
|
if (report.ended !== undefined) {
|
|
1927
|
-
if (into.require(report.ended === true, "
|
|
1928
|
-
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");
|
|
1929
2157
|
}
|
|
1930
2158
|
}
|
|
1931
2159
|
else if (report.seconds !== undefined && manifest !== undefined) {
|
|
1932
2160
|
// A running report crosses only to a provider that asked for one: begin and end are the whole
|
|
1933
2161
|
// of what the rest receive.
|
|
1934
|
-
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");
|
|
1935
2163
|
}
|
|
1936
2164
|
return into.violations;
|
|
1937
2165
|
}
|
|
@@ -2020,6 +2248,22 @@ export function validateHostReport(report, path = "host") {
|
|
|
2020
2248
|
const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
|
|
2021
2249
|
const LEAD_ASSIST_ACTIONS = ["request", "cancel", "take-over", "leave"];
|
|
2022
2250
|
const CONFERENCE_ACTIONS = ["add", "remove"];
|
|
2251
|
+
/** Validate the exact interaction/allocation target before provider command dispatch. */
|
|
2252
|
+
export function validateTaskCommandRequest(request, task, path = "request", taskContext) {
|
|
2253
|
+
const into = new Collector();
|
|
2254
|
+
if (!isPlainObject(request) || !isPlainObject(task)) {
|
|
2255
|
+
into.add("command.request.shape", path, "a request and the current published task are required");
|
|
2256
|
+
return into.violations;
|
|
2257
|
+
}
|
|
2258
|
+
into.filled(request.taskId, "command.request.taskId", `${path}.taskId`, "name the interaction task");
|
|
2259
|
+
into.filled(request.allocationId, "command.request.allocationId", `${path}.allocationId`, "name its allocation");
|
|
2260
|
+
into.require(request.taskId === task.id, "command.request.taskId.mismatch", `${path}.taskId`, "the command must target this exact interaction");
|
|
2261
|
+
into.require(request.allocationId === task.allocationId, "command.request.allocationId.mismatch", `${path}.allocationId`, "the command must target this exact allocation");
|
|
2262
|
+
for (const key of Object.keys(request))
|
|
2263
|
+
into.require(["taskId", "allocationId", "command"].includes(key), "command.request.field", `${path}.${key}`, "unsupported task command request field");
|
|
2264
|
+
into.violations.push(...validateTaskCommand(request.command, task, `${path}.command`, taskContext));
|
|
2265
|
+
return into.violations;
|
|
2266
|
+
}
|
|
2023
2267
|
/**
|
|
2024
2268
|
* What a command needs to be issuable, checked against the task it names: the capability the
|
|
2025
2269
|
* guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
|
|
@@ -2044,6 +2288,36 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2044
2288
|
if (type !== "custom" && !into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${describeValue(type)}` : `a ${channel} task has no ${describeValue(type)} command`)) {
|
|
2045
2289
|
return into.violations;
|
|
2046
2290
|
}
|
|
2291
|
+
// Built-in commands carry only their declared fields; custom controls own their payload.
|
|
2292
|
+
// Recording already applies its action-specific field check below.
|
|
2293
|
+
if (type !== "custom" && type !== "recording") {
|
|
2294
|
+
let fields = ["type"];
|
|
2295
|
+
switch (type) {
|
|
2296
|
+
case "call":
|
|
2297
|
+
case "connect-back":
|
|
2298
|
+
fields.push("dialId");
|
|
2299
|
+
break;
|
|
2300
|
+
case "complete":
|
|
2301
|
+
fields.push("disposition", "notes");
|
|
2302
|
+
break;
|
|
2303
|
+
case "transfer":
|
|
2304
|
+
fields.push("action");
|
|
2305
|
+
if (command.action === "cold" || command.action === "warm")
|
|
2306
|
+
fields.push("dialId", "destinationId");
|
|
2307
|
+
break;
|
|
2308
|
+
case "lead-assist":
|
|
2309
|
+
fields.push("action");
|
|
2310
|
+
if (command.action === "request")
|
|
2311
|
+
fields.push("note");
|
|
2312
|
+
break;
|
|
2313
|
+
case "conference":
|
|
2314
|
+
fields.push("action", "destinationId");
|
|
2315
|
+
fields.push(command.action === "add" ? "dialId" : "party");
|
|
2316
|
+
break;
|
|
2317
|
+
}
|
|
2318
|
+
for (const key of Object.keys(command))
|
|
2319
|
+
into.require(fields.includes(key), "command.field", `${path}.${key}`, "field is not supported for this command");
|
|
2320
|
+
}
|
|
2047
2321
|
const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
|
|
2048
2322
|
switch (type) {
|
|
2049
2323
|
case "call":
|
|
@@ -2141,9 +2415,9 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2141
2415
|
};
|
|
2142
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)}`);
|
|
2143
2417
|
// A control on the contact acts on a contact being handled. Before in-progress there is nothing
|
|
2144
|
-
// to act on yet, and in completing
|
|
2145
|
-
//
|
|
2146
|
-
const
|
|
2418
|
+
// to act on yet, and in completing this agent's interaction has ended. The caller and other
|
|
2419
|
+
// channels may continue elsewhere; this task's phase no longer permits these controls.
|
|
2420
|
+
const interaction = () => inPhase("command.phase.interaction", "in-progress", "paused");
|
|
2147
2421
|
// A destination is one the directory offered: the id Omni sends is the id the provider published.
|
|
2148
2422
|
const listed = (name) => {
|
|
2149
2423
|
const declared = capabilities[name];
|
|
@@ -2166,11 +2440,11 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2166
2440
|
case "resume":
|
|
2167
2441
|
case "pause":
|
|
2168
2442
|
offered("hold");
|
|
2169
|
-
|
|
2443
|
+
interaction();
|
|
2170
2444
|
break;
|
|
2171
2445
|
case "end-call":
|
|
2172
2446
|
offered("endCall");
|
|
2173
|
-
|
|
2447
|
+
interaction();
|
|
2174
2448
|
break;
|
|
2175
2449
|
case "recording": {
|
|
2176
2450
|
if (!offered("recording"))
|
|
@@ -2179,11 +2453,11 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2179
2453
|
const state = isPlainObject(task.recording) ? task.recording.provider : undefined;
|
|
2180
2454
|
into.violations.push(...validateRecordingCommandState(command, policy, state, path));
|
|
2181
2455
|
if (command.action === "start" || command.action === "resume") {
|
|
2182
|
-
|
|
2456
|
+
interaction();
|
|
2183
2457
|
into.require(task.media === "started", "recording.request.phase", path, "capture needs live media");
|
|
2184
2458
|
}
|
|
2185
2459
|
else
|
|
2186
|
-
inPhase("command.phase.
|
|
2460
|
+
inPhase("command.phase.interaction", "in-progress", "paused", "completing");
|
|
2187
2461
|
break;
|
|
2188
2462
|
}
|
|
2189
2463
|
case "connect-back":
|
|
@@ -2191,7 +2465,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2191
2465
|
inPhase("command.phase.completing", "completing");
|
|
2192
2466
|
break;
|
|
2193
2467
|
case "transfer":
|
|
2194
|
-
|
|
2468
|
+
interaction();
|
|
2195
2469
|
if (command.action === "cold") {
|
|
2196
2470
|
if (offered("coldTransfer"))
|
|
2197
2471
|
listed("coldTransfer");
|
|
@@ -2205,7 +2479,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2205
2479
|
}
|
|
2206
2480
|
break;
|
|
2207
2481
|
case "lead-assist":
|
|
2208
|
-
|
|
2482
|
+
interaction();
|
|
2209
2483
|
if (command.action === "request" || command.action === "cancel") {
|
|
2210
2484
|
offered("leadAssist");
|
|
2211
2485
|
if (command.action === "cancel") {
|
|
@@ -2217,7 +2491,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2217
2491
|
}
|
|
2218
2492
|
break;
|
|
2219
2493
|
case "conference": {
|
|
2220
|
-
|
|
2494
|
+
interaction();
|
|
2221
2495
|
const conferencing = offered("conference");
|
|
2222
2496
|
if (conferencing && command.action === "add")
|
|
2223
2497
|
listed("conference");
|
|
@@ -2327,6 +2601,8 @@ export function validateResult(result, method, path = "result", dialId) {
|
|
|
2327
2601
|
else {
|
|
2328
2602
|
into.require(result.dialId === undefined, "result.dialId.unexpected", `${path}.dialId`, `${statuses.success} dialled nothing and names no dial`);
|
|
2329
2603
|
}
|
|
2604
|
+
if (method === "recordStep")
|
|
2605
|
+
into.timestamp(result.at, "result.recordStep.at", `${path}.at`);
|
|
2330
2606
|
}
|
|
2331
2607
|
else if (statuses.failure !== undefined && result.status === statuses.failure) {
|
|
2332
2608
|
if (result.failure === undefined) {
|