@xema/omni-protocol 0.1.114 → 0.1.116
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 +25 -19
- package/dist/index.js +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +30 -10
- package/dist/validation.d.ts +4 -4
- package/dist/validation.js +38 -21
- package/guide/agent.md +22 -19
- package/guide/breaks.md +2 -2
- package/guide/lead.md +11 -11
- package/guide/phone.md +36 -27
- package/guide/queue.md +6 -6
- package/guide/voice.md +8 -8
- package/guide.md +52 -49
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ export type UserId = string;
|
|
|
30
30
|
* still speaking about it -- a lead names a member's assignment from another login, so the scope is
|
|
31
31
|
* the provider, not the login. What the platform calls its own record, and whether it reuses that
|
|
32
32
|
* name, is the adapter's business: a platform that reoffers a closed call under the same handle
|
|
33
|
-
* seconds later is why the adapter, not the
|
|
33
|
+
* seconds later is why the adapter, not the agent computer, is held to this. Scope it with `assignmentKey()`.
|
|
34
34
|
*/
|
|
35
35
|
export type AssignmentId = string;
|
|
36
36
|
/**
|
|
@@ -106,12 +106,12 @@ export type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "r
|
|
|
106
106
|
export declare const DIAL_OUTCOMES: readonly ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled", "unexplained"];
|
|
107
107
|
/**
|
|
108
108
|
* How the agent hears the call: on a `softphone`, where the host owns the audio and opens it
|
|
109
|
-
* through `openAudio`, or on a `
|
|
109
|
+
* through `openAudio`, or on a `hardphone`, a handset the platform rings, where the host opens
|
|
110
110
|
* nothing and only shows the call. A voice manifest lists the phones its platform supports, and
|
|
111
111
|
* the host picks one for the login at authentication.
|
|
112
112
|
*/
|
|
113
|
-
export type Phone = "softphone" | "
|
|
114
|
-
export declare const PHONES: readonly ["softphone", "
|
|
113
|
+
export type Phone = "softphone" | "hardphone";
|
|
114
|
+
export declare const PHONES: readonly ["softphone", "hardphone"];
|
|
115
115
|
/**
|
|
116
116
|
* The device itself, as the platform sees it. `ready` is registered and usable, idle or busy; the
|
|
117
117
|
* other three are the states in which no call can land: the platform has lost the phone's
|
|
@@ -138,14 +138,14 @@ export interface PhoneChannel {
|
|
|
138
138
|
* is on, whether it can take a call, its own mute where the platform observes it, and its channels
|
|
139
139
|
* -- one active at most, any number held. The task side says which of those calls are the agent's
|
|
140
140
|
* work and where each stands; the phone side exists whether or not a task is behind a call. `muted`
|
|
141
|
-
* is the phone's own button as the platform sees it, silencing whatever is active: a
|
|
141
|
+
* is the phone's own button as the platform sees it, silencing whatever is active: a hardphone's,
|
|
142
142
|
* never a softphone's, whose microphone is the host's and whose mute is the host's report.
|
|
143
143
|
*/
|
|
144
144
|
export interface PhoneState {
|
|
145
145
|
/** The agent's phone for this provider, as the host chose it for the login at connect: which of the manifest's phones the agent hears this provider's calls on. */
|
|
146
146
|
phone: Phone;
|
|
147
147
|
status: PhoneStatus;
|
|
148
|
-
/** The phone's own mute, on a
|
|
148
|
+
/** The phone's own mute, on a hardphone the platform observes; absent where it does not, or on a softphone. */
|
|
149
149
|
muted?: true;
|
|
150
150
|
/** When the status began, counted from the provider's clock. Omitted rather than invented. */
|
|
151
151
|
since?: IsoTimestamp;
|
|
@@ -195,7 +195,7 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
195
195
|
/** Optional provider-clock sampling through Connection.checkTime; absent means unsupported. */
|
|
196
196
|
/**
|
|
197
197
|
* Implements `checkTime`, and states `providerTime` on every snapshot. Required of a provider
|
|
198
|
-
* that publishes an instant the
|
|
198
|
+
* that publishes an instant the agent computer renders as a running duration -- a member's `since`, a
|
|
199
199
|
* lead's `listening.since`, an `onCall.since`, a shift's `signedInAt` -- so every screen counts
|
|
200
200
|
* from the provider's clock and two screens differ by their delay alone.
|
|
201
201
|
*/
|
|
@@ -569,6 +569,12 @@ export interface HostGuarantees {
|
|
|
569
569
|
browserUrlVisibility?: true;
|
|
570
570
|
/** A `consent` offer is accepted only by the person's own explicit act, never on their behalf. */
|
|
571
571
|
personConsent?: true;
|
|
572
|
+
/**
|
|
573
|
+
* The host's Mute is its own control on any call and is never offered while the task is
|
|
574
|
+
* `paused`; a mute standing when a hold begins stands through it. Required of a softphone
|
|
575
|
+
* login's host and refused of any other, since a hardphone's mute is the handset's.
|
|
576
|
+
*/
|
|
577
|
+
muteUnavailableOnHold?: true;
|
|
572
578
|
}
|
|
573
579
|
export interface ProviderTimeScope {
|
|
574
580
|
providerId: string;
|
|
@@ -587,7 +593,7 @@ export interface Host {
|
|
|
587
593
|
/** Voice softphone recording support; policy and destination remain per task. */
|
|
588
594
|
recording?: HostRecording;
|
|
589
595
|
guarantees: HostGuarantees;
|
|
590
|
-
/** What pressing Mute does on this host. Stated on a softphone login, where the host holds the microphone; absent on a
|
|
596
|
+
/** What pressing Mute does on this host. Stated on a softphone login, where the host holds the microphone; absent on a hardphone and off voice. */
|
|
591
597
|
mute?: HostMute;
|
|
592
598
|
report(): HostReport;
|
|
593
599
|
subscribe(listener: (report: HostReport) => void): Unsubscribe;
|
|
@@ -618,7 +624,7 @@ export type ConnectContext = {
|
|
|
618
624
|
log?: (entry: unknown) => void;
|
|
619
625
|
} & (
|
|
620
626
|
/**
|
|
621
|
-
* On a softphone the host holds the microphone and states what its Mute does; on a
|
|
627
|
+
* On a softphone the host holds the microphone and states what its Mute does; on a hardphone,
|
|
622
628
|
* or off voice, it holds none and states nothing. The arms make the omission a compile error
|
|
623
629
|
* where a live seat would otherwise be the first to find it.
|
|
624
630
|
*/
|
|
@@ -628,7 +634,7 @@ export type ConnectContext = {
|
|
|
628
634
|
mute: HostMute;
|
|
629
635
|
};
|
|
630
636
|
} | {
|
|
631
|
-
phone?: "
|
|
637
|
+
phone?: "hardphone";
|
|
632
638
|
host: {
|
|
633
639
|
mute?: never;
|
|
634
640
|
recording?: never;
|
|
@@ -812,7 +818,7 @@ export type HistoryStep = "queued" | "offered" | "answered" | "held" | "muted" |
|
|
|
812
818
|
/**
|
|
813
819
|
* The call record: the steps that brought the task here, one entry per occurrence and oldest
|
|
814
820
|
* first, and what they add up to before this agent -- stated by the provider from its own record,
|
|
815
|
-
* never summed by
|
|
821
|
+
* never summed by an agent computer from instants. Each total is present when the provider knows it and
|
|
816
822
|
* absent when it does not; a plausible nought is the fallback the no-fallbacks rule forbids. The
|
|
817
823
|
* record rides on the task and is replaced with it, so a late entry corrects the sums.
|
|
818
824
|
*/
|
|
@@ -935,7 +941,7 @@ export interface TaskTakenOver {
|
|
|
935
941
|
* never describes the chain: which levels a person passes through is the structure's to know.
|
|
936
942
|
*/
|
|
937
943
|
export type Level = string;
|
|
938
|
-
/** A level the structure has, with the label
|
|
944
|
+
/** A level the structure has, with the label an agent computer shows for "who decided". */
|
|
939
945
|
export interface LevelDeclaration {
|
|
940
946
|
id: Level;
|
|
941
947
|
label: string;
|
|
@@ -1028,7 +1034,7 @@ export type Task<C extends Channel = Channel> = {
|
|
|
1028
1034
|
/**
|
|
1029
1035
|
* In `completing`, wherever `wrapAllowance` is stated: how much of the wrap is left, in whole
|
|
1030
1036
|
* seconds from this publication, restated on every publication that carries it, so a reloaded
|
|
1031
|
-
*
|
|
1037
|
+
* agent computer and a lead's screen count down the same number. Under `provider-automatic` the provider
|
|
1032
1038
|
* ends the task when it reaches zero; under `agent-command` it is the expected wrap left, `0`
|
|
1033
1039
|
* once overrun, and nothing acts on it.
|
|
1034
1040
|
*/
|
|
@@ -1405,11 +1411,11 @@ export interface MemberRequest {
|
|
|
1405
1411
|
since: IsoTimestamp;
|
|
1406
1412
|
}
|
|
1407
1413
|
/**
|
|
1408
|
-
* A member's task as the lead sees it: the same task the member's
|
|
1414
|
+
* A member's task as the lead sees it: the same task the member's computer holds, less the workspace.
|
|
1409
1415
|
* The assignment, what the call is and where it stands, the party as the provider shows it to
|
|
1410
1416
|
* leads, the room, the audio, the completion terms and the whole record are here; the controls,
|
|
1411
|
-
* their source and the browsers are the member's
|
|
1412
|
-
* lead whenever the member's own
|
|
1417
|
+
* their source and the browsers are the member's computer's alone and never travel. Published to every
|
|
1418
|
+
* lead whenever the member's own agent computer hears of the task.
|
|
1413
1419
|
*/
|
|
1414
1420
|
export type MemberTask<C extends Channel = Channel> = {
|
|
1415
1421
|
assignmentId: AssignmentId;
|
|
@@ -1580,7 +1586,7 @@ export type TeamCommandResult = {
|
|
|
1580
1586
|
/**
|
|
1581
1587
|
* The task's real-time audio as the provider holds it: `started` while audio should be attached,
|
|
1582
1588
|
* `ended` once primary interaction's audio ended, and the field omitted while none should be. The
|
|
1583
|
-
* provider's word --
|
|
1589
|
+
* provider's word -- an agent computer attaches and renders audio from it, never from its own senses.
|
|
1584
1590
|
*/
|
|
1585
1591
|
export type TaskAudioState = "started" | "ended";
|
|
1586
1592
|
export interface CallAudio {
|
|
@@ -1689,7 +1695,7 @@ export interface Snapshot<C extends Channel = Channel> {
|
|
|
1689
1695
|
loginId: string;
|
|
1690
1696
|
/**
|
|
1691
1697
|
* The provider's own instant of this read, required of a provider that declares `timeCheck`:
|
|
1692
|
-
* every duration the
|
|
1698
|
+
* every duration the agent computer renders off the active call is counted from the provider's clock, and
|
|
1693
1699
|
* this sample saves a `checkTime` for it. Every envelope's `occurredAt` is a sample too.
|
|
1694
1700
|
*/
|
|
1695
1701
|
providerTime?: IsoTimestamp;
|
|
@@ -1857,7 +1863,7 @@ export type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
|
|
|
1857
1863
|
/**
|
|
1858
1864
|
* What the host refused, told to the adapter that published it: a snapshot the host would not
|
|
1859
1865
|
* replace its state with, or an event it dropped, and every rule it broke. A refusal the provider
|
|
1860
|
-
* never hears of is
|
|
1866
|
+
* never hears of is an agent computer frozen at its last good state for a shift with nothing wrong on the
|
|
1861
1867
|
* provider's side; this is how both sides see it.
|
|
1862
1868
|
*/
|
|
1863
1869
|
export interface Refusal {
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ export function negotiateProtocolVersion(adapterVersions, hostVersions = OMNI_SU
|
|
|
16
16
|
return shared.length === 0 ? undefined : Math.max(...shared);
|
|
17
17
|
}
|
|
18
18
|
export const DIAL_OUTCOMES = ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled", "unexplained"];
|
|
19
|
-
export const PHONES = ["softphone", "
|
|
19
|
+
export const PHONES = ["softphone", "hardphone"];
|
|
20
20
|
export const PHONE_STATUSES = ["ready", "unregistered", "do-not-disturb", "off-hook"];
|
|
21
21
|
export const PHONE_CHANNEL_STATES = ["ringing", "active", "held"];
|
|
22
22
|
/** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
|
package/dist/testing.d.ts
CHANGED
|
@@ -241,7 +241,7 @@ export declare function assertAudioFollowsTheTask(envelopes: readonly ProviderEv
|
|
|
241
241
|
export declare function memoryStore(): LoginStore;
|
|
242
242
|
/**
|
|
243
243
|
* A host that reports one thing and never changes: what most adapter tests hand `testAdapter`.
|
|
244
|
-
* A softphone host states what its Mute does; a
|
|
244
|
+
* A softphone host states what its Mute does; a hardphone or conversation host has no microphone and states nothing.
|
|
245
245
|
*/
|
|
246
246
|
export declare function stillHost(report: HostReport, guarantees: HostGuarantees, mute: HostMute): Host & {
|
|
247
247
|
mute: HostMute;
|
package/dist/testing.js
CHANGED
|
@@ -324,11 +324,11 @@ export async function testAdapter(adapter, context, options = {}) {
|
|
|
324
324
|
// testing a host that cannot exist. Its first report and every later one are validated, a
|
|
325
325
|
// voice connection's host reports its audio and no other does, and the host the adapter
|
|
326
326
|
// receives is wrapped so the harness can tell whether the adapter ever asked.
|
|
327
|
-
violations.push(...validateHostGuarantees(context.host.guarantees, "context.host.guarantees"));
|
|
327
|
+
violations.push(...validateHostGuarantees(context.host.guarantees, "context.host.guarantees", adapter.manifest.channel === "voice" ? context.phone : undefined));
|
|
328
328
|
// The zone is the host's to state, so a context without one is a host that cannot exist.
|
|
329
329
|
violations.push(...validateTimeZone(context.timeZone, "context.timeZone"));
|
|
330
330
|
// How the agent hears the call decides what the host owns: on a softphone the host has the
|
|
331
|
-
// audio and reports it; on a
|
|
331
|
+
// audio and reports it; on a hardphone, or off voice, there is none for it to report.
|
|
332
332
|
violations.push(...validatePhone(context.phone, adapter.manifest, "context.phone"));
|
|
333
333
|
const softphone = adapter.manifest.channel === "voice" && context.phone === "softphone";
|
|
334
334
|
violations.push(...validateHostRecording(context.host.recording, softphone));
|
|
@@ -340,7 +340,7 @@ export async function testAdapter(adapter, context, options = {}) {
|
|
|
340
340
|
}
|
|
341
341
|
if (!softphone && hasAudio) {
|
|
342
342
|
violations.push({ rule: "context.host.audio.unexpected", path: "context.host.audio",
|
|
343
|
-
message: adapter.manifest.channel === "voice" ? "a
|
|
343
|
+
message: adapter.manifest.channel === "voice" ? "a hardphone login has no audio in the host to report" : `a ${adapter.manifest.channel} connection has no audio for the host to report` });
|
|
344
344
|
}
|
|
345
345
|
// What the host's Mute does is stated where the host holds a microphone, and nowhere else.
|
|
346
346
|
violations.push(...validateHostMute(context.host.mute, softphone, "context.host.mute"));
|
|
@@ -376,11 +376,11 @@ export async function testAdapter(adapter, context, options = {}) {
|
|
|
376
376
|
// than an `enabled` flag, so its presence is the declaration.
|
|
377
377
|
if (adapter.manifest.idleCapabilities?.dial !== undefined)
|
|
378
378
|
requireMethod(on, "dial", "the manifest declares dial");
|
|
379
|
-
// On a softphone the call's audio lands in Omni, so the adapter has to open it; on a
|
|
379
|
+
// On a softphone the call's audio lands in Omni, so the adapter has to open it; on a hardphone the host opens nothing.
|
|
380
380
|
if (softphone)
|
|
381
381
|
requireMethod(on, "openAudio", "the login is on a softphone");
|
|
382
382
|
// The microphone is the host's, so on a softphone every call can be muted by it, and the
|
|
383
|
-
// record of that leg is the provider's to take; on a
|
|
383
|
+
// record of that leg is the provider's to take; on a hardphone the host holds no microphone.
|
|
384
384
|
if (softphone)
|
|
385
385
|
requireMethod(on, "recordStep", "the login is on a softphone, whose microphone the host mutes");
|
|
386
386
|
};
|
|
@@ -1804,11 +1804,11 @@ async function testOneCall(call) {
|
|
|
1804
1804
|
// 2. A preview: press Call, which is a dial. Where the preview says how long it has, within what
|
|
1805
1805
|
// the test will wait, the deadline is kept to its atDeadline first: under provider-dials the
|
|
1806
1806
|
// provider dials when it runs out and the test presses nothing; under host-dials and waits the
|
|
1807
|
-
// preview stands until then, and the test presses Call at zero as the
|
|
1807
|
+
// preview stands until then, and the test presses Call at zero as the agent computer would.
|
|
1808
1808
|
if (latestTask().phase === "preview") {
|
|
1809
1809
|
const left = latestTask().previewEndsInSeconds;
|
|
1810
1810
|
const atDeadline = latestTask().atDeadline;
|
|
1811
|
-
// Under provider-dials the provider places the call, and the
|
|
1811
|
+
// Under provider-dials the provider places the call, and the agent computer presses nothing.
|
|
1812
1812
|
let pressCall = true;
|
|
1813
1813
|
if (typeof left === "number" && left * 1000 <= call.timeoutMs && typeof atDeadline === "string") {
|
|
1814
1814
|
ruleTested("test.preview.deadline");
|
|
@@ -1830,7 +1830,7 @@ async function testOneCall(call) {
|
|
|
1830
1830
|
// waits the seconds out and expects the task where it left it.
|
|
1831
1831
|
const moved = await updated(t => t.phase !== "preview", "nothing", { ms: left * 1000, onExpiry: () => undefined });
|
|
1832
1832
|
if (moved !== undefined) {
|
|
1833
|
-
refuse("test.preview.deadline", "test.preview", `${taskId} is a preview under ${atDeadline} with ${left}s left, and the provider moved it to ${String(moved.found.phase)} before the deadline: under ${atDeadline} the preview stands until the
|
|
1833
|
+
refuse("test.preview.deadline", "test.preview", `${taskId} is a preview under ${atDeadline} with ${left}s left, and the provider moved it to ${String(moved.found.phase)} before the deadline: under ${atDeadline} the preview stands until the agent computer dials, or for as long as the agent needs`);
|
|
1834
1834
|
return found;
|
|
1835
1835
|
}
|
|
1836
1836
|
}
|
|
@@ -1903,7 +1903,7 @@ async function testOneCall(call) {
|
|
|
1903
1903
|
ruleTested("test.recordStep.rejected", "test.recordStep.failed");
|
|
1904
1904
|
// The test holds its own report to the contract before it crosses, as a host must.
|
|
1905
1905
|
const leg = { assignmentId: taskId, step: "muted", mutedBy: "host", ...body };
|
|
1906
|
-
const own = validateHistoryReport(leg, "test.recordStep.report", call.manifest);
|
|
1906
|
+
const own = validateHistoryReport(leg, "test.recordStep.report", call.manifest, latestTask());
|
|
1907
1907
|
found.push(...own);
|
|
1908
1908
|
if (own.length > 0)
|
|
1909
1909
|
return;
|
|
@@ -1959,6 +1959,24 @@ async function testOneCall(call) {
|
|
|
1959
1959
|
return found;
|
|
1960
1960
|
}
|
|
1961
1961
|
}
|
|
1962
|
+
// The host's Mute is unavailable on hold, and the host promised as much at login: a host holds the
|
|
1963
|
+
// report back (historyReport.muted.held), and an adapter that receives one anyway must refuse it --
|
|
1964
|
+
// so the test sends a mute begun on hold past the validator and expects failed.
|
|
1965
|
+
const muteRefusedOnHold = async () => {
|
|
1966
|
+
ruleTested("test.recordStep.rejected", "test.recordStep.held");
|
|
1967
|
+
const leg = { assignmentId: taskId, step: "muted", mutedBy: "host", at: new Date().toISOString() };
|
|
1968
|
+
let answer;
|
|
1969
|
+
try {
|
|
1970
|
+
answer = await call.connection.recordStep(leg);
|
|
1971
|
+
}
|
|
1972
|
+
catch (error) {
|
|
1973
|
+
refuse("test.recordStep.rejected", "test.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
if (isRecord(answer) && answer.status !== "failed") {
|
|
1977
|
+
refuse("test.recordStep.held", "test.recordStep", "the provider recorded the host's mute begun with the caller on hold: the Mute waits for resume, and the adapter is the second gate");
|
|
1978
|
+
}
|
|
1979
|
+
};
|
|
1962
1980
|
// Ending my part from hold would leave the caller in the hold with nobody coming back to them: a
|
|
1963
1981
|
// host holds it back (command.endCall.held), and an adapter that receives it anyway must refuse
|
|
1964
1982
|
// it -- so the test sends it past the validator and expects failed.
|
|
@@ -1982,6 +2000,8 @@ async function testOneCall(call) {
|
|
|
1982
2000
|
if (latestTask().phase === "in-progress" && offers("hold")) {
|
|
1983
2001
|
if (await send({ type: call.channel === "chat" ? "pause" : "hold" }) !== undefined) {
|
|
1984
2002
|
if (await updated(t => t.phase === "paused", "the task pausing after hold") !== undefined) {
|
|
2003
|
+
if (canRecordMute)
|
|
2004
|
+
await muteRefusedOnHold();
|
|
1985
2005
|
if (call.channel === "voice" && offers("endCall"))
|
|
1986
2006
|
await endCallRefusedOnHold();
|
|
1987
2007
|
// An adapter that ended the agent's part from hold has nothing left to resume; the run goes on from where it put the task.
|
|
@@ -2087,7 +2107,7 @@ async function testOneCall(call) {
|
|
|
2087
2107
|
if (await send(command) !== undefined) {
|
|
2088
2108
|
// applied says the provider has completed the task, and its ending follows within the bound
|
|
2089
2109
|
// the manifest stated. Past it the host resyncs: a snapshot still carrying the task is a task
|
|
2090
|
-
// held open by a provider that said it was done, and the
|
|
2110
|
+
// held open by a provider that said it was done, and the agent computer shows it as unsettled.
|
|
2091
2111
|
ruleTested("test.completion.unsettled");
|
|
2092
2112
|
const settle = Number(call.manifest.settleMs);
|
|
2093
2113
|
let unsettled = false;
|
package/dist/validation.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface TaskValidationContext {
|
|
|
50
50
|
/**
|
|
51
51
|
* A member's task on the team member list, trimmed by the provider: the workspace -- controls,
|
|
52
52
|
* their source, browsers, completion terms -- is the member's and may be absent; what is present
|
|
53
|
-
* is held to the same rules as on the member's own
|
|
53
|
+
* is held to the same rules as on the member's own agent computer.
|
|
54
54
|
*/
|
|
55
55
|
member?: true;
|
|
56
56
|
}
|
|
@@ -133,7 +133,7 @@ export declare function validateLinedUp(value: unknown, path?: string, levels?:
|
|
|
133
133
|
/** The phone as the platform sees it: the agent's device for this provider, whether it can take a call, its own mute where observed. */
|
|
134
134
|
export declare function validatePhoneState(value: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
135
135
|
/**
|
|
136
|
-
* What pressing Mute does on this host, stated on a softphone login and nowhere else: on
|
|
136
|
+
* What pressing Mute does on this host, stated on a softphone login and nowhere else: on an agent computer
|
|
137
137
|
* phone the microphone is the phone's, and off voice there is none. Neither the value nor its
|
|
138
138
|
* absence is inferred from what kind of application the host is.
|
|
139
139
|
*/
|
|
@@ -146,13 +146,13 @@ export declare function validateHostMute(mute: unknown, softphone: boolean, path
|
|
|
146
146
|
* What a host promises. Presence is the guarantee, so a key declared `false` is refused: a
|
|
147
147
|
* promise withheld is an absent key, never a false one, exactly as a capability is.
|
|
148
148
|
*/
|
|
149
|
-
export declare function validateHostGuarantees(guarantees: unknown, path?: string): ProtocolViolation[];
|
|
149
|
+
export declare function validateHostGuarantees(guarantees: unknown, path?: string, phone?: unknown): ProtocolViolation[];
|
|
150
150
|
/**
|
|
151
151
|
* What the host reports of a leg it performed, as an adapter may validate it before forwarding:
|
|
152
152
|
* a task, a step, when it began, how long so far if the host says, and an explicit end that
|
|
153
153
|
* carries the final duration.
|
|
154
154
|
*/
|
|
155
|
-
export declare function validateHistoryReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];
|
|
155
|
+
export declare function validateHistoryReport(report: unknown, path?: string, manifest?: unknown, task?: unknown): ProtocolViolation[];
|
|
156
156
|
export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
|
|
157
157
|
/** Validate the exact interaction/assignment target before provider command dispatch. */
|
|
158
158
|
export declare function validateTaskCommandRequest(request: unknown, task: unknown, path?: string, taskContext?: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
|
package/dist/validation.js
CHANGED
|
@@ -86,7 +86,7 @@ const OFFERABLE_PHASES = membersOf({
|
|
|
86
86
|
pending: true,
|
|
87
87
|
});
|
|
88
88
|
const PREVIEW_DEADLINES = membersOf({ "provider-dials": true, "host-dials": true, waits: true });
|
|
89
|
-
const PHONES = membersOf({ softphone: true,
|
|
89
|
+
const PHONES = membersOf({ softphone: true, hardphone: true });
|
|
90
90
|
const PHONE_STATUSES = membersOf({ ready: true, unregistered: true, "do-not-disturb": true, "off-hook": true });
|
|
91
91
|
const PHONE_CHANNEL_STATES = membersOf({ ringing: true, active: true, held: true });
|
|
92
92
|
const LISTENING_MODES = membersOf({ listen: true, coach: true, "join-call": true });
|
|
@@ -322,7 +322,7 @@ function validateIdleCapabilities(value, channel, path, into) {
|
|
|
322
322
|
}
|
|
323
323
|
}
|
|
324
324
|
/**
|
|
325
|
-
* A provider that dials says how a dial can end, both ways: `answered`, or the
|
|
325
|
+
* A provider that dials says how a dial can end, both ways: `answered`, or the agent computer cannot tell
|
|
326
326
|
* reached from still ringing, and at least one way of not reaching, or it can never say failed.
|
|
327
327
|
* Off voice nothing dials, and the idle dialpad is a dial, so declaring it is declaring this.
|
|
328
328
|
*/
|
|
@@ -353,7 +353,7 @@ function validateDialOutcomes(manifest, path, into) {
|
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
355
|
/**
|
|
356
|
-
* A voice platform says which phones it can put an agent on -- a softphone in the host,
|
|
356
|
+
* A voice platform says which phones it can put an agent on -- a softphone in the host, an agent computer
|
|
357
357
|
* phone it rings -- and nothing off voice does, since there is no call to hear.
|
|
358
358
|
*/
|
|
359
359
|
function validatePhones(manifest, path, into) {
|
|
@@ -363,7 +363,7 @@ function validatePhones(manifest, path, into) {
|
|
|
363
363
|
return;
|
|
364
364
|
}
|
|
365
365
|
if (!Array.isArray(declared) || declared.length === 0) {
|
|
366
|
-
into.add("manifest.phones.required", path, "a voice manifest lists the phones its platform can put an agent on: softphone,
|
|
366
|
+
into.add("manifest.phones.required", path, "a voice manifest lists the phones its platform can put an agent on: softphone, hardphone, or both");
|
|
367
367
|
return;
|
|
368
368
|
}
|
|
369
369
|
declared.forEach((phone, index) => {
|
|
@@ -557,7 +557,7 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
557
557
|
}
|
|
558
558
|
else
|
|
559
559
|
wellFormed = false;
|
|
560
|
-
if (!into.filled(level.label, "manifest.orgLevel.label", `${at}.label`, "a level needs the label
|
|
560
|
+
if (!into.filled(level.label, "manifest.orgLevel.label", `${at}.label`, "a level needs the label an agent computer shows for it"))
|
|
561
561
|
wellFormed = false;
|
|
562
562
|
});
|
|
563
563
|
if (wellFormed && !ids.has("person")) {
|
|
@@ -715,7 +715,7 @@ const levelIds = (levels) => levels ?? DEFAULT_LEVEL_IDS;
|
|
|
715
715
|
/**
|
|
716
716
|
* What the queue locks is locked on the whole task. Given the locked values, every other field an
|
|
717
717
|
* agent reads -- the title, the reference, an attribute, a custom control's label, a browser URL the
|
|
718
|
-
*
|
|
718
|
+
* agent computer shows -- is held to carrying none of them. A number is compared by its digits, so no
|
|
719
719
|
* formatting hides it; anything else by its text, case aside.
|
|
720
720
|
*/
|
|
721
721
|
function validateNothingLeaksInto(task, locked, path, into) {
|
|
@@ -1157,7 +1157,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1157
1157
|
// travels exactly when Omni said tasks may be auto-accepted, and only while the task is pending.
|
|
1158
1158
|
// Work the agent originated -- a dial or connect-back, carrying the host's dialId on onCall; a
|
|
1159
1159
|
// lead's join, carrying assisting; a listen, carrying listening -- was accepted by the command
|
|
1160
|
-
// that created it, and says so whatever the provisioning: the
|
|
1160
|
+
// that created it, and says so whatever the provisioning: the agent computer shows no Accept for a call
|
|
1161
1161
|
// the agent placed. The provisioning governs work the queue routes, and nothing else.
|
|
1162
1162
|
const originated = task.phase === "pending" && originatedByTheAgent(task);
|
|
1163
1163
|
ruleTested("task.acceptance.originated");
|
|
@@ -1194,7 +1194,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1194
1194
|
// How much wrap is left travels with the completing task, restated, so every screen counts the same number.
|
|
1195
1195
|
if (task.phase === "completing" && task.wrapAllowance !== undefined) {
|
|
1196
1196
|
if (task.wrapEndsInSeconds === undefined) {
|
|
1197
|
-
into.add("task.completing.wrapEndsInSeconds.required", `${path}.wrapEndsInSeconds`, "a completing task with a wrap allowance says how much of it is left, so a reloaded
|
|
1197
|
+
into.add("task.completing.wrapEndsInSeconds.required", `${path}.wrapEndsInSeconds`, "a completing task with a wrap allowance says how much of it is left, so a reloaded agent computer and a lead's screen count down the same number");
|
|
1198
1198
|
}
|
|
1199
1199
|
else {
|
|
1200
1200
|
into.require(isDurationSeconds(task.wrapEndsInSeconds), "task.completing.wrapEndsInSeconds", `${path}.wrapEndsInSeconds`, "wrapEndsInSeconds is how much wrap is left: a whole number of seconds, zero or more, counted from this publication");
|
|
@@ -1245,7 +1245,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1245
1245
|
// Where the terms came from is stated with them: a host cannot tell "the platform permits
|
|
1246
1246
|
// nothing" from "the terms could not be read" from the set alone, and the provider knows which.
|
|
1247
1247
|
if (context.member === true) {
|
|
1248
|
-
// The lead's copy is the record, the room and the terms; the workspace stays on the member's
|
|
1248
|
+
// The lead's copy is the record, the room and the terms; the workspace stays on the member's computer.
|
|
1249
1249
|
for (const field of ["capabilities", "capabilitySource", "browsers"]) {
|
|
1250
1250
|
if (task[field] !== undefined)
|
|
1251
1251
|
into.add("team.member.task.workspace", `${path}.${field}`, `${field} is the member's workspace, and never travels to a lead`);
|
|
@@ -1748,7 +1748,7 @@ function validateTeamMemberInto(member, at, context, into) {
|
|
|
1748
1748
|
// The member's own history for the day: about the person, not any one call.
|
|
1749
1749
|
if (member.shift !== undefined)
|
|
1750
1750
|
validateShiftInto(member.shift, `${at}.shift`, into, "team.member.shift");
|
|
1751
|
-
// The member's phone as the platform sees it, the same state the member's own
|
|
1751
|
+
// The member's phone as the platform sees it, the same state the member's own agent computer holds.
|
|
1752
1752
|
if (member.phone !== undefined)
|
|
1753
1753
|
validatePhoneStateInto(member.phone, `${at}.phone`, {}, into);
|
|
1754
1754
|
// The member's own queue, as their own snapshot carries it.
|
|
@@ -1782,7 +1782,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1782
1782
|
const levels = context.levels ?? manifestLevels(manifest);
|
|
1783
1783
|
into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.transport`);
|
|
1784
1784
|
// The provider's own instant of the read: required where the provider has a clock to state, and
|
|
1785
|
-
// a clock nobody declared is not one the
|
|
1785
|
+
// a clock nobody declared is not one the agent computer may count from.
|
|
1786
1786
|
if (isPlainObject(manifest) && manifest.timeCheck === true) {
|
|
1787
1787
|
into.timestamp(snapshot.providerTime, "snapshot.providerTime", `${path}.providerTime`);
|
|
1788
1788
|
}
|
|
@@ -1833,7 +1833,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1833
1833
|
: task.phase === "pending" || (Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.role === "party" && who.stage === "ringing"));
|
|
1834
1834
|
into.require(agrees, "phone.channel.task", `${here}.state`, `the phone says ${String(channel.state)} and the task ${String(channel.assignmentId)} says ${String(task.phase)}${task.audio === undefined ? "" : ` with audio ${String(task.audio)}`}: the two views of one call agree`);
|
|
1835
1835
|
});
|
|
1836
|
-
// And a task at work with audio has a channel: the phone carries every call the
|
|
1836
|
+
// And a task at work with audio has a channel: the phone carries every call the agent computer is on.
|
|
1837
1837
|
tasks.forEach((task, index) => {
|
|
1838
1838
|
if (task.audio === "started" && !snapshot.phone.channels.some(channel => isPlainObject(channel) && channel.assignmentId === task.assignmentId)) {
|
|
1839
1839
|
into.add("phone.channel.missing", `${path}.tasks[${index}]`, `${String(task.assignmentId)} has its audio started and the phone carries no channel for it`);
|
|
@@ -2193,7 +2193,7 @@ function validatePreferencesInto(value, path, into, levels) {
|
|
|
2193
2193
|
});
|
|
2194
2194
|
}
|
|
2195
2195
|
/** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
|
|
2196
|
-
/** Whether a publication carries an instant the
|
|
2196
|
+
/** Whether a publication carries an instant the agent computer renders as a running duration, which the provider's clock reads. */
|
|
2197
2197
|
function carriesRunningInstant(value) {
|
|
2198
2198
|
if (!isPlainObject(value))
|
|
2199
2199
|
return false;
|
|
@@ -2218,7 +2218,7 @@ function carriesRunningInstant(value) {
|
|
|
2218
2218
|
function requireClockInto(value, manifest, path, into) {
|
|
2219
2219
|
if (!isPlainObject(manifest) || manifest.timeCheck === true || !carriesRunningInstant(value))
|
|
2220
2220
|
return;
|
|
2221
|
-
into.add("manifest.timeCheck.required", path, "this publishes an instant the
|
|
2221
|
+
into.add("manifest.timeCheck.required", path, "this publishes an instant the agent computer renders as a running duration -- a since, a signedInAt -- and the manifest declares no timeCheck: every screen counts such a duration from the provider's clock, so the provider states one");
|
|
2222
2222
|
}
|
|
2223
2223
|
/** The agent's standing ask for the next call: since when. */
|
|
2224
2224
|
export function validateNextCall(value, path = "nextCall") {
|
|
@@ -2275,7 +2275,7 @@ function validatePhoneStateInto(value, path, context, into) {
|
|
|
2275
2275
|
into.oneOf(value.status, PHONE_STATUSES, "phone.status", `${path}.status`);
|
|
2276
2276
|
if (value.muted !== undefined) {
|
|
2277
2277
|
into.require(value.muted === true, "phone.muted", `${path}.muted`, "the phone's mute is stated by presence: send true or omit it");
|
|
2278
|
-
// The softphone's microphone is the host's, and its mute is the host's report; the platform observes a
|
|
2278
|
+
// The softphone's microphone is the host's, and its mute is the host's report; the platform observes a hardphone's button, or nothing.
|
|
2279
2279
|
into.require(value.phone !== "softphone", "phone.muted.softphone", `${path}.muted`, "a softphone's microphone is the host's and its mute is the host's report; the platform echoes nothing of it");
|
|
2280
2280
|
}
|
|
2281
2281
|
if (value.since !== undefined)
|
|
@@ -2400,11 +2400,11 @@ function validateUnavailable(value, rule, path, into) {
|
|
|
2400
2400
|
* check belongs to the host's own tests and to the harness, which validates whatever host a test
|
|
2401
2401
|
* hands the adapter.
|
|
2402
2402
|
*/
|
|
2403
|
-
const HOST_GUARANTEES = membersOf({ browserUrlVisibility: true, personConsent: true });
|
|
2403
|
+
const HOST_GUARANTEES = membersOf({ browserUrlVisibility: true, personConsent: true, muteUnavailableOnHold: true });
|
|
2404
2404
|
const MUTED_BY = membersOf({ host: true, station: true });
|
|
2405
2405
|
const HOST_MUTES = membersOf({ stream: true, station: true });
|
|
2406
2406
|
/**
|
|
2407
|
-
* What pressing Mute does on this host, stated on a softphone login and nowhere else: on
|
|
2407
|
+
* What pressing Mute does on this host, stated on a softphone login and nowhere else: on an agent computer
|
|
2408
2408
|
* phone the microphone is the phone's, and off voice there is none. Neither the value nor its
|
|
2409
2409
|
* absence is inferred from what kind of application the host is.
|
|
2410
2410
|
*/
|
|
@@ -2435,7 +2435,7 @@ export function validateHostMute(mute, softphone, path = "host.mute") {
|
|
|
2435
2435
|
if (mute === undefined) {
|
|
2436
2436
|
into.require(!softphone, "host.mute.required", path, "a softphone login's host states what its Mute does: stream or station");
|
|
2437
2437
|
}
|
|
2438
|
-
else if (into.require(softphone, "host.mute.unexpected", path, "only a softphone login's host holds a microphone to mute; a
|
|
2438
|
+
else if (into.require(softphone, "host.mute.unexpected", path, "only a softphone login's host holds a microphone to mute; a hardphone's is the phone's, and a conversation has none")) {
|
|
2439
2439
|
into.oneOf(mute, HOST_MUTES, "host.mute", path);
|
|
2440
2440
|
}
|
|
2441
2441
|
return into.violations;
|
|
@@ -2444,12 +2444,22 @@ export function validateHostMute(mute, softphone, path = "host.mute") {
|
|
|
2444
2444
|
* What a host promises. Presence is the guarantee, so a key declared `false` is refused: a
|
|
2445
2445
|
* promise withheld is an absent key, never a false one, exactly as a capability is.
|
|
2446
2446
|
*/
|
|
2447
|
-
export function validateHostGuarantees(guarantees, path = "host.guarantees") {
|
|
2447
|
+
export function validateHostGuarantees(guarantees, path = "host.guarantees", phone) {
|
|
2448
2448
|
const into = new Collector();
|
|
2449
2449
|
if (!isPlainObject(guarantees)) {
|
|
2450
2450
|
into.add("host.guarantees.shape", path, "a host declares its guarantees as an object, empty when it makes none");
|
|
2451
2451
|
return into.violations;
|
|
2452
2452
|
}
|
|
2453
|
+
// The login's phone decides who holds the mute: a softphone host owns it and promises to keep it
|
|
2454
|
+
// off the hold; a hardphone's is the handset's, and a host off voice has none.
|
|
2455
|
+
if (phone !== undefined) {
|
|
2456
|
+
if (phone === "softphone") {
|
|
2457
|
+
into.require(guarantees.muteUnavailableOnHold === true, "host.guarantee.muteUnavailableOnHold.required", `${path}.muteUnavailableOnHold`, "a softphone login's host holds the mute and promises it is unavailable while the task is paused");
|
|
2458
|
+
}
|
|
2459
|
+
else {
|
|
2460
|
+
into.require(guarantees.muteUnavailableOnHold === undefined, "host.guarantee.muteUnavailableOnHold.unexpected", `${path}.muteUnavailableOnHold`, `a ${describeValue(phone)} login's host holds no mute to promise anything of`);
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2453
2463
|
for (const [name, declared] of Object.entries(guarantees)) {
|
|
2454
2464
|
if (declared === undefined)
|
|
2455
2465
|
continue;
|
|
@@ -2464,7 +2474,7 @@ export function validateHostGuarantees(guarantees, path = "host.guarantees") {
|
|
|
2464
2474
|
* a task, a step, when it began, how long so far if the host says, and an explicit end that
|
|
2465
2475
|
* carries the final duration.
|
|
2466
2476
|
*/
|
|
2467
|
-
export function validateHistoryReport(report, path = "historyReport", manifest) {
|
|
2477
|
+
export function validateHistoryReport(report, path = "historyReport", manifest, task) {
|
|
2468
2478
|
const into = new Collector();
|
|
2469
2479
|
if (!isPlainObject(report)) {
|
|
2470
2480
|
into.add("historyReport.shape", path, "a history report must be an object");
|
|
@@ -2475,8 +2485,15 @@ export function validateHistoryReport(report, path = "historyReport", manifest)
|
|
|
2475
2485
|
into.oneOf(report.step, HISTORY_STEPS, "historyReport.step", `${path}.step`);
|
|
2476
2486
|
into.timestamp(report.at, "historyReport.at", `${path}.at`);
|
|
2477
2487
|
// A muted leg says whose the silence was; no other leg has anyone to name for it.
|
|
2478
|
-
if (report.step === "muted")
|
|
2488
|
+
if (report.step === "muted") {
|
|
2479
2489
|
into.oneOf(report.mutedBy, MUTED_BY, "historyReport.mutedBy", `${path}.mutedBy`);
|
|
2490
|
+
// The host's own Mute is unavailable while the caller is on hold: a leg of it beginning on a paused
|
|
2491
|
+
// task is a control the host promised not to offer. A station mute it observed on hold is a fact and
|
|
2492
|
+
// stands, and so do the running and closing reports of a leg begun before the hold.
|
|
2493
|
+
if (report.mutedBy === "host" && report.ended === undefined && report.seconds === undefined && isPlainObject(task)) {
|
|
2494
|
+
into.require(task.phase !== "paused", "historyReport.muted.held", `${path}.at`, "the caller is on hold: the host's Mute waits for resume, and a mute begun on hold is not recorded");
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2480
2497
|
else if (HISTORY_STEPS.includes(report.step)) {
|
|
2481
2498
|
into.require(report.mutedBy === undefined, "historyReport.mutedBy.unexpected", `${path}.mutedBy`, "only a muted leg says who silenced the microphone");
|
|
2482
2499
|
}
|