@xema/omni-protocol 0.1.32 → 0.1.34

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.
@@ -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 { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
12
+ import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HANDLING_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, 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") {
@@ -67,6 +67,10 @@ const ACCESS_APPLIES_TO = membersOf({
67
67
  "initial-url": true, "all-navigation": true,
68
68
  });
69
69
  const DIAL_DESTINATION_POLICIES = membersOf({ "contacts-only": true, "any-number": true });
70
+ const DIAL_OUTCOMES = membersOf({ answered: true, busy: true, "no-answer": true, unreachable: true, rejected: true, cancelled: true });
71
+ const ON_CALL_ROLES = membersOf({ party: true, agent: true, consulted: true, conferenced: true });
72
+ /** The task capabilities under which a command dials, and so need the manifest to say how a dial ends. */
73
+ const DIALLING_CAPABILITIES = ["callback", "blindTransfer", "consultTransfer", "conference"];
70
74
  const SNAPSHOT_REASONS = membersOf({
71
75
  reconnected: true, "provider-requested": true,
72
76
  });
@@ -92,6 +96,8 @@ const TASK_CAPABILITIES = {
92
96
  chat: membersOf({ browsers: true, dispositions: true, custom: true, reject: true, hold: true }),
93
97
  email: membersOf({ browsers: true, dispositions: true, custom: true, reject: true }),
94
98
  };
99
+ // The published list and the type's keys are the same set, or one of them is wrong.
100
+ true;
95
101
  /** Idle capabilities each channel may declare. Only voice may dial, and runtime has to say so too. */
96
102
  const IDLE_CAPABILITIES_BY_CHANNEL = {
97
103
  voice: IDLE_CAPABILITIES,
@@ -266,6 +272,36 @@ function validateIdleCapabilities(value, channel, path, into) {
266
272
  }
267
273
  }
268
274
  }
275
+ /**
276
+ * A provider that dials says how a dial can end, both ways: `answered`, or the desk cannot tell
277
+ * reached from still ringing, and at least one way of not reaching, or it can never say failed.
278
+ * Off voice nothing dials, and the idle dialpad is a dial, so declaring it is declaring this.
279
+ */
280
+ function validateDialOutcomes(manifest, path, into) {
281
+ const declared = manifest.dialOutcomes;
282
+ const dials = isPlainObject(manifest.idleCapabilities) && manifest.idleCapabilities.dial !== undefined;
283
+ if (declared === undefined) {
284
+ into.require(!dials, "manifest.dialOutcomes.required", path, "a manifest that declares dial says how a dial ends: dialOutcomes with answered and at least one way of not reaching");
285
+ return;
286
+ }
287
+ if (!into.require(manifest.channel === "voice", "manifest.dialOutcomes.channel", path, `a ${String(manifest.channel)} provider dials nothing and declares no dial outcomes`))
288
+ return;
289
+ if (!Array.isArray(declared) || declared.length === 0) {
290
+ into.add("manifest.dialOutcomes.shape", path, "dialOutcomes is a non-empty array of the outcomes the platform distinguishes");
291
+ return;
292
+ }
293
+ declared.forEach((outcome, index) => {
294
+ if (into.oneOf(outcome, DIAL_OUTCOMES, "manifest.dialOutcome", `${path}[${index}]`) && declared.indexOf(outcome) !== index) {
295
+ into.add("manifest.dialOutcome.unique", `${path}[${index}]`, `duplicate dial outcome: ${String(outcome)}`);
296
+ }
297
+ });
298
+ into.require(declared.includes("answered"), "manifest.dialOutcomes.answered", path, "a dial that reached its destination is stated, never inferred: dialOutcomes includes answered");
299
+ into.require(declared.some((outcome) => outcome !== "answered"), "manifest.dialOutcomes.failure", path, "a provider that can only say answered can never say a destination was not reached: declare at least one other outcome");
300
+ }
301
+ /** Whether a manifest says how a dial ends, and so may publish tasks that dial. `undefined` where there is no manifest to ask. */
302
+ function manifestDials(manifest) {
303
+ return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
304
+ }
269
305
  export function validateManifest(manifest, path = "manifest") {
270
306
  const into = new Collector();
271
307
  if (!isPlainObject(manifest)) {
@@ -301,6 +337,7 @@ export function validateManifest(manifest, path = "manifest") {
301
337
  }
302
338
  if (channelValid)
303
339
  validateIdleCapabilities(manifest.idleCapabilities, manifest.channel, `${path}.idleCapabilities`, into);
340
+ validateDialOutcomes(manifest, `${path}.dialOutcomes`, into);
304
341
  if (manifest.phaseLabels !== undefined) {
305
342
  if (!isPlainObject(manifest.phaseLabels)) {
306
343
  into.add("manifest.phaseLabels.shape", `${path}.phaseLabels`, "phaseLabels must be an object when present");
@@ -675,6 +712,15 @@ function validateHandlingHistory(value, path, into) {
675
712
  // On `queued` nobody takes part, so there is nothing to name.
676
713
  into.require(entry.step !== "queued", "task.handlingHistory.by.unexpected", `${at}.by`, "a queued step names nobody");
677
714
  }
715
+ // A step that dialled says where to and, when a host placed it, which dial; no other step dialled.
716
+ const dialled = typeof entry.step === "string" && HANDLING_STEPS_THAT_DIAL.includes(entry.step);
717
+ for (const field of ["dialId", "destination"]) {
718
+ if (entry[field] === undefined)
719
+ continue;
720
+ if (into.filled(entry[field], `task.handlingHistory.${field}`, `${at}.${field}`, `${field} must not be empty when present`)) {
721
+ into.require(dialled, `task.handlingHistory.${field}.unexpected`, `${at}.${field}`, `${String(entry.step)} dialled nothing; ${field} belongs on transferred, conferenced, or unanswered`);
722
+ }
723
+ }
678
724
  });
679
725
  }
680
726
  /** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
@@ -687,21 +733,57 @@ function validateTaskMedia(value, channel, path, into) {
687
733
  return;
688
734
  into.oneOf(value, TASK_MEDIA_STATES, "task.media", path);
689
735
  }
690
- function validateConsultation(value, channel, path, into) {
736
+ /**
737
+ * Who is on the call, as the provider states it. Voice only. Each entry is a role with what that
738
+ * role needs and nothing another role would: a party or an agent dialled nowhere, so neither
739
+ * carries a destination; somebody consulted or conferenced came from one, so both do.
740
+ */
741
+ function validateOnCall(value, channel, path, into) {
691
742
  if (value === undefined)
692
743
  return;
693
- if (!into.require(channel === "voice", "task.consultation.channel", path, `a ${channel} task cannot carry a consultation`))
744
+ if (!into.require(channel === "voice", "task.onCall.channel", path, `a ${channel} task has nobody on a call`))
694
745
  return;
695
- if (!isPlainObject(value)) {
696
- into.add("task.consultation.shape", path, "a consultation must be an object when present");
746
+ if (!Array.isArray(value)) {
747
+ into.add("task.onCall.shape", path, "onCall is an array of who is on the call, when the provider knows");
697
748
  return;
698
749
  }
699
- into.filled(value.destination, "task.consultation.destination", `${path}.destination`, "a consultation names the destination being consulted");
700
- if (value.label !== undefined) {
701
- into.filled(value.label, "task.consultation.label", `${path}.label`, "a label must not be empty when present");
702
- }
703
- if (value.since !== undefined)
704
- into.timestamp(value.since, "task.consultation.since", `${path}.since`);
750
+ const counted = { party: 0, consulted: 0 };
751
+ value.forEach((entry, index) => {
752
+ const at = `${path}[${index}]`;
753
+ if (!isPlainObject(entry)) {
754
+ into.add("task.onCall.entry", at, "each entry on the call must be an object");
755
+ return;
756
+ }
757
+ if (!into.oneOf(entry.role, ON_CALL_ROLES, "task.onCall.role", `${at}.role`))
758
+ return;
759
+ const role = entry.role;
760
+ into.timestamp(entry.since, "task.onCall.since", `${at}.since`);
761
+ if (entry.held !== undefined) {
762
+ into.require(entry.held === true, "task.onCall.held", `${at}.held`, "held is stated by presence: send true or omit it");
763
+ }
764
+ if (role === "party" || role === "consulted")
765
+ counted[role] += 1;
766
+ if (role === "agent") {
767
+ into.require(isUserId(entry.userId), "task.onCall.userId", `${at}.userId`, "an agent on the call is named by their user id");
768
+ }
769
+ else {
770
+ into.require(entry.userId === undefined, "task.onCall.userId.unexpected", `${at}.userId`, `a ${role} is not named by a user id`);
771
+ }
772
+ if (role === "consulted" || role === "conferenced") {
773
+ into.filled(entry.destination, "task.onCall.destination", `${at}.destination`, `a ${role} entry names the destination that was dialled`);
774
+ if (entry.dialId !== undefined)
775
+ into.filled(entry.dialId, "task.onCall.dialId", `${at}.dialId`, "dialId must not be empty when present");
776
+ if (entry.label !== undefined)
777
+ into.filled(entry.label, "task.onCall.label", `${at}.label`, "a label must not be empty when present");
778
+ }
779
+ else {
780
+ for (const field of ["destination", "dialId", "label"]) {
781
+ into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
782
+ }
783
+ }
784
+ });
785
+ into.require(counted.party <= 1, "task.onCall.party.single", path, "a call has one party; a second is somebody else's role");
786
+ 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");
705
787
  }
706
788
  const LEAD_STAGES = membersOf({ requested: true, joined: true });
707
789
  /** The agent's request for a lead. Voice only; `joined` names the lead, `requested` cannot. */
@@ -790,7 +872,7 @@ function validateTaskInto(task, context, path, into) {
790
872
  validateBrowsers(task.browsers, `${path}.browsers`, into);
791
873
  validateTaskAttributes(task.attributes, `${path}.attributes`, into);
792
874
  validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
793
- validateConsultation(task.consultation, context.channel, `${path}.consultation`, into);
875
+ validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
794
876
  validateTaskMedia(task.media, context.channel, `${path}.media`, into);
795
877
  validateLead(task.lead, context.channel, `${path}.lead`, into);
796
878
  validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
@@ -814,6 +896,10 @@ function validateTaskInto(task, context, path, into) {
814
896
  continue;
815
897
  if (!into.require(allowed.includes(name), "task.capability.channel", `${path}.capabilities.${name}`, `a ${context.channel} task may not declare ${name}`))
816
898
  continue;
899
+ // A control that dials needs the manifest to have said how a dial ends, or its outcome has no words.
900
+ if (DIALLING_CAPABILITIES.includes(name) && context.dialOutcomesDeclared === false) {
901
+ into.add("task.capability.dialOutcomes.required", `${path}.capabilities.${name}`, `${name} dials, and the manifest declares no dialOutcomes to say how a dial ends`);
902
+ }
817
903
  // A control the queue could allow may stand locked in its place, saying whose. What the
818
904
  // queue provides -- browsers, dispositions, custom controls -- is content, not a control.
819
905
  if (isLocked(declared)) {
@@ -1049,7 +1135,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1049
1135
  const seen = new Set();
1050
1136
  let assisting;
1051
1137
  snapshot.tasks.forEach((task, index) => {
1052
- validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks }, `${path}.tasks[${index}]`, into);
1138
+ validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${path}.tasks[${index}]`, into);
1053
1139
  // A lead assists one call at a time.
1054
1140
  if (isPlainObject(task) && task.assisting !== undefined) {
1055
1141
  if (assisting !== undefined)
@@ -1236,7 +1322,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1236
1322
  validateBreakState(event.break, `${at}.break`, into);
1237
1323
  break;
1238
1324
  case "task-offered":
1239
- validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks }, `${at}.task`, into);
1325
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
1240
1326
  // An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
1241
1327
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1242
1328
  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}`);
@@ -1247,7 +1333,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1247
1333
  }
1248
1334
  break;
1249
1335
  case "task-updated":
1250
- validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks }, `${at}.task`, into);
1336
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
1251
1337
  break;
1252
1338
  case "task-media-started":
1253
1339
  into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
@@ -1259,6 +1345,21 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1259
1345
  into.require(isTaskId(event.taskId), "event.taskEnded.taskId", `${at}.taskId`, "a task id is required");
1260
1346
  validateTaskOutcome(event.outcome, `${at}.outcome`, into);
1261
1347
  break;
1348
+ case "dial-outcome": {
1349
+ into.filled(event.dialId, "event.dialOutcome.dialId", `${at}.dialId`, "an outcome names the dial it ends, by the host's dialId");
1350
+ // Only a distinction the manifest declared: an undeclared word is a guess dressed as a code.
1351
+ const declared = isPlainObject(manifest) && Array.isArray(manifest.dialOutcomes) ? manifest.dialOutcomes : undefined;
1352
+ if (into.oneOf(event.outcome, DIAL_OUTCOMES, "event.dialOutcome.outcome", `${at}.outcome`)) {
1353
+ into.require(declared !== undefined && declared.includes(event.outcome), "event.dialOutcome.undeclared", `${at}.outcome`, `the manifest does not declare ${String(event.outcome)} among its dialOutcomes`);
1354
+ }
1355
+ if (event.taskId !== undefined)
1356
+ into.require(isTaskId(event.taskId), "event.dialOutcome.taskId", `${at}.taskId`, "taskId must be a task id when present");
1357
+ if (event.destination !== undefined)
1358
+ into.filled(event.destination, "event.dialOutcome.destination", `${at}.destination`, "a destination must not be empty when present");
1359
+ if (event.reason !== undefined)
1360
+ into.filled(event.reason, "event.dialOutcome.reason", `${at}.reason`, "a reason must not be empty when present");
1361
+ break;
1362
+ }
1262
1363
  case "announcement":
1263
1364
  into.filled(event.text, "event.announcement.text", `${at}.text`, "an announcement needs text");
1264
1365
  into.timestamp(event.announcedAt, "event.announcement.announcedAt", `${at}.announcedAt`);
@@ -1501,7 +1602,7 @@ export function validateHostReport(report, path = "host") {
1501
1602
  // failure. A method added to `Connection` without a row here is a compile error at the call site.
1502
1603
  const RESULT_STATUSES = {
1503
1604
  execute: { success: "applied", failure: "failed" },
1504
- dial: { success: "dialled", failure: "failed" },
1605
+ dial: { success: "dialling", failure: "failed" },
1505
1606
  setCapacity: { success: "applied", failure: "failed" },
1506
1607
  requestBreak: { success: "requested", failure: "failed" },
1507
1608
  commitBreak: { success: "committed", failure: "failed" },
@@ -1520,9 +1621,12 @@ const RESULT_STATUSES = {
1520
1621
  * shows the agent what it says. A status the method does not answer, a failure status without a
1521
1622
  * failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
1522
1623
  */
1523
- export function validateResult(result, method, path = "result") {
1624
+ export function validateResult(result, method, path = "result", dialId) {
1524
1625
  const into = new Collector();
1525
- const statuses = RESULT_STATUSES[method];
1626
+ // A command that dials -- `dial`, or `execute` with a command carrying a `dialId` -- answers
1627
+ // `dialling` and restates the host's identity, so the host compares and confirms.
1628
+ const dials = method === "dial" || (method === "execute" && dialId !== undefined);
1629
+ const statuses = dials ? { success: "dialling", failure: RESULT_STATUSES[method].failure } : RESULT_STATUSES[method];
1526
1630
  if (!isPlainObject(result)) {
1527
1631
  into.add("result.shape", path, `${method} must answer an object`);
1528
1632
  return into.violations;
@@ -1532,6 +1636,14 @@ export function validateResult(result, method, path = "result") {
1532
1636
  if (method === "openMedia") {
1533
1637
  into.require(isPlainObject(result.session), "result.session", `${path}.session`, "opened carries the media session");
1534
1638
  }
1639
+ if (dials) {
1640
+ if (into.filled(result.dialId, "result.dialId", `${path}.dialId`, "dialling restates the dialId the host sent") && dialId !== undefined) {
1641
+ into.require(result.dialId === dialId, "result.dialId.mismatch", `${path}.dialId`, `the host dialled ${dialId} and the provider answered for ${String(result.dialId)}`);
1642
+ }
1643
+ }
1644
+ else {
1645
+ into.require(result.dialId === undefined, "result.dialId.unexpected", `${path}.dialId`, `${statuses.success} dialled nothing and names no dial`);
1646
+ }
1535
1647
  }
1536
1648
  else if (result.status === statuses.failure) {
1537
1649
  if (result.failure === undefined) {