@callembed/sdk 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +66 -2
- package/dist/callembed.iife.js +129 -0
- package/dist/core/agent.d.ts +10 -0
- package/dist/core/agent.js +19 -0
- package/dist/core/api.d.ts +44 -0
- package/dist/core/api.js +169 -0
- package/dist/core/audio.d.ts +20 -0
- package/dist/core/audio.js +68 -0
- package/dist/core/call.d.ts +45 -0
- package/dist/core/call.js +252 -0
- package/dist/core/client.d.ts +53 -0
- package/dist/core/client.js +157 -0
- package/dist/core/errors.d.ts +22 -0
- package/dist/core/errors.js +41 -0
- package/dist/core/events.d.ts +20 -0
- package/dist/core/events.js +43 -0
- package/dist/core/metadata.d.ts +13 -0
- package/dist/core/metadata.js +38 -0
- package/dist/core/ring-watch.d.ts +20 -0
- package/dist/core/ring-watch.js +46 -0
- package/dist/core/room-events.d.ts +13 -0
- package/dist/core/room-events.js +29 -0
- package/dist/core/state-machine.d.ts +11 -0
- package/dist/core/state-machine.js +44 -0
- package/dist/core/teardown.d.ts +49 -0
- package/dist/core/teardown.js +107 -0
- package/dist/core/types.d.ts +175 -0
- package/dist/core/types.js +5 -0
- package/dist/iife.d.ts +23 -0
- package/dist/iife.js +27 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +10 -0
- package/dist/ui/bind.d.ts +14 -0
- package/dist/ui/bind.js +82 -0
- package/dist/ui/button.d.ts +28 -0
- package/dist/ui/button.js +57 -0
- package/dist/ui/copy-en.d.ts +3 -0
- package/dist/ui/copy-en.js +25 -0
- package/dist/ui/copy-zh-tw.d.ts +28 -0
- package/dist/ui/copy-zh-tw.js +25 -0
- package/dist/ui/copy.d.ts +18 -0
- package/dist/ui/copy.js +45 -0
- package/dist/ui/index.d.ts +12 -0
- package/dist/ui/index.js +12 -0
- package/dist/ui/panel.d.ts +26 -0
- package/dist/ui/panel.js +62 -0
- package/dist/ui/theme.d.ts +13 -0
- package/dist/ui/theme.js +124 -0
- package/package.json +68 -4
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { CallSessionController } from "./call.js";
|
|
2
|
+
import { CallEmbedError } from "./errors.js";
|
|
3
|
+
import { isTerminal } from "./state-machine.js";
|
|
4
|
+
/**
|
|
5
|
+
* Default locale when `options.locale` is omitted (X10).
|
|
6
|
+
* `navigator.language` starting with `zh` → `zh-TW`; otherwise `en`.
|
|
7
|
+
*/
|
|
8
|
+
export function defaultLocaleFromNavigator(language = typeof navigator !== "undefined"
|
|
9
|
+
? navigator.language
|
|
10
|
+
: undefined) {
|
|
11
|
+
if (language && language.toLowerCase().startsWith("zh"))
|
|
12
|
+
return "zh-TW";
|
|
13
|
+
return "en";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Module-level SDK state.
|
|
17
|
+
*
|
|
18
|
+
* Reason: the embed script is a singleton per page (one publishable key /
|
|
19
|
+
* one active caller session). Keeping config here avoids forcing every
|
|
20
|
+
* integrator to thread a client instance through button handlers.
|
|
21
|
+
*/
|
|
22
|
+
let runtime = null;
|
|
23
|
+
let active = null;
|
|
24
|
+
/**
|
|
25
|
+
* Optional UI binder registered by `@callembed/sdk/ui` via
|
|
26
|
+
* {@link registerUiHooksFactory}. Core never imports the UI package.
|
|
27
|
+
* Loading `@callembed/sdk/ui` alone does **not** register this — call
|
|
28
|
+
* `enableDefaultUi()` or `mountButton()`.
|
|
29
|
+
*/
|
|
30
|
+
let uiHooksFactory = null;
|
|
31
|
+
/**
|
|
32
|
+
* @internal Allow the UI package to attach panel hooks without core importing UI.
|
|
33
|
+
*/
|
|
34
|
+
export function registerUiHooksFactory(factory) {
|
|
35
|
+
uiHooksFactory = factory;
|
|
36
|
+
}
|
|
37
|
+
function requireConfig() {
|
|
38
|
+
if (!runtime) {
|
|
39
|
+
throw new CallEmbedError("not_initialized", "Call CallEmbed.init({ publishableKey, apiBaseUrl }) first.");
|
|
40
|
+
}
|
|
41
|
+
return runtime;
|
|
42
|
+
}
|
|
43
|
+
function assertPublishableKey(key) {
|
|
44
|
+
const trimmed = key.trim();
|
|
45
|
+
if (!trimmed) {
|
|
46
|
+
throw new CallEmbedError("invalid_config", "publishableKey is required.");
|
|
47
|
+
}
|
|
48
|
+
if (/^(sk_|ak_)/i.test(trimmed) || /service_role/i.test(trimmed)) {
|
|
49
|
+
throw new CallEmbedError("invalid_config", "Only publishable keys (pk_…) may be used in the browser SDK.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function bindStartHandlers(call, handlers) {
|
|
53
|
+
if (!handlers)
|
|
54
|
+
return;
|
|
55
|
+
Object.keys(handlers).forEach((name) => {
|
|
56
|
+
const handler = handlers[name];
|
|
57
|
+
if (handler) {
|
|
58
|
+
call.on(name, handler);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Headless CallEmbed client.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* CallEmbed.init({ publishableKey: "pk_…", apiBaseUrl: "" });
|
|
68
|
+
* const call = await CallEmbed.startCall({
|
|
69
|
+
* queue: "onboarding",
|
|
70
|
+
* hidePanel: true,
|
|
71
|
+
* on: {
|
|
72
|
+
* statusChange: ({ status }) => { … },
|
|
73
|
+
* ringing: () => { … },
|
|
74
|
+
* error: ({ code }) => { … },
|
|
75
|
+
* },
|
|
76
|
+
* });
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export const CallEmbed = {
|
|
80
|
+
/**
|
|
81
|
+
* Configure the SDK for this page.
|
|
82
|
+
* @throws {@link CallEmbedError} `invalid_config` when keys/URL are missing or unsafe.
|
|
83
|
+
*/
|
|
84
|
+
init(options) {
|
|
85
|
+
assertPublishableKey(options.publishableKey);
|
|
86
|
+
if (options.apiBaseUrl === undefined || options.apiBaseUrl === null) {
|
|
87
|
+
throw new CallEmbedError("invalid_config", 'apiBaseUrl is required (use "" for same-origin).');
|
|
88
|
+
}
|
|
89
|
+
runtime = {
|
|
90
|
+
publishableKey: options.publishableKey.trim(),
|
|
91
|
+
apiBaseUrl: String(options.apiBaseUrl).trim().replace(/\/$/, ""),
|
|
92
|
+
// X10: zh* navigator → zh-TW; otherwise en (override via options.locale).
|
|
93
|
+
locale: options.locale ?? defaultLocaleFromNavigator(),
|
|
94
|
+
copyOverrides: options.copy ?? {},
|
|
95
|
+
branding: options.branding !== false,
|
|
96
|
+
debug: options.debug === true,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
/**
|
|
100
|
+
* Start a call and resolve once the room is connected (`ringing`), or reject
|
|
101
|
+
* on startup failure. Register `options.on` to observe `connecting` / `ringing`
|
|
102
|
+
* / startup `error` during the await.
|
|
103
|
+
*
|
|
104
|
+
* Panel UI appears only when `enableDefaultUi()` or `mountButton()` was used
|
|
105
|
+
* and `hidePanel` is not true.
|
|
106
|
+
*
|
|
107
|
+
* @returns The active call (status is typically `ringing` on success).
|
|
108
|
+
*/
|
|
109
|
+
async startCall(options) {
|
|
110
|
+
const cfg = requireConfig();
|
|
111
|
+
if (!options.queue?.trim()) {
|
|
112
|
+
throw new CallEmbedError("invalid_queue", "queue is required.");
|
|
113
|
+
}
|
|
114
|
+
if (options.queue.trim().length > 64) {
|
|
115
|
+
throw new CallEmbedError("invalid_queue", "queue must be at most 64 characters.");
|
|
116
|
+
}
|
|
117
|
+
if (options.displayName !== undefined && typeof options.displayName !== "string") {
|
|
118
|
+
throw new CallEmbedError("invalid_display_name", "displayName must be a string when provided.");
|
|
119
|
+
}
|
|
120
|
+
if (active && !isTerminal(active.status)) {
|
|
121
|
+
throw new CallEmbedError("call_in_progress", "A call is already in progress. Hang up before starting another.");
|
|
122
|
+
}
|
|
123
|
+
const call = new CallSessionController(cfg);
|
|
124
|
+
bindStartHandlers(call, options.on);
|
|
125
|
+
if (uiHooksFactory) {
|
|
126
|
+
call.setUiHooks(uiHooksFactory(call, options));
|
|
127
|
+
}
|
|
128
|
+
active = call;
|
|
129
|
+
try {
|
|
130
|
+
await call.start(options);
|
|
131
|
+
return call;
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (active === call && call.status === "error") {
|
|
135
|
+
// Retain briefly so optional UI can show the error state.
|
|
136
|
+
}
|
|
137
|
+
else if (active === call) {
|
|
138
|
+
active = null;
|
|
139
|
+
}
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
/** Currently active call, or `null`. */
|
|
144
|
+
getActiveCall() {
|
|
145
|
+
return active;
|
|
146
|
+
},
|
|
147
|
+
/** @internal Test helper — reset singleton state. */
|
|
148
|
+
_resetForTests() {
|
|
149
|
+
runtime = null;
|
|
150
|
+
active = null;
|
|
151
|
+
uiHooksFactory = null;
|
|
152
|
+
},
|
|
153
|
+
/** @internal Expose runtime for the UI package (locale / copy / debug). */
|
|
154
|
+
_getRuntime() {
|
|
155
|
+
return runtime;
|
|
156
|
+
},
|
|
157
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error codes and {@link CallEmbedError} for the public SDK surface.
|
|
3
|
+
*/
|
|
4
|
+
/** Well-known SDK / API error codes (English, machine-readable). */
|
|
5
|
+
export type ErrorCode = "not_initialized" | "invalid_config" | "invalid_queue" | "invalid_metadata" | "invalid_display_name" | "invalid_container" | "invalid_selector" | "call_in_progress" | "mic_permission_denied" | "no_microphone" | "connection_failed" | "call_failed" | "no_agent_online" | "no_answer" | "agent_busy" | "call_already_answered" | "invalid_end_token" | "quota_exceeded" | "origin_not_allowed" | "invalid_publishable_key" | "rate_limited" | "livekit_not_configured" | "unknown_queue";
|
|
6
|
+
/**
|
|
7
|
+
* Thrown by CallEmbed SDK methods and surfaced via the `error` event.
|
|
8
|
+
*/
|
|
9
|
+
export declare class CallEmbedError extends Error {
|
|
10
|
+
readonly code: string;
|
|
11
|
+
readonly status: number;
|
|
12
|
+
/**
|
|
13
|
+
* @param code - Machine-readable English error code.
|
|
14
|
+
* @param message - Human-readable message (may be localized by the API).
|
|
15
|
+
* @param status - Optional HTTP status when the error came from the API.
|
|
16
|
+
*/
|
|
17
|
+
constructor(code: string, message: string, status?: number);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Map browser / LiveKit failure reasons onto stable SDK error codes.
|
|
21
|
+
*/
|
|
22
|
+
export declare function mapMediaError(err: unknown): CallEmbedError;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error codes and {@link CallEmbedError} for the public SDK surface.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Thrown by CallEmbed SDK methods and surfaced via the `error` event.
|
|
6
|
+
*/
|
|
7
|
+
export class CallEmbedError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
status;
|
|
10
|
+
/**
|
|
11
|
+
* @param code - Machine-readable English error code.
|
|
12
|
+
* @param message - Human-readable message (may be localized by the API).
|
|
13
|
+
* @param status - Optional HTTP status when the error came from the API.
|
|
14
|
+
*/
|
|
15
|
+
constructor(code, message, status = 400) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "CallEmbedError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.status = status;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Map browser / LiveKit failure reasons onto stable SDK error codes.
|
|
24
|
+
*/
|
|
25
|
+
export function mapMediaError(err) {
|
|
26
|
+
const name = err && typeof err === "object" && "name" in err
|
|
27
|
+
? String(err.name)
|
|
28
|
+
: "";
|
|
29
|
+
const message = err instanceof Error ? err.message : typeof err === "string" ? err : "Media error";
|
|
30
|
+
if (name === "NotAllowedError" ||
|
|
31
|
+
name === "PermissionDeniedError" ||
|
|
32
|
+
/permission|denied|NotAllowed/i.test(message)) {
|
|
33
|
+
return new CallEmbedError("mic_permission_denied", "Microphone permission was denied.");
|
|
34
|
+
}
|
|
35
|
+
if (name === "NotFoundError" ||
|
|
36
|
+
name === "DevicesNotFoundError" ||
|
|
37
|
+
/Requested device not found|no microphone|NotFound/i.test(message)) {
|
|
38
|
+
return new CallEmbedError("no_microphone", "No microphone device was found.");
|
|
39
|
+
}
|
|
40
|
+
return new CallEmbedError("connection_failed", message || "Connection failed.");
|
|
41
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CallEventHandler, CallEventMap, CallEventName } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tiny typed pub/sub used by {@link ActiveCall}.
|
|
4
|
+
* Handlers run synchronously; exceptions are swallowed so one bad listener
|
|
5
|
+
* cannot break the call lifecycle.
|
|
6
|
+
*/
|
|
7
|
+
export declare class TypedEmitter {
|
|
8
|
+
private readonly listeners;
|
|
9
|
+
/**
|
|
10
|
+
* Register a handler for `event`.
|
|
11
|
+
* @returns Unsubscribe function.
|
|
12
|
+
*/
|
|
13
|
+
on<E extends CallEventName>(event: E, handler: CallEventHandler<E>): () => void;
|
|
14
|
+
/** Remove a handler previously registered with {@link on}. */
|
|
15
|
+
off<E extends CallEventName>(event: E, handler: CallEventHandler<E>): void;
|
|
16
|
+
/** Emit `event` with a typed payload. */
|
|
17
|
+
emit<E extends CallEventName>(event: E, payload: CallEventMap[E]): void;
|
|
18
|
+
/** Drop all listeners (call teardown). */
|
|
19
|
+
clear(): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny typed pub/sub used by {@link ActiveCall}.
|
|
3
|
+
* Handlers run synchronously; exceptions are swallowed so one bad listener
|
|
4
|
+
* cannot break the call lifecycle.
|
|
5
|
+
*/
|
|
6
|
+
export class TypedEmitter {
|
|
7
|
+
listeners = new Map();
|
|
8
|
+
/**
|
|
9
|
+
* Register a handler for `event`.
|
|
10
|
+
* @returns Unsubscribe function.
|
|
11
|
+
*/
|
|
12
|
+
on(event, handler) {
|
|
13
|
+
let set = this.listeners.get(event);
|
|
14
|
+
if (!set) {
|
|
15
|
+
set = new Set();
|
|
16
|
+
this.listeners.set(event, set);
|
|
17
|
+
}
|
|
18
|
+
set.add(handler);
|
|
19
|
+
return () => this.off(event, handler);
|
|
20
|
+
}
|
|
21
|
+
/** Remove a handler previously registered with {@link on}. */
|
|
22
|
+
off(event, handler) {
|
|
23
|
+
this.listeners.get(event)?.delete(handler);
|
|
24
|
+
}
|
|
25
|
+
/** Emit `event` with a typed payload. */
|
|
26
|
+
emit(event, payload) {
|
|
27
|
+
const set = this.listeners.get(event);
|
|
28
|
+
if (!set)
|
|
29
|
+
return;
|
|
30
|
+
for (const handler of [...set]) {
|
|
31
|
+
try {
|
|
32
|
+
handler(payload);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Ignore listener failures
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Drop all listeners (call teardown). */
|
|
40
|
+
clear() {
|
|
41
|
+
this.listeners.clear();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Max UTF-8 / JSON size for `metadata` sent to `POST /v1/calls`.
|
|
3
|
+
* Backend stores metadata as JSON text without a tighter documented cap;
|
|
4
|
+
* the SDK enforces this shared client-side limit before the request leaves
|
|
5
|
+
* the browser.
|
|
6
|
+
*/
|
|
7
|
+
export declare const METADATA_MAX_BYTES = 4096;
|
|
8
|
+
/**
|
|
9
|
+
* Validate `metadata` is a plain object within {@link METADATA_MAX_BYTES}.
|
|
10
|
+
*
|
|
11
|
+
* @throws {@link CallEmbedError} with code `invalid_metadata`
|
|
12
|
+
*/
|
|
13
|
+
export declare function assertValidMetadata(metadata: Record<string, unknown> | undefined): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { CallEmbedError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Max UTF-8 / JSON size for `metadata` sent to `POST /v1/calls`.
|
|
4
|
+
* Backend stores metadata as JSON text without a tighter documented cap;
|
|
5
|
+
* the SDK enforces this shared client-side limit before the request leaves
|
|
6
|
+
* the browser.
|
|
7
|
+
*/
|
|
8
|
+
export const METADATA_MAX_BYTES = 4096;
|
|
9
|
+
/**
|
|
10
|
+
* Validate `metadata` is a plain object within {@link METADATA_MAX_BYTES}.
|
|
11
|
+
*
|
|
12
|
+
* @throws {@link CallEmbedError} with code `invalid_metadata`
|
|
13
|
+
*/
|
|
14
|
+
export function assertValidMetadata(metadata) {
|
|
15
|
+
if (metadata === undefined)
|
|
16
|
+
return;
|
|
17
|
+
if (metadata === null ||
|
|
18
|
+
typeof metadata !== "object" ||
|
|
19
|
+
Array.isArray(metadata)) {
|
|
20
|
+
throw new CallEmbedError("invalid_metadata", "metadata must be a plain JSON object.");
|
|
21
|
+
}
|
|
22
|
+
let serialized;
|
|
23
|
+
try {
|
|
24
|
+
serialized = JSON.stringify(metadata);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new CallEmbedError("invalid_metadata", "metadata must be JSON-serializable.");
|
|
28
|
+
}
|
|
29
|
+
if (serialized === undefined) {
|
|
30
|
+
throw new CallEmbedError("invalid_metadata", "metadata must be a plain JSON object.");
|
|
31
|
+
}
|
|
32
|
+
const bytes = typeof TextEncoder !== "undefined"
|
|
33
|
+
? new TextEncoder().encode(serialized).length
|
|
34
|
+
: serialized.length;
|
|
35
|
+
if (bytes > METADATA_MAX_BYTES) {
|
|
36
|
+
throw new CallEmbedError("invalid_metadata", `metadata exceeds ${METADATA_MAX_BYTES} bytes.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { CallEmbedError } from "./errors.js";
|
|
2
|
+
import type { RuntimeConfig } from "./types.js";
|
|
3
|
+
export interface RingWatchHost {
|
|
4
|
+
cfg: RuntimeConfig;
|
|
5
|
+
getCallId: () => string;
|
|
6
|
+
getEndToken: () => string | null;
|
|
7
|
+
isRinging: () => boolean;
|
|
8
|
+
isHangUpStarted: () => boolean;
|
|
9
|
+
onNoAnswer: () => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare class RingWatch {
|
|
12
|
+
private readonly host;
|
|
13
|
+
private timer;
|
|
14
|
+
constructor(host: RingWatchHost);
|
|
15
|
+
start(intervalMs?: number): void;
|
|
16
|
+
stop(): void;
|
|
17
|
+
checkAfterDisconnect(): Promise<boolean>;
|
|
18
|
+
private tick;
|
|
19
|
+
}
|
|
20
|
+
export declare function noAnswerError(): CallEmbedError;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* While status is `ringing`, poll server status so ring-timeout `no_answer`
|
|
3
|
+
* surfaces as `error(no_answer)` even before LiveKit disconnects.
|
|
4
|
+
*/
|
|
5
|
+
import { apiGetCallStatus } from "./api.js";
|
|
6
|
+
import { CallEmbedError } from "./errors.js";
|
|
7
|
+
export class RingWatch {
|
|
8
|
+
host;
|
|
9
|
+
timer = null;
|
|
10
|
+
constructor(host) {
|
|
11
|
+
this.host = host;
|
|
12
|
+
}
|
|
13
|
+
start(intervalMs = 3_000) {
|
|
14
|
+
this.stop();
|
|
15
|
+
this.timer = setInterval(() => {
|
|
16
|
+
void this.tick();
|
|
17
|
+
}, intervalMs);
|
|
18
|
+
}
|
|
19
|
+
stop() {
|
|
20
|
+
if (this.timer !== null) {
|
|
21
|
+
clearInterval(this.timer);
|
|
22
|
+
this.timer = null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async checkAfterDisconnect() {
|
|
26
|
+
if (!this.host.isRinging() || this.host.isHangUpStarted())
|
|
27
|
+
return false;
|
|
28
|
+
const info = await apiGetCallStatus(this.host.cfg, this.host.getCallId(), this.host.getEndToken());
|
|
29
|
+
if (info?.status === "failed" && info.failReason === "no_answer") {
|
|
30
|
+
await this.host.onNoAnswer();
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
async tick() {
|
|
36
|
+
if (!this.host.isRinging() || this.host.isHangUpStarted())
|
|
37
|
+
return;
|
|
38
|
+
const info = await apiGetCallStatus(this.host.cfg, this.host.getCallId(), this.host.getEndToken());
|
|
39
|
+
if (info?.status === "failed" && info.failReason === "no_answer") {
|
|
40
|
+
await this.host.onNoAnswer();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function noAnswerError() {
|
|
45
|
+
return new CallEmbedError("no_answer", "沒有客服接聽,通話已結束。", 409);
|
|
46
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Room, type RemoteParticipant, type RemoteTrack } from "livekit-client";
|
|
2
|
+
import type { RemoteAudioHost } from "./audio.js";
|
|
3
|
+
/** Callbacks used by {@link bindRoomEvents}. */
|
|
4
|
+
export interface RoomEventHandlers {
|
|
5
|
+
onAgentAudio: (participant: RemoteParticipant, track: RemoteTrack) => void;
|
|
6
|
+
onAgentPresent: (participant: RemoteParticipant) => void;
|
|
7
|
+
onAgentLeft: () => void;
|
|
8
|
+
onDisconnected: () => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Wire LiveKit room events to call-lifecycle callbacks.
|
|
12
|
+
*/
|
|
13
|
+
export declare function bindRoomEvents(room: Room, audio: RemoteAudioHost, handlers: RoomEventHandlers): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { RoomEvent, Track, } from "livekit-client";
|
|
2
|
+
import { isAgentParticipant } from "./agent.js";
|
|
3
|
+
/**
|
|
4
|
+
* Wire LiveKit room events to call-lifecycle callbacks.
|
|
5
|
+
*/
|
|
6
|
+
export function bindRoomEvents(room, audio, handlers) {
|
|
7
|
+
room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
|
|
8
|
+
if (track.kind === Track.Kind.Audio) {
|
|
9
|
+
audio.attach(track, participant.identity);
|
|
10
|
+
if (isAgentParticipant(participant)) {
|
|
11
|
+
handlers.onAgentAudio(participant, track);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
room.on(RoomEvent.TrackUnsubscribed, (track) => {
|
|
16
|
+
audio.detachTrack(track);
|
|
17
|
+
});
|
|
18
|
+
room.on(RoomEvent.ParticipantConnected, (participant) => {
|
|
19
|
+
handlers.onAgentPresent(participant);
|
|
20
|
+
});
|
|
21
|
+
room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
|
22
|
+
if (isAgentParticipant(participant)) {
|
|
23
|
+
handlers.onAgentLeft();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
room.on(RoomEvent.Disconnected, () => {
|
|
27
|
+
handlers.onDisconnected();
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CallStatus } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Transition `from` → `to`, or throw if illegal.
|
|
4
|
+
*
|
|
5
|
+
* @returns The new status (`to`).
|
|
6
|
+
*/
|
|
7
|
+
export declare function transition(from: CallStatus, to: CallStatus): CallStatus;
|
|
8
|
+
/** Whether `status` is a terminal state. */
|
|
9
|
+
export declare function isTerminal(status: CallStatus): boolean;
|
|
10
|
+
/** Exported for unit tests. */
|
|
11
|
+
export declare const LEGAL_TRANSITIONS: Record<CallStatus, ReadonlySet<CallStatus>>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { CallEmbedError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Legal status transitions for a single call.
|
|
4
|
+
*
|
|
5
|
+
* ```
|
|
6
|
+
* idle → connecting → ringing → answered → ended
|
|
7
|
+
* ↘ ↘ ↘
|
|
8
|
+
* error error error
|
|
9
|
+
* connecting | ringing | answered → ended (hang-up / disconnect)
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* Error codes that map to the `error` status (not separate statuses):
|
|
13
|
+
* - `no_answer` — server ring timeout while `ringing` → `error`
|
|
14
|
+
* - `agent_busy` — agent answer rejected (Inbox); callers typically never see this
|
|
15
|
+
* - `no_agent_online` / `call_failed` / media failures → `error` from `connecting`/`ringing`
|
|
16
|
+
*/
|
|
17
|
+
const ALLOWED = {
|
|
18
|
+
idle: new Set(["connecting"]),
|
|
19
|
+
connecting: new Set(["ringing", "error", "ended"]),
|
|
20
|
+
ringing: new Set(["answered", "error", "ended"]),
|
|
21
|
+
answered: new Set(["ended", "error"]),
|
|
22
|
+
ended: new Set(),
|
|
23
|
+
error: new Set(),
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Transition `from` → `to`, or throw if illegal.
|
|
27
|
+
*
|
|
28
|
+
* @returns The new status (`to`).
|
|
29
|
+
*/
|
|
30
|
+
export function transition(from, to) {
|
|
31
|
+
if (from === to)
|
|
32
|
+
return to;
|
|
33
|
+
const allowed = ALLOWED[from];
|
|
34
|
+
if (!allowed.has(to)) {
|
|
35
|
+
throw new CallEmbedError("call_failed", `Illegal call status transition: ${from} → ${to}`);
|
|
36
|
+
}
|
|
37
|
+
return to;
|
|
38
|
+
}
|
|
39
|
+
/** Whether `status` is a terminal state. */
|
|
40
|
+
export function isTerminal(status) {
|
|
41
|
+
return status === "ended" || status === "error";
|
|
42
|
+
}
|
|
43
|
+
/** Exported for unit tests. */
|
|
44
|
+
export const LEGAL_TRANSITIONS = ALLOWED;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hang-up / media teardown / pagehide helpers extracted from CallSessionController
|
|
3
|
+
* so `call.ts` stays within the ~300-line maintainability budget.
|
|
4
|
+
*/
|
|
5
|
+
import type { Room } from "livekit-client";
|
|
6
|
+
import type { RemoteAudioHost } from "./audio.js";
|
|
7
|
+
import type { TypedEmitter } from "./events.js";
|
|
8
|
+
import type { CallStatus, RuntimeConfig } from "./types.js";
|
|
9
|
+
/** Minimal UI hook shape used by teardown (avoids circular import with call.ts). */
|
|
10
|
+
export interface CallUiHooksLike {
|
|
11
|
+
onStatus?: (status: CallStatus, message?: string) => void;
|
|
12
|
+
onMuteChange?: (muted: boolean) => void;
|
|
13
|
+
onTerminal?: () => void;
|
|
14
|
+
}
|
|
15
|
+
export interface TeardownHost {
|
|
16
|
+
cfg: RuntimeConfig;
|
|
17
|
+
emitter: TypedEmitter;
|
|
18
|
+
audio: RemoteAudioHost;
|
|
19
|
+
ui: CallUiHooksLike;
|
|
20
|
+
getCallId: () => string;
|
|
21
|
+
getStatus: () => CallStatus;
|
|
22
|
+
setStatusRaw: (status: CallStatus) => void;
|
|
23
|
+
getRoom: () => Room | null;
|
|
24
|
+
setRoom: (room: Room | null) => void;
|
|
25
|
+
getHangUpStarted: () => boolean;
|
|
26
|
+
setHangUpStarted: (v: boolean) => void;
|
|
27
|
+
getEndCallSent: () => boolean;
|
|
28
|
+
setEndCallSent: (v: boolean) => void;
|
|
29
|
+
getEndToken: () => string | null;
|
|
30
|
+
clearEndToken: () => void;
|
|
31
|
+
getPageHideHandler: () => (() => void) | null;
|
|
32
|
+
setPageHideHandler: (h: (() => void) | null) => void;
|
|
33
|
+
}
|
|
34
|
+
declare function setStatus(host: TeardownHost, next: CallStatus): void;
|
|
35
|
+
/**
|
|
36
|
+
* Stop mic + disconnect LiveKit. Safe to fire-and-forget — hangUp must not
|
|
37
|
+
* wait on slow peer-connection teardown (often multi-second).
|
|
38
|
+
*/
|
|
39
|
+
export declare function teardownMedia(host: TeardownHost): Promise<void>;
|
|
40
|
+
export declare function cleanupSession(host: TeardownHost): void;
|
|
41
|
+
/**
|
|
42
|
+
* End the call promptly: mark ended + notify server, tear down media in the
|
|
43
|
+
* background. Double hangUp is a no-op via hangUpStarted.
|
|
44
|
+
*/
|
|
45
|
+
export declare function hangUpSession(host: TeardownHost): Promise<void>;
|
|
46
|
+
/** @returns true when start() must stop (hang-up already in progress). */
|
|
47
|
+
export declare function finishAfterHangUp(host: TeardownHost): Promise<boolean>;
|
|
48
|
+
export declare function bindPageHide(host: TeardownHost): void;
|
|
49
|
+
export { setStatus as applyStatusTransition };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { apiEndCall, apiEndCallKeepalive } from "./api.js";
|
|
2
|
+
import { isTerminal, transition } from "./state-machine.js";
|
|
3
|
+
function setStatus(host, next) {
|
|
4
|
+
const previous = host.getStatus();
|
|
5
|
+
const status = transition(previous, next);
|
|
6
|
+
host.setStatusRaw(status);
|
|
7
|
+
if (previous !== status) {
|
|
8
|
+
host.emitter.emit("statusChange", {
|
|
9
|
+
status,
|
|
10
|
+
previousStatus: previous,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Stop mic + disconnect LiveKit. Safe to fire-and-forget — hangUp must not
|
|
16
|
+
* wait on slow peer-connection teardown (often multi-second).
|
|
17
|
+
*/
|
|
18
|
+
export async function teardownMedia(host) {
|
|
19
|
+
const room = host.getRoom();
|
|
20
|
+
host.setRoom(null);
|
|
21
|
+
if (room) {
|
|
22
|
+
try {
|
|
23
|
+
await room.localParticipant.setMicrophoneEnabled(false);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* ignore */
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
await room.disconnect();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* ignore */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
host.audio.destroy();
|
|
36
|
+
}
|
|
37
|
+
export function cleanupSession(host) {
|
|
38
|
+
const pageHideHandler = host.getPageHideHandler();
|
|
39
|
+
if (pageHideHandler && typeof window !== "undefined") {
|
|
40
|
+
window.removeEventListener("pagehide", pageHideHandler);
|
|
41
|
+
host.setPageHideHandler(null);
|
|
42
|
+
}
|
|
43
|
+
host.clearEndToken();
|
|
44
|
+
host.emitter.clear();
|
|
45
|
+
}
|
|
46
|
+
async function sendEndCallIfReady(host) {
|
|
47
|
+
if (host.getEndCallSent() || host.getCallId() === "pending")
|
|
48
|
+
return;
|
|
49
|
+
host.setEndCallSent(true);
|
|
50
|
+
const token = host.getEndToken();
|
|
51
|
+
await apiEndCall(host.cfg, host.getCallId(), token);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* End the call promptly: mark ended + notify server, tear down media in the
|
|
55
|
+
* background. Double hangUp is a no-op via hangUpStarted.
|
|
56
|
+
*/
|
|
57
|
+
export async function hangUpSession(host) {
|
|
58
|
+
if (host.getHangUpStarted())
|
|
59
|
+
return;
|
|
60
|
+
host.setHangUpStarted(true);
|
|
61
|
+
// Do not await disconnect — it can take several seconds after the server ends.
|
|
62
|
+
void teardownMedia(host).catch(() => undefined);
|
|
63
|
+
if (!isTerminal(host.getStatus())) {
|
|
64
|
+
setStatus(host, "ended");
|
|
65
|
+
host.emitter.emit("ended", { callId: host.getCallId(), status: "ended" });
|
|
66
|
+
host.ui.onStatus?.("ended");
|
|
67
|
+
}
|
|
68
|
+
await sendEndCallIfReady(host);
|
|
69
|
+
host.ui.onTerminal?.();
|
|
70
|
+
cleanupSession(host);
|
|
71
|
+
}
|
|
72
|
+
/** @returns true when start() must stop (hang-up already in progress). */
|
|
73
|
+
export async function finishAfterHangUp(host) {
|
|
74
|
+
if (!host.getHangUpStarted())
|
|
75
|
+
return false;
|
|
76
|
+
void teardownMedia(host).catch(() => undefined);
|
|
77
|
+
await sendEndCallIfReady(host);
|
|
78
|
+
if (!isTerminal(host.getStatus()))
|
|
79
|
+
host.setStatusRaw("ended");
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
export function bindPageHide(host) {
|
|
83
|
+
if (typeof window === "undefined")
|
|
84
|
+
return;
|
|
85
|
+
const pageHideHandler = () => {
|
|
86
|
+
if (host.getCallId() !== "pending" && !host.getEndCallSent()) {
|
|
87
|
+
host.setEndCallSent(true);
|
|
88
|
+
apiEndCallKeepalive(host.cfg, host.getCallId(), host.getEndToken());
|
|
89
|
+
}
|
|
90
|
+
void teardownMedia(host).catch(() => undefined);
|
|
91
|
+
if (!isTerminal(host.getStatus()) && !host.getHangUpStarted()) {
|
|
92
|
+
host.setHangUpStarted(true);
|
|
93
|
+
try {
|
|
94
|
+
setStatus(host, "ended");
|
|
95
|
+
host.emitter.emit("ended", { callId: host.getCallId(), status: "ended" });
|
|
96
|
+
host.ui.onStatus?.("ended");
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
host.setStatusRaw("ended");
|
|
100
|
+
}
|
|
101
|
+
cleanupSession(host);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
host.setPageHideHandler(pageHideHandler);
|
|
105
|
+
window.addEventListener("pagehide", pageHideHandler);
|
|
106
|
+
}
|
|
107
|
+
export { setStatus as applyStatusTransition };
|