@xema/omni-protocol 0.1.101 → 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 +70 -71
- package/dist/index.js +4 -4
- package/dist/testing.d.ts +19 -1
- package/dist/testing.js +161 -34
- package/dist/validation.d.ts +6 -3
- package/dist/validation.js +150 -150
- package/guide.md +252 -194
- package/package.json +1 -1
package/dist/validation.js
CHANGED
|
@@ -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,68 +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
1611
|
}
|
|
1649
|
-
|
|
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
|
-
const listened = member.listening;
|
|
1675
|
-
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)) {
|
|
1676
|
-
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");
|
|
1677
|
-
}
|
|
1678
|
-
into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
|
|
1679
|
-
into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
|
|
1680
|
-
}
|
|
1681
|
-
}
|
|
1682
|
-
// The member's own history for the day: about the person, not any one call.
|
|
1683
|
-
if (member.shift !== undefined)
|
|
1684
|
-
validateShiftInto(member.shift, `${at}.shift`, into);
|
|
1612
|
+
validateTeamMemberInto(member, at, context, into);
|
|
1685
1613
|
});
|
|
1686
1614
|
// One member's call at a time.
|
|
1687
1615
|
const listened = teamMembers.members.filter((member) => isPlainObject(member) && member.listening !== undefined);
|
|
1688
1616
|
if (listened.length > 1)
|
|
1689
1617
|
into.add("team.listening.single", `${path}.members`, "a lead is on one member's call at a time");
|
|
1690
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
|
+
}
|
|
1691
1695
|
export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
|
|
1692
1696
|
const into = new Collector();
|
|
1693
1697
|
if (!isPlainObject(snapshot)) {
|
|
@@ -1761,14 +1765,15 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1761
1765
|
into.add("snapshot.calendar.shape", `${path}.calendar`, "calendar must be an array when present");
|
|
1762
1766
|
}
|
|
1763
1767
|
}
|
|
1764
|
-
// The
|
|
1765
|
-
//
|
|
1766
|
-
//
|
|
1767
|
-
|
|
1768
|
-
|
|
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");
|
|
1769
1774
|
}
|
|
1770
1775
|
if (context.leadFeatures === false && snapshot.team !== undefined) {
|
|
1771
|
-
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");
|
|
1772
1777
|
}
|
|
1773
1778
|
if (snapshot.team !== undefined)
|
|
1774
1779
|
validateTeamMembersInto(snapshot.team, `${path}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
|
|
@@ -1786,11 +1791,6 @@ function validateTaskOutcome(value, path, into) {
|
|
|
1786
1791
|
case "completed":
|
|
1787
1792
|
into.oneOf(value.by, COMPLETED_BY, "event.taskEnded.outcome.completed", `${path}.by`);
|
|
1788
1793
|
break;
|
|
1789
|
-
case "transferred":
|
|
1790
|
-
if (value.destinationId !== undefined) {
|
|
1791
|
-
into.filled(value.destinationId, "event.taskEnded.outcome.transferred", `${path}.destinationId`, "a destinationId must not be empty when present");
|
|
1792
|
-
}
|
|
1793
|
-
break;
|
|
1794
1794
|
case "taken-over":
|
|
1795
1795
|
// A lead is not a directory item: the ending names the lead, and the host resolves the name.
|
|
1796
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");
|
|
@@ -1904,8 +1904,8 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1904
1904
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1905
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}`);
|
|
1906
1906
|
}
|
|
1907
|
-
if (event.
|
|
1908
|
-
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");
|
|
1909
1909
|
break;
|
|
1910
1910
|
case "task-updated":
|
|
1911
1911
|
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
@@ -1956,10 +1956,26 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1956
1956
|
validateQueueSummary(event.summary, `${at}.summary`, into);
|
|
1957
1957
|
break;
|
|
1958
1958
|
case "team-updated":
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
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);
|
|
1962
1977
|
break;
|
|
1978
|
+
}
|
|
1963
1979
|
case "contacts-updated":
|
|
1964
1980
|
into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
|
|
1965
1981
|
if (!Array.isArray(event.contacts)) {
|
|
@@ -2282,7 +2298,6 @@ export function validateHostReport(report, path = "host") {
|
|
|
2282
2298
|
// ---------------------------------------------------------------------------
|
|
2283
2299
|
// Commands.
|
|
2284
2300
|
// ---------------------------------------------------------------------------
|
|
2285
|
-
const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
|
|
2286
2301
|
const LEAD_ASSIST_ACTIONS = ["request", "cancel"];
|
|
2287
2302
|
const CONFERENCE_ACTIONS = ["add", "remove"];
|
|
2288
2303
|
/** Validate the exact interaction/assignment target before provider command dispatch. */
|
|
@@ -2303,7 +2318,7 @@ export function validateTaskCommandRequest(request, task, path = "request", task
|
|
|
2303
2318
|
/**
|
|
2304
2319
|
* What a command needs to be issuable, checked against the task it names: the capability the
|
|
2305
2320
|
* guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
|
|
2306
|
-
*
|
|
2321
|
+
* lead requested, somebody else on the call. Without a task only the command's
|
|
2307
2322
|
* own shape is checked. A host validates before sending, and an adapter before acting: a command
|
|
2308
2323
|
* for a control the task never offered is the host's error, and this names it.
|
|
2309
2324
|
*/
|
|
@@ -2336,10 +2351,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2336
2351
|
case "complete":
|
|
2337
2352
|
fields.push("outcome", "notes");
|
|
2338
2353
|
break;
|
|
2339
|
-
case "
|
|
2340
|
-
fields.push("
|
|
2341
|
-
if (command.action === "cold" || command.action === "warm")
|
|
2342
|
-
fields.push("dialId", "destinationId");
|
|
2354
|
+
case "schedule":
|
|
2355
|
+
fields.push("at", "note");
|
|
2343
2356
|
break;
|
|
2344
2357
|
case "lead-assist":
|
|
2345
2358
|
fields.push("action");
|
|
@@ -2362,16 +2375,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2362
2375
|
case "connect-back":
|
|
2363
2376
|
dial("command.connectBack.dialId");
|
|
2364
2377
|
break;
|
|
2365
|
-
case "
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
into.filled(command.destinationId, "command.transfer.destinationId", `${path}.destinationId`, "a transfer names the directory item it goes to");
|
|
2370
|
-
}
|
|
2371
|
-
else {
|
|
2372
|
-
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`);
|
|
2373
|
-
}
|
|
2374
|
-
}
|
|
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");
|
|
2375
2382
|
break;
|
|
2376
2383
|
case "lead-assist":
|
|
2377
2384
|
into.oneOf(command.action, LEAD_ASSIST_ACTIONS, "command.leadAssist.action", `${path}.action`);
|
|
@@ -2504,19 +2511,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2504
2511
|
offered("connectBack");
|
|
2505
2512
|
inPhase("command.phase.completing", "completing");
|
|
2506
2513
|
break;
|
|
2507
|
-
case "
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
listed("coldTransfer");
|
|
2512
|
-
}
|
|
2513
|
-
else if (command.action === "warm") {
|
|
2514
|
-
if (offered("warmTransfer"))
|
|
2515
|
-
listed("warmTransfer");
|
|
2516
|
-
}
|
|
2517
|
-
else if (command.action === "complete" || command.action === "cancel") {
|
|
2518
|
-
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`);
|
|
2519
|
-
}
|
|
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");
|
|
2520
2518
|
break;
|
|
2521
2519
|
case "lead-assist":
|
|
2522
2520
|
interaction();
|
|
@@ -2543,7 +2541,9 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2543
2541
|
break;
|
|
2544
2542
|
}
|
|
2545
2543
|
case "complete": {
|
|
2546
|
-
|
|
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");
|
|
2547
2547
|
// What travels with complete is what the outcomes capability published: a code from its
|
|
2548
2548
|
// list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
|
|
2549
2549
|
const outcomes = capabilities.outcomes;
|
|
@@ -2999,10 +2999,10 @@ export function validateRecordingCommandShape(value, source, path = "command") {
|
|
|
2999
2999
|
check(false, "command.shape", path, "expected recording command");
|
|
3000
3000
|
return violations;
|
|
3001
3001
|
}
|
|
3002
|
-
keys(value, ["type", "source", "
|
|
3002
|
+
keys(value, ["type", "source", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
|
|
3003
3003
|
check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
|
|
3004
3004
|
check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
|
|
3005
|
-
for (const key of ["
|
|
3005
|
+
for (const key of ["observationId", ...(value.action === "start" ? [] : ["recordingId"])])
|
|
3006
3006
|
check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
|
|
3007
3007
|
return violations;
|
|
3008
3008
|
}
|