@chanl/widget-sdk 0.2.0-canary.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/README.md +257 -0
- package/dist/auth.d.ts +26 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +36 -0
- package/dist/auth.js.map +1 -0
- package/dist/chat/chat-client.d.ts +81 -0
- package/dist/chat/chat-client.d.ts.map +1 -0
- package/dist/chat/chat-client.js +192 -0
- package/dist/chat/chat-client.js.map +1 -0
- package/dist/chat/stream-parser.d.ts +20 -0
- package/dist/chat/stream-parser.d.ts.map +1 -0
- package/dist/chat/stream-parser.js +134 -0
- package/dist/chat/stream-parser.js.map +1 -0
- package/dist/chat/widget-config.d.ts +7 -0
- package/dist/chat/widget-config.d.ts.map +1 -0
- package/dist/chat/widget-config.js +26 -0
- package/dist/chat/widget-config.js.map +1 -0
- package/dist/client.d.ts +66 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +49 -0
- package/dist/client.js.map +1 -0
- package/dist/defaults.d.ts +12 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +27 -0
- package/dist/defaults.js.map +1 -0
- package/dist/embed/loader-types.d.ts +119 -0
- package/dist/embed/loader-types.d.ts.map +1 -0
- package/dist/embed/loader-types.js +20 -0
- package/dist/embed/loader-types.js.map +1 -0
- package/dist/embed/loader.d.ts +101 -0
- package/dist/embed/loader.d.ts.map +1 -0
- package/dist/embed/loader.js +439 -0
- package/dist/embed/loader.js.map +1 -0
- package/dist/embed/v1.global.js +5 -0
- package/dist/embed/v1.global.js.map +1 -0
- package/dist/events.d.ts +10 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +25 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +14 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +3 -0
- package/dist/logger.js.map +1 -0
- package/dist/next/index.d.ts +58 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +83 -0
- package/dist/next/index.js.map +1 -0
- package/dist/react/index.d.ts +16 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +20 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/types.d.ts +27 -0
- package/dist/react/types.d.ts.map +1 -0
- package/dist/react/types.js +8 -0
- package/dist/react/types.js.map +1 -0
- package/dist/react/use-chanl.d.ts +27 -0
- package/dist/react/use-chanl.d.ts.map +1 -0
- package/dist/react/use-chanl.js +57 -0
- package/dist/react/use-chanl.js.map +1 -0
- package/dist/react/use-chat.d.ts +32 -0
- package/dist/react/use-chat.d.ts.map +1 -0
- package/dist/react/use-chat.js +224 -0
- package/dist/react/use-chat.js.map +1 -0
- package/dist/react/use-voice.d.ts +37 -0
- package/dist/react/use-voice.d.ts.map +1 -0
- package/dist/react/use-voice.js +268 -0
- package/dist/react/use-voice.js.map +1 -0
- package/dist/react/widget.d.ts +43 -0
- package/dist/react/widget.d.ts.map +1 -0
- package/dist/react/widget.js +188 -0
- package/dist/react/widget.js.map +1 -0
- package/dist/storage/session-storage.d.ts +48 -0
- package/dist/storage/session-storage.d.ts.map +1 -0
- package/dist/storage/session-storage.js +84 -0
- package/dist/storage/session-storage.js.map +1 -0
- package/dist/types.d.ts +140 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/dist/voice/audio-recorder.d.ts +43 -0
- package/dist/voice/audio-recorder.d.ts.map +1 -0
- package/dist/voice/audio-recorder.js +127 -0
- package/dist/voice/audio-recorder.js.map +1 -0
- package/dist/voice/index.d.ts +13 -0
- package/dist/voice/index.d.ts.map +1 -0
- package/dist/voice/index.js +16 -0
- package/dist/voice/index.js.map +1 -0
- package/dist/voice/mock-mode.d.ts +93 -0
- package/dist/voice/mock-mode.d.ts.map +1 -0
- package/dist/voice/mock-mode.js +375 -0
- package/dist/voice/mock-mode.js.map +1 -0
- package/dist/voice/transports/index.d.ts +5 -0
- package/dist/voice/transports/index.d.ts.map +1 -0
- package/dist/voice/transports/index.js +10 -0
- package/dist/voice/transports/index.js.map +1 -0
- package/dist/voice/transports/transport.d.ts +70 -0
- package/dist/voice/transports/transport.d.ts.map +1 -0
- package/dist/voice/transports/transport.js +12 -0
- package/dist/voice/transports/transport.js.map +1 -0
- package/dist/voice/transports/vapi.d.ts +147 -0
- package/dist/voice/transports/vapi.d.ts.map +1 -0
- package/dist/voice/transports/vapi.js +337 -0
- package/dist/voice/transports/vapi.js.map +1 -0
- package/dist/voice/transports/webrtc.d.ts +58 -0
- package/dist/voice/transports/webrtc.d.ts.map +1 -0
- package/dist/voice/transports/webrtc.js +318 -0
- package/dist/voice/transports/webrtc.js.map +1 -0
- package/dist/voice/transports/websocket.d.ts +39 -0
- package/dist/voice/transports/websocket.d.ts.map +1 -0
- package/dist/voice/transports/websocket.js +280 -0
- package/dist/voice/transports/websocket.js.map +1 -0
- package/dist/voice/types.d.ts +323 -0
- package/dist/voice/types.d.ts.map +1 -0
- package/dist/voice/types.js +41 -0
- package/dist/voice/types.js.map +1 -0
- package/dist/voice/utils.d.ts +22 -0
- package/dist/voice/utils.d.ts.map +1 -0
- package/dist/voice/utils.js +44 -0
- package/dist/voice/utils.js.map +1 -0
- package/dist/voice/voice-client.d.ts +231 -0
- package/dist/voice/voice-client.d.ts.map +1 -0
- package/dist/voice/voice-client.js +1187 -0
- package/dist/voice/voice-client.js.map +1 -0
- package/package.json +91 -0
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VoiceClient = void 0;
|
|
4
|
+
const eventemitter3_1 = require("eventemitter3");
|
|
5
|
+
const types_1 = require("./types");
|
|
6
|
+
const websocket_1 = require("./transports/websocket");
|
|
7
|
+
const webrtc_1 = require("./transports/webrtc");
|
|
8
|
+
const vapi_1 = require("./transports/vapi");
|
|
9
|
+
class VoiceClient extends eventemitter3_1.EventEmitter {
|
|
10
|
+
constructor(publicKey, config = {}) {
|
|
11
|
+
super();
|
|
12
|
+
this.log = {
|
|
13
|
+
log: console.debug.bind(console),
|
|
14
|
+
info: console.info.bind(console),
|
|
15
|
+
debug: console.debug.bind(console),
|
|
16
|
+
warn: console.warn.bind(console),
|
|
17
|
+
error: console.error.bind(console),
|
|
18
|
+
};
|
|
19
|
+
// =========================
|
|
20
|
+
// 2. Connection State & Status
|
|
21
|
+
// =========================
|
|
22
|
+
this.state = types_1.AudialState.Idle;
|
|
23
|
+
/**
|
|
24
|
+
* Active transport — WebSocket or WebRTC.
|
|
25
|
+
* Created on start(), destroyed on stop().
|
|
26
|
+
*/
|
|
27
|
+
this.transport = null;
|
|
28
|
+
/**
|
|
29
|
+
* Which transport is actually in use for the current call.
|
|
30
|
+
*/
|
|
31
|
+
this.activeTransportType = null;
|
|
32
|
+
/**
|
|
33
|
+
* Room connection state
|
|
34
|
+
*/
|
|
35
|
+
this.roomState = null;
|
|
36
|
+
/**
|
|
37
|
+
* The current node ID
|
|
38
|
+
*/
|
|
39
|
+
this.currentNodeId = null;
|
|
40
|
+
// =========================
|
|
41
|
+
// 3. Audio State (tracked by Audial, delegated to transport)
|
|
42
|
+
// =========================
|
|
43
|
+
this.audioDeafened = false;
|
|
44
|
+
this.micMuted = false;
|
|
45
|
+
// =========================
|
|
46
|
+
// 4. Retry & Reconnection Logic
|
|
47
|
+
// =========================
|
|
48
|
+
this.retryPolicy = { maxRetries: 3, backoffMs: 1000 };
|
|
49
|
+
this.retryCount = 0;
|
|
50
|
+
this.reconnectTimer = null;
|
|
51
|
+
this.connectionTimeoutTimer = null;
|
|
52
|
+
// Per-call-intent Idempotency-Key (IETF draft + Stripe pattern).
|
|
53
|
+
// Generated on user-initiated start, reused across retries within the
|
|
54
|
+
// same intent, cleared on successful connection (call is now
|
|
55
|
+
// "consumed") or on stop (next start is a new intent).
|
|
56
|
+
// Persisted to sessionStorage so a reload in the same tab during a
|
|
57
|
+
// pending connect reuses the same key → server returns the existing
|
|
58
|
+
// queued interaction instead of leaking a new one.
|
|
59
|
+
this.idempotencyKey = null;
|
|
60
|
+
// =========================
|
|
61
|
+
// 6. Chat Session State
|
|
62
|
+
// =========================
|
|
63
|
+
this.chatState = types_1.ChatState.Idle;
|
|
64
|
+
this.chatSession = null;
|
|
65
|
+
this.chatMessages = [];
|
|
66
|
+
this.publicKey = publicKey;
|
|
67
|
+
this.config = {
|
|
68
|
+
baseUrl: this.getBaseUrl(),
|
|
69
|
+
debug: true, // TODO URGENT: TESTING ONLY
|
|
70
|
+
initialMuted: false,
|
|
71
|
+
// autoReconnect defaults to FALSE. The old default of true produced
|
|
72
|
+
// zombie interactions: every retry calls start() → createRoom() which
|
|
73
|
+
// hits POST /interactions/websocket and creates a brand-new
|
|
74
|
+
// interaction. A transient WebRTC blip could leave 10+ "queued"
|
|
75
|
+
// interactions in the DB, none of which ever connected or produced
|
|
76
|
+
// a transcript. Users opting into reconnect must set it explicitly.
|
|
77
|
+
autoReconnect: false,
|
|
78
|
+
...config
|
|
79
|
+
};
|
|
80
|
+
// Validate: authToken requires workspaceId
|
|
81
|
+
if (this.config.authToken && !this.config.workspaceId) {
|
|
82
|
+
throw new Error('Audial: workspaceId is required when using authToken');
|
|
83
|
+
}
|
|
84
|
+
if (this.config.debug) {
|
|
85
|
+
const authMode = this.config.authToken ? 'jwt' : 'publicKey';
|
|
86
|
+
console.debug("🎤 Audial SDK initialized:", {
|
|
87
|
+
authMode,
|
|
88
|
+
publicKey: this.publicKey ? this.publicKey.substring(0, 10) + "..." : '(none)',
|
|
89
|
+
baseUrl: this.config.baseUrl,
|
|
90
|
+
transport: this.config.transport ?? 'auto',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
this.audioDeafened = this.config.initialMuted;
|
|
94
|
+
}
|
|
95
|
+
// ── Auth helpers (unchanged) ───────────────────────────────────────────
|
|
96
|
+
getAuthHeaders() {
|
|
97
|
+
if (this.config.authToken) {
|
|
98
|
+
const headers = {
|
|
99
|
+
'Authorization': `Bearer ${this.config.authToken}`,
|
|
100
|
+
};
|
|
101
|
+
if (this.config.workspaceId) {
|
|
102
|
+
headers['x-workspace-id'] = this.config.workspaceId;
|
|
103
|
+
}
|
|
104
|
+
return headers;
|
|
105
|
+
}
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
applyPublicKeyAuth(url) {
|
|
109
|
+
if (!this.config.authToken && this.publicKey) {
|
|
110
|
+
url.searchParams.set('publicKey', this.publicKey);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
start(agentIdOrPayload, overrides) {
|
|
114
|
+
if (this.state !== types_1.AudialState.Idle) {
|
|
115
|
+
throw new Error("Audial: call already active");
|
|
116
|
+
}
|
|
117
|
+
this.currentNodeId = null;
|
|
118
|
+
let payload;
|
|
119
|
+
if (typeof agentIdOrPayload === 'string') {
|
|
120
|
+
payload = { agentId: agentIdOrPayload };
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
payload = agentIdOrPayload;
|
|
124
|
+
}
|
|
125
|
+
this.state = types_1.AudialState.CreatingRoom;
|
|
126
|
+
this.retryCount = 0;
|
|
127
|
+
this.lastCallPayload = payload;
|
|
128
|
+
this.lastOverrides = overrides;
|
|
129
|
+
// Mint or restore the Idempotency-Key for this call intent.
|
|
130
|
+
// Same key is reused on every retry path inside this call attempt —
|
|
131
|
+
// the server dedups against it, so a retry loop can't leak zombie
|
|
132
|
+
// interactions. Cleared in stop() and on successful connection.
|
|
133
|
+
if (!this.idempotencyKey) {
|
|
134
|
+
this.idempotencyKey = this.restoreOrMintIdempotencyKey();
|
|
135
|
+
}
|
|
136
|
+
// Connection timeout — if 'ready' not received within limit, emit error
|
|
137
|
+
if (this.connectionTimeoutTimer) {
|
|
138
|
+
clearTimeout(this.connectionTimeoutTimer);
|
|
139
|
+
}
|
|
140
|
+
const timeoutMs = this.config.connectionTimeoutMs ?? 30000;
|
|
141
|
+
this.connectionTimeoutTimer = setTimeout(() => {
|
|
142
|
+
this.connectionTimeoutTimer = null;
|
|
143
|
+
this.emit('error', new Error('Connection timeout - voice service did not respond'));
|
|
144
|
+
this.stop(4000, 'connection-timeout');
|
|
145
|
+
}, timeoutMs);
|
|
146
|
+
if (this.config.debug) {
|
|
147
|
+
console.debug('🎤 Starting call:', { payload, overrides, timeoutMs });
|
|
148
|
+
}
|
|
149
|
+
this.createRoomAndConnect(payload, overrides);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Minimal description of a real-time session result returned by a
|
|
153
|
+
* `createSession` function. Mirrors the shape of `RealtimeSessionResult`
|
|
154
|
+
* from `@chanl/sdk` so consumers can pass `sdk.realtime.createSession`'s
|
|
155
|
+
* return value (after unwrapping `{ data }`) directly, without importing
|
|
156
|
+
* that type here (which would create a cross-package dependency).
|
|
157
|
+
*
|
|
158
|
+
* Only the fields needed for wiring are required; everything else is optional
|
|
159
|
+
* so this interface is forward-compatible as new credential types are added.
|
|
160
|
+
*/
|
|
161
|
+
/**
|
|
162
|
+
* High-level orchestration method: fetch a provider session via an injected
|
|
163
|
+
* `createSession` function, then wire the credential into `connectProviderSession`.
|
|
164
|
+
*
|
|
165
|
+
* This keeps widget-sdk free of a dependency on `@chanl/sdk`. The consumer
|
|
166
|
+
* (e.g. chanl-admin) injects `sdk.realtime.createSession` as a plain function.
|
|
167
|
+
*
|
|
168
|
+
* Phase 1 only supports `credentialType === 'join-url'` (VAPI Daily rooms).
|
|
169
|
+
* Attempting to start a session with `token` or `ws-url` credentials will
|
|
170
|
+
* throw immediately with a clear "not yet supported" message.
|
|
171
|
+
*
|
|
172
|
+
* @param createSession - Async function that, given an agentId, resolves with
|
|
173
|
+
* a session result containing `{ provider, credentialType, joinUrl?, ... }`.
|
|
174
|
+
* @param agentId - The agent ID to pass to `createSession`.
|
|
175
|
+
* @param opts.overrides - Optional assistant overrides forwarded to the transport.
|
|
176
|
+
*
|
|
177
|
+
* @throws Error if credentialType is not 'join-url' (Phase 1 only).
|
|
178
|
+
* @throws Error if a call is already active.
|
|
179
|
+
* @throws Error (async, via 'error' event) if the transport connect fails.
|
|
180
|
+
*/
|
|
181
|
+
async startProviderSession(createSession, agentId, opts) {
|
|
182
|
+
if (this.state !== types_1.AudialState.Idle) {
|
|
183
|
+
throw new Error('Audial: call already active');
|
|
184
|
+
}
|
|
185
|
+
if (this.config.debug) {
|
|
186
|
+
console.debug('🎤 startProviderSession: fetching session for agent', agentId);
|
|
187
|
+
}
|
|
188
|
+
const session = await createSession(agentId);
|
|
189
|
+
if (session.credentialType !== 'join-url') {
|
|
190
|
+
throw new Error(`Audial: credentialType "${session.credentialType}" is not yet supported in Phase 1 — only 'join-url' is implemented`);
|
|
191
|
+
}
|
|
192
|
+
if (!session.joinUrl) {
|
|
193
|
+
throw new Error('Audial: createSession returned credentialType "join-url" but joinUrl is missing');
|
|
194
|
+
}
|
|
195
|
+
// Delegate to connectProviderSession which manages state transitions,
|
|
196
|
+
// timeout, and transport instantiation — no duplication.
|
|
197
|
+
this.connectProviderSession({
|
|
198
|
+
provider: session.provider,
|
|
199
|
+
joinUrl: session.joinUrl,
|
|
200
|
+
overrides: opts?.overrides,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Connect to a provider-managed voice session via a direct join URL.
|
|
205
|
+
*
|
|
206
|
+
* Unlike `start()`, this method does NOT call POST /interactions/websocket
|
|
207
|
+
* to create a room. Instead it instantiates the appropriate provider
|
|
208
|
+
* transport (currently only 'vapi' is supported) and connects directly
|
|
209
|
+
* to the provider's session URL (e.g. a VAPI Daily.co web-call URL).
|
|
210
|
+
*
|
|
211
|
+
* The public event API (ready, closed, transcript, speech-start, etc.)
|
|
212
|
+
* is identical to the room-based flow.
|
|
213
|
+
*
|
|
214
|
+
* @param params.provider - Provider identifier. Currently supports 'vapi'.
|
|
215
|
+
* @param params.joinUrl - The provider's session join URL.
|
|
216
|
+
* @param params.overrides - Optional assistant overrides forwarded to the transport.
|
|
217
|
+
*
|
|
218
|
+
* @throws Error if provider is unsupported or if already in an active call.
|
|
219
|
+
*/
|
|
220
|
+
connectProviderSession({ provider, joinUrl, overrides, }) {
|
|
221
|
+
if (this.state !== types_1.AudialState.Idle) {
|
|
222
|
+
throw new Error('Audial: call already active');
|
|
223
|
+
}
|
|
224
|
+
if (provider !== 'vapi') {
|
|
225
|
+
throw new Error(`Audial: unsupported provider "${provider}" — only 'vapi' is supported`);
|
|
226
|
+
}
|
|
227
|
+
if (!joinUrl) {
|
|
228
|
+
throw new Error('Audial: joinUrl is required for provider sessions');
|
|
229
|
+
}
|
|
230
|
+
this.state = types_1.AudialState.Connecting;
|
|
231
|
+
this.currentNodeId = null;
|
|
232
|
+
// Connection timeout — mirrors the room-based flow
|
|
233
|
+
if (this.connectionTimeoutTimer) {
|
|
234
|
+
clearTimeout(this.connectionTimeoutTimer);
|
|
235
|
+
}
|
|
236
|
+
const timeoutMs = this.config.connectionTimeoutMs ?? 30000;
|
|
237
|
+
this.connectionTimeoutTimer = setTimeout(() => {
|
|
238
|
+
this.connectionTimeoutTimer = null;
|
|
239
|
+
this.emit('error', new Error('Connection timeout - provider session did not respond'));
|
|
240
|
+
this.stop(4000, 'connection-timeout');
|
|
241
|
+
}, timeoutMs);
|
|
242
|
+
if (this.config.debug) {
|
|
243
|
+
console.debug('🎤 Connecting provider session:', { provider, joinUrl, timeoutMs });
|
|
244
|
+
}
|
|
245
|
+
this._connectVapiProviderSession(joinUrl, overrides);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Internal async implementation of connectProviderSession.
|
|
249
|
+
* Separated so the public method can remain synchronous (matching start()).
|
|
250
|
+
*/
|
|
251
|
+
async _connectVapiProviderSession(joinUrl, overrides) {
|
|
252
|
+
try {
|
|
253
|
+
const callbacks = this.createTransportCallbacks();
|
|
254
|
+
this.transport = new vapi_1.VapiTransport(callbacks);
|
|
255
|
+
this.activeTransportType = 'vapi';
|
|
256
|
+
if (this.config.debug) {
|
|
257
|
+
console.debug('🚀 Using vapi transport for provider session');
|
|
258
|
+
}
|
|
259
|
+
await this.transport.connect({
|
|
260
|
+
joinUrl,
|
|
261
|
+
overrides,
|
|
262
|
+
debug: this.config.debug,
|
|
263
|
+
// roomState is intentionally omitted — provider sessions bypass room creation
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
this.handleConnectionError(error instanceof Error ? error : new Error(String(error)));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Stop the current call
|
|
272
|
+
*/
|
|
273
|
+
stop(code = 1000, reason = "client-stop") {
|
|
274
|
+
if (this.config.debug) {
|
|
275
|
+
console.debug("🛑 Stopping call:", { code, reason });
|
|
276
|
+
}
|
|
277
|
+
// Clear all timers
|
|
278
|
+
if (this.reconnectTimer) {
|
|
279
|
+
clearTimeout(this.reconnectTimer);
|
|
280
|
+
this.reconnectTimer = null;
|
|
281
|
+
}
|
|
282
|
+
if (this.connectionTimeoutTimer) {
|
|
283
|
+
clearTimeout(this.connectionTimeoutTimer);
|
|
284
|
+
this.connectionTimeoutTimer = null;
|
|
285
|
+
}
|
|
286
|
+
// Stop ends this call intent — clear the Idempotency-Key so the next
|
|
287
|
+
// start() mints a fresh one (new intent → new interaction).
|
|
288
|
+
this.clearIdempotencyKey();
|
|
289
|
+
this.roomState = null;
|
|
290
|
+
if (this.transport) {
|
|
291
|
+
this.transport.disconnect(code, reason);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
this.emit("closed", code, reason);
|
|
295
|
+
this.cleanup();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Send a message to the server
|
|
300
|
+
*/
|
|
301
|
+
send(message, shouldLog = true) {
|
|
302
|
+
if (!this.transport || !this.transport.isConnected()) {
|
|
303
|
+
if (this.config.debug) {
|
|
304
|
+
console.warn("⚠️ Cannot send message (type: " + message.type + "): not connected");
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
// Block unsupported message types
|
|
309
|
+
if (message.type === "assistant-overrides" ||
|
|
310
|
+
message.type === "set-context" ||
|
|
311
|
+
message.type === "clear-context") {
|
|
312
|
+
if (this.config.debug) {
|
|
313
|
+
console.warn(`⚠️ Message type '${message.type}' not supported by current backend`);
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
this.transport.sendMessage(message);
|
|
319
|
+
if (this.config.debug && shouldLog) {
|
|
320
|
+
console.debug("📤 Sent message:", message);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
if (this.config.debug) {
|
|
325
|
+
console.error('❌ Error sending message:', error);
|
|
326
|
+
}
|
|
327
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// ── Audio controls (delegated to transport) ────────────────────────────
|
|
331
|
+
deafen() {
|
|
332
|
+
if (this.audioDeafened)
|
|
333
|
+
return;
|
|
334
|
+
this.audioDeafened = true;
|
|
335
|
+
this.transport?.deafen();
|
|
336
|
+
this.emit("audio-deafened", true);
|
|
337
|
+
}
|
|
338
|
+
undeafen() {
|
|
339
|
+
if (!this.audioDeafened)
|
|
340
|
+
return;
|
|
341
|
+
this.audioDeafened = false;
|
|
342
|
+
this.transport?.undeafen();
|
|
343
|
+
this.emit("audio-undeafened", false);
|
|
344
|
+
}
|
|
345
|
+
toggleDeafen() {
|
|
346
|
+
this.audioDeafened ? this.undeafen() : this.deafen();
|
|
347
|
+
}
|
|
348
|
+
setVolume(volume) {
|
|
349
|
+
this.transport?.setVolume(volume);
|
|
350
|
+
}
|
|
351
|
+
muteMic() {
|
|
352
|
+
if (this.micMuted)
|
|
353
|
+
return;
|
|
354
|
+
this.micMuted = true;
|
|
355
|
+
this.emit("mic-muted");
|
|
356
|
+
this.transport?.muteMic();
|
|
357
|
+
}
|
|
358
|
+
async unmuteMic() {
|
|
359
|
+
if (!this.micMuted)
|
|
360
|
+
return;
|
|
361
|
+
this.micMuted = false;
|
|
362
|
+
this.emit("mic-unmuted");
|
|
363
|
+
try {
|
|
364
|
+
await this.transport?.unmuteMic();
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
toggleMicMute() {
|
|
371
|
+
this.micMuted ? this.unmuteMic() : this.muteMic();
|
|
372
|
+
}
|
|
373
|
+
// ── Connection management ──────────────────────────────────────────────
|
|
374
|
+
/**
|
|
375
|
+
* Create room and connect via the appropriate transport.
|
|
376
|
+
*/
|
|
377
|
+
async createRoomAndConnect(payload, overrides) {
|
|
378
|
+
try {
|
|
379
|
+
if (this.config.debug) {
|
|
380
|
+
console.debug('🏠 Creating websocket room...');
|
|
381
|
+
}
|
|
382
|
+
const room = await this.createRoom(payload);
|
|
383
|
+
this.roomState = {
|
|
384
|
+
roomId: room.id,
|
|
385
|
+
// The WebRTC /offer relay resolves the room by interactionId, not the
|
|
386
|
+
// room's own id. Prefer interactionId; fall back to id for safety.
|
|
387
|
+
interactionId: room.interactionId ?? room.id,
|
|
388
|
+
status: room.status,
|
|
389
|
+
websocketUrl: room.websocketUrl,
|
|
390
|
+
connectionParameters: room.connectionParameters,
|
|
391
|
+
};
|
|
392
|
+
if (room.status === types_1.WebsocketRoomStatus.Failed || room.status === types_1.WebsocketRoomStatus.Expired) {
|
|
393
|
+
throw new Error(`Room ${room.status}: ${room.id}`);
|
|
394
|
+
}
|
|
395
|
+
if (!room.websocketUrl) {
|
|
396
|
+
throw new Error('Room created but no websocket URL available');
|
|
397
|
+
}
|
|
398
|
+
this.state = types_1.AudialState.Connecting;
|
|
399
|
+
// Determine and create transport
|
|
400
|
+
const transportType = this.resolveTransportType();
|
|
401
|
+
const callbacks = this.createTransportCallbacks();
|
|
402
|
+
if (transportType === 'webrtc') {
|
|
403
|
+
const signalingUrl = this.resolveSignalingUrl();
|
|
404
|
+
this.transport = new webrtc_1.WebRTCTransport(callbacks, signalingUrl);
|
|
405
|
+
this.activeTransportType = 'webrtc';
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
this.transport = new websocket_1.WebSocketTransport(callbacks);
|
|
409
|
+
this.activeTransportType = 'websocket';
|
|
410
|
+
}
|
|
411
|
+
if (this.config.debug) {
|
|
412
|
+
console.debug(`🚀 Using ${this.activeTransportType} transport`);
|
|
413
|
+
}
|
|
414
|
+
// Connect transport
|
|
415
|
+
try {
|
|
416
|
+
await this.transport.connect({
|
|
417
|
+
roomState: this.roomState,
|
|
418
|
+
overrides,
|
|
419
|
+
debug: this.config.debug,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
catch (transportError) {
|
|
423
|
+
// WebRTC failed — try falling back to WebSocket
|
|
424
|
+
if (this.activeTransportType === 'webrtc') {
|
|
425
|
+
if (this.config.debug) {
|
|
426
|
+
console.warn('⚠️ WebRTC failed, falling back to WebSocket:', transportError);
|
|
427
|
+
}
|
|
428
|
+
this.transport.destroy();
|
|
429
|
+
this.transport = new websocket_1.WebSocketTransport(callbacks);
|
|
430
|
+
this.activeTransportType = 'websocket';
|
|
431
|
+
await this.transport.connect({
|
|
432
|
+
roomState: this.roomState,
|
|
433
|
+
overrides,
|
|
434
|
+
debug: this.config.debug,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
throw transportError;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
this.roomState = null;
|
|
444
|
+
this.handleConnectionError(error instanceof Error ? error : new Error(String(error)));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Determine which transport to use based on config + browser capabilities.
|
|
449
|
+
*
|
|
450
|
+
* Default is 'auto': uses WebRTC if browser supports RTCPeerConnection,
|
|
451
|
+
* otherwise falls back to WebSocket. Signaling goes through the platform's
|
|
452
|
+
* /voice-ws/ proxy — no separate bot URL needed.
|
|
453
|
+
*/
|
|
454
|
+
resolveTransportType() {
|
|
455
|
+
const requested = this.config.transport;
|
|
456
|
+
if (requested === 'websocket')
|
|
457
|
+
return 'websocket';
|
|
458
|
+
if (requested === 'webrtc')
|
|
459
|
+
return 'webrtc';
|
|
460
|
+
// Auto-detect: use WebRTC if browser supports it
|
|
461
|
+
if (typeof RTCPeerConnection !== 'undefined') {
|
|
462
|
+
return 'webrtc';
|
|
463
|
+
}
|
|
464
|
+
return 'websocket';
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Get the WebRTC signaling relay URL.
|
|
468
|
+
*
|
|
469
|
+
* Points to the platform's bot provider endpoint which relays signaling
|
|
470
|
+
* to the voice-bot. Same URL in dev, staging, prod — no separate bot URL.
|
|
471
|
+
*
|
|
472
|
+
* POST {url}/offer/{interactionId} → platform → voice-bot /offer
|
|
473
|
+
* POST {url}/candidates → platform → voice-bot /candidates
|
|
474
|
+
*/
|
|
475
|
+
resolveSignalingUrl() {
|
|
476
|
+
// Explicit override (testing, direct bot access)
|
|
477
|
+
const explicit = this.config.webrtcSignalingUrl;
|
|
478
|
+
if (explicit)
|
|
479
|
+
return explicit;
|
|
480
|
+
// Default: platform API signaling relay
|
|
481
|
+
return `${this.getHttpUrl()}/api/v1/bot/call/providers/webrtc`;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Create the callbacks object that wires transport events to Audial.
|
|
485
|
+
*/
|
|
486
|
+
createTransportCallbacks() {
|
|
487
|
+
return {
|
|
488
|
+
onReady: () => {
|
|
489
|
+
// Cancel connection timeout
|
|
490
|
+
if (this.connectionTimeoutTimer) {
|
|
491
|
+
clearTimeout(this.connectionTimeoutTimer);
|
|
492
|
+
this.connectionTimeoutTimer = null;
|
|
493
|
+
}
|
|
494
|
+
// The call successfully connected — this intent is now
|
|
495
|
+
// "consumed." Any subsequent start() should create a new
|
|
496
|
+
// interaction, so clear the Idempotency-Key.
|
|
497
|
+
this.clearIdempotencyKey();
|
|
498
|
+
this.state = types_1.AudialState.Active;
|
|
499
|
+
this.emit('ready');
|
|
500
|
+
},
|
|
501
|
+
onMessage: (msg) => {
|
|
502
|
+
this.handleTransportMessage(msg);
|
|
503
|
+
},
|
|
504
|
+
onError: (error) => {
|
|
505
|
+
if (this.config.debug) {
|
|
506
|
+
console.error('❌ Transport error:', error);
|
|
507
|
+
}
|
|
508
|
+
this.emit('error', error);
|
|
509
|
+
},
|
|
510
|
+
onClose: (code, reason) => {
|
|
511
|
+
if (this.config.debug) {
|
|
512
|
+
console.debug('🔌 Transport closed:', { code, reason });
|
|
513
|
+
}
|
|
514
|
+
this.emit('closed', code, reason);
|
|
515
|
+
this.cleanup();
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Handle messages from the transport. All event routing happens here.
|
|
521
|
+
* Audio handling is done internally by the transport — we only get
|
|
522
|
+
* the message for event emission purposes.
|
|
523
|
+
*/
|
|
524
|
+
handleTransportMessage(msg) {
|
|
525
|
+
// Emit generic 'message' event for all types (hook uses this for config_loaded)
|
|
526
|
+
this.emit('message', msg);
|
|
527
|
+
switch (msg.type) {
|
|
528
|
+
case 'ready': {
|
|
529
|
+
// WebSocket sends {type:"ready"} explicitly — trigger ready logic here.
|
|
530
|
+
// WebRTC fires onReady from dc.onopen (no ready message).
|
|
531
|
+
// Safe to call multiple times — timeout clear is idempotent.
|
|
532
|
+
if (this.connectionTimeoutTimer) {
|
|
533
|
+
clearTimeout(this.connectionTimeoutTimer);
|
|
534
|
+
this.connectionTimeoutTimer = null;
|
|
535
|
+
}
|
|
536
|
+
if (this.state !== types_1.AudialState.Active) {
|
|
537
|
+
this.state = types_1.AudialState.Active;
|
|
538
|
+
this.emit('ready');
|
|
539
|
+
}
|
|
540
|
+
if (this.lastOverrides && this.config.debug) {
|
|
541
|
+
console.warn('⚠️ Assistant overrides not supported by current backend');
|
|
542
|
+
}
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
case 'speech-start': {
|
|
546
|
+
// Event name matches the declared AudialEventMap key (kebab-case).
|
|
547
|
+
// Previously emitted 'speechStart' (camelCase) via `as any`, which
|
|
548
|
+
// meant use-voice subscribers (which listen to 'speech-start') never
|
|
549
|
+
// fired — isAgentSpeaking stayed false for the entire call. Fixed
|
|
550
|
+
// while adding hook tests that caught the mismatch.
|
|
551
|
+
this.emit('speech-start');
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
case 'speech-end': {
|
|
555
|
+
this.emit('speech-end');
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
case 'call-end': {
|
|
559
|
+
if (this.config.debug) {
|
|
560
|
+
console.debug("Message call-end received from server");
|
|
561
|
+
}
|
|
562
|
+
this.emit('closed', 1000, 'server-end');
|
|
563
|
+
this.stop();
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
case 'error': {
|
|
567
|
+
this.emit('error', new Error(msg?.message));
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
case 'transcript': {
|
|
571
|
+
this.emit('transcript', msg);
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
case 'audio': {
|
|
575
|
+
// Audio is handled internally by WebSocketTransport — no action needed
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
case 'node-update': {
|
|
579
|
+
if (this.config.debug) {
|
|
580
|
+
console.debug("[Audial] Node update:", msg.node_id);
|
|
581
|
+
}
|
|
582
|
+
this.currentNodeId = msg.node_id;
|
|
583
|
+
this.emit('node-update', this.currentNodeId);
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
case 'interruption': {
|
|
587
|
+
if (this.config.debug) {
|
|
588
|
+
console.debug("[Audial] Interruption received - clearing audio buffer");
|
|
589
|
+
}
|
|
590
|
+
this.transport?.clearAudioBuffer();
|
|
591
|
+
this.emit('interruption');
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// ── Connection error + retry ──────────────────────────────────────────
|
|
597
|
+
handleConnectionError(error) {
|
|
598
|
+
if (this.config.debug) {
|
|
599
|
+
console.error('❌ Connection error:', error);
|
|
600
|
+
}
|
|
601
|
+
this.state = types_1.AudialState.Idle;
|
|
602
|
+
this.emit('error', error);
|
|
603
|
+
if (this.config.autoReconnect && this.retryCount < this.retryPolicy.maxRetries) {
|
|
604
|
+
this.retryCount++;
|
|
605
|
+
const delay = this.retryPolicy.backoffMs * Math.pow(2, this.retryCount - 1);
|
|
606
|
+
if (this.config.debug) {
|
|
607
|
+
console.debug(`🔄 Retrying connection in ${delay}ms (attempt ${this.retryCount}/${this.retryPolicy.maxRetries})`);
|
|
608
|
+
}
|
|
609
|
+
// If we already have a room from the initial createRoom(), reuse it on
|
|
610
|
+
// retry — only the TRANSPORT connection failed, not the room. Calling
|
|
611
|
+
// start() again would POST /interactions/websocket and leak a new
|
|
612
|
+
// "queued" interaction per retry. This was the zombie-interaction bug.
|
|
613
|
+
const roomToReuse = this.roomState;
|
|
614
|
+
this.reconnectTimer = setTimeout(() => {
|
|
615
|
+
if (roomToReuse) {
|
|
616
|
+
this.retryTransportOnly(roomToReuse);
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
// No room yet (createRoom itself failed) — full start is safe.
|
|
620
|
+
this.start(this.lastCallPayload, this.lastOverrides);
|
|
621
|
+
}
|
|
622
|
+
}, delay);
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
this.cleanup();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Reconnect the transport layer without re-creating the platform room.
|
|
630
|
+
* Used by the retry path to avoid leaking a new "queued" interaction per
|
|
631
|
+
* attempt. The existing roomState is preserved; only a fresh transport
|
|
632
|
+
* is constructed and connected to the same room.
|
|
633
|
+
*/
|
|
634
|
+
async retryTransportOnly(roomState) {
|
|
635
|
+
try {
|
|
636
|
+
if (this.transport) {
|
|
637
|
+
try {
|
|
638
|
+
this.transport.destroy();
|
|
639
|
+
}
|
|
640
|
+
catch { /* ignore */ }
|
|
641
|
+
this.transport = null;
|
|
642
|
+
}
|
|
643
|
+
this.state = types_1.AudialState.Connecting;
|
|
644
|
+
this.roomState = roomState;
|
|
645
|
+
const transportType = this.resolveTransportType();
|
|
646
|
+
const callbacks = this.createTransportCallbacks();
|
|
647
|
+
if (transportType === 'webrtc') {
|
|
648
|
+
const signalingUrl = this.resolveSignalingUrl();
|
|
649
|
+
this.transport = new webrtc_1.WebRTCTransport(callbacks, signalingUrl);
|
|
650
|
+
this.activeTransportType = 'webrtc';
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
this.transport = new websocket_1.WebSocketTransport(callbacks);
|
|
654
|
+
this.activeTransportType = 'websocket';
|
|
655
|
+
}
|
|
656
|
+
await this.transport.connect({
|
|
657
|
+
roomState,
|
|
658
|
+
overrides: this.lastOverrides,
|
|
659
|
+
debug: this.config.debug,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
this.handleConnectionError(error instanceof Error ? error : new Error(String(error)));
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
reconnect() {
|
|
667
|
+
if (this.lastCallPayload) {
|
|
668
|
+
if (this.config.debug) {
|
|
669
|
+
console.debug('🔄 Reconnecting...');
|
|
670
|
+
}
|
|
671
|
+
this.stop();
|
|
672
|
+
setTimeout(() => {
|
|
673
|
+
this.start(this.lastCallPayload, this.lastOverrides);
|
|
674
|
+
}, 100);
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
throw new Error('Audial: no previous call to reconnect to');
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// ── Connection status ─────────────────────────────────────────────────
|
|
681
|
+
getConnectionStatus() {
|
|
682
|
+
const baseStatus = {
|
|
683
|
+
connected: this.transport?.isConnected() ?? false,
|
|
684
|
+
state: this.state,
|
|
685
|
+
readyState: this.transport?.isConnected() ? 1 : null,
|
|
686
|
+
retryCount: this.retryCount,
|
|
687
|
+
};
|
|
688
|
+
if (this.roomState) {
|
|
689
|
+
baseStatus.roomState = this.roomState;
|
|
690
|
+
}
|
|
691
|
+
return baseStatus;
|
|
692
|
+
}
|
|
693
|
+
// ── Cleanup ───────────────────────────────────────────────────────────
|
|
694
|
+
cleanup() {
|
|
695
|
+
if (this.reconnectTimer) {
|
|
696
|
+
clearTimeout(this.reconnectTimer);
|
|
697
|
+
this.reconnectTimer = null;
|
|
698
|
+
}
|
|
699
|
+
if (this.connectionTimeoutTimer) {
|
|
700
|
+
clearTimeout(this.connectionTimeoutTimer);
|
|
701
|
+
this.connectionTimeoutTimer = null;
|
|
702
|
+
}
|
|
703
|
+
this.roomState = null;
|
|
704
|
+
if (this.transport) {
|
|
705
|
+
this.transport.destroy();
|
|
706
|
+
this.transport = null;
|
|
707
|
+
}
|
|
708
|
+
this.activeTransportType = null;
|
|
709
|
+
this.state = types_1.AudialState.Idle;
|
|
710
|
+
this.removeAllListeners();
|
|
711
|
+
}
|
|
712
|
+
// ── Room creation (unchanged) ─────────────────────────────────────────
|
|
713
|
+
getHttpUrl() {
|
|
714
|
+
return this.config.baseUrl.startsWith('http')
|
|
715
|
+
? this.config.baseUrl
|
|
716
|
+
: this.config.baseUrl.includes('localhost')
|
|
717
|
+
? `http://${this.config.baseUrl}`
|
|
718
|
+
: `https://${this.config.baseUrl}`;
|
|
719
|
+
}
|
|
720
|
+
getBaseUrl() {
|
|
721
|
+
if (typeof window !== 'undefined' && window.__AUDIAL_CONFIG__) {
|
|
722
|
+
return window.__AUDIAL_CONFIG__.baseUrl;
|
|
723
|
+
}
|
|
724
|
+
if (typeof process !== 'undefined') {
|
|
725
|
+
const env = process.env;
|
|
726
|
+
if (env.AUDIAL_BASE_URL)
|
|
727
|
+
return env.AUDIAL_BASE_URL;
|
|
728
|
+
if (env.NODE_ENV === 'production' && env.AUDIAL_PROD_URL)
|
|
729
|
+
return env.AUDIAL_PROD_URL;
|
|
730
|
+
if (env.NODE_ENV === 'staging' && env.AUDIAL_STAGING_URL)
|
|
731
|
+
return env.AUDIAL_STAGING_URL;
|
|
732
|
+
if (env.NODE_ENV === 'development' && env.AUDIAL_DEV_URL)
|
|
733
|
+
return env.AUDIAL_DEV_URL;
|
|
734
|
+
}
|
|
735
|
+
if (typeof window !== 'undefined' && window.location && window.location.hostname) {
|
|
736
|
+
const hostname = window.location.hostname;
|
|
737
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
|
738
|
+
return 'localhost:8000';
|
|
739
|
+
}
|
|
740
|
+
if (hostname.includes('staging')) {
|
|
741
|
+
return 'lavoz-api-staging.fly.dev';
|
|
742
|
+
}
|
|
743
|
+
if (hostname.includes('production') || hostname.includes('audial.co') || hostname.includes('audial.ai')) {
|
|
744
|
+
return 'lavoz-api.fly.dev';
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return 'lavoz-api.fly.dev';
|
|
748
|
+
}
|
|
749
|
+
async createRoom(payload) {
|
|
750
|
+
const baseUrl = `${this.getHttpUrl()}/api/v1/interactions/websocket`;
|
|
751
|
+
const fullPayload = {
|
|
752
|
+
type: 'voice',
|
|
753
|
+
provider: 'websocket',
|
|
754
|
+
direction: 'web',
|
|
755
|
+
agentId: payload.agentId,
|
|
756
|
+
agentSettings: payload.agentSettings
|
|
757
|
+
? this.expandAgentSettings(payload.agentSettings)
|
|
758
|
+
: undefined,
|
|
759
|
+
languageCode: payload.languageCode,
|
|
760
|
+
customerId: payload.customerId,
|
|
761
|
+
userHash: payload.userHash,
|
|
762
|
+
customer: payload.customer,
|
|
763
|
+
};
|
|
764
|
+
const url = new URL(baseUrl);
|
|
765
|
+
this.applyPublicKeyAuth(url);
|
|
766
|
+
if (payload.agentId) {
|
|
767
|
+
url.searchParams.set('agentId', payload.agentId);
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
const response = await fetch(url.toString(), {
|
|
771
|
+
method: 'POST',
|
|
772
|
+
headers: {
|
|
773
|
+
'Content-Type': 'application/json',
|
|
774
|
+
...this.getAuthHeaders(),
|
|
775
|
+
// IETF Idempotency-Key (draft-ietf-httpapi-idempotency-key-header-07).
|
|
776
|
+
// Lets the server recognize retries of the same call intent and
|
|
777
|
+
// return the existing interaction instead of leaking a new one.
|
|
778
|
+
...(this.idempotencyKey && { 'Idempotency-Key': this.idempotencyKey }),
|
|
779
|
+
},
|
|
780
|
+
body: JSON.stringify(fullPayload),
|
|
781
|
+
});
|
|
782
|
+
if (!response.ok) {
|
|
783
|
+
let errorMessage;
|
|
784
|
+
try {
|
|
785
|
+
const errorText = await response.text();
|
|
786
|
+
errorMessage = errorText || response.statusText || 'Unknown error';
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
errorMessage = response.statusText || 'Failed to read error response';
|
|
790
|
+
}
|
|
791
|
+
throw new Error(`Room creation failed [${response.status}]: ${errorMessage}`);
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
const result = await response.json();
|
|
795
|
+
const room = result.data?.data || result.data || result;
|
|
796
|
+
if (room.expiresAt instanceof Date)
|
|
797
|
+
room.expiresAt = room.expiresAt.toISOString();
|
|
798
|
+
if (room.createdAt instanceof Date)
|
|
799
|
+
room.createdAt = room.createdAt.toISOString();
|
|
800
|
+
if (room.updatedAt instanceof Date)
|
|
801
|
+
room.updatedAt = room.updatedAt.toISOString();
|
|
802
|
+
return room;
|
|
803
|
+
}
|
|
804
|
+
catch (parseError) {
|
|
805
|
+
throw new Error(`Room creation succeeded but response is not valid JSON: ${parseError instanceof Error ? parseError.message : 'Parse error'}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
catch (error) {
|
|
809
|
+
if (error instanceof Error) {
|
|
810
|
+
if (error.message.includes('Room creation failed'))
|
|
811
|
+
throw error;
|
|
812
|
+
throw new Error(`Network error during room creation: ${error.message}`);
|
|
813
|
+
}
|
|
814
|
+
throw new Error(`Unexpected error during room creation: ${String(error)}`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
// ── Idempotency-Key helpers ───────────────────────────────────────────
|
|
818
|
+
/**
|
|
819
|
+
* Mint a new Idempotency-Key, or restore one from sessionStorage if a
|
|
820
|
+
* recent key exists (within IDEMPOTENCY_MAX_AGE_MS). Restore path
|
|
821
|
+
* handles the "user refreshed the tab during a pending connect" case:
|
|
822
|
+
* the original interaction is still queued on the server, and reusing
|
|
823
|
+
* the key gets us back the same interaction instead of leaking a new
|
|
824
|
+
* one.
|
|
825
|
+
*/
|
|
826
|
+
restoreOrMintIdempotencyKey() {
|
|
827
|
+
try {
|
|
828
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
829
|
+
const cached = sessionStorage.getItem(VoiceClient.IDEMPOTENCY_STORAGE_KEY);
|
|
830
|
+
const at = Number(sessionStorage.getItem(VoiceClient.IDEMPOTENCY_STORAGE_AT) || 0);
|
|
831
|
+
if (cached && Date.now() - at < VoiceClient.IDEMPOTENCY_MAX_AGE_MS) {
|
|
832
|
+
if (this.config.debug) {
|
|
833
|
+
console.debug('🔑 Restored Idempotency-Key from sessionStorage:', cached.slice(0, 8) + '…');
|
|
834
|
+
}
|
|
835
|
+
return cached;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
catch { /* private-mode / SSR — fall through to minting */ }
|
|
840
|
+
const fresh = this.mintUuid();
|
|
841
|
+
try {
|
|
842
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
843
|
+
sessionStorage.setItem(VoiceClient.IDEMPOTENCY_STORAGE_KEY, fresh);
|
|
844
|
+
sessionStorage.setItem(VoiceClient.IDEMPOTENCY_STORAGE_AT, String(Date.now()));
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
catch { /* ignore */ }
|
|
848
|
+
if (this.config.debug) {
|
|
849
|
+
console.debug('🔑 Minted Idempotency-Key:', fresh.slice(0, 8) + '…');
|
|
850
|
+
}
|
|
851
|
+
return fresh;
|
|
852
|
+
}
|
|
853
|
+
clearIdempotencyKey() {
|
|
854
|
+
this.idempotencyKey = null;
|
|
855
|
+
try {
|
|
856
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
857
|
+
sessionStorage.removeItem(VoiceClient.IDEMPOTENCY_STORAGE_KEY);
|
|
858
|
+
sessionStorage.removeItem(VoiceClient.IDEMPOTENCY_STORAGE_AT);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
catch { /* ignore */ }
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Generate a UUID v4. Prefers crypto.randomUUID() (modern browsers +
|
|
865
|
+
* Node 14.17+); falls back to a getRandomValues-backed implementation,
|
|
866
|
+
* and finally Math.random if neither is available (SSR test envs).
|
|
867
|
+
*/
|
|
868
|
+
mintUuid() {
|
|
869
|
+
try {
|
|
870
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
871
|
+
return crypto.randomUUID();
|
|
872
|
+
}
|
|
873
|
+
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
|
874
|
+
const bytes = new Uint8Array(16);
|
|
875
|
+
crypto.getRandomValues(bytes);
|
|
876
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40; // v4
|
|
877
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
|
|
878
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
879
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
catch { /* fall through */ }
|
|
883
|
+
// Last resort — low entropy but still unique enough to avoid self-collision
|
|
884
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
885
|
+
}
|
|
886
|
+
expandAgentSettings(simple) {
|
|
887
|
+
return {
|
|
888
|
+
name: simple.name || 'WebSDK Agent',
|
|
889
|
+
languageCode: simple.languageCode || 'en',
|
|
890
|
+
prompt: simple.prompt || 'You are a helpful assistant.',
|
|
891
|
+
model: {
|
|
892
|
+
provider: simple.model?.provider || (typeof simple.model === 'string' ? 'openai' : 'openai'),
|
|
893
|
+
model: simple.model?.model || (typeof simple.model === 'string' ? simple.model : 'gpt-4'),
|
|
894
|
+
temperature: simple.model?.temperature ?? 0.7,
|
|
895
|
+
storeCompletions: simple.model?.storeCompletions ?? true,
|
|
896
|
+
},
|
|
897
|
+
voice: {
|
|
898
|
+
provider: 'elevenlabs',
|
|
899
|
+
voiceId: simple.voiceId || '21m00Tcm4TlvDq8ikWAM',
|
|
900
|
+
stability: 0.6,
|
|
901
|
+
similarityBoost: 0.9,
|
|
902
|
+
},
|
|
903
|
+
transcriber: {
|
|
904
|
+
provider: 'deepgram',
|
|
905
|
+
model: 'nova-2',
|
|
906
|
+
},
|
|
907
|
+
tools: simple.tools || [],
|
|
908
|
+
routings: simple.routings || [],
|
|
909
|
+
maxDurationSeconds: simple.maxDurationSeconds || 600,
|
|
910
|
+
checkHumanPresence: simple.checkHumanPresence ?? true,
|
|
911
|
+
checkHumanPresenceCount: simple.checkHumanPresenceCount || 2,
|
|
912
|
+
allowedIdleTime: simple.allowedIdleTime || 30,
|
|
913
|
+
allowInterruptions: simple.allowInterruptions ?? true,
|
|
914
|
+
interruptionSensitivity: simple.interruptionSensitivity || 'high',
|
|
915
|
+
enableCutoffResponses: simple.enableCutoffResponses ?? false,
|
|
916
|
+
cutoffResponses: simple.cutoffResponses || [],
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
920
|
+
// CHAT API METHODS (unchanged — purely HTTP, no transport dependency)
|
|
921
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
922
|
+
async createChatSession(agentId, options = {}) {
|
|
923
|
+
if (this.chatState !== types_1.ChatState.Idle && this.chatState !== types_1.ChatState.Ended) {
|
|
924
|
+
throw new Error('Chat session already active. End current session first.');
|
|
925
|
+
}
|
|
926
|
+
this.setChatState(types_1.ChatState.Creating);
|
|
927
|
+
const url = new URL(`${this.getHttpUrl()}/api/v1/interactions/chat`);
|
|
928
|
+
this.applyPublicKeyAuth(url);
|
|
929
|
+
url.searchParams.set('agentId', agentId);
|
|
930
|
+
try {
|
|
931
|
+
const response = await fetch(url.toString(), {
|
|
932
|
+
method: 'POST',
|
|
933
|
+
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
|
934
|
+
body: JSON.stringify(options),
|
|
935
|
+
});
|
|
936
|
+
if (!response.ok) {
|
|
937
|
+
const errorText = await response.text().catch(() => 'Unknown error');
|
|
938
|
+
throw new Error(`Failed to create chat session [${response.status}]: ${errorText}`);
|
|
939
|
+
}
|
|
940
|
+
const result = await response.json();
|
|
941
|
+
const sessionData = result.data?.data || result.data || result;
|
|
942
|
+
this.chatSession = {
|
|
943
|
+
sessionId: sessionData.sessionId,
|
|
944
|
+
interactionId: sessionData.interactionId || sessionData.sessionId,
|
|
945
|
+
agentId: sessionData.agentId,
|
|
946
|
+
agentName: sessionData.agentName,
|
|
947
|
+
model: sessionData.model,
|
|
948
|
+
messages: [],
|
|
949
|
+
createdAt: sessionData.createdAt,
|
|
950
|
+
};
|
|
951
|
+
this.chatMessages = [];
|
|
952
|
+
this.setChatState(types_1.ChatState.Active);
|
|
953
|
+
this.emit('chat-session-created', sessionData);
|
|
954
|
+
if (this.config.debug) {
|
|
955
|
+
console.debug('💬 Chat session created:', sessionData);
|
|
956
|
+
}
|
|
957
|
+
return sessionData;
|
|
958
|
+
}
|
|
959
|
+
catch (error) {
|
|
960
|
+
this.setChatState(types_1.ChatState.Error);
|
|
961
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
962
|
+
throw error;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
async sendChatMessage(message) {
|
|
966
|
+
if (!this.chatSession) {
|
|
967
|
+
throw new Error('No active chat session. Create a session first.');
|
|
968
|
+
}
|
|
969
|
+
if (this.chatState === types_1.ChatState.Sending) {
|
|
970
|
+
throw new Error('Message already being sent. Wait for response.');
|
|
971
|
+
}
|
|
972
|
+
if (!message.trim()) {
|
|
973
|
+
throw new Error('Message cannot be empty.');
|
|
974
|
+
}
|
|
975
|
+
const previousState = this.chatState;
|
|
976
|
+
this.setChatState(types_1.ChatState.Sending);
|
|
977
|
+
const userMessage = {
|
|
978
|
+
role: 'user',
|
|
979
|
+
content: message,
|
|
980
|
+
timestamp: new Date().toISOString(),
|
|
981
|
+
};
|
|
982
|
+
this.chatMessages.push(userMessage);
|
|
983
|
+
if (this.chatSession) {
|
|
984
|
+
this.chatSession.messages.push(userMessage);
|
|
985
|
+
}
|
|
986
|
+
const url = new URL(`${this.getHttpUrl()}/api/v1/interactions/${this.chatSession.interactionId}/message`);
|
|
987
|
+
this.applyPublicKeyAuth(url);
|
|
988
|
+
try {
|
|
989
|
+
const response = await fetch(url.toString(), {
|
|
990
|
+
method: 'POST',
|
|
991
|
+
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
|
992
|
+
body: JSON.stringify({ message }),
|
|
993
|
+
});
|
|
994
|
+
if (!response.ok) {
|
|
995
|
+
const errorText = await response.text().catch(() => 'Unknown error');
|
|
996
|
+
throw new Error(`Failed to send message [${response.status}]: ${errorText}`);
|
|
997
|
+
}
|
|
998
|
+
const result = await response.json();
|
|
999
|
+
const messageData = result.data?.data || result.data || result;
|
|
1000
|
+
const assistantMessage = {
|
|
1001
|
+
role: 'assistant',
|
|
1002
|
+
content: messageData.message,
|
|
1003
|
+
timestamp: new Date().toISOString(),
|
|
1004
|
+
};
|
|
1005
|
+
this.chatMessages.push(assistantMessage);
|
|
1006
|
+
if (this.chatSession) {
|
|
1007
|
+
this.chatSession.messages.push(assistantMessage);
|
|
1008
|
+
}
|
|
1009
|
+
this.setChatState(types_1.ChatState.Active);
|
|
1010
|
+
this.emit('chat-message', messageData);
|
|
1011
|
+
if (this.config.debug) {
|
|
1012
|
+
console.debug('💬 Chat message sent:', { sent: message, received: messageData.message });
|
|
1013
|
+
}
|
|
1014
|
+
return messageData;
|
|
1015
|
+
}
|
|
1016
|
+
catch (error) {
|
|
1017
|
+
this.setChatState(previousState);
|
|
1018
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
1019
|
+
throw error;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
async streamChatMessage(message, onChunk) {
|
|
1023
|
+
if (!this.chatSession) {
|
|
1024
|
+
throw new Error('No active chat session. Create a session first.');
|
|
1025
|
+
}
|
|
1026
|
+
if (this.chatState === types_1.ChatState.Sending) {
|
|
1027
|
+
throw new Error('Message already being sent. Wait for response.');
|
|
1028
|
+
}
|
|
1029
|
+
if (!message.trim()) {
|
|
1030
|
+
throw new Error('Message cannot be empty.');
|
|
1031
|
+
}
|
|
1032
|
+
const previousState = this.chatState;
|
|
1033
|
+
this.setChatState(types_1.ChatState.Sending);
|
|
1034
|
+
const userMessage = {
|
|
1035
|
+
role: 'user',
|
|
1036
|
+
content: message,
|
|
1037
|
+
timestamp: new Date().toISOString(),
|
|
1038
|
+
};
|
|
1039
|
+
this.chatMessages.push(userMessage);
|
|
1040
|
+
if (this.chatSession) {
|
|
1041
|
+
this.chatSession.messages.push(userMessage);
|
|
1042
|
+
}
|
|
1043
|
+
const url = new URL(`${this.getHttpUrl()}/api/v1/interactions/${this.chatSession.interactionId}/message`);
|
|
1044
|
+
this.applyPublicKeyAuth(url);
|
|
1045
|
+
url.searchParams.set('stream', 'true');
|
|
1046
|
+
try {
|
|
1047
|
+
const response = await fetch(url.toString(), {
|
|
1048
|
+
method: 'POST',
|
|
1049
|
+
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
|
1050
|
+
body: JSON.stringify({ message }),
|
|
1051
|
+
});
|
|
1052
|
+
if (!response.ok) {
|
|
1053
|
+
const errorText = await response.text().catch(() => 'Unknown error');
|
|
1054
|
+
throw new Error(`Failed to stream message [${response.status}]: ${errorText}`);
|
|
1055
|
+
}
|
|
1056
|
+
const reader = response.body?.getReader();
|
|
1057
|
+
if (!reader) {
|
|
1058
|
+
throw new Error('Response body is not readable (streaming not supported by environment)');
|
|
1059
|
+
}
|
|
1060
|
+
const decoder = new TextDecoder();
|
|
1061
|
+
let fullText = '';
|
|
1062
|
+
while (true) {
|
|
1063
|
+
const { done, value } = await reader.read();
|
|
1064
|
+
if (done)
|
|
1065
|
+
break;
|
|
1066
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
1067
|
+
fullText += chunk;
|
|
1068
|
+
onChunk(chunk);
|
|
1069
|
+
}
|
|
1070
|
+
const sessionId = this.chatSession?.sessionId || '';
|
|
1071
|
+
const messageData = { sessionId, message: fullText };
|
|
1072
|
+
const assistantMessage = {
|
|
1073
|
+
role: 'assistant',
|
|
1074
|
+
content: fullText,
|
|
1075
|
+
timestamp: new Date().toISOString(),
|
|
1076
|
+
};
|
|
1077
|
+
this.chatMessages.push(assistantMessage);
|
|
1078
|
+
if (this.chatSession) {
|
|
1079
|
+
this.chatSession.messages.push(assistantMessage);
|
|
1080
|
+
}
|
|
1081
|
+
this.setChatState(types_1.ChatState.Active);
|
|
1082
|
+
this.emit('chat-message', messageData);
|
|
1083
|
+
if (this.config.debug) {
|
|
1084
|
+
console.debug('💬 Chat message streamed:', { sent: message, received: fullText.substring(0, 100) + (fullText.length > 100 ? '...' : '') });
|
|
1085
|
+
}
|
|
1086
|
+
return messageData;
|
|
1087
|
+
}
|
|
1088
|
+
catch (error) {
|
|
1089
|
+
this.setChatState(previousState);
|
|
1090
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
1091
|
+
throw error;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
async getChatHistory() {
|
|
1095
|
+
if (!this.chatSession) {
|
|
1096
|
+
throw new Error('No active chat session.');
|
|
1097
|
+
}
|
|
1098
|
+
const url = new URL(`${this.getHttpUrl()}/api/v1/interactions/${this.chatSession.interactionId}/messages`);
|
|
1099
|
+
this.applyPublicKeyAuth(url);
|
|
1100
|
+
try {
|
|
1101
|
+
const response = await fetch(url.toString(), {
|
|
1102
|
+
headers: { ...this.getAuthHeaders() },
|
|
1103
|
+
});
|
|
1104
|
+
if (!response.ok) {
|
|
1105
|
+
const errorText = await response.text().catch(() => 'Unknown error');
|
|
1106
|
+
throw new Error(`Failed to get chat history [${response.status}]: ${errorText}`);
|
|
1107
|
+
}
|
|
1108
|
+
const result = await response.json();
|
|
1109
|
+
const historyData = result.data?.data || result.data || result;
|
|
1110
|
+
this.chatMessages = historyData.messages;
|
|
1111
|
+
if (this.chatSession) {
|
|
1112
|
+
this.chatSession.messages = historyData.messages;
|
|
1113
|
+
}
|
|
1114
|
+
if (this.config.debug) {
|
|
1115
|
+
console.debug('💬 Chat history retrieved:', historyData.messages.length, 'messages');
|
|
1116
|
+
}
|
|
1117
|
+
return historyData;
|
|
1118
|
+
}
|
|
1119
|
+
catch (error) {
|
|
1120
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
1121
|
+
throw error;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
async endChatSession() {
|
|
1125
|
+
if (!this.chatSession) {
|
|
1126
|
+
if (this.config.debug) {
|
|
1127
|
+
console.debug('💬 No active chat session to end');
|
|
1128
|
+
}
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
this.setChatState(types_1.ChatState.Ending);
|
|
1132
|
+
const interactionId = this.chatSession.interactionId;
|
|
1133
|
+
const url = new URL(`${this.getHttpUrl()}/api/v1/interactions/${interactionId}/end`);
|
|
1134
|
+
this.applyPublicKeyAuth(url);
|
|
1135
|
+
try {
|
|
1136
|
+
const response = await fetch(url.toString(), {
|
|
1137
|
+
method: 'POST',
|
|
1138
|
+
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
|
1139
|
+
body: JSON.stringify({}),
|
|
1140
|
+
});
|
|
1141
|
+
if (!response.ok) {
|
|
1142
|
+
const errorText = await response.text().catch(() => 'Unknown error');
|
|
1143
|
+
throw new Error(`Failed to end chat session [${response.status}]: ${errorText}`);
|
|
1144
|
+
}
|
|
1145
|
+
this.chatSession = null;
|
|
1146
|
+
this.chatMessages = [];
|
|
1147
|
+
this.setChatState(types_1.ChatState.Ended);
|
|
1148
|
+
this.emit('chat-session-ended', interactionId);
|
|
1149
|
+
if (this.config.debug) {
|
|
1150
|
+
console.debug('💬 Chat session ended:', interactionId);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
catch (error) {
|
|
1154
|
+
this.setChatState(types_1.ChatState.Error);
|
|
1155
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
1156
|
+
throw error;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
getChatState() {
|
|
1160
|
+
return this.chatState;
|
|
1161
|
+
}
|
|
1162
|
+
getChatSession() {
|
|
1163
|
+
return this.chatSession;
|
|
1164
|
+
}
|
|
1165
|
+
getChatMessages() {
|
|
1166
|
+
return [...this.chatMessages];
|
|
1167
|
+
}
|
|
1168
|
+
hasChatSession() {
|
|
1169
|
+
return this.chatSession !== null && this.chatState === types_1.ChatState.Active;
|
|
1170
|
+
}
|
|
1171
|
+
setChatState(state) {
|
|
1172
|
+
if (this.chatState !== state) {
|
|
1173
|
+
this.chatState = state;
|
|
1174
|
+
this.emit('chat-state-change', state);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
resetChatState() {
|
|
1178
|
+
this.chatState = types_1.ChatState.Idle;
|
|
1179
|
+
this.chatSession = null;
|
|
1180
|
+
this.chatMessages = [];
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
exports.VoiceClient = VoiceClient;
|
|
1184
|
+
VoiceClient.IDEMPOTENCY_STORAGE_KEY = 'chanl:voice-idem-key';
|
|
1185
|
+
VoiceClient.IDEMPOTENCY_STORAGE_AT = 'chanl:voice-idem-at';
|
|
1186
|
+
VoiceClient.IDEMPOTENCY_MAX_AGE_MS = 120000; // 2 min
|
|
1187
|
+
//# sourceMappingURL=voice-client.js.map
|