dvgateway-sdk 1.5.0 → 1.5.2
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 +13 -0
- package/dist/client.d.ts +165 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +393 -0
- package/dist/client.js.map +1 -1
- package/dist/collect-dtmf.test.d.ts +16 -0
- package/dist/collect-dtmf.test.d.ts.map +1 -0
- package/dist/collect-dtmf.test.js +191 -0
- package/dist/collect-dtmf.test.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/play-audio.test.d.ts +9 -0
- package/dist/play-audio.test.d.ts.map +1 -0
- package/dist/play-audio.test.js +109 -0
- package/dist/play-audio.test.js.map +1 -0
- package/dist/streams/call-events.d.ts.map +1 -1
- package/dist/streams/call-events.js +20 -0
- package/dist/streams/call-events.js.map +1 -1
- package/dist/types/index.d.ts +101 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/warm-transfer.test.d.ts +9 -0
- package/dist/warm-transfer.test.d.ts.map +1 -0
- package/dist/warm-transfer.test.js +120 -0
- package/dist/warm-transfer.test.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -789,6 +789,19 @@ SDK는 429 응답 시 자동으로 지수 백오프 재시도합니다.
|
|
|
789
789
|
|
|
790
790
|
---
|
|
791
791
|
|
|
792
|
+
## 고급 통화 제어
|
|
793
|
+
|
|
794
|
+
| 메서드 | 설명 |
|
|
795
|
+
|--------|------|
|
|
796
|
+
| `await gw.collectDtmf({ linkedId, ... })` | DTMF 키 수집 (타임아웃, 종료 키, STT 자동 뮤트 포함) |
|
|
797
|
+
| `await gw.muteStt(linkedId)` | STT 입력 뮤트 (DTMF 입력 중 음성 인식 차단) |
|
|
798
|
+
| `await gw.unmuteStt(linkedId)` | STT 뮤트 해제 |
|
|
799
|
+
| `await gw.playAudio({ linkedId, audioUrl: ..., ... })` | 통화 채널에 오디오 파일 재생 |
|
|
800
|
+
| `await gw.stopAudio(linkedId)` | 재생 중인 오디오 중단 |
|
|
801
|
+
| `await gw.warmTransfer({ linkedId, ... })` | 상담원 이관 (whisper 안내 포함; whisper는 이관 대상에게만 들림) |
|
|
802
|
+
|
|
803
|
+
자세한 옵션 및 예제는 [docs/sdk-guide/13-voice-flow-controls.md](../../docs/sdk-guide/13-voice-flow-controls.md) 참조.
|
|
804
|
+
|
|
792
805
|
## 보안
|
|
793
806
|
|
|
794
807
|
- **API Key → JWT 자동 교환**: SDK가 내부적으로 처리, 사용자 코드에서 토큰 관리 불필요
|
package/dist/client.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { type AudioStreamHandle, type PipelineType } from './streams/audio-strea
|
|
|
23
23
|
import { PipelineBuilder } from './pipeline/builder.js';
|
|
24
24
|
import { PipelineMetrics } from './observability/metrics.js';
|
|
25
25
|
import { type SimulateOptions, type SimulatedCall } from './simulator/simulator.js';
|
|
26
|
-
import type { DVGatewayOptions, CallSession, CallEvent, TTSCompleteEvent, TranscriptResult, StreamDir, TtsAdapter, Unsubscribe } from './types/index.js';
|
|
26
|
+
import type { DVGatewayOptions, CallSession, CallEvent, TTSCompleteEvent, TranscriptResult, StreamDir, TtsAdapter, Unsubscribe, DTMFResult, PlayAudioResult, WarmTransferResult } from './types/index.js';
|
|
27
27
|
export declare class DVGatewayClient {
|
|
28
28
|
private readonly auth;
|
|
29
29
|
private readonly wsPool;
|
|
@@ -34,6 +34,14 @@ export declare class DVGatewayClient {
|
|
|
34
34
|
private readonly minutes;
|
|
35
35
|
readonly metrics: PipelineMetrics;
|
|
36
36
|
private readonly logger;
|
|
37
|
+
/**
|
|
38
|
+
* Per-linkedID STT mute reference counter used by collectDtmf to
|
|
39
|
+
* coordinate concurrent collectors within this process. Only the first
|
|
40
|
+
* acquire actually sends the mute REST call; only the last release sends
|
|
41
|
+
* the unmute REST call. JS is single-threaded so synchronous increment
|
|
42
|
+
* / decrement on this map is race-free across `await` boundaries.
|
|
43
|
+
*/
|
|
44
|
+
private readonly sttMuteRefs;
|
|
37
45
|
constructor(opts: DVGatewayOptions);
|
|
38
46
|
/**
|
|
39
47
|
* Create a fluent AI voice pipeline.
|
|
@@ -213,6 +221,118 @@ export declare class DVGatewayClient {
|
|
|
213
221
|
* ```
|
|
214
222
|
*/
|
|
215
223
|
broadcastSay(confId: string, text: string, tts: TtsAdapter): Promise<void>;
|
|
224
|
+
/**
|
|
225
|
+
* Mute the gateway's STT audio pipeline for a call.
|
|
226
|
+
*
|
|
227
|
+
* While muted, inbound audio frames for `linkedId` are dropped before
|
|
228
|
+
* reaching the STT provider. Used by {@link collectDtmf} so DTMF button
|
|
229
|
+
* tones are not transcribed as spurious text, but also callable directly
|
|
230
|
+
* for custom flows.
|
|
231
|
+
*
|
|
232
|
+
* @param linkedId Call session identifier.
|
|
233
|
+
* @param durationMs Auto-expiry in milliseconds. Omit (or pass 0) to
|
|
234
|
+
* keep the mute open until an explicit {@link unmuteStt}.
|
|
235
|
+
*/
|
|
236
|
+
muteStt(linkedId: string, durationMs?: number): Promise<void>;
|
|
237
|
+
/**
|
|
238
|
+
* Explicitly unmute the STT pipeline for a call.
|
|
239
|
+
*
|
|
240
|
+
* Forces the mute state to off — even if other callers still hold mutes
|
|
241
|
+
* on the gateway. Use {@link collectDtmf} for reference-counted
|
|
242
|
+
* semantics; this is the escape hatch.
|
|
243
|
+
*/
|
|
244
|
+
unmuteStt(linkedId: string): Promise<void>;
|
|
245
|
+
/**
|
|
246
|
+
* Collect DTMF digits from the caller over the callinfo stream.
|
|
247
|
+
*
|
|
248
|
+
* Completes when any of the following fires first:
|
|
249
|
+
* - `maxDigits` digits have been accumulated
|
|
250
|
+
* - the `terminator` key is pressed
|
|
251
|
+
* - the first-key `timeoutMs` elapses before any digit
|
|
252
|
+
* - `interDigitTimeoutMs` elapses after at least one digit
|
|
253
|
+
*
|
|
254
|
+
* Gateway prerequisites:
|
|
255
|
+
* - `GW_DTMF_ENABLED=true` (default) — AMI DTMF forwarding on.
|
|
256
|
+
* - `GW_DTMF_PHASE_FILTER` should include `"end"` (default). Only
|
|
257
|
+
* `phase === "end"` events are counted, mirroring IVR semantics
|
|
258
|
+
* where a digit is "entered" when released.
|
|
259
|
+
*
|
|
260
|
+
* Only events matching `linkedId` are accumulated. Concurrent calls to
|
|
261
|
+
* `collectDtmf` on the same `linkedId` are independent collections; STT
|
|
262
|
+
* mute is reference-counted so the STT only resumes when the final
|
|
263
|
+
* concurrent collector releases.
|
|
264
|
+
*
|
|
265
|
+
* @example
|
|
266
|
+
* ```typescript
|
|
267
|
+
* const result = await gw.collectDtmf({
|
|
268
|
+
* linkedId,
|
|
269
|
+
* maxDigits: 4,
|
|
270
|
+
* timeoutMs: 8_000,
|
|
271
|
+
* interDigitTimeoutMs: 3_000,
|
|
272
|
+
* terminator: '#',
|
|
273
|
+
* });
|
|
274
|
+
* if (result.timedOut) {
|
|
275
|
+
* await gw.say(linkedId, '입력 시간이 초과되었습니다.', tts);
|
|
276
|
+
* } else {
|
|
277
|
+
* await processPin(result.digits);
|
|
278
|
+
* }
|
|
279
|
+
* ```
|
|
280
|
+
*/
|
|
281
|
+
collectDtmf(options: {
|
|
282
|
+
linkedId: string;
|
|
283
|
+
maxDigits?: number;
|
|
284
|
+
timeoutMs?: number;
|
|
285
|
+
interDigitTimeoutMs?: number;
|
|
286
|
+
terminator?: string;
|
|
287
|
+
muteStt?: boolean;
|
|
288
|
+
}): Promise<DTMFResult>;
|
|
289
|
+
/**
|
|
290
|
+
* Play an audio file from a URL into an active call.
|
|
291
|
+
*
|
|
292
|
+
* The gateway downloads the audio asset (auto-converting to 16kHz mono
|
|
293
|
+
* PCM via FFmpeg when needed) and injects it into the call's channel.
|
|
294
|
+
* Typical uses: IVR prompts, legal disclosures, on-hold music,
|
|
295
|
+
* pre-recorded announcements.
|
|
296
|
+
*
|
|
297
|
+
* Return-value semantics:
|
|
298
|
+
* - `waitForCompletion=true` (default): the promise resolves only after
|
|
299
|
+
* the gateway reports playback finished (or was interrupted / stopped).
|
|
300
|
+
* - `waitForCompletion=false`: the gateway returns 202 as soon as
|
|
301
|
+
* playback starts, so the promise resolves immediately with
|
|
302
|
+
* `completed=false`, `durationMs=0`, `interruptedByDtmf=null`.
|
|
303
|
+
*
|
|
304
|
+
* Empty-string `interruptedByDtmf` from the gateway is normalized to
|
|
305
|
+
* `null` for parity with the Python SDK.
|
|
306
|
+
*
|
|
307
|
+
* @throws {Error} when `options.url` is an empty string.
|
|
308
|
+
*
|
|
309
|
+
* @example Legal notice with DTMF consent
|
|
310
|
+
* ```typescript
|
|
311
|
+
* const res = await gw.playAudio({
|
|
312
|
+
* linkedId,
|
|
313
|
+
* url: 'https://cdn.example.com/legal-notice.mp3',
|
|
314
|
+
* interruptOnDtmf: true,
|
|
315
|
+
* });
|
|
316
|
+
* if (res.interruptedByDtmf === '1') {
|
|
317
|
+
* await processConsent(linkedId);
|
|
318
|
+
* }
|
|
319
|
+
* ```
|
|
320
|
+
*/
|
|
321
|
+
playAudio(options: {
|
|
322
|
+
linkedId: string;
|
|
323
|
+
url: string;
|
|
324
|
+
loop?: boolean;
|
|
325
|
+
interruptOnDtmf?: boolean;
|
|
326
|
+
waitForCompletion?: boolean;
|
|
327
|
+
}): Promise<PlayAudioResult>;
|
|
328
|
+
/**
|
|
329
|
+
* Stop any active audio playback on a call.
|
|
330
|
+
*
|
|
331
|
+
* No-op when nothing is playing. Safe to call concurrently with
|
|
332
|
+
* {@link playAudio} — the outstanding `playAudio` promise will resolve
|
|
333
|
+
* with `completed=false`.
|
|
334
|
+
*/
|
|
335
|
+
stopAudio(linkedId: string): Promise<void>;
|
|
216
336
|
/**
|
|
217
337
|
* Terminate an active call by linkedId.
|
|
218
338
|
* Sends AMI Hangup action to Asterisk via the gateway.
|
|
@@ -223,6 +343,50 @@ export declare class DVGatewayClient {
|
|
|
223
343
|
* Sends AMI Redirect action to Asterisk via the gateway.
|
|
224
344
|
*/
|
|
225
345
|
redirect(linkedId: string, destination: string, context?: string): Promise<void>;
|
|
346
|
+
/**
|
|
347
|
+
* Warm-transfer an active call to an agent extension.
|
|
348
|
+
*
|
|
349
|
+
* The gateway originates a new leg to `destination` (in `context`),
|
|
350
|
+
* optionally plays a whisper prompt to the agent, optionally plays
|
|
351
|
+
* hold audio to the caller, and bridges the two legs once the agent
|
|
352
|
+
* answers. If the agent does not answer before `timeoutMs` elapses
|
|
353
|
+
* the transfer is reported as timed out and the original call is
|
|
354
|
+
* left untouched.
|
|
355
|
+
*
|
|
356
|
+
* **Note:** the gateway does not yet synthesize the `whisperText`
|
|
357
|
+
* prompt; the response's `whisperPlayed` flag will therefore typically
|
|
358
|
+
* be `false` in this SDK release. When gateway-side whisper
|
|
359
|
+
* synthesis lands, no SDK changes are required.
|
|
360
|
+
*
|
|
361
|
+
* Server-side empty strings for `error` / `agentChannel` / `bridgeId`
|
|
362
|
+
* are normalized to `null` for parity with the Python SDK.
|
|
363
|
+
*
|
|
364
|
+
* @throws {Error} when `destination` is empty or `timeoutMs <= 0`.
|
|
365
|
+
*
|
|
366
|
+
* @example
|
|
367
|
+
* ```typescript
|
|
368
|
+
* const res = await gw.warmTransfer({
|
|
369
|
+
* linkedId,
|
|
370
|
+
* destination: '1001',
|
|
371
|
+
* whisperText: 'VIP 고객입니다',
|
|
372
|
+
* holdAudioUrl: 'https://cdn.example.com/hold.mp3',
|
|
373
|
+
* timeoutMs: 30_000,
|
|
374
|
+
* });
|
|
375
|
+
* if (res.connected) {
|
|
376
|
+
* console.log('agent', res.agentChannel, 'bridged', res.bridgeId);
|
|
377
|
+
* } else if (res.timedOut) {
|
|
378
|
+
* await gw.say(linkedId, '담당자 연결에 실패했습니다.', tts);
|
|
379
|
+
* }
|
|
380
|
+
* ```
|
|
381
|
+
*/
|
|
382
|
+
warmTransfer(options: {
|
|
383
|
+
linkedId: string;
|
|
384
|
+
destination: string;
|
|
385
|
+
whisperText?: string;
|
|
386
|
+
holdAudioUrl?: string;
|
|
387
|
+
context?: string;
|
|
388
|
+
timeoutMs?: number;
|
|
389
|
+
}): Promise<WarmTransferResult>;
|
|
226
390
|
/** Apply PBX configuration changes (required after callerID/extension changes) */
|
|
227
391
|
applyChanges(): Promise<unknown>;
|
|
228
392
|
/** Click-to-call: initiate an outbound call via PBX */
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAMH,OAAO,EAAqB,KAAK,iBAAiB,EAAE,KAAK,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAIzG,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAE7D,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,aAAa,EACnB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EACV,gBAAgB,EAChB,WAAW,EACX,SAAS,
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
|
package/dist/client.js
CHANGED
|
@@ -41,6 +41,14 @@ export class DVGatewayClient {
|
|
|
41
41
|
minutes;
|
|
42
42
|
metrics;
|
|
43
43
|
logger;
|
|
44
|
+
/**
|
|
45
|
+
* Per-linkedID STT mute reference counter used by collectDtmf to
|
|
46
|
+
* coordinate concurrent collectors within this process. Only the first
|
|
47
|
+
* acquire actually sends the mute REST call; only the last release sends
|
|
48
|
+
* the unmute REST call. JS is single-threaded so synchronous increment
|
|
49
|
+
* / decrement on this map is race-free across `await` boundaries.
|
|
50
|
+
*/
|
|
51
|
+
sttMuteRefs = new Map();
|
|
44
52
|
constructor(opts) {
|
|
45
53
|
// Enforce TLS in production
|
|
46
54
|
const baseUrl = this.normalizeBaseUrl(opts);
|
|
@@ -260,6 +268,320 @@ export class DVGatewayClient {
|
|
|
260
268
|
async broadcastSay(confId, text, tts) {
|
|
261
269
|
return this.ttsInjector.broadcastToConference(confId, tts.synthesize(text));
|
|
262
270
|
}
|
|
271
|
+
// ─── DTMF Collection ──────────────────────────────────────────────────
|
|
272
|
+
/**
|
|
273
|
+
* Mute the gateway's STT audio pipeline for a call.
|
|
274
|
+
*
|
|
275
|
+
* While muted, inbound audio frames for `linkedId` are dropped before
|
|
276
|
+
* reaching the STT provider. Used by {@link collectDtmf} so DTMF button
|
|
277
|
+
* tones are not transcribed as spurious text, but also callable directly
|
|
278
|
+
* for custom flows.
|
|
279
|
+
*
|
|
280
|
+
* @param linkedId Call session identifier.
|
|
281
|
+
* @param durationMs Auto-expiry in milliseconds. Omit (or pass 0) to
|
|
282
|
+
* keep the mute open until an explicit {@link unmuteStt}.
|
|
283
|
+
*/
|
|
284
|
+
async muteStt(linkedId, durationMs) {
|
|
285
|
+
const query = durationMs !== undefined && durationMs > 0
|
|
286
|
+
? `?duration_ms=${Math.floor(durationMs)}`
|
|
287
|
+
: '';
|
|
288
|
+
await this.http.post(`/api/v1/stt/${encodeURIComponent(linkedId)}/mute${query}`, {});
|
|
289
|
+
this.logger.debug({ linkedId, durationMs }, 'STT muted');
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Explicitly unmute the STT pipeline for a call.
|
|
293
|
+
*
|
|
294
|
+
* Forces the mute state to off — even if other callers still hold mutes
|
|
295
|
+
* on the gateway. Use {@link collectDtmf} for reference-counted
|
|
296
|
+
* semantics; this is the escape hatch.
|
|
297
|
+
*/
|
|
298
|
+
async unmuteStt(linkedId) {
|
|
299
|
+
await this.http.delete(`/api/v1/stt/${encodeURIComponent(linkedId)}/mute`);
|
|
300
|
+
this.logger.debug({ linkedId }, 'STT unmuted');
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Collect DTMF digits from the caller over the callinfo stream.
|
|
304
|
+
*
|
|
305
|
+
* Completes when any of the following fires first:
|
|
306
|
+
* - `maxDigits` digits have been accumulated
|
|
307
|
+
* - the `terminator` key is pressed
|
|
308
|
+
* - the first-key `timeoutMs` elapses before any digit
|
|
309
|
+
* - `interDigitTimeoutMs` elapses after at least one digit
|
|
310
|
+
*
|
|
311
|
+
* Gateway prerequisites:
|
|
312
|
+
* - `GW_DTMF_ENABLED=true` (default) — AMI DTMF forwarding on.
|
|
313
|
+
* - `GW_DTMF_PHASE_FILTER` should include `"end"` (default). Only
|
|
314
|
+
* `phase === "end"` events are counted, mirroring IVR semantics
|
|
315
|
+
* where a digit is "entered" when released.
|
|
316
|
+
*
|
|
317
|
+
* Only events matching `linkedId` are accumulated. Concurrent calls to
|
|
318
|
+
* `collectDtmf` on the same `linkedId` are independent collections; STT
|
|
319
|
+
* mute is reference-counted so the STT only resumes when the final
|
|
320
|
+
* concurrent collector releases.
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* ```typescript
|
|
324
|
+
* const result = await gw.collectDtmf({
|
|
325
|
+
* linkedId,
|
|
326
|
+
* maxDigits: 4,
|
|
327
|
+
* timeoutMs: 8_000,
|
|
328
|
+
* interDigitTimeoutMs: 3_000,
|
|
329
|
+
* terminator: '#',
|
|
330
|
+
* });
|
|
331
|
+
* if (result.timedOut) {
|
|
332
|
+
* await gw.say(linkedId, '입력 시간이 초과되었습니다.', tts);
|
|
333
|
+
* } else {
|
|
334
|
+
* await processPin(result.digits);
|
|
335
|
+
* }
|
|
336
|
+
* ```
|
|
337
|
+
*/
|
|
338
|
+
async collectDtmf(options) {
|
|
339
|
+
const linkedId = options.linkedId;
|
|
340
|
+
const maxDigits = options.maxDigits ?? 1;
|
|
341
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
342
|
+
const interDigitTimeoutMs = options.interDigitTimeoutMs ?? 2_000;
|
|
343
|
+
const terminator = options.terminator ?? '#';
|
|
344
|
+
const muteStt = options.muteStt ?? true;
|
|
345
|
+
if (maxDigits < 1) {
|
|
346
|
+
throw new Error('maxDigits must be >= 1');
|
|
347
|
+
}
|
|
348
|
+
const digitsBuf = [];
|
|
349
|
+
let terminated = false;
|
|
350
|
+
// Promise-based "next digit" signal, reset each wait cycle.
|
|
351
|
+
let resolveNext = null;
|
|
352
|
+
let nextPromise = new Promise((resolve) => {
|
|
353
|
+
resolveNext = resolve;
|
|
354
|
+
});
|
|
355
|
+
const resetSignal = () => {
|
|
356
|
+
nextPromise = new Promise((resolve) => {
|
|
357
|
+
resolveNext = resolve;
|
|
358
|
+
});
|
|
359
|
+
};
|
|
360
|
+
const fireSignal = () => {
|
|
361
|
+
if (resolveNext) {
|
|
362
|
+
const r = resolveNext;
|
|
363
|
+
resolveNext = null;
|
|
364
|
+
r();
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
const onDtmf = (event) => {
|
|
368
|
+
if (event.linkedId !== linkedId)
|
|
369
|
+
return;
|
|
370
|
+
if (event.phase !== 'end')
|
|
371
|
+
return;
|
|
372
|
+
const digit = event.digit;
|
|
373
|
+
if (!digit)
|
|
374
|
+
return;
|
|
375
|
+
if (terminator && digit === terminator) {
|
|
376
|
+
terminated = true;
|
|
377
|
+
fireSignal();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
digitsBuf.push(digit);
|
|
381
|
+
fireSignal();
|
|
382
|
+
};
|
|
383
|
+
const unsub = this.on('call:dtmf', onDtmf);
|
|
384
|
+
// Reference-counted STT mute (see collectDtmf in Python SDK).
|
|
385
|
+
let muteEngaged = false;
|
|
386
|
+
if (muteStt) {
|
|
387
|
+
const prev = this.sttMuteRefs.get(linkedId) ?? 0;
|
|
388
|
+
this.sttMuteRefs.set(linkedId, prev + 1);
|
|
389
|
+
if (prev === 0) {
|
|
390
|
+
try {
|
|
391
|
+
await this.muteStt(linkedId);
|
|
392
|
+
muteEngaged = true;
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
this.logger.warn({ linkedId, err: err instanceof Error ? err.message : String(err) }, 'collectDtmf: muteStt failed, continuing without mute');
|
|
396
|
+
// Rollback ref so we don't leak; disable mute side of finally.
|
|
397
|
+
const cur = this.sttMuteRefs.get(linkedId) ?? 1;
|
|
398
|
+
if (cur <= 1) {
|
|
399
|
+
this.sttMuteRefs.delete(linkedId);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
this.sttMuteRefs.set(linkedId, cur - 1);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
// Another collector already engaged the gateway mute; we only
|
|
408
|
+
// participate in ref counting.
|
|
409
|
+
muteEngaged = true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Timeout helper: race signal promise against a timeout.
|
|
413
|
+
const waitWithTimeout = async (ms) => {
|
|
414
|
+
if (ms <= 0) {
|
|
415
|
+
// Zero/negative timeout: check synchronously; if nothing queued,
|
|
416
|
+
// treat as immediate timeout.
|
|
417
|
+
return Promise.race([
|
|
418
|
+
nextPromise.then(() => 'signal'),
|
|
419
|
+
Promise.resolve('timeout'),
|
|
420
|
+
]);
|
|
421
|
+
}
|
|
422
|
+
let timer = null;
|
|
423
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
424
|
+
timer = setTimeout(() => resolve('timeout'), ms);
|
|
425
|
+
});
|
|
426
|
+
const sigPromise = nextPromise.then(() => 'signal');
|
|
427
|
+
try {
|
|
428
|
+
return await Promise.race([sigPromise, timeoutPromise]);
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
if (timer)
|
|
432
|
+
clearTimeout(timer);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
try {
|
|
436
|
+
// First-key wait.
|
|
437
|
+
const firstOutcome = await waitWithTimeout(timeoutMs);
|
|
438
|
+
if (firstOutcome === 'timeout') {
|
|
439
|
+
return { digits: '', timedOut: true, terminatedByKey: false };
|
|
440
|
+
}
|
|
441
|
+
if (terminated) {
|
|
442
|
+
return {
|
|
443
|
+
digits: digitsBuf.join(''),
|
|
444
|
+
timedOut: false,
|
|
445
|
+
terminatedByKey: true,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
if (digitsBuf.length >= maxDigits) {
|
|
449
|
+
return {
|
|
450
|
+
digits: digitsBuf.slice(0, maxDigits).join(''),
|
|
451
|
+
timedOut: false,
|
|
452
|
+
terminatedByKey: false,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
// Subsequent digits: inter-digit timeout governs.
|
|
456
|
+
// Loop until completion.
|
|
457
|
+
// eslint-disable-next-line no-constant-condition
|
|
458
|
+
while (true) {
|
|
459
|
+
resetSignal();
|
|
460
|
+
const outcome = await waitWithTimeout(interDigitTimeoutMs);
|
|
461
|
+
if (outcome === 'timeout') {
|
|
462
|
+
return {
|
|
463
|
+
digits: digitsBuf.join(''),
|
|
464
|
+
timedOut: true,
|
|
465
|
+
terminatedByKey: false,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
if (terminated) {
|
|
469
|
+
return {
|
|
470
|
+
digits: digitsBuf.join(''),
|
|
471
|
+
timedOut: false,
|
|
472
|
+
terminatedByKey: true,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (digitsBuf.length >= maxDigits) {
|
|
476
|
+
return {
|
|
477
|
+
digits: digitsBuf.slice(0, maxDigits).join(''),
|
|
478
|
+
timedOut: false,
|
|
479
|
+
terminatedByKey: false,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
try {
|
|
486
|
+
unsub();
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
// defensive — unsubscribe should never throw.
|
|
490
|
+
}
|
|
491
|
+
if (muteStt && muteEngaged) {
|
|
492
|
+
const remaining = (this.sttMuteRefs.get(linkedId) ?? 1) - 1;
|
|
493
|
+
if (remaining <= 0) {
|
|
494
|
+
this.sttMuteRefs.delete(linkedId);
|
|
495
|
+
try {
|
|
496
|
+
await this.unmuteStt(linkedId);
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
this.logger.warn({
|
|
500
|
+
linkedId,
|
|
501
|
+
err: err instanceof Error ? err.message : String(err),
|
|
502
|
+
}, 'collectDtmf: unmuteStt failed');
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
this.sttMuteRefs.set(linkedId, remaining);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// ─── Audio Playback ────────────────────────────────────────────────────
|
|
512
|
+
/**
|
|
513
|
+
* Play an audio file from a URL into an active call.
|
|
514
|
+
*
|
|
515
|
+
* The gateway downloads the audio asset (auto-converting to 16kHz mono
|
|
516
|
+
* PCM via FFmpeg when needed) and injects it into the call's channel.
|
|
517
|
+
* Typical uses: IVR prompts, legal disclosures, on-hold music,
|
|
518
|
+
* pre-recorded announcements.
|
|
519
|
+
*
|
|
520
|
+
* Return-value semantics:
|
|
521
|
+
* - `waitForCompletion=true` (default): the promise resolves only after
|
|
522
|
+
* the gateway reports playback finished (or was interrupted / stopped).
|
|
523
|
+
* - `waitForCompletion=false`: the gateway returns 202 as soon as
|
|
524
|
+
* playback starts, so the promise resolves immediately with
|
|
525
|
+
* `completed=false`, `durationMs=0`, `interruptedByDtmf=null`.
|
|
526
|
+
*
|
|
527
|
+
* Empty-string `interruptedByDtmf` from the gateway is normalized to
|
|
528
|
+
* `null` for parity with the Python SDK.
|
|
529
|
+
*
|
|
530
|
+
* @throws {Error} when `options.url` is an empty string.
|
|
531
|
+
*
|
|
532
|
+
* @example Legal notice with DTMF consent
|
|
533
|
+
* ```typescript
|
|
534
|
+
* const res = await gw.playAudio({
|
|
535
|
+
* linkedId,
|
|
536
|
+
* url: 'https://cdn.example.com/legal-notice.mp3',
|
|
537
|
+
* interruptOnDtmf: true,
|
|
538
|
+
* });
|
|
539
|
+
* if (res.interruptedByDtmf === '1') {
|
|
540
|
+
* await processConsent(linkedId);
|
|
541
|
+
* }
|
|
542
|
+
* ```
|
|
543
|
+
*/
|
|
544
|
+
async playAudio(options) {
|
|
545
|
+
const { linkedId, url } = options;
|
|
546
|
+
if (!url) {
|
|
547
|
+
throw new Error('url is required');
|
|
548
|
+
}
|
|
549
|
+
const loop = options.loop ?? false;
|
|
550
|
+
const interruptOnDtmf = options.interruptOnDtmf ?? false;
|
|
551
|
+
const waitForCompletion = options.waitForCompletion ?? true;
|
|
552
|
+
const body = {
|
|
553
|
+
url,
|
|
554
|
+
loop,
|
|
555
|
+
interruptOnDtmf,
|
|
556
|
+
waitForCompletion,
|
|
557
|
+
};
|
|
558
|
+
const res = await this.http.post(`/api/v1/play/${encodeURIComponent(linkedId)}`, body);
|
|
559
|
+
const data = (res.data ?? {});
|
|
560
|
+
if (!waitForCompletion) {
|
|
561
|
+
// 202 — gateway accepted the request; playback proceeds async.
|
|
562
|
+
return { completed: false, interruptedByDtmf: null, durationMs: 0 };
|
|
563
|
+
}
|
|
564
|
+
const rawDtmf = data['interruptedByDtmf'];
|
|
565
|
+
const interruptedByDtmf = typeof rawDtmf === 'string' && rawDtmf !== '' ? rawDtmf : null;
|
|
566
|
+
const durationMsRaw = data['durationMs'];
|
|
567
|
+
const durationMs = typeof durationMsRaw === 'number' ? Math.floor(durationMsRaw) : 0;
|
|
568
|
+
return {
|
|
569
|
+
completed: Boolean(data['completed']),
|
|
570
|
+
interruptedByDtmf,
|
|
571
|
+
durationMs,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Stop any active audio playback on a call.
|
|
576
|
+
*
|
|
577
|
+
* No-op when nothing is playing. Safe to call concurrently with
|
|
578
|
+
* {@link playAudio} — the outstanding `playAudio` promise will resolve
|
|
579
|
+
* with `completed=false`.
|
|
580
|
+
*/
|
|
581
|
+
async stopAudio(linkedId) {
|
|
582
|
+
await this.http.delete(`/api/v1/play/${encodeURIComponent(linkedId)}`);
|
|
583
|
+
this.logger.debug({ linkedId }, 'Audio playback stopped');
|
|
584
|
+
}
|
|
263
585
|
// ─── Call Control ──────────────────────────────────────────────────────
|
|
264
586
|
/**
|
|
265
587
|
* Terminate an active call by linkedId.
|
|
@@ -281,6 +603,77 @@ export class DVGatewayClient {
|
|
|
281
603
|
});
|
|
282
604
|
this.logger.debug({ linkedId, destination, context }, 'Call redirect requested');
|
|
283
605
|
}
|
|
606
|
+
/**
|
|
607
|
+
* Warm-transfer an active call to an agent extension.
|
|
608
|
+
*
|
|
609
|
+
* The gateway originates a new leg to `destination` (in `context`),
|
|
610
|
+
* optionally plays a whisper prompt to the agent, optionally plays
|
|
611
|
+
* hold audio to the caller, and bridges the two legs once the agent
|
|
612
|
+
* answers. If the agent does not answer before `timeoutMs` elapses
|
|
613
|
+
* the transfer is reported as timed out and the original call is
|
|
614
|
+
* left untouched.
|
|
615
|
+
*
|
|
616
|
+
* **Note:** the gateway does not yet synthesize the `whisperText`
|
|
617
|
+
* prompt; the response's `whisperPlayed` flag will therefore typically
|
|
618
|
+
* be `false` in this SDK release. When gateway-side whisper
|
|
619
|
+
* synthesis lands, no SDK changes are required.
|
|
620
|
+
*
|
|
621
|
+
* Server-side empty strings for `error` / `agentChannel` / `bridgeId`
|
|
622
|
+
* are normalized to `null` for parity with the Python SDK.
|
|
623
|
+
*
|
|
624
|
+
* @throws {Error} when `destination` is empty or `timeoutMs <= 0`.
|
|
625
|
+
*
|
|
626
|
+
* @example
|
|
627
|
+
* ```typescript
|
|
628
|
+
* const res = await gw.warmTransfer({
|
|
629
|
+
* linkedId,
|
|
630
|
+
* destination: '1001',
|
|
631
|
+
* whisperText: 'VIP 고객입니다',
|
|
632
|
+
* holdAudioUrl: 'https://cdn.example.com/hold.mp3',
|
|
633
|
+
* timeoutMs: 30_000,
|
|
634
|
+
* });
|
|
635
|
+
* if (res.connected) {
|
|
636
|
+
* console.log('agent', res.agentChannel, 'bridged', res.bridgeId);
|
|
637
|
+
* } else if (res.timedOut) {
|
|
638
|
+
* await gw.say(linkedId, '담당자 연결에 실패했습니다.', tts);
|
|
639
|
+
* }
|
|
640
|
+
* ```
|
|
641
|
+
*/
|
|
642
|
+
async warmTransfer(options) {
|
|
643
|
+
const linkedId = options.linkedId;
|
|
644
|
+
const destination = options.destination;
|
|
645
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
646
|
+
const context = options.context ?? 'from-internal';
|
|
647
|
+
const whisperText = options.whisperText ?? '';
|
|
648
|
+
const holdAudioUrl = options.holdAudioUrl ?? '';
|
|
649
|
+
if (!destination) {
|
|
650
|
+
throw new Error('destination is required');
|
|
651
|
+
}
|
|
652
|
+
if (timeoutMs <= 0) {
|
|
653
|
+
throw new Error('timeoutMs must be > 0');
|
|
654
|
+
}
|
|
655
|
+
const body = {
|
|
656
|
+
destination,
|
|
657
|
+
timeoutMs: Math.floor(timeoutMs),
|
|
658
|
+
};
|
|
659
|
+
if (context)
|
|
660
|
+
body['context'] = context;
|
|
661
|
+
if (whisperText)
|
|
662
|
+
body['whisperText'] = whisperText;
|
|
663
|
+
if (holdAudioUrl)
|
|
664
|
+
body['holdAudioUrl'] = holdAudioUrl;
|
|
665
|
+
const res = await this.http.post(`/api/v1/transfer/warm/${encodeURIComponent(linkedId)}`, body);
|
|
666
|
+
const data = (res.data ?? {});
|
|
667
|
+
const toNullable = (v) => typeof v === 'string' && v !== '' ? v : null;
|
|
668
|
+
return {
|
|
669
|
+
connected: Boolean(data['connected']),
|
|
670
|
+
timedOut: Boolean(data['timedOut']),
|
|
671
|
+
error: toNullable(data['error']),
|
|
672
|
+
agentChannel: toNullable(data['agentChannel']),
|
|
673
|
+
bridgeId: toNullable(data['bridgeId']),
|
|
674
|
+
whisperPlayed: Boolean(data['whisperPlayed']),
|
|
675
|
+
};
|
|
676
|
+
}
|
|
284
677
|
// ─── PBX Management ─────────────────────────────────────────────────
|
|
285
678
|
/** Apply PBX configuration changes (required after callerID/extension changes) */
|
|
286
679
|
async applyChanges() {
|