@xema/omni-protocol 0.1.66 → 0.1.68
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 +36 -3
- package/dist/index.js +2 -2
- package/dist/testing.d.ts +20 -1
- package/dist/testing.js +233 -22
- package/dist/validation.d.ts +16 -2
- package/dist/validation.js +149 -22
- package/guide.md +103 -36
- package/package.json +1 -1
package/dist/validation.js
CHANGED
|
@@ -131,14 +131,33 @@ const isDurationSeconds = (value) => typeof value === "number" && Number.isInteg
|
|
|
131
131
|
/** Opaque, non-empty, provider-issued. Never parsed and never compared across providers. */
|
|
132
132
|
const isUserId = isFilled;
|
|
133
133
|
const isTaskId = isFilled;
|
|
134
|
+
const isAllocationId = isFilled;
|
|
135
|
+
const ruleListeners = new Set();
|
|
136
|
+
/**
|
|
137
|
+
* Hears every rule a validator evaluates, pass or fail, until the returned function is called. A
|
|
138
|
+
* green run can then say which rules it looked at rather than only which subjects it reached: a
|
|
139
|
+
* rule never evaluated is a visible gap, not a pass. The harness registers one for each exercise.
|
|
140
|
+
*/
|
|
141
|
+
export function observeRules(listener) {
|
|
142
|
+
ruleListeners.add(listener);
|
|
143
|
+
return () => { ruleListeners.delete(listener); };
|
|
144
|
+
}
|
|
145
|
+
/** Says a rule was evaluated, for code that decides outside a collector: the stream, the drive. */
|
|
146
|
+
export function ruleEvaluated(...rules) {
|
|
147
|
+
for (const rule of rules)
|
|
148
|
+
for (const listener of ruleListeners)
|
|
149
|
+
listener(rule);
|
|
150
|
+
}
|
|
134
151
|
class Collector {
|
|
135
152
|
violations = [];
|
|
136
153
|
add(rule, path, message) {
|
|
154
|
+
ruleEvaluated(rule);
|
|
137
155
|
this.violations.push({ rule, path, message });
|
|
138
156
|
}
|
|
139
157
|
require(condition, rule, path, message) {
|
|
158
|
+
ruleEvaluated(rule);
|
|
140
159
|
if (!condition)
|
|
141
|
-
this.
|
|
160
|
+
this.violations.push({ rule, path, message });
|
|
142
161
|
return Boolean(condition);
|
|
143
162
|
}
|
|
144
163
|
filled(value, rule, path, message) {
|
|
@@ -198,7 +217,7 @@ export function validateScheduledActivity(activity, path = "scheduledActivity")
|
|
|
198
217
|
validateScheduledActivityInto(activity, path, into);
|
|
199
218
|
return into.violations;
|
|
200
219
|
}
|
|
201
|
-
function validateScheduledActivityInto(activity, path, into) {
|
|
220
|
+
function validateScheduledActivityInto(activity, path, into, levels) {
|
|
202
221
|
if (!isPlainObject(activity)) {
|
|
203
222
|
into.add("activity.shape", path, "a scheduled activity must be an object");
|
|
204
223
|
return;
|
|
@@ -213,7 +232,7 @@ function validateScheduledActivityInto(activity, path, into) {
|
|
|
213
232
|
}
|
|
214
233
|
}
|
|
215
234
|
if (activity.party !== undefined)
|
|
216
|
-
validateContactInto(activity.party, `${path}.party`, into);
|
|
235
|
+
validateContactInto(activity.party, `${path}.party`, into, levels);
|
|
217
236
|
validateAttributes(activity.attributes, `${path}.attributes`, into);
|
|
218
237
|
}
|
|
219
238
|
// ---------------------------------------------------------------------------
|
|
@@ -672,7 +691,7 @@ function validateBrowsers(value, path, into) {
|
|
|
672
691
|
}
|
|
673
692
|
});
|
|
674
693
|
}
|
|
675
|
-
function validateTaskAttributes(value, path, into) {
|
|
694
|
+
function validateTaskAttributes(value, path, into, levels) {
|
|
676
695
|
if (value === undefined)
|
|
677
696
|
return;
|
|
678
697
|
if (!Array.isArray(value)) {
|
|
@@ -699,7 +718,7 @@ function validateTaskAttributes(value, path, into) {
|
|
|
699
718
|
into.require(typeof attribute.value === "string", "task.attribute.text", `${at}.value`, "a text attribute needs a string value");
|
|
700
719
|
break;
|
|
701
720
|
case "contact":
|
|
702
|
-
validateContactInto(attribute.
|
|
721
|
+
validateContactInto(attribute.party, `${at}.party`, into, levels);
|
|
703
722
|
break;
|
|
704
723
|
case "timestamp":
|
|
705
724
|
into.timestamp(attribute.at, "task.attribute.timestamp", `${at}.at`);
|
|
@@ -791,13 +810,18 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
791
810
|
/** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
|
|
792
811
|
const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
|
|
793
812
|
/** Real-time media is a voice affair, and its state is one of two words. */
|
|
794
|
-
function validateTaskMedia(value, channel, path, into) {
|
|
813
|
+
function validateTaskMedia(value, channel, phase, path, into) {
|
|
795
814
|
if (value === undefined)
|
|
796
815
|
return;
|
|
797
816
|
if (!into.require(channel === "voice", "task.media.channel", path, `a ${channel} task carries no real-time media state`))
|
|
798
817
|
return;
|
|
799
|
-
into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)
|
|
818
|
+
if (into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)) {
|
|
819
|
+
// Nothing is acquired while pending, and a preview has placed no call: media names a task whose
|
|
820
|
+
// work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
|
|
821
|
+
into.require(!WORK_NOT_BEGUN.includes(phase), "task.media.beforeWork", path, `a ${String(phase)} task has no media: audio arrives once its work has begun`);
|
|
822
|
+
}
|
|
800
823
|
}
|
|
824
|
+
const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
|
|
801
825
|
/**
|
|
802
826
|
* Who is on the call, as the provider states it. Voice only. Each entry is a role with what that
|
|
803
827
|
* role needs and nothing another role would: a party or an agent dialled nowhere, so neither
|
|
@@ -845,9 +869,23 @@ function validateOnCall(value, channel, path, into) {
|
|
|
845
869
|
if (entry.label !== undefined)
|
|
846
870
|
into.filled(entry.label, "task.onCall.label", `${at}.label`, "a label must not be empty when present");
|
|
847
871
|
}
|
|
872
|
+
else if (role === "party" && (entry.dialId !== undefined || entry.stage !== undefined)) {
|
|
873
|
+
// The party is dialled again on the same task -- a connect-back the host placed, a callback the
|
|
874
|
+
// platform placed -- and then carries the stage the dial has reached; a dial with no stage is
|
|
875
|
+
// half a claim, and the host's dialId is present where a host placed it, as on any dialled entry.
|
|
876
|
+
into.require(entry.stage !== undefined, "task.onCall.party.dial", at, "a party being dialled again carries the stage the dial has reached; the host's dialId beside it where a host placed the dial");
|
|
877
|
+
if (entry.dialId !== undefined)
|
|
878
|
+
into.filled(entry.dialId, "task.onCall.dialId", `${at}.dialId`, "dialId must not be empty when present");
|
|
879
|
+
if (entry.stage !== undefined && into.oneOf(entry.stage, ON_CALL_STAGES, "task.onCall.stage", `${at}.stage`)) {
|
|
880
|
+
into.require(!(entry.stage === "ringing" && entry.held === true), "task.onCall.held.ringing", `${at}.held`, "nobody ringing is held; held belongs to somebody who has joined");
|
|
881
|
+
}
|
|
882
|
+
for (const field of ["destinationId", "label"]) {
|
|
883
|
+
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a party is not a directory item; ${field} belongs on consulted or conferenced`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
848
886
|
else {
|
|
849
887
|
for (const field of ["destinationId", "dialId", "label", "stage"]) {
|
|
850
|
-
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
|
|
888
|
+
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, role === "party" ? `a party carries ${field} on a connect-back alone, with its dial and stage together` : `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
|
|
851
889
|
}
|
|
852
890
|
}
|
|
853
891
|
});
|
|
@@ -904,6 +942,7 @@ function validateMonitoring(value, channel, path, into) {
|
|
|
904
942
|
}
|
|
905
943
|
into.require(isUserId(value.memberId), "task.monitoring.memberId", `${path}.memberId`, "a monitored call names the member on it");
|
|
906
944
|
into.require(isTaskId(value.taskId), "task.monitoring.taskId", `${path}.taskId`, "a monitored call names the member's task");
|
|
945
|
+
into.require(isAllocationId(value.allocationId), "task.monitoring.allocationId", `${path}.allocationId`, "a monitored call names the member's task by its allocation too");
|
|
907
946
|
into.oneOf(value.mode, MONITOR_MODES, "task.monitoring.mode", `${path}.mode`);
|
|
908
947
|
into.timestamp(value.since, "task.monitoring.since", `${path}.since`);
|
|
909
948
|
}
|
|
@@ -918,6 +957,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
918
957
|
return;
|
|
919
958
|
}
|
|
920
959
|
into.require(isTaskId(task.id), "task.id", `${path}.id`, "a task needs a non-empty id");
|
|
960
|
+
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");
|
|
921
961
|
into.filled(task.title, "task.title", `${path}.title`, "a task needs a title");
|
|
922
962
|
into.filled(task.taskType, "task.taskType", `${path}.taskType`, "a task needs a task type");
|
|
923
963
|
if (into.oneOf(task.phase, TASK_PHASES, "task.phase", `${path}.phase`) && task.phase === "preview") {
|
|
@@ -975,7 +1015,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
975
1015
|
if (task.party !== undefined)
|
|
976
1016
|
validateContactInto(task.party, `${path}.party`, into, context.levels);
|
|
977
1017
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
978
|
-
validateTaskAttributes(task.attributes, `${path}.attributes`, into);
|
|
1018
|
+
validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
|
|
979
1019
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
|
|
980
1020
|
validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
|
|
981
1021
|
// The room is who is on the call now, and a task outlives its call by the whole of wrap-up: a
|
|
@@ -984,7 +1024,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
984
1024
|
if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
|
|
985
1025
|
into.add("task.onCall.ended", `${path}.onCall`, `the call has ended (${task.phase === "completing" ? "the task is completing" : "its media ended"}) and onCall still names people on it: the room is who is on the call now, and now nobody is`);
|
|
986
1026
|
}
|
|
987
|
-
validateTaskMedia(task.media, context.channel, `${path}.media`, into);
|
|
1027
|
+
validateTaskMedia(task.media, context.channel, task.phase, `${path}.media`, into);
|
|
988
1028
|
validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
|
|
989
1029
|
validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
|
|
990
1030
|
validateMonitoring(task.monitoring, context.channel, `${path}.monitoring`, into);
|
|
@@ -1197,6 +1237,7 @@ function validateTeamRosterInto(roster, path, context, into) {
|
|
|
1197
1237
|
into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the roster carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
|
|
1198
1238
|
}
|
|
1199
1239
|
into.require(isTaskId(request.taskId), "team.request.taskId", `${at}.taskId`, "a request names the task the lead would join");
|
|
1240
|
+
into.require(isAllocationId(request.allocationId), "team.request.allocationId", `${at}.allocationId`, "a request names the task by its allocation too");
|
|
1200
1241
|
if (request.note !== undefined)
|
|
1201
1242
|
into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
|
|
1202
1243
|
into.timestamp(request.since, "team.request.since", `${at}.since`);
|
|
@@ -1320,7 +1361,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1320
1361
|
if (snapshot.contacts !== undefined) {
|
|
1321
1362
|
into.require(idle.contacts === true, "snapshot.contacts.capability", `${path}.contacts`, "contacts require the contacts idle capability");
|
|
1322
1363
|
if (Array.isArray(snapshot.contacts)) {
|
|
1323
|
-
snapshot.contacts.forEach((contact, index) => validateContactInto(contact, `${path}.contacts[${index}]`, into));
|
|
1364
|
+
snapshot.contacts.forEach((contact, index) => validateContactInto(contact, `${path}.contacts[${index}]`, into, levels));
|
|
1324
1365
|
}
|
|
1325
1366
|
else {
|
|
1326
1367
|
into.add("snapshot.contacts.shape", `${path}.contacts`, "contacts must be an array when present");
|
|
@@ -1331,7 +1372,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1331
1372
|
if (Array.isArray(snapshot.scheduledActivities)) {
|
|
1332
1373
|
const seen = new Set();
|
|
1333
1374
|
snapshot.scheduledActivities.forEach((activity, index) => {
|
|
1334
|
-
validateScheduledActivityInto(activity, `${path}.scheduledActivities[${index}]`, into);
|
|
1375
|
+
validateScheduledActivityInto(activity, `${path}.scheduledActivities[${index}]`, into, levels);
|
|
1335
1376
|
if (isPlainObject(activity) && isFilled(activity.id)) {
|
|
1336
1377
|
if (seen.has(activity.id)) {
|
|
1337
1378
|
into.add("activity.id.unique", `${path}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
|
|
@@ -1485,12 +1526,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1485
1526
|
break;
|
|
1486
1527
|
case "task-media-started":
|
|
1487
1528
|
into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
|
|
1529
|
+
into.require(isAllocationId(event.allocationId), "event.taskMediaStarted.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1488
1530
|
break;
|
|
1489
1531
|
case "task-media-ended":
|
|
1490
1532
|
into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
1533
|
+
into.require(isAllocationId(event.allocationId), "event.taskMediaEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1491
1534
|
break;
|
|
1492
1535
|
case "task-ended":
|
|
1493
1536
|
into.require(isTaskId(event.taskId), "event.taskEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
1537
|
+
into.require(isAllocationId(event.allocationId), "event.taskEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1494
1538
|
validateTaskOutcome(event.outcome, `${at}.outcome`, into);
|
|
1495
1539
|
break;
|
|
1496
1540
|
case "dial-outcome": {
|
|
@@ -1502,6 +1546,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1502
1546
|
}
|
|
1503
1547
|
if (event.taskId !== undefined)
|
|
1504
1548
|
into.require(isTaskId(event.taskId), "event.dialOutcome.taskId", `${at}.taskId`, "taskId must be a task id when present");
|
|
1549
|
+
allocationWithTask(event, "event.dialOutcome", at, into);
|
|
1505
1550
|
if (event.destinationId !== undefined)
|
|
1506
1551
|
into.filled(event.destinationId, "event.dialOutcome.destinationId", `${at}.destinationId`, "a destinationId must not be empty when present");
|
|
1507
1552
|
if (event.reason !== undefined)
|
|
@@ -1522,6 +1567,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1522
1567
|
into.filled(event.observed, "event.diagnostic.observed", `${at}.observed`, "a diagnostic states what was observed instead");
|
|
1523
1568
|
if (event.taskId !== undefined)
|
|
1524
1569
|
into.require(isTaskId(event.taskId), "event.diagnostic.taskId", `${at}.taskId`, "taskId must be a task id when present");
|
|
1570
|
+
allocationWithTask(event, "event.diagnostic", at, into);
|
|
1525
1571
|
break;
|
|
1526
1572
|
case "queue-summary":
|
|
1527
1573
|
validateQueueSummary(event.summary, `${at}.summary`, into);
|
|
@@ -1535,7 +1581,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1535
1581
|
into.add("event.contacts.shape", `${at}.contacts`, "contacts must be an array");
|
|
1536
1582
|
}
|
|
1537
1583
|
else {
|
|
1538
|
-
event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into));
|
|
1584
|
+
event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into, levels));
|
|
1539
1585
|
}
|
|
1540
1586
|
break;
|
|
1541
1587
|
case "calendar-updated":
|
|
@@ -1546,7 +1592,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1546
1592
|
else {
|
|
1547
1593
|
const ids = new Set();
|
|
1548
1594
|
event.scheduledActivities.forEach((activity, index) => {
|
|
1549
|
-
validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into);
|
|
1595
|
+
validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into, levels);
|
|
1550
1596
|
if (isPlainObject(activity) && isFilled(activity.id)) {
|
|
1551
1597
|
if (ids.has(activity.id))
|
|
1552
1598
|
into.add("activity.id.unique", `${at}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
|
|
@@ -1707,6 +1753,7 @@ export function validateHandlingReport(report, path = "handlingReport", manifest
|
|
|
1707
1753
|
return into.violations;
|
|
1708
1754
|
}
|
|
1709
1755
|
into.require(isTaskId(report.taskId), "handlingReport.taskId", `${path}.taskId`, "a report names the task");
|
|
1756
|
+
into.require(isAllocationId(report.allocationId), "handlingReport.allocationId", `${path}.allocationId`, "a report names the task's allocation, so a late one never lands on the next life of the id");
|
|
1710
1757
|
into.oneOf(report.step, HANDLING_STEPS, "handlingReport.step", `${path}.step`);
|
|
1711
1758
|
into.timestamp(report.at, "handlingReport.at", `${path}.at`);
|
|
1712
1759
|
// A muted leg says whose the silence was; no other leg has anyone to name for it.
|
|
@@ -1739,6 +1786,15 @@ function validateMutedBy(device, rule, path, into) {
|
|
|
1739
1786
|
into.require(device.mutedBy === undefined, `${rule}.mutedBy.unexpected`, `${path}.mutedBy`, "mutedBy says who silenced a device that is not flowing; a flowing one names nobody");
|
|
1740
1787
|
}
|
|
1741
1788
|
}
|
|
1789
|
+
/** An event that may name a task names its allocation with it, and never an allocation alone. */
|
|
1790
|
+
function allocationWithTask(event, rule, at, into) {
|
|
1791
|
+
if (event.taskId !== undefined) {
|
|
1792
|
+
into.require(isAllocationId(event.allocationId), `${rule}.allocationId`, `${at}.allocationId`, "an event that names a task names its allocation too");
|
|
1793
|
+
}
|
|
1794
|
+
else {
|
|
1795
|
+
into.require(event.allocationId === undefined, `${rule}.allocationId.unexpected`, `${at}.allocationId`, "an allocation is named with its task, never alone");
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1742
1798
|
export function validateHostReport(report, path = "host") {
|
|
1743
1799
|
const into = new Collector();
|
|
1744
1800
|
if (!isPlainObject(report)) {
|
|
@@ -1826,6 +1882,22 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1826
1882
|
: [...new Set([...TASK_COMMAND_NAMES.voice, ...TASK_COMMAND_NAMES.chat, ...TASK_COMMAND_NAMES.email])];
|
|
1827
1883
|
if (type === "custom") {
|
|
1828
1884
|
into.filled(command.name, "command.custom.name", `${path}.name`, "a custom command names the control it presses");
|
|
1885
|
+
if (task === undefined)
|
|
1886
|
+
return into.violations;
|
|
1887
|
+
// Held to the task: the control it presses is one the task published, and what the control asked for travels with it.
|
|
1888
|
+
const custom = isPlainObject(task) && isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
|
|
1889
|
+
const control = Array.isArray(custom) ? custom.filter(isPlainObject).find(entry => entry.id === command.name) : undefined;
|
|
1890
|
+
if (control === undefined) {
|
|
1891
|
+
into.add("command.capability.custom", path, `custom ${String(command.name)} presses a control the task never published`);
|
|
1892
|
+
return into.violations;
|
|
1893
|
+
}
|
|
1894
|
+
if (isPlainObject(control.prompt) && Array.isArray(control.prompt.fields)) {
|
|
1895
|
+
for (const field of control.prompt.fields.filter(isPlainObject)) {
|
|
1896
|
+
if (typeof field.name === "string") {
|
|
1897
|
+
into.require(typeof command[field.name] === "string" && command[field.name].length > 0, "command.custom.prompt", `${path}.${field.name}`, `the control asked for ${field.name} and the command does not carry it`);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1829
1901
|
return into.violations;
|
|
1830
1902
|
}
|
|
1831
1903
|
if (!into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${String(type)}` : `a ${channel} task has no ${String(type)} command`)) {
|
|
@@ -1911,12 +1983,17 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1911
1983
|
// to act on yet, and in completing the handling has ended: a call with nobody on it, a
|
|
1912
1984
|
// conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
|
|
1913
1985
|
const handling = () => inPhase("command.phase.handling", "in-progress", "paused");
|
|
1986
|
+
// A destination is one the directory offered: the id Omni sends is the id the provider published.
|
|
1987
|
+
const listed = (name) => {
|
|
1988
|
+
const declared = capabilities[name];
|
|
1989
|
+
const directory = isPlainObject(declared) && Array.isArray(declared.destinations) ? declared.destinations.filter(isPlainObject) : [];
|
|
1990
|
+
into.require(directory.some(item => item.id === command.destinationId), "command.destination.unknown", `${path}.destinationId`, `${String(command.destinationId)} is not a destination the task's ${name} directory offered`);
|
|
1991
|
+
};
|
|
1914
1992
|
const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
|
|
1915
1993
|
switch (type) {
|
|
1916
1994
|
case "answer":
|
|
1917
1995
|
case "accept":
|
|
1918
1996
|
case "decline":
|
|
1919
|
-
case "reject":
|
|
1920
1997
|
inPhase("command.phase.pending", "pending");
|
|
1921
1998
|
if (type === "decline")
|
|
1922
1999
|
offered("decline");
|
|
@@ -1944,10 +2021,14 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1944
2021
|
break;
|
|
1945
2022
|
case "transfer":
|
|
1946
2023
|
handling();
|
|
1947
|
-
if (command.action === "cold")
|
|
1948
|
-
offered("coldTransfer")
|
|
1949
|
-
|
|
1950
|
-
|
|
2024
|
+
if (command.action === "cold") {
|
|
2025
|
+
if (offered("coldTransfer"))
|
|
2026
|
+
listed("coldTransfer");
|
|
2027
|
+
}
|
|
2028
|
+
else if (command.action === "warm") {
|
|
2029
|
+
if (offered("warmTransfer"))
|
|
2030
|
+
listed("warmTransfer");
|
|
2031
|
+
}
|
|
1951
2032
|
else if (command.action === "complete" || command.action === "cancel") {
|
|
1952
2033
|
into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${String(command.action)} needs a consulted entry on onCall: without one there is nothing to complete or cancel`);
|
|
1953
2034
|
}
|
|
@@ -1964,9 +2045,12 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1964
2045
|
into.require(task.assisting !== undefined, "command.leadAssist.assisting", path, `${String(command.action)} is the lead's own act on a call they joined: the task carries assisting`);
|
|
1965
2046
|
}
|
|
1966
2047
|
break;
|
|
1967
|
-
case "conference":
|
|
2048
|
+
case "conference": {
|
|
1968
2049
|
handling();
|
|
1969
|
-
|
|
2050
|
+
const conferencing = offered("conference");
|
|
2051
|
+
if (conferencing && command.action === "add")
|
|
2052
|
+
listed("conference");
|
|
2053
|
+
if (conferencing && command.action === "remove") {
|
|
1970
2054
|
const others = onCall.filter(entry => entry.role !== "agent");
|
|
1971
2055
|
if (command.party === true) {
|
|
1972
2056
|
into.require(others.some(entry => entry.role !== "party"), "command.conference.remove.alone", path, "removing the party would leave the agent alone: that is end-call");
|
|
@@ -1977,9 +2061,32 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1977
2061
|
}
|
|
1978
2062
|
}
|
|
1979
2063
|
break;
|
|
1980
|
-
|
|
2064
|
+
}
|
|
2065
|
+
case "complete": {
|
|
1981
2066
|
into.require(task.completionMode === "agent-command", "command.complete.mode", path, "complete belongs to agent-command; a provider-automatic task completes itself");
|
|
2067
|
+
// What travels with complete is what the dispositions capability published: a code from its
|
|
2068
|
+
// list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
|
|
2069
|
+
const dispositions = capabilities.dispositions;
|
|
2070
|
+
if (dispositions === undefined) {
|
|
2071
|
+
into.require(command.disposition === undefined, "command.complete.disposition.unexpected", `${path}.disposition`, "the task declares no dispositions; complete travels with no code");
|
|
2072
|
+
into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task declares no dispositions; complete travels with no notes");
|
|
2073
|
+
}
|
|
2074
|
+
else if (isPlainObject(dispositions)) {
|
|
2075
|
+
if (Array.isArray(dispositions.codes) && command.disposition !== undefined) {
|
|
2076
|
+
into.require(dispositions.codes.some(code => isPlainObject(code) && code.id === command.disposition), "command.complete.disposition.unknown", `${path}.disposition`, `${String(command.disposition)} is not a code the task published`);
|
|
2077
|
+
}
|
|
2078
|
+
if (dispositions.required === true) {
|
|
2079
|
+
into.require(command.disposition !== undefined, "command.complete.disposition.required", `${path}.disposition`, "the task requires a disposition and complete carries none");
|
|
2080
|
+
}
|
|
2081
|
+
if (dispositions.notes === "none") {
|
|
2082
|
+
into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task takes no notes");
|
|
2083
|
+
}
|
|
2084
|
+
else if (dispositions.notes === "required") {
|
|
2085
|
+
into.require(typeof command.notes === "string" && command.notes.length > 0, "command.complete.notes.required", `${path}.notes`, "the task requires notes and complete carries none");
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
1982
2088
|
break;
|
|
2089
|
+
}
|
|
1983
2090
|
default:
|
|
1984
2091
|
break;
|
|
1985
2092
|
}
|
|
@@ -2076,6 +2183,26 @@ export function validateTimeZone(value, path = "context.timeZone") {
|
|
|
2076
2183
|
into.require(isTimeZone(value), "context.timeZone", path, "a host states the agent's time zone at connect as an IANA name, such as Asia/Kolkata; an offset cannot survive a daylight-saving boundary");
|
|
2077
2184
|
return into.violations;
|
|
2078
2185
|
}
|
|
2186
|
+
/**
|
|
2187
|
+
* What `describeUsers(ids)` answered: an array of users, each one asked for, each a valid identity.
|
|
2188
|
+
* The provider may answer fewer than asked, which is what "unknown to it" looks like; it never
|
|
2189
|
+
* answers somebody nobody asked about.
|
|
2190
|
+
*/
|
|
2191
|
+
export function validateDescribedUsers(users, asked, path = "describeUsers") {
|
|
2192
|
+
const into = new Collector();
|
|
2193
|
+
if (!Array.isArray(users)) {
|
|
2194
|
+
into.add("describeUsers.shape", path, "describeUsers answers an array of users, empty when it knows none of them");
|
|
2195
|
+
return into.violations;
|
|
2196
|
+
}
|
|
2197
|
+
users.forEach((user, index) => {
|
|
2198
|
+
const at = `${path}[${index}]`;
|
|
2199
|
+
validateUser(user, "describeUsers.user", at, into);
|
|
2200
|
+
if (isPlainObject(user) && typeof user.id === "string") {
|
|
2201
|
+
into.require(asked.includes(user.id), "describeUsers.unasked", `${at}.id`, `${user.id} was not among the ids asked for`);
|
|
2202
|
+
}
|
|
2203
|
+
});
|
|
2204
|
+
return into.violations;
|
|
2205
|
+
}
|
|
2079
2206
|
function validateUser(value, rule, path, into) {
|
|
2080
2207
|
if (!isPlainObject(value)) {
|
|
2081
2208
|
into.add(rule, path, "an identity must be an object");
|