UNPKG

@signalwire/realtime-api

Version:
1,352 lines (1,221 loc) 34.3 kB
import { CallingCallConnectEventParams, CallingCall, uuid, VoiceCallDisconnectReason, toSnakeCaseKeys, CallingCallWaitForState, CallingCallState, VoiceCallConnectMethodParams, toExternalJSON, VoiceCallConnectPhoneMethodParams, VoiceCallConnectSipMethodParams, CallingCallConnectFailedEventParams, } from '@signalwire/core' import { ListenSubscriber } from '../ListenSubscriber' import { CallCollectMethodParams, CallDetectDigitParams, CallDetectFaxParams, CallDetectMachineParams, CallDetectMethodParams, CallPlayAudioMethodarams, CallPlayMethodParams, CallPlayRingtoneMethodParams, CallPlaySilenceMethodParams, CallPlayTTSMethodParams, CallPromptAudioMethodParams, CallPromptMethodParams, CallPromptRingtoneMethodParams, CallPromptTTSMethodParams, CallRecordAudioMethodParams, CallRecordMethodParams, CallTapAudioMethodParams, CallTapMethodParams, RealTimeCallEvents, RealTimeCallListeners, RealtimeCallListenersEventsMapping, } from '../types' import { toInternalDevices, toInternalPlayParams } from './utils' import { voiceCallCollectWorker, voiceCallConnectWorker, voiceCallDetectWorker, voiceCallPlayWorker, voiceCallRecordWorker, voiceCallSendDigitsWorker, voiceCallTapWorker, } from './workers' import { Playlist } from './Playlist' import { Voice } from './Voice' import { CallPlayback, decoratePlaybackPromise } from './CallPlayback' import { CallRecording, decorateRecordingPromise } from './CallRecording' import { CallPrompt, decoratePromptPromise } from './CallPrompt' import { CallCollect, decorateCollectPromise } from './CallCollect' import { CallTap, decorateTapPromise } from './CallTap' import { DeviceBuilder } from './DeviceBuilder' import { CallDetect, decorateDetectPromise } from './CallDetect' interface CallOptions { voice: Voice payload?: CallingCall connectPayload?: CallingCallConnectEventParams listeners?: RealTimeCallListeners } export class Call extends ListenSubscriber< RealTimeCallListeners, RealTimeCallEvents > { private _voice: Voice private _context: string | undefined private _peer: Call | undefined private _payload: CallingCall | undefined private _connectPayload: CallingCallConnectEventParams | undefined protected _eventMap: RealtimeCallListenersEventsMapping = { onStateChanged: 'call.state', onPlaybackStarted: 'playback.started', onPlaybackUpdated: 'playback.updated', onPlaybackFailed: 'playback.failed', onPlaybackEnded: 'playback.ended', onRecordingStarted: 'recording.started', onRecordingUpdated: 'recording.updated', onRecordingFailed: 'recording.failed', onRecordingEnded: 'recording.ended', onPromptStarted: 'prompt.started', onPromptUpdated: 'prompt.updated', onPromptFailed: 'prompt.failed', onPromptEnded: 'prompt.ended', onCollectStarted: 'collect.started', onCollectInputStarted: 'collect.startOfInput', onCollectUpdated: 'collect.updated', onCollectFailed: 'collect.failed', onCollectEnded: 'collect.ended', onTapStarted: 'tap.started', onTapEnded: 'tap.ended', onDetectStarted: 'detect.started', onDetectUpdated: 'detect.updated', onDetectEnded: 'detect.ended', } constructor(options: CallOptions) { super({ swClient: options.voice._sw }) this._voice = options.voice this._payload = options.payload this._context = options.payload?.context this._connectPayload = options.connectPayload if (options.listeners) { this.listen(options.listeners) } } /** Unique id for this voice call */ get id() { return this._payload?.call_id } get callId() { return this._payload?.call_id } get state() { return this._payload?.call_state } get callState() { return this._payload?.call_state } get tag() { return this._payload?.tag } get nodeId() { return this._payload?.node_id } get device() { return this._payload?.device } /** The type of call. Only phone and sip are currently supported. */ get type() { return this.device?.type ?? '' } /** The phone number that the call is coming from. */ get from() { if (this.type === 'phone') { return ( // @ts-expect-error (this.device?.params?.from_number || this.device?.params?.fromNumber) ?? '' ) } return ( // @ts-expect-error this.device?.params?.from ?? '' ) } /** The phone number you are attempting to call. */ get to() { if (this.type === 'phone') { return ( // @ts-expect-error (this.device?.params?.to_number || this.device?.params?.toNumber) ?? '' ) } // @ts-expect-error return this.device?.params?.to ?? '' } get headers() { // @ts-expect-error return this.device?.params?.headers ?? [] } get active() { return this.state === 'answered' } get connected() { return this.connectState === 'connected' } get direction() { return this._payload?.direction } get context() { return this._context } get connectState() { return this._connectPayload?.connect_state } get peer() { return this._peer } /** @internal */ set peer(callInstance: Call | undefined) { this._peer = callInstance } /** @internal */ setPayload(payload: CallingCall) { this._payload = payload } /** @internal */ setConnectPayload(payload: CallingCallConnectEventParams) { this._connectPayload = payload } /** * Hangs up the call. * @param reason Optional reason for hanging up * * @example * * ```js * call.hangup(); * ``` */ hangup(reason: VoiceCallDisconnectReason = 'hangup') { return new Promise<void>((resolve, reject) => { if (!this.callId || !this.nodeId) { reject( new Error( `Can't call hangup() on a call that hasn't been established.` ) ) } this.on('call.state', (params) => { if (params.callState === 'ended') { resolve() } }) this._client .execute({ method: 'calling.end', params: { node_id: this.nodeId, call_id: this.callId, reason: reason, }, }) .catch((e) => { reject(e) }) }) } /** * Pass the incoming call to another consumer. * * @example * * ```js * call.pass(); * ``` */ pass() { return new Promise<void>((resolve, reject) => { if (!this.callId || !this.nodeId) { reject(new Error(`Can't call pass() on a call without callId.`)) } this._client .execute({ method: 'calling.pass', params: { node_id: this.nodeId, call_id: this.callId, }, }) .then(() => { resolve() }) .catch((e) => { reject(e) }) }) } /** * Answers the incoming call. * * @example * * ```js * voice.client.listen({ * topics: ['home'], * onCallReceived: async (call) => { * try { * await call.answer() * console.log('Inbound call answered') * } catch (error) { * console.error('Error answering inbound call', error) * } * } * }) * ``` */ answer() { return new Promise<this>((resolve, reject) => { if (!this.callId || !this.nodeId) { reject(new Error(`Can't call answer() on a call without callId.`)) } this.on('call.state', (params) => { if (params.state === 'answered') { resolve(this) } else if (params.state === 'ended') { reject(new Error('Failed to answer the call.')) } }) this._client .execute({ method: 'calling.answer', params: { node_id: this.nodeId, call_id: this.callId, }, }) .catch((e) => { reject(e) }) }) } /** * Play one or multiple media in a Call and waits until the playing has ended. * * The play method is a generic method for all types of media, see * {@link playAudio}, {@link playSilence}, {@link playTTS} or * {@link playRingtone} for more specific usages. * * @param params a media playlist. See {@link Voice.Playlist}. * * @example * * ```js * await call.play(new Voice.Playlist({ volume: 1.0 }).add( * Voice.Playlist.TTS({ * text: 'Welcome to SignalWire! Please enter your 4 digits PIN', * }) * )) * ``` */ play(params: CallPlayMethodParams) { const promise = new Promise<CallPlayback>((resolve, reject) => { const { playlist, listen } = params if (!this.callId || !this.nodeId) { reject(new Error(`Can't call play() on a call not established yet.`)) } const resolveHandler = (callPlayback: CallPlayback) => { this.off('playback.failed', rejectHandler) resolve(callPlayback) } const rejectHandler = (callPlayback: CallPlayback) => { this.off('playback.started', resolveHandler) reject(callPlayback) } this.once('playback.started', resolveHandler) this.once('playback.failed', rejectHandler) const controlId = uuid() this._client.runWorker('voiceCallPlayWorker', { worker: voiceCallPlayWorker, initialState: { controlId, listeners: listen, }, }) this._client .execute({ method: 'calling.play', params: { node_id: this.nodeId, call_id: this.callId, control_id: controlId, volume: playlist.volume, play: toInternalPlayParams(playlist.media), }, }) .then(() => { // TODO: handle then? }) .catch((e) => { this.off('playback.started', resolveHandler) this.off('playback.failed', rejectHandler) reject(e) }) }) return decoratePlaybackPromise.call(this, promise) } /** * Plays an audio file. * * @example * * ```js * const playback = await call.playAudio({ url: 'https://cdn.signalwire.com/default-music/welcome.mp3' }); * ``` */ playAudio(params: CallPlayAudioMethodarams) { const { volume, listen, ...rest } = params const playlist = new Playlist({ volume }).add(Playlist.Audio(rest)) return this.play({ playlist, listen }) } /** * Plays some silence. * * @example * * ```js * const playback = await call.playSilence({ duration: 3 }); * ``` */ playSilence(params: CallPlaySilenceMethodParams) { const { listen, ...rest } = params const playlist = new Playlist().add(Playlist.Silence(rest)) return this.play({ playlist, listen }) } /** * Plays a ringtone. * * @example * * ```js * const playback = await call.playRingtone({ name: 'it' }); * ``` */ playRingtone(params: CallPlayRingtoneMethodParams) { const { volume, listen, ...rest } = params const playlist = new Playlist({ volume }).add(Playlist.Ringtone(rest)) return this.play({ playlist, listen }) } /** * Plays text-to-speech. * * @example * * ```js * const playback = await call.playTTS({ text: 'Welcome to SignalWire!' }); * ``` */ playTTS(params: CallPlayTTSMethodParams) { const { volume, listen, ...rest } = params const playlist = new Playlist({ volume }).add(Playlist.TTS(rest)) return this.play({ playlist, listen }) } /** * Generic method to record a call. Please see {@link recordAudio}. */ record(params: CallRecordMethodParams) { const promise = new Promise<CallRecording>((resolve, reject) => { const { audio, listen } = params if (!this.callId || !this.nodeId) { reject(new Error(`Can't call record() on a call not established yet.`)) } const resolveHandler = (callRecording: CallRecording) => { resolve(callRecording) } const rejectHandler = (callRecording: CallRecording) => { this.off('recording.started', resolveHandler) reject(callRecording) } this.once('recording.started', resolveHandler) this.once('recording.failed', rejectHandler) const controlId = uuid() const record = toSnakeCaseKeys({ audio }) this._client.runWorker('voiceCallRecordWorker', { worker: voiceCallRecordWorker, initialState: { controlId, listeners: listen, }, }) this._client .execute({ method: 'calling.record', params: { node_id: this.nodeId, call_id: this.callId, control_id: controlId, record, }, }) .then(() => { // TODO: handle then? }) .catch((e) => { this.off('recording.started', resolveHandler) this.off('recording.failed', rejectHandler) reject(e) }) }) return decorateRecordingPromise.call(this, promise) } /** * Records the audio from the call. * * @example * * ```js * const recording = await call.recordAudio({ direction: 'both' }) * ``` */ recordAudio(params: CallRecordAudioMethodParams = {}) { const { listen, ...rest } = params return this.record({ audio: rest, listen, }) } /** * Generic method to prompt the user for input. Please see {@link promptAudio}, {@link promptRingtone}, {@link promptTTS}. */ prompt(params: CallPromptMethodParams) { const promise = new Promise<CallPrompt>((resolve, reject) => { const { listen, ...rest } = params if (!this.callId || !this.nodeId) { reject(new Error(`Can't call record() on a call not established yet.`)) } if (!params.playlist) { reject(new Error(`Missing 'playlist' params.`)) } const controlId = `${uuid()}.prompt` const { volume, media } = params.playlist // TODO: move this to a method to build `collect` const { initial_timeout, digits, speech } = toSnakeCaseKeys(rest) const collect = { initial_timeout, digits, speech, } this._client.runWorker('voiceCallPlayWorker', { worker: voiceCallPlayWorker, initialState: { controlId, }, }) this._client.runWorker('voiceCallCollectWorker', { worker: voiceCallCollectWorker, initialState: { controlId, }, }) this._client .execute({ method: 'calling.play_and_collect', params: { node_id: this.nodeId, call_id: this.callId, control_id: controlId, volume, play: toInternalPlayParams(media), collect, }, }) .then(() => { const promptInstance = new CallPrompt({ call: this, listeners: listen, // @ts-expect-error payload: { control_id: controlId, call_id: this.id!, node_id: this.nodeId!, }, }) this._client.instanceMap.set<CallPrompt>(controlId, promptInstance) this.emit('prompt.started', promptInstance) promptInstance.emit('prompt.started', promptInstance) resolve(promptInstance) }) .catch((e) => { this.emit('prompt.failed', e) reject(e) }) }) return decoratePromptPromise.call(this, promise) } /** * Play an audio while collecting user input from the call, such as `digits` or `speech`. * * @example * * Prompting for digits and waiting for a result: * * ```js * const prompt = await call.promptAudio({ * url: 'https://cdn.signalwire.com/default-music/welcome.mp3', * digits: { * max: 5, * digitTimeout: 2, * terminators: '#*' * } * }) * const { type, digits, terminator } = await prompt.ended() * ``` */ promptAudio(params: CallPromptAudioMethodParams) { const { url, volume, ...rest } = params const playlist = new Playlist({ volume }).add(Playlist.Audio({ url })) return this.prompt({ playlist, ...rest, }) } /** * Play a ringtone while collecting user input from the call, such as `digits` or `speech`. * * @example * * Prompting for digits and waiting for a result: * * ```js * const prompt = await call.promptRingtone({ * name: 'it', * duration: 10, * digits: { * max: 5, * digitTimeout: 2, * terminators: '#*' * } * }) * const { type, digits, terminator } = await prompt.ended() * ``` */ promptRingtone(params: CallPromptRingtoneMethodParams) { const { name, duration, volume, ...rest } = params const playlist = new Playlist({ volume }).add( Playlist.Ringtone({ name, duration }) ) return this.prompt({ playlist, ...rest, }) } /** * Say some text while collecting user input from the call, such as `digits` or `speech`. * * @example * * Prompting for digits and waiting for a result: * * ```js * const prompt = await call.promptTTS({ * text: 'Please enter your PIN', * digits: { * max: 5, * digitTimeout: 2, * terminators: '#*' * } * }) * const { type, digits, terminator } = await prompt.ended() * ``` */ promptTTS(params: CallPromptTTSMethodParams) { const { text, language, gender, volume, ...rest } = params const playlist = new Playlist({ volume }).add( Playlist.TTS({ text, language, gender }) ) return this.prompt({ playlist, ...rest, }) } /** * Play DTMF digits to the other party on the call. * * @example * * ```js * await call.sendDigits('123') * ``` */ sendDigits(digits: string) { return new Promise<Call>((resolve, reject) => { if (!this.callId || !this.nodeId) { reject( new Error(`Can't call sendDigits() on a call not established yet.`) ) } const callStateHandler = (params: any) => { if (params.callState === 'ended' || params.callState === 'ending') { reject( new Error( "Call is ended or about to end, couldn't send digits in time." ) ) } } this.once('call.state', callStateHandler) const cleanup = () => { this.off('call.state', callStateHandler) } const resolveHandler = (call: Call) => { cleanup() // @ts-expect-error this.off('send_digits.failed', rejectHandler) resolve(call) } const rejectHandler = (error: Error) => { cleanup() // @ts-expect-error this.off('send_digits.finished', resolveHandler) reject(error) } // @ts-expect-error this.once('send_digits.finished', resolveHandler) // @ts-expect-error this.once('send_digits.failed', rejectHandler) const controlId = uuid() this._client.runWorker('voiceCallSendDigitsWorker', { worker: voiceCallSendDigitsWorker, initialState: { controlId, }, }) this._client .execute({ method: 'calling.send_digits', params: { node_id: this.nodeId, call_id: this.callId, control_id: controlId, digits, }, }) .catch((e) => { reject(e) }) }) } /** * Intercept call media and stream it to the specified WebSocket endpoint. * Prefer using {@link tapAudio} if you only need to tap audio. * * @example * * ```js * const tap = await call.tapAudio({ * audio: { * direction: 'both', * }, * device: { * type: 'ws', * uri: 'wss://example.domain.com/endpoint', * }, * }) * ``` */ tap(params: CallTapMethodParams) { const promise = new Promise<CallTap>((resolve, reject) => { if (!this.callId || !this.nodeId) { reject(new Error(`Can't call tap() on a call not established yet.`)) } const resolveHandler = (callTap: CallTap) => { this.off('tap.ended', rejectHandler) resolve(callTap) } const rejectHandler = (callTap: CallTap) => { this.off('tap.started', resolveHandler) reject(callTap) } this.once('tap.started', resolveHandler) this.once('tap.ended', rejectHandler) const controlId = uuid() // TODO: Move to a method to build the objects and transform camelCase to snake_case const { audio = {}, device: { type, ...rest }, listen, } = params this._client.runWorker('voiceCallTapWorker', { worker: voiceCallTapWorker, initialState: { controlId, listeners: listen, }, }) this._client .execute({ method: 'calling.tap', params: { node_id: this.nodeId, call_id: this.callId, control_id: controlId, tap: { type: 'audio', params: audio, }, device: { type, params: rest, }, }, }) .then(() => { // TODO: handle then? }) .catch((e) => { this.off('tap.started', resolveHandler) this.off('tap.ended', rejectHandler) reject(e) }) }) return decorateTapPromise.call(this, promise) } /** * Intercept call audio and stream it to the specified WebSocket endpoint. * * @example * * ```js * const tap = await call.tapAudio({ * direction: 'both', * device: { * type: 'ws', * uri: 'wss://example.domain.com/endpoint', * }, * }) * * await tap.stop() * ``` */ tapAudio(params: CallTapAudioMethodParams) { const { direction, ...rest } = params return this.tap({ audio: { direction }, ...rest }) } /** * Attempt to connect an existing call to a new outbound call. You can wait * until the call is disconnected by calling {@link waitForDisconnected}. * * This is a generic method that allows you to connect to multiple devices in * series, parallel, or combinations of both with the use of a * {@link Voice.DeviceBuilder}. For simpler use cases, prefer using * {@link connectPhone} or {@link connectSip}. * * @example * * Connecting to a new SIP call. * * ```js * const plan = new Voice.DeviceBuilder().add( * Voice.DeviceBuilder.Sip({ * from: 'sip:user1@domain.com', * to: 'sip:user2@domain.com', * timeout: 30, * }) * ) * * const peer = await call.connect(plan) * ``` */ connect(params: VoiceCallConnectMethodParams) { return new Promise<any>((resolve, reject) => { if (!this.callId || !this.nodeId) { reject(new Error(`Can't call connect() on a call not established yet.`)) } const _tag = uuid() // We can ignore the "ringback" error since we just want to cleanup "...rest" // @ts-expect-error const { devices, ringback, ...rest } = params const executeParams: Record<string, any> = { tag: _tag, ...toSnakeCaseKeys(rest), } if ('ringback' in params) { executeParams.ringback = toInternalPlayParams( params.ringback?.media ?? [] ) } if (params instanceof DeviceBuilder) { executeParams.devices = toInternalDevices(params.devices) } else if (devices instanceof DeviceBuilder) { executeParams.devices = toInternalDevices(devices.devices) } else { throw new Error('[connect] Invalid "devices" parameter.') } const resolveHandler = (payload: Call) => { // @ts-expect-error this.off('connect.failed', rejectHandler) resolve(payload) } const rejectHandler = (payload: CallingCallConnectFailedEventParams) => { // @ts-expect-error this.off('connect.connected', resolveHandler) reject(toExternalJSON(payload)) } // @ts-expect-error this.once('connect.connected', resolveHandler) // @ts-expect-error this.once('connect.failed', rejectHandler) this._client.runWorker('voiceCallConnectWorker', { worker: voiceCallConnectWorker, initialState: { voice: this._voice, tag: _tag, }, }) this._client .execute({ method: 'calling.connect', params: { node_id: this.nodeId, call_id: this.callId, tag: _tag, ...executeParams, }, }) .catch((e) => { // @ts-expect-error this.off('connect.connected', resolveHandler) // @ts-expect-error this.off('connect.failed', rejectHandler) reject(e) }) }) } /** * Attempt to connect an existing call to a new outbound phone call. You can * wait until the call is disconnected by calling {@link waitForDisconnected}. * * @example * * ```js * const peer = await call.connectPhone({ * from: '+xxxxxx', * to: '+yyyyyy', * timeout: 30 * }) * ``` */ connectPhone({ ringback, maxPricePerMinute, ...params }: VoiceCallConnectPhoneMethodParams) { const devices = new DeviceBuilder().add(DeviceBuilder.Phone(params)) return this.connect({ devices, maxPricePerMinute, ringback }) } /** * Attempt to connect an existing call to a new outbound SIP call. You can * wait until the call is disconnected by calling {@link waitForDisconnected}. * * @example * * ```js * const peer = await call.connectPhone({ * from: 'sip:user1@domain.com', * to: 'sip:user2@domain.com', * timeout: 30 * }) * ``` */ connectSip({ ringback, maxPricePerMinute, ...params }: VoiceCallConnectSipMethodParams) { const devices = new DeviceBuilder().add(DeviceBuilder.Sip(params)) return this.connect({ devices, maxPricePerMinute, ringback }) } disconnect() { return new Promise<void>((resolve, reject) => { if (!this.callId || !this.nodeId || !this.peer) { reject( new Error(`Can't call disconnect() on a call not connected yet.`) ) } const resolveHandler = () => { resolve() } // @ts-expect-error this.once('connect.disconnected', resolveHandler) this._client .execute({ method: 'calling.disconnect', params: { node_id: this.nodeId, call_id: this.callId, }, }) .catch((e) => { // @ts-expect-error this.off('connect.disconnected', resolveHandler) reject(e) }) }) } /** * @deprecated use {@link disconnected} instead. */ waitForDisconnected() { return this.disconnect } disconnected() { return new Promise<this>((resolve) => { const resolveHandler = () => { resolve(this) } // @ts-expect-error this.once('connect.disconnected', resolveHandler) // @ts-expect-error this.once('connect.failed', resolveHandler) if (this.state === 'ended' || this.state === 'ending') { return resolveHandler() } }) } /** * Generic method. Please see {@link amd}, {@link detectFax}, {@link detectDigit}. */ detect(params: CallDetectMethodParams) { const promise = new Promise<CallDetect>((resolve, reject) => { if (!this.callId || !this.nodeId) { reject(new Error(`Can't call detect() on a call not established yet.`)) } const controlId = uuid() // TODO: build params in a method const { listen, timeout, type, waitForBeep = false, ...rest } = params this._client.runWorker('voiceCallDetectWorker', { worker: voiceCallDetectWorker, initialState: { controlId, listeners: listen, }, }) this._client .execute({ method: 'calling.detect', params: { node_id: this.nodeId, call_id: this.callId, control_id: controlId, timeout, detect: { type, params: toSnakeCaseKeys(rest), }, }, }) .then(() => { const detectInstance = new CallDetect({ call: this, payload: { control_id: controlId, call_id: this.id!, node_id: this.nodeId!, waitForBeep: params.waitForBeep ?? false, }, listeners: listen, }) this._client.instanceMap.set<CallDetect>(controlId, detectInstance) this.emit('detect.started', detectInstance) detectInstance.emit('detect.started', detectInstance) resolve(detectInstance) }) .catch((e) => { this.emit('detect.ended', e) reject(e) }) }) return decorateDetectPromise.call(this, promise) } /** * Detects the presence of an answering machine. * * @example * * ```js * const detect = await call.amd() * const result = await detect.ended() * * console.log('Detect result:', result.type) * ``` */ amd(params: CallDetectMachineParams = {}) { return this.detect({ ...params, type: 'machine', }) } /** * Alias for amd() */ detectAnsweringMachine = this.amd /** * Detects the presence of a fax machine. * * @example * * ```js * const detect = await call.detectFax() * const result = await detect.ended() * * console.log('Detect result:', result.type) * ``` */ detectFax(params: CallDetectFaxParams = {}) { return this.detect({ ...params, type: 'fax', }) } /** * Detects digits in the audio stream. * * @example * * ```js * const detect = await call.detectDigit() * const result = await detect.ended() * * console.log('Detect result:', result.type) * ``` */ detectDigit(params: CallDetectDigitParams = {}) { return this.detect({ ...params, type: 'digit', }) } /** * Collect user input from the call, such as `digits` or `speech`. * * @example * * Collect digits and waiting for a result: * * ```js * const collectObj = await call.collect({ * digits: { * max: 5, * digitTimeout: 2, * terminators: '#*' * } * }) * const { digits, terminator } = await collectObj.ended() * ``` */ collect(params: CallCollectMethodParams) { const promise = new Promise<CallCollect>((resolve, reject) => { const { listen, ...rest } = params if (!this.callId || !this.nodeId) { reject(new Error(`Can't call collect() on a call not established yet.`)) } const controlId = uuid() // TODO: move this to a method to build the params const { initial_timeout, partial_results, digits, speech, continuous, send_start_of_input, start_input_timers, } = toSnakeCaseKeys(rest) this._client.runWorker('voiceCallCollectWorker', { worker: voiceCallCollectWorker, initialState: { controlId, }, }) this._client .execute({ method: 'calling.collect', params: { node_id: this.nodeId, call_id: this.callId, control_id: controlId, initial_timeout, digits, speech, partial_results, continuous, send_start_of_input, start_input_timers, }, }) .then(() => { const collectInstance = new CallCollect({ call: this, listeners: listen, // @ts-expect-error payload: { control_id: controlId, call_id: this.id!, node_id: this.nodeId!, }, }) this._client.instanceMap.set<CallCollect>(controlId, collectInstance) this.emit('collect.started', collectInstance) collectInstance.emit('collect.started', collectInstance) resolve(collectInstance) }) .catch((e) => { this.emit('collect.failed', e) reject(e) }) }) return decorateCollectPromise.call(this, promise) } /** * Returns a promise that is resolved only after the current call is in one of * the specified states. * * @returns true if the requested states have been reached, false if they * won't be reached because the call ended. * * @example * * ```js * await call.waitFor('ended') * ``` */ waitFor(params: CallingCallWaitForState | CallingCallWaitForState[]) { return new Promise((resolve) => { if (!params) { resolve(true) } const events = Array.isArray(params) ? params : [params] const emittedCallStates = new Set<CallingCallState>() const shouldResolve = () => emittedCallStates.size === events.length const shouldWaitForEnded = events.includes('ended') // If the user is not awaiting for the `ended` state // and we've got that from the server then we won't // get the event/s the user was awaiting for const shouldResolveUnsuccessful = (state: CallingCallState) => { return !shouldWaitForEnded && state === 'ended' } this.on('call.state', (params) => { if (events.includes(params.state as CallingCallWaitForState)) { emittedCallStates.add(params.state!) } else if (shouldResolveUnsuccessful(params.state!)) { return resolve(false) } if (shouldResolve()) { resolve(true) } }) }) } }