@xema/omni-protocol 0.1.100 → 0.1.102
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 +100 -88
- package/dist/index.js +5 -4
- package/dist/testing.d.ts +19 -1
- package/dist/testing.js +162 -32
- package/dist/validation.d.ts +6 -3
- package/dist/validation.js +188 -152
- package/guide.md +322 -230
- 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, BROWSER_ISOLATION_SCHEMES, HISTORY_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
|
|
12
|
+
import { RECORDING_ACTIONS, SHIFT_EVENT_KINDS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_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") {
|
|
@@ -71,10 +71,10 @@ const DIAL_DESTINATION_POLICIES = membersOf({ "contacts-only": true, "any-number
|
|
|
71
71
|
const DIAL_OUTCOMES = membersOf({ answered: true, busy: true, "no-answer": true, unreachable: true, rejected: true, cancelled: true, unexplained: true });
|
|
72
72
|
/** The outcomes that name a cause. `unexplained` is declarable only beside one of these. */
|
|
73
73
|
const EXPLAINED_FAILURES = ["busy", "no-answer", "unreachable", "rejected", "cancelled"];
|
|
74
|
-
const ON_CALL_ROLES = membersOf({ party: true, agent: true,
|
|
74
|
+
const ON_CALL_ROLES = membersOf({ party: true, agent: true, conferenced: true });
|
|
75
75
|
const ON_CALL_STAGES = membersOf({ ringing: true, joined: true });
|
|
76
76
|
/** The task capabilities under which a command dials, and so need the manifest to say how a dial ends. */
|
|
77
|
-
const DIALLING_CAPABILITIES = ["connectBack", "
|
|
77
|
+
const DIALLING_CAPABILITIES = ["connectBack", "conference"];
|
|
78
78
|
const SNAPSHOT_REASONS = membersOf({
|
|
79
79
|
reconnected: true, "provider-requested": true,
|
|
80
80
|
});
|
|
@@ -98,7 +98,7 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
|
|
|
98
98
|
const TASK_CAPABILITIES = {
|
|
99
99
|
voice: membersOf({
|
|
100
100
|
browsers: true, outcomes: true, custom: true, decline: true, hold: true,
|
|
101
|
-
endCall: true, terminateCall: true, connectBack: true,
|
|
101
|
+
endCall: true, terminateCall: true, connectBack: true, schedule: true, leadAssist: true, conference: true, recording: true,
|
|
102
102
|
}),
|
|
103
103
|
chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true }),
|
|
104
104
|
email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true }),
|
|
@@ -579,7 +579,7 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
579
579
|
// ---------------------------------------------------------------------------
|
|
580
580
|
function validateDestinationDirectory(value, path, into) {
|
|
581
581
|
if (!isPlainObject(value)) {
|
|
582
|
-
into.add("task.destinations.shape", path, "a
|
|
582
|
+
into.add("task.destinations.shape", path, "a conference control carries the directory it offers: { destinations: [...] }");
|
|
583
583
|
return;
|
|
584
584
|
}
|
|
585
585
|
if (!Array.isArray(value.destinations)) {
|
|
@@ -982,7 +982,7 @@ const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
|
|
|
982
982
|
/**
|
|
983
983
|
* Who is on the call, as the provider states it. Voice only. Each entry is a role with what that
|
|
984
984
|
* role needs and nothing another role would: a party or an agent dialled nowhere, so neither
|
|
985
|
-
* carries a destination; somebody
|
|
985
|
+
* carries a destination; somebody conferenced came from one, so they do.
|
|
986
986
|
*/
|
|
987
987
|
function validateOnCall(value, channel, path, into) {
|
|
988
988
|
if (value === undefined)
|
|
@@ -993,7 +993,7 @@ function validateOnCall(value, channel, path, into) {
|
|
|
993
993
|
into.add("task.onCall.shape", path, "onCall is an array of who is on the call, when the provider knows");
|
|
994
994
|
return;
|
|
995
995
|
}
|
|
996
|
-
|
|
996
|
+
let parties = 0;
|
|
997
997
|
value.forEach((entry, index) => {
|
|
998
998
|
const at = `${path}[${index}]`;
|
|
999
999
|
if (!isPlainObject(entry)) {
|
|
@@ -1007,15 +1007,15 @@ function validateOnCall(value, channel, path, into) {
|
|
|
1007
1007
|
if (entry.held !== undefined) {
|
|
1008
1008
|
into.require(entry.held === true, "task.onCall.held", `${at}.held`, "held is stated by presence: send true or omit it");
|
|
1009
1009
|
}
|
|
1010
|
-
if (role === "party"
|
|
1011
|
-
|
|
1010
|
+
if (role === "party")
|
|
1011
|
+
parties += 1;
|
|
1012
1012
|
if (role === "agent") {
|
|
1013
1013
|
into.require(isUserId(entry.userId), "task.onCall.userId", `${at}.userId`, "an agent on the call is named by their user id");
|
|
1014
1014
|
}
|
|
1015
1015
|
else {
|
|
1016
1016
|
into.require(entry.userId === undefined, "task.onCall.userId.unexpected", `${at}.userId`, `a ${role} is not named by a user id`);
|
|
1017
1017
|
}
|
|
1018
|
-
if (role === "
|
|
1018
|
+
if (role === "conferenced") {
|
|
1019
1019
|
into.filled(entry.destinationId, "task.onCall.destinationId", `${at}.destinationId`, `a ${role} entry names the directory item that was dialled`);
|
|
1020
1020
|
// Stated, so a snapshot says who is present without anyone having seen the dial's outcome.
|
|
1021
1021
|
if (into.oneOf(entry.stage, ON_CALL_STAGES, "task.onCall.stage", `${at}.stage`)) {
|
|
@@ -1037,17 +1037,16 @@ function validateOnCall(value, channel, path, into) {
|
|
|
1037
1037
|
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");
|
|
1038
1038
|
}
|
|
1039
1039
|
for (const field of ["destinationId", "label"]) {
|
|
1040
|
-
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a party is not a directory item; ${field} belongs on
|
|
1040
|
+
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a party is not a directory item; ${field} belongs on conferenced`);
|
|
1041
1041
|
}
|
|
1042
1042
|
}
|
|
1043
1043
|
else {
|
|
1044
1044
|
for (const field of ["destinationId", "dialId", "label", "stage"]) {
|
|
1045
|
-
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
|
|
1045
|
+
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 conferenced`);
|
|
1046
1046
|
}
|
|
1047
1047
|
}
|
|
1048
1048
|
});
|
|
1049
|
-
into.require(
|
|
1050
|
-
into.require(counted.consulted <= 1, "task.onCall.consulted.single", path, "one consultation at a time: transfer complete and cancel name no destination because there is exactly one");
|
|
1049
|
+
into.require(parties <= 1, "task.onCall.party.single", path, "a call has one party; a second is somebody else's role");
|
|
1051
1050
|
}
|
|
1052
1051
|
const LEAD_STAGES = membersOf({ requested: true, joined: true });
|
|
1053
1052
|
/** The agent's request for a lead. Voice only; `joined` names the lead, `requested` cannot. */
|
|
@@ -1122,13 +1121,13 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1122
1121
|
into.require(context.dialOutcomesDeclared !== false, "task.preview.dialOutcomes.required", `${path}.phase`, "a preview ends in the agent pressing Call, which dials, and the manifest declares no dialOutcomes to say how a dial ends");
|
|
1123
1122
|
}
|
|
1124
1123
|
// The deadline and what happens at it travel together, and only while the record is being previewed.
|
|
1125
|
-
if (task.
|
|
1126
|
-
into.require(task.phase === "preview", "task.preview.deadline.unexpected", `${path}.
|
|
1127
|
-
if (task.
|
|
1128
|
-
into.add("task.preview.
|
|
1124
|
+
if (task.previewEndsInSeconds !== undefined || task.atDeadline !== undefined) {
|
|
1125
|
+
into.require(task.phase === "preview", "task.preview.deadline.unexpected", `${path}.previewEndsInSeconds`, "previewEndsInSeconds and atDeadline belong to a task in preview; past it there is nothing to wait for");
|
|
1126
|
+
if (task.previewEndsInSeconds === undefined) {
|
|
1127
|
+
into.add("task.preview.previewEndsInSeconds.required", `${path}.previewEndsInSeconds`, "atDeadline says what happens at a deadline, so there has to be one");
|
|
1129
1128
|
}
|
|
1130
1129
|
else {
|
|
1131
|
-
into.
|
|
1130
|
+
into.require(isDurationSeconds(task.previewEndsInSeconds), "task.preview.previewEndsInSeconds", `${path}.previewEndsInSeconds`, "previewEndsInSeconds is how long the preview has left: a whole number of seconds, zero or more, counted from this publication");
|
|
1132
1131
|
}
|
|
1133
1132
|
if (task.atDeadline === undefined) {
|
|
1134
1133
|
into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what happens at it: provider dials, host dials, or waits for the agent -- an agent counting down has to know which");
|
|
@@ -1235,6 +1234,14 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1235
1234
|
if (capabilities.browsers === true && Array.isArray(task.browsers) && task.browsers.length === 0) {
|
|
1236
1235
|
into.add("task.browsers.required", `${path}.browsers`, "a task that declares capabilities.browsers supplies at least one; with none, omit the capability");
|
|
1237
1236
|
}
|
|
1237
|
+
// An outcome is given in wrap, by the agent: a provider that completes the task itself cannot
|
|
1238
|
+
// wait for one, and a task with no wrap has nowhere to take one.
|
|
1239
|
+
if (isPlainObject(capabilities.outcomes) && capabilities.outcomes.required === true) {
|
|
1240
|
+
into.require(task.completionMode !== "provider-automatic", "task.outcomes.required.mode", `${path}.capabilities.outcomes.required`, "a required outcome is the agent's to give, so the task completes on the agent's command; provider-automatic completion cannot wait for it");
|
|
1241
|
+
}
|
|
1242
|
+
if (capabilities.outcomes !== undefined) {
|
|
1243
|
+
into.require(task.wrapAllowance !== 0, "task.outcomes.wrapAllowance", `${path}.capabilities.outcomes`, "outcomes are collected in wrap, and wrapAllowance 0 gives none: publish a wrap or no outcomes");
|
|
1244
|
+
}
|
|
1238
1245
|
for (const [name, declared] of Object.entries(capabilities)) {
|
|
1239
1246
|
if (declared === undefined)
|
|
1240
1247
|
continue;
|
|
@@ -1267,8 +1274,6 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1267
1274
|
case "custom":
|
|
1268
1275
|
validateCustomCapabilities(declared, `${path}.capabilities.custom`, into);
|
|
1269
1276
|
break;
|
|
1270
|
-
case "coldTransfer":
|
|
1271
|
-
case "warmTransfer":
|
|
1272
1277
|
case "conference":
|
|
1273
1278
|
validateDestinationDirectory(declared, `${path}.capabilities.${name}`, into);
|
|
1274
1279
|
break;
|
|
@@ -1287,7 +1292,7 @@ function validateForcedBreak(value, path, into) {
|
|
|
1287
1292
|
into.add("break.forced.shape", path, "a forced break must be an object");
|
|
1288
1293
|
return;
|
|
1289
1294
|
}
|
|
1290
|
-
into.require(isUserId(value.by), "break.forced.by", `${path}.by`, "a forced break
|
|
1295
|
+
into.require(isUserId(value.by) || value.by === "provider", "break.forced.by", `${path}.by`, "a forced break says who forced it: the lead by user id, or provider where the platform did");
|
|
1291
1296
|
for (const field of ["endsAutomatically", "endsAt"]) {
|
|
1292
1297
|
into.require(!Object.hasOwn(value, field), "break.forced.manualResume", `${path}.${field}`, "automatic ending fields are unsupported; the agent must resume manually");
|
|
1293
1298
|
}
|
|
@@ -1406,9 +1411,8 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
|
|
|
1406
1411
|
}
|
|
1407
1412
|
}
|
|
1408
1413
|
else if (command.type === "join" || command.type === "decline") {
|
|
1409
|
-
const
|
|
1410
|
-
into.require(
|
|
1411
|
-
&& (command.assignmentId === undefined || r.assignmentId === command.assignmentId)), "team.command.request", path, `${command.type} answers a request the team member list carries, and this member has none`);
|
|
1414
|
+
const asked = isPlainObject(target) && isPlainObject(target.request) ? target.request : undefined;
|
|
1415
|
+
into.require(asked !== undefined && (command.assignmentId === undefined || asked.assignmentId === command.assignmentId), "team.command.request", path, `${command.type} answers a request the team member list carries on the member, and this member has none`);
|
|
1412
1416
|
}
|
|
1413
1417
|
else if (command.type === "coach" || command.type === "join-call" || command.type === "leave") {
|
|
1414
1418
|
into.require(isPlainObject(target) && target.listening !== undefined, "team.command.listening", path, `${command.type} needs the lead on this member's call: the team member list shows listening on them`);
|
|
@@ -1593,32 +1597,6 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
|
|
|
1593
1597
|
// Policies are present where the provider offers policy control, absent where it does not.
|
|
1594
1598
|
if (teamMembers.policies !== undefined)
|
|
1595
1599
|
validateTeamPoliciesInto(teamMembers.policies, `${path}.policies`, into, context.levels);
|
|
1596
|
-
// Requests are what a lead acts on: always carried, `[]` when nobody is asking.
|
|
1597
|
-
if (!Array.isArray(teamMembers.requests)) {
|
|
1598
|
-
into.add("team.requests.shape", `${path}.requests`, "a team member list carries its requests: [] when nobody is asking");
|
|
1599
|
-
}
|
|
1600
|
-
else {
|
|
1601
|
-
const seenRequests = new Set();
|
|
1602
|
-
teamMembers.requests.forEach((request, index) => {
|
|
1603
|
-
const at = `${path}.requests[${index}]`;
|
|
1604
|
-
if (!isPlainObject(request)) {
|
|
1605
|
-
into.add("team.request.shape", at, "each request must be an object");
|
|
1606
|
-
return;
|
|
1607
|
-
}
|
|
1608
|
-
if (into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")) {
|
|
1609
|
-
into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the team member list carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
|
|
1610
|
-
}
|
|
1611
|
-
if (into.require(isAssignmentId(request.assignmentId), "team.request.assignmentId", `${at}.assignmentId`, "a request names the member's assignment the lead would join")) {
|
|
1612
|
-
const key = `${String(request.memberId)}@${String(request.assignmentId)}`;
|
|
1613
|
-
if (seenRequests.has(key))
|
|
1614
|
-
into.add("team.request.unique", at, `duplicate request: ${key}`);
|
|
1615
|
-
seenRequests.add(key);
|
|
1616
|
-
}
|
|
1617
|
-
if (request.note !== undefined)
|
|
1618
|
-
into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
|
|
1619
|
-
into.timestamp(request.since, "team.request.since", `${at}.since`);
|
|
1620
|
-
});
|
|
1621
|
-
}
|
|
1622
1600
|
if (!Array.isArray(teamMembers.members)) {
|
|
1623
1601
|
into.add("team.members.shape", `${path}.members`, "a team member list must carry a members array");
|
|
1624
1602
|
return;
|
|
@@ -1626,61 +1604,94 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
|
|
|
1626
1604
|
const seen = new Set();
|
|
1627
1605
|
teamMembers.members.forEach((member, index) => {
|
|
1628
1606
|
const at = `${path}.members[${index}]`;
|
|
1629
|
-
if (
|
|
1630
|
-
into.add("team.member.shape", at, "each team member must be an object");
|
|
1631
|
-
return;
|
|
1632
|
-
}
|
|
1633
|
-
if (into.require(isUserId(member.id), "team.member.id", `${at}.id`, "a team member needs a user id")) {
|
|
1607
|
+
if (isPlainObject(member) && isUserId(member.id)) {
|
|
1634
1608
|
if (seen.has(member.id))
|
|
1635
1609
|
into.add("team.member.unique", `${at}.id`, `duplicate team member: ${member.id}`);
|
|
1636
1610
|
seen.add(member.id);
|
|
1637
|
-
into.require(member.id !== context.self, "team.member.self", `${at}.id`, "the team member list carries the agent it is published to: a lead does not report to themself");
|
|
1638
|
-
}
|
|
1639
|
-
into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
|
|
1640
|
-
if (member.since !== undefined)
|
|
1641
|
-
into.timestamp(member.since, "team.member.since", `${at}.since`);
|
|
1642
|
-
if (member.break !== undefined) {
|
|
1643
|
-
// Only an outstanding request appears here: `not-requested` is absence, `on-break` is
|
|
1644
|
-
// `availability: "on-break"`, and a denial never survives to be reported.
|
|
1645
|
-
if (into.oneOf(member.break, MEMBER_BREAKS, "team.member.break", `${at}.break`)) {
|
|
1646
|
-
into.require(member.availability !== "signed-out" && member.availability !== "on-break", "team.member.break.availability", `${at}.break`, "a member on a break or signed out has no request outstanding");
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
// The member's open tasks, as the lead sees them: trimmed by the provider, held to the task rules otherwise.
|
|
1650
|
-
if (member.tasks !== undefined) {
|
|
1651
|
-
if (!Array.isArray(member.tasks)) {
|
|
1652
|
-
into.add("team.member.tasks.shape", `${at}.tasks`, "a member's tasks are an array: [] when they hold none, omitted only where the provider cannot see them");
|
|
1653
|
-
}
|
|
1654
|
-
else {
|
|
1655
|
-
const held = new Set();
|
|
1656
|
-
member.tasks.forEach((task, index) => {
|
|
1657
|
-
const channel = isPlainObject(task) && typeof task.channel === "string" && isChannel(task.channel) ? task.channel : context.channel ?? "voice";
|
|
1658
|
-
validateTaskInto(task, { channel, levels: context.levels, member: true }, `${at}.tasks[${index}]`, into);
|
|
1659
|
-
if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
|
|
1660
|
-
if (held.has(task.assignmentId))
|
|
1661
|
-
into.add("team.member.tasks.unique", `${at}.tasks[${index}].assignmentId`, `duplicate assignment on one member: ${task.assignmentId}`);
|
|
1662
|
-
held.add(task.assignmentId);
|
|
1663
|
-
}
|
|
1664
|
-
});
|
|
1665
|
-
into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
// The lead on this member's call, in the mode they are heard.
|
|
1669
|
-
if (member.listening !== undefined) {
|
|
1670
|
-
if (!isPlainObject(member.listening)) {
|
|
1671
|
-
into.add("team.member.listening.shape", `${at}.listening`, "listening must be an object when present");
|
|
1672
|
-
}
|
|
1673
|
-
else {
|
|
1674
|
-
into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
|
|
1675
|
-
into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
|
|
1676
|
-
}
|
|
1677
1611
|
}
|
|
1612
|
+
validateTeamMemberInto(member, at, context, into);
|
|
1678
1613
|
});
|
|
1679
1614
|
// One member's call at a time.
|
|
1680
1615
|
const listened = teamMembers.members.filter((member) => isPlainObject(member) && member.listening !== undefined);
|
|
1681
1616
|
if (listened.length > 1)
|
|
1682
1617
|
into.add("team.listening.single", `${path}.members`, "a lead is on one member's call at a time");
|
|
1683
1618
|
}
|
|
1619
|
+
/** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
|
|
1620
|
+
export function validateTeamMember(member, path = "member", context = {}) {
|
|
1621
|
+
const into = new Collector();
|
|
1622
|
+
validateTeamMemberInto(member, path, context, into);
|
|
1623
|
+
return into.violations;
|
|
1624
|
+
}
|
|
1625
|
+
function validateTeamMemberInto(member, at, context, into) {
|
|
1626
|
+
if (!isPlainObject(member)) {
|
|
1627
|
+
into.add("team.member.shape", at, "each team member must be an object");
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (into.require(isUserId(member.id), "team.member.id", `${at}.id`, "a team member needs a user id")) {
|
|
1631
|
+
into.require(member.id !== context.self, "team.member.self", `${at}.id`, "the team member list carries the agent it is published to: a lead does not report to themself");
|
|
1632
|
+
}
|
|
1633
|
+
into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
|
|
1634
|
+
if (member.since !== undefined)
|
|
1635
|
+
into.timestamp(member.since, "team.member.since", `${at}.since`);
|
|
1636
|
+
if (member.break !== undefined) {
|
|
1637
|
+
// Only an outstanding request appears here: `not-requested` is absence, `on-break` is
|
|
1638
|
+
// `availability: "on-break"`, and a denial never survives to be reported.
|
|
1639
|
+
if (into.oneOf(member.break, MEMBER_BREAKS, "team.member.break", `${at}.break`)) {
|
|
1640
|
+
into.require(member.availability !== "signed-out" && member.availability !== "on-break", "team.member.break.availability", `${at}.break`, "a member on a break or signed out has no request outstanding");
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
// The member's open tasks, as the lead sees them: trimmed by the provider, held to the task rules otherwise.
|
|
1644
|
+
if (member.tasks !== undefined) {
|
|
1645
|
+
if (!Array.isArray(member.tasks)) {
|
|
1646
|
+
into.add("team.member.tasks.shape", `${at}.tasks`, "a member's tasks are an array: [] when they hold none, omitted only where the provider cannot see them");
|
|
1647
|
+
}
|
|
1648
|
+
else {
|
|
1649
|
+
const held = new Set();
|
|
1650
|
+
member.tasks.forEach((task, index) => {
|
|
1651
|
+
const channel = isPlainObject(task) && typeof task.channel === "string" && isChannel(task.channel) ? task.channel : context.channel ?? "voice";
|
|
1652
|
+
validateTaskInto(task, { channel, levels: context.levels, member: true }, `${at}.tasks[${index}]`, into);
|
|
1653
|
+
if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
|
|
1654
|
+
if (held.has(task.assignmentId))
|
|
1655
|
+
into.add("team.member.tasks.unique", `${at}.tasks[${index}].assignmentId`, `duplicate assignment on one member: ${task.assignmentId}`);
|
|
1656
|
+
held.add(task.assignmentId);
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
// The lead on this member's call, in the mode they are heard.
|
|
1663
|
+
if (member.listening !== undefined) {
|
|
1664
|
+
if (!isPlainObject(member.listening)) {
|
|
1665
|
+
into.add("team.member.listening.shape", `${at}.listening`, "listening must be an object when present");
|
|
1666
|
+
}
|
|
1667
|
+
else {
|
|
1668
|
+
const listened = member.listening;
|
|
1669
|
+
if (into.require(isAssignmentId(member.listening.assignmentId), "team.member.listening.assignmentId", `${at}.listening.assignmentId`, "listening names which of the member's calls the lead is on: the application opens the lead's audio on it") && Array.isArray(member.tasks)) {
|
|
1670
|
+
into.require(member.tasks.some((task) => isPlainObject(task) && task.assignmentId === listened.assignmentId), "team.member.listening.assignment", `${at}.listening.assignmentId`, "the call the lead is on is one the member's tasks carry");
|
|
1671
|
+
}
|
|
1672
|
+
into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
|
|
1673
|
+
into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
// The member's own history for the day: about the person, not any one call.
|
|
1677
|
+
if (member.shift !== undefined)
|
|
1678
|
+
validateShiftInto(member.shift, `${at}.shift`, into);
|
|
1679
|
+
// The member's standing ask for a lead, on one of the calls they hold.
|
|
1680
|
+
if (member.request !== undefined) {
|
|
1681
|
+
if (!isPlainObject(member.request)) {
|
|
1682
|
+
into.add("team.member.request.shape", `${at}.request`, "request must be an object when present");
|
|
1683
|
+
}
|
|
1684
|
+
else {
|
|
1685
|
+
const asked = member.request;
|
|
1686
|
+
if (into.require(isAssignmentId(asked.assignmentId), "team.member.request.assignmentId", `${at}.request.assignmentId`, "a request names the member's assignment the lead would join") && Array.isArray(member.tasks)) {
|
|
1687
|
+
into.require(member.tasks.some((task) => isPlainObject(task) && task.assignmentId === asked.assignmentId), "team.member.request.assignment", `${at}.request.assignmentId`, "the call the member asks a lead onto is one their tasks carry");
|
|
1688
|
+
}
|
|
1689
|
+
if (asked.note !== undefined)
|
|
1690
|
+
into.filled(asked.note, "team.member.request.note", `${at}.request.note`, "a note must not be empty when present");
|
|
1691
|
+
into.timestamp(asked.since, "team.member.request.since", `${at}.request.since`);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1684
1695
|
export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
|
|
1685
1696
|
const into = new Collector();
|
|
1686
1697
|
if (!isPlainObject(snapshot)) {
|
|
@@ -1754,14 +1765,15 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1754
1765
|
into.add("snapshot.calendar.shape", `${path}.calendar`, "calendar must be an array when present");
|
|
1755
1766
|
}
|
|
1756
1767
|
}
|
|
1757
|
-
// The
|
|
1758
|
-
//
|
|
1759
|
-
//
|
|
1760
|
-
|
|
1761
|
-
|
|
1768
|
+
// The switch is the permission: once the application has said the team feature is on, a
|
|
1769
|
+
// snapshot carries the team, `members: []` included. Before it has said, the provider assumes
|
|
1770
|
+
// nothing and owes nothing. The other direction -- a team member list to a login that does not
|
|
1771
|
+
// lead, or to a lead who turned the feature off -- is the team member list's own rule.
|
|
1772
|
+
if (context.leadFeatures === true && snapshot.team === undefined) {
|
|
1773
|
+
into.add("team.required", `${path}.team`, "the team feature is on, so every snapshot carries a team member list: members: [] when nobody is in it");
|
|
1762
1774
|
}
|
|
1763
1775
|
if (context.leadFeatures === false && snapshot.team !== undefined) {
|
|
1764
|
-
into.add("team.unexpected", `${path}.team`, "the
|
|
1776
|
+
into.add("team.unexpected", `${path}.team`, "the team feature is not on for this lead: nothing of the team reaches them until the application switches it on, and nothing after it switches it off");
|
|
1765
1777
|
}
|
|
1766
1778
|
if (snapshot.team !== undefined)
|
|
1767
1779
|
validateTeamMembersInto(snapshot.team, `${path}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
|
|
@@ -1779,11 +1791,6 @@ function validateTaskOutcome(value, path, into) {
|
|
|
1779
1791
|
case "completed":
|
|
1780
1792
|
into.oneOf(value.by, COMPLETED_BY, "event.taskEnded.outcome.completed", `${path}.by`);
|
|
1781
1793
|
break;
|
|
1782
|
-
case "transferred":
|
|
1783
|
-
if (value.destinationId !== undefined) {
|
|
1784
|
-
into.filled(value.destinationId, "event.taskEnded.outcome.transferred", `${path}.destinationId`, "a destinationId must not be empty when present");
|
|
1785
|
-
}
|
|
1786
|
-
break;
|
|
1787
1794
|
case "taken-over":
|
|
1788
1795
|
// A lead is not a directory item: the ending names the lead, and the host resolves the name.
|
|
1789
1796
|
into.require(isUserId(value.leadId), "event.taskEnded.outcome.takenOver", `${path}.leadId`, "a take-over names the lead who took the call, by user id");
|
|
@@ -1897,8 +1904,8 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1897
1904
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1898
1905
|
into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
|
|
1899
1906
|
}
|
|
1900
|
-
if (event.
|
|
1901
|
-
into.
|
|
1907
|
+
if (event.expiresInSeconds !== undefined)
|
|
1908
|
+
into.require(isDurationSeconds(event.expiresInSeconds), "event.taskOffered.expiresInSeconds", `${at}.expiresInSeconds`, "expiresInSeconds is how long the offer stands: a whole number of seconds, zero or more, counted from this event");
|
|
1902
1909
|
break;
|
|
1903
1910
|
case "task-updated":
|
|
1904
1911
|
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
@@ -1949,18 +1956,26 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1949
1956
|
validateQueueSummary(event.summary, `${at}.summary`, into);
|
|
1950
1957
|
break;
|
|
1951
1958
|
case "team-updated":
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1959
|
+
case "team-member-updated":
|
|
1960
|
+
case "team-member-removed":
|
|
1961
|
+
case "team-policies-updated": {
|
|
1962
|
+
const field = event.type === "team-updated" ? "team" : event.type === "team-member-updated" ? "member" : event.type === "team-member-removed" ? "memberId" : "policies";
|
|
1963
|
+
into.require(context.capabilities === undefined || context.capabilities.lead === true, "event.team.capability", `${at}.${field}`, `${event.type} reaches a login that declares lead, and nobody else`);
|
|
1964
|
+
into.require(context.leadFeatures !== false, "event.team.features", `${at}.${field}`, "the team feature is not on for this lead: nothing of the team reaches them until the application switches it on, and nothing after it switches it off");
|
|
1965
|
+
const teamContext = { ...context, levels, channel: isChannel(channel) ? channel : undefined };
|
|
1966
|
+
if (event.type === "team-updated")
|
|
1967
|
+
validateTeamMembersInto(event.team, `${at}.team`, teamContext, into);
|
|
1968
|
+
else if (event.type === "team-member-updated")
|
|
1969
|
+
validateTeamMemberInto(event.member, `${at}.member`, teamContext, into);
|
|
1970
|
+
else if (event.type === "team-member-removed") {
|
|
1971
|
+
if (into.require(isUserId(event.memberId), "event.teamMemberRemoved.memberId", `${at}.memberId`, "team-member-removed names the member taken off, by user id")) {
|
|
1972
|
+
into.require(event.memberId !== context.self, "team.member.self", `${at}.memberId`, "a lead does not report to themself, so is never removed from their own team");
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
else
|
|
1976
|
+
validateTeamPoliciesInto(event.policies, `${at}.policies`, into, levels);
|
|
1963
1977
|
break;
|
|
1978
|
+
}
|
|
1964
1979
|
case "contacts-updated":
|
|
1965
1980
|
into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
|
|
1966
1981
|
if (!Array.isArray(event.contacts)) {
|
|
@@ -2021,6 +2036,43 @@ function validatePreferencesInto(value, path, into, levels) {
|
|
|
2021
2036
|
});
|
|
2022
2037
|
}
|
|
2023
2038
|
/** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
|
|
2039
|
+
function validateShiftInto(value, path, into) {
|
|
2040
|
+
if (!isPlainObject(value)) {
|
|
2041
|
+
into.add("team.member.shift.shape", path, "shift must be an object when present");
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
const signedIn = into.timestamp(value.signedInAt, "team.member.shift.signedInAt", `${path}.signedInAt`);
|
|
2045
|
+
if (value.signedOutAt !== undefined && into.timestamp(value.signedOutAt, "team.member.shift.signedOutAt", `${path}.signedOutAt`) && signedIn) {
|
|
2046
|
+
into.require(Date.parse(value.signedOutAt) >= Date.parse(value.signedInAt), "team.member.shift.signedOutAt.order", `${path}.signedOutAt`, "a member signs out after signing in");
|
|
2047
|
+
}
|
|
2048
|
+
for (const field of ["talkSeconds", "holdSeconds", "breakSeconds"]) {
|
|
2049
|
+
if (value[field] !== undefined)
|
|
2050
|
+
into.require(isDurationSeconds(value[field]), `team.member.shift.${field}`, `${path}.${field}`, `${field} must be a whole number of seconds, zero or more, or omitted when unknown`);
|
|
2051
|
+
}
|
|
2052
|
+
if (value.tasksHandled !== undefined)
|
|
2053
|
+
into.require(Number.isInteger(value.tasksHandled) && value.tasksHandled >= 0, "team.member.shift.tasksHandled", `${path}.tasksHandled`, "tasksHandled must be a whole number, zero or more, or omitted when unknown");
|
|
2054
|
+
if (value.events !== undefined) {
|
|
2055
|
+
if (!Array.isArray(value.events)) {
|
|
2056
|
+
into.add("team.member.shift.events.shape", `${path}.events`, "events must be an array when present");
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
let previous;
|
|
2060
|
+
value.events.forEach((event, index) => {
|
|
2061
|
+
const at = `${path}.events[${index}]`;
|
|
2062
|
+
if (!isPlainObject(event)) {
|
|
2063
|
+
into.add("team.member.shift.event.shape", at, "each shift event must be an object");
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
into.oneOf(event.kind, SHIFT_EVENT_KINDS, "team.member.shift.event.kind", `${at}.kind`);
|
|
2067
|
+
if (into.timestamp(event.at, "team.member.shift.event.at", `${at}.at`)) {
|
|
2068
|
+
const instant = Date.parse(event.at);
|
|
2069
|
+
if (previous !== undefined && instant < previous)
|
|
2070
|
+
into.add("team.member.shift.events.order", `${at}.at`, "shift events are oldest first; this event is earlier than the one before it");
|
|
2071
|
+
previous = instant;
|
|
2072
|
+
}
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2024
2076
|
function validateTeamPoliciesInto(value, path, into, levels) {
|
|
2025
2077
|
if (!isPlainObject(value)) {
|
|
2026
2078
|
into.add("team.policies.shape", path, "policies must be an object keyed by capability");
|
|
@@ -2246,7 +2298,6 @@ export function validateHostReport(report, path = "host") {
|
|
|
2246
2298
|
// ---------------------------------------------------------------------------
|
|
2247
2299
|
// Commands.
|
|
2248
2300
|
// ---------------------------------------------------------------------------
|
|
2249
|
-
const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
|
|
2250
2301
|
const LEAD_ASSIST_ACTIONS = ["request", "cancel"];
|
|
2251
2302
|
const CONFERENCE_ACTIONS = ["add", "remove"];
|
|
2252
2303
|
/** Validate the exact interaction/assignment target before provider command dispatch. */
|
|
@@ -2267,7 +2318,7 @@ export function validateTaskCommandRequest(request, task, path = "request", task
|
|
|
2267
2318
|
/**
|
|
2268
2319
|
* What a command needs to be issuable, checked against the task it names: the capability the
|
|
2269
2320
|
* guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
|
|
2270
|
-
*
|
|
2321
|
+
* lead requested, somebody else on the call. Without a task only the command's
|
|
2271
2322
|
* own shape is checked. A host validates before sending, and an adapter before acting: a command
|
|
2272
2323
|
* for a control the task never offered is the host's error, and this names it.
|
|
2273
2324
|
*/
|
|
@@ -2300,10 +2351,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2300
2351
|
case "complete":
|
|
2301
2352
|
fields.push("outcome", "notes");
|
|
2302
2353
|
break;
|
|
2303
|
-
case "
|
|
2304
|
-
fields.push("
|
|
2305
|
-
if (command.action === "cold" || command.action === "warm")
|
|
2306
|
-
fields.push("dialId", "destinationId");
|
|
2354
|
+
case "schedule":
|
|
2355
|
+
fields.push("at", "note");
|
|
2307
2356
|
break;
|
|
2308
2357
|
case "lead-assist":
|
|
2309
2358
|
fields.push("action");
|
|
@@ -2326,16 +2375,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2326
2375
|
case "connect-back":
|
|
2327
2376
|
dial("command.connectBack.dialId");
|
|
2328
2377
|
break;
|
|
2329
|
-
case "
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
into.filled(command.destinationId, "command.transfer.destinationId", `${path}.destinationId`, "a transfer names the directory item it goes to");
|
|
2334
|
-
}
|
|
2335
|
-
else {
|
|
2336
|
-
into.require(command.dialId === undefined && command.destinationId === undefined, "command.transfer.unexpected", path, `${describeValue(command.action)} names no destination: there is exactly one it could mean`);
|
|
2337
|
-
}
|
|
2338
|
-
}
|
|
2378
|
+
case "schedule":
|
|
2379
|
+
into.timestamp(command.at, "command.schedule.at", `${path}.at`);
|
|
2380
|
+
if (command.note !== undefined)
|
|
2381
|
+
into.filled(command.note, "command.schedule.note", `${path}.note`, "a note must not be empty when present");
|
|
2339
2382
|
break;
|
|
2340
2383
|
case "lead-assist":
|
|
2341
2384
|
into.oneOf(command.action, LEAD_ASSIST_ACTIONS, "command.leadAssist.action", `${path}.action`);
|
|
@@ -2468,19 +2511,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2468
2511
|
offered("connectBack");
|
|
2469
2512
|
inPhase("command.phase.completing", "completing");
|
|
2470
2513
|
break;
|
|
2471
|
-
case "
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
listed("coldTransfer");
|
|
2476
|
-
}
|
|
2477
|
-
else if (command.action === "warm") {
|
|
2478
|
-
if (offered("warmTransfer"))
|
|
2479
|
-
listed("warmTransfer");
|
|
2480
|
-
}
|
|
2481
|
-
else if (command.action === "complete" || command.action === "cancel") {
|
|
2482
|
-
into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${describeValue(command.action)} needs a consulted entry on onCall: without one there is nothing to complete or cancel`);
|
|
2483
|
-
}
|
|
2514
|
+
case "schedule":
|
|
2515
|
+
// A follow-up is promised on the call and written up in wrap alike.
|
|
2516
|
+
offered("schedule");
|
|
2517
|
+
inPhase("command.phase.interaction", "in-progress", "paused", "completing");
|
|
2484
2518
|
break;
|
|
2485
2519
|
case "lead-assist":
|
|
2486
2520
|
interaction();
|
|
@@ -2507,7 +2541,9 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2507
2541
|
break;
|
|
2508
2542
|
}
|
|
2509
2543
|
case "complete": {
|
|
2510
|
-
|
|
2544
|
+
// The agent may finish early under either mode; the provider is free to end at once. With
|
|
2545
|
+
// no wrap at all there is no window to cut short, and nothing for the command to do.
|
|
2546
|
+
into.require(task.wrapAllowance !== 0, "command.complete.wrapAllowance", path, "the task has no wrap: with wrapAllowance 0 the provider ends it itself, and there is nothing to complete");
|
|
2511
2547
|
// What travels with complete is what the outcomes capability published: a code from its
|
|
2512
2548
|
// list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
|
|
2513
2549
|
const outcomes = capabilities.outcomes;
|
|
@@ -2963,10 +2999,10 @@ export function validateRecordingCommandShape(value, source, path = "command") {
|
|
|
2963
2999
|
check(false, "command.shape", path, "expected recording command");
|
|
2964
3000
|
return violations;
|
|
2965
3001
|
}
|
|
2966
|
-
keys(value, ["type", "source", "
|
|
3002
|
+
keys(value, ["type", "source", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
|
|
2967
3003
|
check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
|
|
2968
3004
|
check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
|
|
2969
|
-
for (const key of ["
|
|
3005
|
+
for (const key of ["observationId", ...(value.action === "start" ? [] : ["recordingId"])])
|
|
2970
3006
|
check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
|
|
2971
3007
|
return violations;
|
|
2972
3008
|
}
|