dvgateway-sdk 1.4.9 → 1.5.1

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 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 */
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
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() {