diff --git a/.changeset/blue-files-tease.md b/.changeset/blue-files-tease.md new file mode 100644 index 000000000..df50f1723 --- /dev/null +++ b/.changeset/blue-files-tease.md @@ -0,0 +1,7 @@ +--- +'@sw-internal/playground-realtime-api': minor +'@signalwire/core': minor +'@signalwire/realtime-api': minor +--- + +Expose the `Voice.createPlaylist()` method to simplify playing media on a Voice Call. diff --git a/.changeset/calm-dingos-exist.md b/.changeset/calm-dingos-exist.md new file mode 100644 index 000000000..f77c22854 --- /dev/null +++ b/.changeset/calm-dingos-exist.md @@ -0,0 +1,6 @@ +--- +'@signalwire/core': patch +'@signalwire/realtime-api': patch +--- + +[internal] add sendDigits method to Voice.Call diff --git a/.changeset/clever-countries-bake.md b/.changeset/clever-countries-bake.md new file mode 100644 index 000000000..5644604e9 --- /dev/null +++ b/.changeset/clever-countries-bake.md @@ -0,0 +1,7 @@ +--- +'@sw-internal/playground-realtime-api': minor +'@signalwire/core': minor +'@signalwire/realtime-api': minor +--- + +Add ability to record audio in `Voice` Call. diff --git a/.changeset/few-colts-mate.md b/.changeset/few-colts-mate.md new file mode 100644 index 000000000..43be01144 --- /dev/null +++ b/.changeset/few-colts-mate.md @@ -0,0 +1,5 @@ +--- +'@signalwire/core': patch +--- + +[internal] add `runWorker` api to replace setWorker/attachWorker combo diff --git a/.changeset/flat-students-fail.md b/.changeset/flat-students-fail.md new file mode 100644 index 000000000..1ecd52247 --- /dev/null +++ b/.changeset/flat-students-fail.md @@ -0,0 +1,7 @@ +--- +'@sw-internal/playground-realtime-api': minor +'@signalwire/core': minor +'@signalwire/realtime-api': minor +--- + +Add ability to prompt for digits or speech using `prompt()` in `Voice` Call. diff --git a/.changeset/forty-moons-dance.md b/.changeset/forty-moons-dance.md new file mode 100644 index 000000000..b726620a8 --- /dev/null +++ b/.changeset/forty-moons-dance.md @@ -0,0 +1,7 @@ +--- +'@sw-internal/playground-realtime-api': minor +'@signalwire/core': minor +'@signalwire/realtime-api': minor +--- + +Expose the `Voice.createDialer()` method to simplify dialing devices on a Voice Call. diff --git a/.changeset/friendly-pumpkins-tickle.md b/.changeset/friendly-pumpkins-tickle.md new file mode 100644 index 000000000..23e1e2360 --- /dev/null +++ b/.changeset/friendly-pumpkins-tickle.md @@ -0,0 +1,6 @@ +--- +'@signalwire/core': patch +'@signalwire/realtime-api': patch +--- + +[internal] Migrate Voice namespace to runWorkers API diff --git a/.changeset/hip-wombats-help.md b/.changeset/hip-wombats-help.md new file mode 100644 index 000000000..74a6f6d66 --- /dev/null +++ b/.changeset/hip-wombats-help.md @@ -0,0 +1,6 @@ +--- +'@signalwire/core': patch +'@signalwire/realtime-api': patch +--- + +[internal] Add ability to return the payload when the dial fails diff --git a/.changeset/hungry-singers-deliver.md b/.changeset/hungry-singers-deliver.md new file mode 100644 index 000000000..5c18504a7 --- /dev/null +++ b/.changeset/hungry-singers-deliver.md @@ -0,0 +1,7 @@ +--- +'@sw-internal/playground-realtime-api': minor +'@signalwire/core': minor +'@signalwire/realtime-api': minor +--- + +Add ability to play media in `Voice` Call. diff --git a/.changeset/mean-starfishes-compete.md b/.changeset/mean-starfishes-compete.md new file mode 100644 index 000000000..95e9e6efe --- /dev/null +++ b/.changeset/mean-starfishes-compete.md @@ -0,0 +1,7 @@ +--- +'@sw-internal/playground-realtime-api': minor +'@signalwire/core': minor +'@signalwire/realtime-api': minor +--- + +Add ability to connect and disconnect legs in `Voice` namespace. diff --git a/.changeset/modern-terms-wave.md b/.changeset/modern-terms-wave.md new file mode 100644 index 000000000..75ea1eef8 --- /dev/null +++ b/.changeset/modern-terms-wave.md @@ -0,0 +1,5 @@ +--- +'@signalwire/realtime-api': patch +--- + +[internal] remove usage of synthetic events for dial/answer/hangup diff --git a/.changeset/popular-steaks-draw.md b/.changeset/popular-steaks-draw.md new file mode 100644 index 000000000..e13f687d9 --- /dev/null +++ b/.changeset/popular-steaks-draw.md @@ -0,0 +1,7 @@ +--- +'@sw-internal/playground-realtime-api': minor +'@signalwire/core': minor +'@signalwire/realtime-api': minor +--- + +Add ability to tap audio in `Voice` Call. diff --git a/.changeset/real-bikes-travel.md b/.changeset/real-bikes-travel.md new file mode 100644 index 000000000..5f28e32a0 --- /dev/null +++ b/.changeset/real-bikes-travel.md @@ -0,0 +1,5 @@ +--- +'@signalwire/core': patch +--- + +[internal] add option to skip caching the base instance when using the event emitter transform pipeline. diff --git a/.changeset/rich-bikes-drive.md b/.changeset/rich-bikes-drive.md new file mode 100644 index 000000000..51b3dd5a4 --- /dev/null +++ b/.changeset/rich-bikes-drive.md @@ -0,0 +1,7 @@ +--- +'@sw-internal/playground-realtime-api': minor +'@signalwire/core': minor +'@signalwire/realtime-api': minor +--- + +Add ability to start detectors for machine/digit/fax in `Voice` Call. diff --git a/.changeset/seven-steaks-punch.md b/.changeset/seven-steaks-punch.md new file mode 100644 index 000000000..fdcf24a0a --- /dev/null +++ b/.changeset/seven-steaks-punch.md @@ -0,0 +1,7 @@ +--- +'@sw-internal/playground-realtime-api': minor +'@signalwire/core': minor +'@signalwire/realtime-api': minor +--- + +Add `waitForEnded()` method to the CallPlayback component to easily wait for playbacks to end. diff --git a/.changeset/sweet-camels-melt.md b/.changeset/sweet-camels-melt.md new file mode 100644 index 000000000..3303a1b3c --- /dev/null +++ b/.changeset/sweet-camels-melt.md @@ -0,0 +1,7 @@ +--- +'@sw-internal/playground-realtime-api': minor +'@signalwire/core': minor +'@signalwire/realtime-api': minor +--- + +Add ability to receive inbound Calls in the `Voice` namespace. diff --git a/.changeset/thick-starfishes-appear.md b/.changeset/thick-starfishes-appear.md new file mode 100644 index 000000000..ce72c466d --- /dev/null +++ b/.changeset/thick-starfishes-appear.md @@ -0,0 +1,7 @@ +--- +'@sw-internal/playground-realtime-api': patch +'@signalwire/core': patch +'@signalwire/realtime-api': patch +--- + +Migrate `createDialer` and `createPlaylist` to Dialer and Playlist constructors diff --git a/.drone.yml b/.drone.yml index 115e9c4c2..fd2fa15e4 100644 --- a/.drone.yml +++ b/.drone.yml @@ -30,6 +30,19 @@ steps: from_secret: RELAY_FROM_NUMBER RELAY_TO_NUMBER: from_secret: RELAY_TO_NUMBER + # Scoped value for Voice tests + VOICE_HOST: + from_secret: VOICE_HOST + VOICE_PROJECT: + from_secret: VOICE_PROJECT + VOICE_TOKEN: + from_secret: VOICE_TOKEN + VOICE_CONTEXT: + from_secret: VOICE_CONTEXT + VOICE_DIAL_FROM_NUMBER: + from_secret: VOICE_DIAL_FROM_NUMBER + VOICE_DIAL_TO_NUMBER: + from_secret: VOICE_DIAL_TO_NUMBER trigger: event: push diff --git a/internal/e2e-realtime-api/.env.example b/internal/e2e-realtime-api/.env.example index e254fc147..6aeb3070b 100644 --- a/internal/e2e-realtime-api/.env.example +++ b/internal/e2e-realtime-api/.env.example @@ -5,3 +5,11 @@ RELAY_TOKEN=yyy RELAY_CONTEXT=default RELAY_FROM_NUMBER=+10000000000 RELAY_TO_NUMBER=+10000000001 + +# Voice Namespace +#------------------------------------ +VOICE_DIAL_FROM_NUMBER=+10000000000 +VOICE_DIAL_TO_NUMBER=+10000000001 +VOICE_CONTEXT="office" +VOICE_PROJECT=xxx +VOICE_TOKEN=yyy diff --git a/internal/e2e-realtime-api/src/voice.test.ts b/internal/e2e-realtime-api/src/voice.test.ts new file mode 100644 index 000000000..8d2fbd1a1 --- /dev/null +++ b/internal/e2e-realtime-api/src/voice.test.ts @@ -0,0 +1,69 @@ +import { Voice } from '@signalwire/realtime-api' +import { createTestRunner } from './utils' + +const handler = () => { + return new Promise(async (resolve, reject) => { + const client = new Voice.Client({ + host: process.env.VOICE_HOST || 'relay.swire.io', + project: process.env.VOICE_PROJECT as string, + token: process.env.VOICE_TOKEN as string, + contexts: [process.env.VOICE_CONTEXT as string], + // debug: { + // logWsTraffic: true, + // }, + }) + + client.on('call.received', async (call) => { + console.log('Got call', call.id, call.from, call.to, call.direction) + + try { + await call.answer() + console.log('Inbound call answered') + + const recording = await call.recordAudio() + console.log('Recording STARTED!', recording.id) + + const playlist = new Voice.Playlist({ volume: 2 }).add( + Voice.Playlist.TTS({ + text: 'Message is getting recorded', + }) + ) + const playback = await call.play(playlist) + console.log('Playback', playback.id) + + console.log('Waiting for Playback to end') + // TODO: waitForEnded should probably accept a timeout + await playback.waitForEnded() + console.log('Playback ended') + + console.log('Finishing the call.') + await call.hangup() + + resolve(0) + } catch (error) { + console.error('Error', error) + reject(4) + } + }) + + const call = await client.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + }) + + console.log('Call resolved', call.id) + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/playground-realtime-api/.env.example b/internal/playground-realtime-api/.env.example new file mode 100644 index 000000000..4c49603d4 --- /dev/null +++ b/internal/playground-realtime-api/.env.example @@ -0,0 +1,6 @@ +HOST=example.domain.com +PROJECT=xxx +TOKEN=yyy +RELAY_CONTEXT=default +FROM_NUMBER=+1xxx +TO_NUMBER=+1yyy diff --git a/internal/playground-realtime-api/src/voice/index.ts b/internal/playground-realtime-api/src/voice/index.ts index 02cd3505d..8f68df082 100644 --- a/internal/playground-realtime-api/src/voice/index.ts +++ b/internal/playground-realtime-api/src/voice/index.ts @@ -1,42 +1,274 @@ import { Voice } from '@signalwire/realtime-api' +const sleep = (ms = 3000) => { + return new Promise((r) => { + setTimeout(r, ms) + }) +} + +// In this example you need to perform and outbound/inbound call +const RUN_DETECTOR = false + async function run() { try { const client = new Voice.Client({ - // @ts-expect-error host: process.env.HOST || 'relay.swire.io', project: process.env.PROJECT as string, token: process.env.TOKEN as string, + contexts: [process.env.RELAY_CONTEXT as string], + // logLevel: 'trace', + // debug: { + // logWsTraffic: true, + // }, }) - // call.on('call.created', () => {}) + client.on('call.received', async (call) => { + console.log('Got call', call.id, call.from, call.to, call.direction) + + try { + await call.answer() + console.log('Inbound call answered') + await sleep(1000) + + // Send digits to trigger the detector + await call.sendDigits('1w2w3') + + // Play media to mock an answering machine + // await call.play({ + // media: [ + // { + // type: 'tts', + // text: 'Hello, please leave a message', + // }, + // { + // type: 'silence', + // duration: 2, + // }, + // { + // type: 'audio', + // url: 'https://www.soundjay.com/buttons/beep-01a.mp3', + // }, + // ], + // volume: 2.0, + // }) + + // setTimeout(async () => { + // console.log('Terminating the call') + // await call.hangup() + // console.log('Call terminated!') + // }, 3000) + } catch (error) { + console.error('Error answering inbound call', error) + } + }) try { - const call = await client.dial({ - devices: [ - [ - { - type: 'phone', - to: '+12083660792', - from: '+15183601338', - timeout: 30, - }, + // Using "new Voice.Dialer" API + // const dialer = new Voice.Dialer().add( + // Voice.Dialer.Phone({ + // to: process.env.TO_NUMBER as string, + // from: process.env.FROM_NUMBER as string, + // timeout: 30, + // }) + // ) + // const call = await client.dial(dialer) + + // Using dialPhone Alias + const call = await client.dialPhone({ + to: process.env.TO_NUMBER as string, + from: process.env.FROM_NUMBER as string, + timeout: 30, + }) + + console.log('Dial resolved!', call.id) + + if (RUN_DETECTOR) { + // See the `call.received` handler + const detect = await call.detectDigit() + const result = await detect.waitForResult() + console.log('Detect Result', result.type) + + await sleep() + } + + try { + const peer = await call.connect({ + devices: [ + [ + { + type: 'sip', + from: 'sip:user1@domain.com', + to: 'sip:user2@domain.com', + timeout: 30, + }, + ], ], + ringback: [{ type: 'ringtone', name: 'it' }], + }) + + console.log('Peer:', peer.id, peer.type, peer.from, peer.to) + + console.log('Main:', call.id, call.type, call.from, call.to) + + // Wait until Main and Peer are connected + await call.waitUntilConnected() + + const playlist = new Voice.Playlist({ volume: 2 }).add( + Voice.Playlist.TTS({ + text: 'Thank you, you are now disconnected from the peer', + }) + ) + await call.play(playlist) + + await sleep() + } catch (error) { + console.error('Connect Error', error) + } + + call.on('tap.started', (p) => { + console.log('>> tap.started', p.id, p.state) + }) + + call.on('tap.ended', (p) => { + console.log('>> tap.ended', p.id, p.state) + }) + + const tap = await call.tapAudio({ + direction: 'both', + device: { + type: 'ws', + uri: 'wss://example.domain.com/endpoint', + }, + }) + + await sleep(1000) + console.log('>> Trying to stop', tap.id, tap.state) + await tap.stop() + + call.on('prompt.started', (p) => { + console.log('>> prompt.started', p.id) + }) + call.on('prompt.updated', (p) => { + console.log('>> prompt.updated', p.id) + }) + call.on('prompt.failed', (p) => { + console.log('>> prompt.failed', p.id, p.reason) + }) + call.on('prompt.ended', (p) => { + console.log( + '>> prompt.ended', + p.id, + p.type, + 'Digits: ', + p.digits, + 'Terminator', + p.terminator + ) + }) + + const prompt = await call.prompt({ + media: [ + { + type: 'tts', + text: 'Welcome to SignalWire! Please enter your 4 digits PIN', + }, ], + volume: 1.0, + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, }) - console.log('Dial resolved!') + /** Wait for the result - sync way */ + // const { type, digits, terminator } = await prompt.waitForResult() + // console.log('Prompt Output:', type, digits, terminator) - setTimeout(async () => { - console.log('Terminating the call') - await call.hangup() - console.log('Call terminated!') - }, 3000) - } catch (e) { - console.log('---> E', JSON.stringify(e, null, 2)) - } + console.log('Prompt STARTED!', prompt.id) + await prompt.setVolume(2.0) + await sleep() + await prompt.stop() + console.log('Prompt STOPPED!', prompt.id) + + call.on('recording.started', (r) => { + console.log('>> recording.started', r.id) + }) + call.on('recording.failed', (r) => { + console.log('>> recording.failed', r.id, r.state) + }) + call.on('recording.ended', (r) => { + console.log( + '>> recording.ended', + r.id, + r.state, + r.size, + r.duration, + r.url + ) + }) + + const recording = await call.recordAudio() + console.log('Recording STARTED!', recording.id) + + call.on('playback.started', (p) => { + console.log('>> playback.started', p.id, p.state) + }) + call.on('playback.updated', (p) => { + console.log('>> playback.updated', p.id, p.state) + }) + call.on('playback.ended', (p) => { + console.log('>> playback.ended', p.id, p.state) + }) - console.log('Client Running..') + const playlist = new Voice.Playlist({ volume: 2 }) + .add( + Voice.Playlist.Audio({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + }) + ) + .add( + Voice.Playlist.Silence({ + duration: 5, + }) + ) + .add( + Voice.Playlist.TTS({ + text: 'Thank you, you are now disconnected from the peer', + }) + ) + const playback = await call.play(playlist) + + // To wait for the playback to end (without pause/resume/stop it) + // await playback.waitForEnded() + + console.log('Playback STARTED!', playback.id) + + await sleep() + await playback.pause() + console.log('Playback PAUSED!') + await sleep() + await playback.resume() + console.log('Playback RESUMED!') + await sleep() + await playback.stop() + console.log('Playback STOPPED!') + + await sleep() + await recording.stop() + console.log( + 'Recording STOPPED!', + recording.id, + recording.state, + recording.size, + recording.duration, + recording.url + ) + + await call.hangup() + } catch (error) { + console.log('Error:', error) + } } catch (error) { console.log('', error) } diff --git a/packages/core/src/BaseComponent.ts b/packages/core/src/BaseComponent.ts index 5f9a96e26..27517a5b6 100644 --- a/packages/core/src/BaseComponent.ts +++ b/packages/core/src/BaseComponent.ts @@ -20,7 +20,8 @@ import { SDKWorker, SDKWorkerDefinition, SessionAuthStatus, - SDKWorkerParams, + AttachSDKWorkerParams, + SDKWorkerHooks, } from './utils/interfaces' import { EventEmitter } from './utils/EventEmitter' import { SDKState } from './redux/interfaces' @@ -291,7 +292,11 @@ export class BaseComponent< transform: EventTransform payload: unknown }): BaseComponent { - if (!this._eventsTransformsCache.has(internalEvent)) { + if (transform.mode === 'no-cache') { + const instance = transform.instanceFactory(payload) + + return instance + } else if (!this._eventsTransformsCache.has(internalEvent)) { const instance = transform.instanceFactory(payload) this._eventsTransformsCache.set(internalEvent, instance) @@ -942,28 +947,68 @@ export class BaseComponent< } /** @internal */ + protected runWorker( + name: string, + def: SDKWorkerDefinition + ) { + if (this._workers.has(name)) { + getLogger().warn( + `[runWorker] Worker with name ${name} has already been registerd.` + ) + } else { + this._setWorker(name, def) + } + + this._attachWorker(name, def) + } + + /** + * @internal + * @deprecated use {@link runWorker} instead + */ protected setWorker(name: string, def: SDKWorkerDefinition) { - this._workers.set(name, def) + this._setWorker(name, def) } - /** @internal */ - protected attachWorkers(params: Partial> = {}) { - return this._workers.forEach(({ worker }, name) => { - const task = this.store.runSaga(worker, { - instance: this, - runSaga: this.store.runSaga, + /** + * @internal + * @deprecated use {@link runWorker} instead + */ + protected attachWorkers(params: AttachSDKWorkerParams = {}) { + return this._workers.forEach(({ worker, ...workerOptions }, name) => { + this._attachWorker(name, { + worker, + ...workerOptions, ...params, }) - this._runningWorkers.push(task) - /** - * Attaching workers is a one-time op for instances so - * the moment we attach one we'll remove it from the - * queue. - */ - this._workers.delete(name) }) } + private _setWorker( + name: string, + def: SDKWorkerDefinition + ) { + this._workers.set(name, def) + } + + private _attachWorker( + name: string, + { worker, ...params }: SDKWorkerDefinition + ) { + const task = this.store.runSaga(worker, { + instance: this, + runSaga: this.store.runSaga, + ...params, + }) + this._runningWorkers.push(task) + /** + * Attaching workers is a one-time op for instances so + * the moment we attach one we'll remove it from the + * queue. + */ + this._workers.delete(name) + } + private detachWorkers() { this._runningWorkers.forEach((task) => { task.cancel() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e13eee555..5d5a834e0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ import { getLogger, isGlobalEvent, toExternalJSON, + toSnakeCaseKeys, toLocalEvent, toSyntheticEvent, extendComponent, @@ -44,6 +45,7 @@ export { getEventEmitter, isGlobalEvent, toExternalJSON, + toSnakeCaseKeys, toLocalEvent, toInternalEventName, serializeableProxy, @@ -67,6 +69,7 @@ export type { MapToPubSubShape, SDKActions, } from './redux/interfaces' +export type { ToExternalJSONResult } from './utils' export * as actions from './redux/actions' export * as sagaHelpers from './redux/utils/sagaHelpers' export * as sagaEffects from '@redux-saga/core/effects' diff --git a/packages/core/src/memberPosition/workers.ts b/packages/core/src/memberPosition/workers.ts index c0fe0b1cb..628515b49 100644 --- a/packages/core/src/memberPosition/workers.ts +++ b/packages/core/src/memberPosition/workers.ts @@ -226,13 +226,13 @@ const mutateMemberCurrentPosition = ({ } const initializeMemberList = (payload: VideoRoomSubscribedEventParams) => { - const members = payload.room.members + const members = payload.room_session.members const memberList: MemberEventParamsList = new Map() members.forEach((member) => { memberList.set(member.id, { - room_id: payload.room.room_id, - room_session_id: payload.room.room_session_id, + room_id: payload.room_session.room_id, + room_session_id: payload.room_session.id, // At this point we don't have `member.updated` // @ts-expect-error member, diff --git a/packages/core/src/redux/features/pubSub/pubSubSaga.ts b/packages/core/src/redux/features/pubSub/pubSubSaga.ts index 17dcbe63d..bf21f9b7f 100644 --- a/packages/core/src/redux/features/pubSub/pubSubSaga.ts +++ b/packages/core/src/redux/features/pubSub/pubSubSaga.ts @@ -34,6 +34,10 @@ export function* pubSubSaga({ emitter.emit(type, payload) } + getLogger().trace( + 'Emit:', + toInternalEventName({ namespace, event: type }) + ) emitter.emit( toInternalEventName({ namespace, event: type }), payload diff --git a/packages/core/src/redux/features/session/sessionSaga.ts b/packages/core/src/redux/features/session/sessionSaga.ts index e5d35331a..577af8e40 100644 --- a/packages/core/src/redux/features/session/sessionSaga.ts +++ b/packages/core/src/redux/features/session/sessionSaga.ts @@ -88,7 +88,7 @@ export function* executeActionWatcher(session: BaseSession): SagaIterator { ) } } catch (error) { - getLogger().warn('worker error', componentId, error) + getLogger().warn('worker error', componentId, JSON.stringify(error)) if (componentId && requestId) { yield put( componentActions.executeFailure({ diff --git a/packages/core/src/redux/features/shared/namespace.ts b/packages/core/src/redux/features/shared/namespace.ts index d73a535d2..4dd396c81 100644 --- a/packages/core/src/redux/features/shared/namespace.ts +++ b/packages/core/src/redux/features/shared/namespace.ts @@ -73,7 +73,13 @@ export const findNamespaceInPayload = (action: PubSubAction): string => { } else if (isChatEvent(action)) { return '' } else if (isVoiceCallEvent(action)) { - return action.payload.tag + /** + * Some calling events (ie: `calling.call.receive`) have no "tag" + * but we inject it within the workers before put the action. + * See voiceCallPlayWorker as an example. + */ + // @ts-expect-error + return action.payload.tag ?? '' } if ('development' === process.env.NODE_ENV) { diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index d79b9c2e0..f71f67e7c 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -5,6 +5,7 @@ import type { VideoManagerEvent } from './cantina' import type { ChatEvent } from './chat' import type { TaskEvent } from './task' import type { MessagingEvent } from './messaging' +import type { VoiceCallEvent } from './voice' export interface SwEvent { event_channel: string @@ -135,6 +136,7 @@ export type SwEventParams = | ChatEvent | TaskEvent | MessagingEvent + | VoiceCallEvent // prettier-ignore export type PubSubChannelEvents = diff --git a/packages/core/src/types/utils.ts b/packages/core/src/types/utils.ts index 9dcd2dc04..db2c7756d 100644 --- a/packages/core/src/types/utils.ts +++ b/packages/core/src/types/utils.ts @@ -173,3 +173,11 @@ export type DeepReadonly = T extends Builtin : IsUnknown extends true ? unknown : Readonly + +/** + * If one property is present then all properties should be + * present. + */ +export type AllOrNone> = + | T + | Partial> diff --git a/packages/core/src/types/voiceCall.ts b/packages/core/src/types/voiceCall.ts index a47243302..d9519fa86 100644 --- a/packages/core/src/types/voiceCall.ts +++ b/packages/core/src/types/voiceCall.ts @@ -9,24 +9,121 @@ import type { type ToInternalVoiceEvent = `${VoiceNamespace}.${T}` export type VoiceNamespace = typeof PRODUCT_PREFIX_VOICE_CALL +type RingtoneName = + | 'at' + | 'au' + | 'bg' + | 'br' + | 'be' + | 'ch' + | 'cl' + | 'cn' + | 'cz' + | 'de' + | 'dk' + | 'ee' + | 'es' + | 'fi' + | 'fr' + | 'gr' + | 'hu' + | 'il' + | 'in' + | 'it' + | 'lt' + | 'jp' + | 'mx' + | 'my' + | 'nl' + | 'no' + | 'nz' + | 'ph' + | 'pl' + | 'pt' + | 'ru' + | 'se' + | 'sg' + | 'th' + | 'uk' + | 'us' + | 'tw' + | 've' + | 'za' +/** + * Private event types + */ +export type CallDial = 'call.dial' +export type CallState = 'call.state' +export type CallReceive = 'call.receive' +export type CallPlay = 'call.play' +export type CallRecord = 'call.record' +export type CallCollect = 'call.collect' +export type CallTap = 'call.tap' +export type CallConnect = 'call.connect' +export type CallSendDigits = 'call.send_digits' +export type CallDetect = 'call.detect' /** * Public event types */ export type CallCreated = 'call.created' export type CallEnded = 'call.ended' +export type CallReceived = 'call.received' +export type CallPlaybackStarted = 'playback.started' +export type CallPlaybackUpdated = 'playback.updated' +export type CallPlaybackEnded = 'playback.ended' +export type CallRecordingStarted = 'recording.started' +export type CallRecordingUpdated = 'recording.updated' +export type CallRecordingEnded = 'recording.ended' +export type CallRecordingFailed = 'recording.failed' +export type CallPromptStarted = 'prompt.started' +export type CallPromptUpdated = 'prompt.updated' +export type CallPromptEnded = 'prompt.ended' +export type CallPromptFailed = 'prompt.failed' +export type CallTapStarted = 'tap.started' +export type CallTapEnded = 'tap.ended' +// Not exposed yet to the public-side +export type CallConnectConnecting = 'connect.connecting' +export type CallConnectConnected = 'connect.connected' +export type CallConnectDisconnected = 'connect.disconnected' +export type CallConnectFailed = 'connect.failed' +export type CallDetectStarted = 'detect.started' +export type CallDetectUpdated = 'detect.updated' +export type CallDetectEnded = 'detect.ended' /** * List of public event names */ -export type VoiceCallEventNames = CallCreated | CallEnded +export type VoiceCallEventNames = + | CallCreated + | CallEnded + | CallPlaybackStarted + | CallPlaybackUpdated + | CallPlaybackEnded + | CallRecordingStarted + | CallRecordingUpdated + | CallRecordingEnded + | CallRecordingFailed + | CallPromptStarted + | CallPromptUpdated + | CallPromptEnded + | CallPromptFailed + | CallTapStarted + | CallTapEnded + | CallConnectConnecting + | CallConnectConnected + | CallConnectDisconnected + | CallConnectFailed + | CallDetectStarted + | CallDetectUpdated + | CallDetectEnded /** * List of internal events * @internal */ -export type InternalVoiceCallEventNames = - ToInternalVoiceEvent +// export type InternalVoiceCallEventNames = +// ToInternalVoiceEvent type SipCodec = 'PCMU' | 'PCMA' | 'OPUS' | 'G729' | 'G722' | 'VP8' | 'H264' @@ -42,6 +139,8 @@ export interface VoiceCallPhoneParams { timeout?: number } +export type OmitType = Omit + export interface VoiceCallSipParams { type: 'sip' from: string @@ -49,7 +148,7 @@ export interface VoiceCallSipParams { timeout?: number headers?: SipHeader[] codecs?: SipCodec[] - webrtc_media?: boolean + webrtcMedia?: boolean } export interface NestedArray extends Array> {} @@ -61,6 +160,175 @@ export interface VoiceCallDialMethodParams { devices: NestedArray } +export interface VoiceCallPlayAudioParams { + type: 'audio' + url: string +} + +export interface VoiceCallPlayTTSParams { + type: 'tts' + text: string + language?: string + gender?: 'male' | 'female' +} + +export interface VoiceCallPlaySilenceParams { + type: 'silence' + duration: number +} + +export interface VoiceCallPlayRingtoneParams { + type: 'ringtone' + name: RingtoneName + duration?: number +} + +export type VoiceCallPlayParams = + | VoiceCallPlayAudioParams + | VoiceCallPlayTTSParams + | VoiceCallPlaySilenceParams + | VoiceCallPlayRingtoneParams + +export interface VoiceCallPlayMethodParams { + media: NestedArray + volume?: number +} + +export interface VoiceCallPlayAudioMethodParams + extends OmitType { + volume?: number +} + +export interface VoiceCallPlaySilenceMethodParams + extends OmitType {} + +export interface VoiceCallPlayRingtoneMethodParams + extends OmitType { + volume?: number +} +export interface VoiceCallPlayTTSMethodParams + extends OmitType { + volume?: number +} + +export interface VoiceCallRecordMethodParams { + audio: { + beep?: boolean + format?: 'mp3' | 'wav' + stereo?: boolean + direction?: 'listen' | 'speak' | 'both' + initialTimeout?: number + endSilenceTimeout?: number + terminators?: string + } +} + +type SpeechOrDigits = + | { + digits: { + max: number + digitTimeout?: number + terminators?: string + } + speech?: never + } + | { + digits?: never + speech: { + endSilenceTimeout: number + speechTimeout: number + language: number + hints: string[] + } + } +export type VoiceCallPromptMethodParams = SpeechOrDigits & { + media: NestedArray + volume?: number + initialTimeout?: number + partialResults?: boolean +} +export type VoiceCallPromptAudioMethodParams = SpeechOrDigits & + OmitType & { + volume?: number + initialTimeout?: number + partialResults?: boolean + } +export type VoiceCallPromptRingtoneMethodParams = SpeechOrDigits & + OmitType & { + volume?: number + initialTimeout?: number + partialResults?: boolean + } +export type VoiceCallPromptTTSMethodParams = SpeechOrDigits & + OmitType & { + volume?: number + initialTimeout?: number + partialResults?: boolean + } +type TapCodec = 'OPUS' | 'PCMA' | 'PCMU' +interface TapDeviceWS { + type: 'ws' + uri: string + codec?: TapCodec + rate?: number +} + +interface TapDeviceRTP { + type: 'rtp' + addr: string + port: string + codec?: TapCodec + ptime?: number +} + +type TapDevice = TapDeviceWS | TapDeviceRTP +type TapDirection = 'listen' | 'speak' | 'both' +export interface VoiceCallTapMethodParams { + device: TapDevice + audio: { + direction: TapDirection + } +} + +export interface VoiceCallTapAudioMethodParams { + device: TapDevice + direction: TapDirection +} + +export interface VoiceCallConnectMethodParams { + ringback?: NestedArray + devices: NestedArray +} + +interface VoiceCallDetectBaseParams { + timeout?: number + waitForBeep?: boolean // SDK-side only +} + +export interface VoiceCallDetectMachineParams + extends VoiceCallDetectBaseParams { + type: 'machine' + initialTimeout?: number + endSilenceTimeout?: number + machineVoiceThreshold?: number + machineWordsThreshold?: number +} + +export interface VoiceCallDetectFaxParams extends VoiceCallDetectBaseParams { + type: 'fax' + tone?: 'CED' | 'CNG' +} + +export interface VoiceCallDetectDigitParams extends VoiceCallDetectBaseParams { + type: 'digit' + digits?: string +} + +export type VoiceCallDetectMethodParams = + | VoiceCallDetectMachineParams + | VoiceCallDetectFaxParams + | VoiceCallDetectDigitParams + export type VoiceCallDisconnectReason = | 'hangup' | 'cancel' @@ -69,15 +337,256 @@ export type VoiceCallDisconnectReason = | 'decline' | 'error' +export type VoiceCallDialPhoneMethodParams = OmitType +export type VoiceCallDialSipMethodParams = OmitType +export interface CreateVoiceDialerParams { + region?: string +} + +export interface VoiceDialer extends CreateVoiceDialerParams { + devices: VoiceCallDialMethodParams['devices'] + add(params: VoiceCallDeviceParams | VoiceCallDeviceParams[]): this +} + +export interface CreateVoicePlaylistParams { + volume?: number +} + +export interface VoicePlaylist extends CreateVoicePlaylistParams { + media: VoiceCallPlayMethodParams['media'] + add(params: VoiceCallPlayParams): this +} + +/** + * Public Contract for a VoiceCall + */ +export interface VoiceCallPlaybackContract { + /** Unique id for this playback */ + readonly id: string + /** @ignore */ + readonly callId: string + /** @ignore */ + readonly controlId: string + /** @ignore */ + readonly volume: number + /** @ignore */ + readonly state: CallingCallPlayState + + pause(): Promise + resume(): Promise + stop(): Promise + setVolume(volume: number): Promise + waitForEnded(): Promise +} + +/** + * VoiceCallPlayback properties + */ +export type VoiceCallPlaybackEntity = + OnlyStateProperties + +/** + * VoiceCallPlayback methods + */ +export type VoiceCallPlaybackMethods = + OnlyFunctionProperties + +/** + * Public Contract for a VoiceCallRecording + */ +export interface VoiceCallRecordingContract { + /** Unique id for this recording */ + readonly id: string + /** @ignore */ + readonly callId: string + /** @ignore */ + readonly controlId: string + /** @ignore */ + readonly state?: CallingCallRecordState + /** @ignore */ + readonly url?: string + /** @ignore */ + readonly size?: number + /** @ignore */ + readonly duration?: number + + stop(): Promise +} + +/** + * VoiceCallRecording properties + */ +export type VoiceCallRecordingEntity = + OnlyStateProperties + +/** + * VoiceCallRecording methods + */ +export type VoiceCallRecordingMethods = + OnlyFunctionProperties + +/** + * Public Contract for a VoiceCallDetect + */ +export interface VoiceCallDetectContract { + /** Unique id for this recording */ + readonly id: string + /** @ignore */ + readonly callId: string + /** @ignore */ + readonly controlId: string + /** @ignore */ + readonly type?: CallingCallDetectType + + stop(): Promise + waitForResult(): Promise +} + +/** + * VoiceCallDetect properties + */ +export type VoiceCallDetectEntity = OnlyStateProperties + +/** + * VoiceCallDetect methods + */ +export type VoiceCallDetectMethods = + OnlyFunctionProperties + +/** + * Public Contract for a VoiceCallPrompt + */ +export interface VoiceCallPromptContract { + /** Unique id for this recording */ + readonly id: string + /** @ignore */ + readonly callId: string + /** @ignore */ + readonly controlId: string + + readonly type?: CallingCallCollectResult['type'] + /** Alias for type in case of errors */ + readonly reason?: string + readonly digits?: string + readonly terminator?: string + readonly text?: string + readonly confidence?: number + + stop(): Promise + setVolume(volume: number): Promise + waitForResult(): Promise +} + +/** + * VoiceCallPrompt properties + */ +export type VoiceCallPromptEntity = OnlyStateProperties + +/** + * VoiceCallPrompt methods + */ +export type VoiceCallPromptMethods = + OnlyFunctionProperties + +/** + * Public Contract for a VoiceCallTap + */ +export interface VoiceCallTapContract { + /** Unique id for this recording */ + readonly id: string + /** @ignore */ + readonly callId: string + /** @ignore */ + readonly controlId: string + /** @ignore */ + readonly state: CallingCallTapState + + stop(): Promise +} + +/** + * VoiceCallTap properties + */ +export type VoiceCallTapEntity = OnlyStateProperties + +/** + * VoiceCallTap methods + */ +export type VoiceCallTapMethods = OnlyFunctionProperties + /** * Public Contract for a VoiceCall */ export interface VoiceCallContract { /** Unique id for this voice call */ - id: string + readonly id: string + /** @ignore */ + tag: string + /** @ignore */ + callId: string + /** @ignore */ + nodeId: string + /** @ignore */ + state: CallingCallState + /** @ignore */ + context?: string - dial(params?: VoiceCallDialMethodParams): Promise + type: 'phone' | 'sip' + device: any // FIXME: + from: string + to: string + direction: CallingCallDirection + headers?: SipHeader[] + + dial(params: VoiceDialer): Promise hangup(reason?: VoiceCallDisconnectReason): Promise + answer(): Promise + play(params: VoicePlaylist): Promise + playAudio( + params: VoiceCallPlayAudioMethodParams + ): Promise + playSilence( + params: VoiceCallPlaySilenceMethodParams + ): Promise + playRingtone( + params: VoiceCallPlayRingtoneMethodParams + ): Promise + playTTS( + params: VoiceCallPlayTTSMethodParams + ): Promise + record( + params: VoiceCallRecordMethodParams + ): Promise + recordAudio( + params?: VoiceCallRecordMethodParams['audio'] + ): Promise + prompt(params: VoiceCallPromptMethodParams): Promise + promptAudio( + params: VoiceCallPromptAudioMethodParams + ): Promise + promptRingtone( + params: VoiceCallPromptRingtoneMethodParams + ): Promise + promptTTS( + params: VoiceCallPromptTTSMethodParams + ): Promise + // TODO: add derived prompt methods + sendDigits(digits: string): Promise + tap(params: VoiceCallTapMethodParams): Promise + tapAudio(params: VoiceCallTapAudioMethodParams): Promise + connect(params: VoiceCallConnectMethodParams): Promise + waitUntilConnected(): Promise + disconnect(): Promise + detect(params: VoiceCallDetectMethodParams): Promise + amd( + params?: Omit + ): Promise + detectFax( + params?: Omit + ): Promise + detectDigit( + params?: Omit + ): Promise } /** @@ -107,38 +616,621 @@ export type InternalVoiceCallEntity = { * ========== */ -interface VoiceCallStateEvent { +interface CallingCallPhoneDevice { + type: 'phone' + params: { + from_number: string + to_number: string + timeout: number + max_duration: number + } +} + +interface CallingCallSIPDevice { + type: 'sip' + params: { + from: string + from_name?: string + to: string + timeout?: number + max_duration?: number + headers?: SipHeader[] + codecs?: SipCodec[] + webrtc_media?: boolean + } +} + +type CallingCallDevice = CallingCallPhoneDevice | CallingCallSIPDevice +type CallingCallState = 'created' | 'ringing' | 'answered' | 'ending' | 'ended' +type CallingCallDirection = 'inbound' | 'outbound' + +interface CallingCall { + call_id: string + call_state: CallingCallState + context?: string + tag?: string + direction: CallingCallDirection + device: CallingCallDevice + node_id: string + segment_id?: string +} + +interface CallingCallDial extends CallingCall { + dial_winner: 'true' | 'false' +} + +/** + * 'calling.call.dial' + */ +export type CallingCallDialDialingEventParams = { + node_id: string + tag: string + dial_state: 'dialing' +} +export type CallingCallDialAnsweredEventParams = { + node_id: string + tag: string + dial_state: 'answered' + call: CallingCallDial +} +export type CallingCallDialFailedEventParams = { + node_id: string + tag: string + dial_state: 'failed' + reason: string + source: CallingCallDirection +} +export type CallingCallDialEventParams = + | CallingCallDialDialingEventParams + | CallingCallDialAnsweredEventParams + | CallingCallDialFailedEventParams + +export interface CallingCallDialEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallDialEventParams +} + +/** + * 'calling.call.state' + */ +export interface CallingCallStateEventParams extends CallingCall { + peer?: { + call_id: string + node_id: string + } +} + +export interface CallingCallStateEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallStateEventParams +} + +/** + * 'calling.call.receive' + */ +export interface CallingCallReceiveEventParams extends CallingCall {} + +export interface CallingCallReceiveEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallReceiveEventParams +} + +/** + * 'calling.call.play' + */ +export type CallingCallPlayState = 'playing' | 'paused' | 'error' | 'finished' +export interface CallingCallPlayEventParams { + node_id: string + call_id: string + control_id: string + state: CallingCallPlayState +} + +export interface CallingCallPlayEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallPlayEventParams +} + +/** + * 'calling.call.record' + */ +export type CallingCallRecordState = 'recording' | 'no_input' | 'finished' +export interface CallingCallRecordEventParams { + node_id: string + call_id: string + control_id: string + state: CallingCallRecordState + url?: string + duration?: number + size?: number + record: any // FIXME: +} + +export interface CallingCallRecordEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallRecordEventParams +} + +/** + * 'calling.call.collect' + */ +interface CallingCallCollectResultError { + type: 'error' +} +interface CallingCallCollectResultNoInput { + type: 'no_input' +} +interface CallingCallCollectResultNoMatch { + type: 'no_match' +} +interface CallingCallCollectResultDigit { + type: 'digit' + params: { + digits: string + terminator: string + } +} +interface CallingCallCollectResultSpeech { + type: 'speech' + params: { + text: string + confidence: number + } +} +export type CallingCallCollectResult = + | CallingCallCollectResultError + | CallingCallCollectResultNoInput + | CallingCallCollectResultNoMatch + | CallingCallCollectResultDigit + | CallingCallCollectResultSpeech + +export interface CallingCallCollectEventParams { + node_id: string + call_id: string + control_id: string + result: CallingCallCollectResult + final?: boolean +} + +export interface CallingCallCollectEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallCollectEventParams +} + +/** + * 'calling.call.tap' + */ +export type CallingCallTapState = 'tapping' | 'finished' + +interface CallingCallTapDeviceRTP { + type: 'rtp' + params: { + addr: string + port: number + codec?: TapCodec + ptime?: number + } +} + +interface CallingCallTapDeviceWS { + type: 'ws' + params: { + uri: string + codec?: TapCodec + rate?: number + } +} + +interface CallingCallTapAudio { + type: 'audio' + params: { + direction?: TapDirection + } +} + +export interface CallingCallTapEventParams { + node_id: string call_id: string + control_id: string + state: CallingCallTapState + tap: CallingCallTapAudio + device: CallingCallTapDeviceRTP | CallingCallTapDeviceWS +} + +export interface CallingCallTapEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallTapEventParams +} + +/** + * 'calling.call.connect' + */ +export type CallingCallConnectState = + | 'connecting' + | 'connected' + | 'failed' + | 'disconnected' +export interface CallingCallConnectEventParams { node_id: string + call_id: string tag: string + connect_state: CallingCallConnectState + failed_reason?: string + peer: { + node_id: string + call_id: string + tag: string + device: CallingCallDevice + } +} + +export interface CallingCallConnectEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallConnectEventParams } /** - * 'voice.call.created' + * 'calling.call.send_digits */ -export interface VoiceCallCreatedEventParams extends VoiceCallStateEvent {} -export interface VoiceCallCreatedEvent extends SwEvent { - event_type: ToInternalVoiceEvent - params: VoiceCallCreatedEventParams +export type CallingCallSendDigitsState = 'finished' +export interface CallingCallSendDigitsEventParams { + node_id: string + call_id: string + control_id: string + state: CallingCallSendDigitsState +} + +export interface CallingCallSendDigitsEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallSendDigitsEventParams } /** - * 'voice.call.ended' + * 'calling.call.detect' */ -export interface VoiceCallEndedEventParams extends VoiceCallStateEvent {} +type CallingCallDetectState = 'finished' | 'error' +interface CallingCallDetectFax { + type: 'fax' + params: { + event: 'CED' | 'CNG' | CallingCallDetectState + } +} +interface CallingCallDetectMachine { + type: 'machine' + params: { + event: + | 'MACHINE' + | 'HUMAN' + | 'UNKNOWN' + | 'READY' + | 'NOT_READY' + | CallingCallDetectState + } +} +interface CallingCallDetectDigit { + type: 'digit' + params: { + event: + | '0' + | '1' + | '2' + | '3' + | '4' + | '5' + | '6' + | '7' + | '8' + | '9' + | '#' + | '*' + | CallingCallDetectState + } +} +export type Detector = + | CallingCallDetectFax + | CallingCallDetectMachine + | CallingCallDetectDigit +type CallingCallDetectType = Detector['type'] +export interface CallingCallDetectEventParams { + node_id: string + call_id: string + control_id: string + detect?: Detector +} -export interface VoiceCallEndedEvent extends SwEvent { - event_type: ToInternalVoiceEvent - params: VoiceCallEndedEventParams +export interface CallingCallDetectEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallDetectEventParams } -export type VoiceCallEvent = VoiceCallCreatedEvent | VoiceCallEndedEvent +/** + * ========== + * ========== + * SDK-Side Events + * ========== + * ========== + */ + +/** + * 'calling.playback.started' + */ +export interface CallPlaybackStartedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallPlayEventParams & { tag: string } +} +/** + * 'calling.playback.updated' + */ +export interface CallPlaybackUpdatedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallPlayEventParams & { tag: string } +} +/** + * 'calling.playback.ended' + */ +export interface CallPlaybackEndedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallPlayEventParams & { tag: string } +} +/** + * 'calling.call.received' + */ +export interface CallReceivedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallReceiveEventParams +} + +/** + * 'calling.recording.started' + */ +export interface CallRecordingStartedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallRecordEventParams & { tag: string } +} +/** + * 'calling.recording.updated' + */ +export interface CallRecordingUpdatedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallRecordEventParams & { tag: string } +} +/** + * 'calling.recording.ended' + */ +export interface CallRecordingEndedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallRecordEventParams & { tag: string } +} +/** + * 'calling.recording.failed' + */ +export interface CallRecordingFailedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallRecordEventParams & { tag: string } +} + +/** + * 'calling.prompt.started' + */ +export interface CallPromptStartedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallCollectEventParams & { tag: string } +} +/** + * 'calling.prompt.updated' + */ +export interface CallPromptUpdatedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallCollectEventParams & { tag: string } +} +/** + * 'calling.prompt.ended' + */ +export interface CallPromptEndedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallCollectEventParams & { tag: string } +} +/** + * 'calling.prompt.failed' + */ +export interface CallPromptFailedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallCollectEventParams & { tag: string } +} + +/** + * 'calling.tap.started' + */ +export interface CallTapStartedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallTapEventParams & { tag: string } +} +/** + * 'calling.tap.ended' + */ +export interface CallTapEndedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallTapEventParams & { tag: string } +} + +/** + * 'calling.connect.connecting' + */ +export interface CallConnectConnectingEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallConnectEventParams +} +/** + * 'calling.connect.connected' + */ +export interface CallConnectConnectedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallConnectEventParams +} +/** + * 'calling.connect.disconnected' + */ +export interface CallConnectDisconnectedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallConnectEventParams +} +/** + * 'calling.connect.failed' + */ +export interface CallConnectFailedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallConnectEventParams +} + +/** + * 'calling.detect.started' + */ +export interface CallDetectStartedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallDetectEventParams & { tag: string } +} +/** + * 'calling.detect.updated' + */ +export interface CallDetectUpdatedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallDetectEventParams & { tag: string } +} +/** + * 'calling.detect.ended' + */ +export interface CallDetectEndedEvent extends SwEvent { + event_type: ToInternalVoiceEvent + params: CallingCallDetectEventParams & { tag: string } +} + +// interface VoiceCallStateEvent { +// call_id: string +// node_id: string +// tag: string +// } + +// /** +// * 'voice.call.created' +// */ +// export interface VoiceCallCreatedEventParams extends VoiceCallStateEvent {} + +// export interface VoiceCallCreatedEvent extends SwEvent { +// event_type: ToInternalVoiceEvent +// params: VoiceCallCreatedEventParams +// } + +// /** +// * 'voice.call.ended' +// */ +// export interface VoiceCallEndedEventParams extends VoiceCallStateEvent {} + +// export interface VoiceCallEndedEvent extends SwEvent { +// event_type: ToInternalVoiceEvent +// params: VoiceCallEndedEventParams +// } + +export type VoiceCallEvent = + // Server Events + | CallingCallDialEvent + | CallingCallStateEvent + | CallingCallReceiveEvent + | CallingCallPlayEvent + | CallingCallRecordEvent + | CallingCallCollectEvent + | CallingCallTapEvent + | CallingCallConnectEvent + | CallingCallSendDigitsEvent + | CallingCallDetectEvent + // SDK Events + | CallReceivedEvent + | CallPlaybackStartedEvent + | CallPlaybackUpdatedEvent + | CallPlaybackEndedEvent + | CallRecordingStartedEvent + | CallRecordingUpdatedEvent + | CallRecordingEndedEvent + | CallRecordingFailedEvent + | CallPromptStartedEvent + | CallPromptUpdatedEvent + | CallPromptEndedEvent + | CallPromptFailedEvent + | CallTapStartedEvent + | CallTapEndedEvent + | CallConnectConnectingEvent + | CallConnectConnectedEvent + | CallConnectDisconnectedEvent + | CallConnectFailedEvent + | CallDetectStartedEvent + | CallDetectUpdatedEvent + | CallDetectEndedEvent export type VoiceCallEventParams = - | VoiceCallCreatedEventParams - | VoiceCallEndedEventParams + // Server Event Params + | CallingCallDialEventParams + | CallingCallStateEventParams + | CallingCallReceiveEventParams + | CallingCallPlayEventParams + | CallingCallRecordEventParams + | CallingCallCollectEventParams + | CallingCallTapEventParams + | CallingCallConnectEventParams + | CallingCallSendDigitsEventParams + | CallingCallDetectEventParams + // SDK Event Params + | CallReceivedEvent['params'] + | CallPlaybackStartedEvent['params'] + | CallPlaybackUpdatedEvent['params'] + | CallPlaybackEndedEvent['params'] + | CallRecordingStartedEvent['params'] + | CallRecordingUpdatedEvent['params'] + | CallRecordingEndedEvent['params'] + | CallRecordingFailedEvent['params'] + | CallPromptStartedEvent['params'] + | CallPromptUpdatedEvent['params'] + | CallPromptEndedEvent['params'] + | CallPromptFailedEvent['params'] + | CallTapStartedEvent['params'] + | CallTapEndedEvent['params'] + | CallConnectConnectingEvent['params'] + | CallConnectConnectedEvent['params'] + | CallConnectDisconnectedEvent['params'] + | CallConnectFailedEvent['params'] + | CallDetectStartedEvent['params'] + | CallDetectUpdatedEvent['params'] + | CallDetectEndedEvent['params'] export type VoiceCallAction = MapToPubSubShape -export type VoiceCallJSONRPCMethod = 'calling.dial' | 'calling.end' +export type VoiceCallJSONRPCMethod = + | 'calling.dial' + | 'calling.end' + | 'calling.answer' + | 'calling.play' + | 'calling.play.pause' + | 'calling.play.resume' + | 'calling.play.volume' + | 'calling.play.stop' + | 'calling.record' + | 'calling.record.stop' + | 'calling.play_and_collect' + | 'calling.play_and_collect.stop' + | 'calling.play_and_collect.volume' + | 'calling.tap' + | 'calling.tap.stop' + | 'calling.connect' + | 'calling.disconnect' + | 'calling.send_digits' + | 'calling.detect' + | 'calling.detect.stop' + +export type CallingTransformType = + | 'voiceCallReceived' + | 'voiceCallPlayback' + | 'voiceCallRecord' + | 'voiceCallPrompt' + | 'voiceCallTap' + | 'voiceCallConnect' + | 'voiceCallState' + | 'voiceCallDetect' diff --git a/packages/core/src/utils/common.ts b/packages/core/src/utils/common.ts new file mode 100644 index 000000000..5823e1550 --- /dev/null +++ b/packages/core/src/utils/common.ts @@ -0,0 +1,11 @@ +const UPPERCASE_REGEX = /[A-Z]/g +/** + * Converts values from camelCase to snake_case + * @internal + */ +export const fromCamelToSnakeCase = (event: T): T => { + // @ts-ignore + return event.replace(UPPERCASE_REGEX, (letter) => { + return `_${letter.toLowerCase()}` + }) as T +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 073c4e90d..f5990dcb8 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -14,6 +14,7 @@ export * from './parseRPCResponse' export * from './toExternalJSON' export * from './toInternalEventName' export * from './toInternalAction' +export * from './toSnakeCaseKeys' export * from './extendComponent' export * from './eventTransformUtils' export * from './proxyUtils' diff --git a/packages/core/src/utils/interfaces.ts b/packages/core/src/utils/interfaces.ts index 562bcb9dd..2a36d956f 100644 --- a/packages/core/src/utils/interfaces.ts +++ b/packages/core/src/utils/interfaces.ts @@ -14,6 +14,8 @@ import type { } from '../redux/interfaces' import type { URL as NodeURL } from 'node:url' import { + AllOrNone, + CallingTransformType, ChatJSONRPCMethod, ChatTransformType, MessagingJSONRPCMethod, @@ -342,6 +344,7 @@ export type EventTransformType = | 'roomSessionPlayback' | ChatTransformType | MessagingTransformType + | CallingTransformType export interface NestedFieldToProcess { /** @@ -432,6 +435,12 @@ export interface EventTransform { * Allow us to define the `event_channel` for the Proxy. */ getInstanceEventChannel?: (payload: any) => string + /** + * Determines if the instance created by `instanceFactory` + * should be cached per event. This is the instance that + * will be passed to our event handlers + */ + mode?: 'cache' | 'no-cache' } export type BaseEventHandler = (...args: any[]) => void @@ -441,17 +450,43 @@ export type InternalChannels = { swEventChannel: SwEventChannel } -export type SDKWorkerParams = { +export type SDKWorkerHooks< + OnDone = (options?: any) => void, + OnFail = (options?: any) => void +> = AllOrNone<{ + onDone: OnDone + onFail: OnFail +}> + +type SDKWorkerBaseParams = { channels: InternalChannels instance: T runSaga: any + /** + * TODO: rename `payload` with something more explicit or + * create derived types of `SDKWorkerParams` with specific arguments (?) + * @deprecated use `initialState` + */ payload?: any + initialState?: any } -export type SDKWorker = (params: SDKWorkerParams) => SagaIterator -export interface SDKWorkerDefinition { - worker: SDKWorker -} +export type SDKWorkerParams< + T, + Hooks extends SDKWorkerHooks = SDKWorkerHooks +> = SDKWorkerBaseParams & Hooks + +export type AttachSDKWorkerParams = Partial> + +export type SDKWorker = ( + params: SDKWorkerParams +) => SagaIterator + +export type SDKWorkerDefinition = + { + worker: SDKWorker + initialState?: any + } & Hooks interface LogFn { (obj: T, msg?: string, ...args: any[]): void diff --git a/packages/core/src/utils/toExternalJSON.ts b/packages/core/src/utils/toExternalJSON.ts index 6ee570d4c..ff200e4a6 100644 --- a/packages/core/src/utils/toExternalJSON.ts +++ b/packages/core/src/utils/toExternalJSON.ts @@ -39,7 +39,7 @@ const isTimestampProperty = (prop: string) => { return prop.endsWith('At') } -type ToExternalJSONResult = { +export type ToExternalJSONResult = { [Property in NonNullable as SnakeToCamelCase< Extract >]: ConverToExternalTypes, T[Property]> diff --git a/packages/core/src/utils/toInternalEventName.ts b/packages/core/src/utils/toInternalEventName.ts index e002953e4..1677a0470 100644 --- a/packages/core/src/utils/toInternalEventName.ts +++ b/packages/core/src/utils/toInternalEventName.ts @@ -1,3 +1,4 @@ +import { fromCamelToSnakeCase } from './common' import { EVENT_NAMESPACE_DIVIDER } from './constants' import { EventEmitter } from './EventEmitter' @@ -27,18 +28,6 @@ export const toInternalEventName = < return event } -const UPPERCASE_REGEX = /[A-Z]/g -/** - * Converts values from camelCase to snake_case - * @internal - */ -const fromCamelToSnakeCase = (event: T): T => { - // @ts-ignore - return event.replace(UPPERCASE_REGEX, (letter) => { - return `_${letter.toLowerCase()}` - }) as T -} - const getNamespacedEvent = ({ namespace, event, diff --git a/packages/core/src/utils/toSnakeCaseKeys.test.ts b/packages/core/src/utils/toSnakeCaseKeys.test.ts new file mode 100644 index 000000000..3c893c0e6 --- /dev/null +++ b/packages/core/src/utils/toSnakeCaseKeys.test.ts @@ -0,0 +1,114 @@ +import { toSnakeCaseKeys } from './toSnakeCaseKeys' + +describe('toSnakeCaseKeys', () => { + it('should convert properties from camelCase to snake_case', () => { + expect( + toSnakeCaseKeys({ + someProperty: 'someValue', + someOtherProperty: 'someOtherValue', + nestedProperty: { + nestedProperty1: 'nestedValue', + nestedProperty2: 'nestedValue2', + nestedProperty3: { + nestedProperty4: 'nestedValue4', + nestedProperty5: { + nestedProperty6: 'nestedValue6', + nestedProperty7: { + nestedProperty8: 'nestedValue8', + }, + }, + }, + nestedProperty4: [ + { + somePropertyKey: 'value_in_key', + somePropertyNested: { + somePropertyNested1: 'value_in_key_nested1', + somePropertyNested2: [ + { + somePropertyNested21: { + somePropertyNested211: 'value', + }, + }, + ], + }, + }, + ], + }, + nestedPropertyWithLongName: 'nestedValueWithLongName', + }) + ).toEqual({ + some_property: 'someValue', + some_other_property: 'someOtherValue', + nested_property: { + nested_property1: 'nestedValue', + nested_property2: 'nestedValue2', + nested_property3: { + nested_property4: 'nestedValue4', + nested_property5: { + nested_property6: 'nestedValue6', + nested_property7: { + nested_property8: 'nestedValue8', + }, + }, + }, + nested_property4: [ + { + some_property_key: 'value_in_key', + some_property_nested: { + some_property_nested1: 'value_in_key_nested1', + some_property_nested2: [ + { + some_property_nested21: { + some_property_nested211: 'value', + }, + }, + ], + }, + }, + ], + }, + nested_property_with_long_name: 'nestedValueWithLongName', + }) + }) + + it('should allow passing a function for transforming values', () => { + expect( + toSnakeCaseKeys( + { + someProperty: 'someValue', + someOtherProperty: 'someOtherValue', + nestedProperty: { + nestedProperty1: 'nestedValue', + nestedProperty2: 'nestedValue2', + nestedProperty3: { + nestedProperty4: 'nestedValue4', + nestedProperty5: { + nestedProperty6: 'nestedValue6', + nestedProperty7: { + nestedProperty8: 'nestedValue8', + }, + }, + }, + }, + }, + (value: string) => value.toUpperCase() + ) + ).toEqual({ + some_property: 'SOMEVALUE', + some_other_property: 'SOMEOTHERVALUE', + nested_property: { + nested_property1: 'NESTEDVALUE', + nested_property2: 'NESTEDVALUE2', + nested_property3: { + nested_property4: 'NESTEDVALUE4', + nested_property5: { + nested_property6: 'NESTEDVALUE6', + nested_property7: { + nested_property8: 'NESTEDVALUE8', + }, + }, + }, + }, + }) + }) +}) diff --git a/packages/core/src/utils/toSnakeCaseKeys.ts b/packages/core/src/utils/toSnakeCaseKeys.ts new file mode 100644 index 000000000..c7e64bbfb --- /dev/null +++ b/packages/core/src/utils/toSnakeCaseKeys.ts @@ -0,0 +1,32 @@ +import { CamelToSnakeCase } from '../types/utils' +import { fromCamelToSnakeCase } from './common' + +type ToSnakeCaseKeys = { + [Property in NonNullable as CamelToSnakeCase< + Extract + >]: T[Property] +} + +export const toSnakeCaseKeys = >( + obj: T, + transform: (value: string) => any = (value: string) => value, + result: Record = {} +) => { + if (Array.isArray(obj)) { + result = obj.map((item: any, index: number) => + toSnakeCaseKeys(item, transform, result[index]) + ) + } else { + Object.keys(obj).forEach((key) => { + const newKey = fromCamelToSnakeCase(key) + // Both 'object's and arrays will enter this branch + if (obj[key] && typeof obj[key] === 'object') { + result[newKey] = toSnakeCaseKeys(obj[key], transform, result[newKey]) + } else { + result[newKey] = transform(obj[key]) + } + }) + } + + return result as ToSnakeCaseKeys +} diff --git a/packages/realtime-api/src/AutoApplyTransformsConsumer.ts b/packages/realtime-api/src/AutoApplyTransformsConsumer.ts new file mode 100644 index 000000000..0c78d2841 --- /dev/null +++ b/packages/realtime-api/src/AutoApplyTransformsConsumer.ts @@ -0,0 +1,33 @@ +import { BaseConsumer, EventEmitter } from '@signalwire/core' + +/** + * This class is extended by Call and Voice since they don't + * invoke "signalwire.subscribe" but they need to apply the + * emitter transforms on each `.on()`/`.once()` call. + * TODO: improve this logic. + * https://github.com/signalwire/signalwire-js/pull/477#discussion_r841623381 + * https://github.com/signalwire/signalwire-js/pull/477#discussion_r841435646 + */ +export class AutoApplyTransformsConsumer< + EventTypes extends EventEmitter.ValidEventTypes +> extends BaseConsumer { + override on( + event: EventEmitter.EventNames, + fn: EventEmitter.EventListener + ) { + const instance = super.on(event, fn) + this.applyEmitterTransforms() + + return instance + } + + override once( + event: EventEmitter.EventNames, + fn: EventEmitter.EventListener + ) { + const instance = super.once(event, fn) + this.applyEmitterTransforms() + + return instance + } +} diff --git a/packages/realtime-api/src/chat/ChatClient.test.ts b/packages/realtime-api/src/chat/ChatClient.test.ts index fa8636ede..87f1d7f4b 100644 --- a/packages/realtime-api/src/chat/ChatClient.test.ts +++ b/packages/realtime-api/src/chat/ChatClient.test.ts @@ -22,7 +22,7 @@ describe('ChatClient', () => { parsedData.method === 'signalwire.connect' && parsedData.params.authentication.token === '' ) { - socket.send( + return socket.send( JSON.stringify({ jsonrpc: '2.0', id: parsedData.id, @@ -58,6 +58,8 @@ describe('ChatClient', () => { chat.once('member.joined', () => {}) chat._session.on('session.connected', () => { + chat._session.disconnect() + done() }) }) @@ -85,6 +87,8 @@ describe('ChatClient', () => { message: 'Authentication service failed with status ProtocolError, 401 Unauthorized: {}', }) + + chat._session.disconnect() }) }) }) diff --git a/packages/realtime-api/src/createClient.test.ts b/packages/realtime-api/src/createClient.test.ts index 909733edf..acd032aa8 100644 --- a/packages/realtime-api/src/createClient.test.ts +++ b/packages/realtime-api/src/createClient.test.ts @@ -22,7 +22,7 @@ describe('createClient', () => { parsedData.method === 'signalwire.connect' && parsedData.params.authentication.token === '' ) { - socket.send( + return socket.send( JSON.stringify({ jsonrpc: '2.0', id: parsedData.id, @@ -74,6 +74,8 @@ describe('createClient', () => { // @ts-expect-error client._waitUntilSessionAuthorized().then((c) => { expect(c).toEqual(client) + + client.disconnect() }) await client.connect() @@ -109,5 +111,7 @@ describe('createClient', () => { await Promise.all([client.connect(), client.connect(), client.connect()]) expect(messageHandler).toHaveBeenCalledTimes(1) + + client.disconnect() }) }) diff --git a/packages/realtime-api/src/messaging/MessagingClient.test.ts b/packages/realtime-api/src/messaging/MessagingClient.test.ts index aa286e2c2..97e4534e8 100644 --- a/packages/realtime-api/src/messaging/MessagingClient.test.ts +++ b/packages/realtime-api/src/messaging/MessagingClient.test.ts @@ -17,7 +17,7 @@ describe('MessagingClient', () => { server.on('connection', (socket: any) => { socket.on('message', (data: any) => { const parsedData = JSON.parse(data) - console.log('>>', parsedData) + if ( parsedData.method === 'signalwire.connect' && parsedData.params.authentication.token === '' @@ -119,8 +119,6 @@ describe('MessagingClient', () => { project: 'some-project', token: 'some-other-token', contexts: ['foo'], - - logLevel: 'debug', }) messaging.on('message.updated', (message) => { diff --git a/packages/realtime-api/src/testUtils.ts b/packages/realtime-api/src/testUtils.ts index 69cd460f4..8969238ec 100644 --- a/packages/realtime-api/src/testUtils.ts +++ b/packages/realtime-api/src/testUtils.ts @@ -34,6 +34,7 @@ export const configureFullStack = () => { const session = { dispatch: console.log, connect: jest.fn(), + disconnect: jest.fn(), execute: jest.fn(), } const emitter = new EventEmitter() diff --git a/packages/realtime-api/src/types/voice.ts b/packages/realtime-api/src/types/voice.ts index bebaaa878..8d70cbfba 100644 --- a/packages/realtime-api/src/types/voice.ts +++ b/packages/realtime-api/src/types/voice.ts @@ -1,10 +1,45 @@ -import type { CallCreated, CallEnded } from '@signalwire/core' +import type { + CallReceived, + CallPlaybackStarted, + CallPlaybackUpdated, + CallPlaybackEnded, + CallRecordingStarted, + CallRecordingUpdated, + CallRecordingEnded, + CallRecordingFailed, + CallPromptStarted, + CallPromptUpdated, + CallPromptEnded, + CallPromptFailed, + CallTapStarted, + CallTapEnded, +} from '@signalwire/core' +import type { Call } from '../voice/Call' +import type { CallPlayback } from '../voice/CallPlayback' +import type { CallRecording } from '../voice/CallRecording' +import type { CallPrompt } from '../voice/CallPrompt' +import type { CallTap } from '../voice/CallTap' -// TODO: replace `any` with proper types. export type RealTimeCallApiEventsHandlerMapping = Record< - CallCreated | CallEnded, - (params: any) => void -> + CallReceived, + (call: Call) => void +> & + Record< + CallPlaybackStarted | CallPlaybackUpdated | CallPlaybackEnded, + (playback: CallPlayback) => void + > & + Record< + | CallRecordingStarted + | CallRecordingUpdated + | CallRecordingEnded + | CallRecordingFailed, + (recording: CallRecording) => void + > & + Record< + CallPromptStarted | CallPromptUpdated | CallPromptEnded | CallPromptFailed, + (recording: CallPrompt) => void + > & + Record void> export type RealTimeCallApiEvents = { [k in keyof RealTimeCallApiEventsHandlerMapping]: RealTimeCallApiEventsHandlerMapping[k] diff --git a/packages/realtime-api/src/video/VideoClient.test.ts b/packages/realtime-api/src/video/VideoClient.test.ts index dcb7f3dbe..75d799381 100644 --- a/packages/realtime-api/src/video/VideoClient.test.ts +++ b/packages/realtime-api/src/video/VideoClient.test.ts @@ -59,7 +59,8 @@ describe('VideoClient', () => { video.once('room.started', () => {}) video._session.on('session.connected', () => { - expect(true).toEqual(true) + video._session.disconnect() + done() }) }) diff --git a/packages/realtime-api/src/voice/Call.ts b/packages/realtime-api/src/voice/Call.ts index 40f96c088..e771edfc5 100644 --- a/packages/realtime-api/src/voice/Call.ts +++ b/packages/realtime-api/src/voice/Call.ts @@ -1,30 +1,91 @@ import { + uuid, AssertSameType, BaseComponentOptions, connect, - ConsumerContract, + EmitterContract, extendComponent, VoiceCallMethods, VoiceCallContract, - VoiceCallDialMethodParams, + VoiceDialer, VoiceCallDisconnectReason, + VoicePlaylist, + VoiceCallPlayAudioMethodParams, + VoiceCallPlaySilenceMethodParams, + VoiceCallPlayRingtoneMethodParams, + VoiceCallPlayTTSMethodParams, + CallingCallRecordEventParams, + VoiceCallRecordMethodParams, + CallingCallCollectEventParams, + VoiceCallPromptMethodParams, + VoiceCallPromptAudioMethodParams, + VoiceCallPromptRingtoneMethodParams, + VoiceCallPromptTTSMethodParams, + EventTransform, + toLocalEvent, + toExternalJSON, + toSnakeCaseKeys, + CallingCallPlayEventParams, + VoiceCallTapMethodParams, + VoiceCallTapAudioMethodParams, + CallingCallTapEventParams, + CallingCallStateEventParams, + VoiceCallConnectMethodParams, + CallingCallConnectEventParams, + VoiceCallDetectMethodParams, + VoiceCallDetectMachineParams, + VoiceCallDetectFaxParams, + VoiceCallDetectDigitParams, + CallingCallDetectEventParams, } from '@signalwire/core' -import { AutoSubscribeConsumer } from '../AutoSubscribeConsumer' import { RealTimeCallApiEvents } from '../types' -import { toInternalDevices } from './utils' +import { AutoApplyTransformsConsumer } from '../AutoApplyTransformsConsumer' +import { toInternalDevices, toInternalPlayParams } from './utils' +import { Playlist } from './Playlist' import { - SYNTHETIC_CALL_STATE_FAILED_EVENT, - SYNTHETIC_CALL_STATE_ANSWERED_EVENT, - SYNTHETIC_CALL_STATE_ENDED_EVENT, voiceCallStateWorker, + voiceCallPlayWorker, + voiceCallRecordWorker, + voiceCallPromptWorker, + voiceCallTapWorker, + voiceCallConnectWorker, + voiceCallDialWorker, + voiceCallSendDigitsWorker, + voiceCallDetectWorker, + VoiceCallDialWorkerHooks, + VoiceCallSendDigitsWorkerHooks, } from './workers' +import { CallPlayback, createCallPlaybackObject } from './CallPlayback' +import { CallRecording, createCallRecordingObject } from './CallRecording' +import { CallPrompt, createCallPromptObject } from './CallPrompt' +import { CallTap, createCallTapObject } from './CallTap' +import { CallDetect, createCallDetectObject } from './CallDetect' -// TODO: -type EmitterTransformsEvents = '' +type EmitterTransformsEvents = + | 'calling.playback.start' + | 'calling.playback.started' + | 'calling.playback.updated' + | 'calling.playback.ended' + | 'calling.recording.started' + | 'calling.recording.updated' + | 'calling.recording.ended' + | 'calling.recording.failed' + | 'calling.prompt.started' + | 'calling.prompt.updated' + | 'calling.prompt.ended' + | 'calling.prompt.failed' + | 'calling.tap.started' + | 'calling.tap.ended' + | 'calling.detect.started' + | 'calling.detect.ended' + // events not exposed + | 'calling.call.state' + | 'calling.detect.updated' + | 'calling.connect.connected' interface CallMain extends VoiceCallContract, - ConsumerContract {} + EmitterContract {} interface CallDocs extends CallMain {} @@ -32,48 +93,276 @@ export interface Call extends AssertSameType {} export interface CallFullState extends Call {} -export class CallConsumer extends AutoSubscribeConsumer { - protected _eventsPrefix = 'calling' as const +/** + * Used to resolve the play() method and to update the CallPlayback object through the EmitterTransform + */ +export const callingPlaybackTriggerEvent = + toLocalEvent('calling.playback.trigger') - /** @internal */ - protected subscribeParams = { - get_initial_state: true, - } +/** + * Used to resolve the record() method and to update the CallRecording object through the EmitterTransform + */ +export const callingRecordTriggerEvent = toLocalEvent( + 'calling.recording.trigger' +) + +/** + * Used to resolve the prompt() method and to update the CallPrompt object through the EmitterTransform + */ +export const callingPromptTriggerEvent = toLocalEvent( + 'calling.prompt.trigger' +) + +/** + * Used to resolve the tap() method and to update the CallTap object through the EmitterTransform + */ +export const callingTapTriggerEvent = toLocalEvent( + 'calling.tap.trigger' +) + +/** + * Used to resolve the detect() method and to update the CallDetect object through the EmitterTransform + */ +export const callingDetectTriggerEvent = toLocalEvent( + 'calling.detect.trigger' +) + +export class CallConsumer extends AutoApplyTransformsConsumer { + protected _eventsPrefix = 'calling' as const - private _callId: string - private _nodeId: string + public callId: string + public nodeId: string + public peer: string constructor(options: BaseComponentOptions) { super(options) this._attachListeners(this.__uuid) + this.applyEmitterTransforms({ local: true }) - this.setWorker('voiceCallStateWorker', { + // @ts-expect-error + this.on('call.state', () => { + /** + * FIXME: this no-op listener is required for our EE transforms to + * update the call object via the `calling.call.state` transform + * and apply the "peer" property to the Proxy. + */ + }) + + /** + * It will take care of keeping instances of this class + * up-to-date with the latest changes sent from the + * server. Changes will be available to the consumer via + * our Proxy API. + */ + this.runWorker('voiceCallStateWorker', { worker: voiceCallStateWorker, }) - this.attachWorkers() } - dial(params: VoiceCallDialMethodParams) { - return new Promise((resolve, reject) => { - // @ts-expect-error - this.once(SYNTHETIC_CALL_STATE_ANSWERED_EVENT, (payload) => { - this._callId = payload.call_id - this._nodeId = payload.node_id + get id() { + return this.callId + } - resolve(this) - }) + get tag() { + return this.__uuid + } + get type() { + // @ts-expect-error + return this.device?.type ?? '' + } + + get from() { + if (this.type === 'phone') { + // @ts-expect-error + return this.device?.params?.fromNumber ?? '' + } else if (this.type === 'sip') { + // @ts-expect-error + return this.device?.params?.from ?? '' + } + this.logger.warn('Unknow Call type', this.type) + // @ts-expect-error + return this.device?.params?.from ?? '' + } + + get to() { + if (this.type === 'phone') { // @ts-expect-error - this.once(SYNTHETIC_CALL_STATE_FAILED_EVENT, () => { - reject(new Error('Failed to establish the call.')) + return this.device?.params?.toNumber ?? '' + } else if (this.type === 'sip') { + // @ts-expect-error + return this.device?.params?.to ?? '' + } + this.logger.warn('Unknow Call type', this.type) + // @ts-expect-error + return this.device?.params?.to ?? '' + } + + get headers() { + // @ts-expect-error + return this.device?.params?.headers ?? [] + } + + /** @internal */ + protected getEmitterTransforms() { + return new Map< + EmitterTransformsEvents | EmitterTransformsEvents[], + EventTransform + >([ + [ + [ + callingPlaybackTriggerEvent, + 'calling.playback.started', + 'calling.playback.updated', + 'calling.playback.ended', + ], + { + type: 'voiceCallPlayback', + instanceFactory: (_payload: any) => { + return createCallPlaybackObject({ + store: this.store, + // @ts-expect-error + emitter: this.emitter, + }) + }, + payloadTransform: (payload: CallingCallPlayEventParams) => { + return toExternalJSON(payload) + }, + }, + ], + [ + [ + callingRecordTriggerEvent, + 'calling.recording.started', + 'calling.recording.updated', + 'calling.recording.ended', + 'calling.recording.failed', + ], + { + type: 'voiceCallRecord', + instanceFactory: (_payload: any) => { + return createCallRecordingObject({ + store: this.store, + // @ts-expect-error + emitter: this.emitter, + }) + }, + payloadTransform: (payload: CallingCallRecordEventParams) => { + return toExternalJSON(payload) + }, + }, + ], + [ + [ + callingPromptTriggerEvent, + 'calling.prompt.started', + 'calling.prompt.updated', + 'calling.prompt.ended', + 'calling.prompt.failed', + ], + { + type: 'voiceCallPrompt', + instanceFactory: (_payload: any) => { + return createCallPromptObject({ + store: this.store, + // @ts-expect-error + emitter: this.emitter, + }) + }, + payloadTransform: (payload: CallingCallCollectEventParams) => { + return toExternalJSON(payload) + }, + }, + ], + [ + [callingTapTriggerEvent, 'calling.tap.started', 'calling.tap.ended'], + { + type: 'voiceCallTap', + instanceFactory: (_payload: any) => { + return createCallTapObject({ + store: this.store, + // @ts-expect-error + emitter: this.emitter, + }) + }, + payloadTransform: (payload: CallingCallTapEventParams) => { + return toExternalJSON(payload) + }, + }, + ], + [ + ['calling.call.state'], + { + type: 'voiceCallState', + instanceFactory: (_payload: any) => { + return this + }, + payloadTransform: (payload: CallingCallStateEventParams) => { + return toExternalJSON(payload) + }, + }, + ], + [ + ['calling.connect.connected'], + { + type: 'voiceCallConnect', + instanceFactory: (_payload: any) => { + return createCallObject({ + store: this.store, + // @ts-expect-error + emitter: this.emitter, + }) + }, + payloadTransform: (payload: CallingCallConnectEventParams) => { + /** + * Within a `calling.connect` process `tag` refers to the originator leg. + * We need to remove tag from the server payload to let the new (connected) + * Call object to use its own tag value set to `this.__uuid`. + */ + const { tag, ...peerParams } = payload.peer + return toExternalJSON(peerParams) + }, + }, + ], + [ + [ + callingDetectTriggerEvent, + 'calling.detect.started', + 'calling.detect.updated', + 'calling.detect.ended', + ], + { + type: 'voiceCallDetect', + instanceFactory: (_payload: any) => { + return createCallDetectObject({ + store: this.store, + // @ts-expect-error + emitter: this.emitter, + }) + }, + payloadTransform: (payload: CallingCallDetectEventParams) => { + return toExternalJSON(payload) + }, + }, + ], + ]) + } + + dial(params: VoiceDialer) { + return new Promise((resolve, reject) => { + this.runWorker('voiceCallDialWorker', { + worker: voiceCallDialWorker, + onDone: resolve, + onFail: reject, }) + const { region, devices } = params this.execute({ method: 'calling.dial', params: { - ...params, tag: this.__uuid, - devices: toInternalDevices(params.devices), + region, + devices: toInternalDevices(devices), }, }).catch((e) => { reject(e) @@ -83,7 +372,7 @@ export class CallConsumer extends AutoSubscribeConsumer { hangup(reason: VoiceCallDisconnectReason = 'hangup') { return new Promise((resolve, reject) => { - if (!this._callId || !this._nodeId) { + if (!this.callId || !this.nodeId) { reject( new Error( `Can't call hangup() on a call that hasn't been established.` @@ -92,15 +381,17 @@ export class CallConsumer extends AutoSubscribeConsumer { } // @ts-expect-error - this.once(SYNTHETIC_CALL_STATE_ENDED_EVENT, () => { - resolve(undefined) + this.on('call.state', (params) => { + if (params.callState === 'ended') { + resolve(new Error('Failed to hangup the call.')) + } }) this.execute({ method: 'calling.end', params: { - node_id: this._nodeId, - call_id: this._callId, + node_id: this.nodeId, + call_id: this.callId, reason: reason, }, }).catch((e) => { @@ -108,12 +399,562 @@ export class CallConsumer extends AutoSubscribeConsumer { }) }) } + + answer() { + return new Promise((resolve, reject) => { + if (!this.callId || !this.nodeId) { + reject(new Error(`Can't call answer() on a call without callId.`)) + } + + // @ts-expect-error + this.on('call.state', (params) => { + if (params.callState === 'answered') { + resolve(this) + } else if (params.callState === 'ended') { + reject(new Error('Failed to answer the call.')) + } + }) + + this.execute({ + method: 'calling.answer', + params: { + node_id: this.nodeId, + call_id: this.callId, + }, + }).catch((e) => { + reject(e) + }) + }) + } + + play(params: VoicePlaylist) { + return new Promise((resolve, reject) => { + if (!this.callId || !this.nodeId) { + reject(new Error(`Can't call play() on a call not established yet.`)) + } + + const controlId = uuid() + + this.runWorker('voiceCallPlayWorker', { + worker: voiceCallPlayWorker, + initialState: { + controlId, + }, + }) + + const resolveHandler = (callPlayback: any) => { + resolve(callPlayback) + } + + // @ts-expect-error + this.on(callingPlaybackTriggerEvent, resolveHandler) + + this.execute({ + method: 'calling.play', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: controlId, + volume: params.volume, + play: toInternalPlayParams(params.media), + }, + }) + .then(() => { + const startEvent: CallingCallPlayEventParams = { + control_id: controlId, + call_id: this.id, + node_id: this.nodeId, + state: 'playing', + } + // @ts-expect-error + this.emit(callingPlaybackTriggerEvent, startEvent) + }) + .catch((e) => { + // @ts-expect-error + this.off(callingPlaybackTriggerEvent, resolveHandler) + reject(e) + }) + }) + } + + playAudio(params: VoiceCallPlayAudioMethodParams) { + const { volume, ...rest } = params + const playlist = new Playlist({ volume }).add(Playlist.Audio(rest)) + return this.play(playlist) + } + + playSilence(params: VoiceCallPlaySilenceMethodParams) { + const playlist = new Playlist().add(Playlist.Silence(params)) + return this.play(playlist) + } + + playRingtone(params: VoiceCallPlayRingtoneMethodParams) { + const { volume, ...rest } = params + const playlist = new Playlist({ volume }).add(Playlist.Ringtone(rest)) + return this.play(playlist) + } + + playTTS(params: VoiceCallPlayTTSMethodParams) { + const { volume, ...rest } = params + const playlist = new Playlist({ volume }).add(Playlist.TTS(rest)) + return this.play(playlist) + } + + record(params: VoiceCallRecordMethodParams) { + return new Promise((resolve, reject) => { + if (!this.callId || !this.nodeId) { + reject(new Error(`Can't call record() on a call not established yet.`)) + } + + const controlId = uuid() + + this.runWorker('voiceCallRecordWorker', { + worker: voiceCallRecordWorker, + initialState: { + controlId, + }, + }) + + const resolveHandler = (callRecording: CallRecording) => { + resolve(callRecording) + } + + // @ts-expect-error + this.on(callingRecordTriggerEvent, resolveHandler) + + const record = toSnakeCaseKeys(params) + this.execute({ + method: 'calling.record', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: controlId, + record, + }, + }) + .then(() => { + const startEvent: Omit = { + control_id: controlId, + call_id: this.id, + node_id: this.nodeId, + // state: 'recording', + record, + } + // @ts-expect-error + this.emit(callingRecordTriggerEvent, startEvent) + }) + .catch((e) => { + // @ts-expect-error + this.off(callingRecordTriggerEvent, resolveHandler) + reject(e) + }) + }) + } + + recordAudio(params: VoiceCallRecordMethodParams['audio'] = {}) { + return this.record({ + audio: params, + }) + } + + prompt(params: VoiceCallPromptMethodParams) { + return new Promise((resolve, reject) => { + if (!this.callId || !this.nodeId) { + reject(new Error(`Can't call record() on a call not established yet.`)) + } + + const controlId = uuid() + + this.runWorker('voiceCallPromptWorker', { + worker: voiceCallPromptWorker, + initialState: { + controlId, + }, + }) + + const resolveHandler = (callRecording: CallPrompt) => { + resolve(callRecording) + } + // @ts-expect-error + this.on(callingPromptTriggerEvent, resolveHandler) + + // TODO: move this to a method to build `collect` + const { + initial_timeout, + partial_results, + digits, + speech, + media, + volume, + } = toSnakeCaseKeys(params) + const collect = { + initial_timeout, + partial_results, + digits, + speech, + } + this.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 startEvent: Omit = { + control_id: controlId, + call_id: this.id, + node_id: this.nodeId, + } + // TODO: (review) There's no event for prompt started so we generate it here + this.emit('prompt.started', startEvent) + + // @ts-expect-error + this.emit(callingPromptTriggerEvent, startEvent) + }) + .catch((e) => { + this.off('prompt.started', resolveHandler) + + // @ts-expect-error + this.off(callingPromptTriggerEvent, resolveHandler) + reject(e) + }) + }) + } + + promptAudio(params: VoiceCallPromptAudioMethodParams) { + const { url, ...rest } = params + + return this.prompt({ + media: [{ type: 'audio', url }], + ...rest, + }) + } + + promptRingtone(params: VoiceCallPromptRingtoneMethodParams) { + // FIXME: ringtone `name` is too generic as argument + const { name, duration, ...rest } = params + + return this.prompt({ + media: [{ type: 'ringtone', name, duration }], + ...rest, + }) + } + + promptTTS(params: VoiceCallPromptTTSMethodParams) { + const { text, language, gender, ...rest } = params + + return this.prompt({ + media: [{ type: 'tts', text, language, gender }], + ...rest, + }) + } + + sendDigits(digits: string) { + return new Promise((resolve, reject) => { + if (!this.callId || !this.nodeId) { + reject( + new Error(`Can't call sendDigits() on a call not established yet.`) + ) + } + + const controlId = uuid() + + const cleanup = () => { + // @ts-expect-error + this.off('call.state', callStateHandler) + } + + this.runWorker( + 'voiceCallSendDigitsWorker', + { + worker: voiceCallSendDigitsWorker, + initialState: { + controlId, + }, + onDone: (args) => { + cleanup() + resolve(args) + }, + onFail: ({ error }) => { + cleanup() + reject(error) + }, + } + ) + + 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." + ) + ) + } + } + // @ts-expect-error + this.once('call.state', callStateHandler) + + this.execute({ + method: 'calling.send_digits', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: controlId, + digits, + }, + }).catch((e) => { + reject(e) + }) + }) + } + + tap(params: VoiceCallTapMethodParams) { + return new Promise((resolve, reject) => { + if (!this.callId || !this.nodeId) { + reject(new Error(`Can't call tap() on a call not established yet.`)) + } + + const controlId = uuid() + + this.runWorker('voiceCallTapWorker', { + worker: voiceCallTapWorker, + initialState: { + controlId, + }, + }) + + const resolveHandler = (callTap: CallTap) => { + resolve(callTap) + } + + // @ts-expect-error + this.on(callingTapTriggerEvent, resolveHandler) + + // TODO: Move to a method to build the objects and transform camelCase to snake_case + const { + audio = {}, + device: { type, ...rest }, + } = params + + this.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(() => { + const startEvent: Omit< + CallingCallTapEventParams, + 'state' | 'tap' | 'device' + > = { + control_id: controlId, + call_id: this.id, + node_id: this.nodeId, + } + // @ts-expect-error + this.emit(callingTapTriggerEvent, startEvent) + }) + .catch((e) => { + // @ts-expect-error + this.off(callingTapTriggerEvent, resolveHandler) + reject(e) + }) + }) + } + + tapAudio(params: VoiceCallTapAudioMethodParams) { + const { direction, device } = params + return this.tap({ audio: { direction }, device }) + } + + connect(params: VoiceCallConnectMethodParams) { + return new Promise((resolve, reject) => { + if (!this.callId || !this.nodeId) { + reject(new Error(`Can't call connect() on a call not established yet.`)) + } + + this.runWorker('voiceCallConnectWorker', { + worker: voiceCallConnectWorker, + }) + + const resolveHandler = (payload: CallingCallConnectEventParams) => { + // @ts-expect-error + this.off('connect.failed', rejectHandler) + + resolve(payload) + } + + const rejectHandler = (payload: CallingCallConnectEventParams) => { + // @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) + + const { devices, ringback = [] } = params + this.execute({ + method: 'calling.connect', + params: { + node_id: this.nodeId, + call_id: this.callId, + tag: this.__uuid, + devices: toInternalDevices(devices), + ringback: toInternalPlayParams(ringback), + }, + }).catch((e) => { + // @ts-expect-error + this.off('connect.connected', resolveHandler) + // @ts-expect-error + this.off('connect.failed', rejectHandler) + + reject(e) + }) + }) + } + + disconnect() { + return new Promise((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.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) + }) + }) + } + + waitUntilConnected() { + return new Promise((resolve) => { + const resolveHandler = () => { + resolve(this) + } + // @ts-expect-error + this.once('connect.disconnected', resolveHandler) + // @ts-expect-error + this.once('connect.failed', resolveHandler) + }) + } + + detect(params: VoiceCallDetectMethodParams) { + return new Promise((resolve, reject) => { + if (!this.callId || !this.nodeId) { + reject(new Error(`Can't call detect() on a call not established yet.`)) + } + + // TODO: build params in a method + const { waitForBeep = false, timeout, type, ...rest } = params + const controlId = uuid() + + this.runWorker('voiceCallDetectWorker', { + worker: voiceCallDetectWorker, + initialState: { + controlId, + waitForBeep, + }, + }) + + const resolveHandler = (callDetect: CallDetect) => { + resolve(callDetect) + } + + // @ts-expect-error + this.on(callingDetectTriggerEvent, resolveHandler) + + this.execute({ + method: 'calling.detect', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: controlId, + timeout, + detect: { + type, + params: toSnakeCaseKeys(rest), + }, + }, + }) + .then(() => { + const startEvent: CallingCallDetectEventParams = { + control_id: controlId, + call_id: this.id, + node_id: this.nodeId, + } + // @ts-expect-error + this.emit(callingDetectTriggerEvent, startEvent) + }) + .catch((e) => { + // @ts-expect-error + this.off(callingDetectTriggerEvent, resolveHandler) + reject(e) + }) + }) + } + + amd(params: Omit = {}) { + return this.detect({ + ...params, + type: 'machine', + }) + } + + detectFax(params: Omit = {}) { + return this.detect({ + ...params, + type: 'fax', + }) + } + + detectDigit(params: Omit = {}) { + return this.detect({ + ...params, + type: 'digit', + }) + } } -export const CallAPI = extendComponent< +// FIXME: instead of Omit methods, i used "Partial" +export const CallAPI = extendComponent>( CallConsumer, - Omit ->(CallConsumer, {}) + {} +) export const createCallObject = ( params: BaseComponentOptions diff --git a/packages/realtime-api/src/voice/CallClient.ts b/packages/realtime-api/src/voice/CallClient.ts deleted file mode 100644 index 984dcdde3..000000000 --- a/packages/realtime-api/src/voice/CallClient.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { AssertSameType, UserOptions } from '@signalwire/core' -import type { RealTimeCallApiEvents } from '../types' -import { getLogger } from '@signalwire/core' -import { setupClient, clientConnect, RealtimeClient } from '../client/index' -import { createCallObject, Call } from './Call' -import { CallClientDocs } from './CallClient.docs' - -/** - * List of events for {@link Voice.Call}. - */ -export interface CallClientApiEvents extends RealTimeCallApiEvents {} - -interface CallClientMain extends Call { - new (opts: CallClientOptions): this -} - -interface CallClient extends AssertSameType {} - -/** @ignore */ -export interface CallClientOptions - extends Omit { - token?: string -} - -/** @ignore */ -const CallClient = function (options?: CallClientOptions) { - const { client, store, emitter } = setupClient(options) - - const clientOn: RealtimeClient['on'] = (...args) => { - clientConnect(client) - - return client.on(...args) - } - const clientOnce: RealtimeClient['once'] = (...args) => { - clientConnect(client) - - return client.once(...args) - } - - const callDial: Call['dial'] = async (...args) => { - await clientConnect(client) - - const call = createCallObject({ - store, - emitter, - }) - - client.once('session.connected', async () => { - try { - await call.subscribe() - } catch (e) { - // TODO: In the future we'll provide a - // `onSubscribedError` (or similar) to allow the user - // customize this behavior. - getLogger().error('Client subscription failed.') - client.disconnect() - } - }) - - await call.dial(...args) - - return call - } - - const interceptors = { - on: clientOn, - once: clientOnce, - dial: callDial, - _session: client, - } as const - - return new Proxy>(client, { - get(target, prop, receiver) { - if (prop in interceptors) { - // @ts-expect-error - return interceptors[prop] - } - - return Reflect.get(target, prop, receiver) - }, - }) - // For consistency with other constructors we'll make TS force the use of `new` -} as unknown as { new (options?: CallClientOptions): CallClient } - -export { CallClient } diff --git a/packages/realtime-api/src/voice/CallDetect.test.ts b/packages/realtime-api/src/voice/CallDetect.test.ts new file mode 100644 index 000000000..b06e28ba9 --- /dev/null +++ b/packages/realtime-api/src/voice/CallDetect.test.ts @@ -0,0 +1,46 @@ +import { EventEmitter } from '@signalwire/core' +import { configureJestStore } from '../testUtils' +import { + createCallDetectObject, + CallDetect, + CallDetectEventsHandlerMapping, +} from './CallDetect' + +describe('CallDetect', () => { + describe('createCallDetectObject', () => { + let instance: CallDetect + beforeEach(() => { + instance = createCallDetectObject({ + store: configureJestStore(), + emitter: new EventEmitter(), + }) + // @ts-expect-error + instance.execute = jest.fn() + }) + + it('should control an active playback', async () => { + // @ts-expect-error + instance.callId = 'call_id' + // @ts-expect-error + instance.nodeId = 'node_id' + // @ts-expect-error + instance.controlId = 'control_id' + + const baseExecuteParams = { + method: '', + params: { + call_id: 'call_id', + node_id: 'node_id', + control_id: 'control_id', + }, + } + + await instance.stop() + // @ts-expect-error + expect(instance.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.detect.stop', + }) + }) + }) +}) diff --git a/packages/realtime-api/src/voice/CallDetect.ts b/packages/realtime-api/src/voice/CallDetect.ts new file mode 100644 index 000000000..bb7a7be09 --- /dev/null +++ b/packages/realtime-api/src/voice/CallDetect.ts @@ -0,0 +1,85 @@ +import { + connect, + BaseComponent, + BaseComponentOptions, + VoiceCallDetectContract, + Detector, +} from '@signalwire/core' + +/** + * Instances of this class allow you to control (e.g., resume) the + * detect inside a Voice Call. You can obtain instances of this class by + * starting a Detect from the desired {@link Call} (see + * {@link Call.detect}) + */ +export interface CallDetect extends VoiceCallDetectContract {} + +export type CallDetectEventsHandlerMapping = {} + +export interface CallDetectOptions + extends BaseComponentOptions {} + +export class CallDetectAPI + extends BaseComponent + implements VoiceCallDetectContract +{ + protected _eventsPrefix = 'calling' as const + + callId: string + nodeId: string + controlId: string + detect?: Detector + + get id() { + return this.controlId + } + + get type() { + return this?.detect?.type + } + + async stop() { + // if (this.state !== 'finished') { + await this.execute({ + method: 'calling.detect.stop', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: this.controlId, + }, + }) + // } + + return this + } + + waitForResult() { + return new Promise((resolve) => { + this._attachListeners(this.controlId) + + // @ts-expect-error + this.once('detect.ended', () => { + resolve(this) + }) + }) + } +} + +export const createCallDetectObject = ( + params: CallDetectOptions +): CallDetect => { + const detect = connect< + CallDetectEventsHandlerMapping, + CallDetectAPI, + CallDetect + >({ + store: params.store, + Component: CallDetectAPI, + componentListeners: { + errors: 'onError', + responses: 'onSuccess', + }, + })(params) + + return detect +} diff --git a/packages/realtime-api/src/voice/CallPlayback.test.ts b/packages/realtime-api/src/voice/CallPlayback.test.ts new file mode 100644 index 000000000..73b156ac3 --- /dev/null +++ b/packages/realtime-api/src/voice/CallPlayback.test.ts @@ -0,0 +1,68 @@ +import { EventEmitter } from '@signalwire/core' +import { configureJestStore } from '../testUtils' +import { + createCallPlaybackObject, + CallPlayback, + CallPlaybackEventsHandlerMapping, +} from './CallPlayback' + +describe('CallPlayback', () => { + describe('createCallPlaybackObject', () => { + let instance: CallPlayback + beforeEach(() => { + instance = createCallPlaybackObject({ + store: configureJestStore(), + emitter: new EventEmitter(), + }) + // @ts-expect-error + instance.execute = jest.fn() + }) + + it('should control an active playback', async () => { + // @ts-expect-error + instance.callId = 'call_id' + // @ts-expect-error + instance.nodeId = 'node_id' + // @ts-expect-error + instance.controlId = 'control_id' + + const baseExecuteParams = { + method: '', + params: { + call_id: 'call_id', + node_id: 'node_id', + control_id: 'control_id', + }, + } + await instance.pause() + // @ts-expect-error + expect(instance.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.play.pause', + }) + await instance.resume() + // @ts-expect-error + expect(instance.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.play.resume', + }) + await instance.stop() + // @ts-expect-error + expect(instance.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.play.stop', + }) + await instance.setVolume(2) + // @ts-expect-error + expect(instance.execute).toHaveBeenLastCalledWith({ + method: 'calling.play.volume', + params: { + call_id: 'call_id', + node_id: 'node_id', + control_id: 'control_id', + volume: 2, + }, + }) + }) + }) +}) diff --git a/packages/realtime-api/src/voice/CallPlayback.ts b/packages/realtime-api/src/voice/CallPlayback.ts new file mode 100644 index 000000000..814a455e5 --- /dev/null +++ b/packages/realtime-api/src/voice/CallPlayback.ts @@ -0,0 +1,131 @@ +import { + connect, + BaseComponent, + BaseComponentOptions, + VoiceCallPlaybackContract, + CallingCallPlayState, +} from '@signalwire/core' + +/** + * Instances of this class allow you to control (e.g., pause, resume, stop) the + * playback inside a Voice Call. You can obtain instances of this class by + * starting a playback from the desired {@link Call} (see + * {@link Call.play}) + */ +export interface CallPlayback extends VoiceCallPlaybackContract {} + +// export type CallPlaybackEventsHandlerMapping = Record< +// VideoPlaybackEventNames, +// (playback: CallPlayback) => void +// > +export type CallPlaybackEventsHandlerMapping = {} + +export interface CallPlaybackOptions + extends BaseComponentOptions {} + +export class CallPlaybackAPI + extends BaseComponent + implements VoiceCallPlaybackContract +{ + protected _eventsPrefix = 'calling' as const + + callId: string + nodeId: string + controlId: string + state: CallingCallPlayState = 'playing' + private _volume: number + + get id() { + return this.controlId + } + + get volume() { + return this._volume + } + + async pause() { + await this.execute({ + method: 'calling.play.pause', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: this.controlId, + }, + }) + + return this + } + + async resume() { + await this.execute({ + method: 'calling.play.resume', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: this.controlId, + }, + }) + + return this + } + + async stop() { + await this.execute({ + method: 'calling.play.stop', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: this.controlId, + }, + }) + + return this + } + + async setVolume(volume: number) { + this._volume = volume + + await this.execute({ + method: 'calling.play.volume', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: this.controlId, + volume, + }, + }) + + return this + } + + waitForEnded() { + return new Promise((resolve) => { + this._attachListeners(this.controlId) + + const handler = () => resolve(this) + // @ts-expect-error + this.once('playback.ended', handler) + // // @ts-expect-error + // this.on('prompt.failed', handler) + }) + } +} + +export const createCallPlaybackObject = ( + params: CallPlaybackOptions +): CallPlayback => { + const playback = connect< + CallPlaybackEventsHandlerMapping, + CallPlaybackAPI, + CallPlayback + >({ + store: params.store, + Component: CallPlaybackAPI, + componentListeners: { + errors: 'onError', + responses: 'onSuccess', + }, + })(params) + + return playback +} diff --git a/packages/realtime-api/src/voice/CallPrompt.test.ts b/packages/realtime-api/src/voice/CallPrompt.test.ts new file mode 100644 index 000000000..fbc5ec672 --- /dev/null +++ b/packages/realtime-api/src/voice/CallPrompt.test.ts @@ -0,0 +1,58 @@ +import { EventEmitter } from '@signalwire/core' +import { configureJestStore } from '../testUtils' +import { + createCallPromptObject, + CallPrompt, + CallPromptEventsHandlerMapping, +} from './CallPrompt' + +describe('CallPrompt', () => { + describe('createCallPromptObject', () => { + let instance: CallPrompt + beforeEach(() => { + instance = createCallPromptObject({ + store: configureJestStore(), + emitter: new EventEmitter(), + }) + // @ts-expect-error + instance.execute = jest.fn() + }) + + it('should control an active playback', async () => { + // @ts-expect-error + instance.callId = 'call_id' + // @ts-expect-error + instance.nodeId = 'node_id' + // @ts-expect-error + instance.controlId = 'control_id' + + const baseExecuteParams = { + method: '', + params: { + call_id: 'call_id', + node_id: 'node_id', + control_id: 'control_id', + }, + } + + await instance.stop() + // @ts-expect-error + expect(instance.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.play_and_collect.stop', + }) + + await instance.setVolume(5) + // @ts-expect-error + expect(instance.execute).toHaveBeenLastCalledWith({ + method: 'calling.play_and_collect.volume', + params: { + call_id: 'call_id', + node_id: 'node_id', + control_id: 'control_id', + volume: 5, + }, + }) + }) + }) +}) diff --git a/packages/realtime-api/src/voice/CallPrompt.ts b/packages/realtime-api/src/voice/CallPrompt.ts new file mode 100644 index 000000000..ddb7cfda1 --- /dev/null +++ b/packages/realtime-api/src/voice/CallPrompt.ts @@ -0,0 +1,142 @@ +import { + connect, + BaseComponent, + BaseComponentOptions, + VoiceCallPromptContract, + CallingCallCollectResult, +} from '@signalwire/core' + +/** + * Instances of this class allow you to control (e.g., resume) the + * prompt inside a Voice Call. You can obtain instances of this class by + * starting a Prompt from the desired {@link Call} (see + * {@link Call.prompt}) + */ +export interface CallPrompt extends VoiceCallPromptContract {} + +export type CallPromptEventsHandlerMapping = {} + +export interface CallPromptOptions + extends BaseComponentOptions {} + +export class CallPromptAPI + extends BaseComponent + implements VoiceCallPromptContract +{ + protected _eventsPrefix = 'calling' as const + + callId: string + nodeId: string + controlId: string + result?: CallingCallCollectResult + + get id() { + return this.controlId + } + + get type() { + return this.result?.type + } + + /** + * User-friendly alias to understand the reason in case of errors + * no_match | no_input | error + */ + get reason() { + return this.type + } + + get digits() { + if (this.result?.type === 'digit') { + return this.result.params.digits + } + return undefined + } + + get terminator() { + if (this.result?.type === 'digit') { + return this.result.params.terminator + } + return undefined + } + + get text() { + if (this.result?.type === 'speech') { + return this.result.params.text + } + return undefined + } + + get confidence() { + if (this.result?.type === 'speech') { + return this.result.params.confidence + } + return undefined + } + + async stop() { + // Execute stop only if we don't have result yet + if (!this.result) { + await this.execute({ + method: 'calling.play_and_collect.stop', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: this.controlId, + }, + }) + } + + /** + * TODO: we should wait for the prompt to be finished to allow + * the CallPrompt/Proxy object to update the payload properly + */ + + return this + } + + async setVolume(volume: number): Promise { + await this.execute({ + method: 'calling.play_and_collect.volume', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: this.controlId, + volume, + }, + }) + + return this + } + + waitForResult() { + return new Promise((resolve) => { + this._attachListeners(this.controlId) + + const handler = () => resolve(this) + // @ts-expect-error + this.once('prompt.ended', handler) + // @ts-expect-error + this.once('prompt.failed', handler) + }) + } +} + +export const createCallPromptObject = ( + params: CallPromptOptions +): CallPrompt => { + const record = connect< + CallPromptEventsHandlerMapping, + CallPromptAPI, + CallPrompt + >({ + store: params.store, + Component: CallPromptAPI, + componentListeners: { + errors: 'onError', + responses: 'onSuccess', + }, + })(params) + + return record +} diff --git a/packages/realtime-api/src/voice/CallRecording.test.ts b/packages/realtime-api/src/voice/CallRecording.test.ts new file mode 100644 index 000000000..806edbf0c --- /dev/null +++ b/packages/realtime-api/src/voice/CallRecording.test.ts @@ -0,0 +1,46 @@ +import { EventEmitter } from '@signalwire/core' +import { configureJestStore } from '../testUtils' +import { + createCallRecordingObject, + CallRecording, + CallRecordingEventsHandlerMapping, +} from './CallRecording' + +describe('CallRecording', () => { + describe('createCallRecordingObject', () => { + let instance: CallRecording + beforeEach(() => { + instance = createCallRecordingObject({ + store: configureJestStore(), + emitter: new EventEmitter(), + }) + // @ts-expect-error + instance.execute = jest.fn() + }) + + it('should control an active playback', async () => { + // @ts-expect-error + instance.callId = 'call_id' + // @ts-expect-error + instance.nodeId = 'node_id' + // @ts-expect-error + instance.controlId = 'control_id' + + const baseExecuteParams = { + method: '', + params: { + call_id: 'call_id', + node_id: 'node_id', + control_id: 'control_id', + }, + } + + await instance.stop() + // @ts-expect-error + expect(instance.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.record.stop', + }) + }) + }) +}) diff --git a/packages/realtime-api/src/voice/CallRecording.ts b/packages/realtime-api/src/voice/CallRecording.ts new file mode 100644 index 000000000..1720ce018 --- /dev/null +++ b/packages/realtime-api/src/voice/CallRecording.ts @@ -0,0 +1,71 @@ +import { + connect, + BaseComponent, + BaseComponentOptions, + VoiceCallRecordingContract, + CallingCallRecordState, +} from '@signalwire/core' + +/** + * Instances of this class allow you to control (e.g., resume) the + * recording inside a Voice Call. You can obtain instances of this class by + * starting a recording from the desired {@link Call} (see + * {@link Call.record}) + */ +export interface CallRecording extends VoiceCallRecordingContract {} + +export type CallRecordingEventsHandlerMapping = {} + +export interface CallRecordingOptions + extends BaseComponentOptions {} + +export class CallRecordingAPI + extends BaseComponent + implements VoiceCallRecordingContract +{ + callId: string + nodeId: string + controlId: string + state: CallingCallRecordState + + get id() { + return this.controlId + } + + async stop() { + await this.execute({ + method: 'calling.record.stop', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: this.controlId, + }, + }) + + /** + * TODO: we should wait for the recording `finished` event to allow + * the CallRecording/Proxy object to update the payload properly + */ + + return this + } +} + +export const createCallRecordingObject = ( + params: CallRecordingOptions +): CallRecording => { + const record = connect< + CallRecordingEventsHandlerMapping, + CallRecordingAPI, + CallRecording + >({ + store: params.store, + Component: CallRecordingAPI, + componentListeners: { + errors: 'onError', + responses: 'onSuccess', + }, + })(params) + + return record +} diff --git a/packages/realtime-api/src/voice/CallTap.test.ts b/packages/realtime-api/src/voice/CallTap.test.ts new file mode 100644 index 000000000..cf7a160e5 --- /dev/null +++ b/packages/realtime-api/src/voice/CallTap.test.ts @@ -0,0 +1,46 @@ +import { EventEmitter } from '@signalwire/core' +import { configureJestStore } from '../testUtils' +import { + createCallTapObject, + CallTap, + CallTapEventsHandlerMapping, +} from './CallTap' + +describe('CallTap', () => { + describe('createCallTapObject', () => { + let instance: CallTap + beforeEach(() => { + instance = createCallTapObject({ + store: configureJestStore(), + emitter: new EventEmitter(), + }) + // @ts-expect-error + instance.execute = jest.fn() + }) + + it('should control an active playback', async () => { + // @ts-expect-error + instance.callId = 'call_id' + // @ts-expect-error + instance.nodeId = 'node_id' + // @ts-expect-error + instance.controlId = 'control_id' + + const baseExecuteParams = { + method: '', + params: { + call_id: 'call_id', + node_id: 'node_id', + control_id: 'control_id', + }, + } + + await instance.stop() + // @ts-expect-error + expect(instance.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.tap.stop', + }) + }) + }) +}) diff --git a/packages/realtime-api/src/voice/CallTap.ts b/packages/realtime-api/src/voice/CallTap.ts new file mode 100644 index 000000000..b21a186f8 --- /dev/null +++ b/packages/realtime-api/src/voice/CallTap.ts @@ -0,0 +1,62 @@ +import { + connect, + BaseComponent, + BaseComponentOptions, + VoiceCallTapContract, + CallingCallTapState, +} from '@signalwire/core' + +/** + * Instances of this class allow you to control (e.g., resume) the + * tap inside a Voice Call. You can obtain instances of this class by + * starting a Tap from the desired {@link Call} (see + * {@link Call.tap}) + */ +export interface CallTap extends VoiceCallTapContract {} + +export type CallTapEventsHandlerMapping = {} + +export interface CallTapOptions + extends BaseComponentOptions {} + +export class CallTapAPI + extends BaseComponent + implements VoiceCallTapContract +{ + callId: string + nodeId: string + controlId: string + state: CallingCallTapState + + get id() { + return this.controlId + } + + async stop() { + if (this.state !== 'finished') { + await this.execute({ + method: 'calling.tap.stop', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: this.controlId, + }, + }) + } + + return this + } +} + +export const createCallTapObject = (params: CallTapOptions): CallTap => { + const tap = connect({ + store: params.store, + Component: CallTapAPI, + componentListeners: { + errors: 'onError', + responses: 'onSuccess', + }, + })(params) + + return tap +} diff --git a/packages/realtime-api/src/voice/Dialer.test.ts b/packages/realtime-api/src/voice/Dialer.test.ts new file mode 100644 index 000000000..6639bab4f --- /dev/null +++ b/packages/realtime-api/src/voice/Dialer.test.ts @@ -0,0 +1,87 @@ +import { Dialer } from './Dialer' + +describe('Dialer', () => { + it('should build a list of devices to dial', () => { + const dialer = new Dialer() + + dialer + .add(Dialer.Phone({ from: '+1', to: '+2', timeout: 30 })) + .add( + Dialer.Sip({ + from: 'sip:one', + to: 'sip:two', + headers: [{ name: 'foo', value: 'bar' }], + }) + ) + .add([ + Dialer.Phone({ from: '+3', to: '+4' }), + Dialer.Sip({ + from: 'sip:three', + to: 'sip:four', + headers: [{ name: 'baz', value: 'qux' }], + }), + Dialer.Phone({ from: '+5', to: '+6' }), + ]) + + expect(dialer.devices).toStrictEqual([ + [ + { + type: 'phone', + from: '+1', + to: '+2', + timeout: 30, + }, + ], + [ + { + type: 'sip', + from: 'sip:one', + to: 'sip:two', + headers: [{ name: 'foo', value: 'bar' }], + }, + ], + [ + { + type: 'phone', + from: '+3', + to: '+4', + }, + { + type: 'sip', + from: 'sip:three', + to: 'sip:four', + headers: [{ name: 'baz', value: 'qux' }], + }, + { + type: 'phone', + from: '+5', + to: '+6', + }, + ], + ]) + }) + + it('should build a list of devices to dial including region', () => { + const dialer = new Dialer({ region: 'us' }) + dialer.add([ + Dialer.Phone({ from: '+3', to: '+4' }), + Dialer.Phone({ from: '+5', to: '+6' }), + ]) + + expect(dialer.region).toBe('us') + expect(dialer.devices).toStrictEqual([ + [ + { + type: 'phone', + from: '+3', + to: '+4', + }, + { + type: 'phone', + from: '+5', + to: '+6', + }, + ], + ]) + }) +}) diff --git a/packages/realtime-api/src/voice/Dialer.ts b/packages/realtime-api/src/voice/Dialer.ts new file mode 100644 index 000000000..f92f9ed77 --- /dev/null +++ b/packages/realtime-api/src/voice/Dialer.ts @@ -0,0 +1,41 @@ +import type { + CreateVoiceDialerParams, + VoiceDialer, + VoiceCallDeviceParams, + VoiceCallPhoneParams, + VoiceCallDialPhoneMethodParams, + VoiceCallSipParams, + VoiceCallDialSipMethodParams, +} from '@signalwire/core' + +export class Dialer implements VoiceDialer { + private _devices: VoiceDialer['devices'] = [] + + constructor(private params: CreateVoiceDialerParams = {}) {} + + get region() { + return this.params?.region + } + + get devices() { + return this._devices + } + + add(params: VoiceCallDeviceParams | VoiceCallDeviceParams[]) { + if (Array.isArray(params)) { + this._devices.push(params) + } else { + this._devices.push([params]) + } + + return this + } + + static Phone(params: VoiceCallDialPhoneMethodParams): VoiceCallPhoneParams { + return { type: 'phone', ...params } + } + + static Sip(params: VoiceCallDialSipMethodParams): VoiceCallSipParams { + return { type: 'sip', ...params } + } +} diff --git a/packages/realtime-api/src/voice/Playlist.test.ts b/packages/realtime-api/src/voice/Playlist.test.ts new file mode 100644 index 000000000..7312725bb --- /dev/null +++ b/packages/realtime-api/src/voice/Playlist.test.ts @@ -0,0 +1,58 @@ +import { Playlist } from './Playlist' + +describe('Playlist', () => { + it('should build a list of devices to dial', () => { + const playlist = new Playlist() + playlist.add(Playlist.Audio({ url: 'https://example.com/hello.mp3' })) + playlist.add(Playlist.Silence({ duration: 5 })) + playlist.add(Playlist.TTS({ text: 'Hello World' })) + playlist.add(Playlist.Ringtone({ name: 'us' })) + playlist.add(Playlist.Audio({ url: 'https://example.com/hello2.mp3' })) + + expect(playlist.media).toStrictEqual([ + { + type: 'audio', + url: 'https://example.com/hello.mp3', + }, + { + type: 'silence', + duration: 5, + }, + { + type: 'tts', + text: 'Hello World', + }, + { + type: 'ringtone', + name: 'us', + }, + { + type: 'audio', + url: 'https://example.com/hello2.mp3', + }, + ]) + }) + + it('should build a list of devices to dial including volume', () => { + const playlist = new Playlist({ volume: 2 }) + playlist.add(Playlist.Audio({ url: 'https://example.com/hello.mp3' })) + playlist.add(Playlist.Silence({ duration: 5 })) + playlist.add(Playlist.TTS({ text: 'Hello World' })) + + expect(playlist.volume).toBe(2) + expect(playlist.media).toStrictEqual([ + { + type: 'audio', + url: 'https://example.com/hello.mp3', + }, + { + type: 'silence', + duration: 5, + }, + { + type: 'tts', + text: 'Hello World', + }, + ]) + }) +}) diff --git a/packages/realtime-api/src/voice/Playlist.ts b/packages/realtime-api/src/voice/Playlist.ts new file mode 100644 index 000000000..1bb3afc12 --- /dev/null +++ b/packages/realtime-api/src/voice/Playlist.ts @@ -0,0 +1,54 @@ +import type { + CreateVoicePlaylistParams, + VoicePlaylist, + VoiceCallPlayParams, + VoiceCallPlayAudioParams, + VoiceCallPlayAudioMethodParams, + VoiceCallPlayTTSParams, + VoiceCallPlayTTSMethodParams, + VoiceCallPlaySilenceParams, + VoiceCallPlaySilenceMethodParams, + VoiceCallPlayRingtoneParams, + VoiceCallPlayRingtoneMethodParams, +} from '@signalwire/core' + +export class Playlist implements VoicePlaylist { + private _media: VoicePlaylist['media'] = [] + + constructor(private params: CreateVoicePlaylistParams = {}) {} + + get volume() { + return this.params?.volume + } + + get media() { + return this._media + } + + add(params: VoiceCallPlayParams) { + this._media.push(params) + return this + } + + static Audio( + params: VoiceCallPlayAudioMethodParams + ): VoiceCallPlayAudioParams { + return { type: 'audio', ...params } + } + + static TTS(params: VoiceCallPlayTTSMethodParams): VoiceCallPlayTTSParams { + return { type: 'tts', ...params } + } + + static Silence( + params: VoiceCallPlaySilenceMethodParams + ): VoiceCallPlaySilenceParams { + return { type: 'silence', ...params } + } + + static Ringtone( + params: VoiceCallPlayRingtoneMethodParams + ): VoiceCallPlayRingtoneParams { + return { type: 'ringtone', ...params } + } +} diff --git a/packages/realtime-api/src/voice/Voice.ts b/packages/realtime-api/src/voice/Voice.ts index 944b9aa52..1b37815dd 100644 --- a/packages/realtime-api/src/voice/Voice.ts +++ b/packages/realtime-api/src/voice/Voice.ts @@ -1,9 +1,105 @@ -import { - CallClient, - CallClientApiEvents, - CallClientOptions, -} from './CallClient' +import { connect, BaseComponentOptions, toExternalJSON } from '@signalwire/core' +import type { + EmitterContract, + EventTransform, + CallingCallReceiveEventParams, + VoiceDialer, + VoiceCallDialPhoneMethodParams, + VoiceCallDialSipMethodParams, +} from '@signalwire/core' +import { RealtimeClient } from '../client/index' +import { createCallObject, Call } from './Call' +import { voiceCallReceiveWorker } from './workers' +import { Dialer } from './Dialer' +import type { RealTimeCallApiEvents } from '../types' +import { AutoApplyTransformsConsumer } from '../AutoApplyTransformsConsumer' -export type { CallClientApiEvents, CallClientOptions } +export * from './VoiceClient' +export { Dialer } +export { Playlist } from './Playlist' -export { CallClient as Client } +/** + * List of events for {@link Voice.Call}. + */ +export interface RealTimeVoiceApiEvents extends RealTimeCallApiEvents {} + +type EmitterTransformsEvents = 'calling.call.received' + +export interface Voice extends EmitterContract { + /** @internal */ + _session: RealtimeClient + dial(dialer: VoiceDialer): Promise + dialPhone(params: VoiceCallDialPhoneMethodParams): Promise + dialSip(params: VoiceCallDialSipMethodParams): Promise +} + +/** @internal */ +class VoiceAPI extends AutoApplyTransformsConsumer { + /** @internal */ + protected _eventsPrefix = 'calling' as const + + constructor(options: BaseComponentOptions) { + super(options) + + this.runWorker('voiceCallReceiveWorker', { + worker: voiceCallReceiveWorker, + }) + + this._attachListeners('') + } + + /** @internal */ + protected getEmitterTransforms() { + return new Map< + EmitterTransformsEvents | EmitterTransformsEvents[], + EventTransform + >([ + [ + 'calling.call.received', + { + type: 'voiceCallReceived', + instanceFactory: (_payload: any) => { + return createCallObject({ + store: this.store, + // @ts-expect-error + emitter: this.emitter, + }) + }, + payloadTransform: (payload: CallingCallReceiveEventParams) => { + return toExternalJSON(payload) + }, + }, + ], + ]) + } + + dialPhone(params: VoiceCallDialPhoneMethodParams) { + const dialer = new Dialer().add(Dialer.Phone(params)) + // dial is available through the VoiceClient Proxy + // @ts-expect-error + return this.dial(dialer) + } + + dialSip(params: VoiceCallDialSipMethodParams) { + const dialer = new Dialer().add(Dialer.Sip(params)) + // dial is available through the VoiceClient Proxy + // @ts-expect-error + return this.dial(dialer) + } +} + +/** @internal */ +export const createVoiceObject = ( + params: BaseComponentOptions +): Voice => { + const voice = connect({ + store: params.store, + Component: VoiceAPI, + componentListeners: { + errors: 'onError', + responses: 'onSuccess', + }, + })(params) + + return voice +} diff --git a/packages/realtime-api/src/voice/CallClient.docs.ts b/packages/realtime-api/src/voice/VoiceClient.docs.ts similarity index 60% rename from packages/realtime-api/src/voice/CallClient.docs.ts rename to packages/realtime-api/src/voice/VoiceClient.docs.ts index 78e0174fa..639495c1f 100644 --- a/packages/realtime-api/src/voice/CallClient.docs.ts +++ b/packages/realtime-api/src/voice/VoiceClient.docs.ts @@ -1,10 +1,12 @@ -import { Call } from './Call' +import { Voice } from './Voice' -export interface CallClientDocs extends Call { +export interface VoiceClientDocs extends Voice { new (opts: { /** SignalWire project id, e.g. `a10d8a9f-2166-4e82-56ff-118bc3a4840f` */ project: string /** SignalWire project token, e.g. `PT9e5660c101cd140a1c93a0197640a369cf5f16975a0079c9` */ token: string + /** SignalWire contexts, e.g. 'home', 'office' */ + contexts: string[] }): this } diff --git a/packages/realtime-api/src/voice/VoiceClient.ts b/packages/realtime-api/src/voice/VoiceClient.ts new file mode 100644 index 000000000..52666f651 --- /dev/null +++ b/packages/realtime-api/src/voice/VoiceClient.ts @@ -0,0 +1,74 @@ +import type { AssertSameType, UserOptions } from '@signalwire/core' +import { setupClient, clientConnect } from '../client/index' +import { createCallObject, Call } from './Call' +import { VoiceClientDocs } from './VoiceClient.docs' +import { createVoiceObject, Voice } from './Voice' + +interface VoiceClientMain extends Voice { + new (opts: VoiceClientOptions): this +} + +interface VoiceClient + extends AssertSameType {} + +/** @ignore */ +export interface VoiceClientOptions + extends Omit { + contexts: string[] +} + +/** @ignore */ +const VoiceClient = function (options?: VoiceClientOptions) { + const { client, store, emitter } = setupClient(options) + + const voice = createVoiceObject({ + store, + emitter, + ...options, + }) + + const clientOn: Voice['on'] = (...args) => { + clientConnect(client) + + return voice.on(...args) + } + const clientOnce: Voice['once'] = (...args) => { + clientConnect(client) + + return voice.once(...args) + } + + const callDial: Call['dial'] = async (dialer) => { + await clientConnect(client) + + const call = createCallObject({ + store, + emitter, + }) + + await call.dial(dialer) + + return call + } + + const interceptors = { + on: clientOn, + once: clientOnce, + dial: callDial, + _session: client, + } as const + + return new Proxy>(voice, { + get(target, prop, receiver) { + if (prop in interceptors) { + // @ts-expect-error + return interceptors[prop] + } + + return Reflect.get(target, prop, receiver) + }, + }) + // For consistency with other constructors we'll make TS force the use of `new` +} as unknown as { new (options?: VoiceClientOptions): VoiceClient } + +export { VoiceClient as Client } diff --git a/packages/realtime-api/src/voice/utils.test.ts b/packages/realtime-api/src/voice/utils.test.ts index 5d3a57a7e..424aa8d41 100644 --- a/packages/realtime-api/src/voice/utils.test.ts +++ b/packages/realtime-api/src/voice/utils.test.ts @@ -81,8 +81,8 @@ describe('toInternalDevices', () => { { type: 'sip', params: { - to_number: '+12083660791', - from_number: '+15183601331', + to: '+12083660791', + from: '+15183601331', timeout: 30, }, }, diff --git a/packages/realtime-api/src/voice/utils.ts b/packages/realtime-api/src/voice/utils.ts index ce7118492..c33359d2d 100644 --- a/packages/realtime-api/src/voice/utils.ts +++ b/packages/realtime-api/src/voice/utils.ts @@ -1,20 +1,29 @@ -import { +import type { VoiceCallDeviceParams, VoiceCallDialMethodParams, + VoiceCallPlayParams, + VoiceCallPlayMethodParams, } from '@signalwire/core' +import { toSnakeCaseKeys } from '@signalwire/core' const toInternalDevice = (device: VoiceCallDeviceParams) => { switch (device.type) { - case 'sip': + case 'sip': { + const { type, ...params } = device + return { + type, + params: toSnakeCaseKeys(params), + } + } case 'phone': { const { to, from, type, ...rest } = device return { type, - params: { + params: toSnakeCaseKeys({ ...rest, to_number: to, from_number: from, - }, + }), } } @@ -42,3 +51,25 @@ export const toInternalDevices = ( }) return internalDevices } + +const toInternalPlay = (media: VoiceCallPlayParams) => { + const { type, ...params } = media + return { type, params } +} + +// TODO: add proper to internal mapping +type ToInternalPlayParams = T extends any ? any : any + +export const toInternalPlayParams = ( + params: VoiceCallPlayMethodParams['media'], + result: ToInternalPlayParams = [] +) => { + params.forEach((media, index) => { + if (Array.isArray(media)) { + result[index] = toInternalPlayParams(media) + } else { + result[index] = toInternalPlay(media) + } + }) + return result +} diff --git a/packages/realtime-api/src/voice/workers.ts b/packages/realtime-api/src/voice/workers.ts deleted file mode 100644 index 9d3ed5ac9..000000000 --- a/packages/realtime-api/src/voice/workers.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - findNamespaceInPayload, - sagaEffects, - SagaIterator, - SDKWorker, - toSyntheticEvent, -} from '@signalwire/core' - -export const SYNTHETIC_CALL_STATE_ANSWERED_EVENT = toSyntheticEvent( - 'calling.call.answered' -) - -export const SYNTHETIC_CALL_STATE_FAILED_EVENT = toSyntheticEvent( - 'calling.call.failed' -) - -export const SYNTHETIC_CALL_STATE_ENDED_EVENT = - toSyntheticEvent('calling.call.ended') - -const TARGET_CALL_STATES = ['answered', 'failed', 'ended'] - -export const voiceCallStateWorker: SDKWorker = function* ( - options -): SagaIterator { - let isDone = false - while (!isDone) { - const { channels, instance } = options - const { swEventChannel } = channels - const action = yield sagaEffects.take(swEventChannel, (action: any) => { - return ( - action.type === 'calling.call.state' && - findNamespaceInPayload(action) === instance.__uuid && - TARGET_CALL_STATES.includes(action.payload.call_state) - ) - }) - - if (action.payload.call_state === 'answered') { - yield sagaEffects.put(channels.pubSubChannel, { - type: SYNTHETIC_CALL_STATE_ANSWERED_EVENT, - payload: action.payload, - }) - } else if (action.payload.call_state === 'failed') { - yield sagaEffects.put(channels.pubSubChannel, { - type: SYNTHETIC_CALL_STATE_FAILED_EVENT, - payload: action.payload, - }) - } else if (action.payload.call_state === 'ended') { - isDone = true - - yield sagaEffects.put(channels.pubSubChannel, { - type: SYNTHETIC_CALL_STATE_ENDED_EVENT, - payload: action.payload, - }) - } else { - throw new Error('[voiceCallStateWorker] unhandled call_state') - } - } -} diff --git a/packages/realtime-api/src/voice/workers/VoiceCallSendDigitWorker.ts b/packages/realtime-api/src/voice/workers/VoiceCallSendDigitWorker.ts new file mode 100644 index 000000000..7652a1a73 --- /dev/null +++ b/packages/realtime-api/src/voice/workers/VoiceCallSendDigitWorker.ts @@ -0,0 +1,63 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, + SDKActions, + MapToPubSubShape, + CallingCallSendDigitsEvent, + SDKWorkerHooks, +} from '@signalwire/core' +import type { Call } from '../Call' + +const TARGET_STATES: CallingCallSendDigitsEvent['params']['state'][] = [ + 'finished', +] + +type VoiceCallSendDigitsWorkerOnDone = (args: Call) => void +type VoiceCallSendDigitsWorkerOnFail = (args: { error: Error }) => void + +export type VoiceCallSendDigitsWorkerHooks = SDKWorkerHooks< + VoiceCallSendDigitsWorkerOnDone, + VoiceCallSendDigitsWorkerOnFail +> + +export const voiceCallSendDigitsWorker: SDKWorker< + Call, + VoiceCallSendDigitsWorkerHooks +> = function* (options): SagaIterator { + getLogger().trace('voiceCallSendDigitsWorker started') + const { channels, onDone, onFail, initialState = {}, instance } = options + const { swEventChannel } = channels + const { controlId } = initialState + + if (!controlId) { + throw new Error('Missing controlId for sendDigits') + } + + const action: MapToPubSubShape = + yield sagaEffects.take(swEventChannel, (action: SDKActions) => { + if ( + action.type === 'calling.call.send_digits' && + TARGET_STATES.includes(action.payload.state) + ) { + return action.payload.control_id === controlId + } + return false + }) + + if (action.payload.state === 'finished') { + onDone?.(instance) + } else { + const error = new Error( + `[voiceCallSendDigitsWorker] unhandled state: '${action.payload.state}'` + ) + if (typeof onFail === 'function') { + onFail({ error }) + } else { + throw error + } + } + + getLogger().trace('voiceCallSendDigitsWorker ended') +} diff --git a/packages/realtime-api/src/voice/workers/index.ts b/packages/realtime-api/src/voice/workers/index.ts new file mode 100644 index 000000000..e29fd05a4 --- /dev/null +++ b/packages/realtime-api/src/voice/workers/index.ts @@ -0,0 +1,10 @@ +export * from './voiceCallStateWorker' +export * from './voiceCallReceiveWorker' +export * from './voiceCallPlayWorker' +export * from './voiceCallRecordWorker' +export * from './voiceCallPromptWorker' +export * from './voiceCallTapWorker' +export * from './voiceCallConnectWorker' +export * from './voiceCallDialWorker' +export * from './VoiceCallSendDigitWorker' +export * from './voiceCallDetectWorker' diff --git a/packages/realtime-api/src/voice/workers/voiceCallConnectWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallConnectWorker.ts new file mode 100644 index 000000000..740b22b4e --- /dev/null +++ b/packages/realtime-api/src/voice/workers/voiceCallConnectWorker.ts @@ -0,0 +1,68 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, + SDKActions, + CallingCallConnectEvent, + MapToPubSubShape, +} from '@signalwire/core' +import type { Call } from '../Call' + +export const voiceCallConnectWorker: SDKWorker = function* ( + options +): SagaIterator { + getLogger().trace('voiceCallConnectWorker started') + const { channels, instance } = options + const { swEventChannel, pubSubChannel } = channels + + let run = true + const done = () => (run = false) + + while (run) { + const action: MapToPubSubShape = + yield sagaEffects.take(swEventChannel, (action: SDKActions) => { + return ( + action.type === 'calling.call.connect' && + action.payload.tag === instance.tag + ) + }) + + /** + * Dispatch public events for each connect_state + */ + yield sagaEffects.put(pubSubChannel, { + type: `calling.connect.${action.payload.connect_state}`, + payload: action.payload, + }) + + switch (action.payload.connect_state) { + case 'connected': { + /** + * Update the Call object payload with the new state + */ + yield sagaEffects.put(pubSubChannel, { + type: 'calling.call.state', + payload: { + call_id: instance.callId, + call_state: instance.state, + context: instance.context, + tag: instance.tag, + direction: instance.direction, + device: instance.device, + node_id: instance.nodeId, + peer: action.payload.peer, + }, + }) + break + } + case 'disconnected': + case 'failed': { + done() + break + } + } + } + + getLogger().trace('voiceCallConnectWorker ended') +} diff --git a/packages/realtime-api/src/voice/workers/voiceCallDetectWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallDetectWorker.ts new file mode 100644 index 000000000..8f9a398cb --- /dev/null +++ b/packages/realtime-api/src/voice/workers/voiceCallDetectWorker.ts @@ -0,0 +1,118 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, + SDKActions, + CallingCallDetectEvent, + MapToPubSubShape, +} from '@signalwire/core' +import { callingDetectTriggerEvent } from '../Call' +import type { Call } from '../Call' + +export const voiceCallDetectWorker: SDKWorker = function* ( + options +): SagaIterator { + getLogger().trace('voiceCallDetectWorker started') + const { channels, instance, initialState } = options + const { swEventChannel, pubSubChannel } = channels + const { controlId, waitForBeep = false } = initialState + if (!controlId) { + throw new Error('Missing controlId for tapping') + } + + let waitingForReady = false + let run = true + let lastAction!: MapToPubSubShape + const done = () => (run = false) + + while (run) { + const action: MapToPubSubShape = + yield sagaEffects.take(swEventChannel, (action: SDKActions) => { + return ( + action.type === 'calling.call.detect' && + action.payload.control_id === controlId + ) + }) + + const { detect } = action.payload + if (!detect) { + // Ignore events without detect and (also) make TS happy + continue + } + lastAction = action + + /** Add `tag` to the payload to allow pubSubSaga to match it with the Call namespace */ + const payloadWithTag = { + tag: instance.tag, + ...action.payload, + } + + /** + * Update the original CallDetect object using the transform pipeline + */ + yield sagaEffects.put(pubSubChannel, { + // @ts-ignore + type: callingDetectTriggerEvent, + // @ts-ignore + payload: payloadWithTag, + }) + + const { + type, + params: { event }, + } = detect + + if (event === 'error' || event === 'finished') { + yield sagaEffects.put(pubSubChannel, { + type: 'calling.detect.ended', + payload: payloadWithTag, + }) + + done() + continue + } + + yield sagaEffects.put(pubSubChannel, { + type: 'calling.detect.updated', + payload: payloadWithTag, + }) + + switch (type) { + // case 'digit': + // case 'fax': { + // break + // } + case 'machine': { + if (waitingForReady && event === 'READY') { + yield sagaEffects.put(pubSubChannel, { + type: 'calling.detect.ended', + payload: payloadWithTag, + }) + + done() + } + if (waitForBeep) { + waitingForReady = true + } + break + } + } + } + + if (lastAction) { + /** + * On endef, dispatch an event to resolve `waitForResult` in CallDetect + * overriding the `tag` to be the controlId + */ + yield sagaEffects.put(pubSubChannel, { + type: 'calling.detect.ended', + payload: { + ...lastAction.payload, + tag: controlId, + }, + }) + } + + getLogger().trace('voiceCallDetectWorker ended') +} diff --git a/packages/realtime-api/src/voice/workers/voiceCallDialWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallDialWorker.ts new file mode 100644 index 000000000..7d5c5c813 --- /dev/null +++ b/packages/realtime-api/src/voice/workers/voiceCallDialWorker.ts @@ -0,0 +1,57 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, + SDKActions, + MapToPubSubShape, + CallingCallDialEvent, + SDKWorkerHooks, + ToExternalJSONResult, + CallingCallDialFailedEventParams, + toExternalJSON, +} from '@signalwire/core' +import type { Call } from '../Call' + +const TARGET_DIAL_STATES: CallingCallDialEvent['params']['dial_state'][] = [ + 'answered', + 'failed', +] + +type VoiceCallDialWorkerOnDone = (args: Call) => void +type VoiceCallDialWorkerOnFail = ( + args: ToExternalJSONResult +) => void + +export type VoiceCallDialWorkerHooks = SDKWorkerHooks< + VoiceCallDialWorkerOnDone, + VoiceCallDialWorkerOnFail +> + +export const voiceCallDialWorker: SDKWorker = + function* (options): SagaIterator { + const { channels, instance, onDone, onFail } = options + const { swEventChannel } = channels + getLogger().trace('voiceCallDialWorker started') + + const action: MapToPubSubShape = + yield sagaEffects.take(swEventChannel, (action: SDKActions) => { + if ( + action.type === 'calling.call.dial' && + TARGET_DIAL_STATES.includes(action.payload.dial_state) + ) { + return instance.tag === action.payload.tag + } + return false + }) + + if (action.payload.dial_state === 'answered') { + onDone?.(instance) + } else if (action.payload.dial_state === 'failed') { + onFail?.(toExternalJSON(action.payload)) + } else { + throw new Error('[voiceCallDialWorker] unhandled call_state') + } + + getLogger().trace('voiceCallDialWorker ended') + } diff --git a/packages/realtime-api/src/voice/workers/voiceCallPlayWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallPlayWorker.ts new file mode 100644 index 000000000..0252b726d --- /dev/null +++ b/packages/realtime-api/src/voice/workers/voiceCallPlayWorker.ts @@ -0,0 +1,93 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, + SDKActions, + CallingCallPlayEvent, + MapToPubSubShape, +} from '@signalwire/core' +import type { Call } from '../Call' + +export const voiceCallPlayWorker: SDKWorker = function* ( + options +): SagaIterator { + getLogger().trace('voiceCallPlayWorker started') + const { channels, instance, initialState } = options + const { swEventChannel, pubSubChannel } = channels + const { controlId } = initialState + if (!controlId) { + throw new Error('Missing controlId for playback') + } + + let paused = false + let run = true + const done = () => (run = false) + + while (run) { + const action: MapToPubSubShape = + yield sagaEffects.take(swEventChannel, (action: SDKActions) => { + return ( + action.type === 'calling.call.play' && + action.payload.control_id === controlId + ) + }) + + /** Add `tag` to the payload to allow pubSubSaga to match it with the Call namespace */ + const payloadWithTag = { + tag: instance.tag, + ...action.payload, + } + + switch (action.payload.state) { + case 'playing': { + const type = paused + ? 'calling.playback.updated' + : 'calling.playback.started' + paused = false + + yield sagaEffects.put(pubSubChannel, { + type, + payload: payloadWithTag, + }) + break + } + case 'paused': { + paused = true + + yield sagaEffects.put(pubSubChannel, { + type: 'calling.playback.updated', + payload: payloadWithTag, + }) + break + } + case 'error': + // TODO: dispatch calling.playback.error ? + break + case 'finished': { + yield sagaEffects.put(pubSubChannel, { + type: 'calling.playback.ended', + payload: payloadWithTag, + }) + + /** + * Dispatch an event to resolve `waitForEnded` in CallPlayback + * when ended + */ + yield sagaEffects.put(pubSubChannel, { + type: 'calling.playback.ended', + // @ts-ignore + payload: { + tag: controlId, + ...action.payload, + }, + }) + + done() + break + } + } + } + + getLogger().trace('voiceCallPlayWorker ended') +} diff --git a/packages/realtime-api/src/voice/workers/voiceCallPromptWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallPromptWorker.ts new file mode 100644 index 000000000..1ce9f56c3 --- /dev/null +++ b/packages/realtime-api/src/voice/workers/voiceCallPromptWorker.ts @@ -0,0 +1,104 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, + SDKActions, + CallingCallCollectEvent, + MapToPubSubShape, +} from '@signalwire/core' +import { callingPromptTriggerEvent } from '../Call' +import type { Call } from '../Call' + +export const voiceCallPromptWorker: SDKWorker = function* ( + options +): SagaIterator { + getLogger().trace('voiceCallPromptWorker started') + const { channels, instance, initialState } = options + const { swEventChannel, pubSubChannel } = channels + const { controlId } = initialState + if (!controlId) { + throw new Error('Missing controlId for prompt') + } + + let run = true + const done = () => (run = false) + + while (run) { + const action: MapToPubSubShape = + yield sagaEffects.take(swEventChannel, (action: SDKActions) => { + return ( + action.type === 'calling.call.collect' && + action.payload.control_id === controlId + ) + }) + + /** Add `tag` to the payload to allow pubSubSaga to match it with the Call namespace */ + const payloadWithTag = { + tag: instance.tag, + ...action.payload, + } + + /** + * Update the original CallPrompt object using the transform pipeline + */ + yield sagaEffects.put(pubSubChannel, { + // @ts-expect-error + type: callingPromptTriggerEvent, + // @ts-ignore + payload: payloadWithTag, + }) + + if (action.payload.result) { + let typeToEmit: 'calling.prompt.failed' | 'calling.prompt.ended' + switch (action.payload.result.type) { + case 'no_match': + case 'no_input': + case 'error': { + typeToEmit = 'calling.prompt.failed' + break + } + case 'speech': + case 'digit': { + typeToEmit = 'calling.prompt.ended' + break + } + // case 'start_of_speech': { TODO: + // break + // } + } + + yield sagaEffects.put(pubSubChannel, { + type: typeToEmit, + payload: payloadWithTag, + }) + + /** + * Dispatch an event to resolve `waitForResult` in CallPrompt + * when ended + */ + yield sagaEffects.put(pubSubChannel, { + type: typeToEmit, + // @ts-ignore + payload: { + tag: controlId, + ...action.payload, + }, + }) + + done() + } + + /** + * Only when partial_results: true + */ + if (action.payload.final === false) { + yield sagaEffects.put(pubSubChannel, { + type: 'calling.prompt.updated', + payload: payloadWithTag, + }) + } + } + + getLogger().trace('voiceCallPromptWorker ended') +} diff --git a/packages/realtime-api/src/voice/workers/voiceCallReceiveWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallReceiveWorker.ts new file mode 100644 index 000000000..03e9af83f --- /dev/null +++ b/packages/realtime-api/src/voice/workers/voiceCallReceiveWorker.ts @@ -0,0 +1,36 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, +} from '@signalwire/core' +import type { Client } from '../../client/index' + +export const voiceCallReceiveWorker: SDKWorker = function* ( + options +): SagaIterator { + getLogger().trace('voiceCallReceiveWorker started') + const { channels, instance } = options + const { swEventChannel, pubSubChannel } = channels + // contexts is required + const { contexts = [] } = instance?.options ?? {} + if (!contexts.length) { + throw new Error('Invalid contexts to receive inbound calls') + } + + while (true) { + const action = yield sagaEffects.take(swEventChannel, (action: any) => { + return ( + action.type === 'calling.call.receive' && + contexts.includes(action.payload.context) + ) + }) + + yield sagaEffects.put(pubSubChannel, { + type: 'calling.call.received', + payload: action.payload, + }) + } + + getLogger().trace('voiceCallReceiveWorker ended') +} diff --git a/packages/realtime-api/src/voice/workers/voiceCallRecordWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallRecordWorker.ts new file mode 100644 index 000000000..e61fee091 --- /dev/null +++ b/packages/realtime-api/src/voice/workers/voiceCallRecordWorker.ts @@ -0,0 +1,82 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, + SDKActions, + CallingCallRecordEvent, + MapToPubSubShape, +} from '@signalwire/core' +import { callingRecordTriggerEvent } from '../Call' +import type { Call } from '../Call' + +export const voiceCallRecordWorker: SDKWorker = function* ( + options +): SagaIterator { + getLogger().trace('voiceCallRecordWorker started') + const { channels, instance, initialState } = options + const { swEventChannel, pubSubChannel } = channels + const { controlId } = initialState + if (!controlId) { + throw new Error('Missing controlId for recording') + } + + let run = true + const done = () => (run = false) + + while (run) { + const action: MapToPubSubShape = + yield sagaEffects.take(swEventChannel, (action: SDKActions) => { + return ( + action.type === 'calling.call.record' && + action.payload.control_id === controlId + ) + }) + + /** Add `tag` to the payload to allow pubSubSaga to match it with the Call namespace */ + const payloadWithTag = { + tag: instance.tag, + ...action.payload, + } + + /** + * Update the original CallRecording object using the + * transform pipeline + */ + yield sagaEffects.put(pubSubChannel, { + // @ts-ignore + type: callingRecordTriggerEvent, + // @ts-ignore + payload: payloadWithTag, + }) + + switch (action.payload.state) { + case 'recording': { + yield sagaEffects.put(pubSubChannel, { + type: 'calling.recording.started', + payload: payloadWithTag, + }) + break + } + case 'no_input': + yield sagaEffects.put(pubSubChannel, { + type: 'calling.recording.failed', + payload: payloadWithTag, + }) + + done() + break + case 'finished': { + yield sagaEffects.put(pubSubChannel, { + type: 'calling.recording.ended', + payload: payloadWithTag, + }) + + done() + break + } + } + } + + getLogger().trace('voiceCallRecordWorker ended') +} diff --git a/packages/realtime-api/src/voice/workers/voiceCallStateWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallStateWorker.ts new file mode 100644 index 000000000..3a0accc8e --- /dev/null +++ b/packages/realtime-api/src/voice/workers/voiceCallStateWorker.ts @@ -0,0 +1,69 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, + SDKActions, + CallingCallStateEvent, + MapToPubSubShape, +} from '@signalwire/core' +import type { Call } from '../Call' + +export const voiceCallStateWorker: SDKWorker = function* ( + options +): SagaIterator { + const { channels, instance } = options + const { swEventChannel, pubSubChannel } = channels + getLogger().trace('voiceCallStateWorker started', instance.id, instance.tag) + + let run = true + const done = () => (run = false) + + while (run) { + const action: MapToPubSubShape = + yield sagaEffects.take(swEventChannel, (action: SDKActions) => { + if (action.type === 'calling.call.state') { + // To avoid mixing events on `connect` we check + // for `instance.id` if there's already a callId + // value. + if (instance.id) { + return instance.id === action.payload.call_id + } + return instance.tag === action.payload.tag + } + return false + }) + + /** + * Override (or inject) "tag" with `instance.tag` + * because we use it as namespace in the EE and: + * - all the inbound legs have no "tag" in the + * `calling.call.state` events + * - all the legs created by a "connect" RPC will share + * the same "tag" of the originator leg to allow the + * SDK to make a relation + * + * Since in the SDK each Call has its own "tag" + * (__uuid), we need to target them through the EE with + * the right "tag". + */ + const newPayload = { + ...action.payload, + tag: instance.tag, + } + + /** + * Update the Call object payload with the new state + */ + yield sagaEffects.put(pubSubChannel, { + type: 'calling.call.state', + payload: newPayload, + }) + + if (newPayload.call_state === 'ended') { + done() + } + } + + getLogger().trace('voiceCallStateWorker ended', instance.id, instance.tag) +} diff --git a/packages/realtime-api/src/voice/workers/voiceCallTapWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallTapWorker.ts new file mode 100644 index 000000000..17a21cc0b --- /dev/null +++ b/packages/realtime-api/src/voice/workers/voiceCallTapWorker.ts @@ -0,0 +1,73 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, + SDKActions, + CallingCallTapEvent, + MapToPubSubShape, +} from '@signalwire/core' +import { callingTapTriggerEvent } from '../Call' +import type { Call } from '../Call' + +export const voiceCallTapWorker: SDKWorker = function* ( + options +): SagaIterator { + getLogger().trace('voiceCallTapWorker started') + const { channels, instance, initialState } = options + const { swEventChannel, pubSubChannel } = channels + const { controlId } = initialState + if (!controlId) { + throw new Error('Missing controlId for tapping') + } + + let run = true + const done = () => (run = false) + + while (run) { + const action: MapToPubSubShape = + yield sagaEffects.take(swEventChannel, (action: SDKActions) => { + return ( + action.type === 'calling.call.tap' && + action.payload.control_id === controlId + ) + }) + + /** Add `tag` to the payload to allow pubSubSaga to match it with the Call namespace */ + const payloadWithTag = { + tag: instance.tag, + ...action.payload, + } + + /** + * Update the original CallTap object using the transform pipeline + */ + yield sagaEffects.put(pubSubChannel, { + // @ts-ignore + type: callingTapTriggerEvent, + // @ts-ignore + payload: payloadWithTag, + }) + + switch (action.payload.state) { + case 'tapping': { + yield sagaEffects.put(pubSubChannel, { + type: 'calling.tap.started', + payload: payloadWithTag, + }) + break + } + case 'finished': { + yield sagaEffects.put(pubSubChannel, { + type: 'calling.tap.ended', + payload: payloadWithTag, + }) + + done() + break + } + } + } + + getLogger().trace('voiceCallTapWorker ended') +}