@xema/omni-protocol 0.1.107 → 0.1.108
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 +54 -0
- package/dist/index.js +2 -0
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +12 -2
- package/dist/validation.d.ts +8 -1
- package/dist/validation.js +94 -0
- package/guide/agent.md +1 -0
- package/guide/lead.md +1 -0
- package/guide/phone.md +65 -0
- package/guide.md +33 -1
- package/package.json +1 -1
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
|
|
@@ -1290,6 +1335,8 @@ export interface TeamMember {
|
|
|
1290
1335
|
shift?: Shift;
|
|
1291
1336
|
/** The member's standing ask for a lead on one of their calls; absent when they are not asking. */
|
|
1292
1337
|
request?: MemberRequest;
|
|
1338
|
+
/** The member's phone as the platform sees it, the same `PhoneState` the member's own snapshot carries; present where the provider declares `phoneStatus`. */
|
|
1339
|
+
phone?: PhoneState;
|
|
1293
1340
|
}
|
|
1294
1341
|
/** This member asking their lead to join a call: which assignment, the note they wrote, since when. */
|
|
1295
1342
|
export interface MemberRequest {
|
|
@@ -1589,6 +1636,8 @@ export interface Snapshot<C extends Channel = Channel> {
|
|
|
1589
1636
|
calendar?: ScheduledActivity[];
|
|
1590
1637
|
/** The agent's own day so far, as the provider counts it; replaced whole by `shift-updated`. Omitted where the provider cannot say. */
|
|
1591
1638
|
shift?: Shift;
|
|
1639
|
+
/** The phone as the platform sees it, from a provider that declares `phoneStatus`; replaced whole by `phone-updated`. */
|
|
1640
|
+
phone?: PhoneState;
|
|
1592
1641
|
team?: TeamMembers;
|
|
1593
1642
|
}
|
|
1594
1643
|
/**
|
|
@@ -1705,6 +1754,11 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1705
1754
|
| {
|
|
1706
1755
|
type: "shift-updated";
|
|
1707
1756
|
shift: Shift;
|
|
1757
|
+
}
|
|
1758
|
+
/** 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. */
|
|
1759
|
+
| {
|
|
1760
|
+
type: "phone-updated";
|
|
1761
|
+
phone: PhoneState;
|
|
1708
1762
|
};
|
|
1709
1763
|
/**
|
|
1710
1764
|
* The `Provider` prefix survives here for a mechanical reason rather than a naming one: `Event`
|
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. */
|
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", "shift", "phone", "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,9 @@ const STATE_SUBJECTS = [
|
|
|
28
28
|
"team.tasks",
|
|
29
29
|
"team.listening",
|
|
30
30
|
"team.shift",
|
|
31
|
+
"team.phone",
|
|
31
32
|
"shift",
|
|
33
|
+
"phone",
|
|
32
34
|
"contacts",
|
|
33
35
|
"calendar",
|
|
34
36
|
"team.policies",
|
|
@@ -39,7 +41,7 @@ const EVENT_TYPES = {
|
|
|
39
41
|
snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
|
|
40
42
|
"task-audio-started": true, "task-audio-ended": true, "task-ended": true, "dial-outcome": true, announcement: true, "queue-summary": true, diagnostic: true,
|
|
41
43
|
"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,
|
|
44
|
+
"contacts-updated": true, "calendar-updated": true, "shift-updated": true, "phone-updated": true,
|
|
43
45
|
};
|
|
44
46
|
const CONTRACT_SUBJECTS = [
|
|
45
47
|
...STATE_SUBJECTS,
|
|
@@ -101,6 +103,8 @@ function observeMember(member, seen) {
|
|
|
101
103
|
seen.add("team.shift");
|
|
102
104
|
if (member.request !== undefined)
|
|
103
105
|
seen.add("team.request");
|
|
106
|
+
if (member.phone !== undefined)
|
|
107
|
+
seen.add("team.phone");
|
|
104
108
|
}
|
|
105
109
|
function observePolicies(value, seen) {
|
|
106
110
|
if (isRecord(value) && Object.keys(value).length > 0)
|
|
@@ -129,6 +133,8 @@ function observeSnapshot(value, seen) {
|
|
|
129
133
|
seen.add("calendar");
|
|
130
134
|
if (value.shift !== undefined)
|
|
131
135
|
seen.add("shift");
|
|
136
|
+
if (value.phone !== undefined)
|
|
137
|
+
seen.add("phone");
|
|
132
138
|
}
|
|
133
139
|
function observeEvent(envelope, seen) {
|
|
134
140
|
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
@@ -168,6 +174,9 @@ function observeEvent(envelope, seen) {
|
|
|
168
174
|
case "shift-updated":
|
|
169
175
|
seen.add("shift");
|
|
170
176
|
break;
|
|
177
|
+
case "phone-updated":
|
|
178
|
+
seen.add("phone");
|
|
179
|
+
break;
|
|
171
180
|
default: break;
|
|
172
181
|
}
|
|
173
182
|
}
|
|
@@ -237,6 +246,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
237
246
|
self: current().identity.id,
|
|
238
247
|
capabilities: current().capabilities,
|
|
239
248
|
leadFeatures: leadFeaturesOn,
|
|
249
|
+
phone: context.phone,
|
|
240
250
|
loginId: context.loginId,
|
|
241
251
|
autoAcceptTasks: context.autoAcceptTasks,
|
|
242
252
|
locked: options.lockedValues,
|
|
@@ -1074,7 +1084,7 @@ const REACHABLE_PHASES = {
|
|
|
1074
1084
|
export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
|
|
1075
1085
|
"snapshot", "transport-status", "break-state", "task-offered", "task-updated", "task-ended",
|
|
1076
1086
|
"task-audio-started", "task-audio-ended", "team-updated", "team-member-updated", "team-member-removed", "team-policies-updated",
|
|
1077
|
-
"contacts-updated", "calendar-updated", "shift-updated",
|
|
1087
|
+
"contacts-updated", "calendar-updated", "shift-updated", "phone-updated",
|
|
1078
1088
|
]);
|
|
1079
1089
|
export class TaskStream {
|
|
1080
1090
|
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,8 @@ 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 phone as the platform sees it: the agent's device for this provider, whether it can take a call, its own mute where observed. */
|
|
130
|
+
export declare function validatePhoneState(value: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
124
131
|
/**
|
|
125
132
|
* What pressing Mute does on this host, stated on a softphone login and nowhere else: on a desk
|
|
126
133
|
* phone the microphone is the phone's, and off voice there is none. Neither the value nor its
|
package/dist/validation.js
CHANGED
|
@@ -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,9 @@ 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);
|
|
1745
1754
|
// The member's standing ask for a lead, on one of the calls they hold.
|
|
1746
1755
|
if (member.request !== undefined) {
|
|
1747
1756
|
if (!isPlainObject(member.request)) {
|
|
@@ -1779,6 +1788,39 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1779
1788
|
// The agent's own day, as the provider counts it: the same numbers their lead sees.
|
|
1780
1789
|
if (snapshot.shift !== undefined)
|
|
1781
1790
|
validateShiftInto(snapshot.shift, `${path}.shift`, into, "shift");
|
|
1791
|
+
// The phone as the platform sees it: owed by a provider that declares it can, and by nobody else.
|
|
1792
|
+
const seesPhone = isPlainObject(manifest) && manifest.phoneStatus === true;
|
|
1793
|
+
if (seesPhone && snapshot.phone === undefined) {
|
|
1794
|
+
into.add("snapshot.phone.required", `${path}.phone`, "the manifest declares phoneStatus, so every snapshot carries the phone as the platform sees it");
|
|
1795
|
+
}
|
|
1796
|
+
else if (!seesPhone && snapshot.phone !== undefined && isPlainObject(manifest)) {
|
|
1797
|
+
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");
|
|
1798
|
+
}
|
|
1799
|
+
if (snapshot.phone !== undefined) {
|
|
1800
|
+
validatePhoneStateInto(snapshot.phone, `${path}.phone`, context, into);
|
|
1801
|
+
// The two views of one call agree: a channel that is a task's stands where the task does.
|
|
1802
|
+
if (isPlainObject(snapshot.phone) && Array.isArray(snapshot.phone.channels) && Array.isArray(snapshot.tasks)) {
|
|
1803
|
+
const tasks = snapshot.tasks.filter(isPlainObject);
|
|
1804
|
+
snapshot.phone.channels.forEach((channel, index) => {
|
|
1805
|
+
if (!isPlainObject(channel) || !isAssignmentId(channel.assignmentId))
|
|
1806
|
+
return;
|
|
1807
|
+
const here = `${path}.phone.channels[${index}]`;
|
|
1808
|
+
const task = tasks.find(task => task.assignmentId === channel.assignmentId);
|
|
1809
|
+
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`))
|
|
1810
|
+
return;
|
|
1811
|
+
const agrees = channel.state === "active" ? task.phase === "in-progress" && task.audio === "started"
|
|
1812
|
+
: channel.state === "held" ? task.phase === "paused"
|
|
1813
|
+
: task.phase === "pending" || (Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.role === "party" && who.stage === "ringing"));
|
|
1814
|
+
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`);
|
|
1815
|
+
});
|
|
1816
|
+
// And a task at work with audio has a channel: the phone carries every call the desk is on.
|
|
1817
|
+
tasks.forEach((task, index) => {
|
|
1818
|
+
if (task.audio === "started" && !snapshot.phone.channels.some(channel => isPlainObject(channel) && channel.assignmentId === task.assignmentId)) {
|
|
1819
|
+
into.add("phone.channel.missing", `${path}.tasks[${index}]`, `${String(task.assignmentId)} has its audio started and the phone carries no channel for it`);
|
|
1820
|
+
}
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1782
1824
|
if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
|
|
1783
1825
|
&& context.loginId !== undefined) {
|
|
1784
1826
|
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 +2107,10 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
2065
2107
|
event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into, levels));
|
|
2066
2108
|
}
|
|
2067
2109
|
break;
|
|
2110
|
+
case "phone-updated":
|
|
2111
|
+
into.require(isPlainObject(manifest) && manifest.phoneStatus === true, "event.phone.capability", `${at}.phone`, "phone-updated comes from a provider whose manifest declares phoneStatus");
|
|
2112
|
+
validatePhoneStateInto(event.phone, `${at}.phone`, context, into);
|
|
2113
|
+
break;
|
|
2068
2114
|
case "shift-updated":
|
|
2069
2115
|
validateShiftInto(event.shift, `${at}.shift`, into, "shift");
|
|
2070
2116
|
break;
|
|
@@ -2146,6 +2192,54 @@ function requireClockInto(value, manifest, path, into) {
|
|
|
2146
2192
|
return;
|
|
2147
2193
|
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
2194
|
}
|
|
2195
|
+
/** 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. */
|
|
2196
|
+
export function validatePhoneState(value, path = "phone", context = {}) {
|
|
2197
|
+
const into = new Collector();
|
|
2198
|
+
validatePhoneStateInto(value, path, context, into);
|
|
2199
|
+
return into.violations;
|
|
2200
|
+
}
|
|
2201
|
+
function validatePhoneStateInto(value, path, context, into) {
|
|
2202
|
+
if (!isPlainObject(value)) {
|
|
2203
|
+
into.add("phone.shape", path, "the phone state must be an object");
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
if (into.oneOf(value.phone, PHONES, "phone.phone", `${path}.phone`) && context.phone !== undefined) {
|
|
2207
|
+
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`);
|
|
2208
|
+
}
|
|
2209
|
+
into.oneOf(value.status, PHONE_STATUSES, "phone.status", `${path}.status`);
|
|
2210
|
+
if (value.muted !== undefined) {
|
|
2211
|
+
into.require(value.muted === true, "phone.muted", `${path}.muted`, "the phone's mute is stated by presence: send true or omit it");
|
|
2212
|
+
// 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.
|
|
2213
|
+
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");
|
|
2214
|
+
}
|
|
2215
|
+
if (value.since !== undefined)
|
|
2216
|
+
into.timestamp(value.since, "phone.since", `${path}.since`);
|
|
2217
|
+
// The phone's own calls: one active at most, any number held, and none on a phone that cannot carry one.
|
|
2218
|
+
if (!Array.isArray(value.channels)) {
|
|
2219
|
+
into.add("phone.channels.shape", `${path}.channels`, "channels is the phone's calls, an array: [] when idle");
|
|
2220
|
+
}
|
|
2221
|
+
else {
|
|
2222
|
+
let active = 0;
|
|
2223
|
+
value.channels.forEach((channel, index) => {
|
|
2224
|
+
const here = `${path}.channels[${index}]`;
|
|
2225
|
+
if (!isPlainObject(channel)) {
|
|
2226
|
+
into.add("phone.channel.shape", here, "each channel must be an object");
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
if (into.oneOf(channel.state, PHONE_CHANNEL_STATES, "phone.channel.state", `${here}.state`) && channel.state === "active")
|
|
2230
|
+
active += 1;
|
|
2231
|
+
into.timestamp(channel.since, "phone.channel.since", `${here}.since`);
|
|
2232
|
+
if (channel.assignmentId !== undefined)
|
|
2233
|
+
into.require(isAssignmentId(channel.assignmentId), "phone.channel.assignmentId", `${here}.assignmentId`, "assignmentId must name an assignment when present");
|
|
2234
|
+
for (const key of Object.keys(channel))
|
|
2235
|
+
into.require(["state", "since", "assignmentId"].includes(key), "phone.channel.field", `${here}.${key}`, "unsupported channel field");
|
|
2236
|
+
});
|
|
2237
|
+
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`);
|
|
2238
|
+
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`);
|
|
2239
|
+
}
|
|
2240
|
+
for (const key of Object.keys(value))
|
|
2241
|
+
into.require(["phone", "status", "muted", "since", "channels"].includes(key), "phone.field", `${path}.${key}`, "unsupported phone state field");
|
|
2242
|
+
}
|
|
2149
2243
|
function validateShiftInto(value, path, into, rule) {
|
|
2150
2244
|
if (!isPlainObject(value)) {
|
|
2151
2245
|
into.add(`${rule}.shape`, path, "shift must be an object when present");
|
package/guide/agent.md
CHANGED
|
@@ -381,6 +381,7 @@ 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`) |
|
|
384
385
|
|
|
385
386
|
Update producers, consumers, saved task snapshots, and validation-rule assertions together.
|
|
386
387
|
History and report rule names use `history` and `historyReport`; assignment rules use
|
package/guide/lead.md
CHANGED
|
@@ -32,6 +32,7 @@ computes nothing. See **`team-updated`** in `guide.md` and its companions.
|
|
|
32
32
|
| `tasks` | The member's open tasks as `MemberTask`s: the same task the member's desk holds, less the workspace. The assignment, title, task type, phase, party, room, audio and completion terms -- `completionMode`, `wrapAllowance`, `wrapEndsInSeconds` -- are what the lead reads, and the task's record -- `history`, steps and totals -- travels whole, exactly as the member's desk holds it, so the lead's screen and the member's show the same record. The workspace -- `capabilities`, `capabilitySource`, `browsers` and their URLs -- is the member's desk's alone and never travels (`team.member.task.workspace`). The party's number and email are shown to leads or locked as the provider decides for leads, apart from what it decides for the member -- see **What the queue locks is locked on the whole task**. `[]` when the member holds none; omitted only where the provider cannot see them. |
|
|
33
33
|
| `listening` | Every lead on this member's call — listening, coaching or joined — published to every lead alike: each entry names the lead by `leadId`, which of the member's calls by `assignmentId`, the `mode` they are heard in, and since when. The entry naming the signed-in lead is what their own audio opens on. Absent while nobody is on the call, never `[]` (`team.member.listening.*`); a lead is on one member's call at a time (`team.listening.single`), and a lead who is heard is also in the task's `onCall` (`team.member.listening.heard`). |
|
|
34
34
|
| `request` | Present while the member is asking a lead to join one of their calls: which assignment, the note they wrote, since when. The call named is one their `tasks` carry (`team.member.request.assignment`). Absent when they are not asking. See **Lead assist**. |
|
|
35
|
+
| `phone` | The member's phone as the platform sees it: the same `PhoneState` the member's own snapshot carries, so a lead sees a desk phone that is down, or a call on it that is no task, as the member does. Present where the provider declares `phoneStatus`. See **The phone's own view** in `guide/phone.md`. |
|
|
35
36
|
| `shift` | The member's own history for the day, about the person rather than any one call: `signedInAt`, `signedOutAt` once it has happened, today's totals as the provider counts them — `talkSeconds`, `holdSeconds`, `breakSeconds`, `tasksHandled`, each present only when the provider knows it — and the day's sign-in, sign-out and break `events`, oldest first. The same `Shift` the member's own snapshot carries, the same numbers from the same count. Omitted where the provider cannot say. |
|
|
36
37
|
|
|
37
38
|
Each availability value means one thing:
|
package/guide/phone.md
CHANGED
|
@@ -272,3 +272,68 @@ call, the same line in the ready state, so an agent does not learn of it from a
|
|
|
272
272
|
whether work is held back is the adapter's decision from the report, as it is for every station
|
|
273
273
|
fact. Where a provider's own platform holds a mute of its own -- a bridge that silences a leg --
|
|
274
274
|
that is a fact about the switch, reported as the provider sees fit, and never the agent application's Mute.
|
|
275
|
+
|
|
276
|
+
### The phone's own view
|
|
277
|
+
|
|
278
|
+
There are two views of a call, and the contract carries both. The task side says which calls are
|
|
279
|
+
the agent's work and where each stands: the phase, the audio, the room. The phone side is the
|
|
280
|
+
device: whether it can take a call at all, its own mute, and the calls on it, which exist whether
|
|
281
|
+
or not a task is behind them -- an internal call on the extension, a lead's listening leg, a call
|
|
282
|
+
the platform put on the phone that the desk never saw as a task. The host's report covers neither:
|
|
283
|
+
it says what the station has, a microphone and a speaker, and cannot say whether the phone is
|
|
284
|
+
registered. The platform sees the phone, so this is the provider's to publish, as `Snapshot.phone`
|
|
285
|
+
and `phone-updated`, from a manifest that declares `phoneStatus` (`snapshot.phone.required`,
|
|
286
|
+
`snapshot.phone.unexpected`, `event.phone.capability`).
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
// a conference in progress on alloc-42 -- one channel, since the bridge mixes -- with alloc-43 parked
|
|
290
|
+
const busy: PhoneState = { phone: "deskPhone", status: "ready", muted: true, channels: [
|
|
291
|
+
{ state: "active", since: "2026-09-12T10:30:04Z", assignmentId: "alloc-42" },
|
|
292
|
+
{ state: "held", since: "2026-09-12T10:35:20Z", assignmentId: "alloc-43" },
|
|
293
|
+
] };
|
|
294
|
+
// an internal call on the extension the desk never saw as a task
|
|
295
|
+
const internal: PhoneState = { phone: "deskPhone", status: "ready", channels: [{ state: "active", since: "2026-09-12T11:02:00Z" }] };
|
|
296
|
+
// a desk phone the platform lost
|
|
297
|
+
const down: PhoneState = { phone: "deskPhone", status: "unregistered", since: "2026-09-12T10:41:00Z", channels: [] };
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**The device.** `status` is the phone itself: `ready` is registered and usable, idle or busy, and
|
|
301
|
+
the other three are the states in which nothing can land, each wanting a different fix.
|
|
302
|
+
|
|
303
|
+
| `status` | Meaning |
|
|
304
|
+
| --- | --- |
|
|
305
|
+
| `ready` | Registered and usable, idle or busy. |
|
|
306
|
+
| `unregistered` | The platform has lost the phone: no registration from the extension, no route to the handset. Nothing can land, and no channel is on it (`phone.status.channels`). |
|
|
307
|
+
| `do-not-disturb` | The agent pressed it on the device. Nothing lands until they clear it; it is not a break, and the provider assigns or not as its platform does. |
|
|
308
|
+
| `off-hook` | The handset is up with no call on it, and no channel is on it. |
|
|
309
|
+
|
|
310
|
+
`phone` is the agent's device for this provider: the one the host chose for the login at
|
|
311
|
+
connect, from the manifest's phones, as the way the agent hears this provider's calls. A state
|
|
312
|
+
naming the other is the wrong phone (`phone.phone.mismatch`). The phone is the agent's; the login
|
|
313
|
+
is the agent's session with the provider, and what belongs to the login is that choice. `since` is when the status began, counted from the
|
|
314
|
+
provider's clock like every duration off the active call, and omitted rather than invented.
|
|
315
|
+
|
|
316
|
+
**The channels.** The phone has one active audio channel and any number held
|
|
317
|
+
(`phone.channel.active.single`), and a bridge is one channel: a conference is one entry, and its
|
|
318
|
+
members are on the task's `onCall`, never repeated here. Each channel is `ringing`, `active` or
|
|
319
|
+
`held`, since when, and names the task its call is where it is one. **Where a channel is a task's,
|
|
320
|
+
the two views agree** (`phone.channel.task`): `active` with the task at work and its audio
|
|
321
|
+
started, `held` with the task `paused`, `ringing` with the task `pending` or its party ringing on
|
|
322
|
+
`onCall`; and a task with its audio started has a channel (`phone.channel.missing`), since the
|
|
323
|
+
phone carries every call the desk is on. A channel naming no task is the phone's alone, and the
|
|
324
|
+
desk shows it as a call it cannot act on.
|
|
325
|
+
|
|
326
|
+
**The mute is the phone's, and it silences whatever is active.** It is one flag on the phone, not
|
|
327
|
+
a property of a channel: whichever channel is active is silent while it is set, and the held
|
|
328
|
+
channels are unaffected. Who owns it follows the phone. On a desk phone the mute button is the
|
|
329
|
+
handset's, and the platform observes it or does not: `muted: true` where it does, absent where it
|
|
330
|
+
does not, never invented. On a softphone the microphone is the host's, its mute is the host's
|
|
331
|
+
report as **The station is the agent application's** sets out, and nothing the provider publishes
|
|
332
|
+
moves it: `muted` on a softphone's state is the platform echoing the host, and is refused
|
|
333
|
+
(`phone.muted.softphone`). The `muted` step in the record stays what it is, the account of a leg,
|
|
334
|
+
never the live flag.
|
|
335
|
+
|
|
336
|
+
**The lead sees the member's phone as the member does.** The member on the team member list
|
|
337
|
+
carries the same `PhoneState`, republished with the member on every change, so a desk phone that
|
|
338
|
+
is down, or a call on it that is no task, shows the same on both screens, and neither derives it
|
|
339
|
+
from anything else.
|
package/guide.md
CHANGED
|
@@ -199,6 +199,24 @@ type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected
|
|
|
199
199
|
|
|
200
200
|
type Phone = "softphone" | "deskPhone";
|
|
201
201
|
|
|
202
|
+
type PhoneStatus = "ready" | "unregistered" | "do-not-disturb" | "off-hook";
|
|
203
|
+
|
|
204
|
+
type PhoneChannelState = "ringing" | "active" | "held";
|
|
205
|
+
|
|
206
|
+
type PhoneChannel = {
|
|
207
|
+
state: PhoneChannelState;
|
|
208
|
+
since: IsoTimestamp;
|
|
209
|
+
assignmentId?: AssignmentId;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
type PhoneState = {
|
|
213
|
+
phone: Phone;
|
|
214
|
+
status: PhoneStatus;
|
|
215
|
+
muted?: true;
|
|
216
|
+
since?: IsoTimestamp;
|
|
217
|
+
channels: PhoneChannel[];
|
|
218
|
+
};
|
|
219
|
+
|
|
202
220
|
type IdleCapabilities<C extends Channel = Channel> = {
|
|
203
221
|
personalBrowser?: PersonalBrowserCapability;
|
|
204
222
|
calendar?: true;
|
|
@@ -219,6 +237,7 @@ type Manifest<C extends Channel = Channel> = {
|
|
|
219
237
|
orgLevels?: LevelDeclaration[];
|
|
220
238
|
dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
|
|
221
239
|
phones?: C extends "voice" ? Phone[] : never;
|
|
240
|
+
phoneStatus?: C extends "voice" ? true : never;
|
|
222
241
|
runningStepReports?: true;
|
|
223
242
|
settleMs: number;
|
|
224
243
|
};
|
|
@@ -498,6 +517,7 @@ type Snapshot = {
|
|
|
498
517
|
contacts?: Contact[];
|
|
499
518
|
calendar?: ScheduledActivity[];
|
|
500
519
|
shift?: Shift;
|
|
520
|
+
phone?: PhoneState;
|
|
501
521
|
team?: TeamMembers;
|
|
502
522
|
};
|
|
503
523
|
|
|
@@ -1006,6 +1026,7 @@ type TeamMember = {
|
|
|
1006
1026
|
listening?: MemberListening[];
|
|
1007
1027
|
shift?: Shift;
|
|
1008
1028
|
request?: MemberRequest;
|
|
1029
|
+
phone?: PhoneState;
|
|
1009
1030
|
};
|
|
1010
1031
|
|
|
1011
1032
|
type MemberRequest = {
|
|
@@ -1162,7 +1183,8 @@ type ProviderEvent =
|
|
|
1162
1183
|
| { type: "team-policies-updated"; policies: TeamPolicies }
|
|
1163
1184
|
| { type: "contacts-updated"; contacts: Contact[] }
|
|
1164
1185
|
| { type: "calendar-updated"; calendar: ScheduledActivity[] }
|
|
1165
|
-
| { type: "shift-updated"; shift: Shift }
|
|
1186
|
+
| { type: "shift-updated"; shift: Shift }
|
|
1187
|
+
| { type: "phone-updated"; phone: PhoneState };
|
|
1166
1188
|
|
|
1167
1189
|
type ProviderEventEnvelope = {
|
|
1168
1190
|
id: string;
|
|
@@ -1654,6 +1676,7 @@ compile time.
|
|
|
1654
1676
|
| `taskTypePresentation` | Optional static adapter-defined presentation keyed by exact `taskType`. It names the item and its optional agent-facing reference. |
|
|
1655
1677
|
| `orgLevels` | The organisation's whole ladder as the provider calls it, each level with the label a desk shows for "who decided". Stated outright, `person` included: what it leaves out does not exist. Omitted for the typical four, `DEFAULT_LEVELS`. See **Who decides what an agent may do**. |
|
|
1656
1678
|
| `phones` | Voice only, and required there: the phones this platform can put an agent on, `softphone` (the call's audio lands in the agent application) and/or `deskPhone` (a handset the platform rings; the agent application shows the call and opens nothing). The agent application picks one per login. See **How the agent hears the call** in `guide/phone.md`. |
|
|
1679
|
+
| `phoneStatus` | Voice only, declared by presence as `true`: the platform sees the phone itself -- its registration, its do-not-disturb, its hook, its calls -- and publishes it as `Snapshot.phone` and `phone-updated`. A platform that cannot see the phone omits it, and then publishes no phone (`manifest.phoneStatus`). See **The phone's own view** in `guide/phone.md`. |
|
|
1657
1680
|
| `dialOutcomes` | Voice only. How a dial can end on this platform, as it distinguishes them: `answered` and at least one way of not reaching the destination. Required of a provider that dials at all — an idle dialpad, or tasks that conference or call back — and a `dial-outcome` carries only a declared member. See **Every dial has an outcome** in `guide/voice.md`. |
|
|
1658
1681
|
| `timeCheck` | `true`: implements `checkTime` and states `providerTime` on every snapshot. Required of a provider that publishes an instant the desk renders as a running duration -- a member's `since`, a `listening[].since`, an `onCall.since`, a shift's `signedInAt` -- so every screen counts from the provider's clock (`manifest.timeCheck.required`); agent application polling is independently opt-in. See **Every screen counts from the provider's clock**. |
|
|
1659
1682
|
| `timestampAuthority` | Optional `"provider"`: provider timestamps are final; agent application instants are advisory. Omission makes no trust promise. |
|
|
@@ -2183,6 +2206,7 @@ a capability it agrees with the login: a lead's snapshot carries `team`, nobody
|
|
|
2183
2206
|
| `taskCount` | The provider's own count of those tasks, stated rather than inferred, and it must equal `tasks.length`. A snapshot with no work says `taskCount: 0` in so many words — a blank or unanswered state lacks the count and cannot pass as a confirmed empty. |
|
|
2184
2207
|
| `contacts` | Required complete contact contribution when the manifest declares `contacts`; `[]` clears it. Omitted only when it does not. |
|
|
2185
2208
|
| `calendar` | Required complete calendar contribution when the manifest declares `calendar`; `[]` clears it. Omitted only when it does not. |
|
|
2209
|
+
| `phone` | The phone as the platform sees it -- the device, whether it can take a call, its own mute where observed -- from a provider whose manifest declares `phoneStatus`; required there (`snapshot.phone.required`) and forbidden otherwise (`snapshot.phone.unexpected`). Replaced whole by `phone-updated`. See **The phone's own view** in `guide/phone.md`. |
|
|
2186
2210
|
| `shift` | The agent's own day so far, as the provider counts it -- the same `Shift` their lead sees on the team member list, the same numbers from the same count. Replaced whole by `shift-updated`. Omitted only where the provider cannot say. See **The agent's day is on the wire**. |
|
|
2187
2211
|
| `team` | Required `TeamMembers` when the login declares `capabilities.lead` and the lead has the team feature on, `members: []` when nobody is in it. Forbidden otherwise — the login is the permission, and a lead who turned the feature off gets nothing of the team. |
|
|
2188
2212
|
|
|
@@ -2743,6 +2767,14 @@ the `contacts` idle capability.
|
|
|
2743
2767
|
Replaces this provider's complete scheduled-activity contribution. It is emitted only when the manifest
|
|
2744
2768
|
declares the `calendar` idle capability.
|
|
2745
2769
|
|
|
2770
|
+
### `phone-updated`
|
|
2771
|
+
|
|
2772
|
+
The phone as the platform now sees it, whole, replacing `Snapshot.phone`: a registration lost or
|
|
2773
|
+
back, do-not-disturb pressed or cleared, the handset lifted with no call or replaced, the desk
|
|
2774
|
+
phone's own mute. Emitted only by a provider whose manifest declares `phoneStatus`
|
|
2775
|
+
(`event.phone.capability`). The lead's member carries the same `PhoneState`, republished with the
|
|
2776
|
+
member. See **The phone's own view** in `guide/phone.md`.
|
|
2777
|
+
|
|
2746
2778
|
### `shift-updated`
|
|
2747
2779
|
|
|
2748
2780
|
The agent's own day, whole, replacing `Snapshot.shift`: emitted whenever a total moves -- a task
|