@xema/omni-protocol 0.1.107 → 0.1.109
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/README.md +1 -1
- package/dist/index.d.ts +133 -0
- package/dist/index.js +4 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +38 -2
- package/dist/validation.d.ts +13 -2
- package/dist/validation.js +157 -1
- package/guide/agent.md +2 -0
- package/guide/breaks.md +4 -288
- package/guide/lead.md +2 -0
- package/guide/phone.md +65 -0
- package/guide/queue.md +360 -0
- package/guide.md +85 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ system.
|
|
|
12
12
|
| Path | |
|
|
13
13
|
| --- | --- |
|
|
14
14
|
| `guide.md` | **The protocol.** The terms, the shapes, the rules, and how an adapter is declared, connected and held to them. |
|
|
15
|
-
| `guide/` | One file per role and channel: `agent.md`, `lead.md`, `breaks.md`, `phone.md`, `voice.md`, `chat.md`, `email.md`. Each points back into `guide.md` for what it relies on. |
|
|
15
|
+
| `guide/` | One file per role and channel: `agent.md`, `lead.md`, `queue.md`, `breaks.md`, `phone.md`, `voice.md`, `chat.md`, `email.md`. Each points back into `guide.md` for what it relies on. |
|
|
16
16
|
| `src/index.ts` | The TypeScript declarations. |
|
|
17
17
|
| `src/validation.ts` | Runtime validators Omni applies to adapter output. |
|
|
18
18
|
| `src/testing.ts` | Conformance helpers an adapter runs against its own test state. |
|
package/dist/index.d.ts
CHANGED
|
@@ -112,6 +112,46 @@ export declare const DIAL_OUTCOMES: readonly ["answered", "busy", "no-answer", "
|
|
|
112
112
|
*/
|
|
113
113
|
export type Phone = "softphone" | "deskPhone";
|
|
114
114
|
export declare const PHONES: readonly ["softphone", "deskPhone"];
|
|
115
|
+
/**
|
|
116
|
+
* The device itself, as the platform sees it. `ready` is registered and usable, idle or busy; the
|
|
117
|
+
* other three are the states in which no call can land: the platform has lost the phone's
|
|
118
|
+
* registration, the agent pressed do-not-disturb on it, or left the handset off the hook with no
|
|
119
|
+
* call on it.
|
|
120
|
+
*/
|
|
121
|
+
export type PhoneStatus = "ready" | "unregistered" | "do-not-disturb" | "off-hook";
|
|
122
|
+
export declare const PHONE_STATUSES: readonly ["ready", "unregistered", "do-not-disturb", "off-hook"];
|
|
123
|
+
export type PhoneChannelState = "ringing" | "active" | "held";
|
|
124
|
+
export declare const PHONE_CHANNEL_STATES: readonly ["ringing", "active", "held"];
|
|
125
|
+
/**
|
|
126
|
+
* One call on the phone, as the phone sees it: ringing, the one active, or held. A bridge is one
|
|
127
|
+
* channel, so a conference is one entry and its members are on the task's `onCall`. Where the call
|
|
128
|
+
* is a task's, `assignmentId` names it and the two views agree; absent, the call is the phone's
|
|
129
|
+
* alone -- an internal call on the extension, a lead's listening leg.
|
|
130
|
+
*/
|
|
131
|
+
export interface PhoneChannel {
|
|
132
|
+
state: PhoneChannelState;
|
|
133
|
+
since: IsoTimestamp;
|
|
134
|
+
assignmentId?: AssignmentId;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The phone's own view, published by a provider that declares `phoneStatus`: the device the login
|
|
138
|
+
* is on, whether it can take a call, its own mute where the platform observes it, and its channels
|
|
139
|
+
* -- one active at most, any number held. The task side says which of those calls are the agent's
|
|
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 desk phone's,
|
|
142
|
+
* never a softphone's, whose microphone is the host's and whose mute is the host's report.
|
|
143
|
+
*/
|
|
144
|
+
export interface PhoneState {
|
|
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
|
+
phone: Phone;
|
|
147
|
+
status: PhoneStatus;
|
|
148
|
+
/** The phone's own mute, on a desk phone the platform observes; absent where it does not, or on a softphone. */
|
|
149
|
+
muted?: true;
|
|
150
|
+
/** When the status began, counted from the provider's clock. Omitted rather than invented. */
|
|
151
|
+
since?: IsoTimestamp;
|
|
152
|
+
/** The calls on the phone; `[]` when idle. None under `unregistered` or `off-hook`. */
|
|
153
|
+
channels: PhoneChannel[];
|
|
154
|
+
}
|
|
115
155
|
/** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
|
|
116
156
|
export declare const IDLE_CAPABILITIES: readonly ["dial", "personalBrowser", "calendar", "contacts"];
|
|
117
157
|
export type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
|
|
@@ -178,6 +218,11 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
178
218
|
* off voice, where there is no call to hear. The host picks one of these per login.
|
|
179
219
|
*/
|
|
180
220
|
phones?: C extends "voice" ? Phone[] : never;
|
|
221
|
+
/**
|
|
222
|
+
* The platform sees the phone itself -- its registration, its do-not-disturb, its hook -- and
|
|
223
|
+
* publishes it as `Snapshot.phone` and `phone-updated`. Voice only, declared by presence.
|
|
224
|
+
*/
|
|
225
|
+
phoneStatus?: C extends "voice" ? true : never;
|
|
181
226
|
/**
|
|
182
227
|
* The provider takes running reports of a host-performed step -- `recordStep` with `seconds`
|
|
183
228
|
* so far and no `ended`. Absent, the host sends exactly two reports per leg, when it began and
|
|
@@ -251,6 +296,12 @@ export declare const LISTENING_MODES: readonly ["listen", "coach", "join-call"];
|
|
|
251
296
|
export interface UserCapabilities {
|
|
252
297
|
/** This login may request a break. Requires the four break methods. */
|
|
253
298
|
breaks?: true;
|
|
299
|
+
/**
|
|
300
|
+
* The platform lines calls up for this agent: the agent may press Next call while on one to
|
|
301
|
+
* have the next queued call put in their own queue, to start when their wrap ends unless
|
|
302
|
+
* somebody else takes it first. Requires `requestNextCall`, `cancelNextCall` and `releaseLinedUp`.
|
|
303
|
+
*/
|
|
304
|
+
nextCall?: true;
|
|
254
305
|
/** The choices the team left to this person, with where each stands. Omitted when there are none. Requires `setPreference`. */
|
|
255
306
|
preferences?: AgentPreference[];
|
|
256
307
|
/**
|
|
@@ -1273,6 +1324,53 @@ export type BreakEndResult = {
|
|
|
1273
1324
|
status: "failed";
|
|
1274
1325
|
failure: ProtocolFailure;
|
|
1275
1326
|
};
|
|
1327
|
+
/**
|
|
1328
|
+
* The agent's standing ask for the next call, made by pressing Next call while an assignment is
|
|
1329
|
+
* at work. It lives only as long as its purpose: the provider clears it when a call is lined up,
|
|
1330
|
+
* when the agent withdraws it, and when the assignment it was pressed on ends.
|
|
1331
|
+
*/
|
|
1332
|
+
export interface NextCallRequest {
|
|
1333
|
+
since: IsoTimestamp;
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* A call waiting for this agent in their own queue: still in the platform's queue, not yet a task,
|
|
1337
|
+
* ringing nothing. It starts when the agent's wrap ends, as an ordinary `task-offered` with
|
|
1338
|
+
* `acceptance: "automatic"`, unless somebody else answered first or the caller gave up, and then
|
|
1339
|
+
* the entry goes and no task ever was. One at a time: the phone only ever takes the next one.
|
|
1340
|
+
* Lined up on the agent's ask, or on the platform's own routing -- a repeat caller put in front
|
|
1341
|
+
* of the agent they spoke to last.
|
|
1342
|
+
*/
|
|
1343
|
+
export interface LinedUpCall {
|
|
1344
|
+
/** The caller, as the provider shows them to this audience. */
|
|
1345
|
+
party?: Contact;
|
|
1346
|
+
/** The queue the call is in, by the platform's label. */
|
|
1347
|
+
queue?: string;
|
|
1348
|
+
/** When the caller entered the queue: their wait, counted from the provider's clock. */
|
|
1349
|
+
queuedSince?: IsoTimestamp;
|
|
1350
|
+
/** When the call was lined up for this agent. */
|
|
1351
|
+
since: IsoTimestamp;
|
|
1352
|
+
/** The agent may let this call go back to the queue for anyone: the provider's say, per call, by presence. */
|
|
1353
|
+
release?: true;
|
|
1354
|
+
}
|
|
1355
|
+
/** `requested` says the provider holds the ask, not that anything is lined up. */
|
|
1356
|
+
export type NextCallResult = {
|
|
1357
|
+
status: "requested";
|
|
1358
|
+
} | {
|
|
1359
|
+
status: "failed";
|
|
1360
|
+
failure: ProtocolFailure;
|
|
1361
|
+
};
|
|
1362
|
+
export type NextCallCancelResult = {
|
|
1363
|
+
status: "cancelled";
|
|
1364
|
+
} | {
|
|
1365
|
+
status: "failed";
|
|
1366
|
+
failure: ProtocolFailure;
|
|
1367
|
+
};
|
|
1368
|
+
export type LinedUpReleaseResult = {
|
|
1369
|
+
status: "released";
|
|
1370
|
+
} | {
|
|
1371
|
+
status: "failed";
|
|
1372
|
+
failure: ProtocolFailure;
|
|
1373
|
+
};
|
|
1276
1374
|
/** `elsewhere`: signed in here, and the host holds this agent's capacity for another provider (`count: 0`, host-stopped); not receiving this provider's work. */
|
|
1277
1375
|
export type TeamMemberAvailability = "ready" | "on-task" | "on-break" | "elsewhere" | "signed-out";
|
|
1278
1376
|
export interface TeamMember {
|
|
@@ -1290,6 +1388,12 @@ export interface TeamMember {
|
|
|
1290
1388
|
shift?: Shift;
|
|
1291
1389
|
/** The member's standing ask for a lead on one of their calls; absent when they are not asking. */
|
|
1292
1390
|
request?: MemberRequest;
|
|
1391
|
+
/** The member's phone as the platform sees it, the same `PhoneState` the member's own snapshot carries; present where the provider declares `phoneStatus`. */
|
|
1392
|
+
phone?: PhoneState;
|
|
1393
|
+
/** The member's standing ask for the next call, as their own snapshot carries it. */
|
|
1394
|
+
nextCall?: NextCallRequest;
|
|
1395
|
+
/** The call lined up in the member's own queue, as their own snapshot carries it. */
|
|
1396
|
+
linedUp?: LinedUpCall;
|
|
1293
1397
|
}
|
|
1294
1398
|
/** This member asking their lead to join a call: which assignment, the note they wrote, since when. */
|
|
1295
1399
|
export interface MemberRequest {
|
|
@@ -1589,6 +1693,12 @@ export interface Snapshot<C extends Channel = Channel> {
|
|
|
1589
1693
|
calendar?: ScheduledActivity[];
|
|
1590
1694
|
/** The agent's own day so far, as the provider counts it; replaced whole by `shift-updated`. Omitted where the provider cannot say. */
|
|
1591
1695
|
shift?: Shift;
|
|
1696
|
+
/** The phone as the platform sees it, from a provider that declares `phoneStatus`; replaced whole by `phone-updated`. */
|
|
1697
|
+
phone?: PhoneState;
|
|
1698
|
+
/** The agent's standing ask for the next call, while it stands; replaced whole by `next-call`. */
|
|
1699
|
+
nextCall?: NextCallRequest;
|
|
1700
|
+
/** The call waiting in the agent's own queue, while one is; replaced whole by `lined-up`. */
|
|
1701
|
+
linedUp?: LinedUpCall;
|
|
1592
1702
|
team?: TeamMembers;
|
|
1593
1703
|
}
|
|
1594
1704
|
/**
|
|
@@ -1705,6 +1815,21 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1705
1815
|
| {
|
|
1706
1816
|
type: "shift-updated";
|
|
1707
1817
|
shift: Shift;
|
|
1818
|
+
}
|
|
1819
|
+
/** The phone as the platform now sees it, whole: a registration lost or back, do-not-disturb pressed, the handset lifted or replaced, the phone's mute. */
|
|
1820
|
+
| {
|
|
1821
|
+
type: "phone-updated";
|
|
1822
|
+
phone: PhoneState;
|
|
1823
|
+
}
|
|
1824
|
+
/** The agent's ask for the next call as it now stands: present while it stands, absent once met, withdrawn or outlived. */
|
|
1825
|
+
| {
|
|
1826
|
+
type: "next-call";
|
|
1827
|
+
nextCall?: NextCallRequest;
|
|
1828
|
+
}
|
|
1829
|
+
/** The agent's own queue as it now stands: the call lined up for them, or nothing. */
|
|
1830
|
+
| {
|
|
1831
|
+
type: "lined-up";
|
|
1832
|
+
linedUp?: LinedUpCall;
|
|
1708
1833
|
};
|
|
1709
1834
|
/**
|
|
1710
1835
|
* The `Provider` prefix survives here for a mechanical reason rather than a naming one: `Event`
|
|
@@ -1776,6 +1901,14 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
1776
1901
|
commitBreak?(): Promise<BreakCommitResult>;
|
|
1777
1902
|
cancelBreak?(): Promise<BreakCancelResult>;
|
|
1778
1903
|
endBreak?(): Promise<BreakEndResult>;
|
|
1904
|
+
/**
|
|
1905
|
+
* The three stand together with `capabilities.nextCall`. Next call asks for the next queued
|
|
1906
|
+
* call to be lined up in the agent's own queue; cancel withdraws the ask before it is met;
|
|
1907
|
+
* release lets a lined-up call go back to the queue for anyone, where the entry grants it.
|
|
1908
|
+
*/
|
|
1909
|
+
requestNextCall?(): Promise<NextCallResult>;
|
|
1910
|
+
cancelNextCall?(): Promise<NextCallCancelResult>;
|
|
1911
|
+
releaseLinedUp?(): Promise<LinedUpReleaseResult>;
|
|
1779
1912
|
/** Required when the login declares `capabilities.lead`: every lead act, on the team surface and nowhere else. */
|
|
1780
1913
|
executeTeam?(request: TeamCommandRequest): Promise<TeamCommandResult>;
|
|
1781
1914
|
/** Required of a voice adapter whose manifest lists `softphone`: on one, the call's audio lands in Omni. */
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,8 @@ export function negotiateProtocolVersion(adapterVersions, hostVersions = OMNI_SU
|
|
|
17
17
|
}
|
|
18
18
|
export const DIAL_OUTCOMES = ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled", "unexplained"];
|
|
19
19
|
export const PHONES = ["softphone", "deskPhone"];
|
|
20
|
+
export const PHONE_STATUSES = ["ready", "unregistered", "do-not-disturb", "off-hook"];
|
|
21
|
+
export const PHONE_CHANNEL_STATES = ["ringing", "active", "held"];
|
|
20
22
|
/** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
|
|
21
23
|
export const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"];
|
|
22
24
|
/** What Omni calls each idle capability. */
|
|
@@ -179,10 +181,11 @@ export function historyStepExpectsAPerson(step) {
|
|
|
179
181
|
export function sameCapabilities(a, b) {
|
|
180
182
|
// Every field, so a capability added later cannot be missed here: `satisfies` pins the field
|
|
181
183
|
// list to the type, and a new key is a compile error until it is compared.
|
|
182
|
-
void { breaks: true, preferences: true, lead: true };
|
|
184
|
+
void { breaks: true, preferences: true, lead: true, nextCall: true };
|
|
183
185
|
const preferences = (list) => list === undefined ? undefined : list.map(p => [p.id, p.label, p.enabled, p.setBy, p.lockedBy ?? "", p.reason ?? ""].join("\u0000")).sort().join("\u0001");
|
|
184
186
|
return a.breaks === b.breaks &&
|
|
185
187
|
a.lead === b.lead &&
|
|
188
|
+
a.nextCall === b.nextCall &&
|
|
186
189
|
preferences(a.preferences) === preferences(b.preferences);
|
|
187
190
|
}
|
|
188
191
|
/**
|
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 team member list, each
|
|
8
8
|
* declared contribution, and each event type.
|
|
9
9
|
*/
|
|
10
|
-
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.history", "task.onCall", "task.leadAssist", "task.takenOver", "task.audio", "task.acceptance", "task.outcomes", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.forced", "team.members", "team.request", "team.tasks", "team.listening", "team.shift", "shift", "contacts", "calendar", "team.policies"];
|
|
10
|
+
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.history", "task.onCall", "task.leadAssist", "task.takenOver", "task.audio", "task.acceptance", "task.outcomes", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.forced", "team.members", "team.request", "team.tasks", "team.listening", "team.shift", "team.phone", "team.nextCall", "team.linedUp", "shift", "phone", "nextCall", "linedUp", "contacts", "calendar", "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
|
@@ -28,7 +28,13 @@ const STATE_SUBJECTS = [
|
|
|
28
28
|
"team.tasks",
|
|
29
29
|
"team.listening",
|
|
30
30
|
"team.shift",
|
|
31
|
+
"team.phone",
|
|
32
|
+
"team.nextCall",
|
|
33
|
+
"team.linedUp",
|
|
31
34
|
"shift",
|
|
35
|
+
"phone",
|
|
36
|
+
"nextCall",
|
|
37
|
+
"linedUp",
|
|
32
38
|
"contacts",
|
|
33
39
|
"calendar",
|
|
34
40
|
"team.policies",
|
|
@@ -39,7 +45,7 @@ const EVENT_TYPES = {
|
|
|
39
45
|
snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
|
|
40
46
|
"task-audio-started": true, "task-audio-ended": true, "task-ended": true, "dial-outcome": true, announcement: true, "queue-summary": true, diagnostic: true,
|
|
41
47
|
"team-updated": true, "team-member-updated": true, "team-member-removed": true, "team-policies-updated": true,
|
|
42
|
-
"contacts-updated": true, "calendar-updated": true, "shift-updated": true,
|
|
48
|
+
"contacts-updated": true, "calendar-updated": true, "shift-updated": true, "phone-updated": true, "next-call": true, "lined-up": true,
|
|
43
49
|
};
|
|
44
50
|
const CONTRACT_SUBJECTS = [
|
|
45
51
|
...STATE_SUBJECTS,
|
|
@@ -101,6 +107,12 @@ function observeMember(member, seen) {
|
|
|
101
107
|
seen.add("team.shift");
|
|
102
108
|
if (member.request !== undefined)
|
|
103
109
|
seen.add("team.request");
|
|
110
|
+
if (member.phone !== undefined)
|
|
111
|
+
seen.add("team.phone");
|
|
112
|
+
if (member.nextCall !== undefined)
|
|
113
|
+
seen.add("team.nextCall");
|
|
114
|
+
if (member.linedUp !== undefined)
|
|
115
|
+
seen.add("team.linedUp");
|
|
104
116
|
}
|
|
105
117
|
function observePolicies(value, seen) {
|
|
106
118
|
if (isRecord(value) && Object.keys(value).length > 0)
|
|
@@ -129,6 +141,12 @@ function observeSnapshot(value, seen) {
|
|
|
129
141
|
seen.add("calendar");
|
|
130
142
|
if (value.shift !== undefined)
|
|
131
143
|
seen.add("shift");
|
|
144
|
+
if (value.phone !== undefined)
|
|
145
|
+
seen.add("phone");
|
|
146
|
+
if (value.nextCall !== undefined)
|
|
147
|
+
seen.add("nextCall");
|
|
148
|
+
if (value.linedUp !== undefined)
|
|
149
|
+
seen.add("linedUp");
|
|
132
150
|
}
|
|
133
151
|
function observeEvent(envelope, seen) {
|
|
134
152
|
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
@@ -168,6 +186,17 @@ function observeEvent(envelope, seen) {
|
|
|
168
186
|
case "shift-updated":
|
|
169
187
|
seen.add("shift");
|
|
170
188
|
break;
|
|
189
|
+
case "phone-updated":
|
|
190
|
+
seen.add("phone");
|
|
191
|
+
break;
|
|
192
|
+
case "next-call":
|
|
193
|
+
if (event.nextCall !== undefined)
|
|
194
|
+
seen.add("nextCall");
|
|
195
|
+
break;
|
|
196
|
+
case "lined-up":
|
|
197
|
+
if (event.linedUp !== undefined)
|
|
198
|
+
seen.add("linedUp");
|
|
199
|
+
break;
|
|
171
200
|
default: break;
|
|
172
201
|
}
|
|
173
202
|
}
|
|
@@ -237,6 +266,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
237
266
|
self: current().identity.id,
|
|
238
267
|
capabilities: current().capabilities,
|
|
239
268
|
leadFeatures: leadFeaturesOn,
|
|
269
|
+
phone: context.phone,
|
|
240
270
|
loginId: context.loginId,
|
|
241
271
|
autoAcceptTasks: context.autoAcceptTasks,
|
|
242
272
|
locked: options.lockedValues,
|
|
@@ -265,6 +295,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
265
295
|
}
|
|
266
296
|
if (capabilities.lead === true)
|
|
267
297
|
requireMethod(on, "executeTeam", "the login declares capabilities.lead");
|
|
298
|
+
if (capabilities.nextCall === true) {
|
|
299
|
+
// The ask, its withdrawal and the release stand together: a lined-up call the agent could neither withdraw nor let go is a promise with no way out.
|
|
300
|
+
for (const method of ["requestNextCall", "cancelNextCall", "releaseLinedUp"]) {
|
|
301
|
+
requireMethod(on, method, "the login declares capabilities.nextCall");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
268
304
|
if (some(capabilities.preferences))
|
|
269
305
|
requireMethod(on, "setPreference", "the login declares capabilities.preferences");
|
|
270
306
|
};
|
|
@@ -1074,7 +1110,7 @@ const REACHABLE_PHASES = {
|
|
|
1074
1110
|
export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
|
|
1075
1111
|
"snapshot", "transport-status", "break-state", "task-offered", "task-updated", "task-ended",
|
|
1076
1112
|
"task-audio-started", "task-audio-ended", "team-updated", "team-member-updated", "team-member-removed", "team-policies-updated",
|
|
1077
|
-
"contacts-updated", "calendar-updated", "shift-updated",
|
|
1113
|
+
"contacts-updated", "calendar-updated", "shift-updated", "phone-updated", "next-call", "lined-up",
|
|
1078
1114
|
]);
|
|
1079
1115
|
export class TaskStream {
|
|
1080
1116
|
tasks = new Map();
|
package/dist/validation.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type RecordingState, type Channel, type ProtocolViolation, type UserCapabilities, type UserId } from "./index.js";
|
|
1
|
+
import { type RecordingState, type Channel, type Phone, type ProtocolViolation, type UserCapabilities, type UserId } from "./index.js";
|
|
2
2
|
export type { ProtocolViolation } from "./index.js";
|
|
3
3
|
export declare class ProtocolConformanceError extends Error {
|
|
4
4
|
readonly violations: readonly ProtocolViolation[];
|
|
@@ -99,6 +99,11 @@ export interface ReaderContext {
|
|
|
99
99
|
levels?: readonly string[];
|
|
100
100
|
/** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
101
101
|
loginId?: string;
|
|
102
|
+
/**
|
|
103
|
+
* The phone the host chose for this login at connect, `ConnectContext.phone`. A phone state
|
|
104
|
+
* naming another is the wrong phone. Unknown to a caller without the context, and then unchecked.
|
|
105
|
+
*/
|
|
106
|
+
phone?: Phone;
|
|
102
107
|
/**
|
|
103
108
|
* Whether the lead has the team feature on, as the application last told the provider. The
|
|
104
109
|
* provider assumes nothing until told: on, a snapshot carries the team; off, nothing of the
|
|
@@ -121,6 +126,12 @@ export declare function validateTeamMembers(teamMembers: unknown, path?: string,
|
|
|
121
126
|
export declare function validateTeamMember(member: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
122
127
|
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
123
128
|
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
129
|
+
/** The agent's standing ask for the next call: since when. */
|
|
130
|
+
export declare function validateNextCall(value: unknown, path?: string): ProtocolViolation[];
|
|
131
|
+
/** A call waiting in the agent's own queue: the caller as shown to this audience, the queue, the waits, and whether the agent may let it go. */
|
|
132
|
+
export declare function validateLinedUp(value: unknown, path?: string, levels?: readonly string[]): ProtocolViolation[];
|
|
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
|
+
export declare function validatePhoneState(value: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
124
135
|
/**
|
|
125
136
|
* What pressing Mute does on this host, stated on a softphone login and nowhere else: on a desk
|
|
126
137
|
* phone the microphone is the phone's, and off voice there is none. Neither the value nor its
|
|
@@ -154,7 +165,7 @@ export declare function validateTaskCommandRequest(request: unknown, task: unkno
|
|
|
154
165
|
*/
|
|
155
166
|
export declare function validateTaskCommand(command: unknown, task?: unknown, path?: string, taskContext?: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
|
|
156
167
|
/** The connection methods whose results `validateResult` knows. */
|
|
157
|
-
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeam" | "openAudio" | "setPreference" | "recordStep";
|
|
168
|
+
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "requestNextCall" | "cancelNextCall" | "releaseLinedUp" | "executeTeam" | "openAudio" | "setPreference" | "recordStep";
|
|
158
169
|
/**
|
|
159
170
|
* Validates what a connection method answered. A result is untrusted for the same reason a
|
|
160
171
|
* snapshot is: it comes from an adapter that may be compiled against another version, and Omni
|
package/dist/validation.js
CHANGED
|
@@ -78,7 +78,7 @@ const DIALLING_CAPABILITIES = ["connectBack", "conference"];
|
|
|
78
78
|
const SNAPSHOT_REASONS = membersOf({
|
|
79
79
|
reconnected: true, "provider-requested": true,
|
|
80
80
|
});
|
|
81
|
-
const SESSION_CAPABILITIES = membersOf({ breaks: true, lead: true, preferences: true });
|
|
81
|
+
const SESSION_CAPABILITIES = membersOf({ breaks: true, lead: true, preferences: true, nextCall: true });
|
|
82
82
|
const MEMBER_BREAKS = membersOf({
|
|
83
83
|
"awaiting-approval": true, granted: true, "starting-after-task": true,
|
|
84
84
|
});
|
|
@@ -87,6 +87,8 @@ const OFFERABLE_PHASES = membersOf({
|
|
|
87
87
|
});
|
|
88
88
|
const PREVIEW_DEADLINES = membersOf({ "provider-dials": true, "host-dials": true, waits: true });
|
|
89
89
|
const PHONES = membersOf({ softphone: true, deskPhone: true });
|
|
90
|
+
const PHONE_STATUSES = membersOf({ ready: true, unregistered: true, "do-not-disturb": true, "off-hook": true });
|
|
91
|
+
const PHONE_CHANNEL_STATES = membersOf({ ringing: true, active: true, held: true });
|
|
90
92
|
const LISTENING_MODES = membersOf({ listen: true, coach: true, "join-call": true });
|
|
91
93
|
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
92
94
|
const CANCELLED_BY = membersOf({ agent: true, provider: true, party: true });
|
|
@@ -369,6 +371,10 @@ function validatePhones(manifest, path, into) {
|
|
|
369
371
|
into.add("manifest.phone.unique", `${path}[${index}]`, `duplicate phone: ${describeValue(phone)}`);
|
|
370
372
|
}
|
|
371
373
|
});
|
|
374
|
+
// Seeing the phone itself is declared by presence, and only where there is a phone to see.
|
|
375
|
+
if (manifest.phoneStatus !== undefined) {
|
|
376
|
+
into.require(manifest.phoneStatus === true, "manifest.phoneStatus", `${path.replace(/phones$/, "phoneStatus")}`, "phoneStatus is declared by presence, as true; a platform that cannot see the phone omits it");
|
|
377
|
+
}
|
|
372
378
|
}
|
|
373
379
|
/**
|
|
374
380
|
* The host's choice of phone for a login, held to the manifest: one the platform listed, required
|
|
@@ -1742,6 +1748,14 @@ function validateTeamMemberInto(member, at, context, into) {
|
|
|
1742
1748
|
// The member's own history for the day: about the person, not any one call.
|
|
1743
1749
|
if (member.shift !== undefined)
|
|
1744
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 desk holds.
|
|
1752
|
+
if (member.phone !== undefined)
|
|
1753
|
+
validatePhoneStateInto(member.phone, `${at}.phone`, {}, into);
|
|
1754
|
+
// The member's own queue, as their own snapshot carries it.
|
|
1755
|
+
if (member.nextCall !== undefined)
|
|
1756
|
+
validateNextCallInto(member.nextCall, `${at}.nextCall`, into);
|
|
1757
|
+
if (member.linedUp !== undefined)
|
|
1758
|
+
validateLinedUpInto(member.linedUp, `${at}.linedUp`, into, context.levels);
|
|
1745
1759
|
// The member's standing ask for a lead, on one of the calls they hold.
|
|
1746
1760
|
if (member.request !== undefined) {
|
|
1747
1761
|
if (!isPlainObject(member.request)) {
|
|
@@ -1779,6 +1793,47 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1779
1793
|
// The agent's own day, as the provider counts it: the same numbers their lead sees.
|
|
1780
1794
|
if (snapshot.shift !== undefined)
|
|
1781
1795
|
validateShiftInto(snapshot.shift, `${path}.shift`, into, "shift");
|
|
1796
|
+
// The phone as the platform sees it: owed by a provider that declares it can, and by nobody else.
|
|
1797
|
+
const seesPhone = isPlainObject(manifest) && manifest.phoneStatus === true;
|
|
1798
|
+
// The agent's own queue: an ask needs an assignment at work to be next after, and one call at most waits.
|
|
1799
|
+
if (snapshot.nextCall !== undefined) {
|
|
1800
|
+
validateNextCallInto(snapshot.nextCall, `${path}.nextCall`, into);
|
|
1801
|
+
const atWork = Array.isArray(snapshot.tasks) && snapshot.tasks.some((task) => isPlainObject(task) && (task.phase === "in-progress" || task.phase === "paused" || task.phase === "completing"));
|
|
1802
|
+
into.require(atWork, "snapshot.nextCall.idle", `${path}.nextCall`, "an ask for the next call stands only while an assignment is at work to be next after: the provider clears it when that assignment ends");
|
|
1803
|
+
}
|
|
1804
|
+
if (snapshot.linedUp !== undefined)
|
|
1805
|
+
validateLinedUpInto(snapshot.linedUp, `${path}.linedUp`, into, levels);
|
|
1806
|
+
if (seesPhone && snapshot.phone === undefined) {
|
|
1807
|
+
into.add("snapshot.phone.required", `${path}.phone`, "the manifest declares phoneStatus, so every snapshot carries the phone as the platform sees it");
|
|
1808
|
+
}
|
|
1809
|
+
else if (!seesPhone && snapshot.phone !== undefined && isPlainObject(manifest)) {
|
|
1810
|
+
into.add("snapshot.phone.unexpected", `${path}.phone`, "the manifest declares no phoneStatus: a phone the platform never said it could see is one it invented");
|
|
1811
|
+
}
|
|
1812
|
+
if (snapshot.phone !== undefined) {
|
|
1813
|
+
validatePhoneStateInto(snapshot.phone, `${path}.phone`, context, into);
|
|
1814
|
+
// The two views of one call agree: a channel that is a task's stands where the task does.
|
|
1815
|
+
if (isPlainObject(snapshot.phone) && Array.isArray(snapshot.phone.channels) && Array.isArray(snapshot.tasks)) {
|
|
1816
|
+
const tasks = snapshot.tasks.filter(isPlainObject);
|
|
1817
|
+
snapshot.phone.channels.forEach((channel, index) => {
|
|
1818
|
+
if (!isPlainObject(channel) || !isAssignmentId(channel.assignmentId))
|
|
1819
|
+
return;
|
|
1820
|
+
const here = `${path}.phone.channels[${index}]`;
|
|
1821
|
+
const task = tasks.find(task => task.assignmentId === channel.assignmentId);
|
|
1822
|
+
if (!into.require(task !== undefined, "phone.channel.assignment", `${here}.assignmentId`, `${String(channel.assignmentId)} is not a task this login holds: a channel names the task its call is, or nothing`))
|
|
1823
|
+
return;
|
|
1824
|
+
const agrees = channel.state === "active" ? task.phase === "in-progress" && task.audio === "started"
|
|
1825
|
+
: channel.state === "held" ? task.phase === "paused"
|
|
1826
|
+
: task.phase === "pending" || (Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.role === "party" && who.stage === "ringing"));
|
|
1827
|
+
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`);
|
|
1828
|
+
});
|
|
1829
|
+
// And a task at work with audio has a channel: the phone carries every call the desk is on.
|
|
1830
|
+
tasks.forEach((task, index) => {
|
|
1831
|
+
if (task.audio === "started" && !snapshot.phone.channels.some(channel => isPlainObject(channel) && channel.assignmentId === task.assignmentId)) {
|
|
1832
|
+
into.add("phone.channel.missing", `${path}.tasks[${index}]`, `${String(task.assignmentId)} has its audio started and the phone carries no channel for it`);
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1782
1837
|
if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
|
|
1783
1838
|
&& context.loginId !== undefined) {
|
|
1784
1839
|
into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${describeValue(snapshot.loginId)} on a login whose session is ${context.loginId}`);
|
|
@@ -2065,6 +2120,18 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
2065
2120
|
event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into, levels));
|
|
2066
2121
|
}
|
|
2067
2122
|
break;
|
|
2123
|
+
case "next-call":
|
|
2124
|
+
if (event.nextCall !== undefined)
|
|
2125
|
+
validateNextCallInto(event.nextCall, `${at}.nextCall`, into);
|
|
2126
|
+
break;
|
|
2127
|
+
case "lined-up":
|
|
2128
|
+
if (event.linedUp !== undefined)
|
|
2129
|
+
validateLinedUpInto(event.linedUp, `${at}.linedUp`, into, levels);
|
|
2130
|
+
break;
|
|
2131
|
+
case "phone-updated":
|
|
2132
|
+
into.require(isPlainObject(manifest) && manifest.phoneStatus === true, "event.phone.capability", `${at}.phone`, "phone-updated comes from a provider whose manifest declares phoneStatus");
|
|
2133
|
+
validatePhoneStateInto(event.phone, `${at}.phone`, context, into);
|
|
2134
|
+
break;
|
|
2068
2135
|
case "shift-updated":
|
|
2069
2136
|
validateShiftInto(event.shift, `${at}.shift`, into, "shift");
|
|
2070
2137
|
break;
|
|
@@ -2146,6 +2213,92 @@ function requireClockInto(value, manifest, path, into) {
|
|
|
2146
2213
|
return;
|
|
2147
2214
|
into.add("manifest.timeCheck.required", path, "this publishes an instant the desk 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");
|
|
2148
2215
|
}
|
|
2216
|
+
/** The agent's standing ask for the next call: since when. */
|
|
2217
|
+
export function validateNextCall(value, path = "nextCall") {
|
|
2218
|
+
const into = new Collector();
|
|
2219
|
+
validateNextCallInto(value, path, into);
|
|
2220
|
+
return into.violations;
|
|
2221
|
+
}
|
|
2222
|
+
function validateNextCallInto(value, path, into) {
|
|
2223
|
+
if (!isPlainObject(value)) {
|
|
2224
|
+
into.add("nextCall.shape", path, "the ask must be an object");
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
into.timestamp(value.since, "nextCall.since", `${path}.since`);
|
|
2228
|
+
for (const key of Object.keys(value))
|
|
2229
|
+
into.require(key === "since", "nextCall.field", `${path}.${key}`, "unsupported field on the ask");
|
|
2230
|
+
}
|
|
2231
|
+
/** A call waiting in the agent's own queue: the caller as shown to this audience, the queue, the waits, and whether the agent may let it go. */
|
|
2232
|
+
export function validateLinedUp(value, path = "linedUp", levels) {
|
|
2233
|
+
const into = new Collector();
|
|
2234
|
+
validateLinedUpInto(value, path, into, levels);
|
|
2235
|
+
return into.violations;
|
|
2236
|
+
}
|
|
2237
|
+
function validateLinedUpInto(value, path, into, levels) {
|
|
2238
|
+
if (!isPlainObject(value)) {
|
|
2239
|
+
into.add("linedUp.shape", path, "a lined-up call must be an object");
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
if (value.party !== undefined)
|
|
2243
|
+
validateContactInto(value.party, `${path}.party`, into, levels);
|
|
2244
|
+
if (value.queue !== undefined)
|
|
2245
|
+
into.filled(value.queue, "linedUp.queue", `${path}.queue`, "the queue's label must not be empty when present");
|
|
2246
|
+
if (value.queuedSince !== undefined)
|
|
2247
|
+
into.timestamp(value.queuedSince, "linedUp.queuedSince", `${path}.queuedSince`);
|
|
2248
|
+
into.timestamp(value.since, "linedUp.since", `${path}.since`);
|
|
2249
|
+
if (value.release !== undefined)
|
|
2250
|
+
into.require(value.release === true, "linedUp.release", `${path}.release`, "letting the call go is granted by presence: send true or omit it");
|
|
2251
|
+
for (const key of Object.keys(value))
|
|
2252
|
+
into.require(["party", "queue", "queuedSince", "since", "release"].includes(key), "linedUp.field", `${path}.${key}`, "unsupported field on a lined-up call");
|
|
2253
|
+
}
|
|
2254
|
+
/** 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. */
|
|
2255
|
+
export function validatePhoneState(value, path = "phone", context = {}) {
|
|
2256
|
+
const into = new Collector();
|
|
2257
|
+
validatePhoneStateInto(value, path, context, into);
|
|
2258
|
+
return into.violations;
|
|
2259
|
+
}
|
|
2260
|
+
function validatePhoneStateInto(value, path, context, into) {
|
|
2261
|
+
if (!isPlainObject(value)) {
|
|
2262
|
+
into.add("phone.shape", path, "the phone state must be an object");
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
if (into.oneOf(value.phone, PHONES, "phone.phone", `${path}.phone`) && context.phone !== undefined) {
|
|
2266
|
+
into.require(value.phone === context.phone, "phone.phone.mismatch", `${path}.phone`, `the host chose a ${context.phone} for this login at connect and the platform reports a ${describeValue(value.phone)}: the phone state is the phone the agent hears this provider's calls on`);
|
|
2267
|
+
}
|
|
2268
|
+
into.oneOf(value.status, PHONE_STATUSES, "phone.status", `${path}.status`);
|
|
2269
|
+
if (value.muted !== undefined) {
|
|
2270
|
+
into.require(value.muted === true, "phone.muted", `${path}.muted`, "the phone's mute is stated by presence: send true or omit it");
|
|
2271
|
+
// The softphone's microphone is the host's, and its mute is the host's report; the platform observes a desk phone's button, or nothing.
|
|
2272
|
+
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");
|
|
2273
|
+
}
|
|
2274
|
+
if (value.since !== undefined)
|
|
2275
|
+
into.timestamp(value.since, "phone.since", `${path}.since`);
|
|
2276
|
+
// The phone's own calls: one active at most, any number held, and none on a phone that cannot carry one.
|
|
2277
|
+
if (!Array.isArray(value.channels)) {
|
|
2278
|
+
into.add("phone.channels.shape", `${path}.channels`, "channels is the phone's calls, an array: [] when idle");
|
|
2279
|
+
}
|
|
2280
|
+
else {
|
|
2281
|
+
let active = 0;
|
|
2282
|
+
value.channels.forEach((channel, index) => {
|
|
2283
|
+
const here = `${path}.channels[${index}]`;
|
|
2284
|
+
if (!isPlainObject(channel)) {
|
|
2285
|
+
into.add("phone.channel.shape", here, "each channel must be an object");
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
if (into.oneOf(channel.state, PHONE_CHANNEL_STATES, "phone.channel.state", `${here}.state`) && channel.state === "active")
|
|
2289
|
+
active += 1;
|
|
2290
|
+
into.timestamp(channel.since, "phone.channel.since", `${here}.since`);
|
|
2291
|
+
if (channel.assignmentId !== undefined)
|
|
2292
|
+
into.require(isAssignmentId(channel.assignmentId), "phone.channel.assignmentId", `${here}.assignmentId`, "assignmentId must name an assignment when present");
|
|
2293
|
+
for (const key of Object.keys(channel))
|
|
2294
|
+
into.require(["state", "since", "assignmentId"].includes(key), "phone.channel.field", `${here}.${key}`, "unsupported channel field");
|
|
2295
|
+
});
|
|
2296
|
+
into.require(active <= 1, "phone.channel.active.single", `${path}.channels`, `${active} channels are active: the phone has one active audio channel, and every other call on it is held`);
|
|
2297
|
+
into.require(!((value.status === "unregistered" || value.status === "off-hook") && value.channels.length > 0), "phone.status.channels", `${path}.channels`, `a phone that is ${String(value.status)} carries no call`);
|
|
2298
|
+
}
|
|
2299
|
+
for (const key of Object.keys(value))
|
|
2300
|
+
into.require(["phone", "status", "muted", "since", "channels"].includes(key), "phone.field", `${path}.${key}`, "unsupported phone state field");
|
|
2301
|
+
}
|
|
2149
2302
|
function validateShiftInto(value, path, into, rule) {
|
|
2150
2303
|
if (!isPlainObject(value)) {
|
|
2151
2304
|
into.add(`${rule}.shape`, path, "shift must be an object when present");
|
|
@@ -2696,6 +2849,9 @@ const RESULT_STATUSES = {
|
|
|
2696
2849
|
commitBreak: { success: "committed", failure: "failed" },
|
|
2697
2850
|
cancelBreak: { success: "cancelled", failure: "failed" },
|
|
2698
2851
|
endBreak: { success: "ended", failure: "failed" },
|
|
2852
|
+
requestNextCall: { success: "requested", failure: "failed" },
|
|
2853
|
+
cancelNextCall: { success: "cancelled", failure: "failed" },
|
|
2854
|
+
releaseLinedUp: { success: "released", failure: "failed" },
|
|
2699
2855
|
executeTeam: { success: "applied", failure: "failed" },
|
|
2700
2856
|
openAudio: { success: "opened", failure: "unavailable" },
|
|
2701
2857
|
setPreference: { success: "applied", failure: "failed" },
|
package/guide/agent.md
CHANGED
|
@@ -381,6 +381,8 @@ Migration from the earlier spellings:
|
|
|
381
381
|
| MemberTask.capabilities, capabilitySource, browsers | gone: the workspace never travels to a lead (`team.member.task.workspace`); the record and the completion terms do |
|
|
382
382
|
| a member republished on a hold, a mute, a take-over | on every publication of the member's task to the member |
|
|
383
383
|
| two clients on one login | the later wins; the first is ended with `recovery: "displaced"` |
|
|
384
|
+
| the phone unspoken | `PhoneState` -- the device, its mute, its channels -- on `Snapshot.phone`, `phone-updated` and `TeamMember.phone`, from a manifest declaring `phoneStatus` (`snapshot.phone.required`, `.unexpected`, `phone.*`, `phone.channel.*`, `event.phone.capability`) |
|
|
385
|
+
| the agent's readiness for the next call read into setCapacity | the agent's own queue: `capabilities.nextCall`, `requestNextCall`/`cancelNextCall`/`releaseLinedUp`, `Snapshot.nextCall` and `linedUp`, the `next-call` and `lined-up` events (`snapshot.nextCall.idle`, `nextCall.*`, `linedUp.*`) |
|
|
384
386
|
|
|
385
387
|
Update producers, consumers, saved task snapshots, and validation-rule assertions together.
|
|
386
388
|
History and report rule names use `history` and `historyReport`; assignment rules use
|