@hainiva/agent-sdk 0.0.0-stage → 0.2.0
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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/SECURITY.md +33 -0
- package/dist/cjs/agent.cjs +316 -0
- package/dist/cjs/agent.d.cts +235 -0
- package/dist/cjs/calls.cjs +277 -0
- package/dist/cjs/calls.d.cts +232 -0
- package/dist/cjs/contract.cjs +14 -0
- package/dist/cjs/contract.d.cts +215 -0
- package/dist/cjs/embed.cjs +11 -0
- package/dist/cjs/embed.d.cts +13 -0
- package/dist/cjs/emitter.cjs +50 -0
- package/dist/cjs/emitter.d.cts +22 -0
- package/dist/cjs/errors.cjs +28 -0
- package/dist/cjs/errors.d.cts +15 -0
- package/dist/cjs/events.cjs +97 -0
- package/dist/cjs/events.d.cts +97 -0
- package/dist/cjs/index.cjs +72 -0
- package/dist/cjs/index.d.cts +28 -0
- package/dist/cjs/livekit.cjs +56 -0
- package/dist/cjs/livekit.d.cts +35 -0
- package/dist/cjs/log.cjs +15 -0
- package/dist/cjs/log.d.cts +5 -0
- package/dist/cjs/react-native.cjs +27 -0
- package/dist/cjs/react-native.d.cts +38 -0
- package/dist/cjs/redact.cjs +71 -0
- package/dist/cjs/redact.d.cts +21 -0
- package/dist/cjs/rest.cjs +280 -0
- package/dist/cjs/rest.d.cts +166 -0
- package/dist/cjs/session.cjs +159 -0
- package/dist/cjs/session.d.cts +101 -0
- package/dist/cjs/singleFlight.cjs +49 -0
- package/dist/cjs/singleFlight.d.cts +32 -0
- package/dist/cjs/url.cjs +39 -0
- package/dist/cjs/url.d.cts +13 -0
- package/dist/cjs/web.cjs +28 -0
- package/dist/cjs/web.d.cts +23 -0
- package/dist/cjs/ws.cjs +246 -0
- package/dist/cjs/ws.d.cts +134 -0
- package/dist/esm/agent.d.ts +236 -0
- package/dist/esm/agent.d.ts.map +1 -0
- package/dist/esm/agent.js +313 -0
- package/dist/esm/agent.js.map +1 -0
- package/dist/esm/calls.d.ts +233 -0
- package/dist/esm/calls.d.ts.map +1 -0
- package/dist/esm/calls.js +271 -0
- package/dist/esm/calls.js.map +1 -0
- package/dist/esm/contract.d.ts +216 -0
- package/dist/esm/contract.d.ts.map +1 -0
- package/dist/esm/contract.js +12 -0
- package/dist/esm/contract.js.map +1 -0
- package/dist/esm/embed.d.ts +14 -0
- package/dist/esm/embed.d.ts.map +1 -0
- package/dist/esm/embed.js +9 -0
- package/dist/esm/embed.js.map +1 -0
- package/dist/esm/emitter.d.ts +23 -0
- package/dist/esm/emitter.d.ts.map +1 -0
- package/dist/esm/emitter.js +47 -0
- package/dist/esm/emitter.js.map +1 -0
- package/dist/esm/errors.d.ts +16 -0
- package/dist/esm/errors.d.ts.map +1 -0
- package/dist/esm/errors.js +25 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/events.d.ts +98 -0
- package/dist/esm/events.d.ts.map +1 -0
- package/dist/esm/events.js +90 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +29 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +29 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/livekit.d.ts +36 -0
- package/dist/esm/livekit.d.ts.map +1 -0
- package/dist/esm/livekit.js +54 -0
- package/dist/esm/livekit.js.map +1 -0
- package/dist/esm/log.d.ts +6 -0
- package/dist/esm/log.d.ts.map +1 -0
- package/dist/esm/log.js +13 -0
- package/dist/esm/log.js.map +1 -0
- package/dist/esm/react-native.d.ts +39 -0
- package/dist/esm/react-native.d.ts.map +1 -0
- package/dist/esm/react-native.js +23 -0
- package/dist/esm/react-native.js.map +1 -0
- package/dist/esm/redact.d.ts +22 -0
- package/dist/esm/redact.d.ts.map +1 -0
- package/dist/esm/redact.js +66 -0
- package/dist/esm/redact.js.map +1 -0
- package/dist/esm/rest.d.ts +167 -0
- package/dist/esm/rest.d.ts.map +1 -0
- package/dist/esm/rest.js +275 -0
- package/dist/esm/rest.js.map +1 -0
- package/dist/esm/session.d.ts +102 -0
- package/dist/esm/session.d.ts.map +1 -0
- package/dist/esm/session.js +155 -0
- package/dist/esm/session.js.map +1 -0
- package/dist/esm/singleFlight.d.ts +33 -0
- package/dist/esm/singleFlight.d.ts.map +1 -0
- package/dist/esm/singleFlight.js +46 -0
- package/dist/esm/singleFlight.js.map +1 -0
- package/dist/esm/url.d.ts +14 -0
- package/dist/esm/url.d.ts.map +1 -0
- package/dist/esm/url.js +37 -0
- package/dist/esm/url.js.map +1 -0
- package/dist/esm/web.d.ts +24 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +23 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/esm/ws.d.ts +135 -0
- package/dist/esm/ws.d.ts.map +1 -0
- package/dist/esm/ws.js +242 -0
- package/dist/esm/ws.js.map +1 -0
- package/package.json +80 -4
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HainivaAgent — one object for a staff member's app: sign-in with your
|
|
3
|
+
* backend's assertion, the REST API, the live event socket and the agent's
|
|
4
|
+
* calls, wired together.
|
|
5
|
+
*
|
|
6
|
+
* const agent = new HainivaAgent({ baseUrl, tenantId, getAssertion, media: joinCallRoom });
|
|
7
|
+
* agent.on("call.ringing", (r) => showRing(r));
|
|
8
|
+
* await agent.connect();
|
|
9
|
+
* await agent.calls.accept(callId); // answers, then joins the room with `media`
|
|
10
|
+
*
|
|
11
|
+
* Transport-agnostic: LiveKit, the DOM and React Native come in through options
|
|
12
|
+
* (`media`, `WebSocket`, `store`), so the core runs in Node too.
|
|
13
|
+
*/
|
|
14
|
+
import { CallStore, type CallEffect, type CallsState, type TransferTarget } from "./calls.cjs";
|
|
15
|
+
import type { AgentAcceptDto, AgentCallEndedData, AgentCallRingingData, AgentConversationDto, AgentDispositionDto, AgentMeDto, AgentMessageDto, AgentPage, AgentPushSubscriptionDto, AgentPushSubscriptionInput, AgentRingDto, AgentSessionDto, SupervisorAgentDto, SupervisorLiveCallDto, SupervisorSuperviseDto } from "./contract.cjs";
|
|
16
|
+
import { TypedEmitter } from "./emitter.cjs";
|
|
17
|
+
import { HainivaAgentError } from "./errors.cjs";
|
|
18
|
+
import { type AgentEventFrame } from "./events.cjs";
|
|
19
|
+
import { type Logger } from "./log.cjs";
|
|
20
|
+
import { AgentRestClient, type AgentStatus, type ConversationListQuery, type MediaCredentials, type RequestOptions } from "./rest.cjs";
|
|
21
|
+
import { SessionManager, type AgentSession, type GetAssertion, type SessionStore } from "./session.cjs";
|
|
22
|
+
import { type ConnectionState, type WebSocketCtor } from "./ws.cjs";
|
|
23
|
+
/** A joined media room — what `joinCallRoom` from `/web` or `/react-native` returns. */
|
|
24
|
+
export interface MediaSession {
|
|
25
|
+
leave(): Promise<void>;
|
|
26
|
+
setMuted(muted: boolean): Promise<void>;
|
|
27
|
+
onDisconnected(fn: () => void): () => void;
|
|
28
|
+
}
|
|
29
|
+
/** Join a room: pass `joinCallRoom` from `@hainiva/agent-sdk/web` or `/react-native`. */
|
|
30
|
+
export type JoinMedia = (join: {
|
|
31
|
+
url: string;
|
|
32
|
+
token: string;
|
|
33
|
+
}) => Promise<MediaSession>;
|
|
34
|
+
export interface HainivaAgentOptions {
|
|
35
|
+
/** https://api.hainiva.com (or your organisation's API host). */
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
tenantId: string;
|
|
38
|
+
/** Ask YOUR backend for a fresh assertion — every time; never cache one. */
|
|
39
|
+
getAssertion: GetAssertion;
|
|
40
|
+
/** Where the session is kept. Default in memory (a restart signs in again). */
|
|
41
|
+
store?: SessionStore;
|
|
42
|
+
/** Joins call media. Without it, `calls.accept` answers and returns the credentials only. */
|
|
43
|
+
media?: JoinMedia;
|
|
44
|
+
/** WebSocket implementation (browsers and React Native have one; Node 22 too). */
|
|
45
|
+
WebSocket?: WebSocketCtor;
|
|
46
|
+
fetch?: typeof fetch;
|
|
47
|
+
device?: {
|
|
48
|
+
label?: string;
|
|
49
|
+
platform?: "ios" | "android" | "web" | "other";
|
|
50
|
+
};
|
|
51
|
+
/** Per REST request. Default 15 s. */
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
/** How long a ring with no server-known end may ring here. Default 60 s. */
|
|
54
|
+
ringTimeoutMs?: number;
|
|
55
|
+
/** A redacted log of what the SDK does. Nothing is logged without it. */
|
|
56
|
+
log?: Logger;
|
|
57
|
+
/** Local development only (http://localhost, ws://localhost). */
|
|
58
|
+
allowInsecureLocalhost?: boolean;
|
|
59
|
+
}
|
|
60
|
+
export interface HainivaAgentEvents {
|
|
61
|
+
/** Socket state. */
|
|
62
|
+
connection: ConnectionState;
|
|
63
|
+
/** The socket is authenticated (after every reconnect). */
|
|
64
|
+
ready: {
|
|
65
|
+
seq: number;
|
|
66
|
+
};
|
|
67
|
+
"call.ringing": AgentCallRingingData;
|
|
68
|
+
"call.ended": AgentCallEndedData;
|
|
69
|
+
"conversation.assigned": Record<string, unknown>;
|
|
70
|
+
"message.created": Record<string, unknown>;
|
|
71
|
+
"wrapup.required": {
|
|
72
|
+
callId: string;
|
|
73
|
+
conversationId: string;
|
|
74
|
+
deadlineAt: string | null;
|
|
75
|
+
};
|
|
76
|
+
"presence.changed": Record<string, unknown>;
|
|
77
|
+
"supervisor.alert": Record<string, unknown>;
|
|
78
|
+
/** An event name this SDK version does not know yet (the server added one). */
|
|
79
|
+
"event.unknown": AgentEventFrame;
|
|
80
|
+
/** The calls changed (state machine). */
|
|
81
|
+
calls: CallsState;
|
|
82
|
+
/** Something the host must do for a call (stop the native ring, leave media). Handled for media already. */
|
|
83
|
+
callEffect: CallEffect;
|
|
84
|
+
/** Events were missed beyond what the server keeps: state was refetched (rings); refetch your own lists. */
|
|
85
|
+
resync: {
|
|
86
|
+
fromSeq: number;
|
|
87
|
+
};
|
|
88
|
+
/** The session expires within a minute: the SDK is refreshing it. */
|
|
89
|
+
"session.expiring": {
|
|
90
|
+
expiresAt: string;
|
|
91
|
+
};
|
|
92
|
+
/** The session was ended by HAINIVA (sign-out elsewhere, suspended, app paused, key rotated). Call connect() to sign in again. */
|
|
93
|
+
revoked: {
|
|
94
|
+
reason: string;
|
|
95
|
+
};
|
|
96
|
+
error: HainivaAgentError;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The staff member's app: session, REST, socket and calls.
|
|
100
|
+
*/
|
|
101
|
+
export declare class HainivaAgent {
|
|
102
|
+
private readonly o;
|
|
103
|
+
readonly events: TypedEmitter<HainivaAgentEvents>;
|
|
104
|
+
readonly session: SessionManager<AgentSession & AgentSessionDto>;
|
|
105
|
+
readonly rest: AgentRestClient;
|
|
106
|
+
readonly callStore: CallStore;
|
|
107
|
+
private socket;
|
|
108
|
+
private meDto;
|
|
109
|
+
private readonly media;
|
|
110
|
+
private readonly offs;
|
|
111
|
+
private readonly redactor;
|
|
112
|
+
private readonly log;
|
|
113
|
+
private readonly ringMs;
|
|
114
|
+
constructor(o: HainivaAgentOptions);
|
|
115
|
+
/** The signed-in member (after connect()). */
|
|
116
|
+
get me(): AgentMeDto | null;
|
|
117
|
+
/** The calls now (the state machine): ringing, connecting, active, held, ended. */
|
|
118
|
+
get callsState(): CallsState;
|
|
119
|
+
get connection(): ConnectionState;
|
|
120
|
+
on<K extends keyof HainivaAgentEvents>(event: K, fn: (payload: HainivaAgentEvents[K]) => void): () => void;
|
|
121
|
+
/** Sign in (or restore the stored session), load who you are and the rings waiting, open the event socket. */
|
|
122
|
+
connect(): Promise<AgentMeDto>;
|
|
123
|
+
/**
|
|
124
|
+
* Close the socket and leave every call's media. `signOut: true` also ends
|
|
125
|
+
* the session on the server (and removes it from the store) — do that when
|
|
126
|
+
* the staff member signs out; unregister this device's push first.
|
|
127
|
+
*/
|
|
128
|
+
disconnect(opts?: {
|
|
129
|
+
signOut?: boolean;
|
|
130
|
+
}): Promise<void>;
|
|
131
|
+
setStatus(status: AgentStatus, o?: RequestOptions): Promise<{
|
|
132
|
+
status: AgentStatus;
|
|
133
|
+
}>;
|
|
134
|
+
readonly rings: {
|
|
135
|
+
/** Rings waiting for you now (also fed into the call state). */
|
|
136
|
+
pending: () => Promise<AgentRingDto[]>;
|
|
137
|
+
};
|
|
138
|
+
readonly calls: {
|
|
139
|
+
/**
|
|
140
|
+
* Answer: the server's answer race decides (409 = someone else was
|
|
141
|
+
* faster). With `media`, joins the room and returns the session.
|
|
142
|
+
*/
|
|
143
|
+
accept: (callId: string, ring?: {
|
|
144
|
+
from?: string;
|
|
145
|
+
conversationId?: string;
|
|
146
|
+
}) => Promise<{
|
|
147
|
+
accepted: AgentAcceptDto;
|
|
148
|
+
media: MediaSession | null;
|
|
149
|
+
}>;
|
|
150
|
+
decline: (callId: string) => Promise<{
|
|
151
|
+
callId: string;
|
|
152
|
+
declined: true;
|
|
153
|
+
}>;
|
|
154
|
+
/** Hang up here: media is left at once, then the server ends the call. */
|
|
155
|
+
end: (callId: string) => Promise<{
|
|
156
|
+
callId: string;
|
|
157
|
+
ended: true;
|
|
158
|
+
}>;
|
|
159
|
+
hold: (callId: string, on: boolean) => Promise<{
|
|
160
|
+
callId: string;
|
|
161
|
+
onHold: boolean;
|
|
162
|
+
}>;
|
|
163
|
+
mute: (callId: string, muted: boolean) => Promise<void>;
|
|
164
|
+
/** Warm or blind to a colleague, to a queue (always blind), or to a phone number. */
|
|
165
|
+
transfer: (callId: string, target: TransferTarget, mode?: "warm" | "blind") => Promise<{
|
|
166
|
+
callId: string;
|
|
167
|
+
transferred: true;
|
|
168
|
+
}>;
|
|
169
|
+
get: (callId: string) => Promise<import("./contract.cjs").AgentCallDto>;
|
|
170
|
+
};
|
|
171
|
+
/** Join (or re-join) a call's media room. Without credentials, a fresh LiveKit token is minted. */
|
|
172
|
+
joinMedia(callId: string, creds?: MediaCredentials): Promise<MediaSession>;
|
|
173
|
+
readonly conversations: {
|
|
174
|
+
list: (q?: ConversationListQuery) => Promise<AgentPage<AgentConversationDto>>;
|
|
175
|
+
get: (id: string) => Promise<AgentConversationDto>;
|
|
176
|
+
/** Take it yourself (no agentId) or, a supervisor, give it to a colleague. */
|
|
177
|
+
assign: (id: string, agentId?: string) => Promise<unknown>;
|
|
178
|
+
release: (id: string, note?: string) => Promise<{
|
|
179
|
+
conversationId: string;
|
|
180
|
+
released: true;
|
|
181
|
+
}>;
|
|
182
|
+
transfer: (id: string, to: {
|
|
183
|
+
agentId: string;
|
|
184
|
+
} | {
|
|
185
|
+
queueId: string;
|
|
186
|
+
note?: string;
|
|
187
|
+
}) => Promise<unknown>;
|
|
188
|
+
close: (id: string) => Promise<{
|
|
189
|
+
conversationId: string;
|
|
190
|
+
closed: true;
|
|
191
|
+
}>;
|
|
192
|
+
};
|
|
193
|
+
readonly messages: {
|
|
194
|
+
list: (conversationId: string, q?: {
|
|
195
|
+
after?: string;
|
|
196
|
+
limit?: number;
|
|
197
|
+
}) => Promise<AgentPage<AgentMessageDto>>;
|
|
198
|
+
send: (conversationId: string, text: string) => Promise<Pick<AgentMessageDto, "id" | "conversationId" | "interactionId" | "createdAt">>;
|
|
199
|
+
typing: (conversationId: string) => Promise<{
|
|
200
|
+
ok: true;
|
|
201
|
+
}>;
|
|
202
|
+
};
|
|
203
|
+
/** After a call: a disposition code from dispositions() and notes. */
|
|
204
|
+
wrapUp(callId: string, w?: {
|
|
205
|
+
dispositionCode?: string;
|
|
206
|
+
notes?: string;
|
|
207
|
+
}): Promise<{
|
|
208
|
+
callId: string;
|
|
209
|
+
wrappedUp: true;
|
|
210
|
+
}>;
|
|
211
|
+
dispositions(channel?: string): Promise<AgentDispositionDto[]>;
|
|
212
|
+
readonly supervisor: {
|
|
213
|
+
liveCalls: () => Promise<SupervisorLiveCallDto[]>;
|
|
214
|
+
agents: () => Promise<SupervisorAgentDto[]>;
|
|
215
|
+
/** Listen in (monitor), coach the agent (whisper) or join the call (barge). Join `media` with your adapter. */
|
|
216
|
+
supervise: (callId: string, mode?: "monitor" | "whisper" | "barge") => Promise<SupervisorSuperviseDto>;
|
|
217
|
+
setMode: (callId: string, mode: "monitor" | "whisper" | "barge", supervisorTrackSid?: string) => Promise<{
|
|
218
|
+
callId: string;
|
|
219
|
+
mode: string;
|
|
220
|
+
}>;
|
|
221
|
+
};
|
|
222
|
+
readonly push: {
|
|
223
|
+
/** Register this device's FCM token (Android) or PushKit/APNs token (iPhone) for YOUR app's credentials. */
|
|
224
|
+
register: (sub: AgentPushSubscriptionInput) => Promise<AgentPushSubscriptionDto>;
|
|
225
|
+
unregister: (token: string) => Promise<void>;
|
|
226
|
+
};
|
|
227
|
+
/** A REST call; a final 401 session_revoked tells the app and forgets the session. */
|
|
228
|
+
private guard;
|
|
229
|
+
private onRevoked;
|
|
230
|
+
private openSocket;
|
|
231
|
+
private onFrame;
|
|
232
|
+
/** Rings waiting on the server → the call state (a cold start, a missed push, a resync). */
|
|
233
|
+
private syncRings;
|
|
234
|
+
private onEffect;
|
|
235
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CallStore = exports.initialCallsState = void 0;
|
|
4
|
+
exports.transition = transition;
|
|
5
|
+
exports.activeCall = activeCall;
|
|
6
|
+
exports.ringingCalls = ringingCalls;
|
|
7
|
+
const emitter_js_1 = require("./emitter.cjs");
|
|
8
|
+
exports.initialCallsState = Object.freeze({ calls: Object.freeze({}), activeId: null });
|
|
9
|
+
const LIVE = new Set(["connecting", "active", "held", "transferring"]);
|
|
10
|
+
const IN_CALL = new Set(["active", "held", "transferring"]);
|
|
11
|
+
function newCall(info, phase, at) {
|
|
12
|
+
return { info, phase, muted: false, since: null, ringingSince: phase === "ringing" ? at : null, transfer: null, notice: null, endReason: null, endedAt: null, error: null };
|
|
13
|
+
}
|
|
14
|
+
function activeOf(calls) {
|
|
15
|
+
for (const c of Object.values(calls))
|
|
16
|
+
if (LIVE.has(c.phase))
|
|
17
|
+
return c.info.id;
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
/** Apply one event. Pure: never mutates `state`. */
|
|
21
|
+
function transition(state, ev) {
|
|
22
|
+
const calls = { ...state.calls };
|
|
23
|
+
const effects = [];
|
|
24
|
+
const same = { state, effects: [], changed: false };
|
|
25
|
+
const id = ev.type === "ring" || ev.type === "dial" ? ev.call.id : ev.id;
|
|
26
|
+
const c = calls[id];
|
|
27
|
+
const put = (next) => { calls[next.info.id] = next; };
|
|
28
|
+
const end = (call, reason, at, extra = {}) => {
|
|
29
|
+
if (call.phase === "ringing")
|
|
30
|
+
effects.push({ type: "ring.cancel", id: call.info.id });
|
|
31
|
+
else if (LIVE.has(call.phase))
|
|
32
|
+
effects.push({ type: "media.leave", id: call.info.id });
|
|
33
|
+
put({ ...call, ...extra, phase: "ended", endReason: reason, endedAt: at, transfer: null });
|
|
34
|
+
effects.push({ type: "ended", id: call.info.id, reason });
|
|
35
|
+
};
|
|
36
|
+
const done = () => {
|
|
37
|
+
const next = { calls, activeId: activeOf(calls) };
|
|
38
|
+
return { state: next, effects, changed: true };
|
|
39
|
+
};
|
|
40
|
+
switch (ev.type) {
|
|
41
|
+
case "ring": {
|
|
42
|
+
if (!ev.call.id)
|
|
43
|
+
return same;
|
|
44
|
+
if (c && c.phase !== "ended")
|
|
45
|
+
return same; // the same ring again (push + socket)
|
|
46
|
+
if (ev.call.expiresAt !== undefined && ev.call.expiresAt <= ev.at)
|
|
47
|
+
return same; // arrived too late
|
|
48
|
+
if (state.activeId) {
|
|
49
|
+
// On a call: this device turns it away (iPhone: CallKit already shows it).
|
|
50
|
+
return { state, effects: [{ type: "ring.cancel", id }], changed: false };
|
|
51
|
+
}
|
|
52
|
+
put(newCall({ ...ev.call, direction: "inbound" }, "ringing", ev.at));
|
|
53
|
+
effects.push({ type: "ring.show", call: ev.call });
|
|
54
|
+
return done();
|
|
55
|
+
}
|
|
56
|
+
case "dial": {
|
|
57
|
+
if (!ev.call.id || state.activeId)
|
|
58
|
+
return same;
|
|
59
|
+
if (Object.values(state.calls).some((x) => x.phase === "ringing"))
|
|
60
|
+
return same; // answer or decline first
|
|
61
|
+
if (c && c.phase !== "ended")
|
|
62
|
+
return same;
|
|
63
|
+
put(newCall({ ...ev.call, direction: "outbound", path: "out" }, "connecting", ev.at));
|
|
64
|
+
return done();
|
|
65
|
+
}
|
|
66
|
+
case "accept": {
|
|
67
|
+
if (!c || c.phase !== "ringing" || state.activeId)
|
|
68
|
+
return same;
|
|
69
|
+
if (c.info.expiresAt !== undefined && c.info.expiresAt <= ev.at) {
|
|
70
|
+
end(c, "missed", ev.at);
|
|
71
|
+
return done();
|
|
72
|
+
}
|
|
73
|
+
effects.push({ type: "ring.cancel", id });
|
|
74
|
+
put({ ...c, phase: "connecting" });
|
|
75
|
+
for (const other of Object.values(calls)) {
|
|
76
|
+
if (other.info.id !== id && other.phase === "ringing")
|
|
77
|
+
end(other, "busy", ev.at);
|
|
78
|
+
}
|
|
79
|
+
return done();
|
|
80
|
+
}
|
|
81
|
+
case "decline": {
|
|
82
|
+
if (!c || c.phase !== "ringing")
|
|
83
|
+
return same;
|
|
84
|
+
end(c, "declined", ev.at);
|
|
85
|
+
return done();
|
|
86
|
+
}
|
|
87
|
+
case "expire": {
|
|
88
|
+
if (!c || c.phase !== "ringing")
|
|
89
|
+
return same;
|
|
90
|
+
if (c.info.expiresAt !== undefined && c.info.expiresAt > ev.at)
|
|
91
|
+
return same;
|
|
92
|
+
end(c, "missed", ev.at);
|
|
93
|
+
return done();
|
|
94
|
+
}
|
|
95
|
+
case "ring_cancelled": {
|
|
96
|
+
if (!c)
|
|
97
|
+
return same;
|
|
98
|
+
if (c.phase === "ringing") {
|
|
99
|
+
end(c, ev.reason, ev.at);
|
|
100
|
+
return done();
|
|
101
|
+
}
|
|
102
|
+
// Answering here: "taken elsewhere" is about the race the server decides
|
|
103
|
+
// (accept_failed tells); the caller hanging up ends it.
|
|
104
|
+
if (c.phase === "connecting" && ev.reason === "remote_ended") {
|
|
105
|
+
end(c, "remote_ended", ev.at);
|
|
106
|
+
return done();
|
|
107
|
+
}
|
|
108
|
+
return same;
|
|
109
|
+
}
|
|
110
|
+
case "accept_failed": {
|
|
111
|
+
if (!c || c.phase !== "connecting")
|
|
112
|
+
return same;
|
|
113
|
+
const reason = ev.reason === "answered_elsewhere" ? "answered_elsewhere" : ev.reason === "gone" ? "remote_ended" : "failed";
|
|
114
|
+
end(c, reason, ev.at, { error: ev.error ?? null });
|
|
115
|
+
return done();
|
|
116
|
+
}
|
|
117
|
+
case "connected": {
|
|
118
|
+
if (!c || c.phase === "ended") {
|
|
119
|
+
// Media came up after the call ended (caller hung up during the answer): leave it.
|
|
120
|
+
return { state, effects: [{ type: "media.leave", id }], changed: false };
|
|
121
|
+
}
|
|
122
|
+
if (c.phase !== "connecting")
|
|
123
|
+
return same;
|
|
124
|
+
put({ ...c, phase: "active", since: ev.answered ? ev.at : null, muted: false });
|
|
125
|
+
return done();
|
|
126
|
+
}
|
|
127
|
+
case "answered": {
|
|
128
|
+
if (!c || !IN_CALL.has(c.phase) || c.since !== null)
|
|
129
|
+
return same;
|
|
130
|
+
put({ ...c, since: ev.at });
|
|
131
|
+
return done();
|
|
132
|
+
}
|
|
133
|
+
case "mute": {
|
|
134
|
+
if (!c || !IN_CALL.has(c.phase) || c.muted === ev.muted)
|
|
135
|
+
return same;
|
|
136
|
+
put({ ...c, muted: ev.muted });
|
|
137
|
+
return done();
|
|
138
|
+
}
|
|
139
|
+
case "hold": {
|
|
140
|
+
if (!c || c.phase !== "active")
|
|
141
|
+
return same;
|
|
142
|
+
put({ ...c, phase: "held" });
|
|
143
|
+
return done();
|
|
144
|
+
}
|
|
145
|
+
case "resume": {
|
|
146
|
+
if (!c || c.phase !== "held")
|
|
147
|
+
return same;
|
|
148
|
+
put({ ...c, phase: "active" });
|
|
149
|
+
return done();
|
|
150
|
+
}
|
|
151
|
+
case "transfer_start": {
|
|
152
|
+
if (!c || (c.phase !== "active" && c.phase !== "held"))
|
|
153
|
+
return same;
|
|
154
|
+
const mode = ev.target.kind === "queue" ? "blind" : ev.mode;
|
|
155
|
+
put({ ...c, phase: "transferring", notice: null, transfer: { mode, target: ev.target, joined: false, startedAt: ev.at, resumeTo: c.phase } });
|
|
156
|
+
return done();
|
|
157
|
+
}
|
|
158
|
+
case "transfer_joined": {
|
|
159
|
+
if (!c || c.phase !== "transferring" || !c.transfer || c.transfer.mode !== "warm" || c.transfer.joined)
|
|
160
|
+
return same;
|
|
161
|
+
put({ ...c, transfer: { ...c.transfer, joined: true } });
|
|
162
|
+
return done();
|
|
163
|
+
}
|
|
164
|
+
case "transfer_cancelled": {
|
|
165
|
+
if (!c || c.phase !== "transferring" || !c.transfer)
|
|
166
|
+
return same;
|
|
167
|
+
put({ ...c, phase: c.transfer.resumeTo, transfer: null, notice: ev.notice ?? null });
|
|
168
|
+
return done();
|
|
169
|
+
}
|
|
170
|
+
case "transfer_completed": {
|
|
171
|
+
if (!c || c.phase !== "transferring")
|
|
172
|
+
return same;
|
|
173
|
+
end(c, "transferred", ev.at);
|
|
174
|
+
return done();
|
|
175
|
+
}
|
|
176
|
+
case "remote_end": {
|
|
177
|
+
if (!c || c.phase === "ended")
|
|
178
|
+
return same;
|
|
179
|
+
// A blind transfer ends here when the server hands the call over.
|
|
180
|
+
const reason = c.phase === "transferring" && c.transfer?.mode === "blind" ? "transferred" : "remote_ended";
|
|
181
|
+
end(c, reason, ev.at);
|
|
182
|
+
return done();
|
|
183
|
+
}
|
|
184
|
+
case "hang_up": {
|
|
185
|
+
if (!c || !LIVE.has(c.phase))
|
|
186
|
+
return same;
|
|
187
|
+
end(c, "hung_up", ev.at);
|
|
188
|
+
return done();
|
|
189
|
+
}
|
|
190
|
+
case "rekey": {
|
|
191
|
+
// An outgoing call learns its server id.
|
|
192
|
+
if (!c || c.phase === "ended" || !ev.newId || ev.newId === id || calls[ev.newId])
|
|
193
|
+
return same;
|
|
194
|
+
delete calls[id];
|
|
195
|
+
put({ ...c, info: { ...c.info, id: ev.newId, ...(ev.conversationId ? { conversationId: ev.conversationId } : {}) } });
|
|
196
|
+
return done();
|
|
197
|
+
}
|
|
198
|
+
case "dismiss": {
|
|
199
|
+
if (!c || c.phase !== "ended")
|
|
200
|
+
return same;
|
|
201
|
+
delete calls[id];
|
|
202
|
+
return done();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** The call holding the line, if any. */
|
|
207
|
+
function activeCall(state) {
|
|
208
|
+
return state.activeId ? state.calls[state.activeId] ?? null : null;
|
|
209
|
+
}
|
|
210
|
+
/** Calls ringing now, oldest first. */
|
|
211
|
+
function ringingCalls(state) {
|
|
212
|
+
return Object.values(state.calls).filter((c) => c.phase === "ringing").sort((a, b) => (a.ringingSince ?? 0) - (b.ringingSince ?? 0));
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The state machine with a clock: rings expire on their own, and listeners hear
|
|
216
|
+
* every change and effect. Still free of React Native, LiveKit and HTTP.
|
|
217
|
+
*/
|
|
218
|
+
class CallStore {
|
|
219
|
+
constructor(o = {}) {
|
|
220
|
+
this.o = o;
|
|
221
|
+
this.events = new emitter_js_1.TypedEmitter();
|
|
222
|
+
this.st = exports.initialCallsState;
|
|
223
|
+
this.timers = new Map();
|
|
224
|
+
this.now = o.now ?? Date.now;
|
|
225
|
+
}
|
|
226
|
+
get state() { return this.st; }
|
|
227
|
+
/** Apply an event. `at` is filled from the clock when omitted. */
|
|
228
|
+
dispatch(ev) {
|
|
229
|
+
const full = ("at" in ev && typeof ev.at === "number" ? ev : { ...ev, at: this.now() });
|
|
230
|
+
const t = transition(this.st, full);
|
|
231
|
+
if (t.changed) {
|
|
232
|
+
this.st = t.state;
|
|
233
|
+
this.syncTimers();
|
|
234
|
+
this.events.emit("change", this.st);
|
|
235
|
+
}
|
|
236
|
+
for (const e of t.effects)
|
|
237
|
+
this.events.emit("effect", e);
|
|
238
|
+
return t;
|
|
239
|
+
}
|
|
240
|
+
/** Stop every timer (e.g. on sign-out). */
|
|
241
|
+
dispose() {
|
|
242
|
+
for (const t of this.timers.values())
|
|
243
|
+
clearTimeout(t);
|
|
244
|
+
this.timers.clear();
|
|
245
|
+
this.events.removeAll();
|
|
246
|
+
}
|
|
247
|
+
syncTimers() {
|
|
248
|
+
for (const [key, t] of this.timers) {
|
|
249
|
+
const cut = key.indexOf(":");
|
|
250
|
+
const kind = key.slice(0, cut);
|
|
251
|
+
const id = key.slice(cut + 1);
|
|
252
|
+
const c = this.st.calls[id];
|
|
253
|
+
if (!c || (kind === "expire" && c.phase !== "ringing") || (kind === "forget" && c.phase !== "ended")) {
|
|
254
|
+
clearTimeout(t);
|
|
255
|
+
this.timers.delete(key);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const c of Object.values(this.st.calls)) {
|
|
259
|
+
const id = c.info.id;
|
|
260
|
+
if (c.phase === "ringing" && c.info.expiresAt !== undefined && !this.timers.has(`expire:${id}`)) {
|
|
261
|
+
const left = Math.max(0, c.info.expiresAt - this.now());
|
|
262
|
+
this.timers.set(`expire:${id}`, setTimeout(() => {
|
|
263
|
+
this.timers.delete(`expire:${id}`);
|
|
264
|
+
this.dispatch({ type: "expire", id, at: Math.max(this.now(), c.info.expiresAt ?? 0) });
|
|
265
|
+
}, left));
|
|
266
|
+
}
|
|
267
|
+
const forget = this.o.forgetEndedAfterMs ?? 0;
|
|
268
|
+
if (c.phase === "ended" && forget > 0 && !this.timers.has(`forget:${id}`)) {
|
|
269
|
+
this.timers.set(`forget:${id}`, setTimeout(() => {
|
|
270
|
+
this.timers.delete(`forget:${id}`);
|
|
271
|
+
this.dispatch({ type: "dismiss", id });
|
|
272
|
+
}, forget));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
exports.CallStore = CallStore;
|