@xema/omni-protocol 0.1.18 → 0.1.20
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 +146 -15
- package/dist/index.js +17 -0
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +15 -4
- package/dist/validation.d.ts +11 -2
- package/dist/validation.js +191 -16
- package/guide.md +312 -20
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -111,6 +111,8 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
111
111
|
phaseLabels?: TaskPhaseLabels;
|
|
112
112
|
/** Keyed by `taskType`. An entry replaces the channel default outright rather than merging. */
|
|
113
113
|
taskTypePresentation?: Record<string, TaskTypePresentation>;
|
|
114
|
+
/** The structure's tiers, relabelling or extending `DEFAULT_TIERS` by id. Omitted for the typical four. */
|
|
115
|
+
tiers?: TierDeclaration[];
|
|
114
116
|
}
|
|
115
117
|
export interface SecretStore {
|
|
116
118
|
get(key: string): Promise<string | undefined>;
|
|
@@ -145,6 +147,8 @@ export interface TeamCapabilities {
|
|
|
145
147
|
breakControl?: true;
|
|
146
148
|
/** This lead may join a member's call on request. Requires `executeTeamConsult`. */
|
|
147
149
|
consultControl?: true;
|
|
150
|
+
/** This lead sets the team's policy per capability -- on, off, or the agent's -- within what the queue allows. Requires `executeTeamPolicy`. */
|
|
151
|
+
policyControl?: true;
|
|
148
152
|
}
|
|
149
153
|
/**
|
|
150
154
|
* What this login may do, beyond any one task. It travels with the identity because it is part
|
|
@@ -154,6 +158,8 @@ export interface TeamCapabilities {
|
|
|
154
158
|
export interface SessionCapabilities {
|
|
155
159
|
/** This login may request a break. Requires the four break methods. */
|
|
156
160
|
breaks?: true;
|
|
161
|
+
/** The choices the team left to this person, with where each stands. Omitted when there are none. Requires `setPreference`. */
|
|
162
|
+
preferences?: AgentPreference[];
|
|
157
163
|
/** This login leads a team: a `TeamRoster` is published to it on every snapshot, `[]` included. */
|
|
158
164
|
team?: TeamCapabilities;
|
|
159
165
|
}
|
|
@@ -286,7 +292,13 @@ export type HostAudioOutput = {
|
|
|
286
292
|
* network. Omni reports it; the adapter decides what any of it means for its platform and what
|
|
287
293
|
* to relay. `audio` is present on a voice connection.
|
|
288
294
|
*/
|
|
295
|
+
/** What Omni's own chrome shows of a task browser's URL: nothing, the domain, or all of it. */
|
|
296
|
+
export type UrlVisibility = "full" | "domain" | "hidden";
|
|
289
297
|
export interface HostReport {
|
|
298
|
+
/** What the agent can see of a task browser's URL in Omni's chrome. Task browsers only; the personal browser is the agent's. */
|
|
299
|
+
browsers: {
|
|
300
|
+
urlVisibility: UrlVisibility;
|
|
301
|
+
};
|
|
290
302
|
/** Whether the host has a network interface up. Not a claim that anything is reachable: the adapter knows its own platform's reachability better than the host does. */
|
|
291
303
|
online: boolean;
|
|
292
304
|
audio?: {
|
|
@@ -317,8 +329,10 @@ export type ConnectionStatus = "connecting" | "active" | "error";
|
|
|
317
329
|
/** Every field is optional: a provider sends what it knows and omits what it does not. */
|
|
318
330
|
export interface Contact {
|
|
319
331
|
name?: string;
|
|
320
|
-
number
|
|
321
|
-
|
|
332
|
+
/** The number, or `{ lockedBy }` where the queue says the agent may not see it: the last digits or nothing are sent, never a flag to honour. */
|
|
333
|
+
number?: Lockable<string>;
|
|
334
|
+
/** The email, or `{ lockedBy }` where the agent may not see it; it identifies a person as a number does. */
|
|
335
|
+
email?: Lockable<string>;
|
|
322
336
|
attributes?: Attribute[];
|
|
323
337
|
}
|
|
324
338
|
export interface ScheduledActivity {
|
|
@@ -356,6 +370,12 @@ export interface CustomCapability {
|
|
|
356
370
|
kind: "button" | "toggle" | "menu-item";
|
|
357
371
|
label: string;
|
|
358
372
|
placement: "primary" | "secondary" | "overflow";
|
|
373
|
+
/** Where the control's work renders: inline in the workspace, or as a page of its own. Inline when absent. */
|
|
374
|
+
render?: "inline" | "page";
|
|
375
|
+
};
|
|
376
|
+
/** What the agent supplies before the action runs; the values travel on the custom command. */
|
|
377
|
+
prompt?: {
|
|
378
|
+
fields: CredentialField[];
|
|
359
379
|
};
|
|
360
380
|
}
|
|
361
381
|
export interface SharedTaskCapabilities {
|
|
@@ -369,24 +389,24 @@ export interface SharedTaskCapabilities {
|
|
|
369
389
|
* The channel arms are why `Task<"email">` rejects `hold` at compile time rather than at runtime.
|
|
370
390
|
*/
|
|
371
391
|
export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ? SharedTaskCapabilities & {
|
|
372
|
-
decline?: true
|
|
373
|
-
mute?: true
|
|
374
|
-
hold?: true
|
|
375
|
-
agentDisconnect?: true
|
|
392
|
+
decline?: Lockable<true>;
|
|
393
|
+
mute?: Lockable<true>;
|
|
394
|
+
hold?: Lockable<true>;
|
|
395
|
+
agentDisconnect?: Lockable<true>;
|
|
376
396
|
/** Reach the party again while `completing`; the task returns to `in-progress`. */
|
|
377
|
-
callback?: true
|
|
378
|
-
blindTransfer?: true | DestinationDirectory
|
|
397
|
+
callback?: Lockable<true>;
|
|
398
|
+
blindTransfer?: Lockable<true | DestinationDirectory>;
|
|
379
399
|
/** Park the customer and call a destination first; then `complete` or `cancel`. */
|
|
380
|
-
consultTransfer?: true | DestinationDirectory
|
|
400
|
+
consultTransfer?: Lockable<true | DestinationDirectory>;
|
|
381
401
|
/** Ask a lead to join this call, with a note. The lead's decision arrives on `Task.lead`. */
|
|
382
|
-
consultLead?: true
|
|
383
|
-
conference?: true | DestinationDirectory
|
|
384
|
-
recording?: true
|
|
402
|
+
consultLead?: Lockable<true>;
|
|
403
|
+
conference?: Lockable<true | DestinationDirectory>;
|
|
404
|
+
recording?: Lockable<true>;
|
|
385
405
|
} : C extends "chat" ? SharedTaskCapabilities & {
|
|
386
|
-
reject?: true
|
|
387
|
-
hold?: true
|
|
406
|
+
reject?: Lockable<true>;
|
|
407
|
+
hold?: Lockable<true>;
|
|
388
408
|
} : SharedTaskCapabilities & {
|
|
389
|
-
reject?: true
|
|
409
|
+
reject?: Lockable<true>;
|
|
390
410
|
};
|
|
391
411
|
/**
|
|
392
412
|
* How a reusing browser's session is keyed.
|
|
@@ -508,6 +528,50 @@ export interface TaskAssisting {
|
|
|
508
528
|
note?: string;
|
|
509
529
|
since: IsoTimestamp;
|
|
510
530
|
}
|
|
531
|
+
/**
|
|
532
|
+
* A tier of the organisation's structure, by the id its manifest declares -- or one of the four
|
|
533
|
+
* every organisation has, `DEFAULT_TIERS`, which a manifest relabels or adds to. The protocol
|
|
534
|
+
* never describes the chain: which tiers a person passes through is the structure's to know.
|
|
535
|
+
*/
|
|
536
|
+
export type Tier = string;
|
|
537
|
+
/** A tier the structure has, with the label a desk shows for "who decided". */
|
|
538
|
+
export interface TierDeclaration {
|
|
539
|
+
id: Tier;
|
|
540
|
+
label: string;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* The tiers a typical organisation has. A manifest that declares `tiers` relabels any of these
|
|
544
|
+
* by id and may add its own; one that declares none has exactly these.
|
|
545
|
+
*/
|
|
546
|
+
export declare const DEFAULT_TIERS: readonly [{
|
|
547
|
+
readonly id: "org";
|
|
548
|
+
readonly label: "Your organisation";
|
|
549
|
+
}, {
|
|
550
|
+
readonly id: "site";
|
|
551
|
+
readonly label: "Your site";
|
|
552
|
+
}, {
|
|
553
|
+
readonly id: "team";
|
|
554
|
+
readonly label: "Your team";
|
|
555
|
+
}, {
|
|
556
|
+
readonly id: "person";
|
|
557
|
+
readonly label: "You";
|
|
558
|
+
}];
|
|
559
|
+
/** The tiers in force for a manifest: the defaults, relabelled or extended by what it declares. */
|
|
560
|
+
export declare function effectiveTiers(declared: readonly TierDeclaration[] | undefined): TierDeclaration[];
|
|
561
|
+
/**
|
|
562
|
+
* Something the queue could allow, locked above the person: the tier that made it unchangeable,
|
|
563
|
+
* and why if they said. `person` never locks their own value, and the queue is not a tier --
|
|
564
|
+
* what the queue does not allow at all is simply absent.
|
|
565
|
+
*/
|
|
566
|
+
export interface Locked {
|
|
567
|
+
lockedBy: Tier;
|
|
568
|
+
reason?: string;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* A value, or `{ lockedBy }` in its place: present without permission, saying whose. `lockedBy`
|
|
572
|
+
* is the discriminant: a value that carries it is the lock, so no `T` may carry that key.
|
|
573
|
+
*/
|
|
574
|
+
export type Lockable<T> = T | Locked;
|
|
511
575
|
export type Task<C extends Channel = Channel> = {
|
|
512
576
|
id: TaskId;
|
|
513
577
|
title: string;
|
|
@@ -804,6 +868,25 @@ export interface TeamRoster {
|
|
|
804
868
|
members: TeamMember[];
|
|
805
869
|
/** Omitted when the login lacks `team.consultControl`; `[]` when nobody is asking. */
|
|
806
870
|
requests?: LeadRequest[];
|
|
871
|
+
/** The team's policy per capability, as it stands. Present exactly when the login declares `team.policyControl`. */
|
|
872
|
+
policies?: TeamPolicies;
|
|
873
|
+
}
|
|
874
|
+
/** A capability a team policy can name: any task control, new call, or a skill by its provider id. */
|
|
875
|
+
export type PolicyKey = Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities> | "dial" | `skill:${string}`;
|
|
876
|
+
/** On for everyone, off for everyone, or the agent's own choice. Only `hold`, `mute` and skills may be `agent`. */
|
|
877
|
+
export type TeamPolicySetting = "on" | "off" | "agent";
|
|
878
|
+
/** One policy as the lead sees it: the setting, who set it, and `lockedBy` when a tier above the team made it theirs to keep. */
|
|
879
|
+
export interface TeamPolicy extends Resolved {
|
|
880
|
+
setting: TeamPolicySetting;
|
|
881
|
+
}
|
|
882
|
+
export type TeamPolicies = Partial<Record<PolicyKey, TeamPolicy>>;
|
|
883
|
+
export type TeamPolicyCommand = {
|
|
884
|
+
type: "set";
|
|
885
|
+
capability: PolicyKey;
|
|
886
|
+
setting: TeamPolicySetting;
|
|
887
|
+
};
|
|
888
|
+
export interface TeamPolicyCommandRequest {
|
|
889
|
+
command: TeamPolicyCommand;
|
|
807
890
|
}
|
|
808
891
|
export type TeamConsultCommand = {
|
|
809
892
|
type: "join";
|
|
@@ -859,6 +942,50 @@ export type OpenMediaResult = {
|
|
|
859
942
|
failure: ProtocolFailure;
|
|
860
943
|
};
|
|
861
944
|
/** The provider's complete state at one moment. It replaces what Omni holds; never a patch. */
|
|
945
|
+
/**
|
|
946
|
+
* What the team may leave to the person: a capability by its own name -- `hold`, `mute` -- or a
|
|
947
|
+
* skill by its provider id. The same key as in `Task.capabilities`, because it is the same
|
|
948
|
+
* capability seen at another tier. Callback and new call are never the person's; they are the
|
|
949
|
+
* team's, on or off, within what the queue allows.
|
|
950
|
+
*/
|
|
951
|
+
export type PreferenceId = "hold" | "mute" | `skill:${string}`;
|
|
952
|
+
/**
|
|
953
|
+
* Who stated a value as it stands: a tier -- `person` among them -- or `provisioning`, the
|
|
954
|
+
* protocol's own word for "no tier has said anything and the provider's default applies".
|
|
955
|
+
* Nothing is hidden for want of a row.
|
|
956
|
+
*/
|
|
957
|
+
export type SetBy = Tier | "provisioning";
|
|
958
|
+
/** What every resolved value carries: who set it, and who locked it if anyone did. */
|
|
959
|
+
export interface Resolved {
|
|
960
|
+
setBy: SetBy;
|
|
961
|
+
lockedBy?: Tier;
|
|
962
|
+
/** Given with `lockedBy`, where whoever locked it said why. */
|
|
963
|
+
reason?: string;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* One choice the team may leave to the person, with where it stands and who set it. The provider
|
|
967
|
+
* keeps it: it is the person's across sessions, written through `setPreference`. Listed whether
|
|
968
|
+
* or not anyone has stated it, and even when a tier above has since locked it.
|
|
969
|
+
*/
|
|
970
|
+
export interface AgentPreference extends Resolved {
|
|
971
|
+
id: PreferenceId;
|
|
972
|
+
label: string;
|
|
973
|
+
enabled: boolean;
|
|
974
|
+
}
|
|
975
|
+
/** The person's act: set their own value, or give it up and inherit again. */
|
|
976
|
+
export type SetPreferenceRequest = {
|
|
977
|
+
id: PreferenceId;
|
|
978
|
+
enabled: boolean;
|
|
979
|
+
} | {
|
|
980
|
+
id: PreferenceId;
|
|
981
|
+
inherit: true;
|
|
982
|
+
};
|
|
983
|
+
export type PreferenceResult = {
|
|
984
|
+
status: "applied";
|
|
985
|
+
} | {
|
|
986
|
+
status: "failed";
|
|
987
|
+
failure: ProtocolFailure;
|
|
988
|
+
};
|
|
862
989
|
export interface Snapshot<C extends Channel = Channel> {
|
|
863
990
|
status: ConnectionStatus;
|
|
864
991
|
sessionId: string;
|
|
@@ -976,8 +1103,12 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
976
1103
|
executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
|
|
977
1104
|
/** Required when the login declares `capabilities.team.consultControl`. */
|
|
978
1105
|
executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
|
|
1106
|
+
/** Required when the login declares `capabilities.team.policyControl`. */
|
|
1107
|
+
executeTeamPolicy?(request: TeamPolicyCommandRequest): Promise<TeamCommandResult>;
|
|
979
1108
|
/** Required of every voice adapter: all voice audio lands in Omni. */
|
|
980
1109
|
openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
|
|
1110
|
+
/** Required when the login declares `capabilities.preferences`: the person's own choice, kept by the provider and republished as `authenticated`. */
|
|
1111
|
+
setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
|
|
981
1112
|
}
|
|
982
1113
|
export interface Adapter<C extends Channel = Channel> {
|
|
983
1114
|
manifest: Manifest<C>;
|
package/dist/index.js
CHANGED
|
@@ -50,6 +50,23 @@ export function isAllowedBrowserUrl(url) {
|
|
|
50
50
|
return false;
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The tiers a typical organisation has. A manifest that declares `tiers` relabels any of these
|
|
55
|
+
* by id and may add its own; one that declares none has exactly these.
|
|
56
|
+
*/
|
|
57
|
+
export const DEFAULT_TIERS = [
|
|
58
|
+
{ id: "org", label: "Your organisation" },
|
|
59
|
+
{ id: "site", label: "Your site" },
|
|
60
|
+
{ id: "team", label: "Your team" },
|
|
61
|
+
{ id: "person", label: "You" },
|
|
62
|
+
];
|
|
63
|
+
/** The tiers in force for a manifest: the defaults, relabelled or extended by what it declares. */
|
|
64
|
+
export function effectiveTiers(declared) {
|
|
65
|
+
const byId = new Map(DEFAULT_TIERS.map(tier => [tier.id, tier]));
|
|
66
|
+
for (const tier of declared ?? [])
|
|
67
|
+
byId.set(tier.id, tier);
|
|
68
|
+
return [...byId.values()];
|
|
69
|
+
}
|
|
53
70
|
// ---------------------------------------------------------------------------
|
|
54
71
|
// Task commands.
|
|
55
72
|
// ---------------------------------------------------------------------------
|
package/dist/testing.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation }
|
|
|
7
7
|
* rules -- each optional part of a task, each optional part of the break state and roster, each
|
|
8
8
|
* declared contribution, and each event type.
|
|
9
9
|
*/
|
|
10
|
-
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.dispositions", "task.destinations", "task.custom", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities"];
|
|
10
|
+
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.dispositions", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
|
|
11
11
|
export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
|
|
12
12
|
export interface AdapterContractResult {
|
|
13
13
|
events: ProviderEventEnvelope[];
|
package/dist/testing.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { browserSessionKey, sameCapabilities, } from "./index.js";
|
|
1
|
+
import { browserSessionKey, effectiveTiers, sameCapabilities, } from "./index.js";
|
|
2
2
|
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostReport, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
@@ -18,12 +18,14 @@ const STATE_SUBJECTS = [
|
|
|
18
18
|
"task.dispositions",
|
|
19
19
|
"task.destinations",
|
|
20
20
|
"task.custom",
|
|
21
|
+
"task.locked",
|
|
21
22
|
"break.reasons",
|
|
22
23
|
"break.imposed",
|
|
23
24
|
"team.members",
|
|
24
25
|
"team.requests",
|
|
25
26
|
"contacts",
|
|
26
27
|
"scheduledActivities",
|
|
28
|
+
"team.policies",
|
|
27
29
|
];
|
|
28
30
|
// Pinned to the event union the way validation pins its closed sets: a type added to
|
|
29
31
|
// `ProviderEvent` without a row here, or a row it lacks, is a compile error.
|
|
@@ -66,6 +68,8 @@ function observeTask(value, seen) {
|
|
|
66
68
|
}
|
|
67
69
|
if (some(capabilities.custom))
|
|
68
70
|
seen.add("task.custom");
|
|
71
|
+
if (Object.values(capabilities).some(declared => isRecord(declared) && declared.lockedBy !== undefined))
|
|
72
|
+
seen.add("task.locked");
|
|
69
73
|
}
|
|
70
74
|
function observeBreak(value, seen) {
|
|
71
75
|
if (!isRecord(value))
|
|
@@ -82,6 +86,8 @@ function observeTeam(value, seen) {
|
|
|
82
86
|
seen.add("team.members");
|
|
83
87
|
if (some(value.requests))
|
|
84
88
|
seen.add("team.requests");
|
|
89
|
+
if (isRecord(value.policies) && Object.keys(value.policies).length > 0)
|
|
90
|
+
seen.add("team.policies");
|
|
85
91
|
}
|
|
86
92
|
function observeSnapshot(value, seen) {
|
|
87
93
|
if (!isRecord(value))
|
|
@@ -163,7 +169,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
163
169
|
let disconnectWasClean = false;
|
|
164
170
|
try {
|
|
165
171
|
authenticationState = await authentication.state();
|
|
166
|
-
|
|
172
|
+
const tiers = effectiveTiers(adapter.manifest.tiers).map(tier => tier.id);
|
|
173
|
+
violations.push(...validateAuthenticationState(authenticationState, "authentication", { tiers }));
|
|
167
174
|
if (authenticationState.status !== "authenticated") {
|
|
168
175
|
throw new Error(`Adapter contract exercise requires authenticated test state, received ${authenticationState.status}`);
|
|
169
176
|
}
|
|
@@ -212,9 +219,13 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
212
219
|
requireMethod(on, "executeTeamBreak", "the login declares capabilities.team.breakControl");
|
|
213
220
|
if (capabilities.team?.consultControl === true)
|
|
214
221
|
requireMethod(on, "executeTeamConsult", "the login declares capabilities.team.consultControl");
|
|
222
|
+
if (capabilities.team?.policyControl === true)
|
|
223
|
+
requireMethod(on, "executeTeamPolicy", "the login declares capabilities.team.policyControl");
|
|
224
|
+
if (some(capabilities.preferences))
|
|
225
|
+
requireMethod(on, "setPreference", "the login declares capabilities.preferences");
|
|
215
226
|
};
|
|
216
227
|
unsubscribeAuthentication = authentication.subscribe(state => {
|
|
217
|
-
const own = validateAuthenticationState(state);
|
|
228
|
+
const own = validateAuthenticationState(state, "authentication", { tiers });
|
|
218
229
|
violations.push(...own);
|
|
219
230
|
if (own.length > 0)
|
|
220
231
|
return;
|
|
@@ -723,7 +734,7 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
|
723
734
|
assertNoViolations(found, "The media follows the task");
|
|
724
735
|
}
|
|
725
736
|
/** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
|
|
726
|
-
export function stillHost(report = { online: true }) {
|
|
737
|
+
export function stillHost(report = { online: true, browsers: { urlVisibility: "hidden" } }) {
|
|
727
738
|
return { report: () => report, subscribe: () => () => undefined };
|
|
728
739
|
}
|
|
729
740
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
package/dist/validation.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export declare function validateManifest(manifest: unknown, path?: string): Prot
|
|
|
13
13
|
export interface TaskValidationContext {
|
|
14
14
|
/** The provider's channel, from its manifest. A task must agree with it. */
|
|
15
15
|
channel: string;
|
|
16
|
+
/** The tier ids in force, from the manifest. The defaults when absent. */
|
|
17
|
+
tiers?: readonly string[];
|
|
16
18
|
}
|
|
17
19
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
18
20
|
/**
|
|
@@ -30,6 +32,8 @@ export interface ReaderContext {
|
|
|
30
32
|
* snapshot carries a roster, nobody else's does, and `requests` need `team.consultControl`.
|
|
31
33
|
*/
|
|
32
34
|
capabilities?: SessionCapabilities;
|
|
35
|
+
/** The tier ids in force. Filled from the manifest by `validateSnapshot` and `validateEventEnvelope`; the defaults otherwise. */
|
|
36
|
+
tiers?: readonly string[];
|
|
33
37
|
/** The login's `sessionId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
34
38
|
sessionId?: string;
|
|
35
39
|
/** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether `task-offered` carries an `acceptanceMode`. */
|
|
@@ -45,7 +49,7 @@ export declare function validateEventEnvelope(envelope: unknown, manifest: unkno
|
|
|
45
49
|
*/
|
|
46
50
|
export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
|
|
47
51
|
/** The connection methods whose results `validateResult` knows. */
|
|
48
|
-
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia";
|
|
52
|
+
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia" | "setPreference" | "executeTeamPolicy";
|
|
49
53
|
/**
|
|
50
54
|
* Validates what a connection method answered. A result is untrusted for the same reason a
|
|
51
55
|
* snapshot is: it comes from an adapter that may be compiled against another version, and Omni
|
|
@@ -53,4 +57,9 @@ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" |
|
|
|
53
57
|
* failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
|
|
54
58
|
*/
|
|
55
59
|
export declare function validateResult(result: unknown, method: ResultMethod, path?: string): ProtocolViolation[];
|
|
56
|
-
|
|
60
|
+
/** What a login is validated against beyond its own shape. */
|
|
61
|
+
export interface LoginValidationContext {
|
|
62
|
+
/** The tier ids in force, from the manifest. The defaults when absent. */
|
|
63
|
+
tiers?: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];
|
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, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, } from "./index.js";
|
|
12
|
+
import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, DEFAULT_TIERS, effectiveTiers, } from "./index.js";
|
|
13
13
|
export class ProtocolConformanceError extends Error {
|
|
14
14
|
violations;
|
|
15
15
|
constructor(violations, summary = "Adapter violates the Omni protocol") {
|
|
@@ -69,14 +69,14 @@ const DIAL_DESTINATION_POLICIES = membersOf({ "contacts-only": true, "any-number
|
|
|
69
69
|
const SNAPSHOT_REASONS = membersOf({
|
|
70
70
|
reconnected: true, "provider-requested": true,
|
|
71
71
|
});
|
|
72
|
-
const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true });
|
|
72
|
+
const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true, preferences: true });
|
|
73
73
|
const MEMBER_BREAKS = membersOf({
|
|
74
74
|
"awaiting-decision": true, granted: true, "starting-after-task": true,
|
|
75
75
|
});
|
|
76
76
|
const OFFERABLE_PHASES = membersOf({
|
|
77
77
|
pending: true, confirmed: true, preparing: true,
|
|
78
78
|
});
|
|
79
|
-
const TEAM_CAPABILITIES = membersOf({ breakControl: true, consultControl: true });
|
|
79
|
+
const TEAM_CAPABILITIES = membersOf({ breakControl: true, consultControl: true, policyControl: true });
|
|
80
80
|
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
81
81
|
const EXPIRABLE_PHASES = membersOf({
|
|
82
82
|
pending: true, confirmed: true, preparing: true,
|
|
@@ -164,15 +164,19 @@ export function validateContact(contact, path = "contact") {
|
|
|
164
164
|
validateContactInto(contact, path, into);
|
|
165
165
|
return into.violations;
|
|
166
166
|
}
|
|
167
|
-
function validateContactInto(contact, path, into) {
|
|
167
|
+
function validateContactInto(contact, path, into, tiers) {
|
|
168
168
|
if (!isPlainObject(contact)) {
|
|
169
169
|
into.add("contact.shape", path, "a contact must be an object");
|
|
170
170
|
return;
|
|
171
171
|
}
|
|
172
172
|
for (const field of ["name", "number", "email"]) {
|
|
173
|
-
if (contact[field]
|
|
174
|
-
|
|
173
|
+
if (contact[field] === undefined)
|
|
174
|
+
continue;
|
|
175
|
+
if ((field === "number" || field === "email") && isLocked(contact[field])) {
|
|
176
|
+
validateLockedInto(contact[field], `contact.${field}.locked`, `${path}.${field}`, tiers, into);
|
|
177
|
+
continue;
|
|
175
178
|
}
|
|
179
|
+
into.filled(contact[field], `contact.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
|
|
176
180
|
}
|
|
177
181
|
validateAttributes(contact.attributes, `${path}.attributes`, into);
|
|
178
182
|
}
|
|
@@ -307,6 +311,27 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
307
311
|
}
|
|
308
312
|
}
|
|
309
313
|
}
|
|
314
|
+
if (manifest.tiers !== undefined) {
|
|
315
|
+
if (!Array.isArray(manifest.tiers)) {
|
|
316
|
+
into.add("manifest.tiers.shape", `${path}.tiers`, "tiers must be an array when present");
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const ids = new Set();
|
|
320
|
+
manifest.tiers.forEach((tier, index) => {
|
|
321
|
+
const at = `${path}.tiers[${index}]`;
|
|
322
|
+
if (!isPlainObject(tier)) {
|
|
323
|
+
into.add("manifest.tier.shape", at, "each tier must be an object with an id and a label");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (into.filled(tier.id, "manifest.tier.id", `${at}.id`, "a tier needs an id")) {
|
|
327
|
+
if (ids.has(tier.id))
|
|
328
|
+
into.add("manifest.tier.unique", `${at}.id`, `duplicate tier: ${tier.id}`);
|
|
329
|
+
ids.add(tier.id);
|
|
330
|
+
}
|
|
331
|
+
into.filled(tier.label, "manifest.tier.label", `${at}.label`, "a tier needs the label a desk shows for it");
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
310
335
|
if (manifest.taskTypePresentation !== undefined) {
|
|
311
336
|
if (!isPlainObject(manifest.taskTypePresentation)) {
|
|
312
337
|
into.add("manifest.taskTypePresentation.shape", `${path}.taskTypePresentation`, "taskTypePresentation must be an object when present");
|
|
@@ -427,8 +452,73 @@ function validateCustomCapabilities(value, path, into) {
|
|
|
427
452
|
into.oneOf(custom.ui.kind, CUSTOM_UI_KINDS, "task.custom.ui.kind", `${at}.ui.kind`);
|
|
428
453
|
into.filled(custom.ui.label, "task.custom.ui.label", `${at}.ui.label`, "a custom control needs a label");
|
|
429
454
|
into.oneOf(custom.ui.placement, CUSTOM_UI_PLACEMENTS, "task.custom.ui.placement", `${at}.ui.placement`);
|
|
455
|
+
if (custom.ui.render !== undefined)
|
|
456
|
+
into.oneOf(custom.ui.render, CUSTOM_RENDERS, "task.custom.ui.render", `${at}.ui.render`);
|
|
457
|
+
if (custom.prompt !== undefined) {
|
|
458
|
+
if (!isPlainObject(custom.prompt) || !Array.isArray(custom.prompt.fields)) {
|
|
459
|
+
into.add("task.custom.prompt.shape", `${at}.prompt`, "a prompt is an object with the fields the agent fills");
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
into.require(custom.prompt.fields.length > 0, "task.custom.prompt.fields", `${at}.prompt.fields`, "a prompt with no fields asks for nothing; omit it");
|
|
463
|
+
custom.prompt.fields.forEach((field, fieldIndex) => {
|
|
464
|
+
const where = `${at}.prompt.fields[${fieldIndex}]`;
|
|
465
|
+
if (!isPlainObject(field)) {
|
|
466
|
+
into.add("task.custom.prompt.field.shape", where, "each prompt field must be an object");
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
into.filled(field.name, "task.custom.prompt.field.name", `${where}.name`, "a prompt field needs a name");
|
|
470
|
+
into.filled(field.label, "task.custom.prompt.field.label", `${where}.label`, "a prompt field needs a label");
|
|
471
|
+
into.oneOf(field.type, CREDENTIAL_FIELD_TYPES, "task.custom.prompt.field.type", `${where}.type`);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
430
475
|
});
|
|
431
476
|
}
|
|
477
|
+
const DEFAULT_TIER_IDS = DEFAULT_TIERS.map(tier => tier.id);
|
|
478
|
+
const POLICY_SETTINGS = membersOf({ on: true, off: true, agent: true });
|
|
479
|
+
const POLICY_KEYS = new Set([
|
|
480
|
+
...TASK_CAPABILITIES.voice, ...TASK_CAPABILITIES.chat, ...TASK_CAPABILITIES.email, "dial",
|
|
481
|
+
].filter(name => name !== "browsers" && name !== "dispositions" && name !== "custom"));
|
|
482
|
+
const AGENT_SETTABLE = /^(hold|mute|skill:.+)$/;
|
|
483
|
+
const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
|
|
484
|
+
/** The tier ids in force: the manifest's, or the defaults when the caller holds no manifest. */
|
|
485
|
+
const tierIds = (tiers) => tiers ?? DEFAULT_TIER_IDS;
|
|
486
|
+
/** `lockedBy`: a declared tier other than `person`, who never locks their own value. */
|
|
487
|
+
function validateLockedByInto(value, rule, path, tiers, into) {
|
|
488
|
+
if (!into.filled(value, rule, path, "lockedBy names the tier that locked it"))
|
|
489
|
+
return;
|
|
490
|
+
into.require(value !== "person", `${rule}.person`, path, "a person never locks their own value");
|
|
491
|
+
into.require(tierIds(tiers).includes(value), `${rule}.unknown`, path, `${String(value)} is not a tier this manifest declares: the defaults are ${DEFAULT_TIER_IDS.join(", ")}`);
|
|
492
|
+
}
|
|
493
|
+
/** `{ lockedBy, reason? }` standing in for a value: who locked it, and a reason if given. */
|
|
494
|
+
function validateLockedInto(value, rule, path, tiers, into) {
|
|
495
|
+
validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, tiers, into);
|
|
496
|
+
if (value.reason !== undefined)
|
|
497
|
+
into.filled(value.reason, `${rule}.reason`, `${path}.reason`, "a reason must not be empty when present");
|
|
498
|
+
}
|
|
499
|
+
/** What every resolved value carries: who set it, and who locked it if anyone. */
|
|
500
|
+
function validateResolvedInto(value, rule, path, tiers, into) {
|
|
501
|
+
if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a tier, or provisioning")) {
|
|
502
|
+
into.require(value.setBy === "provisioning" || tierIds(tiers).includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${String(value.setBy)} is neither provisioning nor a tier this manifest declares`);
|
|
503
|
+
}
|
|
504
|
+
if (value.lockedBy !== undefined)
|
|
505
|
+
validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, tiers, into);
|
|
506
|
+
if (value.reason !== undefined) {
|
|
507
|
+
into.filled(value.reason, `${rule}.reason`, `${path}.reason`, "a reason must not be empty when present");
|
|
508
|
+
into.require(value.lockedBy !== undefined, `${rule}.reason.unexpected`, `${path}.reason`, "a reason goes with lockedBy: it says why it was locked");
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/** The tier ids a manifest puts in force, for validators that receive one. */
|
|
512
|
+
function manifestTiers(manifest) {
|
|
513
|
+
if (!isPlainObject(manifest))
|
|
514
|
+
return undefined;
|
|
515
|
+
const declared = Array.isArray(manifest.tiers)
|
|
516
|
+
? manifest.tiers.filter((tier) => isPlainObject(tier) && typeof tier.id === "string")
|
|
517
|
+
: [];
|
|
518
|
+
return effectiveTiers(declared).map(tier => tier.id);
|
|
519
|
+
}
|
|
520
|
+
const CUSTOM_RENDERS = membersOf({ inline: true, page: true });
|
|
521
|
+
const CREDENTIAL_FIELD_TYPES = membersOf({ text: true, password: true });
|
|
432
522
|
function validateBrowsers(value, path, into) {
|
|
433
523
|
if (!Array.isArray(value)) {
|
|
434
524
|
into.add("task.browsers.shape", path, "browsers must be an array");
|
|
@@ -633,7 +723,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
633
723
|
into.filled(task.reference, "task.reference", `${path}.reference`, "a reference must not be empty when present");
|
|
634
724
|
}
|
|
635
725
|
if (task.contact !== undefined)
|
|
636
|
-
validateContactInto(task.contact, `${path}.contact`, into);
|
|
726
|
+
validateContactInto(task.contact, `${path}.contact`, into, context.tiers);
|
|
637
727
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
638
728
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into);
|
|
639
729
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
|
|
@@ -660,6 +750,14 @@ function validateTaskInto(task, context, path, into) {
|
|
|
660
750
|
continue;
|
|
661
751
|
if (!into.require(allowed.includes(name), "task.capability.channel", `${path}.capabilities.${name}`, `a ${context.channel} task may not declare ${name}`))
|
|
662
752
|
continue;
|
|
753
|
+
// A control the queue could allow may stand locked in its place, saying whose. What the
|
|
754
|
+
// queue provides -- browsers, dispositions, custom controls -- is content, not a control.
|
|
755
|
+
if (isLocked(declared)) {
|
|
756
|
+
if (into.require(name !== "browsers" && name !== "dispositions" && name !== "custom", "task.capability.locked.unexpected", `${path}.capabilities.${name}`, `${name} is what the queue provides, not a control anyone locks`)) {
|
|
757
|
+
validateLockedInto(declared, "task.capability.locked", `${path}.capabilities.${name}`, context.tiers, into);
|
|
758
|
+
}
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
663
761
|
switch (name) {
|
|
664
762
|
case "dispositions":
|
|
665
763
|
validateDispositions(declared, `${path}.capabilities.dispositions`, into);
|
|
@@ -780,6 +878,18 @@ function validateTeamRosterInto(roster, path, context, into) {
|
|
|
780
878
|
if (context.capabilities !== undefined && context.capabilities.team === undefined) {
|
|
781
879
|
into.add("team.unentitled", path, "a roster published to a login that does not declare capabilities.team: the login is the permission");
|
|
782
880
|
}
|
|
881
|
+
// The team's policies travel with the roster exactly when the login may set them.
|
|
882
|
+
if (context.capabilities !== undefined) {
|
|
883
|
+
const may = context.capabilities.team?.policyControl === true;
|
|
884
|
+
if (may && roster.policies === undefined) {
|
|
885
|
+
into.add("team.policies.required", `${path}.policies`, "the login declares team.policyControl, so the roster carries the team's policies");
|
|
886
|
+
}
|
|
887
|
+
if (!may && roster.policies !== undefined) {
|
|
888
|
+
into.add("team.policies.capability", `${path}.policies`, "policies require team.policyControl on the login: a lead who may not set them has nothing to see");
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (roster.policies !== undefined)
|
|
892
|
+
validateTeamPoliciesInto(roster.policies, `${path}.policies`, into, context.tiers);
|
|
783
893
|
if (roster.requests === undefined) {
|
|
784
894
|
// `[]` says nobody is asking; omission says the lead may not be asked. A login that may be
|
|
785
895
|
// asked therefore always carries the list.
|
|
@@ -855,6 +965,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
855
965
|
return into.violations;
|
|
856
966
|
}
|
|
857
967
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
968
|
+
const tiers = context.tiers ?? manifestTiers(manifest);
|
|
858
969
|
into.oneOf(snapshot.status, CONNECTION_STATUSES, "snapshot.status", `${path}.status`);
|
|
859
970
|
if (into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to")
|
|
860
971
|
&& context.sessionId !== undefined) {
|
|
@@ -868,7 +979,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
868
979
|
const seen = new Set();
|
|
869
980
|
let assisting;
|
|
870
981
|
snapshot.tasks.forEach((task, index) => {
|
|
871
|
-
validateTaskInto(task, { channel }, `${path}.tasks[${index}]`, into);
|
|
982
|
+
validateTaskInto(task, { channel, tiers }, `${path}.tasks[${index}]`, into);
|
|
872
983
|
// A lead assists one call at a time.
|
|
873
984
|
if (isPlainObject(task) && task.assisting !== undefined) {
|
|
874
985
|
if (assisting !== undefined)
|
|
@@ -933,7 +1044,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
933
1044
|
into.add("team.required", `${path}.team`, "the login declares capabilities.team, so every snapshot carries a roster: [] when nobody is in it");
|
|
934
1045
|
}
|
|
935
1046
|
if (snapshot.team !== undefined)
|
|
936
|
-
validateTeamRosterInto(snapshot.team, `${path}.team`, context, into);
|
|
1047
|
+
validateTeamRosterInto(snapshot.team, `${path}.team`, { ...context, tiers }, into);
|
|
937
1048
|
return into.violations;
|
|
938
1049
|
}
|
|
939
1050
|
// ---------------------------------------------------------------------------
|
|
@@ -1017,6 +1128,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1017
1128
|
return into.violations;
|
|
1018
1129
|
}
|
|
1019
1130
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
1131
|
+
const tiers = context.tiers ?? manifestTiers(manifest);
|
|
1020
1132
|
into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
|
|
1021
1133
|
if (into.filled(envelope.sessionId, "event.sessionId", `${path}.sessionId`, "an event needs the session id it belongs to")
|
|
1022
1134
|
&& context.sessionId !== undefined) {
|
|
@@ -1033,7 +1145,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1033
1145
|
switch (event.type) {
|
|
1034
1146
|
case "snapshot":
|
|
1035
1147
|
into.oneOf(event.reason, SNAPSHOT_REASONS, "event.snapshot.reason", `${at}.reason`);
|
|
1036
|
-
into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, context));
|
|
1148
|
+
into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, { ...context, tiers }));
|
|
1037
1149
|
break;
|
|
1038
1150
|
case "provider-status":
|
|
1039
1151
|
into.oneOf(event.status, CONNECTION_STATUSES, "event.providerStatus.status", `${at}.status`);
|
|
@@ -1045,7 +1157,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1045
1157
|
validateBreakState(event.break, `${at}.break`, into);
|
|
1046
1158
|
break;
|
|
1047
1159
|
case "task-offered":
|
|
1048
|
-
validateTaskInto(event.task, { channel }, `${at}.task`, into);
|
|
1160
|
+
validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
|
|
1049
1161
|
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1050
1162
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1051
1163
|
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}`);
|
|
@@ -1066,7 +1178,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1066
1178
|
}
|
|
1067
1179
|
break;
|
|
1068
1180
|
case "task-updated":
|
|
1069
|
-
validateTaskInto(event.task, { channel }, `${at}.task`, into);
|
|
1181
|
+
validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
|
|
1070
1182
|
break;
|
|
1071
1183
|
case "task-media-ended":
|
|
1072
1184
|
into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
@@ -1088,7 +1200,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1088
1200
|
validateProviderSummary(event.summary, `${at}.summary`, into);
|
|
1089
1201
|
break;
|
|
1090
1202
|
case "team-updated":
|
|
1091
|
-
validateTeamRosterInto(event.team, `${at}.team`, context, into);
|
|
1203
|
+
validateTeamRosterInto(event.team, `${at}.team`, { ...context, tiers }, into);
|
|
1092
1204
|
break;
|
|
1093
1205
|
case "contacts-updated":
|
|
1094
1206
|
into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
|
|
@@ -1125,6 +1237,53 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1125
1237
|
// Results. A result crosses the same boundary a snapshot does, from an adapter that may be
|
|
1126
1238
|
// compiled against another version, and Omni shows the agent what it says.
|
|
1127
1239
|
// ---------------------------------------------------------------------------
|
|
1240
|
+
const PREFERENCE_ID = /^(hold|mute|skill:.+)$/;
|
|
1241
|
+
/** The choices left to the person, each with where it stands. */
|
|
1242
|
+
function validatePreferencesInto(value, path, into, tiers) {
|
|
1243
|
+
if (!Array.isArray(value)) {
|
|
1244
|
+
into.add("preferences.shape", path, "preferences must be an array");
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const seen = new Set();
|
|
1248
|
+
value.forEach((preference, index) => {
|
|
1249
|
+
const at = `${path}[${index}]`;
|
|
1250
|
+
if (!isPlainObject(preference)) {
|
|
1251
|
+
into.add("preference.shape", at, "each preference must be an object");
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
if (into.require(typeof preference.id === "string" && PREFERENCE_ID.test(preference.id), "preference.id", `${at}.id`, "a preference is hold, mute, or skill:<id>: nothing else is the person's to set")) {
|
|
1255
|
+
if (seen.has(preference.id))
|
|
1256
|
+
into.add("preference.unique", `${at}.id`, `duplicate preference: ${preference.id}`);
|
|
1257
|
+
seen.add(preference.id);
|
|
1258
|
+
}
|
|
1259
|
+
into.filled(preference.label, "preference.label", `${at}.label`, "a preference needs a label");
|
|
1260
|
+
into.require(typeof preference.enabled === "boolean", "preference.enabled", `${at}.enabled`, "a preference says where it stands");
|
|
1261
|
+
validateResolvedInto(preference, "preference", at, tiers, into);
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
/** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
|
|
1265
|
+
function validateTeamPoliciesInto(value, path, into, tiers) {
|
|
1266
|
+
if (!isPlainObject(value)) {
|
|
1267
|
+
into.add("team.policies.shape", path, "policies must be an object keyed by capability");
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
for (const [key, policy] of Object.entries(value)) {
|
|
1271
|
+
if (policy === undefined)
|
|
1272
|
+
continue;
|
|
1273
|
+
const at = `${path}.${key}`;
|
|
1274
|
+
if (!into.require(POLICY_KEYS.has(key) || /^skill:.+$/.test(key), "team.policy.key", at, `${key} is not a control a policy can name: a task control, dial, or skill:<id>`))
|
|
1275
|
+
continue;
|
|
1276
|
+
if (!isPlainObject(policy)) {
|
|
1277
|
+
into.add("team.policy.shape", at, "each policy carries its setting, who set it, and who locked it if anyone");
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
if (into.oneOf(policy.setting, POLICY_SETTINGS, "team.policy.setting", `${at}.setting`)) {
|
|
1281
|
+
into.require(policy.setting !== "agent" || AGENT_SETTABLE.test(key), "team.policy.agent", `${at}.setting`, `${key} is the team's, on or off; only hold, mute and skills may be left to the person`);
|
|
1282
|
+
}
|
|
1283
|
+
validateResolvedInto(policy, "team.policy", at, tiers, into);
|
|
1284
|
+
into.require(policy.setBy !== "person", "team.policy.setBy", `${at}.setBy`, "a team policy is not set by a person");
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1128
1287
|
/** A `ProtocolFailure`, wherever one appears: on a result, or on a task's failed outcome. */
|
|
1129
1288
|
function validateFailureInto(value, path, into) {
|
|
1130
1289
|
if (!isPlainObject(value)) {
|
|
@@ -1144,6 +1303,7 @@ function validateFailureInto(value, path, into) {
|
|
|
1144
1303
|
into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
|
|
1145
1304
|
}
|
|
1146
1305
|
}
|
|
1306
|
+
const URL_VISIBILITIES = membersOf({ full: true, domain: true, hidden: true });
|
|
1147
1307
|
const HOST_AUDIO_REASONS = membersOf({ "no-device": true, denied: true, "not-asked": true, "in-use": true, lost: true });
|
|
1148
1308
|
const HOST_OUTPUT_REASONS = membersOf({ "no-device": true, lost: true });
|
|
1149
1309
|
function validateUnavailable(value, rule, path, into) {
|
|
@@ -1166,6 +1326,12 @@ export function validateHostReport(report, path = "host") {
|
|
|
1166
1326
|
return into.violations;
|
|
1167
1327
|
}
|
|
1168
1328
|
into.require(typeof report.online === "boolean", "host.online", `${path}.online`, "a host report says whether it has a network");
|
|
1329
|
+
if (!isPlainObject(report.browsers)) {
|
|
1330
|
+
into.add("host.browsers.shape", `${path}.browsers`, "a host report says what its chrome shows of a task browser's URL");
|
|
1331
|
+
}
|
|
1332
|
+
else {
|
|
1333
|
+
into.oneOf(report.browsers.urlVisibility, URL_VISIBILITIES, "host.browsers.urlVisibility", `${path}.browsers.urlVisibility`);
|
|
1334
|
+
}
|
|
1169
1335
|
if (report.audio === undefined)
|
|
1170
1336
|
return into.violations;
|
|
1171
1337
|
if (!isPlainObject(report.audio)) {
|
|
@@ -1223,6 +1389,8 @@ const RESULT_STATUSES = {
|
|
|
1223
1389
|
executeTeamBreak: { success: "applied", failure: "failed" },
|
|
1224
1390
|
executeTeamConsult: { success: "applied", failure: "failed" },
|
|
1225
1391
|
openMedia: { success: "opened", failure: "unavailable" },
|
|
1392
|
+
setPreference: { success: "applied", failure: "failed" },
|
|
1393
|
+
executeTeamPolicy: { success: "applied", failure: "failed" },
|
|
1226
1394
|
};
|
|
1227
1395
|
/**
|
|
1228
1396
|
* Validates what a connection method answered. A result is untrusted for the same reason a
|
|
@@ -1267,7 +1435,7 @@ function validateUser(value, rule, path, into) {
|
|
|
1267
1435
|
into.require(isUserId(value.id), `${rule}.id`, `${path}.id`, "an identity needs a provider-issued user id");
|
|
1268
1436
|
into.filled(value.displayName, `${rule}.displayName`, `${path}.displayName`, "an identity needs a display name");
|
|
1269
1437
|
}
|
|
1270
|
-
function validateSessionCapabilitiesInto(value, path, into) {
|
|
1438
|
+
function validateSessionCapabilitiesInto(value, path, into, tiers) {
|
|
1271
1439
|
if (!isPlainObject(value)) {
|
|
1272
1440
|
into.add("authentication.capabilities.shape", path, "a usable login declares its capabilities: an object, {} when it has none");
|
|
1273
1441
|
return;
|
|
@@ -1277,6 +1445,13 @@ function validateSessionCapabilitiesInto(value, path, into) {
|
|
|
1277
1445
|
continue;
|
|
1278
1446
|
if (!into.require(SESSION_CAPABILITIES.includes(name), "authentication.capability.unknown", `${path}.${name}`, `unsupported session capability: ${name}`))
|
|
1279
1447
|
continue;
|
|
1448
|
+
if (name === "preferences") {
|
|
1449
|
+
if (Array.isArray(declared) && declared.length === 0) {
|
|
1450
|
+
into.add("authentication.capability.preferences.empty", `${path}.preferences`, "a login with nothing left to the person omits preferences rather than declaring an empty list");
|
|
1451
|
+
}
|
|
1452
|
+
validatePreferencesInto(declared, `${path}.preferences`, into, tiers);
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1280
1455
|
if (name === "team") {
|
|
1281
1456
|
if (!isPlainObject(declared)) {
|
|
1282
1457
|
into.add("authentication.capability.team.shape", `${path}.team`, "team names what the lead may do: an object, {} for a lead with no controls");
|
|
@@ -1294,7 +1469,7 @@ function validateSessionCapabilitiesInto(value, path, into) {
|
|
|
1294
1469
|
into.require(declared === true, "authentication.capability.value", `${path}.${name}`, `${name} is declared by presence: send true or omit it`);
|
|
1295
1470
|
}
|
|
1296
1471
|
}
|
|
1297
|
-
export function validateAuthenticationState(state, path = "authentication") {
|
|
1472
|
+
export function validateAuthenticationState(state, path = "authentication", context = {}) {
|
|
1298
1473
|
const into = new Collector();
|
|
1299
1474
|
if (!isPlainObject(state)) {
|
|
1300
1475
|
into.add("authentication.shape", path, "an authentication state must be an object");
|
|
@@ -1307,7 +1482,7 @@ export function validateAuthenticationState(state, path = "authentication") {
|
|
|
1307
1482
|
// may carry an identity. Anything else is a state claiming knowledge it does not have.
|
|
1308
1483
|
if (state.status === "authenticated" || state.status === "refreshing") {
|
|
1309
1484
|
validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
|
|
1310
|
-
validateSessionCapabilitiesInto(state.capabilities, `${path}.capabilities`, into);
|
|
1485
|
+
validateSessionCapabilitiesInto(state.capabilities, `${path}.capabilities`, into, context.tiers);
|
|
1311
1486
|
}
|
|
1312
1487
|
else if (state.status === "expired") {
|
|
1313
1488
|
if (state.identity !== undefined)
|
package/guide.md
CHANGED
|
@@ -176,6 +176,7 @@ type Manifest<C extends Channel = Channel> = {
|
|
|
176
176
|
idleCapabilities?: IdleCapabilities<C>;
|
|
177
177
|
phaseLabels?: TaskPhaseLabels;
|
|
178
178
|
taskTypePresentation?: Record<string, TaskTypePresentation>;
|
|
179
|
+
tiers?: TierDeclaration[];
|
|
179
180
|
};
|
|
180
181
|
```
|
|
181
182
|
|
|
@@ -223,10 +224,12 @@ type AuthenticationContext = {
|
|
|
223
224
|
type TeamCapabilities = {
|
|
224
225
|
breakControl?: true;
|
|
225
226
|
consultControl?: true;
|
|
227
|
+
policyControl?: true;
|
|
226
228
|
};
|
|
227
229
|
|
|
228
230
|
type SessionCapabilities = {
|
|
229
231
|
breaks?: true;
|
|
232
|
+
preferences?: AgentPreference[];
|
|
230
233
|
team?: TeamCapabilities;
|
|
231
234
|
};
|
|
232
235
|
|
|
@@ -256,7 +259,10 @@ type HostAudioOutput =
|
|
|
256
259
|
| { status: "ready" }
|
|
257
260
|
| { status: "unavailable"; reason: HostOutputUnavailableReason; failure: ProtocolFailure };
|
|
258
261
|
|
|
262
|
+
type UrlVisibility = "full" | "domain" | "hidden";
|
|
263
|
+
|
|
259
264
|
type HostReport = {
|
|
265
|
+
browsers: { urlVisibility: UrlVisibility };
|
|
260
266
|
online: boolean;
|
|
261
267
|
audio?: {
|
|
262
268
|
input: HostAudioInput;
|
|
@@ -279,11 +285,79 @@ type ConnectContext = {
|
|
|
279
285
|
};
|
|
280
286
|
|
|
281
287
|
type ConnectionStatus = "connecting" | "active" | "error";
|
|
288
|
+
|
|
289
|
+
type CredentialField = {
|
|
290
|
+
name: string;
|
|
291
|
+
label: string;
|
|
292
|
+
type: "text" | "password";
|
|
293
|
+
required?: boolean;
|
|
294
|
+
autocomplete?: string;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
type AuthenticationChallenge =
|
|
298
|
+
| { flowId: string; method: "browser-sso"; authorizationUrl: string; browser: "system" | "omni" }
|
|
299
|
+
| { flowId: string; method: "credentials"; fields: CredentialField[] };
|
|
300
|
+
|
|
301
|
+
type StartAuthenticationRequest =
|
|
302
|
+
| { requestId: string; method: "browser-sso"; callbackUrl: string }
|
|
303
|
+
| { requestId: string; method: "credentials" };
|
|
304
|
+
|
|
305
|
+
type StartAuthenticationResult =
|
|
306
|
+
| { status: "interaction-required"; challenge: AuthenticationChallenge }
|
|
307
|
+
| { status: "rejected"; failure: AuthenticationFailure };
|
|
308
|
+
|
|
309
|
+
type CompleteAuthenticationRequest =
|
|
310
|
+
| { flowId: string; method: "browser-sso"; callbackUrl: string }
|
|
311
|
+
| { flowId: string; method: "credentials"; values: Readonly<Record<string, string>> };
|
|
312
|
+
|
|
313
|
+
type CompleteAuthenticationResult =
|
|
314
|
+
| { status: "authenticated"; identity: User; capabilities: SessionCapabilities; expiresAt?: IsoTimestamp }
|
|
315
|
+
| { status: "rejected"; failure: AuthenticationFailure };
|
|
316
|
+
|
|
317
|
+
type AuthenticationActionResult =
|
|
318
|
+
| { status: "accepted" }
|
|
319
|
+
| { status: "failed"; failure: AuthenticationFailure };
|
|
320
|
+
|
|
321
|
+
type Unsubscribe = () => void;
|
|
322
|
+
|
|
323
|
+
type AuthenticationSession = {
|
|
324
|
+
state(): AuthenticationState | Promise<AuthenticationState>;
|
|
325
|
+
subscribe(listener: (state: AuthenticationState) => void): Unsubscribe;
|
|
326
|
+
start(request: StartAuthenticationRequest): Promise<StartAuthenticationResult>;
|
|
327
|
+
complete(request: CompleteAuthenticationRequest): Promise<CompleteAuthenticationResult>;
|
|
328
|
+
cancelAuthentication(flowId: string): Promise<AuthenticationActionResult>;
|
|
329
|
+
signOut(): Promise<AuthenticationActionResult>;
|
|
330
|
+
close(): Promise<void>;
|
|
331
|
+
};
|
|
282
332
|
```
|
|
283
333
|
|
|
284
334
|
### Provider state
|
|
285
335
|
|
|
286
336
|
```ts
|
|
337
|
+
type PreferenceId = "hold" | "mute" | `skill:${string}`;
|
|
338
|
+
|
|
339
|
+
type SetBy = Tier | "provisioning";
|
|
340
|
+
|
|
341
|
+
type Resolved = {
|
|
342
|
+
setBy: SetBy;
|
|
343
|
+
lockedBy?: Tier;
|
|
344
|
+
reason?: string;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
type AgentPreference = Resolved & {
|
|
348
|
+
id: PreferenceId;
|
|
349
|
+
label: string;
|
|
350
|
+
enabled: boolean;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
type SetPreferenceRequest =
|
|
354
|
+
| { id: PreferenceId; enabled: boolean }
|
|
355
|
+
| { id: PreferenceId; inherit: true };
|
|
356
|
+
|
|
357
|
+
type PreferenceResult =
|
|
358
|
+
| { status: "applied" }
|
|
359
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
360
|
+
|
|
287
361
|
type Snapshot = {
|
|
288
362
|
status: ConnectionStatus;
|
|
289
363
|
sessionId: string;
|
|
@@ -297,6 +371,10 @@ type Snapshot = {
|
|
|
297
371
|
type AgentCapacity = {
|
|
298
372
|
count: number; // absolute ceiling, at least 1
|
|
299
373
|
};
|
|
374
|
+
|
|
375
|
+
type CapacityResult =
|
|
376
|
+
| { status: "accepted" }
|
|
377
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
300
378
|
```
|
|
301
379
|
|
|
302
380
|
### Idle contributions
|
|
@@ -304,8 +382,8 @@ type AgentCapacity = {
|
|
|
304
382
|
```ts
|
|
305
383
|
type Contact = {
|
|
306
384
|
name?: string;
|
|
307
|
-
number?: string
|
|
308
|
-
email?: string
|
|
385
|
+
number?: Lockable<string>;
|
|
386
|
+
email?: Lockable<string>;
|
|
309
387
|
attributes?: Attribute[];
|
|
310
388
|
};
|
|
311
389
|
|
|
@@ -317,6 +395,14 @@ type ScheduledActivity = {
|
|
|
317
395
|
contact?: Contact;
|
|
318
396
|
attributes?: Attribute[];
|
|
319
397
|
};
|
|
398
|
+
|
|
399
|
+
type DialRequest = {
|
|
400
|
+
destination: string;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
type DialResult =
|
|
404
|
+
| { status: "dialled" }
|
|
405
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
320
406
|
```
|
|
321
407
|
|
|
322
408
|
### Task capabilities
|
|
@@ -348,7 +434,9 @@ type CustomCapability = {
|
|
|
348
434
|
kind: "button" | "toggle" | "menu-item";
|
|
349
435
|
label: string;
|
|
350
436
|
placement: "primary" | "secondary" | "overflow";
|
|
437
|
+
render?: "inline" | "page";
|
|
351
438
|
};
|
|
439
|
+
prompt?: { fields: CredentialField[] };
|
|
352
440
|
};
|
|
353
441
|
|
|
354
442
|
type SharedTaskCapabilities = {
|
|
@@ -360,20 +448,20 @@ type SharedTaskCapabilities = {
|
|
|
360
448
|
type TaskCapabilities<C extends Channel = Channel> =
|
|
361
449
|
C extends "voice"
|
|
362
450
|
? SharedTaskCapabilities & {
|
|
363
|
-
decline?: true
|
|
364
|
-
mute?: true
|
|
365
|
-
hold?: true
|
|
366
|
-
agentDisconnect?: true
|
|
367
|
-
callback?: true
|
|
368
|
-
blindTransfer?: true | DestinationDirectory
|
|
369
|
-
consultTransfer?: true | DestinationDirectory
|
|
370
|
-
consultLead?: true
|
|
371
|
-
conference?: true | DestinationDirectory
|
|
372
|
-
recording?: true
|
|
451
|
+
decline?: Lockable<true>;
|
|
452
|
+
mute?: Lockable<true>;
|
|
453
|
+
hold?: Lockable<true>;
|
|
454
|
+
agentDisconnect?: Lockable<true>;
|
|
455
|
+
callback?: Lockable<true>;
|
|
456
|
+
blindTransfer?: Lockable<true | DestinationDirectory>;
|
|
457
|
+
consultTransfer?: Lockable<true | DestinationDirectory>;
|
|
458
|
+
consultLead?: Lockable<true>;
|
|
459
|
+
conference?: Lockable<true | DestinationDirectory>;
|
|
460
|
+
recording?: Lockable<true>;
|
|
373
461
|
}
|
|
374
462
|
: C extends "chat"
|
|
375
|
-
? SharedTaskCapabilities & { reject?: true
|
|
376
|
-
: SharedTaskCapabilities & { reject?: true };
|
|
463
|
+
? SharedTaskCapabilities & { reject?: Lockable<true>; hold?: Lockable<true> }
|
|
464
|
+
: SharedTaskCapabilities & { reject?: Lockable<true> };
|
|
377
465
|
```
|
|
378
466
|
|
|
379
467
|
The channel arms are why `Task<"email">` rejects `hold` at compile time rather than at runtime.
|
|
@@ -393,15 +481,24 @@ const BROWSER_ISOLATION_SCHEMES = {
|
|
|
393
481
|
type BrowserIsolationScheme =
|
|
394
482
|
(typeof BROWSER_ISOLATION_SCHEMES)[keyof typeof BROWSER_ISOLATION_SCHEMES];
|
|
395
483
|
|
|
396
|
-
type
|
|
484
|
+
type TaskBrowserBase = {
|
|
397
485
|
id: string;
|
|
398
486
|
name: string;
|
|
399
487
|
purpose: string;
|
|
400
488
|
url: string;
|
|
401
|
-
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
type TaskBrowser = TaskBrowserBase & (
|
|
402
492
|
| { reuse: false; isolationScheme?: never }
|
|
403
493
|
| { reuse: true; isolationScheme: BrowserIsolationScheme }
|
|
404
494
|
);
|
|
495
|
+
|
|
496
|
+
type BrowserSessionKeyInput = {
|
|
497
|
+
providerId: string;
|
|
498
|
+
taskId: TaskId;
|
|
499
|
+
taskType: string;
|
|
500
|
+
browser: TaskBrowser;
|
|
501
|
+
};
|
|
405
502
|
```
|
|
406
503
|
|
|
407
504
|
That union is what makes a reusing browser with no scheme fail to compile rather than inherit a
|
|
@@ -471,6 +568,20 @@ type TaskAssisting = {
|
|
|
471
568
|
since: IsoTimestamp;
|
|
472
569
|
};
|
|
473
570
|
|
|
571
|
+
type Tier = string;
|
|
572
|
+
|
|
573
|
+
type TierDeclaration = {
|
|
574
|
+
id: Tier;
|
|
575
|
+
label: string;
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
type Locked = {
|
|
579
|
+
lockedBy: Tier;
|
|
580
|
+
reason?: string;
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
type Lockable<T> = T | Locked;
|
|
584
|
+
|
|
474
585
|
type Task<C extends Channel = Channel> = {
|
|
475
586
|
id: TaskId;
|
|
476
587
|
title: string;
|
|
@@ -574,9 +685,80 @@ type TaskCommandRequest<C extends Channel = Channel> = {
|
|
|
574
685
|
taskId: TaskId;
|
|
575
686
|
command: TaskCommand<C>;
|
|
576
687
|
};
|
|
688
|
+
|
|
689
|
+
type TaskCommandResult =
|
|
690
|
+
| { status: "applied" }
|
|
691
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
577
692
|
```
|
|
578
693
|
|
|
579
|
-
###
|
|
694
|
+
### Who decides what an agent may do
|
|
695
|
+
|
|
696
|
+
An agent desk has two managers, not one. **The queue** — a process, a work type — is owned by a
|
|
697
|
+
process manager and **allows** a set of capabilities: hold, mute, callback, new call, conference,
|
|
698
|
+
whether the number is visible, the actions it offers, the skills it needs. **The people** are
|
|
699
|
+
managed through the organisation's structure — a team, a location, the organisation itself, in
|
|
700
|
+
whatever combination the structure defines for a person — and **decide** per capability within
|
|
701
|
+
what the queue allows: on for everyone, off for everyone, or left to the person. What you do to
|
|
702
|
+
your team is a **policy**; what you do to yourself is a **preference**. A person belongs to one
|
|
703
|
+
team and many queues, so a policy applies across every queue the person works.
|
|
704
|
+
|
|
705
|
+
**The provider resolves; the protocol carries the result and who decided.** The structure's tiers
|
|
706
|
+
are the provider's ladder: each tier states only what it sets, an enforcing policy at a tier above
|
|
707
|
+
the person settles the value for everyone below it, and where nothing enforces the most specific
|
|
708
|
+
tier that says anything wins. The protocol names a tier by the id the manifest declares for it and
|
|
709
|
+
never describes the chain between them: which tiers a person passes through is the structure's to
|
|
710
|
+
know. A typical organisation has four, and they are the defaults — `DEFAULT_TIERS`: `org`, `site`,
|
|
711
|
+
`team`, `person`, each with the label a desk shows — which `Manifest.tiers` relabels by id (a
|
|
712
|
+
provider whose "site" means something else says what it means) or extends with tiers of its own;
|
|
713
|
+
a manifest that declares none has exactly the four. `lockedBy` is any tier in force except
|
|
714
|
+
`person`, who never locks their own value; `setBy` is any tier in force, or `provisioning`, the
|
|
715
|
+
protocol's own word for "no tier has said anything and the provider's default applies". A host
|
|
716
|
+
renders "who decided" from the declared labels and needs no others. What the wire carries is the
|
|
717
|
+
resolution:
|
|
718
|
+
|
|
719
|
+
- **`lockedBy`** — a tier above the person made this value theirs to keep. A person never locks
|
|
720
|
+
their own value, and the queue is not a tier: what the queue does not allow at all is absent.
|
|
721
|
+
- **`setBy`** — who stated the value as it stands: a tier, the `person` themself, or
|
|
722
|
+
`provisioning` where no tier has said anything. Provenance, not a lock: a value that came from a
|
|
723
|
+
broad tier as a default is still the person's to change.
|
|
724
|
+
|
|
725
|
+
**On a task, a control the queue could allow may stand locked in its place.** `Task.capabilities`
|
|
726
|
+
is the effective set. What the queue does not allow is absent and nothing is shown. What the queue
|
|
727
|
+
allows and a tier above the person locked is present as `{ lockedBy, reason? }` where the control's
|
|
728
|
+
value would be — `mute: { lockedBy: "team", reason: "Nobody on this team mutes" }` — and Omni
|
|
729
|
+
renders that control disabled, saying who decided, so an agent who cannot press Mute knows whether
|
|
730
|
+
to ask their lead or their site. `lockedBy` is the discriminant: a value that carries it is the
|
|
731
|
+
lock, so nothing that can be locked — a directory, a number — may carry that key itself. A
|
|
732
|
+
contact's number and email are the same, since each identifies a person: where the queue says
|
|
733
|
+
the agent may not see it, the provider sends `{ lockedBy }` in its place — the last digits or
|
|
734
|
+
nothing — and a CRM link carries a token, never the value with a flag the desk is asked to honour.
|
|
735
|
+
A name is not locked. What the queue provides rather than permits — browsers, dispositions, custom
|
|
736
|
+
controls — is content, and is never locked.
|
|
737
|
+
|
|
738
|
+
**A lead sets the team's policy from their roster.** A login that declares
|
|
739
|
+
`capabilities.team.policyControl` may `executeTeamPolicy({ type: "set", capability, setting })`
|
|
740
|
+
with `on`, `off`, or `agent`, for any task control, `dial`, or a skill — and only `hold`, `mute`
|
|
741
|
+
and skills may be `agent`; callback and new call are the team's, on or off, within what the queue
|
|
742
|
+
allows. The roster carries `policies` for such a login: every policy as it stands, who set it, and
|
|
743
|
+
`lockedBy` where a tier above the team made it theirs to keep, which the lead sees and cannot
|
|
744
|
+
change — `executeTeamPolicy` on it answers `failed` with `omni.capability-not-enabled`.
|
|
745
|
+
|
|
746
|
+
**What the team left to the person is the person's, and the provider keeps it.** The login's
|
|
747
|
+
`capabilities.preferences` lists every preference the person may hold — `hold`, `mute`, a skill —
|
|
748
|
+
with where it stands and who set it: `setBy: "team"` while they inherit the team's default,
|
|
749
|
+
`"person"` once they have set their own, `"provisioning"` where no tier has said anything. Nothing
|
|
750
|
+
is hidden for want of a row, and a preference a tier above has since locked is listed with
|
|
751
|
+
`lockedBy`. `setPreference` is the person's act — `{ id, enabled }` to set their own, `{ id,
|
|
752
|
+
inherit: true }` to give it up and inherit again — answered `applied` and republished as a new
|
|
753
|
+
`authenticated` state when something changed, a state and not a flicker, as every republish of
|
|
754
|
+
`authenticated` is; and it is durable: the person's across sessions. A lead may also set a
|
|
755
|
+
person's preference from their own screen, which arrives the same way. A preference is keyed by
|
|
756
|
+
the capability's own name because it is the same capability at another tier: effective in
|
|
757
|
+
`Task.capabilities`, set for the team in `policies`, left to the person in `preferences`. The
|
|
758
|
+
command `mute` acts on one call; the preference `mute` says whether the person wants the control
|
|
759
|
+
at all, and a host renders it in its settings, never as the button on a call.
|
|
760
|
+
|
|
761
|
+
## Breaks
|
|
580
762
|
|
|
581
763
|
```ts
|
|
582
764
|
type BreakApproval =
|
|
@@ -613,6 +795,22 @@ type BreakState = {
|
|
|
613
795
|
activeReasonId?: string;
|
|
614
796
|
imposed?: ImposedBreak;
|
|
615
797
|
};
|
|
798
|
+
|
|
799
|
+
type BreakRequestResult =
|
|
800
|
+
| { status: "requested" }
|
|
801
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
802
|
+
|
|
803
|
+
type BreakCommitResult =
|
|
804
|
+
| { status: "committed" }
|
|
805
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
806
|
+
|
|
807
|
+
type BreakCancelResult =
|
|
808
|
+
| { status: "cancelled" }
|
|
809
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
810
|
+
|
|
811
|
+
type BreakEndResult =
|
|
812
|
+
| { status: "ended" }
|
|
813
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
616
814
|
```
|
|
617
815
|
|
|
618
816
|
### Team
|
|
@@ -638,6 +836,28 @@ type LeadRequest = {
|
|
|
638
836
|
type TeamRoster = {
|
|
639
837
|
members: TeamMember[];
|
|
640
838
|
requests?: LeadRequest[];
|
|
839
|
+
policies?: TeamPolicies;
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
type PolicyKey =
|
|
843
|
+
| Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities>
|
|
844
|
+
| Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities>
|
|
845
|
+
| Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities>
|
|
846
|
+
| "dial"
|
|
847
|
+
| `skill:${string}`;
|
|
848
|
+
|
|
849
|
+
type TeamPolicySetting = "on" | "off" | "agent";
|
|
850
|
+
|
|
851
|
+
type TeamPolicy = Resolved & {
|
|
852
|
+
setting: TeamPolicySetting;
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
type TeamPolicies = Partial<Record<PolicyKey, TeamPolicy>>;
|
|
856
|
+
|
|
857
|
+
type TeamPolicyCommand = { type: "set"; capability: PolicyKey; setting: TeamPolicySetting };
|
|
858
|
+
|
|
859
|
+
type TeamPolicyCommandRequest = {
|
|
860
|
+
command: TeamPolicyCommand;
|
|
641
861
|
};
|
|
642
862
|
|
|
643
863
|
type TeamConsultCommand =
|
|
@@ -649,6 +869,18 @@ type TeamBreakCommand =
|
|
|
649
869
|
| { type: "policy"; policy: "ask" | "auto-approve" | "suspended" }
|
|
650
870
|
| { type: "place"; memberId: UserId; reason?: string }
|
|
651
871
|
| { type: "release"; memberId: UserId };
|
|
872
|
+
|
|
873
|
+
type TeamBreakCommandRequest = {
|
|
874
|
+
command: TeamBreakCommand;
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
type TeamConsultCommandRequest = {
|
|
878
|
+
command: TeamConsultCommand;
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
type TeamCommandResult =
|
|
882
|
+
| { status: "applied" }
|
|
883
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
652
884
|
```
|
|
653
885
|
|
|
654
886
|
### Media
|
|
@@ -663,6 +895,11 @@ type VoiceMediaSession = {
|
|
|
663
895
|
type OpenMediaResult =
|
|
664
896
|
| { status: "opened"; session: VoiceMediaSession }
|
|
665
897
|
| { status: "unavailable"; failure: ProtocolFailure };
|
|
898
|
+
|
|
899
|
+
type OpenMediaRequest = {
|
|
900
|
+
taskId: TaskId;
|
|
901
|
+
localAudio?: MediaStream;
|
|
902
|
+
};
|
|
666
903
|
```
|
|
667
904
|
|
|
668
905
|
### Events
|
|
@@ -710,12 +947,52 @@ type ProviderEventEnvelope = {
|
|
|
710
947
|
reason rather than a naming one: `Event` is a DOM global, and a bare one would shadow it for every
|
|
711
948
|
adapter compiled against the browser lib.
|
|
712
949
|
|
|
950
|
+
### Adapter and connection
|
|
951
|
+
|
|
952
|
+
```ts
|
|
953
|
+
type Connection<C extends Channel = Channel> = {
|
|
954
|
+
snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
|
|
955
|
+
subscribe(listener: (envelope: ProviderEventEnvelope<C>) => void): Unsubscribe;
|
|
956
|
+
setCapacity(capacity: AgentCapacity): Promise<CapacityResult>;
|
|
957
|
+
execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
|
|
958
|
+
disconnect(): Promise<void>;
|
|
959
|
+
|
|
960
|
+
describeUsers?(ids: UserId[]): Promise<User[]>;
|
|
961
|
+
dial?(request: DialRequest): Promise<DialResult>;
|
|
962
|
+
|
|
963
|
+
requestBreak?(request: BreakRequest): Promise<BreakRequestResult>;
|
|
964
|
+
commitBreak?(): Promise<BreakCommitResult>;
|
|
965
|
+
cancelBreak?(): Promise<BreakCancelResult>;
|
|
966
|
+
endBreak?(): Promise<BreakEndResult>;
|
|
967
|
+
|
|
968
|
+
executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
|
|
969
|
+
executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
|
|
970
|
+
openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
|
|
971
|
+
setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
|
|
972
|
+
executeTeamPolicy?(request: TeamPolicyCommandRequest): Promise<TeamCommandResult>;
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
type Adapter<C extends Channel = Channel> = {
|
|
976
|
+
manifest: Manifest<C>;
|
|
977
|
+
createAuthenticationSession(context: AuthenticationContext): Promise<AuthenticationSession> | AuthenticationSession;
|
|
978
|
+
connect(context: ConnectContext): Promise<Connection<C>>;
|
|
979
|
+
};
|
|
980
|
+
```
|
|
981
|
+
|
|
713
982
|
### Published constants
|
|
714
983
|
|
|
715
984
|
```ts
|
|
716
985
|
const ALLOWED_BROWSER_URL_SCHEMES = ["http:", "https:"] as const;
|
|
717
986
|
|
|
718
987
|
const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"] as const;
|
|
988
|
+
type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
|
|
989
|
+
|
|
990
|
+
const DEFAULT_TIERS = [
|
|
991
|
+
{ id: "org", label: "Your organisation" },
|
|
992
|
+
{ id: "site", label: "Your site" },
|
|
993
|
+
{ id: "team", label: "Your team" },
|
|
994
|
+
{ id: "person", label: "You" },
|
|
995
|
+
] as const satisfies readonly TierDeclaration[];
|
|
719
996
|
|
|
720
997
|
const IDLE_CAPABILITY_UI = {
|
|
721
998
|
dial: "Dialpad",
|
|
@@ -758,6 +1035,7 @@ const OMNI_FAILURE_CODES = [
|
|
|
758
1035
|
"omni.unavailable",
|
|
759
1036
|
"omni.break-already-committed",
|
|
760
1037
|
] as const;
|
|
1038
|
+
type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
|
|
761
1039
|
```
|
|
762
1040
|
|
|
763
1041
|
`HANDLING_STEPS_WITH_A_PERSON` is every `HandlingStep` except `queued`, which is the one nobody
|
|
@@ -1071,6 +1349,7 @@ compile time.
|
|
|
1071
1349
|
| `idleCapabilities` | Declares actions Omni may offer while the agent has no active task, such as voice dialing. Task controls do not belong here. |
|
|
1072
1350
|
| `phaseLabels` | Optional static adapter-defined display names for canonical `TaskPhase` values. They cannot vary at runtime. |
|
|
1073
1351
|
| `taskTypePresentation` | Optional static adapter-defined presentation keyed by exact `taskType`. It names the item and its optional agent-facing reference. |
|
|
1352
|
+
| `tiers` | The structure's tiers as the provider calls them, each with the label a desk shows for "who decided". Relabels any of `DEFAULT_TIERS` by id and may add others; omitted for the typical four. See **Who decides what an agent may do**. |
|
|
1074
1353
|
|
|
1075
1354
|
### Authentication methods
|
|
1076
1355
|
|
|
@@ -1378,6 +1657,8 @@ them from what arrives later.
|
|
|
1378
1657
|
| `team` | This login leads a team. The provider publishes a `TeamRoster` to it on every snapshot — `[]` when nobody is in it — and to nobody else. |
|
|
1379
1658
|
| `team.breakControl` | This lead may act on their team's breaks through `executeTeamBreak` — place, release, decide, set policy — as far as the provider supports; a command it lacks answers `omni.capability-not-enabled`. Omni asks for a decision only against a member whose `break` is `awaiting-decision`, so a provider that grants on request is never asked to decide. Requires `executeTeamBreak`. |
|
|
1380
1659
|
| `team.consultControl` | This lead may join a member's call on request. Requires `executeTeamConsult`. |
|
|
1660
|
+
| `team.policyControl` | This lead sets the team's policy per capability — on, off, or the agent's — within what the queue allows. Requires `executeTeamPolicy`; the roster carries `policies`. |
|
|
1661
|
+
| `preferences` | What the team left to this person, with where each stands and who set it. Omitted when nothing was. Requires `setPreference`. See **Who decides what an agent may do**. |
|
|
1381
1662
|
|
|
1382
1663
|
A session action is available only when both the capability and Omni provisioning permit it.
|
|
1383
1664
|
|
|
@@ -1568,6 +1849,8 @@ surface in one place, and what obliges an adapter to implement each one.
|
|
|
1568
1849
|
| `endBreak()` | The login declares `capabilities.breaks`. |
|
|
1569
1850
|
| `executeTeamBreak(command)` | The login declares `capabilities.team.breakControl`. |
|
|
1570
1851
|
| `executeTeamConsult(command)` | The login declares `capabilities.team.consultControl`. |
|
|
1852
|
+
| `setPreference(request)` | The login declares `capabilities.preferences`: the person's choice has to have somewhere to go. |
|
|
1853
|
+
| `executeTeamPolicy(command)` | The login declares `capabilities.team.policyControl`. |
|
|
1571
1854
|
| `openMedia(request)` | The manifest channel is `voice`. Every voice task's audio lands in Omni, so there is no voice adapter that does not implement it. |
|
|
1572
1855
|
|
|
1573
1856
|
**The four break methods stand or fall together.** Declaring `capabilities.breaks` at login and then
|
|
@@ -2245,8 +2528,15 @@ capabilities: {
|
|
|
2245
2528
|
```
|
|
2246
2529
|
|
|
2247
2530
|
Custom capability IDs must be non-empty and unique within the task. `ui.kind` is `button`, `toggle`,
|
|
2248
|
-
or `menu-item`; `ui.placement` is `primary`, `secondary`, or `overflow`.
|
|
2249
|
-
|
|
2531
|
+
or `menu-item`; `ui.placement` is `primary`, `secondary`, or `overflow`. `ui.render` says where the
|
|
2532
|
+
control's work appears: `inline`, in the workspace beside the task, or `page`, as a page of its own
|
|
2533
|
+
— a tab in the same work area as the task's browsers, beside them, and alone on a task that has
|
|
2534
|
+
none; inline when absent. `prompt.fields` are what the agent supplies before the action runs — a
|
|
2535
|
+
destination number, a reference — as `CredentialField`s Omni renders as a form; the values travel
|
|
2536
|
+
on the custom command under the fields' names, as strings, and Omni sends the command only once
|
|
2537
|
+
every `required` field has a value. The provider validates what arrives as it validates any
|
|
2538
|
+
command; a form is a declaration, not a contract for what the agent typed. Omni renders the
|
|
2539
|
+
control and invokes it with the shared custom task command:
|
|
2250
2540
|
|
|
2251
2541
|
```ts
|
|
2252
2542
|
{
|
|
@@ -2654,6 +2944,7 @@ A lead who also takes calls sees their team on the idle dashboard. `Snapshot.tea
|
|
|
2654
2944
|
| --- | --- |
|
|
2655
2945
|
| `members` | Every member of this lead's team, whatever their state. `[]` says the lead has a team with nobody in it; omitting the roster says something else entirely — see **The login is the permission** below. |
|
|
2656
2946
|
| `requests` | The members currently asking this lead to join a call, each with the task and the note. Required when the login declares `team.consultControl`, `[]` when nobody is asking; omitted when it does not. See **Consulting a lead**. |
|
|
2947
|
+
| `policies` | The team's policy per capability as it stands — the setting, who set it, and `lockedBy` where a tier above the team made it theirs to keep. Required when the login declares `team.policyControl`; omitted when it does not. See **Who decides what an agent may do**. |
|
|
2657
2948
|
|
|
2658
2949
|
| `TeamMember` field | Contract |
|
|
2659
2950
|
| --- | --- |
|
|
@@ -2863,7 +3154,8 @@ what failed, and reports. It never decides for the adapter what a missing microp
|
|
|
2863
3154
|
| --- | --- |
|
|
2864
3155
|
| `online` | Whether the host has a network interface up. Not a claim that anything is reachable — the adapter knows whether it can reach its own platform far better than the host does — so `false` is a reason not to go ready and `true` is not a reason to. |
|
|
2865
3156
|
| `audio` | Present on a voice connection, absent where there is no audio. |
|
|
2866
|
-
| `audio.input` | `ready` with `localAudio` — the microphone as captured, the same stream `openMedia` receives — and `flowing`, false while the hardware or OS says no audio moves through it (a headset's own mute switch, which Omni's Mute control never touches). `unavailable` with `reason`, since each wants a different fix from the agent: `no-device`; `denied`; `not-asked`, which a host that asks at connect never publishes; `in-use`, a device present and permitted that another application holds — on an agent desktop the commonest of all; `lost`, a capture that ended. `failure` carries the words Omni showed them. |
|
|
3157
|
+
| `audio.input` | `ready` with `localAudio` — the microphone as captured, the same stream `openMedia` receives — and `flowing`, false while the hardware or OS says no audio moves through it (a headset's own mute switch, which Omni's Mute control never touches). `unavailable` with `reason`, since each wants a different fix from the agent: `no-device`; `denied`; `not-asked`, which a host that asks at connect never publishes; `in-use`, a device present and permitted that another application holds — on an agent desktop the commonest of all; `lost`, a capture that ended. A host decides the reason from the devices before the error name: a browser can report a permission error on a machine with no microphone at all, and "grant permission" is the wrong instruction for an agent who needs to plug one in. `failure` carries the words Omni showed them. |
|
|
3158
|
+
| `browsers.urlVisibility` | What Omni's own chrome shows of a task browser's URL: `hidden`, `domain`, or `full`. A statement about the chrome, not about what the page renders or a screenshot captures; task browsers only — the personal browser is the agent's own and nothing a provider sends appears in it. A provider reads it before deciding what URL it is willing to send: one that carries confidential data goes out under `hidden` and not under `full`. |
|
|
2867
3159
|
| `audio.output` | `ready`, or `unavailable` with `reason` — `no-device`, or `lost` for one removed — and `failure`: an agent who cannot hear is as unable to take a call as one who cannot speak. |
|
|
2868
3160
|
|
|
2869
3161
|
Omni republishes the report whenever it changes — a permission granted late, a headset unplugged,
|