@xema/omni-protocol 0.1.48 → 0.1.50
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 +19 -5
- package/dist/index.js +1 -1
- package/dist/testing.js +4 -14
- package/dist/validation.d.ts +10 -0
- package/dist/validation.js +181 -2
- package/guide.md +49 -12
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -494,7 +494,8 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
|
|
|
494
494
|
decline?: Lockable<true>;
|
|
495
495
|
mute?: Lockable<true>;
|
|
496
496
|
hold?: Lockable<true>;
|
|
497
|
-
|
|
497
|
+
/** The agent may end the whole call: everyone leaves and the media ends, the task stays for its wrap-up. */
|
|
498
|
+
endCall?: Lockable<true>;
|
|
498
499
|
/** Connect back to the party while `completing`, whoever placed the call; the task returns to `in-progress`. */
|
|
499
500
|
connectBack?: Lockable<true>;
|
|
500
501
|
coldTransfer?: Lockable<DestinationDirectory>;
|
|
@@ -815,7 +816,7 @@ export type TaskOutcome = {
|
|
|
815
816
|
failure: ProtocolFailure;
|
|
816
817
|
};
|
|
817
818
|
export declare const TASK_COMMAND_NAMES: {
|
|
818
|
-
readonly voice: readonly ["answer", "decline", "call", "mute", "hold", "resume", "
|
|
819
|
+
readonly voice: readonly ["answer", "decline", "call", "mute", "hold", "resume", "end-call", "connect-back", "transfer", "lead-assist", "conference", "recording", "complete"];
|
|
819
820
|
readonly chat: readonly ["accept", "decline", "pause", "resume", "complete"];
|
|
820
821
|
readonly email: readonly ["accept", "decline", "complete"];
|
|
821
822
|
};
|
|
@@ -840,8 +841,10 @@ export type VoiceTaskCommand = {
|
|
|
840
841
|
type: "hold";
|
|
841
842
|
} | {
|
|
842
843
|
type: "resume";
|
|
843
|
-
}
|
|
844
|
-
|
|
844
|
+
}
|
|
845
|
+
/** End the whole call: everyone leaves and the task's media ends; the task stays for its wrap-up. Gated by `endCall`. */
|
|
846
|
+
| {
|
|
847
|
+
type: "end-call";
|
|
845
848
|
}
|
|
846
849
|
/** Issuable only in `completing`, under the `connectBack` capability. Dials the party's own number, so it names none. */
|
|
847
850
|
| {
|
|
@@ -900,11 +903,22 @@ export type VoiceTaskCommand = {
|
|
|
900
903
|
dialId: DialId;
|
|
901
904
|
destinationId: string;
|
|
902
905
|
}
|
|
903
|
-
/**
|
|
906
|
+
/**
|
|
907
|
+
* Remove one person from the call, named as `onCall` names them, and the call goes on for the
|
|
908
|
+
* rest: a `conferenced` entry by its `destinationId`, which while it still rings calls the dial
|
|
909
|
+
* off, or the `party`, leaving the agent with the colleague. A remove that would leave the agent
|
|
910
|
+
* alone is `end-call`, and a provider answers it `failed`.
|
|
911
|
+
*/
|
|
904
912
|
| {
|
|
905
913
|
type: "conference";
|
|
906
914
|
action: "remove";
|
|
907
915
|
destinationId: string;
|
|
916
|
+
party?: never;
|
|
917
|
+
} | {
|
|
918
|
+
type: "conference";
|
|
919
|
+
action: "remove";
|
|
920
|
+
party: true;
|
|
921
|
+
destinationId?: never;
|
|
908
922
|
} | {
|
|
909
923
|
type: "recording";
|
|
910
924
|
action: "start" | "pause" | "resume" | "stop";
|
package/dist/index.js
CHANGED
|
@@ -72,7 +72,7 @@ export function effectiveLevels(declared) {
|
|
|
72
72
|
// Task commands.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
74
|
export const TASK_COMMAND_NAMES = {
|
|
75
|
-
voice: ["answer", "decline", "call", "mute", "hold", "resume", "
|
|
75
|
+
voice: ["answer", "decline", "call", "mute", "hold", "resume", "end-call",
|
|
76
76
|
"connect-back", "transfer", "lead-assist", "conference", "recording", "complete"],
|
|
77
77
|
chat: ["accept", "decline", "pause", "resume", "complete"],
|
|
78
78
|
email: ["accept", "decline", "complete"],
|
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
|
|
2
|
-
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateManifest, validateResult, validateSnapshot, validateTimeZone, validatePhone, isTimeZone, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateManifest, validateResult, validateSnapshot, validateTimeZone, validatePhone, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
5
5
|
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
@@ -257,18 +257,6 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
257
257
|
violations.push(...validateHostGuarantees(context.host.guarantees, "context.host.guarantees"));
|
|
258
258
|
// The zone is the host's to state, so a context without one is a host that cannot exist.
|
|
259
259
|
violations.push(...validateTimeZone(context.timeZone, "context.timeZone"));
|
|
260
|
-
// A provider that reads its own clock passes the round trip on any machine in the host's zone,
|
|
261
|
-
// which for a demo and a runner in one datacentre is most of them. A conformance run states a
|
|
262
|
-
// zone this machine is not in, so the only way the identity carries it is that the provider
|
|
263
|
-
// took it from the host.
|
|
264
|
-
if (isTimeZone(context.timeZone)) {
|
|
265
|
-
const canonical = (zone) => new Intl.DateTimeFormat("en", { timeZone: zone }).resolvedOptions().timeZone;
|
|
266
|
-
const local = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
267
|
-
if (canonical(context.timeZone) === canonical(local)) {
|
|
268
|
-
violations.push({ rule: "context.timeZone.local", path: "context.timeZone",
|
|
269
|
-
message: `a conformance run states a zone this machine is not in; ${context.timeZone} is this machine's, so a provider reading its own clock would pass the round trip` });
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
260
|
// How the agent hears the call decides what the host owns: on a softphone the host has the
|
|
273
261
|
// audio and reports it; on a desk phone, or off voice, there is none for it to report.
|
|
274
262
|
violations.push(...validatePhone(context.phone, adapter.manifest, "context.phone"));
|
|
@@ -354,7 +342,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
354
342
|
// The provider republishes the agent's day: once connected, the identity carries the zone the
|
|
355
343
|
// host sent. That proves the round trip, not the store -- an echo passes it -- and the name
|
|
356
344
|
// says only what it tests; storage is proved by a colleague's zone arriving from describeUsers().
|
|
357
|
-
|
|
345
|
+
// A zone is judged by what it denotes: Asia/Kolkata and Asia/Calcutta are one zone, and a
|
|
346
|
+
// provider that keeps the canonical name has kept the zone.
|
|
347
|
+
if (isTimeZone(context.timeZone) && !sameTimeZone(current().identity.timeZone, context.timeZone)) {
|
|
358
348
|
violations.push({
|
|
359
349
|
rule: "authentication.identity.timeZone.republished",
|
|
360
350
|
path: "authentication.identity.timeZone",
|
package/dist/validation.d.ts
CHANGED
|
@@ -63,6 +63,14 @@ export declare function validateHostGuarantees(guarantees: unknown, path?: strin
|
|
|
63
63
|
*/
|
|
64
64
|
export declare function validateHandlingReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];
|
|
65
65
|
export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
|
|
66
|
+
/**
|
|
67
|
+
* What a command needs to be issuable, checked against the task it names: the capability the
|
|
68
|
+
* guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
|
|
69
|
+
* consulted entry, a lead requested, somebody else on the call. Without a task only the command's
|
|
70
|
+
* own shape is checked. A host validates before sending, and an adapter before acting: a command
|
|
71
|
+
* for a control the task never offered is the host's error, and this names it.
|
|
72
|
+
*/
|
|
73
|
+
export declare function validateTaskCommand(command: unknown, task?: unknown, path?: string): ProtocolViolation[];
|
|
66
74
|
/** The connection methods whose results `validateResult` knows. */
|
|
67
75
|
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamLeadAssist" | "executeTeamMonitor" | "openMedia" | "setPreference" | "recordStep" | "executeTeamPolicy";
|
|
68
76
|
/**
|
|
@@ -74,6 +82,8 @@ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" |
|
|
|
74
82
|
export declare function validateResult(result: unknown, method: ResultMethod, path?: string, dialId?: string): ProtocolViolation[];
|
|
75
83
|
/** An IANA zone name the runtime knows: `Asia/Kolkata` is one, an offset or a made-up name is not. */
|
|
76
84
|
export declare function isTimeZone(value: unknown): value is string;
|
|
85
|
+
/** Whether two zone names denote one zone: `Asia/Kolkata` and `Asia/Calcutta` do, and a name is judged by what it denotes. */
|
|
86
|
+
export declare function sameTimeZone(a: unknown, b: unknown): boolean;
|
|
77
87
|
/** The zone a host sends at connect: required, and an IANA name. */
|
|
78
88
|
export declare function validateTimeZone(value: unknown, path?: string): ProtocolViolation[];
|
|
79
89
|
/** What a login is validated against beyond its own shape. */
|
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 { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, MONITORING_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";
|
|
12
|
+
import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, MONITORING_BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HANDLING_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") {
|
|
@@ -96,7 +96,7 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
|
|
|
96
96
|
const TASK_CAPABILITIES = {
|
|
97
97
|
voice: membersOf({
|
|
98
98
|
browsers: true, dispositions: true, custom: true, decline: true, mute: true, hold: true,
|
|
99
|
-
|
|
99
|
+
endCall: true, connectBack: true, coldTransfer: true, warmTransfer: true, leadAssist: true, conference: true, recording: true,
|
|
100
100
|
}),
|
|
101
101
|
chat: membersOf({ browsers: true, dispositions: true, custom: true, decline: true, hold: true }),
|
|
102
102
|
email: membersOf({ browsers: true, dispositions: true, custom: true, decline: true }),
|
|
@@ -1710,6 +1710,178 @@ export function validateHostReport(report, path = "host") {
|
|
|
1710
1710
|
}
|
|
1711
1711
|
return into.violations;
|
|
1712
1712
|
}
|
|
1713
|
+
// ---------------------------------------------------------------------------
|
|
1714
|
+
// Commands.
|
|
1715
|
+
// ---------------------------------------------------------------------------
|
|
1716
|
+
const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
|
|
1717
|
+
const LEAD_ASSIST_ACTIONS = ["request", "cancel", "take-over", "leave"];
|
|
1718
|
+
const CONFERENCE_ACTIONS = ["add", "remove"];
|
|
1719
|
+
const RECORDING_ACTIONS = ["start", "pause", "resume", "stop"];
|
|
1720
|
+
/**
|
|
1721
|
+
* What a command needs to be issuable, checked against the task it names: the capability the
|
|
1722
|
+
* guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
|
|
1723
|
+
* consulted entry, a lead requested, somebody else on the call. Without a task only the command's
|
|
1724
|
+
* own shape is checked. A host validates before sending, and an adapter before acting: a command
|
|
1725
|
+
* for a control the task never offered is the host's error, and this names it.
|
|
1726
|
+
*/
|
|
1727
|
+
export function validateTaskCommand(command, task, path = "command") {
|
|
1728
|
+
const into = new Collector();
|
|
1729
|
+
if (!isPlainObject(command)) {
|
|
1730
|
+
into.add("command.shape", path, "a command must be an object");
|
|
1731
|
+
return into.violations;
|
|
1732
|
+
}
|
|
1733
|
+
const type = command.type;
|
|
1734
|
+
const channel = isPlainObject(task) && typeof task.channel === "string" && isChannel(task.channel) ? task.channel : undefined;
|
|
1735
|
+
const names = channel !== undefined
|
|
1736
|
+
? TASK_COMMAND_NAMES[channel]
|
|
1737
|
+
: [...new Set([...TASK_COMMAND_NAMES.voice, ...TASK_COMMAND_NAMES.chat, ...TASK_COMMAND_NAMES.email])];
|
|
1738
|
+
if (type === "custom") {
|
|
1739
|
+
into.filled(command.name, "command.custom.name", `${path}.name`, "a custom command names the control it presses");
|
|
1740
|
+
return into.violations;
|
|
1741
|
+
}
|
|
1742
|
+
if (!into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${String(type)}` : `a ${channel} task has no ${String(type)} command`)) {
|
|
1743
|
+
return into.violations;
|
|
1744
|
+
}
|
|
1745
|
+
const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
|
|
1746
|
+
switch (type) {
|
|
1747
|
+
case "mute":
|
|
1748
|
+
into.require(typeof command.muted === "boolean", "command.mute.muted", `${path}.muted`, "mute says whether the agent is muted");
|
|
1749
|
+
break;
|
|
1750
|
+
case "call":
|
|
1751
|
+
dial("command.call.dialId");
|
|
1752
|
+
break;
|
|
1753
|
+
case "connect-back":
|
|
1754
|
+
dial("command.connectBack.dialId");
|
|
1755
|
+
break;
|
|
1756
|
+
case "transfer":
|
|
1757
|
+
if (into.oneOf(command.action, TRANSFER_ACTIONS, "command.transfer.action", `${path}.action`)) {
|
|
1758
|
+
if (command.action === "cold" || command.action === "warm") {
|
|
1759
|
+
dial("command.transfer.dialId");
|
|
1760
|
+
into.filled(command.destinationId, "command.transfer.destinationId", `${path}.destinationId`, "a transfer names the directory item it goes to");
|
|
1761
|
+
}
|
|
1762
|
+
else {
|
|
1763
|
+
into.require(command.dialId === undefined && command.destinationId === undefined, "command.transfer.unexpected", path, `${String(command.action)} names no destination: there is exactly one it could mean`);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
break;
|
|
1767
|
+
case "lead-assist":
|
|
1768
|
+
into.oneOf(command.action, LEAD_ASSIST_ACTIONS, "command.leadAssist.action", `${path}.action`);
|
|
1769
|
+
if (command.note !== undefined)
|
|
1770
|
+
into.filled(command.note, "command.leadAssist.note", `${path}.note`, "a note must not be empty when present");
|
|
1771
|
+
break;
|
|
1772
|
+
case "conference":
|
|
1773
|
+
if (into.oneOf(command.action, CONFERENCE_ACTIONS, "command.conference.action", `${path}.action`)) {
|
|
1774
|
+
if (command.action === "add") {
|
|
1775
|
+
dial("command.conference.dialId");
|
|
1776
|
+
into.filled(command.destinationId, "command.conference.destinationId", `${path}.destinationId`, "a conference names the directory item it dials in");
|
|
1777
|
+
}
|
|
1778
|
+
else {
|
|
1779
|
+
const party = command.party === true;
|
|
1780
|
+
const item = isFilled(command.destinationId);
|
|
1781
|
+
into.require(party !== item, "command.conference.remove.target", path, "a remove names one person: the party, or a conferenced entry by its destinationId, never both and never neither");
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
break;
|
|
1785
|
+
case "recording":
|
|
1786
|
+
into.oneOf(command.action, RECORDING_ACTIONS, "command.recording.action", `${path}.action`);
|
|
1787
|
+
break;
|
|
1788
|
+
case "complete":
|
|
1789
|
+
if (command.disposition !== undefined)
|
|
1790
|
+
into.filled(command.disposition, "command.complete.disposition", `${path}.disposition`, "a disposition must not be empty when present");
|
|
1791
|
+
if (command.notes !== undefined)
|
|
1792
|
+
into.require(typeof command.notes === "string", "command.complete.notes", `${path}.notes`, "notes must be a string when present");
|
|
1793
|
+
break;
|
|
1794
|
+
default:
|
|
1795
|
+
break;
|
|
1796
|
+
}
|
|
1797
|
+
if (!isPlainObject(task))
|
|
1798
|
+
return into.violations;
|
|
1799
|
+
// What the task has to offer or be in for the command to be issuable.
|
|
1800
|
+
const capabilities = isPlainObject(task.capabilities) ? task.capabilities : {};
|
|
1801
|
+
const offered = (name) => {
|
|
1802
|
+
const declared = capabilities[name];
|
|
1803
|
+
if (declared === undefined) {
|
|
1804
|
+
into.add(`command.capability.${name}`, path, `${String(type)} needs the ${name} capability, and the task does not offer it`);
|
|
1805
|
+
return false;
|
|
1806
|
+
}
|
|
1807
|
+
if (isLocked(declared)) {
|
|
1808
|
+
into.add("command.capability.locked", path, `${name} stands locked by ${String(declared.lockedBy)}: the control is present without permission`);
|
|
1809
|
+
return false;
|
|
1810
|
+
}
|
|
1811
|
+
return true;
|
|
1812
|
+
};
|
|
1813
|
+
const inPhase = (rule, ...phases) => into.require(phases.includes(String(task.phase)), rule, path, `${String(type)} belongs to ${phases.join(" or ")}, and the task is ${String(task.phase)}`);
|
|
1814
|
+
const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
|
|
1815
|
+
switch (type) {
|
|
1816
|
+
case "answer":
|
|
1817
|
+
case "accept":
|
|
1818
|
+
case "decline":
|
|
1819
|
+
case "reject":
|
|
1820
|
+
inPhase("command.phase.pending", "pending");
|
|
1821
|
+
if (type === "decline")
|
|
1822
|
+
offered("decline");
|
|
1823
|
+
break;
|
|
1824
|
+
case "call":
|
|
1825
|
+
inPhase("command.phase.preview", "preview");
|
|
1826
|
+
break;
|
|
1827
|
+
case "mute":
|
|
1828
|
+
offered("mute");
|
|
1829
|
+
break;
|
|
1830
|
+
case "hold":
|
|
1831
|
+
case "resume":
|
|
1832
|
+
case "pause":
|
|
1833
|
+
offered("hold");
|
|
1834
|
+
break;
|
|
1835
|
+
case "end-call":
|
|
1836
|
+
offered("endCall");
|
|
1837
|
+
break;
|
|
1838
|
+
case "recording":
|
|
1839
|
+
offered("recording");
|
|
1840
|
+
break;
|
|
1841
|
+
case "connect-back":
|
|
1842
|
+
offered("connectBack");
|
|
1843
|
+
inPhase("command.phase.completing", "completing");
|
|
1844
|
+
break;
|
|
1845
|
+
case "transfer":
|
|
1846
|
+
if (command.action === "cold")
|
|
1847
|
+
offered("coldTransfer");
|
|
1848
|
+
else if (command.action === "warm")
|
|
1849
|
+
offered("warmTransfer");
|
|
1850
|
+
else if (command.action === "complete" || command.action === "cancel") {
|
|
1851
|
+
into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${String(command.action)} needs a consulted entry on onCall: without one there is nothing to complete or cancel`);
|
|
1852
|
+
}
|
|
1853
|
+
break;
|
|
1854
|
+
case "lead-assist":
|
|
1855
|
+
if (command.action === "request" || command.action === "cancel") {
|
|
1856
|
+
offered("leadAssist");
|
|
1857
|
+
if (command.action === "cancel") {
|
|
1858
|
+
into.require(isPlainObject(task.leadAssist) && task.leadAssist.stage === "requested", "command.leadAssist.requested", path, "cancel needs a request standing: leadAssist with stage requested");
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
else {
|
|
1862
|
+
into.require(task.assisting !== undefined, "command.leadAssist.assisting", path, `${String(command.action)} is the lead's own act on a call they joined: the task carries assisting`);
|
|
1863
|
+
}
|
|
1864
|
+
break;
|
|
1865
|
+
case "conference":
|
|
1866
|
+
if (offered("conference") && command.action === "remove") {
|
|
1867
|
+
const others = onCall.filter(entry => entry.role !== "agent");
|
|
1868
|
+
if (command.party === true) {
|
|
1869
|
+
into.require(others.some(entry => entry.role !== "party"), "command.conference.remove.alone", path, "removing the party would leave the agent alone: that is end-call");
|
|
1870
|
+
}
|
|
1871
|
+
else if (isFilled(command.destinationId)) {
|
|
1872
|
+
into.require(onCall.some(entry => entry.role === "conferenced" && entry.destinationId === command.destinationId), "command.conference.remove.unknown", path, `no conferenced entry on onCall has destinationId ${String(command.destinationId)}`);
|
|
1873
|
+
into.require(others.length > 1, "command.conference.remove.alone", path, "removing the last other person would leave the agent alone: that is end-call");
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
break;
|
|
1877
|
+
case "complete":
|
|
1878
|
+
into.require(task.completionMode === "agent-command", "command.complete.mode", path, "complete belongs to agent-command; a provider-automatic task completes itself");
|
|
1879
|
+
break;
|
|
1880
|
+
default:
|
|
1881
|
+
break;
|
|
1882
|
+
}
|
|
1883
|
+
return into.violations;
|
|
1884
|
+
}
|
|
1713
1885
|
// Pinned to the result unions: each method's one success status, and the status that carries a
|
|
1714
1886
|
// failure. A method added to `Connection` without a row here is a compile error at the call site.
|
|
1715
1887
|
const RESULT_STATUSES = {
|
|
@@ -1788,6 +1960,13 @@ export function isTimeZone(value) {
|
|
|
1788
1960
|
return false;
|
|
1789
1961
|
}
|
|
1790
1962
|
}
|
|
1963
|
+
/** Whether two zone names denote one zone: `Asia/Kolkata` and `Asia/Calcutta` do, and a name is judged by what it denotes. */
|
|
1964
|
+
export function sameTimeZone(a, b) {
|
|
1965
|
+
if (!isTimeZone(a) || !isTimeZone(b))
|
|
1966
|
+
return false;
|
|
1967
|
+
const canonical = (zone) => new Intl.DateTimeFormat("en", { timeZone: zone }).resolvedOptions().timeZone;
|
|
1968
|
+
return a === b || canonical(a) === canonical(b);
|
|
1969
|
+
}
|
|
1791
1970
|
/** The zone a host sends at connect: required, and an IANA name. */
|
|
1792
1971
|
export function validateTimeZone(value, path = "context.timeZone") {
|
|
1793
1972
|
const into = new Collector();
|
package/guide.md
CHANGED
|
@@ -26,7 +26,7 @@ are used precisely throughout and mean nothing looser here.
|
|
|
26
26
|
| **Task** | One unit of assigned work — a call, a chat, a mail. |
|
|
27
27
|
| **Channel** | The kind of work a provider carries: `voice`, `chat`, or `email`. Fixed per provider by its manifest. |
|
|
28
28
|
| **Task type** | The provider's own name for a category of work — a queue, a mailbox folder, a chat source. Free-form, and finer-grained than a channel. |
|
|
29
|
-
| **Capability** | A provider's declaration that a control exists for a task or a session. It says *offer this
|
|
29
|
+
| **Capability** | A provider's declaration that a control exists for a task or a session. It says *offer this*; the provider performs it, and the host only offers it — see **Where a command executes**. |
|
|
30
30
|
| **Login** | One authenticated sign-in to one provider, identified by `loginId`. A transport reconnect keeps it; signing in again replaces it, and nothing tied to the old `loginId` survives. |
|
|
31
31
|
| **Transport** | The adapter's connection to its platform: a WebSocket or SignalR connection, required to be persistent and ordered. Which one and how it reconnects are the adapter's business; losing it does not end a login. |
|
|
32
32
|
| **Connection** | The `Connection` object Omni holds for one login: the methods it can call and the events it receives. |
|
|
@@ -486,7 +486,7 @@ type TaskCapabilities<C extends Channel = Channel> =
|
|
|
486
486
|
decline?: Lockable<true>;
|
|
487
487
|
mute?: Lockable<true>;
|
|
488
488
|
hold?: Lockable<true>;
|
|
489
|
-
|
|
489
|
+
endCall?: Lockable<true>;
|
|
490
490
|
connectBack?: Lockable<true>;
|
|
491
491
|
coldTransfer?: Lockable<DestinationDirectory>;
|
|
492
492
|
warmTransfer?: Lockable<DestinationDirectory>;
|
|
@@ -692,7 +692,7 @@ const TASK_COMMAND_NAMES = {
|
|
|
692
692
|
"mute",
|
|
693
693
|
"hold",
|
|
694
694
|
"resume",
|
|
695
|
-
"
|
|
695
|
+
"end-call",
|
|
696
696
|
"connect-back",
|
|
697
697
|
"transfer",
|
|
698
698
|
"lead-assist",
|
|
@@ -716,7 +716,7 @@ type VoiceTaskCommand =
|
|
|
716
716
|
| { type: "mute"; muted: boolean }
|
|
717
717
|
| { type: "hold" }
|
|
718
718
|
| { type: "resume" }
|
|
719
|
-
| { type: "
|
|
719
|
+
| { type: "end-call" }
|
|
720
720
|
| { type: "connect-back"; dialId: DialId }
|
|
721
721
|
| { type: "transfer"; action: "cold"; dialId: DialId; destinationId: string }
|
|
722
722
|
| { type: "transfer"; action: "warm"; dialId: DialId; destinationId: string }
|
|
@@ -727,7 +727,8 @@ type VoiceTaskCommand =
|
|
|
727
727
|
| { type: "lead-assist"; action: "take-over" }
|
|
728
728
|
| { type: "lead-assist"; action: "leave" }
|
|
729
729
|
| { type: "conference"; action: "add"; dialId: DialId; destinationId: string }
|
|
730
|
-
| { type: "conference"; action: "remove"; destinationId: string }
|
|
730
|
+
| { type: "conference"; action: "remove"; destinationId: string; party?: never }
|
|
731
|
+
| { type: "conference"; action: "remove"; party: true; destinationId?: never }
|
|
731
732
|
| { type: "recording"; action: "start" | "pause" | "resume" | "stop" }
|
|
732
733
|
| ({ type: "complete" } & DispositionPayload);
|
|
733
734
|
|
|
@@ -1939,10 +1940,9 @@ field to fill in later: an identity without one is refused (`authentication.iden
|
|
|
1939
1940
|
so is a zone that is not an IANA name, wherever it appears. Nothing is ever assumed in its place --
|
|
1940
1941
|
not the viewer's browser, which gives a different answer per reader for the same record, and not the
|
|
1941
1942
|
provider's clock. The harness holds a provider to the round trip: the identity carries the zone the
|
|
1942
|
-
host stated (`authentication.identity.timeZone.republished`),
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
in one datacentre is most of them. **The round trip is not the store.**
|
|
1943
|
+
host stated (`authentication.identity.timeZone.republished`), judged by what the name denotes:
|
|
1944
|
+
`Asia/Kolkata` and `Asia/Calcutta` are one zone, and a provider that keeps the canonical name has
|
|
1945
|
+
kept the zone. **The round trip is not the store.**
|
|
1946
1946
|
An adapter that echoes the stated zone back onto the identity passes that check with nothing kept,
|
|
1947
1947
|
and a lead reading a colleague's day would still get the wrong one. What proves the store is a zone
|
|
1948
1948
|
the run never sent: a colleague's `User` from `describeUsers()` carrying theirs. A provider's own
|
|
@@ -2681,12 +2681,12 @@ const taskCapabilities = {
|
|
|
2681
2681
|
| `decline` | Pending-task button: Decline | The provider can decline a pending voice offer. Omni shows it only when provisioning also permits declining. |
|
|
2682
2682
|
| `mute` | Primary toggle: Mute | Omni may mute and unmute the agent's outbound audio. |
|
|
2683
2683
|
| `hold` | Primary toggle: Hold | Omni may issue voice-task `hold` and `resume` commands. |
|
|
2684
|
-
| `
|
|
2684
|
+
| `endCall` | Primary button: End call | Omni may end the whole call: everyone leaves, the media ends, and the task stays for its wrap-up. See **Ending a call, and removing one person from it**. |
|
|
2685
2685
|
| `connectBack` | Completing-task button: Connect back | Omni may have the provider connect the agent back to the task's party while the task is `completing`, returning it to `in-progress` on the same task. Not offered where there is no `completing` window: `provider-automatic` with a zero allowance disposes at provider end. See **Connecting back during completion**. |
|
|
2686
2686
|
| `coldTransfer` | Secondary menu item: Cold transfer | Omni may hand the customer straight to a destination, with nobody spoken to first. |
|
|
2687
2687
|
| `warmTransfer` | Secondary menu item: Warm transfer | Omni may park the customer and call a destination first, then hand the customer over or cancel back. See **Warm transfer**. |
|
|
2688
2688
|
| `leadAssist` | Secondary menu item: Lead assist | Omni may ask a lead to join this call, with a note. The lead's decision reaches the agent on `Task.leadAssist`. See **Lead assist**. |
|
|
2689
|
-
| `conference` | Secondary button: Conference | Omni may dial a destination into the active call, and
|
|
2689
|
+
| `conference` | Secondary button: Conference | Omni may dial a destination into the active call, and remove one person from it -- a conferenced entry, one still ringing included, which calls the dial off, or the party, leaving the agent with the colleague. See **Ending a call, and removing one person from it**. |
|
|
2690
2690
|
| `recording` | Overflow menu item: Recording | Omni may expose start, pause, resume, and stop recording controls. |
|
|
2691
2691
|
| `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
|
|
2692
2692
|
|
|
@@ -2752,6 +2752,27 @@ chosen colleague bypasses all three. The desk shows no agent picker and no dial
|
|
|
2752
2752
|
The one way a named person ends up with an agent's call is a lead taking it over through lead
|
|
2753
2753
|
assist, which is the lead's act, not a transfer the agent chose.
|
|
2754
2754
|
|
|
2755
|
+
#### Ending a call, and removing one person from it
|
|
2756
|
+
|
|
2757
|
+
A call has everyone on `Task.onCall`, and two commands take people off it, both performed by the
|
|
2758
|
+
provider.
|
|
2759
|
+
|
|
2760
|
+
```ts
|
|
2761
|
+
{ type: "end-call" } // everyone leaves; the media ends; the task stays for its wrap-up
|
|
2762
|
+
{ type: "conference", action: "remove", party: true } // the customer leaves; the agent stays with the colleague
|
|
2763
|
+
{ type: "conference", action: "remove", destinationId: "tier2" } // the conferenced person leaves; ringing, this calls the dial off
|
|
2764
|
+
```
|
|
2765
|
+
|
|
2766
|
+
`end-call` ends the whole conversation for everyone on it, and is gated by `endCall`. The task does
|
|
2767
|
+
not end with it: `task-media-ended` follows, any wrap allowance runs, and the agent dispositions
|
|
2768
|
+
the call. `conference` `remove` takes one person off, named as the room names them, and the call
|
|
2769
|
+
goes on for the rest; it is gated by `conference`, since it only means something with a third
|
|
2770
|
+
person on the line. Removing the party leaves the agent with the colleague, a warm hand-over in
|
|
2771
|
+
reverse. A remove that would leave the agent alone is not a remove but an `end-call`, and a
|
|
2772
|
+
provider answers it `failed`. The consulted destination of a warm transfer is not removed this way:
|
|
2773
|
+
`transfer` `cancel` is its own step, which returns the agent to the parked customer, and a remove
|
|
2774
|
+
never promises that.
|
|
2775
|
+
|
|
2755
2776
|
#### Warm transfer
|
|
2756
2777
|
|
|
2757
2778
|
A warm transfer parks the customer, calls the destination so the agent can speak to it first, and
|
|
@@ -3824,7 +3845,17 @@ already did.
|
|
|
3824
3845
|
| Command | The provider's part |
|
|
3825
3846
|
| --- | --- |
|
|
3826
3847
|
| `mute` | **Record it, and keep the history.** The microphone is the host's, so Omni has already stopped the audio through `VoiceMediaSession.setMuted()` — no adapter can do that on the host's behalf. The command still arrives because the provider owns the task's record: it holds the current state for supervision, and each change as a `muted` handling step, exactly as it does for `held`. A platform that never hears about it shows a supervisor an agent who sounds absent for no reason, and reports a call with a silence it cannot explain. |
|
|
3827
|
-
| Every other command | **Perform it.** `hold`, `transfer`, `conference`, `
|
|
3848
|
+
| Every other command | **Perform it.** `hold`, `transfer`, `conference`, `end-call`, `recording` and the rest act on the platform's own call leg, its bridge, or its record of the task. Nothing has happened until the provider applies them. |
|
|
3849
|
+
|
|
3850
|
+
**The provider performs every action; the host only offers it.** A control drawn on the host is
|
|
3851
|
+
an affordance, never the enforcement: the host asks, and the provider does or declines. A provider
|
|
3852
|
+
answers `failed` for a command whose capability it did not publish, and that is the provider
|
|
3853
|
+
honouring its own declaration, not the host deciding policy -- a host that refuses a command on
|
|
3854
|
+
its own reading of a queue's flag has decided something that was never its to decide. What a
|
|
3855
|
+
command means on the platform is the provider's to work out: `end-call` ends the conversation, and
|
|
3856
|
+
which legs on which bridge that touches is a fact about the switch, never a choice the host makes.
|
|
3857
|
+
The one leg the host performs physically is the microphone, and even there the provider owns the
|
|
3858
|
+
record.
|
|
3828
3859
|
|
|
3829
3860
|
**A failed `mute` does not unmute the agent.** The agent asked, Omni holds the microphone, and it
|
|
3830
3861
|
is already done; a failure means only that the provider did not record it, leaving its view stale
|
|
@@ -3843,6 +3874,8 @@ declared:
|
|
|
3843
3874
|
| Command | What makes it available |
|
|
3844
3875
|
| --- | --- |
|
|
3845
3876
|
| `answer`, `accept` | Nothing. A task that was offered can be accepted, or offering it meant nothing. |
|
|
3877
|
+
| `end-call` | The `endCall` capability. |
|
|
3878
|
+
| `conference` with `action: "remove"` | The `conference` capability, and somebody else on the call: a remove that would leave the agent alone is `end-call`, and a provider answers it `failed`. |
|
|
3846
3879
|
| `decline` | The `decline` capability on any channel, **and** Omni provisioning permitting it. One word for refusing an offer, whatever the channel. |
|
|
3847
3880
|
| `call` | The `preview` phase. A record put in front of an agent is there to be called, so the phase is the gate and there is no capability. It is a dial, with a `dialId` and a `dial-outcome`. |
|
|
3848
3881
|
| `complete` | `completionMode: "agent-command"`. The `dispositions` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. |
|
|
@@ -3853,6 +3886,9 @@ declared:
|
|
|
3853
3886
|
| `lead-assist` with `action: "take-over"` or `"leave"` | The lead's own task, on a call they joined -- `Task.assisting` present. An agent's task never has it, and a provider that receives either without it answers `failed`. |
|
|
3854
3887
|
| Everything else | Its own named capability. |
|
|
3855
3888
|
|
|
3889
|
+
`validateTaskCommand(command, task)` holds a command to this table at runtime, both ways: the
|
|
3890
|
+
capability it needs, the phase it belongs to, and the state that has to stand.
|
|
3891
|
+
|
|
3856
3892
|
Declining or rejecting a pending offer ends it without accepting or completing it. The provider
|
|
3857
3893
|
confirms the end with `task-ended` and a `cancelled` outcome.
|
|
3858
3894
|
|
|
@@ -4205,6 +4241,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
4205
4241
|
| `validateHostGuarantees(guarantees)` | What a host promises: only the guarantees this contract names, each declared by presence and never `false`. The harness validates the guarantees of whatever host a test hands the adapter. |
|
|
4206
4242
|
| `validateHandlingReport(report, path?, manifest?)` | What the host reports of a leg it performed, for an adapter to check before forwarding: a task, a step, when it began, a positive `seconds` where stated, and an explicit `ended` that carries the final duration. Given the manifest, a running report is refused unless it declares `runningStepReports`. |
|
|
4207
4243
|
| `validateHostReport(report)` | The host's own report as published to an adapter: `online`, and where there is audio, an input that is `available` with the microphone and `flowing`, or `unavailable` with a reason and the failure that says why, and an output that is `available` or `unavailable` with its failure. The harness validates whatever host a test hands the adapter; `stillHost(report)` builds one that never changes. |
|
|
4244
|
+
| `validateTaskCommand(command, task?)` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a transfer's item, a remove naming exactly one person -- and, with the task, the capability the table above gates it on (`command.capability.<name>`, `.locked`), the phase it belongs to (`command.phase.*`), and the state that has to stand: a consulted entry, a lead requested, somebody else still on the call (`command.conference.remove.alone`). A host validates before sending and an adapter before acting. |
|
|
4208
4245
|
| `validateResult(result, method)` | What a connection method answered: the status it gives, a failure where the status says so and nowhere else, the failure's shape, and that an `omni.` code is one this contract names. |
|
|
4209
4246
|
| `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. Omni applies it to every state a session publishes — the republished as much as the first. |
|
|
4210
4247
|
|