From bc250fc078e73140afda45b0316aaa823b665e56 Mon Sep 17 00:00:00 2001 From: Ammar Ansari Date: Wed, 21 Jun 2023 19:52:43 +0200 Subject: [PATCH 01/15] Task namespace with new interface (#807) * Task namespace with new interface * taskworker include * extend task from applyeventlisteners * base namespace class to handle the listen method * topic attach to event name * type update * remove older Task api * stack test update for Task * changeset include * refactor and e2e test case * rename task emitter * listen function public explicitly * index worker file * utility function to prefix the event * correct type of taskworker --- .changeset/violet-boats-count.md | 6 + internal/e2e-realtime-api/src/task.test.ts | 126 +++++++++----- .../playground-realtime-api/src/task/index.ts | 63 ++++--- internal/stack-tests/src/task/app.ts | 15 +- packages/core/src/BaseComponent.ts | 2 +- packages/realtime-api/src/BaseNamespace.ts | 157 ++++++++++++++++++ packages/realtime-api/src/SWClient.ts | 41 +++++ packages/realtime-api/src/SignalWire.ts | 14 ++ .../src/{ => client}/createClient.test.ts | 6 +- .../realtime-api/src/client/createClient.ts | 17 ++ packages/realtime-api/src/createClient.ts | 61 ------- packages/realtime-api/src/index.ts | 40 +---- packages/realtime-api/src/task/Task.ts | 108 +++++++----- packages/realtime-api/src/task/TaskClient.ts | 66 -------- packages/realtime-api/src/task/send.ts | 87 ---------- packages/realtime-api/src/task/workers.ts | 27 --- .../realtime-api/src/task/workers/index.ts | 1 + .../src/task/workers/taskWorker.ts | 38 +++++ packages/realtime-api/src/utils/internals.ts | 5 + 19 files changed, 484 insertions(+), 396 deletions(-) create mode 100644 .changeset/violet-boats-count.md create mode 100644 packages/realtime-api/src/BaseNamespace.ts create mode 100644 packages/realtime-api/src/SWClient.ts create mode 100644 packages/realtime-api/src/SignalWire.ts rename packages/realtime-api/src/{ => client}/createClient.test.ts (95%) create mode 100644 packages/realtime-api/src/client/createClient.ts delete mode 100644 packages/realtime-api/src/createClient.ts delete mode 100644 packages/realtime-api/src/task/TaskClient.ts delete mode 100644 packages/realtime-api/src/task/send.ts delete mode 100644 packages/realtime-api/src/task/workers.ts create mode 100644 packages/realtime-api/src/task/workers/index.ts create mode 100644 packages/realtime-api/src/task/workers/taskWorker.ts diff --git a/.changeset/violet-boats-count.md b/.changeset/violet-boats-count.md new file mode 100644 index 000000000..b2b8c8947 --- /dev/null +++ b/.changeset/violet-boats-count.md @@ -0,0 +1,6 @@ +--- +'@signalwire/realtime-api': major +'@signalwire/core': major +--- + +Task namespace with new interface diff --git a/internal/e2e-realtime-api/src/task.test.ts b/internal/e2e-realtime-api/src/task.test.ts index f41e64d24..e023d3447 100644 --- a/internal/e2e-realtime-api/src/task.test.ts +++ b/internal/e2e-realtime-api/src/task.test.ts @@ -1,58 +1,96 @@ import { randomUUID } from 'node:crypto' -import { Task } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' import { createTestRunner } from './utils' const handler = () => { return new Promise(async (resolve, reject) => { - const context = randomUUID() - const firstPayload = { - id: Date.now(), - item: 'first', - } - const lastPayload = { - id: Date.now(), - item: 'last', - } + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + }) - const client = new Task.Client({ - host: process.env.RELAY_HOST as string, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - contexts: [context], - }) + const homeTopic = `home-${randomUUID()}` + const officeTopic = `office-${randomUUID()}` - let counter = 0 - - client.on('task.received', (payload) => { - if (payload.id === firstPayload.id && payload.item === 'first') { - counter++ - } else if (payload.id === lastPayload.id && payload.item === 'last') { - counter++ - } else { - console.error('Invalid payload on `task.received`', payload) - return reject(4) + const firstPayload = { + id: Date.now(), + topic: homeTopic, } - - if (counter === 2) { - return resolve(0) + const secondPayload = { + id: Date.now(), + topic: homeTopic, } - }) + const thirdPayload = { + id: Date.now(), + topic: officeTopic, + } + + let counter = 0 + const unsubHomeOffice = await client.task.listen({ + topics: [homeTopic, officeTopic], + onTaskReceived: (payload) => { + if ( + payload.topic !== homeTopic || + payload.id !== firstPayload.id || + payload.id !== secondPayload.id || + counter > 3 + ) { + console.error('Invalid payload on `home` context', payload) + return reject(4) + } + counter++ + }, + }) + + const unsubOffice = await client.task.listen({ + topics: [officeTopic], + onTaskReceived: (payload) => { + if ( + payload.topic !== officeTopic || + payload.id !== thirdPayload.id || + counter > 3 + ) { + console.error('Invalid payload on `home` context', payload) + return reject(4) + } + counter++ - await Task.send({ - host: process.env.RELAY_HOST as string, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - context, - message: firstPayload, - }) + if (counter === 3) { + return resolve(0) + } + }, + }) - await Task.send({ - host: process.env.RELAY_HOST as string, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - context, - message: lastPayload, - }) + await client.task.send({ + topic: homeTopic, + message: firstPayload, + }) + + await client.task.send({ + topic: homeTopic, + message: secondPayload, + }) + + await unsubHomeOffice() + + // This message should not reach the listener + await client.task.send({ + topic: homeTopic, + message: secondPayload, + }) + + await client.task.send({ + topic: officeTopic, + message: thirdPayload, + }) + + await unsubOffice() + } catch (error) { + console.log('Task test error', error) + reject(error) + } }) } diff --git a/internal/playground-realtime-api/src/task/index.ts b/internal/playground-realtime-api/src/task/index.ts index 62a014fef..6189fc4c4 100644 --- a/internal/playground-realtime-api/src/task/index.ts +++ b/internal/playground-realtime-api/src/task/index.ts @@ -1,31 +1,52 @@ -import { Task } from '@signalwire/realtime-api' - -const client = new Task.Client({ - host: process.env.HOST || 'relay.swire.io', - project: process.env.PROJECT as string, - token: process.env.TOKEN as string, - contexts: ['office'], - debug: { - logWsTraffic: true, - }, -}) - -client.on('task.received', (payload) => { - console.log('Task Received', payload) -}) - -setTimeout(async () => { - console.log('Sending to the client..') - await Task.send({ +import { SignalWire } from '@signalwire/realtime-api' +;(async () => { + const client = await SignalWire({ host: process.env.HOST || 'relay.swire.io', project: process.env.PROJECT as string, token: process.env.TOKEN as string, - context: 'office', + }) + + const removeOfficeListeners = await client.task.listen({ + topics: ['office', 'home'], + onTaskReceived: (payload) => { + console.log('Task received under the "office" or "home" context', payload) + }, + }) + + const removeWorkplaceListeners = await client.task.listen({ + topics: ['workplace', 'home'], + onTaskReceived: (payload) => { + console.log( + 'Task received under the "workplace" or "home" context', + payload + ) + }, + }) + + console.log('Sending a message to office..') + await client.task.send({ + topic: 'office', message: { yo: ['bro', 1, true] }, }) + console.log('Sending a message to home..') + await client.task.send({ + topic: 'home', + message: { yo: ['bro', 2, true] }, + }) + + await removeOfficeListeners() + + console.log('Sending a message to workplace..') + await client.task.send({ + topic: 'workplace', + message: { yo: ['bro', 3, true] }, + }) + + await removeWorkplaceListeners() + setTimeout(async () => { console.log('Disconnect the client..') client.disconnect() }, 2000) -}, 2000) +})() diff --git a/internal/stack-tests/src/task/app.ts b/internal/stack-tests/src/task/app.ts index db35d0547..da35604a3 100644 --- a/internal/stack-tests/src/task/app.ts +++ b/internal/stack-tests/src/task/app.ts @@ -1,22 +1,17 @@ -import { Task } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' import tap from 'tap' async function run() { try { - const task = new Task.Client({ + const client = await SignalWire({ host: process.env.RELAY_HOST || 'relay.swire.io', project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, - contexts: [process.env.RELAY_CONTEXT as string], }) - tap.ok(task.on, 'task.on is defined') - tap.ok(task.once, 'task.once is defined') - tap.ok(task.off, 'task.off is defined') - tap.ok(task.removeAllListeners, 'task.removeAllListeners is defined') - tap.ok(task.addContexts, 'task.addContexts is defined') - tap.ok(task.disconnect, 'task.disconnect is defined') - tap.ok(task.removeContexts, 'task.removeContexts is defined') + tap.ok(client.task, 'client.task is defined') + tap.ok(client.task.listen, 'client.task.listen is defined') + tap.ok(client.task.send, 'client.task.send is defined') process.exit(0) } catch (error) { diff --git a/packages/core/src/BaseComponent.ts b/packages/core/src/BaseComponent.ts index 919187267..762984511 100644 --- a/packages/core/src/BaseComponent.ts +++ b/packages/core/src/BaseComponent.ts @@ -301,7 +301,7 @@ export class BaseComponent< } /** @internal */ - protected runWorker( + public runWorker( name: string, def: SDKWorkerDefinition ) { diff --git a/packages/realtime-api/src/BaseNamespace.ts b/packages/realtime-api/src/BaseNamespace.ts new file mode 100644 index 000000000..51c912046 --- /dev/null +++ b/packages/realtime-api/src/BaseNamespace.ts @@ -0,0 +1,157 @@ +import { EventEmitter, ExecuteParams, uuid } from '@signalwire/core' +import type { Client } from './client/Client' +import { SWClient } from './SWClient' +import { prefixEvent } from './utils/internals' + +export interface ListenOptions { + topics: string[] +} + +type ListenersKeys = keyof Omit + +type ListenerMap = Map< + string, + { + topics: Set + listeners: Omit + unsub: () => Promise + } +> + +export class BaseNamespace { + protected _client: Client + protected _sw: SWClient + protected _eventMap: Record + private _namespaceEmitter = new EventEmitter() + private _listenerMap: ListenerMap = new Map() + + constructor(options: { swClient: SWClient }) { + this._sw = options.swClient + this._client = options.swClient.client + } + + get emitter() { + return this._namespaceEmitter + } + + private addTopics(topics: string[]) { + const executeParams: ExecuteParams = { + method: 'signalwire.receive', + params: { + contexts: topics, + }, + } + return this._client.execute(executeParams) + } + + private removeTopics(topics: string[]) { + const executeParams: ExecuteParams = { + method: 'signalwire.unreceive', + params: { + contexts: topics, + }, + } + return this._client.execute(executeParams) + } + + public listen(listenOptions: T) { + return new Promise<() => Promise>(async (resolve, reject) => { + try { + const { topics } = listenOptions + if (topics?.length < 1) { + throw new Error( + 'Invalid options: topics should be an array with at least one topic!' + ) + } + const unsub = await this.subscribe(listenOptions) + resolve(unsub) + } catch (error) { + reject(error) + } + }) + } + + protected async subscribe(listenOptions: T) { + const { topics, ...listeners } = listenOptions + const _uuid = uuid() + + // Attach listeners + this._attachListeners(topics, listeners) + await this.addTopics(topics) + + const unsub = () => { + return new Promise(async (resolve, reject) => { + try { + // Remove the topics + const topicsToRemove = topics.filter( + (topic) => !this.hasOtherListeners(_uuid, topic) + ) + if (topicsToRemove.length > 0) { + await this.removeTopics(topicsToRemove) + } + + // Remove listeners + this._detachListeners(topics, listeners) + + // Remove task from the task listener array + this.removeFromListenerMap(_uuid) + + resolve() + } catch (error) { + reject(error) + } + }) + } + + this._listenerMap.set(_uuid, { + topics: new Set([...topics]), + listeners, + unsub, + }) + + return unsub + } + + private _attachListeners(topics: string[], listeners: Omit) { + const listenerKeys = Object.keys(listeners) as Array + topics.forEach((topic) => { + listenerKeys.forEach((key) => { + if (typeof listeners[key] === 'function' && this._eventMap[key]) { + const event = prefixEvent(topic, this._eventMap[key]) + this.emitter.on(event, listeners[key]) + } + }) + }) + } + + private _detachListeners(topics: string[], listeners: Omit) { + const listenerKeys = Object.keys(listeners) as Array + topics.forEach((topic) => { + listenerKeys.forEach((key) => { + if (typeof listeners[key] === 'function' && this._eventMap[key]) { + const event = prefixEvent(topic, this._eventMap[key]) + this.emitter.off(event, listeners[key]) + } + }) + }) + } + + private hasOtherListeners(uuid: string, topic: string) { + for (const [key, listener] of this._listenerMap) { + if (key === uuid) continue + if (listener.topics.has(topic)) return true + } + return false + } + + protected async unsubscribeAll() { + await Promise.all( + [...this._listenerMap.values()].map(({ unsub }) => unsub()) + ) + this._listenerMap.clear() + } + + private removeFromListenerMap(id: string) { + return this._listenerMap.delete(id) + } +} diff --git a/packages/realtime-api/src/SWClient.ts b/packages/realtime-api/src/SWClient.ts new file mode 100644 index 000000000..a6bc27d72 --- /dev/null +++ b/packages/realtime-api/src/SWClient.ts @@ -0,0 +1,41 @@ +import { createClient } from './client/createClient' +import type { Client } from './client/Client' +import { clientConnect } from './client/clientConnect' +import { Task } from './task/Task' + +export interface SWClientOptions { + host?: string + project: string + token: string + logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' + debug?: { + logWsTraffic?: boolean + } +} + +export class SWClient { + private _task: Task + + public userOptions: SWClientOptions + public client: Client + + constructor(options: SWClientOptions) { + this.userOptions = options + this.client = createClient(options) + } + + async connect() { + await clientConnect(this.client) + } + + disconnect() { + this.client.disconnect() + } + + get task() { + if (!this._task) { + this._task = new Task(this) + } + return this._task + } +} diff --git a/packages/realtime-api/src/SignalWire.ts b/packages/realtime-api/src/SignalWire.ts new file mode 100644 index 000000000..245773b6b --- /dev/null +++ b/packages/realtime-api/src/SignalWire.ts @@ -0,0 +1,14 @@ +import { SWClient, SWClientOptions } from './SWClient' + +export const SignalWire = (options: SWClientOptions): Promise => { + return new Promise(async (resolve, reject) => { + const swClient = new SWClient(options) + + try { + await swClient.connect() + resolve(swClient) + } catch (error) { + reject(error) + } + }) +} diff --git a/packages/realtime-api/src/createClient.test.ts b/packages/realtime-api/src/client/createClient.test.ts similarity index 95% rename from packages/realtime-api/src/createClient.test.ts rename to packages/realtime-api/src/client/createClient.test.ts index acd032aa8..d6b9e328a 100644 --- a/packages/realtime-api/src/createClient.test.ts +++ b/packages/realtime-api/src/client/createClient.test.ts @@ -49,7 +49,7 @@ describe('createClient', () => { it('should throw an error when invalid credentials are provided', async () => { expect.assertions(1) - const client = await createClient({ + const client = createClient({ // @ts-expect-error host, token: '', @@ -65,7 +65,7 @@ describe('createClient', () => { it('should resolve `connect()` when the client is authorized', async () => { expect.assertions(1) - const client = await createClient({ + const client = createClient({ // @ts-expect-error host, token, @@ -102,7 +102,7 @@ describe('createClient', () => { socket.on('message', messageHandler) }) - const client = await createClient({ + const client = createClient({ // @ts-expect-error host: h, token, diff --git a/packages/realtime-api/src/client/createClient.ts b/packages/realtime-api/src/client/createClient.ts new file mode 100644 index 000000000..fc46a8460 --- /dev/null +++ b/packages/realtime-api/src/client/createClient.ts @@ -0,0 +1,17 @@ +import { connect, ClientEvents } from '@signalwire/core' +import { setupInternals } from '../utils/internals' +import { Client } from './Client' + +export const createClient = (userOptions: { + project: string + token: string + logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' +}) => { + const { emitter, store } = setupInternals(userOptions) + const client = connect({ + store, + Component: Client, + })({ ...userOptions, store, emitter }) + + return client +} diff --git a/packages/realtime-api/src/createClient.ts b/packages/realtime-api/src/createClient.ts deleted file mode 100644 index 4f6cb5aff..000000000 --- a/packages/realtime-api/src/createClient.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - ClientEvents, - configureStore, - connect, - getEventEmitter, - UserOptions, - InternalUserOptions, -} from '@signalwire/core' -import { Client, RealtimeClient } from './Client' -import { Session } from './Session' - -/** @internal */ -export interface CreateClientOptions extends UserOptions {} -export type { RealtimeClient, ClientEvents } - -/** - * Creates a real-time Client. - * @param userOptions - * @param userOptions.project SignalWire project id, e.g. `a10d8a9f-2166-4e82-56ff-118bc3a4840f` - * @param userOptions.token SignalWire project token, e.g. `PT9e5660c101cd140a1c93a0197640a369cf5f16975a0079c9` - * @param userOptions.logLevel logging level - * @returns an instance of a real-time Client. - * - * @example - * ```typescript - * const client = await createClient({ - * project: '', - * token: '' - * }) - * ``` - * - * @deprecated You no longer need to create the client - * manually. You can use the product constructors, like - * {@link Video.Client}, to access the same functionality. - */ -export const createClient: (userOptions: { - project?: string - token: string - logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' -}) => Promise = - // Note: types are inlined for clarity of documentation - async (userOptions) => { - const baseUserOptions: InternalUserOptions = { - ...userOptions, - emitter: getEventEmitter(), - } - const store = configureStore({ - userOptions: baseUserOptions, - SessionConstructor: Session, - }) - - const client = connect({ - store, - Component: Client, - sessionListeners: { - authStatus: 'onAuth', - }, - })(baseUserOptions) - - return client - } diff --git a/packages/realtime-api/src/index.ts b/packages/realtime-api/src/index.ts index 1657810a6..0048a5fe9 100644 --- a/packages/realtime-api/src/index.ts +++ b/packages/realtime-api/src/index.ts @@ -50,8 +50,6 @@ */ export * as Video from './video/Video' -export * from './createClient' - /** * Access the Chat API Consumer. You can instantiate a {@link Chat.Client} to * subscribe to Chat events. Please check {@link Chat.ChatClientApiEvents} @@ -99,42 +97,6 @@ export * as PubSub from './pubSub/PubSub' /** @ignore */ export * from './configure' -/** - * Access the Task API. You can instantiate a {@link Task.Client} to - * receive tasks from a different application. Please check - * {@link Task.TaskClientApiEvents} for the full list of events that - * a {@link Task.Client} can subscribe to. - * - * @example - * - * The following example listens for incoming tasks. - * - * ```javascript - * const client = new Task.Client({ - * project: "", - * token: "", - * contexts: ['office'] - * }) - * - * client.on('task.received', (payload) => { - * console.log('Task Received', payload) - * // Do something with the payload... - * }) - * ``` - * - * From a different process, even on a different machine, you can then send tasks: - * - * ```js - * await Task.send({ - * project: "", - * token: "", - * context: 'office', - * message: { hello: ['world', true] }, - * }) - * ``` - */ -export * as Task from './task/Task' - /** * Access the Messaging API. You can instantiate a {@link Messaging.Client} to * send or receive SMS and MMS. Please check @@ -199,3 +161,5 @@ export * as Messaging from './messaging/Messaging' * ``` */ export * as Voice from './voice/Voice' + +export { SignalWire } from './SignalWire' diff --git a/packages/realtime-api/src/task/Task.ts b/packages/realtime-api/src/task/Task.ts index 3970c3b2c..cd734d2cb 100644 --- a/packages/realtime-api/src/task/Task.ts +++ b/packages/realtime-api/src/task/Task.ts @@ -1,55 +1,87 @@ +import { request } from 'node:https' import { - DisconnectableClientContract, - BaseComponentOptions, - BaseComponent, - ClientContextContract, + EventEmitter, + TaskInboundEvent, + TaskReceivedEventName, } from '@signalwire/core' -import { connect } from '@signalwire/core' -import type { TaskClientApiEvents } from '../types' -import { RealtimeClient } from '../client/index' +import { SWClient } from '../SWClient' import { taskWorker } from './workers' +import { ListenOptions, BaseNamespace } from '../BaseNamespace' -export interface Task - extends DisconnectableClientContract, - ClientContextContract { - /** @internal */ - _session: RealtimeClient - /** - * Disconnects this client. The client will stop receiving events and you will - * need to create a new instance if you want to use it again. - * - * @example - * - * ```js - * client.disconnect() - * ``` - */ - disconnect(): void +const PATH = '/api/relay/rest/tasks' +const HOST = 'relay.signalwire.com' + +interface TaskListenOptions extends ListenOptions { + onTaskReceived?: (payload: TaskInboundEvent['message']) => unknown } -/** @internal */ -class TaskAPI extends BaseComponent { - constructor(options: BaseComponentOptions) { - super(options) +type TaskListenersKeys = keyof Omit + +export class Task extends BaseNamespace { + private _taskEmitter = new EventEmitter() + protected _eventMap: Record = { + onTaskReceived: 'task.received', + } + + constructor(options: SWClient) { + super({ swClient: options }) - this.runWorker('taskWorker', { + this._client.runWorker('taskWorker', { worker: taskWorker, + initialState: { + taskEmitter: this._taskEmitter, + }, }) } -} -/** @internal */ -export const createTaskObject = (params: BaseComponentOptions): Task => { - const task = connect({ - store: params.store, - Component: TaskAPI, - })(params) + get emitter() { + return this._taskEmitter + } - return task + send({ + topic, + message, + }: { + topic: string + message: Record + }) { + const { userOptions } = this._sw + if (!userOptions.project || !userOptions.token) { + throw new Error('Invalid options: project and token are required!') + } + return new Promise((resolve, reject) => { + try { + const Authorization = `Basic ${Buffer.from( + `${userOptions.project}:${userOptions.token}` + ).toString('base64')}` + + const data = JSON.stringify({ context: topic, message }) + const options = { + host: userOptions.host ?? HOST, + port: 443, + method: 'POST', + path: PATH, + headers: { + Authorization, + 'Content-Type': 'application/json', + 'Content-Length': data.length, + }, + } + const req = request(options, ({ statusCode }) => { + statusCode === 204 ? resolve() : reject() + }) + + req.on('error', reject) + + req.write(data) + req.end() + } catch (error) { + reject(error) + } + }) + } } -export * from './TaskClient' -export * from './send' export type { TaskReceivedEventName } from '@signalwire/core' export type { TaskClientApiEvents, diff --git a/packages/realtime-api/src/task/TaskClient.ts b/packages/realtime-api/src/task/TaskClient.ts deleted file mode 100644 index 293219735..000000000 --- a/packages/realtime-api/src/task/TaskClient.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { UserOptions } from '@signalwire/core' -import { setupClient, clientConnect } from '../client/index' -import type { Task } from './Task' -import { createTaskObject } from './Task' -import { clientContextInterceptorsFactory } from '../common/clientContext' - -interface TaskClient extends Task { - new (opts: TaskClientOptions): this -} - -export interface TaskClientOptions - extends Omit { - contexts: string[] -} - -/** - * Creates a new Task client. - * - * @param options - {@link TaskClientOptions} - * - * @example - * - * ```js - * import { Task } from '@signalwire/realtime-api' - * - * const taskClient = new Task.Client({ - * project: '', - * token: '', - * contexts: [''], - * }) - * ``` - */ -const TaskClient = function (options?: TaskClientOptions) { - const { client, store } = setupClient(options) - - const task = createTaskObject({ - store, - }) - - const disconnect = () => client.disconnect() - - const interceptors = { - ...clientContextInterceptorsFactory(client), - _session: client, - disconnect, - } as const - - return new Proxy>(task, { - get(target: TaskClient, prop: keyof TaskClient, receiver: any) { - if (prop in interceptors) { - // @ts-expect-error - return interceptors[prop] - } - - // Always connect the underlying client if the user call a function on the Proxy - if (typeof target[prop] === 'function') { - clientConnect(client) - } - - 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?: TaskClientOptions): TaskClient } - -export { TaskClient as Client } diff --git a/packages/realtime-api/src/task/send.ts b/packages/realtime-api/src/task/send.ts deleted file mode 100644 index 44e4ecb3b..000000000 --- a/packages/realtime-api/src/task/send.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { request } from 'node:https' - -const PATH = '/api/relay/rest/tasks' -const HOST = 'relay.signalwire.com' - -/** Parameters for {@link send} */ -export interface TaskSendParams { - /** @ignore */ - host?: string - /** SignalWire project id, e.g. `a10d8a9f-2166-4e82-56ff-118bc3a4840f` */ - project: string - /** SignalWire project token, e.g. `PT9e5660c101cd140a1c93a0197640a369cf5f16975a0079c9` */ - token: string - /** Context to send the task to */ - context: string - /** Message to send */ - message: Record -} - -/** - * Send a job to your Task Client in a specific context. - * - * @param params - * @returns - * - * @example - * - * > Send a task with a message to then make an outbound Call. - * - * ```js - * const message = { - * 'action': 'call', - * 'from': '+18881112222' - * 'to': '+18881113333' - * } - * - * await Task.send({ - * project: "", - * token: "", - * context: 'office', - * message: message, - * }) - * ``` - * - */ -export const send = ({ - host = HOST, - project, - token, - context, - message, -}: TaskSendParams) => { - if (!project || !token) { - throw new Error('Invalid options: project and token are required!') - } - - return new Promise((resolve, reject) => { - try { - const Authorization = `Basic ${Buffer.from( - `${project}:${token}` - ).toString('base64')}` - - const data = JSON.stringify({ context, message }) - const options = { - host, - port: 443, - method: 'POST', - path: PATH, - headers: { - Authorization, - 'Content-Type': 'application/json', - 'Content-Length': data.length, - }, - } - const req = request(options, ({ statusCode }) => { - statusCode === 204 ? resolve() : reject() - }) - - req.on('error', reject) - - req.write(data) - req.end() - } catch (error) { - reject(error) - } - }) -} diff --git a/packages/realtime-api/src/task/workers.ts b/packages/realtime-api/src/task/workers.ts deleted file mode 100644 index 77a2e2552..000000000 --- a/packages/realtime-api/src/task/workers.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - getLogger, - sagaEffects, - SagaIterator, - SDKWorker, - SDKActions, -} from '@signalwire/core' -import type { Task } from './Task' - -export const taskWorker: SDKWorker = function* (options): SagaIterator { - getLogger().trace('taskWorker started') - const { channels, instance } = options - const { swEventChannel } = channels - - while (true) { - const action = yield sagaEffects.take( - swEventChannel, - (action: SDKActions) => { - return action.type === 'queuing.relay.tasks' - } - ) - - instance.emit('task.received', action.payload.message) - } - - getLogger().trace('taskWorker ended') -} diff --git a/packages/realtime-api/src/task/workers/index.ts b/packages/realtime-api/src/task/workers/index.ts new file mode 100644 index 000000000..aeb7a96ae --- /dev/null +++ b/packages/realtime-api/src/task/workers/index.ts @@ -0,0 +1 @@ +export * from './taskWorker' diff --git a/packages/realtime-api/src/task/workers/taskWorker.ts b/packages/realtime-api/src/task/workers/taskWorker.ts new file mode 100644 index 000000000..e1fb9d123 --- /dev/null +++ b/packages/realtime-api/src/task/workers/taskWorker.ts @@ -0,0 +1,38 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, + SDKActions, + TaskAction, +} from '@signalwire/core' +import { prefixEvent } from '../../utils/internals' +import { Task } from '../Task' + +export const taskWorker: SDKWorker = function* (options): SagaIterator { + getLogger().trace('taskWorker started') + const { + channels: { swEventChannel }, + initialState: { taskEmitter }, + } = options + + function* worker(action: TaskAction) { + const { context } = action.payload + + taskEmitter.emit( + prefixEvent(context, 'task.received'), + action.payload.message + ) + } + + const isTaskEvent = (action: SDKActions) => + action.type === 'queuing.relay.tasks' + + while (true) { + const action = yield sagaEffects.take(swEventChannel, isTaskEvent) + + yield sagaEffects.fork(worker, action) + } + + getLogger().trace('taskWorker ended') +} diff --git a/packages/realtime-api/src/utils/internals.ts b/packages/realtime-api/src/utils/internals.ts index 17993fa48..7e7123e19 100644 --- a/packages/realtime-api/src/utils/internals.ts +++ b/packages/realtime-api/src/utils/internals.ts @@ -62,3 +62,8 @@ export const getCredentials = (options?: GetCredentialsOptions) => { return { project, token } } + +export const prefixEvent = (prefix: string, event: string) => { + if (typeof prefix !== 'string' || typeof event !== 'string') return event + return `${prefix}.${event}` +} From b1e211cadc5eb4711b20cb9ea43c493c9cd636b3 Mon Sep 17 00:00:00 2001 From: Ammar Ansari Date: Mon, 28 Aug 2023 18:06:31 +0200 Subject: [PATCH 02/15] PubSub and Chat namespace with new interface (#814) * Task namespace with new interface * taskworker include * extend task from applyeventlisteners * base namespace class to handle the listen method * topic attach to event name * type update * remove older Task api * refactor and e2e test case * utility function to prefix the event * PubSub namespace with new interface * new interface for the Chat API * fix stack tests * include e2e test for PubSub API * e2e test case for Chat interface * test disconnected client * unit tests for Base classes * Unit tests for the Task class * fix TS for the Task class unit test * unit tests for PubSub and Chat API classes * include changeset * Update packages/realtime-api/src/chat/workers/chatWorker.ts Co-authored-by: Edoardo Gallo * Update packages/realtime-api/src/chat/workers/chatWorker.ts Co-authored-by: Edoardo Gallo * Update packages/realtime-api/src/pubSub/workers/pubSubWorker.ts Co-authored-by: Edoardo Gallo * fix typo * type in changeset --------- Co-authored-by: Edoardo Gallo --- .changeset/hip-bobcats-hear.md | 76 ++++++ internal/e2e-realtime-api/src/chat.test.ts | 246 ++++++++++------- .../src/disconnectClient.test.ts | 68 ----- internal/e2e-realtime-api/src/pubSub.test.ts | 152 +++++++---- .../playground-realtime-api/src/chat/index.ts | 72 +++-- .../src/pubSub/index.ts | 56 ++-- internal/stack-tests/src/chat/app.ts | 21 +- internal/stack-tests/src/pubSub/app.ts | 16 +- packages/core/src/BaseSession.ts | 6 + packages/core/src/chat/applyCommonMethods.ts | 109 ++++++++ packages/core/src/chat/index.ts | 1 + packages/core/src/types/chat.ts | 2 + .../realtime-api/src/BaseNamespace.test.ts | 249 ++++++++++++++++++ packages/realtime-api/src/BaseNamespace.ts | 29 +- packages/realtime-api/src/SWClient.test.ts | 66 +++++ packages/realtime-api/src/SWClient.ts | 18 ++ packages/realtime-api/src/SignalWire.ts | 5 + .../realtime-api/src/chat/BaseChat.test.ts | 110 ++++++++ packages/realtime-api/src/chat/BaseChat.ts | 139 ++++++++++ packages/realtime-api/src/chat/Chat.test.ts | 39 +++ packages/realtime-api/src/chat/Chat.ts | 49 +++- .../realtime-api/src/chat/ChatClient.test.ts | 115 -------- packages/realtime-api/src/chat/ChatClient.ts | 114 -------- .../src/chat/workers/chatWorker.ts | 65 +++++ .../realtime-api/src/chat/workers/index.ts | 1 + packages/realtime-api/src/index.ts | 46 +--- .../realtime-api/src/pubSub/PubSub.test.ts | 36 +++ packages/realtime-api/src/pubSub/PubSub.ts | 44 +++- .../realtime-api/src/pubSub/PubSubClient.ts | 96 ------- .../realtime-api/src/pubSub/workers/index.ts | 1 + .../src/pubSub/workers/pubSubWorker.ts | 66 +++++ packages/realtime-api/src/task/Task.test.ts | 78 ++++++ packages/realtime-api/src/task/Task.ts | 4 +- 33 files changed, 1506 insertions(+), 689 deletions(-) create mode 100644 .changeset/hip-bobcats-hear.md delete mode 100644 internal/e2e-realtime-api/src/disconnectClient.test.ts create mode 100644 packages/core/src/chat/applyCommonMethods.ts create mode 100644 packages/realtime-api/src/BaseNamespace.test.ts create mode 100644 packages/realtime-api/src/SWClient.test.ts create mode 100644 packages/realtime-api/src/chat/BaseChat.test.ts create mode 100644 packages/realtime-api/src/chat/BaseChat.ts create mode 100644 packages/realtime-api/src/chat/Chat.test.ts delete mode 100644 packages/realtime-api/src/chat/ChatClient.test.ts delete mode 100644 packages/realtime-api/src/chat/ChatClient.ts create mode 100644 packages/realtime-api/src/chat/workers/chatWorker.ts create mode 100644 packages/realtime-api/src/chat/workers/index.ts create mode 100644 packages/realtime-api/src/pubSub/PubSub.test.ts delete mode 100644 packages/realtime-api/src/pubSub/PubSubClient.ts create mode 100644 packages/realtime-api/src/pubSub/workers/index.ts create mode 100644 packages/realtime-api/src/pubSub/workers/pubSubWorker.ts create mode 100644 packages/realtime-api/src/task/Task.test.ts diff --git a/.changeset/hip-bobcats-hear.md b/.changeset/hip-bobcats-hear.md new file mode 100644 index 000000000..8ec756351 --- /dev/null +++ b/.changeset/hip-bobcats-hear.md @@ -0,0 +1,76 @@ +--- +'@signalwire/realtime-api': major +'@signalwire/core': major +--- + +New interface for PubSub and Chat APIs + +The new interface contains a single SW client with Chat and PubSub namespaces +```javascript +import { SignalWire } from '@signalwire/realtime-api' + +(async () => { + const client = await SignalWire({ + host: process.env.HOST, + project: process.env.PROJECT, + token: process.env.TOKEN, + }) + + // Attach pubSub listeners + const unsubHomePubSubListener = await client.pubSub.listen({ + channels: ['home'], + onMessageReceived: (message) => { + console.log('Message received under the "home" channel', message) + }, + }) + + // Publish on home channel + await client.pubSub.publish({ + content: 'Hello There', + channel: 'home', + meta: { + fooId: 'randomValue', + }, + }) + + // Attach chat listeners + const unsubOfficeChatListener = await client.chat.listen({ + channels: ['office'], + onMessageReceived: (message) => { + console.log('Message received on "office" channel', message) + }, + onMemberJoined: (member) => { + console.log('Member joined on "office" channel', member) + }, + onMemberUpdated: (member) => { + console.log('Member updated on "office" channel', member) + }, + onMemberLeft: (member) => { + console.log('Member left on "office" channel', member) + }, + }) + + // Publish a chat message on the office channel + const pubRes = await client.chat.publish({ + content: 'Hello There', + channel: 'office', + }) + + // Get channel messages + const messagesResult = await client.chat.getMessages({ + channel: 'office', + }) + + // Get channel members + const getMembersResult = await client.chat.getMembers({ channel: 'office' }) + + // Unsubscribe pubSub listener + await unsubHomePubSubListener() + + // Unsubscribe chat listener + await unsubOfficeChatListener() + + // Disconnect the client + client.disconnect() +})(); +``` \ No newline at end of file diff --git a/internal/e2e-realtime-api/src/chat.test.ts b/internal/e2e-realtime-api/src/chat.test.ts index 9a592b194..15eb4040c 100644 --- a/internal/e2e-realtime-api/src/chat.test.ts +++ b/internal/e2e-realtime-api/src/chat.test.ts @@ -6,6 +6,11 @@ */ import { timeoutPromise, SWCloseEvent } from '@signalwire/core' import { Chat as RealtimeAPIChat } from '@signalwire/realtime-api' +import { SignalWire as RealtimeSignalWire } from '@signalwire/realtime-api' +import type { + Chat as RealtimeChat, + SWClient as RealtimeSWClient, +} from '@signalwire/realtime-api' import { Chat as JSChat } from '@signalwire/js' import { WebSocket } from 'ws' import { randomUUID } from 'node:crypto' @@ -39,49 +44,52 @@ const params = { }, } -type ChatClient = RealtimeAPIChat.ChatClient | JSChat.Client -const testChatClientSubscribe = ( - firstClient: ChatClient, - secondClient: ChatClient -) => { +type ChatClient = RealtimeChat | JSChat.Client + +interface TestChatOptions { + jsChat: JSChat.Client + rtChat: RealtimeChat + publisher?: 'JS' | 'RT' +} + +const testSubscribe = ({ jsChat, rtChat }: TestChatOptions) => { const promise = new Promise(async (resolve) => { console.log('Running subscribe..') let events = 0 + const resolveIfDone = () => { // wait 4 events (rt and js receive their own events + the other member) if (events === 4) { - firstClient.off('member.joined') - secondClient.off('member.joined') + jsChat.off('member.joined') resolve(0) } } - firstClient.on('member.joined', (member) => { + jsChat.on('member.joined', (member) => { // TODO: Check the member payload console.log('jsChat member.joined') events += 1 resolveIfDone() }) - secondClient.on('member.joined', (member) => { - // TODO: Check the member payload - console.log('rtChat member.joined') - events += 1 - resolveIfDone() - }) - await Promise.all([ - firstClient.subscribe(channel), - secondClient.subscribe(channel), + const [unsubRTClient] = await Promise.all([ + rtChat.listen({ + channels: [channel], + onMemberJoined(member) { + // TODO: Check the member payload + console.log('rtChat member.joined') + events += 1 + resolveIfDone() + }, + }), + jsChat.subscribe(channel), ]) }) return timeoutPromise(promise, promiseTimeout, promiseException) } -const testChatClientPublish = ( - firstClient: ChatClient, - secondClient: ChatClient -) => { +const testPublish = ({ jsChat, rtChat, publisher }: TestChatOptions) => { const promise = new Promise(async (resolve) => { console.log('Running publish..') let events = 0 @@ -92,28 +100,32 @@ const testChatClientPublish = ( } const now = Date.now() - firstClient.once('message', (message) => { + jsChat.once('message', (message) => { console.log('jsChat message') if (message.meta.now === now) { events += 1 resolveIfDone() } }) - secondClient.once('message', (message) => { - console.log('rtChat message') - if (message.meta.now === now) { - events += 1 - resolveIfDone() - } - }) await Promise.all([ - firstClient.subscribe(channel), - secondClient.subscribe(channel), + jsChat.subscribe(channel), + rtChat.listen({ + channels: [channel], + onMessageReceived: (message) => { + console.log('rtChat message') + if (message.meta.now === now) { + events += 1 + resolveIfDone() + } + }, + }), ]) - await firstClient.publish({ - content: 'Hello There', + const publishClient = publisher === 'JS' ? jsChat : rtChat + + await publishClient.publish({ + content: 'Hello there!', channel, meta: { now, @@ -125,53 +137,48 @@ const testChatClientPublish = ( return timeoutPromise(promise, promiseTimeout, promiseException) } -const testChatClientUnsubscribe = ( - firstClient: ChatClient, - secondClient: ChatClient -) => { +const testUnsubscribe = ({ jsChat, rtChat }: TestChatOptions) => { const promise = new Promise(async (resolve) => { console.log('Running unsubscribe..') let events = 0 + const resolveIfDone = () => { - /** - * waits for 3 events: - * - first one generates 2 events on leave - * - second one generates only 1 event - */ - if (events === 3) { - firstClient.off('member.left') - secondClient.off('member.left') + // Both of these events will occur due to the JS chat + // RT chat will not trigger the `onMemberLeft` when we unsubscribe RT client + if (events === 2) { + jsChat.off('member.left') resolve(0) } } - firstClient.on('member.left', (member) => { + jsChat.on('member.left', (member) => { // TODO: Check the member payload console.log('jsChat member.left') events += 1 resolveIfDone() }) - secondClient.on('member.left', (member) => { - // TODO: Check the member payload - console.log('rtChat member.left') - events += 1 - resolveIfDone() - }) - await Promise.all([ - firstClient.subscribe(channel), - secondClient.subscribe(channel), + const [unsubRTClient] = await Promise.all([ + rtChat.listen({ + channels: [channel], + onMemberLeft(member) { + // TODO: Check the member payload + console.log('rtChat member.left') + events += 1 + resolveIfDone() + }, + }), + jsChat.subscribe(channel), ]) - await firstClient.unsubscribe(channel) - - await secondClient.unsubscribe(channel) + await jsChat.unsubscribe(channel) + await unsubRTClient() }) return timeoutPromise(promise, promiseTimeout, promiseException) } -const testChatClientMethods = async (client: ChatClient) => { +const testChatMethod = async (client: ChatClient) => { console.log('Get Messages..') const jsMessagesResult = await client.getMessages({ channel, @@ -184,10 +191,11 @@ const testChatClientMethods = async (client: ChatClient) => { return 0 } -const testChatClientSetAndGetMemberState = ( - firstClient: ChatClient, - secondClient: ChatClient -) => { +const testSetAndGetMemberState = ({ + jsChat, + rtChat, + publisher, +}: TestChatOptions) => { const promise = new Promise(async (resolve, reject) => { console.log('Set member state..') let events = 0 @@ -197,7 +205,7 @@ const testChatClientSetAndGetMemberState = ( } } - firstClient.once('member.updated', (member) => { + jsChat.once('member.updated', (member) => { // TODO: Check the member payload console.log('jsChat member.updated') if (member.state.email === 'e2e@example.com') { @@ -205,16 +213,9 @@ const testChatClientSetAndGetMemberState = ( resolveIfDone() } }) - secondClient.once('member.updated', (member) => { - console.log('rtChat member.updated') - if (member.state.email === 'e2e@example.com') { - events += 1 - resolveIfDone() - } - }) console.log('Get Member State..') - const getStateResult = await firstClient.getMemberState({ + const getStateResult = await jsChat.getMemberState({ channels: [channel], memberId: params.memberId, }) @@ -225,11 +226,22 @@ const testChatClientSetAndGetMemberState = ( } await Promise.all([ - firstClient.subscribe(channel), - secondClient.subscribe(channel), + jsChat.subscribe(channel), + rtChat.listen({ + channels: [channel], + onMemberUpdated(member) { + console.log('rtChat member.updated') + if (member.state.email === 'e2e@example.com') { + events += 1 + resolveIfDone() + } + }, + }), ]) - await firstClient.setMemberState({ + const publishClient = publisher === 'JS' ? jsChat : rtChat + + await publishClient.setMemberState({ channels: [channel], memberId: params.memberId, state: { @@ -241,6 +253,36 @@ const testChatClientSetAndGetMemberState = ( return timeoutPromise(promise, promiseTimeout, promiseException) } +const testDisconnectedRTClient = (rtClient: RealtimeSWClient) => { + const promise = new Promise(async (resolve, reject) => { + try { + await rtClient.chat.listen({ + channels: ['random'], + onMessageReceived: (message) => { + // Message should not be reached + throw undefined + }, + }) + + rtClient.disconnect() + + await rtClient.chat.publish({ + content: 'Unreached message!', + channel: 'random', + meta: { + foo: 'bar', + }, + }) + + reject(4) + } catch (e) { + resolve(0) + } + }) + + return timeoutPromise(promise, promiseTimeout, promiseException) +} + const handler = async () => { // Create JS Chat Client const CRT = await createCRT(params) @@ -250,64 +292,80 @@ const handler = async () => { token: CRT.token, }) - const jsChatResultCode = await testChatClientMethods(jsChat) + const jsChatResultCode = await testChatMethod(jsChat) if (jsChatResultCode !== 0) { return jsChatResultCode } console.log('Created jsChat') - // Create RT-API Chat Client - const rtChat = new RealtimeAPIChat.Client({ - // @ts-expect-error + // Create RT-API Client + const rtClient = await RealtimeSignalWire({ host: process.env.RELAY_HOST, project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, }) + const rtChat = rtClient.chat - const rtChatResultCode = await testChatClientMethods(rtChat) + const rtChatResultCode = await testChatMethod(rtChat) if (rtChatResultCode !== 0) { return rtChatResultCode } console.log('Created rtChat') // Test Subscribe - const subscribeResultCode = await testChatClientSubscribe(jsChat, rtChat) + const subscribeResultCode = await testSubscribe({ jsChat, rtChat }) if (subscribeResultCode !== 0) { return subscribeResultCode } // Test Publish - const jsChatPublishCode = await testChatClientPublish(jsChat, rtChat) - if (jsChatPublishCode !== 0) { - return jsChatPublishCode + const jsPublishCode = await testPublish({ + jsChat, + rtChat, + publisher: 'JS', + }) + if (jsPublishCode !== 0) { + return jsPublishCode } - const rtChatPublishCode = await testChatClientPublish(rtChat, jsChat) - if (rtChatPublishCode !== 0) { - return rtChatPublishCode + const rtPublishCode = await testPublish({ + jsChat, + rtChat, + publisher: 'RT', + }) + if (rtPublishCode !== 0) { + return rtPublishCode } // Test Set/Get Member State - const jsChatGetSetStateCode = await testChatClientSetAndGetMemberState( + const jsChatGetSetStateCode = await testSetAndGetMemberState({ jsChat, - rtChat - ) + rtChat, + publisher: 'JS', + }) if (jsChatGetSetStateCode !== 0) { return jsChatGetSetStateCode } - const rtChatGetSetStateCode = await testChatClientSetAndGetMemberState( + const rtChatGetSetStateCode = await testSetAndGetMemberState({ + jsChat, rtChat, - jsChat - ) + publisher: 'RT', + }) if (rtChatGetSetStateCode !== 0) { return rtChatGetSetStateCode } // Test Unsubscribe - const unsubscribeResultCode = await testChatClientUnsubscribe(jsChat, rtChat) + const unsubscribeResultCode = await testUnsubscribe({ jsChat, rtChat }) if (unsubscribeResultCode !== 0) { return unsubscribeResultCode } + // Test diconnected client + const disconnectedRTClient = await testDisconnectedRTClient(rtClient) + if (disconnectedRTClient !== 0) { + return disconnectedRTClient + } + return 0 } diff --git a/internal/e2e-realtime-api/src/disconnectClient.test.ts b/internal/e2e-realtime-api/src/disconnectClient.test.ts deleted file mode 100644 index d21031d13..000000000 --- a/internal/e2e-realtime-api/src/disconnectClient.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { PubSub } from '@signalwire/realtime-api' -import { createTestRunner } from './utils' - -const handler = () => { - return new Promise(async (resolve) => { - const clientOptions = { - host: process.env.RELAY_HOST, - project: process.env.RELAY_PROJECT, - token: process.env.RELAY_TOKEN, - } - - const clientOne = new PubSub.Client({ - ...clientOptions, - debug: { - logWsTraffic: false, - }, - }) - - const channel = 'rw' - const meta = { foo: 'bar' } - const content = 'Hello World' - - await clientOne.publish({ - channel, - content, - meta, - }) - - clientOne.disconnect() - - const clientTwo = new PubSub.Client({ - ...clientOptions, - debug: { - logWsTraffic: false, - }, - }) - - await clientTwo.subscribe(channel) - clientTwo.on('message', (message) => { - if (message.meta.foo === 'bar' && message.content === 'Hello World') { - resolve(0) - } - }) - - const clientThree = new PubSub.Client({ - ...clientOptions, - debug: { - logWsTraffic: false, - }, - }) - await clientThree.publish({ - channel, - content, - meta, - }) - }) -} - -async function main() { - const runner = createTestRunner({ - name: 'Disconnect Client Tests', - testHandler: handler, - }) - - await runner.run() -} - -main() diff --git a/internal/e2e-realtime-api/src/pubSub.test.ts b/internal/e2e-realtime-api/src/pubSub.test.ts index 7da663124..956fb3776 100644 --- a/internal/e2e-realtime-api/src/pubSub.test.ts +++ b/internal/e2e-realtime-api/src/pubSub.test.ts @@ -7,7 +7,11 @@ * receive the proper events. */ import { timeoutPromise, SWCloseEvent } from '@signalwire/core' -import { PubSub as RealtimeAPIPubSub } from '@signalwire/realtime-api' +import { SignalWire as RealtimeSignalWire } from '@signalwire/realtime-api' +import type { + PubSub as RealtimePubSub, + SWClient as RealtimeSWClient, +} from '@signalwire/realtime-api' import { PubSub as JSPubSub } from '@signalwire/js' import { WebSocket } from 'ws' import { randomUUID } from 'node:crypto' @@ -41,20 +45,21 @@ const params = { }, } -type PubSubClient = RealtimeAPIPubSub.Client | JSPubSub.Client -const testPubSubClientSubscribe = ( - firstClient: PubSubClient, - secondClient: PubSubClient -) => { +interface TestPubSubOptions { + jsPubSub: JSPubSub.Client + rtPubSub: RealtimePubSub + publisher?: 'JS' | 'RT' +} + +const testSubscribe = ({ jsPubSub, rtPubSub }: TestPubSubOptions) => { const promise = new Promise(async (resolve, reject) => { console.log('Running subscribe..') - firstClient.once('message', () => {}) - secondClient.once('message', () => {}) + jsPubSub.once('message', () => {}) try { await Promise.all([ - firstClient.subscribe(channel), - secondClient.subscribe(channel), + jsPubSub.subscribe(channel), + rtPubSub.listen({ channels: [channel] }), ]) resolve(0) } catch (e) { @@ -65,10 +70,7 @@ const testPubSubClientSubscribe = ( return timeoutPromise(promise, promiseTimeout, promiseException) } -const testPubSubClientPublish = ( - firstClient: PubSubClient, - secondClient: PubSubClient -) => { +const testPublish = ({ jsPubSub, rtPubSub, publisher }: TestPubSubOptions) => { const promise = new Promise(async (resolve) => { console.log('Running publish..') let events = 0 @@ -79,28 +81,32 @@ const testPubSubClientPublish = ( } const now = Date.now() - firstClient.once('message', (message) => { + jsPubSub.once('message', (message) => { console.log('jsPubSub message') if (message.meta.now === now) { events += 1 resolveIfDone() } }) - secondClient.once('message', (message) => { - console.log('rtPubSub message') - if (message.meta.now === now) { - events += 1 - resolveIfDone() - } - }) await Promise.all([ - firstClient.subscribe(channel), - secondClient.subscribe(channel), + jsPubSub.subscribe(channel), + rtPubSub.listen({ + channels: [channel], + onMessageReceived: (message) => { + console.log('rtPubSub message') + if (message.meta.now === now) { + events += 1 + resolveIfDone() + } + }, + }), ]) - await firstClient.publish({ - content: 'Hello There', + const publishClient = publisher === 'JS' ? jsPubSub : rtPubSub + + await publishClient.publish({ + content: 'Hello there!', channel, meta: { now, @@ -112,22 +118,18 @@ const testPubSubClientPublish = ( return timeoutPromise(promise, promiseTimeout, promiseException) } -const testPubSubClientUnsubscribe = ( - firstClient: PubSubClient, - secondClient: PubSubClient -) => { +const testUnsubscribe = ({ jsPubSub, rtPubSub }: TestPubSubOptions) => { const promise = new Promise(async (resolve, reject) => { console.log('Running unsubscribe..') try { - await Promise.all([ - firstClient.subscribe(channel), - secondClient.subscribe(channel), + const [unsubRTClient] = await Promise.all([ + rtPubSub.listen({ channels: [channel] }), + jsPubSub.subscribe(channel), ]) - await firstClient.unsubscribe(channel) - - await secondClient.unsubscribe(channel) + await jsPubSub.unsubscribe(channel) + await unsubRTClient() resolve(0) } catch (e) { @@ -138,6 +140,36 @@ const testPubSubClientUnsubscribe = ( return timeoutPromise(promise, promiseTimeout, promiseException) } +const testDisconnectedRTClient = (rtClient: RealtimeSWClient) => { + const promise = new Promise(async (resolve, reject) => { + try { + await rtClient.pubSub.listen({ + channels: ['random'], + onMessageReceived: (message) => { + // Message should not be reached + throw undefined + }, + }) + + rtClient.disconnect() + + await rtClient.pubSub.publish({ + content: 'Unreached message!', + channel: 'random', + meta: { + foo: 'bar', + }, + }) + + reject(4) + } catch (e) { + resolve(0) + } + }) + + return timeoutPromise(promise, promiseTimeout, promiseException) +} + const handler = async () => { // Create JS PubSub Client const CRT = await createCRT(params) @@ -146,47 +178,55 @@ const handler = async () => { // @ts-expect-error token: CRT.token, }) - console.log('Created jsPubSub') // Create RT-API PubSub Client - const rtPubSub = new RealtimeAPIPubSub.Client({ - // @ts-expect-error + const rtClient = await RealtimeSignalWire({ host: process.env.RELAY_HOST, project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, }) - - console.log('Created rtPubSub') + const rtPubSub = rtClient.pubSub + console.log('Created rtClient') // Test Subscribe - const subscribeResultCode = await testPubSubClientSubscribe( - jsPubSub, - rtPubSub - ) + const subscribeResultCode = await testSubscribe({ jsPubSub, rtPubSub }) if (subscribeResultCode !== 0) { return subscribeResultCode } - // Test Publish - const jsPubSubPublishCode = await testPubSubClientPublish(jsPubSub, rtPubSub) - if (jsPubSubPublishCode !== 0) { - return jsPubSubPublishCode + // Test Publish from JS + const jsPublishResultCode = await testPublish({ + jsPubSub, + rtPubSub, + publisher: 'JS', + }) + if (jsPublishResultCode !== 0) { + return jsPublishResultCode } - const rtPubSubPublishCode = await testPubSubClientPublish(rtPubSub, jsPubSub) - if (rtPubSubPublishCode !== 0) { - return rtPubSubPublishCode + + // Test Publish from RT + const rtPublishResultCode = await testPublish({ + jsPubSub, + rtPubSub, + publisher: 'RT', + }) + if (rtPublishResultCode !== 0) { + return rtPublishResultCode } // Test Unsubscribe - const unsubscribeResultCode = await testPubSubClientUnsubscribe( - jsPubSub, - rtPubSub - ) + const unsubscribeResultCode = await testUnsubscribe({ jsPubSub, rtPubSub }) if (unsubscribeResultCode !== 0) { return unsubscribeResultCode } + // Test diconnected client + const disconnectedRTClient = await testDisconnectedRTClient(rtClient) + if (disconnectedRTClient !== 0) { + return disconnectedRTClient + } + return 0 } diff --git a/internal/playground-realtime-api/src/chat/index.ts b/internal/playground-realtime-api/src/chat/index.ts index a824dde0f..1fabd3a22 100644 --- a/internal/playground-realtime-api/src/chat/index.ts +++ b/internal/playground-realtime-api/src/chat/index.ts @@ -1,72 +1,66 @@ -import { Chat } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' async function run() { try { - const chat = new Chat.Client({ - // @ts-expect-error + const client = await SignalWire({ host: process.env.HOST || 'relay.swire.io', project: process.env.PROJECT as string, token: process.env.TOKEN as string, }) - const channel = 'channel-name-here' - - chat.on('member.joined', (member) => { - console.log('member.joined', member) - }) - - chat.on('member.updated', (member) => { - console.log('member.updated', member) - }) - - chat.on('member.left', (member) => { - console.log('member.left', member) - }) - - chat.on('message', (message) => { - console.log('message', message) + const unsubHome = await client.chat.listen({ + channels: ['home'], + onMessageReceived: (message) => { + console.log('Message received on "home" channel', message) + }, + onMemberJoined: (member) => { + console.log('Member joined on "home" channel', member) + }, + onMemberUpdated: (member) => { + console.log('Member updated on "home" channel', member) + }, + onMemberLeft: (member) => { + console.log('Member left on "home" channel', member) + }, }) - await chat.subscribe([channel]) - - const pubRes = await chat.publish({ + const pubRes = await client.chat.publish({ content: 'Hello There', - channel: channel, + channel: 'home', meta: { fooId: 'randomValue', }, }) - console.log('Publish Result --->', pubRes) - const messagesResult = await chat.getMessages({ - channel: channel, + const messagesResult = await client.chat.getMessages({ + channel: 'home', }) - console.log('Get Messages Result ---> ', messagesResult) - const setStateResult = await chat.setMemberState({ + const getMembersResult = await client.chat.getMembers({ channel: 'home' }) + console.log('Get Member Result --->', getMembersResult) + + const setStateResult = await client.chat.setMemberState({ state: { data: 'state data', }, - channels: [channel], - memberId: 'someMemberId', + channels: ['home'], + memberId: getMembersResult.members[0].id, }) - console.log('Set Member State Result --->', setStateResult) - const getStateResult = await chat.getMemberState({ - channels: [channel], - memberId: 'someMemberId', + const getStateResult = await client.chat.getMemberState({ + channels: 'home', + memberId: getMembersResult.members[0].id, }) - console.log('Get Member State Result --->', getStateResult) - const unsubscribeRes = await chat.unsubscribe(channel) - - console.log('Unsubscribe Result --->', unsubscribeRes) + console.log('Unsubscribing --->') + await unsubHome() - console.log('Client Running..') + console.log('Client disconnecting..') + client.disconnect() } catch (error) { console.log('', error) } diff --git a/internal/playground-realtime-api/src/pubSub/index.ts b/internal/playground-realtime-api/src/pubSub/index.ts index 9f2024483..3a002bbc3 100644 --- a/internal/playground-realtime-api/src/pubSub/index.ts +++ b/internal/playground-realtime-api/src/pubSub/index.ts @@ -1,41 +1,63 @@ -import { PubSub } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' async function run() { try { - const pubSub = new PubSub.Client({ - // @ts-expect-error + const client = await SignalWire({ host: process.env.HOST || 'relay.swire.io', project: process.env.PROJECT as string, token: process.env.TOKEN as string, - logLevel: 'trace', - debug: { - logWsTraffic: true, - }, }) - const channel = 'channel-name-here' + const unsubHomeOffice = await client.pubSub.listen({ + channels: ['office', 'home'], + onMessageReceived: (payload) => { + console.log( + 'Message received under the "office" or "home" channels', + payload + ) + }, + }) - pubSub.on('message', (message) => { - console.log('message', message) + const unsubWorkplace = await client.pubSub.listen({ + channels: ['workplace'], + onMessageReceived: (payload) => { + console.log('Message received under the "workplace" channels', payload) + }, }) - await pubSub.subscribe([channel]) + const pubResOffice = await client.pubSub.publish({ + content: 'Hello There', + channel: 'office', + meta: { + fooId: 'randomValue', + }, + }) + console.log('Publish Result --->', pubResOffice) - const pubRes = await pubSub.publish({ + const pubResWorkplace = await client.pubSub.publish({ content: 'Hello There', - channel: channel, + channel: 'workplace', meta: { fooId: 'randomValue', }, }) + console.log('Publish Result --->', pubResWorkplace) - console.log('Publish Result --->', pubRes) + await unsubHomeOffice() - const unsubscribeRes = await pubSub.unsubscribe(channel) + const pubResHome = await client.pubSub.publish({ + content: 'Hello There', + channel: 'home', + meta: { + fooId: 'randomValue', + }, + }) + console.log('Publish Result --->', pubResHome) - console.log('Unsubscribe Result --->', unsubscribeRes) + await unsubWorkplace() - console.log('Client Running..') + console.log('Disconnect the client..') + client.disconnect() } catch (error) { console.log('', error) } diff --git a/internal/stack-tests/src/chat/app.ts b/internal/stack-tests/src/chat/app.ts index 8b28b6cbe..b7dfa5244 100644 --- a/internal/stack-tests/src/chat/app.ts +++ b/internal/stack-tests/src/chat/app.ts @@ -1,24 +1,21 @@ -import { Chat } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' import tap from 'tap' async function run() { try { - const chat = new Chat.Client({ - // @ts-expect-error + const client = await SignalWire({ host: process.env.RELAY_HOST || 'relay.swire.io', project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, }) - tap.ok(chat.on, 'chat.on is defined') - tap.ok(chat.once, 'chat.once is defined') - tap.ok(chat.off, 'chat.off is defined') - tap.ok(chat.subscribe, 'chat.subscribe is defined') - tap.ok(chat.removeAllListeners, 'chat.removeAllListeners is defined') - tap.ok(chat.getMemberState, 'chat.getMemberState is defined') - tap.ok(chat.getMembers, 'chat.getMembers is defined') - tap.ok(chat.getMessages, 'chat.getMessages is defined') - tap.ok(chat.setMemberState, 'chat.setMemberState is defined') + tap.ok(client.chat, 'client.chat is defined') + tap.ok(client.chat.listen, 'client.chat.listen is defined') + tap.ok(client.chat.publish, 'client.chat.publish is defined') + tap.ok(client.chat.getMessages, 'client.chat.getMessages is defined') + tap.ok(client.chat.getMembers, 'client.chat.getMembers is defined') + tap.ok(client.chat.getMemberState, 'client.chat.getMemberState is defined') + tap.ok(client.chat.setMemberState, 'client.chat.setMemberState is defined') process.exit(0) } catch (error) { diff --git a/internal/stack-tests/src/pubSub/app.ts b/internal/stack-tests/src/pubSub/app.ts index 8693a24ef..837497239 100644 --- a/internal/stack-tests/src/pubSub/app.ts +++ b/internal/stack-tests/src/pubSub/app.ts @@ -1,23 +1,17 @@ -import { PubSub } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' import tap from 'tap' async function run() { try { - const pubSub = new PubSub.Client({ - // @ts-expect-error + const client = await SignalWire({ host: process.env.RELAY_HOST || 'relay.swire.io', project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, - contexts: [process.env.RELAY_CONTEXT as string], }) - tap.ok(pubSub.on, 'pubSub.on is defined') - tap.ok(pubSub.once, 'pubSub.once is defined') - tap.ok(pubSub.off, 'pubSub.off is defined') - tap.ok(pubSub.removeAllListeners, 'pubSub.removeAllListeners is defined') - tap.ok(pubSub.publish, 'pubSub.publish is defined') - tap.ok(pubSub.subscribe, 'pubSub.subscribe is defined') - tap.ok(pubSub.unsubscribe, 'pubSub.unsubscribe is defined') + tap.ok(client.pubSub, 'client.pubSub is defined') + tap.ok(client.pubSub.listen, 'client.pubSub.listen is defined') + tap.ok(client.pubSub.publish, 'client.pubSub.publish is defined') process.exit(0) } catch (error) { diff --git a/packages/core/src/BaseSession.ts b/packages/core/src/BaseSession.ts index 922eba8af..e95a7867a 100644 --- a/packages/core/src/BaseSession.ts +++ b/packages/core/src/BaseSession.ts @@ -285,6 +285,12 @@ export class BaseSession { message: 'The SDK session is disconnecting', }) } + if (this._status === 'disconnected') { + return Promise.reject({ + code: '400', + message: 'The SDK is disconnected', + }) + } // In case of a response don't wait for a result let promise: Promise = Promise.resolve() if ('params' in msg) { diff --git a/packages/core/src/chat/applyCommonMethods.ts b/packages/core/src/chat/applyCommonMethods.ts new file mode 100644 index 000000000..956cf4a51 --- /dev/null +++ b/packages/core/src/chat/applyCommonMethods.ts @@ -0,0 +1,109 @@ +import { + InternalChatMemberEntity, + InternalChatMessageEntity, + PaginationCursor, +} from '../types' +import { toExternalJSON } from '../utils' +import { BaseRPCResult } from '../utils/interfaces' +import { isValidChannels, toInternalChatChannels } from './utils' + +export interface GetMembersInput extends BaseRPCResult { + members: InternalChatMemberEntity[] +} + +export interface GetMessagesInput extends BaseRPCResult { + messages: InternalChatMessageEntity[] + cursor: PaginationCursor +} + +interface ChatMemberMethodParams extends Record { + memberId?: string +} + +interface GetMemberStateOutput { + channels: any +} + +const transformParamChannels = (params: ChatMemberMethodParams) => { + const channels = isValidChannels(params?.channels) + ? toInternalChatChannels(params.channels) + : undefined + + return { + ...params, + channels, + } +} + +const baseCodeTransform = () => {} + +export function applyCommonMethods any>( + targetClass: T +) { + return class extends targetClass { + getMembers(params: GetMembersInput) { + return this._client.execute( + { + method: 'chat.members.get', + params, + }, + { + transformResolve: (payload: GetMembersInput) => ({ + members: payload.members.map((member) => toExternalJSON(member)), + }), + } + ) + } + + getMessages(params: GetMessagesInput) { + return this._client.execute( + { + method: 'chat.messages.get', + params, + }, + { + transformResolve: (payload: GetMessagesInput) => ({ + messages: payload.messages.map((message) => + toExternalJSON(message) + ), + cursor: payload.cursor, + }), + } + ) + } + + setMemberState({ memberId, ...rest }: Record = {}) { + return this._client.execute( + { + method: 'chat.member.set_state', + params: { + member_id: memberId, + ...rest, + }, + }, + { + transformResolve: baseCodeTransform, + transformParams: transformParamChannels, + } + ) + } + + getMemberState({ memberId, ...rest }: Record = {}) { + return this._client.execute( + { + method: 'chat.member.get_state', + params: { + member_id: memberId, + ...rest, + }, + }, + { + transformResolve: (payload: GetMemberStateOutput) => ({ + channels: payload.channels, + }), + transformParams: transformParamChannels, + } + ) + } + } +} diff --git a/packages/core/src/chat/index.ts b/packages/core/src/chat/index.ts index 7ac4bd3a1..8b0f6eb89 100644 --- a/packages/core/src/chat/index.ts +++ b/packages/core/src/chat/index.ts @@ -2,3 +2,4 @@ export * from './methods' export * from './BaseChat' export * from './ChatMessage' export * from './ChatMember' +export * from './applyCommonMethods' diff --git a/packages/core/src/types/chat.ts b/packages/core/src/types/chat.ts index 269ec72f9..018aace1a 100644 --- a/packages/core/src/types/chat.ts +++ b/packages/core/src/types/chat.ts @@ -31,6 +31,8 @@ export type ChatMemberEventNames = export type ChatEventNames = ChatMessageEventName | ChatMemberEventNames +export type ChatEvents = ToInternalChatEvent + export type ChatChannel = string | string[] export interface ChatSetMemberStateParams { diff --git a/packages/realtime-api/src/BaseNamespace.test.ts b/packages/realtime-api/src/BaseNamespace.test.ts new file mode 100644 index 000000000..1ba14bdd2 --- /dev/null +++ b/packages/realtime-api/src/BaseNamespace.test.ts @@ -0,0 +1,249 @@ +import { EventEmitter } from '@signalwire/core' +import { BaseNamespace } from './BaseNamespace' + +describe('BaseNamespace', () => { + // Using 'any' data type to bypass TypeScript checks for private or protected members. + let baseNamespace: any + let swClientMock: any + const listenOptions = { + topics: ['topic1', 'topic2'], + onEvent1: jest.fn(), + onEvent2: jest.fn(), + } + const eventMap: Record = { + onEvent1: 'event1', + onEvent2: 'event2', + } + + beforeEach(() => { + swClientMock = { + client: { + execute: jest.fn(), + }, + } + baseNamespace = new BaseNamespace({ swClient: swClientMock }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('constructor', () => { + it('should initialize the necessary properties', () => { + expect(baseNamespace._sw).toBe(swClientMock) + expect(baseNamespace._client).toBe(swClientMock.client) + expect(baseNamespace._eventMap).toEqual({}) + expect(baseNamespace._namespaceEmitter).toBeInstanceOf(EventEmitter) + expect(baseNamespace._listenerMap).toBeInstanceOf(Map) + expect(baseNamespace._listenerMap.size).toBe(0) + }) + }) + + describe('addTopics', () => { + it('should call execute to add topics with the correct parameters', async () => { + const executeMock = jest.spyOn(swClientMock.client, 'execute') + + await baseNamespace.addTopics(listenOptions.topics) + + expect(executeMock).toHaveBeenCalledWith({ + method: 'signalwire.receive', + params: { + contexts: listenOptions.topics, + }, + }) + }) + }) + + describe('removeTopics', () => { + it('should call execute to remove topics with the correct parameters', async () => { + const executeMock = jest.spyOn(swClientMock.client, 'execute') + + await baseNamespace.removeTopics(listenOptions.topics) + + expect(executeMock).toHaveBeenCalledWith({ + method: 'signalwire.unreceive', + params: { + contexts: listenOptions.topics, + }, + }) + }) + }) + + describe('listen', () => { + it('should throw an error if topics is not an array with at least one topic', async () => { + const thrownMessage = + 'Invalid options: topics should be an array with at least one topic!' + + await expect(baseNamespace.listen({ topics: [] })).rejects.toThrow( + thrownMessage + ) + await expect(baseNamespace.listen({ topics: 'topic' })).rejects.toThrow( + thrownMessage + ) + }) + + it('should call the subscribe method with listen options', async () => { + const subscribeMock = jest.spyOn(baseNamespace, 'subscribe') + + await baseNamespace.listen(listenOptions) + + expect(subscribeMock).toHaveBeenCalledWith(listenOptions) + }) + + it('should resolve with a function to unsubscribe', async () => { + const unsubscribeMock = jest.fn().mockResolvedValue(undefined) + jest.spyOn(baseNamespace, 'subscribe').mockResolvedValue(unsubscribeMock) + + const unsub = await baseNamespace.listen(listenOptions) + expect(typeof unsub).toBe('function') + + await unsub() + expect(unsubscribeMock).toHaveBeenCalled() + }) + }) + + describe('subscribe', () => { + let emitterOnMock: jest.Mock + let emitterOffMock: jest.Mock + let addTopicsMock: jest.Mock + let removeTopicsMock: jest.Mock + + beforeEach(() => { + // Mock this._eventMap + baseNamespace._eventMap = eventMap + + // Mock emitter.on method + emitterOnMock = jest.fn() + baseNamespace.emitter.on = emitterOnMock + + // Mock emitter.off method + emitterOffMock = jest.fn() + baseNamespace.emitter.off = emitterOffMock + + // Mock addTopics method + addTopicsMock = jest.fn() + baseNamespace.addTopics = addTopicsMock + + // Mock removeTopics method + removeTopicsMock = jest.fn() + baseNamespace.removeTopics = removeTopicsMock + }) + + it('should attach listeners, add topics, and return an unsubscribe function', async () => { + const { topics, ...listeners } = listenOptions + const unsub = await baseNamespace.subscribe(listenOptions) + + // Check if the listeners are attached + const listenerKeys = Object.keys(listeners) as Array< + keyof typeof listeners + > + topics.forEach((topic) => { + listenerKeys.forEach((key) => { + const expectedEventName = `${topic}.${eventMap[key]}` + expect(emitterOnMock).toHaveBeenCalledWith( + expectedEventName, + listeners[key] + ) + }) + }) + + // Check if topics are added + expect(baseNamespace.addTopics).toHaveBeenCalledWith(topics) + + // Check if the listener is added to the listener map + expect(baseNamespace._listenerMap.size).toBe(1) + const [[_, value]] = baseNamespace._listenerMap.entries() + expect(value.topics).toEqual(new Set(topics)) + expect(value.listeners).toEqual(listeners) + + // Check if the returned unsubscribe function is valid + expect(unsub).toBeInstanceOf(Function) + await expect(unsub()).resolves.toBeUndefined() + + // Check if the topics are removed, listeners are detached, and entry is removed from the listener map + expect(baseNamespace.removeTopics).toHaveBeenCalledWith(topics) + topics.forEach((topic) => { + listenerKeys.forEach((key) => { + const expectedEventName = `${topic}.${eventMap[key]}` + expect(emitterOffMock).toHaveBeenCalledWith( + expectedEventName, + listeners[key] + ) + }) + }) + expect(baseNamespace._listenerMap.size).toBe(0) + }) + }) + + describe('hasOtherListeners', () => { + it('should return true if other listeners exist for the given topic', () => { + const uuid = 'uuid1' + const otherUUID = 'uuid2' + + baseNamespace._listenerMap.set(uuid, { + topics: new Set(['topic1', 'topic2']), + listeners: {}, + unsub: jest.fn(), + }) + baseNamespace._listenerMap.set(otherUUID, { + topics: new Set(['topic2']), + listeners: {}, + unsub: jest.fn(), + }) + + const result = baseNamespace.hasOtherListeners(uuid, 'topic2') + + expect(result).toBe(true) + }) + + it('should return false if no other listeners exist for the given topic', () => { + const uuid = 'uuid1' + const otherUUID = 'uuid2' + + baseNamespace._listenerMap.set(uuid, { + topics: new Set(['topic1', 'topic2']), + listeners: {}, + unsub: jest.fn(), + }) + baseNamespace._listenerMap.set(otherUUID, { + topics: new Set(['topic2']), + listeners: {}, + unsub: jest.fn(), + }) + + const result = baseNamespace.hasOtherListeners(uuid, 'topic1') + + expect(result).toBe(false) + }) + }) + + describe('unsubscribeAll', () => { + it('should call unsubscribe for each listener and clear the listener map', async () => { + const listener1 = { unsub: jest.fn() } + const listener2 = { unsub: jest.fn() } + baseNamespace._listenerMap.set('uuid1', listener1) + baseNamespace._listenerMap.set('uuid2', listener2) + + expect(baseNamespace._listenerMap.size).toBe(2) + + await baseNamespace.unsubscribeAll() + + expect(listener1.unsub).toHaveBeenCalledTimes(1) + expect(listener2.unsub).toHaveBeenCalledTimes(1) + expect(baseNamespace._listenerMap.size).toBe(0) + }) + }) + + describe('removeFromListenerMap', () => { + it('should remove the listener with the given UUID from the listener map', () => { + const idToRemove = 'uuid1' + baseNamespace._listenerMap.set('uuid1', {}) + baseNamespace._listenerMap.set('uuid2', {}) + + baseNamespace.removeFromListenerMap(idToRemove) + + expect(baseNamespace._listenerMap.size).toBe(1) + expect(baseNamespace._listenerMap.has(idToRemove)).toBe(false) + }) + }) +}) diff --git a/packages/realtime-api/src/BaseNamespace.ts b/packages/realtime-api/src/BaseNamespace.ts index 51c912046..ece79f50f 100644 --- a/packages/realtime-api/src/BaseNamespace.ts +++ b/packages/realtime-api/src/BaseNamespace.ts @@ -7,7 +7,7 @@ export interface ListenOptions { topics: string[] } -type ListenersKeys = keyof Omit +export type ListenersKeys = keyof Omit type ListenerMap = Map< string, @@ -21,20 +21,20 @@ type ListenerMap = Map< export class BaseNamespace { protected _client: Client protected _sw: SWClient - protected _eventMap: Record + protected _eventMap: Record = {} private _namespaceEmitter = new EventEmitter() - private _listenerMap: ListenerMap = new Map() + protected _listenerMap: ListenerMap = new Map() constructor(options: { swClient: SWClient }) { this._sw = options.swClient this._client = options.swClient.client } - get emitter() { + protected get emitter() { return this._namespaceEmitter } - private addTopics(topics: string[]) { + protected addTopics(topics: string[]) { const executeParams: ExecuteParams = { method: 'signalwire.receive', params: { @@ -44,7 +44,7 @@ export class BaseNamespace { return this._client.execute(executeParams) } - private removeTopics(topics: string[]) { + protected removeTopics(topics: string[]) { const executeParams: ExecuteParams = { method: 'signalwire.unreceive', params: { @@ -58,7 +58,7 @@ export class BaseNamespace { return new Promise<() => Promise>(async (resolve, reject) => { try { const { topics } = listenOptions - if (topics?.length < 1) { + if (!Array.isArray(topics) || topics?.length < 1) { throw new Error( 'Invalid options: topics should be an array with at least one topic!' ) @@ -90,10 +90,10 @@ export class BaseNamespace { await this.removeTopics(topicsToRemove) } - // Remove listeners + // Detach listeners this._detachListeners(topics, listeners) - // Remove task from the task listener array + // Remove topics from the listener map this.removeFromListenerMap(_uuid) resolve() @@ -112,7 +112,7 @@ export class BaseNamespace { return unsub } - private _attachListeners(topics: string[], listeners: Omit) { + protected _attachListeners(topics: string[], listeners: Omit) { const listenerKeys = Object.keys(listeners) as Array topics.forEach((topic) => { listenerKeys.forEach((key) => { @@ -124,7 +124,7 @@ export class BaseNamespace { }) } - private _detachListeners(topics: string[], listeners: Omit) { + protected _detachListeners(topics: string[], listeners: Omit) { const listenerKeys = Object.keys(listeners) as Array topics.forEach((topic) => { listenerKeys.forEach((key) => { @@ -136,10 +136,9 @@ export class BaseNamespace { }) } - private hasOtherListeners(uuid: string, topic: string) { + protected hasOtherListeners(uuid: string, topic: string) { for (const [key, listener] of this._listenerMap) { - if (key === uuid) continue - if (listener.topics.has(topic)) return true + if (key !== uuid && listener.topics.has(topic)) return true } return false } @@ -151,7 +150,7 @@ export class BaseNamespace { this._listenerMap.clear() } - private removeFromListenerMap(id: string) { + protected removeFromListenerMap(id: string) { return this._listenerMap.delete(id) } } diff --git a/packages/realtime-api/src/SWClient.test.ts b/packages/realtime-api/src/SWClient.test.ts new file mode 100644 index 000000000..a8270cf65 --- /dev/null +++ b/packages/realtime-api/src/SWClient.test.ts @@ -0,0 +1,66 @@ +import { SWClient } from './SWClient' +import { createClient } from './client/createClient' +import { clientConnect } from './client/clientConnect' +import { Task } from './task/Task' +import { PubSub } from './pubSub/PubSub' +import { Chat } from './chat/Chat' + +jest.mock('./client/createClient') +jest.mock('./client/clientConnect') + +describe('SWClient', () => { + let swClient: SWClient + let clientMock: any + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + + beforeEach(() => { + clientMock = { + disconnect: jest.fn(), + runWorker: jest.fn(), + } + ;(createClient as any).mockReturnValue(clientMock) + + swClient = new SWClient(userOptions) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should create SWClient instance with the provided options', () => { + expect(swClient.userOptions).toEqual(userOptions) + expect(createClient).toHaveBeenCalledWith(userOptions) + }) + + it('should connect the client', async () => { + await swClient.connect() + expect(clientConnect).toHaveBeenCalledWith(clientMock) + }) + + it('should disconnect the client', () => { + swClient.disconnect() + expect(clientMock.disconnect).toHaveBeenCalled() + }) + + it('should create and return a Task instance', () => { + const task = swClient.task + expect(task).toBeInstanceOf(Task) + expect(swClient.task).toBe(task) // Ensure the same instance is returned on subsequent calls + }) + + it('should create and return a PubSub instance', () => { + const pubSub = swClient.pubSub + expect(pubSub).toBeInstanceOf(PubSub) + expect(swClient.pubSub).toBe(pubSub) + }) + + it('should create and return a Chat instance', () => { + const chat = swClient.chat + expect(chat).toBeInstanceOf(Chat) + expect(swClient.chat).toBe(chat) + }) +}) diff --git a/packages/realtime-api/src/SWClient.ts b/packages/realtime-api/src/SWClient.ts index a6bc27d72..9f0fb3c0f 100644 --- a/packages/realtime-api/src/SWClient.ts +++ b/packages/realtime-api/src/SWClient.ts @@ -2,6 +2,8 @@ import { createClient } from './client/createClient' import type { Client } from './client/Client' import { clientConnect } from './client/clientConnect' import { Task } from './task/Task' +import { PubSub } from './pubSub/PubSub' +import { Chat } from './chat/Chat' export interface SWClientOptions { host?: string @@ -15,6 +17,8 @@ export interface SWClientOptions { export class SWClient { private _task: Task + private _pubSub: PubSub + private _chat: Chat public userOptions: SWClientOptions public client: Client @@ -38,4 +42,18 @@ export class SWClient { } return this._task } + + get pubSub() { + if (!this._pubSub) { + this._pubSub = new PubSub(this) + } + return this._pubSub + } + + get chat() { + if (!this._chat) { + this._chat = new Chat(this) + } + return this._chat + } } diff --git a/packages/realtime-api/src/SignalWire.ts b/packages/realtime-api/src/SignalWire.ts index 245773b6b..ca3aeeecd 100644 --- a/packages/realtime-api/src/SignalWire.ts +++ b/packages/realtime-api/src/SignalWire.ts @@ -12,3 +12,8 @@ export const SignalWire = (options: SWClientOptions): Promise => { } }) } + +export type { SWClient } from './SWClient' +export type { Chat } from './chat/Chat' +export type { PubSub } from './pubSub/PubSub' +export type { Task } from './task/Task' diff --git a/packages/realtime-api/src/chat/BaseChat.test.ts b/packages/realtime-api/src/chat/BaseChat.test.ts new file mode 100644 index 000000000..78383e622 --- /dev/null +++ b/packages/realtime-api/src/chat/BaseChat.test.ts @@ -0,0 +1,110 @@ +import { BaseChat } from './BaseChat' + +describe('BaseChat', () => { + // Using 'any' data type to bypass TypeScript checks for private or protected members. + let swClientMock: any + let baseChat: any + const listenOptions = { + channels: ['channel1', 'channel2'], + onEvent1: jest.fn(), + onEvent2: jest.fn(), + } + const eventMap: Record = { + onEvent1: 'event1', + onEvent2: 'event2', + } + + beforeEach(() => { + swClientMock = { + client: { + execute: jest.fn(), + }, + } + baseChat = new BaseChat(swClientMock) + + // Mock this._eventMap + baseChat._eventMap = eventMap + }) + + describe('listen', () => { + it('should throw an error if channels is not an array with at least one topic', async () => { + const thrownMessage = + 'Invalid options: channels should be an array with at least one channel!' + + await expect(baseChat.listen({ channels: [] })).rejects.toThrow( + thrownMessage + ) + await expect(baseChat.listen({ channels: 'topic' })).rejects.toThrow( + thrownMessage + ) + }) + + it('should call the subscribe method with listen options', async () => { + const subscribeMock = jest.spyOn(baseChat, 'subscribe') + + await baseChat.listen(listenOptions) + expect(subscribeMock).toHaveBeenCalledWith(listenOptions) + }) + + it('should resolve with a function to unsubscribe', async () => { + const unsubscribeMock = jest.fn().mockResolvedValue(undefined) + jest.spyOn(baseChat, 'subscribe').mockResolvedValue(unsubscribeMock) + + const unsub = await baseChat.listen(listenOptions) + expect(typeof unsub).toBe('function') + + await unsub() + expect(unsubscribeMock).toHaveBeenCalled() + }) + }) + + describe('subscribe', () => { + const { channels, ...listeners } = listenOptions + + it('should add channels and attach listeners', async () => { + const addChannelsMock = jest + .spyOn(baseChat, 'addChannels') + .mockResolvedValueOnce(null) + const attachListenersMock = jest.spyOn(baseChat, '_attachListeners') + + await expect(baseChat.subscribe(listenOptions)).resolves.toBeInstanceOf( + Function + ) + expect(addChannelsMock).toHaveBeenCalledWith(channels, [ + 'event1', + 'event2', + ]) + expect(attachListenersMock).toHaveBeenCalledWith(channels, listeners) + }) + + it('should remove channels and detach listeners when unsubscribed', async () => { + const removeChannelsMock = jest + .spyOn(baseChat, 'removeChannels') + .mockResolvedValueOnce(null) + const detachListenersMock = jest.spyOn(baseChat, '_detachListeners') + + const unsub = await baseChat.subscribe({ channels, ...listeners }) + expect(unsub).toBeInstanceOf(Function) + + await expect(unsub()).resolves.toBeUndefined() + expect(removeChannelsMock).toHaveBeenCalledWith(channels) + expect(detachListenersMock).toHaveBeenCalledWith(channels, listeners) + }) + }) + + describe('publish', () => { + const params = { channel: 'channel1', message: 'Hello from jest!' } + + it('should publish a chat message', async () => { + const executeMock = jest + .spyOn(baseChat._client, 'execute') + .mockResolvedValueOnce(undefined) + + await expect(baseChat.publish(params)).resolves.toBeUndefined() + expect(executeMock).toHaveBeenCalledWith({ + method: 'chat.publish', + params, + }) + }) + }) +}) diff --git a/packages/realtime-api/src/chat/BaseChat.ts b/packages/realtime-api/src/chat/BaseChat.ts new file mode 100644 index 000000000..fb5010505 --- /dev/null +++ b/packages/realtime-api/src/chat/BaseChat.ts @@ -0,0 +1,139 @@ +import { ExecuteParams, PubSubPublishParams, uuid } from '@signalwire/core' +import { BaseNamespace } from '../BaseNamespace' +import { SWClient } from '../SWClient' + +export interface BaseChatListenOptions { + channels: string[] +} + +export type BaseChatListenerKeys = keyof Omit + +export class BaseChat< + T extends BaseChatListenOptions +> extends BaseNamespace { + constructor(options: SWClient) { + super({ swClient: options }) + } + + public listen(listenOptions: T) { + return new Promise<() => Promise>(async (resolve, reject) => { + try { + const { channels } = listenOptions + if (!Array.isArray(channels) || channels?.length < 1) { + throw new Error( + 'Invalid options: channels should be an array with at least one channel!' + ) + } + const unsub = await this.subscribe(listenOptions) + resolve(unsub) + } catch (error) { + reject(error) + } + }) + } + + protected async subscribe(listenOptions: T) { + const { channels, ...listeners } = listenOptions + + const _uuid = uuid() + + // Attach listeners + this._attachListeners(channels, listeners) + + const listenerKeys = Object.keys(listeners) as Array + const events: string[] = [] + listenerKeys.forEach((key) => { + if (this._eventMap[key]) events.push(this._eventMap[key]) + }) + await this.addChannels(channels, events) + + const unsub = () => { + return new Promise(async (resolve, reject) => { + try { + // Remove the channels + const channelsToRemove = channels.filter( + (channel) => !this.hasOtherListeners(_uuid, channel) + ) + if (channelsToRemove.length > 0) { + await this.removeChannels(channelsToRemove) + } + + // Detach listeners + this._detachListeners(channels, listeners) + + // Remove channels from the listener map + this.removeFromListenerMap(_uuid) + + resolve() + } catch (error) { + reject(error) + } + }) + } + + this._listenerMap.set(_uuid, { + topics: new Set([...channels]), + listeners, + unsub, + }) + + return unsub + } + + private addChannels(channels: string[], events: string[]) { + return new Promise(async (resolve, reject) => { + try { + const execParams: ExecuteParams = { + method: 'chat.subscribe', + params: { + channels: channels.map((channel) => ({ + name: channel, + })), + events, + }, + } + + // @TODO: Do not subscribe if the user params are the same + + await this._client.execute(execParams) + resolve(undefined) + } catch (error) { + reject(error) + } + }) + } + + private removeChannels(channels: string[]) { + return new Promise(async (resolve, reject) => { + try { + const execParams: ExecuteParams = { + method: 'chat.unsubscribe', + params: { + channels: channels.map((channel) => ({ + name: channel, + })), + }, + } + + await this._client.execute(execParams) + resolve(undefined) + } catch (error) { + reject(error) + } + }) + } + + public publish(params: PubSubPublishParams) { + return new Promise((resolve, reject) => { + try { + const publish = this._client.execute({ + method: 'chat.publish', + params, + }) + resolve(publish) + } catch (error) { + reject(error) + } + }) + } +} diff --git a/packages/realtime-api/src/chat/Chat.test.ts b/packages/realtime-api/src/chat/Chat.test.ts new file mode 100644 index 000000000..f5cfc43fd --- /dev/null +++ b/packages/realtime-api/src/chat/Chat.test.ts @@ -0,0 +1,39 @@ +import { EventEmitter } from '@signalwire/core' +import { Chat } from './Chat' +import { createClient } from '../client/createClient' + +describe('Chat', () => { + let chat: Chat + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + //@ts-expect-error + chat = new Chat(swClientMock) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should have an event emitter', () => { + expect(chat['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onMessageReceived: 'chat.message', + onMemberJoined: 'chat.member.joined', + onMemberUpdated: 'chat.member.updated', + onMemberLeft: 'chat.member.left', + } + expect(chat['_eventMap']).toEqual(expectedEventMap) + }) +}) diff --git a/packages/realtime-api/src/chat/Chat.ts b/packages/realtime-api/src/chat/Chat.ts index 7820017aa..07d562dea 100644 --- a/packages/realtime-api/src/chat/Chat.ts +++ b/packages/realtime-api/src/chat/Chat.ts @@ -1,3 +1,50 @@ +import { + ChatMember, + ChatMessage, + EventEmitter, + ChatEvents, + Chat as ChatCore, +} from '@signalwire/core' +import { BaseChat, BaseChatListenOptions } from './BaseChat' +import { chatWorker } from './workers' +import { SWClient } from '../SWClient' + +interface ChatListenOptions extends BaseChatListenOptions { + onMessageReceived?: (message: ChatMessage) => unknown + onMemberJoined?: (member: ChatMember) => unknown + onMemberUpdated?: (member: ChatMember) => unknown + onMemberLeft?: (member: ChatMember) => unknown +} + +type ChatListenersKeys = keyof Omit + +export class Chat extends ChatCore.applyCommonMethods( + BaseChat +) { + private _chatEmitter = new EventEmitter() + protected _eventMap: Record = { + onMessageReceived: 'chat.message', + onMemberJoined: 'chat.member.joined', + onMemberUpdated: 'chat.member.updated', + onMemberLeft: 'chat.member.left', + } + + constructor(options: SWClient) { + super(options) + + this._client.runWorker('chatWorker', { + worker: chatWorker, + initialState: { + chatEmitter: this._chatEmitter, + }, + }) + } + + protected get emitter() { + return this._chatEmitter + } +} + export { ChatMember, ChatMessage } from '@signalwire/core' export type { ChatAction, @@ -34,5 +81,3 @@ export type { PubSubEventAction, PubSubPublishParams, } from '@signalwire/core' -export { ChatClientApiEvents, Client } from './ChatClient' -export type { ChatClientOptions } from './ChatClient' diff --git a/packages/realtime-api/src/chat/ChatClient.test.ts b/packages/realtime-api/src/chat/ChatClient.test.ts deleted file mode 100644 index 142221897..000000000 --- a/packages/realtime-api/src/chat/ChatClient.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import WS from 'jest-websocket-mock' -import { Client } from './ChatClient' - -jest.mock('uuid', () => { - return { - v4: jest.fn(() => 'mocked-uuid'), - } -}) - -describe('ChatClient', () => { - describe('Client', () => { - const host = 'ws://localhost:1234' - const token = '' - let server: WS - const authError = { - code: -32002, - message: - 'Authentication service failed with status ProtocolError, 401 Unauthorized: {}', - } - - beforeEach(async () => { - server = new WS(host, { jsonProtocol: true }) - server.on('connection', (socket: any) => { - socket.on('message', (data: any) => { - const parsedData = JSON.parse(data) - - if ( - parsedData.method === 'signalwire.connect' && - parsedData.params.authentication.token === '' - ) { - return socket.send( - JSON.stringify({ - jsonrpc: '2.0', - id: parsedData.id, - error: authError, - }) - ) - } - - socket.send( - JSON.stringify({ - jsonrpc: '2.0', - id: parsedData.id, - result: {}, - }) - ) - }) - }) - }) - - afterEach(() => { - WS.clean() - }) - - describe('Automatic connect', () => { - it('should automatically connect the underlying client', (done) => { - const chat = new Client({ - // @ts-expect-error - host, - project: 'some-project', - token, - }) - - chat.once('member.joined', () => {}) - - // @ts-expect-error - chat.session.on('session.connected', () => { - expect(server).toHaveReceivedMessages([ - { - jsonrpc: '2.0', - id: 'mocked-uuid', - method: 'signalwire.connect', - params: { - version: { major: 3, minor: 0, revision: 0 }, - authentication: { project: 'some-project', token: '' }, - }, - }, - ]) - - chat._session.disconnect() - - done() - }) - }) - }) - - it('should show an error if client.connect failed to connect', async () => { - const logger = { - error: jest.fn(), - info: jest.fn(), - trace: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - } - const chat = new Client({ - // @ts-expect-error - host, - project: 'some-project', - token: '', - logger: logger as any, - }) - - try { - await chat.subscribe('some-channel') - } catch (error) { - expect(error).toStrictEqual(new Error('Unauthorized')) - expect(logger.error).toHaveBeenNthCalledWith(1, 'Auth Error', { - code: -32002, - message: - 'Authentication service failed with status ProtocolError, 401 Unauthorized: {}', - }) - } - }) - }) -}) diff --git a/packages/realtime-api/src/chat/ChatClient.ts b/packages/realtime-api/src/chat/ChatClient.ts deleted file mode 100644 index ea8c2d705..000000000 --- a/packages/realtime-api/src/chat/ChatClient.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { - ChatContract, - ConsumerContract, - UserOptions, - Chat as ChatNamespace, -} from '@signalwire/core' -import { clientConnect, setupClient, RealtimeClient } from '../client/index' -import type { RealTimeChatApiEventsHandlerMapping } from '../types/chat' - -export interface ChatClientApiEvents - extends ChatNamespace.BaseChatApiEvents {} - -export interface ClientFullState extends ChatClient {} -interface ChatClient - extends Omit, - Omit, 'subscribe'> { - new (opts: ChatClientOptions): this - - /** @internal */ - _session: RealtimeClient -} -export interface ChatClientOptions - extends Omit { - token?: string -} - -type ClientMethods = Exclude -const INTERCEPTED_METHODS: ClientMethods[] = [ - 'subscribe', - 'publish', - 'getMessages', - 'getMembers', - 'getMemberState', - 'setMemberState', -] -const UNSUPPORTED_METHODS = ['getAllowedChannels', 'updateToken'] - -/** - * You can use instances of this class to control the chat and subscribe to its - * events. Please see {@link ChatClientApiEvents} for the full list of events - * you can subscribe to. - * - * @param options - {@link ChatClientOptions} - * - * @returns - {@link ChatClient} - * - * @example - * - * ```javascript - * const chatClient = new Chat.Client({ - * project: '', - * token: '' - * }) - * - * await chatClient.subscribe([ 'mychannel1', 'mychannel2' ]) - * - * chatClient.on('message', (message) => { - * console.log("Received", message.content, - * "on", message.channel, - * "at", message.publishedAt) - * }) - * - * await chatClient.publish({ - * channel: 'mychannel1', - * content: 'hello world' - * }) - * ``` - */ -const ChatClient = function (options?: ChatClientOptions) { - const { client, store } = setupClient(options) - const chat = ChatNamespace.createBaseChatObject({ - store, - }) - - const createInterceptor = (prop: K) => { - return async (...params: Parameters) => { - await clientConnect(client) - - // @ts-expect-error - return chat[prop](...params) - } - } - - const interceptors = { - _session: client, - disconnect: () => client.disconnect(), - } as const - - return new Proxy(chat, { - get(target: ChatClient, prop: keyof ChatClient, receiver: any) { - if (prop in interceptors) { - // @ts-expect-error - return interceptors[prop] - } - - // FIXME: types and _session check - if (prop !== '_session' && INTERCEPTED_METHODS.includes(prop)) { - return createInterceptor(prop) - } else if (UNSUPPORTED_METHODS.includes(prop)) { - return undefined - } - - // Always connect the underlying client if the user call a function on the Proxy - if (typeof target[prop] === 'function') { - clientConnect(client) - } - - 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?: ChatClientOptions): ChatClient } - -export { ChatClient as Client } diff --git a/packages/realtime-api/src/chat/workers/chatWorker.ts b/packages/realtime-api/src/chat/workers/chatWorker.ts new file mode 100644 index 000000000..1c6117e1a --- /dev/null +++ b/packages/realtime-api/src/chat/workers/chatWorker.ts @@ -0,0 +1,65 @@ +import { SagaIterator } from '@redux-saga/core' +import { Chat } from '../Chat' +import { + sagaEffects, + SDKWorker, + getLogger, + ChatAction, + toExternalJSON, + ChatMessage, + ChatMember, + SDKActions, +} from '@signalwire/core' +import { prefixEvent } from '../../utils/internals' + +export const chatWorker: SDKWorker = function* (options): SagaIterator { + getLogger().trace('chatWorker started') + const { + channels: { swEventChannel }, + initialState: { chatEmitter }, + } = options + + function* worker(action: ChatAction) { + const { type, payload } = action + + switch (type) { + case 'chat.channel.message': { + const { channel, message } = payload + const externalJSON = toExternalJSON({ + ...message, + channel, + }) + const chatMessage = new ChatMessage(externalJSON) + + chatEmitter.emit(prefixEvent(channel, 'chat.message'), chatMessage) + break + } + case 'chat.member.joined': + case 'chat.member.updated': + case 'chat.member.left': { + const { member, channel } = payload + const externalJSON = toExternalJSON(member) + const chatMember = new ChatMember(externalJSON) + + chatEmitter.emit(prefixEvent(channel, type), chatMember) + break + } + default: + getLogger().warn(`Unknown chat event: "${type}"`, payload) + break + } + } + + const isChatEvent = (action: SDKActions) => action.type.startsWith('chat.') + + while (true) { + const action: ChatAction = yield sagaEffects.take( + swEventChannel, + isChatEvent + ) + + yield sagaEffects.fork(worker, action) + } + + getLogger().trace('chatWorker ended') +} diff --git a/packages/realtime-api/src/chat/workers/index.ts b/packages/realtime-api/src/chat/workers/index.ts new file mode 100644 index 000000000..c872556cb --- /dev/null +++ b/packages/realtime-api/src/chat/workers/index.ts @@ -0,0 +1 @@ +export * from './chatWorker' diff --git a/packages/realtime-api/src/index.ts b/packages/realtime-api/src/index.ts index 0048a5fe9..740b34b76 100644 --- a/packages/realtime-api/src/index.ts +++ b/packages/realtime-api/src/index.ts @@ -50,50 +50,6 @@ */ export * as Video from './video/Video' -/** - * Access the Chat API Consumer. You can instantiate a {@link Chat.Client} to - * subscribe to Chat events. Please check {@link Chat.ChatClientApiEvents} - * for the full list of events that a {@link Chat.Client} can subscribe to. - * - * @example - * - * The following example logs the messages sent to the "welcome" channel. - * - * ```javascript - * const chatClient = new Chat.Client({ - * project: '', - * token: '' - * }) - * - * chatClient.on('message', m => console.log(m)) - * - * await chatClient.subscribe("welcome") - * ``` - */ -export * as Chat from './chat/Chat' - -/** - * Access the PubSub API Consumer. You can instantiate a {@link PubSub.Client} to - * subscribe to PubSub events. Please check {@link PubSub.PubSubClientApiEvents} - * for the full list of events that a {@link PubSub.Client} can subscribe to. - * - * @example - * - * The following example logs the messages sent to the "welcome" channel. - * - * ```javascript - * const pubSubClient = new PubSub.Client({ - * project: '', - * token: '' - * }) - * - * pubSubClient.on('message', m => console.log(m)) - * - * await pubSubClient.subscribe("welcome") - * ``` - */ -export * as PubSub from './pubSub/PubSub' - /** @ignore */ export * from './configure' @@ -162,4 +118,4 @@ export * as Messaging from './messaging/Messaging' */ export * as Voice from './voice/Voice' -export { SignalWire } from './SignalWire' +export * from './SignalWire' diff --git a/packages/realtime-api/src/pubSub/PubSub.test.ts b/packages/realtime-api/src/pubSub/PubSub.test.ts new file mode 100644 index 000000000..1cb8a2a6e --- /dev/null +++ b/packages/realtime-api/src/pubSub/PubSub.test.ts @@ -0,0 +1,36 @@ +import { EventEmitter } from '@signalwire/core' +import { PubSub } from './PubSub' +import { createClient } from '../client/createClient' + +describe('PubSub', () => { + let pubSub: PubSub + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + //@ts-expect-error + pubSub = new PubSub(swClientMock) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should have an event emitter', () => { + expect(pubSub['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onMessageReceived: 'chat.message', + } + expect(pubSub['_eventMap']).toEqual(expectedEventMap) + }) +}) diff --git a/packages/realtime-api/src/pubSub/PubSub.ts b/packages/realtime-api/src/pubSub/PubSub.ts index 5b6b2a7fb..e418f47d7 100644 --- a/packages/realtime-api/src/pubSub/PubSub.ts +++ b/packages/realtime-api/src/pubSub/PubSub.ts @@ -1,4 +1,42 @@ -export { PubSubMessage } from '@signalwire/core' -export { Client } from './PubSubClient' +import { + EventEmitter, + PubSubMessageEventName, + PubSubNamespace, + PubSubMessage, +} from '@signalwire/core' +import { SWClient } from '../SWClient' +import { pubSubWorker } from './workers' +import { BaseChat, BaseChatListenOptions } from '../chat/BaseChat' + +interface PubSubListenOptions extends BaseChatListenOptions { + onMessageReceived?: (message: PubSubMessage) => unknown +} + +type PubSubListenersKeys = keyof Omit + +export class PubSub extends BaseChat { + private _pubSubEmitter = new EventEmitter() + protected _eventMap: Record< + PubSubListenersKeys, + `${PubSubNamespace}.${PubSubMessageEventName}` + > = { + onMessageReceived: 'chat.message', + } + + constructor(options: SWClient) { + super(options) + + this._client.runWorker('pubSubWorker', { + worker: pubSubWorker, + initialState: { + pubSubEmitter: this._pubSubEmitter, + }, + }) + } + + protected get emitter() { + return this._pubSubEmitter + } +} + export type { PubSubMessageContract } from '@signalwire/core' -export type { PubSubClientApiEvents } from './PubSubClient' diff --git a/packages/realtime-api/src/pubSub/PubSubClient.ts b/packages/realtime-api/src/pubSub/PubSubClient.ts deleted file mode 100644 index ee3177c42..000000000 --- a/packages/realtime-api/src/pubSub/PubSubClient.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - ConsumerContract, - UserOptions, - PubSub as PubSubNamespace, - PubSubContract, -} from '@signalwire/core' -import { clientConnect, setupClient, RealtimeClient } from '../client/index' -import type { RealTimePubSubApiEventsHandlerMapping } from '../types/pubSub' - -export interface PubSubClientApiEvents - extends PubSubNamespace.BasePubSubApiEvents {} - -export interface ClientFullState extends PubSubClient {} -interface PubSubClient - extends Omit, - Omit< - ConsumerContract, - 'subscribe' - > { - new (opts: PubSubClientOptions): this - - /** @internal */ - _session: RealtimeClient -} - -interface PubSubClientOptions - extends Omit { - token?: string -} - -type ClientMethods = Exclude -const INTERCEPTED_METHODS: ClientMethods[] = ['subscribe', 'publish'] -const UNSUPPORTED_METHODS = ['getAllowedChannels', 'updateToken'] - -/** - * Creates a new PubSub client. - * - * @param options - {@link PubSubClientOptions} - * - * @example - * - * ```js - * import { PubSub } from '@signalwire/realtime-api' - * - * const pubSubClient = new PubSub.Client({ - * project: '', - * token: '' - * }) - * ``` - */ -const PubSubClient = function (options?: PubSubClientOptions) { - const { client, store } = setupClient(options) - const pubSub = PubSubNamespace.createBasePubSubObject({ - store, - }) - - const createInterceptor = (prop: K) => { - return async (...params: Parameters) => { - await clientConnect(client) - - // @ts-expect-error - return pubSub[prop](...params) - } - } - - const interceptors = { - _session: client, - disconnect: () => client.disconnect(), - } as const - - return new Proxy(pubSub, { - get(target: PubSubClient, prop: keyof PubSubClient, receiver: any) { - if (prop in interceptors) { - // @ts-expect-error - return interceptors[prop] - } - - // FIXME: types and _session check - if (prop !== '_session' && INTERCEPTED_METHODS.includes(prop)) { - return createInterceptor(prop) - } else if (UNSUPPORTED_METHODS.includes(prop)) { - return undefined - } - - // Always connect the underlying client if the user call a function on the Proxy - if (typeof target[prop] === 'function') { - clientConnect(client) - } - - 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?: PubSubClientOptions): PubSubClient } - -export { PubSubClient as Client } diff --git a/packages/realtime-api/src/pubSub/workers/index.ts b/packages/realtime-api/src/pubSub/workers/index.ts new file mode 100644 index 000000000..439bd7018 --- /dev/null +++ b/packages/realtime-api/src/pubSub/workers/index.ts @@ -0,0 +1 @@ +export * from './pubSubWorker' diff --git a/packages/realtime-api/src/pubSub/workers/pubSubWorker.ts b/packages/realtime-api/src/pubSub/workers/pubSubWorker.ts new file mode 100644 index 000000000..fbf0772e5 --- /dev/null +++ b/packages/realtime-api/src/pubSub/workers/pubSubWorker.ts @@ -0,0 +1,66 @@ +import { SagaIterator } from '@redux-saga/core' +import { PubSub } from '../PubSub' +import { + sagaEffects, + PubSubEventAction, + SDKWorker, + getLogger, + PubSubMessage, + toExternalJSON, +} from '@signalwire/core' +import { prefixEvent } from '../../utils/internals' + +export const pubSubWorker: SDKWorker = function* ( + options +): SagaIterator { + getLogger().trace('pubSubWorker started') + const { + channels: { swEventChannel }, + initialState: { pubSubEmitter }, + } = options + + function* worker(action: PubSubEventAction) { + const { type, payload } = action + + switch (type) { + case 'chat.channel.message': { + const { + channel, + /** + * Since we're using the same event as `Chat` + * the payload comes with a `member` prop. To + * avoid confusion (since `PubSub` doesn't + * have members) we'll remove it from the + * payload sent to the end user. + */ + // @ts-expect-error + message: { member, ...restMessage }, + } = payload + const externalJSON = toExternalJSON({ + ...restMessage, + channel, + }) + const pubSubMessage = new PubSubMessage(externalJSON) + + pubSubEmitter.emit(prefixEvent(channel, 'chat.message'), pubSubMessage) + break + } + default: + getLogger().warn(`Unknown pubsub event: "${type}"`, payload) + break + } + } + + const isPubSubEvent = (action: any) => action.type.startsWith('chat.') + + while (true) { + const action: PubSubEventAction = yield sagaEffects.take( + swEventChannel, + isPubSubEvent + ) + + yield sagaEffects.fork(worker, action) + } + + getLogger().trace('pubSubWorker ended') +} diff --git a/packages/realtime-api/src/task/Task.test.ts b/packages/realtime-api/src/task/Task.test.ts new file mode 100644 index 000000000..b22f550e1 --- /dev/null +++ b/packages/realtime-api/src/task/Task.test.ts @@ -0,0 +1,78 @@ +import { request } from 'node:https' +import { EventEmitter } from '@signalwire/core' +import { Task, PATH } from './Task' +import { createClient } from '../client/createClient' + +jest.mock('node:https', () => { + return { + request: jest.fn().mockImplementation((_, callback) => { + callback({ statusCode: 204 }) + }), + } +}) + +describe('Task', () => { + let task: Task + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + const topic = 'jest-topic' + const message = { data: 'Hello from jest!' } + + beforeEach(() => { + // @ts-expect-error + task = new Task(swClientMock) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should have an event emitter', () => { + expect(task['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onTaskReceived: 'task.received', + } + expect(task['_eventMap']).toEqual(expectedEventMap) + }) + + it('should throw an error when sending a task with invalid options', async () => { + // Create a new instance of Task with invalid options + const invalidTask = new Task({ + // @ts-expect-error + userOptions: {}, + client: createClient(userOptions), + }) + + await expect(async () => { + await invalidTask.send({ topic, message }) + }).rejects.toThrowError('Invalid options: project and token are required!') + }) + + it('should send a task', async () => { + await task.send({ topic, message }) + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: PATH, + host: userOptions.host, + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'Content-Length': expect.any(Number), + Authorization: expect.stringContaining('Basic'), + }), + }), + expect.any(Function) + ) + }) +}) diff --git a/packages/realtime-api/src/task/Task.ts b/packages/realtime-api/src/task/Task.ts index cd734d2cb..fe06d09ea 100644 --- a/packages/realtime-api/src/task/Task.ts +++ b/packages/realtime-api/src/task/Task.ts @@ -8,7 +8,7 @@ import { SWClient } from '../SWClient' import { taskWorker } from './workers' import { ListenOptions, BaseNamespace } from '../BaseNamespace' -const PATH = '/api/relay/rest/tasks' +export const PATH = '/api/relay/rest/tasks' const HOST = 'relay.signalwire.com' interface TaskListenOptions extends ListenOptions { @@ -34,7 +34,7 @@ export class Task extends BaseNamespace { }) } - get emitter() { + protected get emitter() { return this._taskEmitter } From 4dd79de052848d1a19698b0b7a6ba548b2928b49 Mon Sep 17 00:00:00 2001 From: Ammar Ansari Date: Fri, 8 Sep 2023 17:43:20 +0200 Subject: [PATCH 03/15] Voice API with new interface (#855) * Task namespace with new interface * taskworker include * extend task from applyeventlisteners * base namespace class to handle the listen method * topic attach to event name * type update * remove older Task api * refactor and e2e test case * Voice API with new interface * handle call.playback listeners with all the methods * run workers through methods * playback events with e2e test cases * remove old call playback class * fix test file names * improve playback tests * rename voice playback tests * voice call record events with e2e test cases * fix playback and record types * implement call.prompt with playback * test utility add * e2e test cases for call prompt * call collect with e2e test cases * Call tap with e2e test cases * Call Detect API with e2e test cases * remove old voice detect test * voice call connect api * update voice pass test with new interface * improve base and listener class for instances * include unit test cases for call apis * voice stack test update * call connect implement with e2e test case * enable ws logs for task test * update voice playground with the new interface * minimize race condition in playback and recording e2e test cases * minimize race condition for collect and detect e2e * improve call state events logic * fix voice unit test * enable ws logs for voice test * fix call connect bug * remove unused voice calling worker * enable ws logs for voice call collect * improve collect and detect e2e test cases * include changeset * Update packages/realtime-api/src/BaseNamespace.ts Co-authored-by: Edoardo Gallo * Update packages/realtime-api/src/ListenSubscriber.ts Co-authored-by: Edoardo Gallo * Update packages/realtime-api/src/task/Task.ts Co-authored-by: Edoardo Gallo * add addToListenerMap method for consistency * Revert "Update packages/realtime-api/src/ListenSubscriber.ts" This reverts commit 69df53639a61dbfeefc471edb3a5da8db860b0c1. * update payload set and extends base calls with EventEmitter * protect event emitter methods * improve call collect test * improve voice record e2e test --------- Co-authored-by: Edoardo Gallo --- .changeset/cuddly-carrots-bathe.md | 95 +++ internal/e2e-realtime-api/src/task.test.ts | 82 +- internal/e2e-realtime-api/src/utils.ts | 116 ++- internal/e2e-realtime-api/src/voice.test.ts | 420 +++++----- .../e2e-realtime-api/src/voiceCollect.test.ts | 153 ---- .../src/voiceCollectAllListeners.test.ts | 191 +++++ .../src/voiceCollectCallListeners.test.ts | 158 ++++ .../src/voiceCollectDialListeners.test.ts | 155 ++++ .../src/voiceCollectListeners.test.ts | 209 +++++ .../e2e-realtime-api/src/voiceDetect.test.ts | 128 --- .../src/voiceDetectAllListeners.test.ts | 183 ++++ .../src/voiceDetectCallListeners.test.ts | 117 +++ .../src/voiceDetectDialListeners.test.ts | 112 +++ .../src/voiceDetectListeners.test.ts | 123 +++ .../e2e-realtime-api/src/voicePass.test.ts | 116 ++- .../src/voicePlayback.test.ts | 169 ---- .../src/voicePlaybackAllListeners.test.ts | 156 ++++ .../src/voicePlaybackCallListeners.test.ts | 93 +++ .../src/voicePlaybackDialListeners.test.ts | 90 ++ .../src/voicePlaybackListeners.test.ts | 113 +++ .../src/voicePlaybackMultiple.test.ts | 272 +++--- .../e2e-realtime-api/src/voicePrompt.test.ts | 161 ---- .../src/voicePromptAllListeners.test.ts | 205 +++++ .../src/voicePromptCallListeners.test.ts | 134 +++ .../src/voicePromptDialListeners.test.ts | 136 +++ .../src/voicePromptListeners.test.ts | 164 ++++ .../src/voiceRecordAllListeners.test.ts | 158 ++++ .../src/voiceRecordCallListeners.test.ts | 88 ++ .../src/voiceRecordDialListeners.test.ts | 85 ++ .../src/voiceRecordListeners.test.ts | 106 +++ .../src/voiceRecordMultiple.test.ts | 168 ++-- .../src/voiceRecording.test.ts | 191 ----- .../e2e-realtime-api/src/voiceTap.test.ts | 111 --- .../src/voiceTapAllListeners.test.ts | 126 +++ .../src/voice-dtmf-loop/index.ts | 15 +- .../src/voice-inbound/index.ts | 54 +- .../src/voice/index.ts | 472 ++++++----- internal/stack-tests/src/voice/app.ts | 19 +- packages/core/src/types/voiceCall.ts | 29 +- .../realtime-api/src/BaseNamespace.test.ts | 30 +- packages/realtime-api/src/BaseNamespace.ts | 70 +- .../realtime-api/src/ListenSubscriber.test.ts | 127 +++ packages/realtime-api/src/ListenSubscriber.ts | 135 +++ packages/realtime-api/src/SWClient.ts | 9 + packages/realtime-api/src/SignalWire.ts | 1 + .../realtime-api/src/chat/BaseChat.test.ts | 10 +- packages/realtime-api/src/chat/BaseChat.ts | 37 +- packages/realtime-api/src/chat/Chat.ts | 13 +- .../src/chat/workers/chatWorker.ts | 19 +- packages/realtime-api/src/index.ts | 49 +- packages/realtime-api/src/pubSub/PubSub.ts | 19 +- .../src/pubSub/workers/pubSubWorker.ts | 16 +- packages/realtime-api/src/task/Task.ts | 20 +- .../src/task/workers/taskWorker.ts | 17 +- packages/realtime-api/src/types/chat.ts | 4 + packages/realtime-api/src/types/pubSub.ts | 4 + packages/realtime-api/src/types/voice.ts | 331 +++++++- packages/realtime-api/src/voice/Call.test.ts | 116 +++ packages/realtime-api/src/voice/Call.ts | 780 ++++++++++-------- .../src/voice/CallCollect.test.ts | 140 +++- .../realtime-api/src/voice/CallCollect.ts | 94 +-- .../realtime-api/src/voice/CallDetect.test.ts | 113 ++- packages/realtime-api/src/voice/CallDetect.ts | 80 +- .../src/voice/CallPlayback.test.ts | 161 ++-- .../realtime-api/src/voice/CallPlayback.ts | 93 +-- .../realtime-api/src/voice/CallPrompt.test.ts | 138 +++- packages/realtime-api/src/voice/CallPrompt.ts | 99 +-- .../src/voice/CallRecording.test.ts | 160 ++-- .../realtime-api/src/voice/CallRecording.ts | 107 +-- .../realtime-api/src/voice/CallTap.test.ts | 137 ++- packages/realtime-api/src/voice/CallTap.ts | 79 +- packages/realtime-api/src/voice/Voice.test.ts | 63 ++ packages/realtime-api/src/voice/Voice.ts | 359 ++++---- .../realtime-api/src/voice/VoiceClient.ts | 111 --- .../voice/workers/VoiceCallSendDigitWorker.ts | 69 +- .../handlers/callConnectEventsHandler.ts | 78 ++ .../workers/handlers/callDialEventsHandler.ts | 31 + .../callStateEventsHandler.ts} | 38 +- .../src/voice/workers/handlers/index.ts | 3 + .../realtime-api/src/voice/workers/index.ts | 2 - .../voice/workers/voiceCallCollectWorker.ts | 170 ++-- .../voice/workers/voiceCallConnectWorker.ts | 110 ++- .../voice/workers/voiceCallDetectWorker.ts | 131 +-- .../src/voice/workers/voiceCallDialWorker.ts | 82 +- .../src/voice/workers/voiceCallPlayWorker.ts | 144 ++-- .../voice/workers/voiceCallReceiveWorker.ts | 96 ++- .../voice/workers/voiceCallRecordWorker.ts | 121 ++- .../src/voice/workers/voiceCallTapWorker.ts | 99 ++- .../src/voice/workers/voiceCallingWorker.ts | 127 --- 89 files changed, 7037 insertions(+), 3678 deletions(-) create mode 100644 .changeset/cuddly-carrots-bathe.md delete mode 100644 internal/e2e-realtime-api/src/voiceCollect.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollectListeners.test.ts delete mode 100644 internal/e2e-realtime-api/src/voiceDetect.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceDetectListeners.test.ts delete mode 100644 internal/e2e-realtime-api/src/voicePlayback.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts delete mode 100644 internal/e2e-realtime-api/src/voicePrompt.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePromptListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceRecordListeners.test.ts delete mode 100644 internal/e2e-realtime-api/src/voiceRecording.test.ts delete mode 100644 internal/e2e-realtime-api/src/voiceTap.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts create mode 100644 packages/realtime-api/src/ListenSubscriber.test.ts create mode 100644 packages/realtime-api/src/ListenSubscriber.ts create mode 100644 packages/realtime-api/src/voice/Call.test.ts create mode 100644 packages/realtime-api/src/voice/Voice.test.ts delete mode 100644 packages/realtime-api/src/voice/VoiceClient.ts create mode 100644 packages/realtime-api/src/voice/workers/handlers/callConnectEventsHandler.ts create mode 100644 packages/realtime-api/src/voice/workers/handlers/callDialEventsHandler.ts rename packages/realtime-api/src/voice/workers/{voiceCallStateWorker.ts => handlers/callStateEventsHandler.ts} (53%) create mode 100644 packages/realtime-api/src/voice/workers/handlers/index.ts delete mode 100644 packages/realtime-api/src/voice/workers/voiceCallingWorker.ts diff --git a/.changeset/cuddly-carrots-bathe.md b/.changeset/cuddly-carrots-bathe.md new file mode 100644 index 000000000..5693e7742 --- /dev/null +++ b/.changeset/cuddly-carrots-bathe.md @@ -0,0 +1,95 @@ +--- +'@signalwire/realtime-api': major +'@signalwire/core': major +--- + +New interface for Voice APIs + +The new interface contains a single SW client with Chat and PubSub namespaces +```javascript +import { SignalWire } from '@signalwire/realtime-api' + +(async () => { + const client = await SignalWire({ + host: process.env.HOST, + project: process.env.PROJECT, + token: process.env.TOKEN, + }) + + const unsubVoiceOffice = await client.voice.listen({ + topics: ['office'], + onCallReceived: async (call) => { + try { + await call.answer() + + const unsubCall = await call.listen({ + onStateChanged: (call) => {}, + onPlaybackUpdated: (playback) => {}, + onRecordingStarted: (recording) => {}, + onCollectInputStarted: (collect) => {}, + onDetectStarted: (detect) => {}, + onTapStarted: (tap) => {}, + onPromptEnded: (prompt) => {} + // ... more call listeners can be attached here + }) + + // ... + + await unsubCall() + } catch (error) { + console.error('Error answering inbound call', error) + } + } + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + onStateChanged: async (call) => { + // When call ends; unsubscribe all listeners and disconnect the client + if (call.state === 'ended') { + await unsubVoiceOffice() + + await unsubVoiceHome() + + await unsubPlay() + + client.disconnect() + } + }, + onPlaybackStarted: (playback) => {}, + }, + }) + + const unsubCall = await call.listen({ + onPlaybackStarted: (playback) => {}, + onPlaybackEnded: (playback) => { + // This will never run since we unsubscribe this listener before the playback stops + }, + }) + + // Play an audio + const play = await call.playAudio({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + listen: { + onStarted: async (playback) => { + await unsubCall() + + await play.stop() + }, + }, + }) + + const unsubPlay = await play.listen({ + onStarted: (playback) => { + // This will never run since this listener is attached after the call.play has started + }, + onEnded: async (playback) => { + await call.hangup() + }, + }) + +}) +``` \ No newline at end of file diff --git a/internal/e2e-realtime-api/src/task.test.ts b/internal/e2e-realtime-api/src/task.test.ts index e023d3447..cb5149ccf 100644 --- a/internal/e2e-realtime-api/src/task.test.ts +++ b/internal/e2e-realtime-api/src/task.test.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' import { createTestRunner } from './utils' @@ -9,57 +10,76 @@ const handler = () => { host: process.env.RELAY_HOST || 'relay.swire.io', project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, }) const homeTopic = `home-${randomUUID()}` const officeTopic = `office-${randomUUID()}` const firstPayload = { - id: Date.now(), + id: 1, topic: homeTopic, } const secondPayload = { - id: Date.now(), + id: 2, topic: homeTopic, } const thirdPayload = { - id: Date.now(), + id: 3, topic: officeTopic, } - let counter = 0 + let unsubHomeOfficeCount = 0 + const unsubHomeOffice = await client.task.listen({ topics: [homeTopic, officeTopic], - onTaskReceived: (payload) => { + onTaskReceived: async (payload) => { if ( payload.topic !== homeTopic || - payload.id !== firstPayload.id || - payload.id !== secondPayload.id || - counter > 3 + (payload.id !== firstPayload.id && payload.id !== secondPayload.id) ) { - console.error('Invalid payload on `home` context', payload) - return reject(4) + tap.notOk( + payload, + "Message received on wrong ['home', 'office'] listener" + ) + } + + tap.ok(payload, 'Message received on ["home", "office"] topics') + unsubHomeOfficeCount++ + + if (unsubHomeOfficeCount === 2) { + await unsubHomeOffice() + + // This message should not reach the listener since we have unsubscribed + await client.task.send({ + topic: homeTopic, + message: secondPayload, + }) + + await client.task.send({ + topic: officeTopic, + message: thirdPayload, + }) } - counter++ }, }) const unsubOffice = await client.task.listen({ topics: [officeTopic], - onTaskReceived: (payload) => { - if ( - payload.topic !== officeTopic || - payload.id !== thirdPayload.id || - counter > 3 - ) { - console.error('Invalid payload on `home` context', payload) - return reject(4) + onTaskReceived: async (payload) => { + if (payload.topic !== officeTopic || payload.id !== thirdPayload.id) { + tap.notOk(payload, "Message received on wrong ['office'] listener") } - counter++ - if (counter === 3) { - return resolve(0) - } + tap.ok(payload, 'Message received on ["office"] topics') + + await unsubOffice() + + client.disconnect() + + return resolve(0) }, }) @@ -72,21 +92,6 @@ const handler = () => { topic: homeTopic, message: secondPayload, }) - - await unsubHomeOffice() - - // This message should not reach the listener - await client.task.send({ - topic: homeTopic, - message: secondPayload, - }) - - await client.task.send({ - topic: officeTopic, - message: thirdPayload, - }) - - await unsubOffice() } catch (error) { console.log('Task test error', error) reject(error) @@ -98,6 +103,7 @@ async function main() { const runner = createTestRunner({ name: 'Task E2E', testHandler: handler, + executionTime: 30_000, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/utils.ts b/internal/e2e-realtime-api/src/utils.ts index d58ac0336..59f29fb18 100644 --- a/internal/e2e-realtime-api/src/utils.ts +++ b/internal/e2e-realtime-api/src/utils.ts @@ -68,7 +68,7 @@ export const createTestRunner = ({ if (exitCode === 0) { console.log(`Test Runner ${name} Passed!`) if (!exitOnSuccess) { - return; + return } } else { console.log(`Test Runner ${name} finished with exitCode: ${exitCode}`) @@ -87,7 +87,7 @@ export const createTestRunner = ({ name: `d-app-${uuid}`, identifier: uuid, call_handler: 'relay_context', - call_relay_context:`d-app-ctx-${uuid}`, + call_relay_context: `d-app-ctx-${uuid}`, }) } const exitCode = await testHandler(params) @@ -261,3 +261,115 @@ const deleteDomainApp = ({ id }: DeleteDomainAppParams): Promise => { req.end() }) } + +export const CALL_PROPS = [ + 'id', + 'callId', + 'nodeId', + 'state', + 'callState', + // 'tag', // Inbound calls does not have tags + 'device', + 'type', + 'from', + 'to', + 'headers', + 'active', + 'connected', + 'direction', + // 'context', // Outbound calls do not have context + // 'connectState', // Undefined unless peer call + // 'peer', // Undefined unless peer call + 'hangup', + 'pass', + 'answer', + 'play', + 'playAudio', + 'playSilence', + 'playRingtone', + 'playTTS', + 'record', + 'recordAudio', + 'prompt', + 'promptAudio', + 'promptRingtone', + 'promptTTS', + 'sendDigits', + 'tap', + 'tapAudio', + 'connect', + 'connectPhone', + 'connectSip', + 'disconnect', + 'waitForDisconnected', + 'disconnected', + 'detect', + 'amd', + 'detectFax', + 'detectDigit', + 'collect', + 'waitFor', +] + +export const CALL_PLAYBACK_PROPS = [ + 'id', + 'callId', + 'nodeId', + 'controlId', + 'state', + 'pause', + 'resume', + 'stop', + 'setVolume', + 'ended', +] + +export const CALL_RECORD_PROPS = [ + 'id', + 'callId', + 'nodeId', + 'controlId', + 'state', + // 'url', // Sometimes server does not return it + 'record', + 'stop', + 'ended', +] + +export const CALL_PROMPT_PROPS = [ + 'id', + 'callId', + 'nodeId', + 'controlId', + 'stop', + 'setVolume', + 'ended', +] + +export const CALL_COLLECT_PROPS = [ + 'id', + 'callId', + 'nodeId', + 'controlId', + 'stop', + 'startInputTimers', + 'ended', +] + +export const CALL_TAP_PROPS = [ + 'id', + 'callId', + 'nodeId', + 'controlId', + 'stop', + 'ended', +] + +export const CALL_DETECT_PROPS = [ + 'id', + 'callId', + 'nodeId', + 'controlId', + 'stop', + 'ended', +] diff --git a/internal/e2e-realtime-api/src/voice.test.ts b/internal/e2e-realtime-api/src/voice.test.ts index 3a6cf1bb4..d62ed8f29 100644 --- a/internal/e2e-realtime-api/src/voice.test.ts +++ b/internal/e2e-realtime-api/src/voice.test.ts @@ -1,5 +1,5 @@ import tap from 'tap' -import { Voice } from '@signalwire/realtime-api' +import { SignalWire, Voice } from '@signalwire/realtime-api' import { type TestHandler, createTestRunner, @@ -10,220 +10,205 @@ const handler: TestHandler = ({ domainApp }) => { if (!domainApp) { throw new Error('Missing domainApp') } - return new Promise(async (resolve, reject) => { - const client = new Voice.Client({ - host: process.env.RELAY_HOST, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - topics: [domainApp.call_relay_context], - // logLevel: "trace", - debug: { - logWsTraffic: true, - }, - }) - - let waitForInboundAnswerResolve: (value: void) => void - const waitForInboundAnswer = new Promise((resolve) => { - waitForInboundAnswerResolve = resolve - }) - let waitForPeerAnswerResolve: (value: void) => void - const waitForPeerAnswer = new Promise((resolve) => { - waitForPeerAnswerResolve = resolve - }) - let waitForSendDigitResolve - const waitForSendDigit = new Promise((resolve) => { - waitForSendDigitResolve = resolve - }) - let waitForPromptStartResolve: (value: void) => void - const waitForPromptStart = new Promise((resolve) => { - waitForPromptStartResolve = resolve - }) - let waitForPeerSendDigitResolve - const waitForPeerSendDigit = new Promise((resolve) => { - waitForPeerSendDigitResolve = resolve - }) - - let callsReceived = new Set() - - client.on('call.received', async (call) => { - callsReceived.add(call.id) - console.log( - `Got call number: ${callsReceived.size}`, - call.id, - call.from, - call.to, - call.direction - ) - try { - tap.equal(call.state, 'created', 'Inbound call state is "created"') - const resultAnswer = await call.answer() - tap.equal(call.state, 'answered', 'Inbound call state is "answered"') - tap.ok(resultAnswer.id, 'Inboud call answered') - tap.equal( - call.id, - resultAnswer.id, - 'Call answered gets the same instance' - ) - - if (callsReceived.size === 2) { - // Resolve the 2nd inbound call promise to inform the caller (inbound) - waitForPeerAnswerResolve() - console.log(`Sending digits from call: ${call.id}`) - - const sendDigitResult = await call.sendDigits('1#') - tap.equal( - call.id, - sendDigitResult.id, - 'Peer - sendDigit returns the same instance' - ) - - // Resolve the send digit promise to inform the caller - waitForPeerSendDigitResolve() - return - } - - // Resolve the 1st inbound call promise to inform the caller (outbound) - waitForInboundAnswerResolve() - - const recording = await call.recordAudio({ - direction: 'speak', - inputSensitivity: 60, - }) - tap.ok(recording.id, 'Recording started') - tap.equal( - recording.state, - 'recording', - 'Recording state is "recording"' - ) - - const playlist = new Voice.Playlist({ volume: 2 }).add( - Voice.Playlist.TTS({ - text: 'Message is getting recorded', - }) - ) - const playback = await call.play(playlist) - tap.ok(playback.id, 'Playback') - - // console.log('Waiting for Playback to end') - const playbackEndedResult = await playback.ended() - tap.equal(playback.id, playbackEndedResult.id, 'Instances are the same') - tap.equal( - playbackEndedResult.state, - 'finished', - 'Playback state is "finished"' - ) - tap.pass('Playback ended') - - console.log('Stopping the recording') - recording.stop() - const recordingEndedResult = await recording.ended() - tap.equal( - recordingEndedResult.state, - 'finished', - 'Recording state is "finished"' - ) - - call.on('prompt.started', (p) => { - tap.ok(p.id, 'Prompt has started') - }) - call.on('prompt.ended', (p) => { - tap.ok(p.id, 'Prompt has ended') - }) + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) - const prompt = await call.prompt({ - playlist: new Voice.Playlist({ volume: 1.0 }).add( - Voice.Playlist.TTS({ - text: 'Welcome to SignalWire! Please enter your 4 digits PIN', + let outboundCall: Voice.Call + let callsReceived = new Set() + + const unsubVoice = await client.voice.listen({ + topics: [domainApp.call_relay_context], + onCallReceived: async (call) => { + try { + callsReceived.add(call.id) + console.log( + `Got call number: ${callsReceived.size}`, + call.id, + call.from, + call.to, + call.direction + ) + + tap.equal(call.state, 'created', 'Inbound call state is "created"') + const resultAnswer = await call.answer() + tap.equal( + call.state, + 'answered', + 'Inbound call state is "answered"' + ) + tap.ok(resultAnswer.id, 'Inboud call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Call answered gets the same instance' + ) + + if (callsReceived.size === 2) { + const sendDigitResult = await call.sendDigits('1#') + tap.equal( + call.id, + sendDigitResult.id, + 'Peer - SendDigit returns the same instance' + ) + + return + } + + const recording = await call.recordAudio({ + direction: 'speak', + inputSensitivity: 60, + }) + tap.ok(recording.id, 'Recording started') + tap.equal( + recording.state, + 'recording', + 'Recording state is "recording"' + ) + + const playlist = new Voice.Playlist({ volume: 2 }).add( + Voice.Playlist.TTS({ + text: 'Message is getting recorded', + }) + ) + const playback = await call.play({ playlist }) + tap.equal(playback.state, 'playing', 'Playback state is "playing"') + + const playbackEndedResult = await playback.ended() + tap.equal( + playback.id, + playbackEndedResult.id, + 'Playback instances are the same' + ) + tap.equal( + playbackEndedResult.state, + 'finished', + 'Playback state is "finished"' + ) + tap.pass('Playback ended') + + console.log('Stopping the recording') + recording.stop() + const recordingEndedResult = await recording.ended() + tap.equal( + recordingEndedResult.state, + 'finished', + 'Recording state is "finished"' + ) + + const prompt = await call.prompt({ + playlist: new Voice.Playlist({ volume: 1.0 }).add( + Voice.Playlist.TTS({ + text: 'Welcome to SignalWire! Please enter your 4 digits PIN', + }) + ), + digits: { + max: 4, + digitTimeout: 100, + terminators: '#', + }, + listen: { + onStarted: async (p) => { + tap.ok(p.id, 'Prompt has started') + + // Send digits from the outbound call + const sendDigitResult = await outboundCall.sendDigits( + '1w2w3w#' + ) + tap.equal( + outboundCall.id, + sendDigitResult.id, + 'OutboundCall - SendDigit returns the same instance' + ) + }, + onEnded: (p) => { + tap.ok(p.id, 'Prompt has ended') + }, + }, }) - ), - digits: { - max: 4, - digitTimeout: 100, - terminators: '#', - }, - }) - - // Resolve the prompt start promise to let the caller know - waitForPromptStartResolve() - - // Wait until the caller send digits - await waitForSendDigit - - const promptEndedResult = await prompt.ended() - tap.equal(prompt.id, promptEndedResult.id, 'Instances are the same') - tap.equal( - promptEndedResult.digits, - '123', - 'Correct Digits were entered' - ) - - console.log(`Connecting to a peer..`) - const ringback = new Voice.Playlist().add( - Voice.Playlist.Ringtone({ - name: 'it', - }) - ) - const peer = await call.connectSip({ - from: makeSipDomainAppAddress({ - name: 'connect-from', - domain: domainApp.domain, - }), - to: makeSipDomainAppAddress({ - name: 'connect-to', - domain: domainApp.domain, - }), - timeout: 30, - ringback, // optional - maxPricePerMinute: 10, - }) - tap.equal(peer.connected, true, 'Peer connected is true') - tap.equal(call.connected, true, 'Call connected is true') - tap.equal( - call.connectState, - 'connected', - 'Call connected is "connected"' - ) - - console.log('Peer:', peer.id, peer.type, peer.from, peer.to) - console.log('Main:', call.id, call.type, call.from, call.to) - - // Wait until the peer answers the call - await waitForPeerAnswer - - const detector = await call.detectDigit({ - digits: '1', - }) - - // Wait until the peer send digits - await waitForPeerSendDigit - const resultDetector = await detector.ended() - // TODO: update this once the backend can send us the actual result - tap.equal( - // @ts-expect-error - resultDetector.detect.params.event, - 'finished', - 'Detect digit is finished' - ) + const promptEndedResult = await prompt.ended() + tap.equal( + prompt.id, + promptEndedResult.id, + 'Prompt instances are the same' + ) + tap.equal( + promptEndedResult.digits, + '123', + 'Prompt - correct digits were entered' + ) + + console.log( + `Connecting ${process.env.VOICE_DIAL_FROM_NUMBER} to ${process.env.VOICE_CONNECT_TO_NUMBER}` + ) + const ringback = new Voice.Playlist().add( + Voice.Playlist.Ringtone({ + name: 'it', + }) + ) + const peer = await call.connectSip({ + from: makeSipDomainAppAddress({ + name: 'connect-from', + domain: domainApp.domain, + }), + to: makeSipDomainAppAddress({ + name: 'connect-to', + domain: domainApp.domain, + }), + timeout: 30, + ringback, // optional + maxPricePerMinute: 10, + }) + tap.equal(peer.connected, true, 'Peer connected is true') + tap.equal(call.connected, true, 'Call connected is true') + tap.equal( + call.connectState, + 'connected', + 'Call connected is state "connected"' + ) + + console.log('Peer:', peer.id, peer.type, peer.from, peer.to) + console.log('Main:', call.id, call.type, call.from, call.to) + + const detector = await call.detectDigit({ + digits: '1', + }) - console.log('Finishing the calls.') - call.disconnected().then(async () => { - console.log('Call has been disconnected') - await call.hangup() - tap.equal(call.state, 'ended', 'Inbound call state is "ended"') - }) + const resultDetector = await detector.ended() + // TODO: update this once the backend can send us the actual result + tap.equal( + // @ts-expect-error + resultDetector.detect.params.event, + 'finished', + 'Peer - Detect digit is finished' + ) + + console.log('Finishing the calls.') + call.disconnected().then(async () => { + console.log('Call has been disconnected') + await call.hangup() + tap.equal(call.state, 'ended', 'Inbound call state is "ended"') + }) - // Peer hangs up a call - await peer.hangup() - } catch (error) { - console.error('Error', error) - reject(4) - } - }) + // Peer hangs up a call + await peer.hangup() + } catch (error) { + console.error('Error', error) + reject(4) + } + }, + }) - try { - const call = await client.dialSip({ + const call = await client.voice.dialSip({ to: makeSipDomainAppAddress({ name: 'to', domain: domainApp.domain, @@ -235,25 +220,10 @@ const handler: TestHandler = ({ domainApp }) => { timeout: 30, maxPricePerMinute: 10, }) + outboundCall = call tap.ok(call.id, 'Call resolved') tap.equal(call.state, 'answered', 'Outbound call state is "answered"') - // Wait until callee answers the call - await waitForInboundAnswer - - // Wait until callee starts the prompt - await waitForPromptStart - - const sendDigitResult = await call.sendDigits('1w2w3w#') - tap.equal( - call.id, - sendDigitResult.id, - 'sendDigit returns the same instance' - ) - - // Resolve the send digit start promise to let the callee know - waitForSendDigitResolve() - // Resolve if the call has ended or ending const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const const results = await Promise.all( @@ -272,7 +242,7 @@ const handler: TestHandler = ({ domainApp }) => { tap.equal(call.state, 'ended', 'Outbound call state is "ended"') resolve(0) } catch (error) { - console.error('Outbound - voice error', error) + console.error('Voice error', error) reject(4) } }) diff --git a/internal/e2e-realtime-api/src/voiceCollect.test.ts b/internal/e2e-realtime-api/src/voiceCollect.test.ts deleted file mode 100644 index c00af3de7..000000000 --- a/internal/e2e-realtime-api/src/voiceCollect.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import tap from 'tap' -import { Voice } from '@signalwire/realtime-api' -import { - type TestHandler, - createTestRunner, - makeSipDomainAppAddress, -} from './utils' - -const handler: TestHandler = ({ domainApp }) => { - if (!domainApp) { - throw new Error('Missing domainApp') - } - return new Promise(async (resolve, reject) => { - const client = new Voice.Client({ - host: process.env.RELAY_HOST, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - contexts: [domainApp.call_relay_context], - // logLevel: "trace", - debug: { - logWsTraffic: true, - }, - }) - - let waitForCollectStartResolve - const waitForCollectStart = new Promise((resolve) => { - waitForCollectStartResolve = resolve - }) - let waitForCollectEndResolve - const waitForCollectEnd = new Promise((resolve) => { - waitForCollectEndResolve = resolve - }) - - client.on('call.received', async (call) => { - console.log('Got call', call.id, call.from, call.to, call.direction) - - try { - const resultAnswer = await call.answer() - tap.ok(resultAnswer.id, 'Inboud call answered') - tap.equal( - call.id, - resultAnswer.id, - 'Call answered gets the same instance' - ) - - call.on('collect.started', (collect) => { - console.log('>>> collect.started') - }) - call.on('collect.updated', (collect) => { - console.log('>>> collect.updated', collect.digits) - }) - call.on('collect.ended', (collect) => { - console.log('>>> collect.ended', collect.digits) - }) - call.on('collect.failed', (collect) => { - console.log('>>> collect.failed', collect.reason) - }) - // call.on('collect.startOfSpeech', (collect) => {}) - - const callCollect = await call.collect({ - initialTimeout: 4.0, - digits: { - max: 4, - digitTimeout: 10, - terminators: '#', - }, - partialResults: true, - continuous: false, - sendStartOfInput: true, - startInputTimers: false, - }) - - // Resolve the answer promise to inform the caller - waitForCollectStartResolve() - - // Wait until the caller ends entring the digits - await waitForCollectEnd - - await callCollect.ended() // block the script until the collect ended - - tap.equal(callCollect.digits, '123', 'Collect the correct digits') - // await callCollect.stop() - // await callCollect.startInputTimers() - - await call.hangup() - } catch (error) { - console.error('Error', error) - reject(4) - } - }) - - try { - const call = await client.dialSip({ - to: makeSipDomainAppAddress({ - name: 'to', - domain: domainApp.domain, - }), - from: makeSipDomainAppAddress({ - name: 'from', - domain: domainApp.domain, - }), - timeout: 30, - }) - tap.ok(call.id, 'Call resolved') - - // Wait until the callee answers the call and start collecting digits - await waitForCollectStart - - const sendDigitResult = await call.sendDigits('1w2w3w#') - tap.equal( - call.id, - sendDigitResult.id, - 'sendDigit returns the same instance' - ) - - // Resolve the collect end promise to inform the callee - waitForCollectEndResolve() - - const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const - const results = await Promise.all( - waitForParams.map((params) => call.waitFor(params as any)) - ) - waitForParams.forEach((value, i) => { - if (typeof value === 'string') { - tap.ok(results[i], `"${value}": completed successfully.`) - } else { - tap.ok( - results[i], - `${JSON.stringify(value)}: completed successfully.` - ) - } - }) - - resolve(0) - } catch (error) { - console.error('Outbound - voiceDetect error', error) - reject(4) - } - }) -} - -async function main() { - const runner = createTestRunner({ - name: 'Voice Collect E2E', - testHandler: handler, - executionTime: 60_000, - useDomainApp: true, - }) - - await runner.run() -} - -main() diff --git a/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts new file mode 100644 index 000000000..90d618683 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts @@ -0,0 +1,191 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_COLLECT_PROPS, CALL_PROPS } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) + + let waitForCollectStartResolve: () => void + const waitForCollectStart = new Promise((resolve) => { + waitForCollectStartResolve = resolve + }) + let waitForCollectEndResolve: () => void + const waitForCollectEnd = new Promise((resolve) => { + waitForCollectEndResolve = resolve + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Wait until the caller starts the collect + await waitForCollectStart + + // Send wrong digits 123 to the caller (callee expects 1234) + const sendDigits = await call.sendDigits('1w2w3w4w#') + tap.equal( + call.id, + sendDigits.id, + 'Inbound - sendDigit returns the same instance' + ) + + // Wait until the caller ends the collect + await waitForCollectEnd + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + onCollectInputStarted(collect) { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'voice.dialPhone: Collect input started' + ) + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const unsubCall = await call.listen({ + onCollectStarted(collect) { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'call.listen: Collect started' + ) + }, + onCollectEnded(collect) { + // NotOk since we unsubscribe this listener before the collect ends + tap.notOk(collect, 'call.listen: Collect ended') + }, + }) + + // Caller starts a collect + const collect = await call.collect({ + initialTimeout: 4.0, + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, + partialResults: true, + continuous: false, + sendStartOfInput: true, + startInputTimers: false, + listen: { + // onUpdated runs three times since callee sends 4 digits (1234) + // 4th (final) digit emits onEnded + onUpdated: (collect) => { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'call.collect: Collect updated' + ) + }, + onFailed: (collect) => { + tap.notOk(collect.id, 'call.collect: Collect failed') + }, + }, + }) + tap.equal( + call.id, + collect.callId, + 'Outbound - Collect returns the same call instance' + ) + + // Resolve the collect start promise + waitForCollectStartResolve!() + + const unsubCollect = await collect.listen({ + onEnded: (_collect) => { + tap.hasProps( + _collect, + CALL_COLLECT_PROPS, + 'collect.listen: Collect ended' + ) + tap.equal( + _collect.id, + collect.id, + 'collect.listen: Collect correct id' + ) + }, + }) + + await unsubCall() + + console.log('Waiting for the digits from the inbound call') + + // Compare what caller has received + const recDigits = await collect.ended() + tap.equal(recDigits.digits, '1234', 'Outbound - Received the same digit') + + // Resolve the collect end promise + waitForCollectEndResolve!() + + // Resolve if the call has ended or ending + const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const + const results = await Promise.all( + waitForParams.map((params) => call.waitFor(params as any)) + ) + waitForParams.forEach((value, i) => { + if (typeof value === 'string') { + tap.ok(results[i], `"${value}": completed successfully.`) + } else { + tap.ok( + results[i], + `${JSON.stringify(value)}: completed successfully.` + ) + } + }) + + await unsubVoice() + + await unsubCollect() + + client.disconnect() + + resolve(0) + } catch (error) { + console.error('VoiceCollectAllListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Collect with all Listeners E2E', + testHandler: handler, + executionTime: 60_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts new file mode 100644 index 000000000..ff3f6d170 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts @@ -0,0 +1,158 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_COLLECT_PROPS, CALL_PROPS } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) + + let waitForCollectStartResolve: () => void + const waitForCollectStart = new Promise((resolve) => { + waitForCollectStartResolve = resolve + }) + let waitForCollectEndResolve: () => void + const waitForCollectEnd = new Promise((resolve) => { + waitForCollectEndResolve = resolve + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Wait until the caller starts the collect + await waitForCollectStart + + // Send wrong digits 123 to the caller (callee expects 1234) + const sendDigits = await call.sendDigits('1w2w3#') + tap.equal( + call.id, + sendDigits.id, + 'Inbound - sendDigit returns the same instance' + ) + + // Wait until the caller ends the collect + await waitForCollectEnd + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const unsubCall = await call.listen({ + onCollectStarted: (collect) => { + tap.hasProps(collect, CALL_COLLECT_PROPS, 'Collect started') + tap.equal(collect.callId, call.id, 'Collect correct call id') + }, + onCollectInputStarted: (collect) => { + tap.hasProps(collect, CALL_COLLECT_PROPS, 'Collect input started') + }, + // onCollectUpdated runs three times since callee sends 4 digits (1234) + // 4th (final) digit emits onCollectEnded + onCollectUpdated: (collect) => { + tap.hasProps(collect, CALL_COLLECT_PROPS, 'Collect updated') + }, + onCollectFailed: (collect) => { + tap.notOk(collect.id, 'Collect failed') + }, + onCollectEnded: (collect) => { + tap.hasProps(collect, CALL_COLLECT_PROPS, 'Collect ended') + }, + }) + + // Caller starts a collect + const collect = await call.collect({ + initialTimeout: 4.0, + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, + partialResults: true, + continuous: false, + sendStartOfInput: true, + startInputTimers: false, + }) + tap.equal( + call.id, + collect.callId, + 'Outbound - Collect returns the same call instance' + ) + + // Resolve the collect start promise + waitForCollectStartResolve!() + + console.log('Waiting for the digits from the inbound call') + + // Compare what caller has received + const recDigits = await collect.ended() + tap.not(recDigits.digits, '1234', 'Outbound - Received the same digit') + + // Resolve the collect end promise + waitForCollectEndResolve!() + + // Resolve if the call has ended or ending + const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const + const results = await Promise.all( + waitForParams.map((params) => call.waitFor(params as any)) + ) + waitForParams.forEach((value, i) => { + if (typeof value === 'string') { + tap.ok(results[i], `"${value}": completed successfully.`) + } else { + tap.ok( + results[i], + `${JSON.stringify(value)}: completed successfully.` + ) + } + }) + + await unsubVoice() + + await unsubCall() + + client.disconnect() + + resolve(0) + } catch (error) { + console.error('VoiceCollectCallListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Collect with Call Listeners E2E', + testHandler: handler, + executionTime: 60_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts new file mode 100644 index 000000000..6d39e973f --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts @@ -0,0 +1,155 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_COLLECT_PROPS, CALL_PROPS } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) + + let waitForCollectStartResolve: () => void + const waitForCollectStart = new Promise((resolve) => { + waitForCollectStartResolve = resolve + }) + let waitForCollectEndResolve: () => void + const waitForCollectEnd = new Promise((resolve) => { + waitForCollectEndResolve = resolve + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Wait until the caller starts the collect + await waitForCollectStart + + // Send digits 1234 to the caller + const sendDigits = await call.sendDigits('1w2w3w4w#') + tap.equal( + call.id, + sendDigits.id, + 'Inbound - sendDigit returns the same instance' + ) + + // Wait until the caller ends the collect + await waitForCollectEnd + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + onCollectStarted: (collect) => { + tap.hasProps(collect, CALL_COLLECT_PROPS, 'Collect started') + tap.equal(collect.callId, call.id, 'Collect correct call id') + }, + onCollectInputStarted: (collect) => { + tap.hasProps(collect, CALL_COLLECT_PROPS, 'Collect input started') + }, + // onCollectUpdated runs three times since callee sends 4 digits (1234) + // 4th (final) digit emits onCollectEnded + onCollectUpdated: (collect) => { + tap.hasProps(collect, CALL_COLLECT_PROPS, 'Collect updated') + }, + onCollectFailed: (collect) => { + tap.notOk(collect.id, 'Collect failed') + }, + onCollectEnded: (collect) => { + tap.hasProps(collect, CALL_COLLECT_PROPS, 'Collect ended') + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + // Caller starts a collect + const collect = await call.collect({ + initialTimeout: 4.0, + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, + partialResults: true, + continuous: false, + sendStartOfInput: true, + startInputTimers: false, + }) + tap.equal( + call.id, + collect.callId, + 'Outbound - Collect returns the same call instance' + ) + + // Resolve the collect start promise + waitForCollectStartResolve!() + + console.log('Waiting for the digits from the inbound call') + + // Compare what caller has received + const recDigits = await collect.ended() + tap.equal(recDigits.digits, '1234', 'Outbound - Received the same digit') + + // Resolve the collect end promise + waitForCollectEndResolve!() + + // Resolve if the call has ended or ending + const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const + const results = await Promise.all( + waitForParams.map((params) => call.waitFor(params as any)) + ) + waitForParams.forEach((value, i) => { + if (typeof value === 'string') { + tap.ok(results[i], `"${value}": completed successfully.`) + } else { + tap.ok( + results[i], + `${JSON.stringify(value)}: completed successfully.` + ) + } + }) + + await unsubVoice() + + client.disconnect() + + resolve(0) + } catch (error) { + console.error('VoiceCollectDialListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Collect with Dial Listeners E2E', + testHandler: handler, + executionTime: 60_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts new file mode 100644 index 000000000..d7a9e2069 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts @@ -0,0 +1,209 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_COLLECT_PROPS, CALL_PROPS } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) + + let waitForCollectStartResolve: () => void + const waitForCollectStart = new Promise((resolve) => { + waitForCollectStartResolve = resolve + }) + let waitForCollectEndResolve: () => void + const waitForCollectEnd = new Promise((resolve) => { + waitForCollectEndResolve = resolve + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Wait until the caller starts the collect + await waitForCollectStart + + // Send wrong digits 123 to the caller (callee expects 1234) + const sendDigits = await call.sendDigits('1w2w3w4w#') + tap.equal( + call.id, + sendDigits.id, + 'Inbound - sendDigit returns the same instance' + ) + + // Wait until the caller ends the collect + await waitForCollectEnd + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + // Caller starts a collect + const collect = await call.collect({ + initialTimeout: 4.0, + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, + partialResults: true, + continuous: false, + sendStartOfInput: true, + startInputTimers: false, + listen: { + onStarted: (collect) => { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'call.collect: Collect started' + ) + tap.equal(collect.callId, call.id, 'call.collect: Correct call id') + }, + onInputStarted: (collect) => { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'call.collect: Collect input started' + ) + }, + // onUpdated runs three times since callee sends 4 digits (1234) + // 4th (final) digit emits onEnded + onUpdated: (collect) => { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'call.collect: Collect updated' + ) + }, + onFailed: (collect) => { + tap.notOk(collect.id, 'call.collect: Collect failed') + }, + onEnded: (collect) => { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'call.collect: Collect ended' + ) + }, + }, + }) + tap.equal( + call.id, + collect.callId, + 'Outbound - Collect returns the same call instance' + ) + + // Resolve the collect start promise + waitForCollectStartResolve!() + + const unsubCollect = await collect.listen({ + onStarted: (collect) => { + // NotOk since this listener is being attached after the call.collect promise has resolved + tap.notOk(collect.id, 'collect.listen: Collect stared') + }, + onInputStarted: (collect) => { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'collect.listen: Collect input started' + ) + }, + onUpdated: (collect) => { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'collect.listen: Collect updated' + ) + }, + onFailed: (collect) => { + tap.notOk(collect.id, 'collect.listen: Collect failed') + }, + onEnded: (_collect) => { + tap.hasProps( + _collect, + CALL_COLLECT_PROPS, + 'collect.listen: Collect ended' + ) + tap.equal( + _collect.id, + collect.id, + 'collect.listen: Collect correct id' + ) + }, + }) + + console.log('Waiting for the digits from the inbound call') + + // Compare what caller has received + const recDigits = await collect.ended() + tap.equal(recDigits.digits, '1234', 'Outbound - Received the same digit') + + // Resolve the collect end promise + waitForCollectEndResolve!() + + // Resolve if the call has ended or ending + const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const + const results = await Promise.all( + waitForParams.map((params) => call.waitFor(params as any)) + ) + waitForParams.forEach((value, i) => { + if (typeof value === 'string') { + tap.ok(results[i], `"${value}": completed successfully.`) + } else { + tap.ok( + results[i], + `${JSON.stringify(value)}: completed successfully.` + ) + } + }) + + await unsubVoice() + + await unsubCollect() + + client.disconnect() + + resolve(0) + } catch (error) { + console.error('VoiceCollectListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Collect Listeners E2E', + testHandler: handler, + executionTime: 60_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceDetect.test.ts b/internal/e2e-realtime-api/src/voiceDetect.test.ts deleted file mode 100644 index bef20bc15..000000000 --- a/internal/e2e-realtime-api/src/voiceDetect.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import tap from 'tap' -import { Voice } from '@signalwire/realtime-api' -import { - type TestHandler, - createTestRunner, - makeSipDomainAppAddress, -} from './utils' - -const handler: TestHandler = ({ domainApp }) => { - if (!domainApp) { - throw new Error('Missing domainApp') - } - return new Promise(async (resolve, reject) => { - const client = new Voice.Client({ - host: process.env.RELAY_HOST, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - contexts: [domainApp.call_relay_context], - debug: { - logWsTraffic: true, - }, - }) - - let waitForDetectStartResolve - const waitForDetectStart = new Promise((resolve) => { - waitForDetectStartResolve = resolve - }) - - client.on('call.received', async (call) => { - console.log( - 'Inbound - Got call', - call.id, - call.from, - call.to, - call.direction - ) - - try { - const resultAnswer = await call.answer() - tap.ok(resultAnswer.id, 'Inbound - Call answered') - tap.equal( - call.id, - resultAnswer.id, - 'Inbound - Call answered gets the same instance' - ) - - // Wait until caller starts detecting digit - await waitForDetectStart - - // Simulate human with TTS to then expect the detector to detect an `HUMAN` - const playlist = new Voice.Playlist() - .add( - Voice.Playlist.TTS({ - text: 'Hello?', - }) - ) - .add(Voice.Playlist.Silence({ duration: 1 })) - .add( - Voice.Playlist.TTS({ - text: 'Joe here, how can i help you?', - }) - ) - const playback = await call.play(playlist) - tap.equal( - call.id, - playback.callId, - 'Inbound - playTTS returns the same instance' - ) - await playback.ended() - } catch (error) { - console.error('Inbound - Error', error) - reject(4) - } - }) - - try { - const call = await client.dialSip({ - to: makeSipDomainAppAddress({ - name: 'to', - domain: domainApp.domain, - }), - from: makeSipDomainAppAddress({ - name: 'from', - domain: domainApp.domain, - }), - timeout: 30, - }) - tap.ok(call.id, 'Outbound - Call resolved') - - // Start the detector - const detector = await call.amd() - tap.equal( - call.id, - detector.callId, - 'Outbound - Detect returns the same instance' - ) - - // Resolve the detect start promise to inform the callee - waitForDetectStartResolve() - - // Wait the callee to start saying something.. - await detector.ended() - tap.equal(detector.type, 'machine', 'Outbound - Received the digit') - tap.equal(detector.result, 'HUMAN', 'Outbound - detected human') - - // Caller hangs up a call - await call.hangup() - - resolve(0) - } catch (error) { - console.error('Outbound - voiceDetect error', error) - reject(4) - } - }) -} - -async function main() { - const runner = createTestRunner({ - name: 'Voice Detect E2E', - testHandler: handler, - executionTime: 30_000, - useDomainApp: true, - }) - - await runner.run() -} - -main() diff --git a/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts new file mode 100644 index 000000000..60e1a7d4e --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts @@ -0,0 +1,183 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { CALL_PROPS, CALL_DETECT_PROPS, createTestRunner } from './utils' + +const handler = () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) + + let waitForDetectStartResolve: () => void + const waitForDetectStart = new Promise((resolve) => { + waitForDetectStartResolve = resolve + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Wait until the caller starts the detect + await waitForDetectStart + + // Send digits 1234 to the caller + const sendDigits = await call.sendDigits('1w2w3w4w#') + tap.equal( + call.id, + sendDigits.id, + 'Inbound - sendDigit returns the same instance' + ) + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + async onStateChanged(call) { + if (call.state === 'ended') { + await unsubVoice() + + await unsubDetect?.() + + client.disconnect() + + resolve(0) + } + }, + onDetectStarted: (detect) => { + tap.hasProps( + detect, + CALL_DETECT_PROPS, + 'voice.dialPhone: Detect started' + ) + tap.equal( + detect.callId, + call.id, + 'voice.dialPhone: Detect with correct call id' + ) + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const unsubCall = await call.listen({ + onDetectStarted: (detect) => { + tap.hasProps( + detect, + CALL_DETECT_PROPS, + 'voice.dialPhone: Detect started' + ) + tap.equal( + detect.callId, + call.id, + 'voice.dialPhone: Detect with correct call id' + ) + }, + onDetectEnded: (detect) => { + // NotOk since the we unsubscribe this listener before detect ends + tap.notOk(detect, 'call.listen: Detect ended') + }, + }) + + // Start a detect + const detectDigit = await call.detectDigit({ + digits: '1234', + listen: { + onStarted: (detect) => { + tap.hasProps( + detect, + CALL_DETECT_PROPS, + 'call.detectDigit: Detect started' + ) + tap.equal( + detect.callId, + call.id, + 'call.detectDigit: Detect with correct call id' + ) + }, + // Update runs 4 times since callee send 4 digits + onUpdated: (detect) => { + tap.hasProps( + detect, + CALL_DETECT_PROPS, + 'call.detectDigit: Detect updated' + ) + tap.equal( + detect.callId, + call.id, + 'call.detectDigit: Detect with correct call id' + ) + }, + }, + }) + tap.equal( + call.id, + detectDigit.callId, + 'Outbound - Detect returns the same instance' + ) + + // Resolve the detect start promise + waitForDetectStartResolve!() + + const unsubDetect = await detectDigit.listen({ + onStarted: (detect) => { + // NotOk since the listener is attached after the call.detectDigit has resolved + tap.notOk(detect, 'detectDigit.listen: Detect stared') + }, + onEnded: async (detect) => { + tap.hasProps( + detect, + CALL_DETECT_PROPS, + 'detectDigit.listen: Detect ended' + ) + tap.equal( + detect.callId, + call.id, + 'detectDigit.listen: Detect with correct call id' + ) + }, + }) + + await unsubCall() + + const recDigits = await detectDigit.ended() + tap.equal(recDigits.type, 'digit', 'Outbound - Received the digit') + } catch (error) { + console.error('VoiceDetectAllListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Detect with All Listeners E2E', + testHandler: handler, + executionTime: 60_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts new file mode 100644 index 000000000..b6f6384f1 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts @@ -0,0 +1,117 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { CALL_PROPS, CALL_DETECT_PROPS, createTestRunner } from './utils' + +const handler = () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) + + let waitForDetectStartResolve: () => void + const waitForDetectStart = new Promise((resolve) => { + waitForDetectStartResolve = resolve + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Wait until the caller starts the detect + await waitForDetectStart + + // Send digits 1234 to the caller + const sendDigits = await call.sendDigits('1w2w3w4w#') + tap.equal( + call.id, + sendDigits.id, + 'Inbound - sendDigit returns the same instance' + ) + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + async onStateChanged(call) { + if (call.state === 'ended') { + await unsubVoice() + + await unsubCall?.() + + client.disconnect() + + resolve(0) + } + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const unsubCall = await call.listen({ + onDetectStarted: (detect) => { + tap.hasProps(detect, CALL_DETECT_PROPS, 'Detect started') + tap.equal(detect.callId, call.id, 'Detect with correct call id') + }, + // Update runs 4 times since callee send 4 digits + onDetectUpdated: (detect) => { + tap.hasProps(detect, CALL_DETECT_PROPS, 'Detect updated') + tap.equal(detect.callId, call.id, 'Detect with correct call id') + }, + onDetectEnded: async (detect) => { + tap.hasProps(detect, CALL_DETECT_PROPS, 'Detect ended') + tap.equal(detect.callId, call.id, 'Detect with correct call id') + }, + }) + + // Start a detect + const detectDigit = await call.detectDigit({ + digits: '1234', + }) + tap.equal( + call.id, + detectDigit.callId, + 'Outbound - Detect returns the same instance' + ) + + // Resolve the detect start promise + waitForDetectStartResolve!() + } catch (error) { + console.error('VoiceDetectDialListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Detect with Call Listeners E2E', + testHandler: handler, + executionTime: 60_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts new file mode 100644 index 000000000..830ce75ae --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts @@ -0,0 +1,112 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { CALL_PROPS, CALL_DETECT_PROPS, createTestRunner } from './utils' + +const handler = () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) + + let waitForDetectStartResolve: () => void + const waitForDetectStart = new Promise((resolve) => { + waitForDetectStartResolve = resolve + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Wait until the caller starts the detect + await waitForDetectStart + + // Send digits 1234 to the caller + const sendDigits = await call.sendDigits('1w2w3w4w#') + tap.equal( + call.id, + sendDigits.id, + 'Inbound - sendDigit returns the same instance' + ) + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + async onStateChanged(call) { + if (call.state === 'ended') { + await unsubVoice() + + client.disconnect() + + resolve(0) + } + }, + onDetectStarted: (detect) => { + tap.hasProps(detect, CALL_DETECT_PROPS, 'Detect started') + tap.equal(detect.callId, call.id, 'Detect with correct call id') + }, + // Update runs 4 times since callee send 4 digits + onDetectUpdated: (detect) => { + tap.hasProps(detect, CALL_DETECT_PROPS, 'Detect updated') + tap.equal(detect.callId, call.id, 'Detect with correct call id') + }, + onDetectEnded: async (detect) => { + tap.hasProps(detect, CALL_DETECT_PROPS, 'Detect ended') + tap.equal(detect.callId, call.id, 'Detect with correct call id') + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + // Start a detect + const detectDigit = await call.detectDigit({ + digits: '1234', + }) + tap.equal( + call.id, + detectDigit.callId, + 'Outbound - Detect returns the same instance' + ) + + // Resolve the detect start promise + waitForDetectStartResolve!() + } catch (error) { + console.error('VoiceDetectDialListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Detect with Dial Listeners E2E', + testHandler: handler, + executionTime: 60_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts new file mode 100644 index 000000000..40d2fbd21 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts @@ -0,0 +1,123 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { CALL_PROPS, CALL_DETECT_PROPS, createTestRunner } from './utils' + +const handler = () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) + + let waitForDetectStartResolve: () => void + const waitForDetectStart = new Promise((resolve) => { + waitForDetectStartResolve = resolve + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Wait until the caller starts the detect + await waitForDetectStart + + // Send digits 1234 to the caller + const sendDigits = await call.sendDigits('1w2w3w4w#') + tap.equal( + call.id, + sendDigits.id, + 'Inbound - sendDigit returns the same instance' + ) + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + async onStateChanged(call) { + if (call.state === 'ended') { + await unsubVoice() + + await unsubDetect?.() + + client.disconnect() + + resolve(0) + } + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + // Start a detect + const detectDigit = await call.detectDigit({ + digits: '1234', + listen: { + onStarted: (detect) => { + tap.hasProps(detect, CALL_DETECT_PROPS, 'Detect started') + tap.equal(detect.callId, call.id, 'Detect with correct call id') + }, + }, + }) + tap.equal( + call.id, + detectDigit.callId, + 'Outbound - Detect returns the same instance' + ) + + // Resolve the detect start promise + waitForDetectStartResolve!() + + const unsubDetect = await detectDigit.listen({ + onStarted: (detect) => { + // NotOk since the listener is attached after the call.detectDigit has resolved + tap.notOk(detect, 'Detect started') + }, + // Update runs 4 times since callee send 4 digits + onUpdated: (detect) => { + tap.hasProps(detect, CALL_DETECT_PROPS, 'Detect updated') + tap.equal(detect.callId, call.id, 'Detect with correct call id') + }, + onEnded: async (detect) => { + tap.hasProps(detect, CALL_DETECT_PROPS, 'Detect ended') + tap.equal(detect.callId, call.id, 'Detect with correct call id') + }, + }) + } catch (error) { + console.error('VoiceDetectDialListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Detect Listeners E2E', + testHandler: handler, + executionTime: 60_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePass.test.ts b/internal/e2e-realtime-api/src/voicePass.test.ts index 9b99d25b8..4315c7acd 100644 --- a/internal/e2e-realtime-api/src/voicePass.test.ts +++ b/internal/e2e-realtime-api/src/voicePass.test.ts @@ -1,5 +1,5 @@ import tap from 'tap' -import { Voice } from '@signalwire/realtime-api' +import { SignalWire, Voice } from '@signalwire/realtime-api' import { type TestHandler, createTestRunner, @@ -11,75 +11,67 @@ const handler: TestHandler = ({ domainApp }) => { throw new Error('Missing domainApp') } return new Promise(async (resolve, reject) => { - const options = { - host: process.env.RELAY_HOST, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - contexts: [domainApp.call_relay_context], - // logLevel: "trace", - debug: { - logWsTraffic: true, - }, - } + try { + const options = { + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + // logWsTraffic: true, + }, + } - const [client1, client2, client3] = [ - new Voice.Client(options), - new Voice.Client(options), - new Voice.Client(options), - ] + const [client1, client2, client3] = [ + await SignalWire(options), + await SignalWire(options), + await SignalWire(options), + ] - let callPassed = false + let callPassed = false - const handleCall = async (call: Voice.Call) => { - if (callPassed) { - console.log('Answering..') - const resultAnswer = await call.answer() - tap.ok(resultAnswer.id, 'Inbound - Call answered') - await call.hangup() - } else { - console.log('Passing..') - const passed = await call.pass() - tap.equal(passed, undefined, 'Call passed!') - callPassed = true + const handleCall = async (call: Voice.Call) => { + if (callPassed) { + console.log('Answering..') + const resultAnswer = await call.answer() + tap.ok(resultAnswer.id, 'Inbound - Call answered') + await call.hangup() + } else { + console.log('Passing..') + const passed = await call.pass() + tap.equal(passed, undefined, 'Call passed!') + callPassed = true + } } - } - client2.on('call.received', async (call) => { - console.log( - 'Got call on client 2', - call.id, - call.from, - call.to, - call.direction - ) + const unsubClient2 = await client2.voice.listen({ + topics: [domainApp.call_relay_context], + onCallReceived: async (call) => { + console.log('Got call on client 2', call.id) - try { - await handleCall(call) - } catch (error) { - console.error('Inbound - voicePass client2 error', error) - reject(4) - } - }) + try { + await handleCall(call) + } catch (error) { + console.error('Inbound - voicePass client2 error', error) + reject(4) + } + }, + }) - client3.on('call.received', async (call) => { - console.log( - 'Got call on client 3', - call.id, - call.from, - call.to, - call.direction - ) + const unsubClient3 = await client3.voice.listen({ + topics: [domainApp.call_relay_context], + onCallReceived: async (call) => { + console.log('Got call on client 3', call.id) - try { - await handleCall(call) - } catch (error) { - console.error('Inbound - voicePass client3 error', error) - reject(4) - } - }) + try { + await handleCall(call) + } catch (error) { + console.error('Inbound - voicePass client3 error', error) + reject(4) + } + }, + }) - try { - const call = await client1.dialSip({ + const call = await client1.voice.dialSip({ to: makeSipDomainAppAddress({ name: 'to', domain: domainApp.domain, @@ -110,7 +102,7 @@ const handler: TestHandler = ({ domainApp }) => { resolve(0) } catch (error) { - console.error('Outbound - voicePass error', error) + console.error('VoicePass error', error) reject(4) } }) diff --git a/internal/e2e-realtime-api/src/voicePlayback.test.ts b/internal/e2e-realtime-api/src/voicePlayback.test.ts deleted file mode 100644 index 042b7a0dd..000000000 --- a/internal/e2e-realtime-api/src/voicePlayback.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import tap from 'tap' -import { Voice } from '@signalwire/realtime-api' -import { - type TestHandler, - createTestRunner, - makeSipDomainAppAddress, -} from './utils' - -const handler: TestHandler = ({ domainApp }) => { - if (!domainApp) { - throw new Error('Missing domainApp') - } - return new Promise(async (resolve, reject) => { - const client = new Voice.Client({ - host: process.env.RELAY_HOST, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - topics: [domainApp.call_relay_context], - debug: { - logWsTraffic: true, - }, - }) - - client.on('call.received', async (call) => { - console.log( - 'Inbound - Got call', - call.id, - call.from, - call.to, - call.direction - ) - - try { - const resultAnswer = await call.answer() - tap.ok(resultAnswer.id, 'Inbound - Call answered') - tap.equal( - call.id, - resultAnswer.id, - 'Inbound - Call answered gets the same instance' - ) - - try { - // Play an invalid audio - const handle = call.playAudio({ - url: 'https://cdn.fake.com/default-music/fake.mp3', - }) - - const waitForPlaybackFailed = new Promise((resolve) => { - call.on('playback.failed', (playback) => { - tap.equal( - playback.state, - 'error', - 'Inbound - playback has failed' - ) - resolve(true) - }) - }) - // Wait for the inbound audio to failed - await waitForPlaybackFailed - - // Resolve late so that we attach `playback.failed` and wait for it - await handle - } catch (error) { - console.log('Inbound - invalid playback error') - tap.equal( - call.id, - error.callId, - 'Inbound - playback returns the same instance' - ) - } - - // Callee hangs up a call - await call.hangup() - } catch (error) { - console.error('Inbound - Error', error) - reject(4) - } - }) - - try { - const call = await client.dialSip({ - to: makeSipDomainAppAddress({ - name: 'to', - domain: domainApp.domain, - }), - from: makeSipDomainAppAddress({ - name: 'from', - domain: domainApp.domain, - }), - timeout: 30, - maxPricePerMinute: 10, - }) - tap.ok(call.id, 'Outbound - Call resolved') - - // Play an audio - const handle = call.playAudio({ - url: 'https://cdn.signalwire.com/default-music/welcome.mp3', - }) - - const waitForPlaybackStarted = new Promise((resolve) => { - call.on('playback.started', (playback) => { - tap.equal( - playback.state, - 'playing', - 'Outbound - Playback has started' - ) - resolve(true) - }) - }) - // Wait for the outbound audio to start - await waitForPlaybackStarted - - // Resolve late so that we attach `playback.started` and wait for it - const resolvedHandle = await handle - - tap.equal( - call.id, - resolvedHandle.callId, - 'Outbound - Playback returns the same instance' - ) - - const waitForPlaybackEnded = new Promise((resolve) => { - call.on('playback.ended', (playback) => { - tap.equal( - playback.state, - 'finished', - 'Outbound - Playback has finished' - ) - resolve(true) - }) - }) - // Wait for the outbound audio to end (callee hung up the call or audio ended) - await waitForPlaybackEnded - - const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const - const results = await Promise.all( - waitForParams.map((params) => call.waitFor(params as any)) - ) - waitForParams.forEach((value, i) => { - if (typeof value === 'string') { - tap.ok(results[i], `"${value}": completed successfully.`) - } else { - tap.ok( - results[i], - `${JSON.stringify(value)}: completed successfully.` - ) - } - }) - - resolve(0) - } catch (error) { - console.error('Outbound - voicePlayback error', error) - reject(4) - } - }) -} - -async function main() { - const runner = createTestRunner({ - name: 'Voice Playback E2E', - testHandler: handler, - executionTime: 60_000, - useDomainApp: true, - }) - - await runner.run() -} - -main() diff --git a/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts new file mode 100644 index 000000000..9fba11d61 --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts @@ -0,0 +1,156 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_PROPS, CALL_PLAYBACK_PROPS } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + // logWsTraffic: true, + }, + }) + + const unsubVoiceOffice = await client.voice.listen({ + topics: ['office'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const unsubVoiceHome = await client.voice.listen({ + topics: ['home'], + // This should never run since VOICE_DIAL_TO_NUMBER is listening only for "office" topic + onCallReceived: async (call) => { + tap.notOk(call, 'Inbound - Home topic received a call') + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoiceOffice() + + await unsubVoiceHome() + + await unsubPlay?.() + + client.disconnect() + + resolve(0) + } + }, + onPlaybackStarted: (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'voice.dialPhone: Playback started' + ) + tap.equal( + playback.state, + 'playing', + 'voice.dialPhone: Playback correct state' + ) + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const unsubCall = await call.listen({ + onPlaybackStarted: (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'call.listen: Playback started' + ) + tap.equal( + playback.state, + 'playing', + 'call.listen: Playback correct state' + ) + }, + onPlaybackEnded: (playback) => { + // NotOk since we unsubscribe this listener before the playback stops + tap.notOk(playback.id, 'call.listen: Playback ended') + }, + }) + + // Play an audio + const play = await call.playAudio({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + listen: { + onStarted: async (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'call.playAudio: Playback started' + ) + tap.equal( + playback.state, + 'playing', + 'call.playAudio: Playback correct state' + ) + + await unsubCall() + + await play.stop() + }, + }, + }) + + const unsubPlay = await play.listen({ + onStarted: (playback) => { + // NotOk since the listener is attached after the call.play has resolved + tap.notOk(playback.id, 'play.listen: Playback stared') + }, + onEnded: async (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'play.listen: Playback ended' + ) + tap.equal(playback.id, play.id, 'play.listen: Playback correct id') + tap.equal( + playback.state, + 'finished', + 'play.listen: Playback correct state' + ) + + await call.hangup() + }, + }) + } catch (error) { + console.error('VoicePlaybackAllListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Playback with all Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts new file mode 100644 index 000000000..9c1b03fee --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts @@ -0,0 +1,93 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_PLAYBACK_PROPS, CALL_PROPS } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + // logWsTraffic: true, + }, + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const unsubCall = await call.listen({ + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoice() + + await unsubCall?.() + + client.disconnect() + + resolve(0) + } + }, + onPlaybackStarted: (playback) => { + tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback started') + tap.equal(playback.state, 'playing', 'Playback correct state') + }, + onPlaybackUpdated: (playback) => { + tap.notOk(playback.id, 'Playback updated') + }, + onPlaybackFailed: (playback) => { + tap.notOk(playback.id, 'Playback failed') + }, + onPlaybackEnded: async (playback) => { + tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback ended') + tap.equal(playback.state, 'finished', 'Playback correct state') + + await call.hangup() + }, + }) + + const play = await call.playAudio({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + }) + + await play.stop() + } catch (error) { + console.error('VoicePlaybackCallListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Playback with Call Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts new file mode 100644 index 000000000..2d59c5f7c --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts @@ -0,0 +1,90 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_PLAYBACK_PROPS, CALL_PROPS } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + // logWsTraffic: true, + }, + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoice() + + client.disconnect() + + resolve(0) + } + }, + onPlaybackStarted: (playback) => { + tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback started') + tap.equal(playback.state, 'playing', 'Playback correct state') + }, + onPlaybackUpdated: (playback) => { + tap.notOk(playback.id, 'Playback updated') + }, + onPlaybackFailed: (playback) => { + tap.notOk(playback.id, 'Playback failed') + }, + onPlaybackEnded: async (playback) => { + tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback ended') + tap.equal(playback.state, 'finished', 'Playback correct state') + + await call.hangup() + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const play = await call.playAudio({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + }) + + await play.stop() + } catch (error) { + console.error('VoicePlaybackDialListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Playback with Dial Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts new file mode 100644 index 000000000..49f30aa39 --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts @@ -0,0 +1,113 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_PLAYBACK_PROPS, CALL_PROPS } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + // logWsTraffic: true, + }, + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoice() + + await unsubPlay?.() + + client.disconnect() + + resolve(0) + } + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const play = await call.playTTS({ + text: 'This is a custom text to speech for test.', + listen: { + onStarted: (playback) => { + tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback started') + tap.equal(playback.state, 'playing', 'Playback correct state') + }, + onUpdated: (playback) => { + tap.notOk(playback.id, 'Playback updated') + }, + onFailed: (playback) => { + tap.notOk(playback.id, 'Playback failed') + }, + onEnded: (playback) => { + tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback ended') + tap.equal(playback.id, play.id, 'Playback correct id') + tap.equal(playback.state, 'finished', 'Playback correct state') + }, + }, + }) + + const unsubPlay = await play.listen({ + onStarted: (playback) => { + // NotOk since this listener is being attached after the call.play promise has resolved + tap.notOk(playback.id, 'Playback stared') + }, + onUpdated: (playback) => { + tap.notOk(playback.id, 'Playback updated') + }, + onFailed: (playback) => { + tap.notOk(playback.id, 'Playback failed') + }, + onEnded: async (playback) => { + tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback ended') + tap.equal(playback.id, play.id, 'Playback correct id') + tap.equal(playback.state, 'finished', 'Playback correct state') + + await call.hangup() + }, + }) + + await play.stop() + } catch (error) { + console.error('VoicePlaybackListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Playback Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts b/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts index f8d67c701..d5cff9a48 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts @@ -1,9 +1,10 @@ import tap from 'tap' -import { Voice } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' import { type TestHandler, createTestRunner, makeSipDomainAppAddress, + CALL_PLAYBACK_PROPS, } from './utils' const handler: TestHandler = ({ domainApp }) => { @@ -11,113 +12,131 @@ const handler: TestHandler = ({ domainApp }) => { throw new Error('Missing domainApp') } return new Promise(async (resolve, reject) => { - const client = new Voice.Client({ - host: process.env.RELAY_HOST, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - topics: [domainApp.call_relay_context], - debug: { - logWsTraffic: true, - }, - }) - - let waitForOutboundPlaybackStartResolve - const waitForOutboundPlaybackStart = new Promise((resolve) => { - waitForOutboundPlaybackStartResolve = resolve - }) - let waitForOutboundPlaybackEndResolve - const waitForOutboundPlaybackEnd = new Promise((resolve) => { - waitForOutboundPlaybackEndResolve = resolve - }) - - client.on('call.received', async (call) => { - console.log( - 'Inbound - Got call', - call.id, - call.from, - call.to, - call.direction - ) + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + // logWsTraffic: true, + }, + }) - try { - const earlyMedia = await call.playTTS({ - text: 'This is early media. I repeat: This is early media.', - }) - tap.equal( - call.id, - earlyMedia.callId, - 'Inbound - earlyMedia returns the same instance' - ) - - await earlyMedia.ended() - tap.equal( - earlyMedia.state, - 'finished', - 'Inbound - earlyMedia state is finished' - ) - - const resultAnswer = await call.answer() - tap.ok(resultAnswer.id, 'Inbound - Call answered') - tap.equal( - call.id, - resultAnswer.id, - 'Inbound - Call answered gets the same instance' - ) - - try { - // Play an invalid audio - const fakePlay = call.playAudio({ - url: 'https://cdn.fake.com/default-music/fake.mp3', - }) - - const waitForPlaybackFailed = new Promise((resolve) => { - call.on('playback.failed', (playback) => { - tap.equal( - playback.state, - 'error', - 'Inbound - playback has failed' - ) - resolve(true) + let inboundCalls = 0 + let startedPlaybacks = 0 + let failedPlaybacks = 0 + let endedPlaybacks = 0 + + const unsubVoice = await client.voice.listen({ + topics: [domainApp.call_relay_context, 'home'], + onCallReceived: async (call) => { + try { + inboundCalls++ + + // Since we are running an early media before answering the call + // The server will keep sending the call.receive event unless we answer or pass it. + if (inboundCalls > 1) { + await call.pass() + return + } + + const unsubCall = await call.listen({ + onPlaybackStarted: () => { + startedPlaybacks++ + }, + onPlaybackFailed: () => { + failedPlaybacks++ + }, + onPlaybackEnded: () => { + endedPlaybacks++ + }, }) - }) - // Wait for the inbound audio to failed - await waitForPlaybackFailed - - // Resolve late so that we attach `playback.failed` and wait for it - await fakePlay - } catch (error) { - tap.equal( - call.id, - error.callId, - 'Inbound - fakePlay returns the same instance' - ) - } - const playback = await call.playTTS({ - text: 'Random TTS message while the call is up. Thanks and good bye!', - }) - tap.equal( - call.id, - playback.callId, - 'Inbound - playback returns the same instance' - ) - await playback.ended() - - tap.equal( - playback.state, - 'finished', - 'Inbound - playback state is finished' - ) - - // Callee hangs up a call - await call.hangup() - } catch (error) { - reject(4) - } - }) + const earlyMedia = await call.playTTS({ + text: 'This is early media. I repeat: This is early media.', + listen: { + onStarted: (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'Inbound - Playback started' + ) + tap.equal(playback.state, 'playing', 'Playback correct state') + }, + }, + }) + tap.equal( + call.id, + earlyMedia.callId, + 'Inbound - earlyMedia returns the same instance' + ) + + await earlyMedia.ended() + tap.equal( + earlyMedia.state, + 'finished', + 'Inbound - earlyMedia state is finished' + ) + + const resultAnswer = await call.answer() + tap.ok(resultAnswer.id, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Play an invalid audio + const fakeAudio = await call.playAudio({ + url: 'https://cdn.fake.com/default-music/fake.mp3', + listen: { + onFailed: (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'Inbound - fakeAudio playback failed' + ) + tap.equal(playback.state, 'error', 'Playback correct state') + }, + }, + }) - try { - const call = await client.dialSip({ + await fakeAudio.ended() + + const playback = await call.playTTS({ + text: 'Random TTS message while the call is up. Thanks and good bye!', + listen: { + onEnded: (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'Inbound - Playback ended' + ) + tap.equal( + playback.state, + 'finished', + 'Playback correct state' + ) + }, + }, + }) + await playback.ended() + + tap.equal(startedPlaybacks, 3, 'Inbound - Started playback count') + tap.equal(failedPlaybacks, 1, 'Inbound - Started failed count') + tap.equal(endedPlaybacks, 2, 'Inbound - Started ended count') + + await unsubCall() + + // Callee hangs up a call + await call.hangup() + } catch (error) { + console.error('Error inbound call', error) + } + }, + }) + + const call = await client.voice.dialSip({ to: makeSipDomainAppAddress({ name: 'to', domain: domainApp.domain, @@ -128,26 +147,31 @@ const handler: TestHandler = ({ domainApp }) => { }), timeout: 30, maxPricePerMinute: 10, + listen: { + onPlaybackStarted: (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'Outbound - Playback started' + ) + tap.equal(playback.state, 'playing', 'Playback correct state') + }, + }, }) tap.ok(call.id, 'Outbound - Call resolved') - call.on('playback.started', (playback) => { - tap.equal(playback.state, 'playing', 'Outbound - Playback has started') - waitForOutboundPlaybackStartResolve() - }) - - call.on('playback.ended', (playback) => { - tap.equal( - playback.state, - 'finished', - 'Outbound - Playback has finished' - ) - waitForOutboundPlaybackEndResolve() - }) - - // Play an audio const playAudio = await call.playAudio({ url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + listen: { + onEnded: (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'Outbound - Playback ended' + ) + tap.equal(playback.state, 'finished', 'Playback correct state') + }, + }, }) tap.equal( call.id, @@ -155,11 +179,7 @@ const handler: TestHandler = ({ domainApp }) => { 'Outbound - Playback returns the same instance' ) - // Wait for the outbound audio to start - await waitForOutboundPlaybackStart - - // Wait for the outbound audio to end (callee hung up the call or audio ended) - await waitForOutboundPlaybackEnd + await unsubVoice() const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const const results = await Promise.all( @@ -176,9 +196,11 @@ const handler: TestHandler = ({ domainApp }) => { } }) + client.disconnect() + resolve(0) } catch (error) { - console.error('Outbound - voicePlaybackMultiple error', error) + console.error('VoicePlaybackMultiple error', error) reject(4) } }) diff --git a/internal/e2e-realtime-api/src/voicePrompt.test.ts b/internal/e2e-realtime-api/src/voicePrompt.test.ts deleted file mode 100644 index 10f6ea763..000000000 --- a/internal/e2e-realtime-api/src/voicePrompt.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import tap from 'tap' -import { Voice } from '@signalwire/realtime-api' -import { - type TestHandler, - createTestRunner, - makeSipDomainAppAddress, -} from './utils' - -const handler: TestHandler = ({ domainApp }) => { - if (!domainApp) { - throw new Error('Missing domainApp') - } - return new Promise(async (resolve, reject) => { - const client = new Voice.Client({ - host: process.env.RELAY_HOST, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - topics: [domainApp.call_relay_context], - debug: { - logWsTraffic: true, - }, - }) - - let waitForCallAnswerResolve: (value: void) => void - const waitForCallAnswer = new Promise((resolve) => { - waitForCallAnswerResolve = resolve - }) - let waitForPromptStartResolve - const waitForPromptStart = new Promise((resolve) => { - waitForPromptStartResolve = resolve - }) - let waitForSendDigitsResolve: (value: void) => void - const waitForSendDigits = new Promise((resolve) => { - waitForSendDigitsResolve = resolve - }) - - client.on('call.received', async (call) => { - console.log( - 'Inbound - Got call', - call.id, - call.from, - call.to, - call.direction - ) - - try { - const resultAnswer = await call.answer() - tap.ok(resultAnswer.id, 'Inbound - Call answered') - tap.equal( - call.id, - resultAnswer.id, - 'Inbound - Call answered gets the same instance' - ) - - // Resolve the answer promise to inform the caller - waitForCallAnswerResolve() - - // Wait for the prompt to begin from the caller side - await waitForPromptStart - - // Send digits 1234 to the caller - const sendDigits = await call.sendDigits('1w2w3w4w#') - tap.equal( - call.id, - sendDigits.id, - 'Inbound - sendDigit returns the same instance' - ) - - // Resolve the send digits promise to inform the caller - waitForSendDigitsResolve() - - // Callee hangs up a call - await call.hangup() - } catch (error) { - console.error('Inbound - Error', error) - reject(4) - } - }) - - try { - const call = await client.dialSip({ - to: makeSipDomainAppAddress({ - name: 'to', - domain: domainApp.domain, - }), - from: makeSipDomainAppAddress({ - name: 'from', - domain: domainApp.domain, - }), - timeout: 30, - }) - tap.ok(call.id, 'Outbound - Call resolved') - - // Wait until callee answers the call - await waitForCallAnswer - - // Caller starts a prompt - const prompt = await call.prompt({ - playlist: new Voice.Playlist({ volume: 1.0 }).add( - Voice.Playlist.TTS({ - text: 'Welcome to SignalWire! Please enter your 4 digits PIN', - }) - ), - digits: { - max: 4, - digitTimeout: 10, - terminators: '#', - }, - }) - tap.equal( - call.id, - prompt.callId, - 'Outbound - Prompt returns the same instance' - ) - - // Resolve the prompt promise to inform the callee - waitForPromptStartResolve() - - // Wait for the callee to send digits - await waitForSendDigits - - // Compare what caller has received - const recDigits = await prompt.ended() - tap.equal(recDigits.digits, '1234', 'Outbound - Received the same digit') - - // Resolve if the call has ended or ending - const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const - const results = await Promise.all( - waitForParams.map((params) => call.waitFor(params as any)) - ) - waitForParams.forEach((value, i) => { - if (typeof value === 'string') { - tap.ok(results[i], `"${value}": completed successfully.`) - } else { - tap.ok( - results[i], - `${JSON.stringify(value)}: completed successfully.` - ) - } - }) - - resolve(0) - } catch (error) { - console.error('Outbound - voicePrompt error', error) - reject(4) - } - }) -} - -async function main() { - const runner = createTestRunner({ - name: 'Voice Prompt E2E', - testHandler: handler, - executionTime: 60_000, - useDomainApp: true, - }) - - await runner.run() -} - -main() diff --git a/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts new file mode 100644 index 000000000..08c2c448a --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts @@ -0,0 +1,205 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_PROPS, CALL_PROMPT_PROPS } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + // logWsTraffic: true, + }, + }) + + const unsubVoiceOffice = await client.voice.listen({ + topics: ['office'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Send digits 1234 to the caller + const sendDigits = await call.sendDigits('1w2w3w4w#') + tap.equal( + call.id, + sendDigits.id, + 'Inbound - sendDigit returns the same instance' + ) + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const unsubVoiceHome = await client.voice.listen({ + topics: ['home'], + // This should never run since VOICE_DIAL_TO_NUMBER is listening only for "office" topic + onCallReceived: async (call) => { + tap.notOk(call, 'Inbound - Home topic received a call') + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + onPromptStarted: (prompt) => { + tap.hasProps( + prompt, + CALL_PROMPT_PROPS, + 'voice.dialPhone: Prompt started' + ) + }, + onPromptUpdated: (prompt) => { + tap.notOk(prompt.id, 'voice.dialPhone: Prompt updated') + }, + onPromptFailed: (prompt) => { + tap.notOk(prompt.id, 'voice.dialPhone: Prompt failed') + }, + onPromptEnded: (prompt) => { + tap.hasProps( + prompt, + CALL_PROMPT_PROPS, + 'voice.dialPhone: Prompt ended' + ) + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const unsubCall = await call.listen({ + onPromptStarted: (prompt) => { + tap.hasProps(prompt, CALL_PROMPT_PROPS, 'call.listen: Prompt started') + }, + onPromptUpdated: (prompt) => { + tap.notOk(prompt.id, 'call.listen: Prompt updated') + }, + onPromptFailed: (prompt) => { + tap.notOk(prompt.id, 'call.listen: Prompt failed') + }, + onPromptEnded: (prompt) => { + // NotOk since we unsubscribe this listener before the prompt stops + tap.notOk(prompt.id, 'call.listen: Prompt ended') + }, + }) + + const prompt = await call.promptRingtone({ + name: 'it', + duration: 10, + digits: { + max: 5, + digitTimeout: 2, + terminators: '#*', + }, + listen: { + onStarted: (prompt) => { + tap.hasProps( + prompt, + CALL_PROMPT_PROPS, + 'call.promptRingtone: Prompt started' + ) + }, + onUpdated: (prompt) => { + tap.notOk(prompt.id, 'call.promptRingtone: Prompt updated') + }, + onFailed: (prompt) => { + tap.notOk(prompt.id, 'call.promptRingtone: Prompt failed') + }, + onEnded: (_prompt) => { + tap.hasProps( + _prompt, + CALL_PROMPT_PROPS, + 'call.promptRingtone: Prompt ended' + ) + tap.equal( + _prompt.id, + prompt.id, + 'call.promptRingtone: Prompt correct id' + ) + }, + }, + }) + + const unsubPrompt = await prompt.listen({ + onStarted: (prompt) => { + // NotOk since the listener is attached after the call.prompt has resolved + tap.notOk(prompt.id, 'prompt.listen: Prompt stared') + }, + onUpdated: (prompt) => { + tap.notOk(prompt.id, 'prompt.listen: Prompt updated') + }, + onFailed: (prompt) => { + tap.notOk(prompt.id, 'prompt.listen: Prompt failed') + }, + onEnded: (_prompt) => { + tap.hasProps( + _prompt, + CALL_PROMPT_PROPS, + 'prompt.listen: Prompt ended' + ) + tap.equal(_prompt.id, prompt.id, 'prompt.listen: Prompt correct id') + }, + }) + + await unsubCall() + + console.log('Waiting for the digits from the inbound call') + + // Compare what caller has received + const recDigits = await prompt.ended() + tap.equal(recDigits.digits, '1234', 'Outbound - Received the same digit') + + // Resolve if the call has ended or ending + const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const + const results = await Promise.all( + waitForParams.map((params) => call.waitFor(params as any)) + ) + waitForParams.forEach((value, i) => { + if (typeof value === 'string') { + tap.ok(results[i], `"${value}": completed successfully.`) + } else { + tap.ok( + results[i], + `${JSON.stringify(value)}: completed successfully.` + ) + } + }) + + await unsubVoiceOffice() + + await unsubVoiceHome() + + await unsubPrompt() + + client.disconnect() + + resolve(0) + } catch (error) { + console.error('VoicePromptAllListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Prompt with all Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts new file mode 100644 index 000000000..b438d693e --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts @@ -0,0 +1,134 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PLAYBACK_PROPS, + CALL_PROPS, + CALL_PROMPT_PROPS, +} from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + // logWsTraffic: true, + }, + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Send digits 1234 to the caller + const sendDigits = await call.sendDigits('1w2w3w4w#') + tap.equal( + call.id, + sendDigits.id, + 'Inbound - sendDigit returns the same instance' + ) + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const unsubCall = await call.listen({ + onPromptStarted: (prompt) => { + tap.hasProps(prompt, CALL_PROMPT_PROPS, 'Prompt started') + }, + onPromptUpdated: (prompt) => { + tap.notOk(prompt.id, 'Prompt updated') + }, + onPromptFailed: (prompt) => { + tap.notOk(prompt.id, 'Prompt failed') + }, + onPromptEnded: (prompt) => { + tap.hasProps(prompt, CALL_PROMPT_PROPS, 'Prompt ended') + }, + onPlaybackEnded: (playback) => { + tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback started') + }, + }) + + const prompt = await call.promptAudio({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, + }) + tap.equal( + call.id, + prompt.callId, + 'Outbound - Prompt returns the same call instance' + ) + + console.log('Waiting for the digits from the inbound call') + + // Compare what caller has received + const recDigits = await prompt.ended() + tap.equal(recDigits.digits, '1234', 'Outbound - Received the same digit') + + // Resolve if the call has ended or ending + const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const + const results = await Promise.all( + waitForParams.map((params) => call.waitFor(params as any)) + ) + waitForParams.forEach((value, i) => { + if (typeof value === 'string') { + tap.ok(results[i], `"${value}": completed successfully.`) + } else { + tap.ok( + results[i], + `${JSON.stringify(value)}: completed successfully.` + ) + } + }) + + await unsubVoice() + + await unsubCall() + + client.disconnect() + + resolve(0) + } catch (error) { + console.error('VoicePromptCallListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Prompt with Call Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts new file mode 100644 index 000000000..99d8e3f2f --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts @@ -0,0 +1,136 @@ +import tap from 'tap' +import { SignalWire, Voice } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PROMPT_PROPS, + CALL_PROPS, + CALL_PLAYBACK_PROPS, +} from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + // logWsTraffic: true, + }, + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Send digits 1234 to the caller + const sendDigits = await call.sendDigits('1w2w3w4w#') + tap.equal( + call.id, + sendDigits.id, + 'Inbound - sendDigit returns the same instance' + ) + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + onPlaybackStarted: (playback) => { + tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback started') + }, + onPromptStarted: (prompt) => { + tap.hasProps(prompt, CALL_PROMPT_PROPS, 'Prompt started') + }, + onPromptUpdated: (prompt) => { + tap.notOk(prompt.id, 'Prompt updated') + }, + onPromptFailed: (prompt) => { + tap.notOk(prompt.id, 'Prompt failed') + }, + onPromptEnded: (prompt) => { + tap.hasProps(prompt, CALL_PROMPT_PROPS, 'Prompt ended') + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + // Caller starts a prompt + const prompt = await call.prompt({ + playlist: new Voice.Playlist({ volume: 1.0 }).add( + Voice.Playlist.TTS({ + text: 'Welcome to SignalWire! Please enter your 4 digits PIN', + }) + ), + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, + }) + tap.equal( + call.id, + prompt.callId, + 'Outbound - Prompt returns the same call instance' + ) + + console.log('Waiting for the digits from the inbound call') + + // Compare what caller has received + const recDigits = await prompt.ended() + tap.equal(recDigits.digits, '1234', 'Outbound - Received the same digit') + + // Resolve if the call has ended or ending + const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const + const results = await Promise.all( + waitForParams.map((params) => call.waitFor(params as any)) + ) + waitForParams.forEach((value, i) => { + if (typeof value === 'string') { + tap.ok(results[i], `"${value}": completed successfully.`) + } else { + tap.ok( + results[i], + `${JSON.stringify(value)}: completed successfully.` + ) + } + }) + + await unsubVoice() + + client.disconnect() + + resolve(0) + } catch (error) { + console.error('VoicePromptDialListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Prompt with Dial Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePromptListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptListeners.test.ts new file mode 100644 index 000000000..f7543bc3e --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePromptListeners.test.ts @@ -0,0 +1,164 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PLAYBACK_PROPS, + CALL_PROPS, + CALL_PROMPT_PROPS, +} from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + // logWsTraffic: true, + }, + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + // Send digits 1234 to the caller + const sendDigits = await call.sendDigits('1w2w3w4w#') + tap.equal( + call.id, + sendDigits.id, + 'Inbound - sendDigit returns the same instance' + ) + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const prompt = await call.promptTTS({ + text: 'Welcome to SignalWire! Please enter your 4 digits PIN', + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, + listen: { + onStarted: (prompt) => { + tap.hasProps( + prompt, + CALL_PROMPT_PROPS, + 'call.promptTTS: Prompt started' + ) + }, + onUpdated: (prompt) => { + tap.notOk(prompt.id, 'call.promptTTS: Prompt updated') + }, + onFailed: (prompt) => { + tap.notOk(prompt.id, 'call.promptTTS: Prompt failed') + }, + onEnded: (_prompt) => { + tap.hasProps( + _prompt, + CALL_PROMPT_PROPS, + 'call.promptTTS: Prompt ended' + ) + tap.equal( + _prompt.id, + prompt.id, + 'call.promptTTS: Prompt correct id' + ) + }, + }, + }) + tap.equal( + call.id, + prompt.callId, + 'Outbound - Prompt returns the same call instance' + ) + + const unsubPrompt = await prompt.listen({ + onStarted: (prompt) => { + // NotOk since this listener is being attached after the call.prompt promise has resolved + tap.notOk(prompt.id, 'prompt.listen: Prompt stared') + }, + onUpdated: (prompt) => { + tap.notOk(prompt.id, 'prompt.listen: Prompt updated') + }, + onFailed: (prompt) => { + tap.notOk(prompt.id, 'prompt.listen: Prompt failed') + }, + onEnded: (_prompt) => { + tap.hasProps( + _prompt, + CALL_PROMPT_PROPS, + 'prompt.listen: Prompt ended' + ) + tap.equal(_prompt.id, prompt.id, 'prompt.listen: Prompt correct id') + }, + }) + + console.log('Waiting for the digits from the inbound call') + + // Compare what caller has received + const recDigits = await prompt.ended() + tap.equal(recDigits.digits, '1234', 'Outbound - Received the same digit') + + // Resolve if the call has ended or ending + const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const + const results = await Promise.all( + waitForParams.map((params) => call.waitFor(params as any)) + ) + waitForParams.forEach((value, i) => { + if (typeof value === 'string') { + tap.ok(results[i], `"${value}": completed successfully.`) + } else { + tap.ok( + results[i], + `${JSON.stringify(value)}: completed successfully.` + ) + } + }) + + await unsubVoice() + + await unsubPrompt() + + client.disconnect() + + resolve(0) + } catch (error) { + console.error('VoicePromptListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Prompt Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts new file mode 100644 index 000000000..7bd9ebaec --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts @@ -0,0 +1,158 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_PROPS, CALL_RECORD_PROPS } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) + + const unsubVoiceOffice = await client.voice.listen({ + topics: ['office'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const unsubVoiceHome = await client.voice.listen({ + topics: ['home'], + // This should never run since VOICE_DIAL_TO_NUMBER is listening only for "office" topic + onCallReceived: async (call) => { + tap.notOk(call, 'Inbound - Home topic received a call') + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoiceOffice() + + await unsubVoiceHome() + + await unsubRecord?.() + + client.disconnect() + + resolve(0) + } + }, + onRecordingStarted: (recording) => { + tap.hasProps( + recording, + CALL_RECORD_PROPS, + 'voice.dialPhone: Recording started' + ) + tap.equal( + recording.state, + 'recording', + 'voice.dialPhone: Recording correct state' + ) + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const unsubCall = await call.listen({ + onRecordingStarted: (recording) => { + tap.hasProps( + recording, + CALL_RECORD_PROPS, + 'call.listen: Recording started' + ) + tap.equal( + recording.state, + 'recording', + 'call.listen: Recording correct state' + ) + }, + onRecordingEnded: (recording) => { + // NotOk since we unsubscribe this listener before the recording stops + tap.notOk(recording.id, 'call.listen: Recording ended') + }, + }) + + const record = await call.recordAudio({ + listen: { + onStarted: async (recording) => { + tap.hasProps( + recording, + CALL_RECORD_PROPS, + 'call.recordAudio: Recording started' + ) + tap.equal( + recording.state, + 'recording', + 'call.recordAudio: Recording correct state' + ) + + await unsubCall() + + await record.stop() + }, + }, + }) + + const unsubRecord = await record.listen({ + onStarted: (recording) => { + // NotOk since the listener is attached after the call.record has resolved + tap.notOk(recording.id, 'record.listen: Recording stared') + }, + onEnded: async (recording) => { + tap.hasProps( + recording, + CALL_RECORD_PROPS, + 'record.listen: Recording ended' + ) + tap.equal( + recording.id, + record.id, + 'record.listen: Recording correct id' + ) + tap.equal( + recording.state, + 'finished', + 'record.listen: Recording correct state' + ) + + await call.hangup() + }, + }) + } catch (error) { + console.error('VoiceRecordingAllListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Record with all Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts new file mode 100644 index 000000000..378da27b4 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts @@ -0,0 +1,88 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_RECORD_PROPS, CALL_PROPS } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const unsubCall = await call.listen({ + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoice() + + await unsubCall?.() + + client.disconnect() + + resolve(0) + } + }, + onRecordingStarted: (recording) => { + tap.hasProps(recording, CALL_RECORD_PROPS, 'Recording started') + tap.equal(recording.state, 'recording', 'Recording correct state') + }, + onRecordingFailed: (recording) => { + tap.notOk(recording.id, 'Recording failed') + }, + onRecordingEnded: async (recording) => { + tap.hasProps(recording, CALL_RECORD_PROPS, 'Recording ended') + tap.equal(recording.state, 'finished', 'Recording correct state') + + await call.hangup() + }, + }) + + const record = await call.recordAudio() + + await record.stop() + } catch (error) { + console.error('VoiceRecordCallListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Record with Call Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts new file mode 100644 index 000000000..f12dcc6ac --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts @@ -0,0 +1,85 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_RECORD_PROPS, CALL_PROPS, sleep } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoice() + + client.disconnect() + + resolve(0) + } + }, + onRecordingStarted: (recording) => { + tap.hasProps(recording, CALL_RECORD_PROPS, 'Recording started') + tap.equal(recording.state, 'recording', 'Recording correct state') + }, + onRecordingFailed: (recording) => { + tap.notOk(recording.id, 'Recording failed') + }, + onRecordingEnded: async (recording) => { + tap.hasProps(recording, CALL_RECORD_PROPS, 'Recording ended') + tap.equal(recording.state, 'finished', 'Recording correct state') + + await call.hangup() + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const record = await call.recordAudio() + + await record.stop() + } catch (error) { + console.error('VoiceRecordDialListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Record with Dial Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts new file mode 100644 index 000000000..924e53b6d --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts @@ -0,0 +1,106 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { createTestRunner, CALL_RECORD_PROPS, CALL_PROPS } from './utils' + +const handler = async () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoice() + + await unsubRecord?.() + + client.disconnect() + + resolve(0) + } + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const record = await call.recordAudio({ + listen: { + onStarted: (recording) => { + tap.hasProps(recording, CALL_RECORD_PROPS, 'Recording started') + tap.equal(recording.state, 'recording', 'Recording correct state') + }, + onFailed: (recording) => { + tap.notOk(recording.id, 'Recording failed') + }, + onEnded: async (recording) => { + tap.hasProps(recording, CALL_RECORD_PROPS, 'Recording ended') + tap.equal(recording.id, record.id, 'Recording correct id') + tap.equal(recording.state, 'finished', 'Recording correct state') + }, + }, + }) + + const unsubRecord = await record.listen({ + onStarted: (recording) => { + // NotOk since this listener is being attached after the call.record promise has resolved + tap.notOk(recording.id, 'Recording started') + }, + onFailed: (recording) => { + tap.notOk(recording.id, 'Recording failed') + }, + onEnded: async (recording) => { + tap.hasProps(recording, CALL_RECORD_PROPS, 'Recording ended') + tap.equal(recording.id, record.id, 'Recording correct id') + tap.equal(recording.state, 'finished', 'Recording correct state') + + await call.hangup() + }, + }) + + await record.stop() + } catch (error) { + console.error('VoiceRecordListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Record Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceRecordMultiple.test.ts b/internal/e2e-realtime-api/src/voiceRecordMultiple.test.ts index 8101d62dd..0899f5af1 100644 --- a/internal/e2e-realtime-api/src/voiceRecordMultiple.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordMultiple.test.ts @@ -1,89 +1,76 @@ import tap from 'tap' -import { Voice } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' import { type TestHandler, createTestRunner, makeSipDomainAppAddress, + CALL_RECORD_PROPS, + CALL_PROPS, } from './utils' const handler: TestHandler = ({ domainApp }) => { if (!domainApp) { throw new Error('Missing domainApp') } + return new Promise(async (resolve, reject) => { - const client = new Voice.Client({ - host: process.env.RELAY_HOST, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - contexts: [domainApp.call_relay_context], - debug: { - logWsTraffic: true, - }, - }) - - let waitForTheAnswerResolve: (value: void) => void - const waitForTheAnswer = new Promise((resolve) => { - waitForTheAnswerResolve = resolve - }) - let waitForOutboundRecordFinishResolve - const waitForOutboundRecordFinish = new Promise((resolve) => { - waitForOutboundRecordFinishResolve = resolve - }) - - client.on('call.received', async (call) => { - console.log( - 'Inbound - Got call', - call.id, - call.from, - call.to, - call.direction - ) + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + logWsTraffic: true, + }, + }) - try { - const resultAnswer = await call.answer() - tap.ok(resultAnswer.id, 'Inbound - Call answered') - tap.equal( - call.id, - resultAnswer.id, - 'Inbound - Call answered gets the same instance' - ) - - // Resolve the answer promise to inform the caller - waitForTheAnswerResolve() - - const firstRecording = await call.recordAudio({ terminators: '#' }) - tap.equal( - call.id, - firstRecording.callId, - 'Inbound - firstRecording returns the same instance' - ) - tap.equal( - firstRecording.state, - 'recording', - 'Inbound - firstRecording state is "recording"' - ) - - await call.sendDigits('#') - - await firstRecording.ended() - tap.match( - firstRecording.state, - /finished|no_input/, - 'Inbound - firstRecording state is "finished"' - ) - - // Wait till the second recording ends - await waitForOutboundRecordFinish - - // Callee hangs up a call - await call.hangup() - } catch (error) { - reject(4) - } - }) + const unsubVoice = await client.voice.listen({ + topics: [domainApp.call_relay_context], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + + const record = await call.recordAudio({ + terminators: '#', + listen: { + async onFailed(recording) { + tap.hasProps( + recording, + CALL_RECORD_PROPS, + 'Inbound - Recording failed' + ) + tap.equal( + recording.state, + 'no_input', + 'Recording correct state' + ) + }, + }, + }) + tap.equal( + call.id, + record.callId, + 'Inbound - Record returns the same call instance' + ) + + await call.sendDigits('#') + + await record.ended() + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) - try { - const call = await client.dialSip({ + const call = await client.voice.dialSip({ to: makeSipDomainAppAddress({ name: 'to', domain: domainApp.domain, @@ -93,36 +80,37 @@ const handler: TestHandler = ({ domainApp }) => { domain: domainApp.domain, }), timeout: 30, + listen: { + onRecordingStarted: (playback) => { + tap.hasProps( + playback, + CALL_RECORD_PROPS, + 'Outbound - Recording started' + ) + tap.equal(playback.state, 'recording', 'Recording correct state') + }, + }, }) tap.ok(call.id, 'Outbound - Call resolved') - // Wait until callee answers the call - await waitForTheAnswer - - const secondRecording = await call.recordAudio({ terminators: '*' }) + const record = await call.recordAudio({ + terminators: '*', + }) tap.equal( call.id, - secondRecording.callId, - 'Outbound - secondRecording returns the same instance' - ) - tap.equal( - secondRecording.state, - 'recording', - 'Outbound - secondRecording state is "recording"' + record.callId, + 'Outbound - Recording returns the same call instance' ) await call.sendDigits('*') - await secondRecording.ended() + await record.ended() tap.match( - secondRecording.state, + record.state, /finished|no_input/, - 'Outbound - secondRecording state is "finished"' + 'Outbound - Recording state is "finished"' ) - // Resolve the record finish promise to inform the callee - waitForOutboundRecordFinishResolve() - const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const const results = await Promise.all( waitForParams.map((params) => call.waitFor(params as any)) @@ -140,7 +128,7 @@ const handler: TestHandler = ({ domainApp }) => { resolve(0) } catch (error) { - console.error('Outbound - voiceRecordMultiple error', error) + console.error('VoiceRecordMultiple error', error) reject(4) } }) diff --git a/internal/e2e-realtime-api/src/voiceRecording.test.ts b/internal/e2e-realtime-api/src/voiceRecording.test.ts deleted file mode 100644 index 81cf78555..000000000 --- a/internal/e2e-realtime-api/src/voiceRecording.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import tap from 'tap' -import { Voice } from '@signalwire/realtime-api' -import { - type TestHandler, - createTestRunner, - makeSipDomainAppAddress, -} from './utils' - -const CALL_RECORDING_GETTERS = [ - 'id', - 'callId', - 'nodeId', - 'controlId', - 'state', - 'url', - 'size', - 'duration', - 'record', -] - -const handler: TestHandler = ({ domainApp }) => { - if (!domainApp) { - throw new Error('Missing domainApp') - } - return new Promise(async (resolve, reject) => { - // Expect exact 12 tests - tap.plan(12) - - const client = new Voice.Client({ - host: process.env.RELAY_HOST, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - contexts: [domainApp.call_relay_context], - debug: { - logWsTraffic: true, - }, - }) - - let waitForTheAnswerResolve: (value: void) => void - const waitForTheAnswer = new Promise((resolve) => { - waitForTheAnswerResolve = resolve - }) - - client.on('call.received', async (call) => { - console.log( - 'Inbound - Got call', - call.id, - call.from, - call.to, - call.direction - ) - - try { - const resultAnswer = await call.answer() - tap.ok(resultAnswer.id, 'Inbound - Call answered') - tap.equal( - call.id, - resultAnswer.id, - 'Inbound - Call answered gets the same instance' - ) - - // Resolve the answer promise to inform the caller - waitForTheAnswerResolve() - - call.on('recording.updated', (recording) => { - tap.match( - recording.state, - /paused|recording/, - 'Outbound - Recording has updated' - ) - }) - - call.on('recording.ended', (recording) => { - tap.hasProps( - recording, - CALL_RECORDING_GETTERS, - 'Recording has valid properties' - ) - tap.equal( - recording.state, - 'finished', - 'Outbound - Recording has ended' - ) - tap.equal( - recording.callId, - call.id, - 'Outbound - Recording has the same callId' - ) - }) - - // Start the recording - const recording = await call.recordAudio({ direction: 'both' }) - tap.equal( - call.id, - recording.callId, - 'Inbound - Recording returns the same instance' - ) - - const playback = await call.playTTS({ - text: 'Hello, this is the callee side. How can i help you?', - }) - - await recording.pause() - - await playback.ended() - - await recording.resume() - - // Stop the recording using terminator - await call.sendDigits('#') - - // Wait for the outbound recording to end - await recording.ended() - - // Callee hangs up a call - await call.hangup() - } catch (error) { - console.error('Inbound - Error', error) - reject(4) - } - }) - - try { - const call = await client.dialSip({ - to: makeSipDomainAppAddress({ - name: 'to', - domain: domainApp.domain, - }), - from: makeSipDomainAppAddress({ - name: 'from', - domain: domainApp.domain, - }), - timeout: 30, - }) - tap.ok(call.id, 'Outbound - Call resolved') - - // Wait until callee answers the call - await waitForTheAnswer - - // Play a valid audio - const playlist = new Voice.Playlist({ volume: 2 }) - .add( - Voice.Playlist.TTS({ - text: 'Hello, this is an automated welcome message. Enjoy!', - }) - ) - .add( - Voice.Playlist.TTS({ - text: 'Thank you for listening the welcome message.', - }) - ) - const playback = await call.play(playlist) - - await playback.ended() - - // Resolve if the call has ended or ending - const waitForParams = ['ended', 'ending', ['ending', 'ended']] as const - const results = await Promise.all( - waitForParams.map((params) => call.waitFor(params as any)) - ) - waitForParams.forEach((value, i) => { - if (typeof value === 'string') { - tap.ok(results[i], `"${value}": completed successfully.`) - } else { - tap.ok( - results[i], - `${JSON.stringify(value)}: completed successfully.` - ) - } - }) - - resolve(0) - } catch (error) { - console.error('Outbound - voiceRecording error', error) - reject(4) - } - }) -} - -async function main() { - const runner = createTestRunner({ - name: 'Voice Recording E2E', - testHandler: handler, - executionTime: 30_000, - useDomainApp: true, - }) - - await runner.run() -} - -main() diff --git a/internal/e2e-realtime-api/src/voiceTap.test.ts b/internal/e2e-realtime-api/src/voiceTap.test.ts deleted file mode 100644 index cb1d7e828..000000000 --- a/internal/e2e-realtime-api/src/voiceTap.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import tap from 'tap' -import { Voice } from '@signalwire/realtime-api' -import { - type TestHandler, - createTestRunner, - makeSipDomainAppAddress, -} from './utils' - -const handler: TestHandler = ({ domainApp }) => { - if (!domainApp) { - throw new Error('Missing domainApp') - } - return new Promise(async (resolve, reject) => { - const client = new Voice.Client({ - host: process.env.RELAY_HOST, - project: process.env.RELAY_PROJECT as string, - token: process.env.RELAY_TOKEN as string, - contexts: [domainApp.call_relay_context], - debug: { - logWsTraffic: true, - }, - }) - - let waitForTheAnswerResolve: (value: void) => void - const waitForTheAnswer = new Promise((resolve) => { - waitForTheAnswerResolve = resolve - }) - - client.on('call.received', async (call) => { - console.log( - 'Inbound - Got call', - call.id, - call.from, - call.to, - call.direction - ) - - try { - const resultAnswer = await call.answer() - tap.ok(resultAnswer.id, 'Inbound - Call answered') - tap.equal( - call.id, - resultAnswer.id, - 'Inbound - Call answered gets the same instance' - ) - - // Resolve the answer promise to inform the caller - waitForTheAnswerResolve() - - // Callee hangs up a call - await call.hangup() - } catch (error) { - console.error('Inbound - Error', error) - reject(4) - } - }) - - try { - const call = await client.dialSip({ - to: makeSipDomainAppAddress({ - name: 'to', - domain: domainApp.domain, - }), - from: makeSipDomainAppAddress({ - name: 'from', - domain: domainApp.domain, - }), - timeout: 30, - }) - tap.ok(call.id, 'Outbound - Call resolved') - - // Wait until callee answers the call - await waitForTheAnswer - - try { - // Start an audio tap - const tapAudio = await call.tapAudio({ - direction: 'both', - device: { - type: 'ws', - uri: 'wss://example.domain.com/endpoint', - }, - }) - - // Tap should fail due to wrong WSS - reject() - } catch (error) { - tap.ok(error, 'Outbound - Tap error') - resolve(0) - } - - resolve(0) - } catch (error) { - console.error('Outbound - voiceTap error', error) - reject(4) - } - }) -} - -async function main() { - const runner = createTestRunner({ - name: 'Voice Tap E2E', - testHandler: handler, - executionTime: 60_000, - useDomainApp: true, - }) - - await runner.run() -} - -main() diff --git a/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts new file mode 100644 index 000000000..61e102a29 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts @@ -0,0 +1,126 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { CALL_PROPS, CALL_TAP_PROPS, createTestRunner } from './utils' + +const handler = () => { + return new Promise(async (resolve, reject) => { + try { + const client = await SignalWire({ + host: process.env.RELAY_HOST || 'relay.swire.io', + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { + // logWsTraffic: true, + }, + }) + + // @FIXME: To run all the assert we need a correct websocket uri for tapAudio + tap.plan(4) + + const unsubVoice = await client.voice.listen({ + topics: ['office', 'home'], + onCallReceived: async (call) => { + try { + const resultAnswer = await call.answer() + tap.hasProps(call, CALL_PROPS, 'Inbound - Call answered') + tap.equal( + call.id, + resultAnswer.id, + 'Inbound - Call answered gets the same instance' + ) + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialPhone({ + to: process.env.VOICE_DIAL_TO_NUMBER as string, + from: process.env.VOICE_DIAL_FROM_NUMBER as string, + timeout: 30, + listen: { + onTapStarted: (callTap) => { + tap.hasProps( + callTap, + CALL_TAP_PROPS, + 'voice.dialPhone: Tap started' + ) + tap.equal( + callTap.callId, + call.id, + 'voice.dialPhone: Tap with correct call id' + ) + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const unsubCall = await call.listen({ + onTapEnded: (callTap) => { + tap.hasProps(callTap, CALL_TAP_PROPS, 'call.listen: Tap ended') + tap.equal( + callTap.callId, + call.id, + 'call.listen: Tap with correct call id' + ) + }, + }) + + try { + // Start an audio tap + const tapAudio = await call.tapAudio({ + direction: 'both', + device: { + type: 'ws', + uri: 'wss://example.domain.com/endpoint', + }, + listen: { + onStarted(callTap) { + tap.hasProps( + callTap, + CALL_TAP_PROPS, + 'call.tapAudio: Tap started' + ) + }, + }, + }) + + const unsubTapAudio = await tapAudio.listen({ + onEnded(callTap) { + tap.hasProps(callTap, CALL_TAP_PROPS, 'tapAudio.listen: Tap ended') + }, + }) + + // Tap should fail due to wrong WSS + reject() + } catch (error) { + tap.ok(error, 'Outbound - Tap error') + + await unsubVoice() + + await unsubCall() + + await call.hangup() + + client.disconnect() + + resolve(0) + } + } catch (error) { + console.error('VoiceTapAllListeners error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Tap with All Listeners E2E', + testHandler: handler, + executionTime: 30_000, + }) + + await runner.run() +} + +main() diff --git a/internal/playground-realtime-api/src/voice-dtmf-loop/index.ts b/internal/playground-realtime-api/src/voice-dtmf-loop/index.ts index c497660a4..cf69c3d55 100644 --- a/internal/playground-realtime-api/src/voice-dtmf-loop/index.ts +++ b/internal/playground-realtime-api/src/voice-dtmf-loop/index.ts @@ -1,14 +1,13 @@ -import { Voice } from '@signalwire/realtime-api' +import { SignalWire, Voice } from '@signalwire/realtime-api' async function run() { let maxDTMFErrors = 1 let errorCount = 0 const invalidDTMFs = ['0', '1', '2', '3'] - const client = new Voice.Client({ + const client = await SignalWire({ project: process.env.PROJECT as string, token: process.env.TOKEN as string, - contexts: [process.env.RELAY_CONTEXT as string], // logLevel: 'debug', // debug: { // logWsTraffic: true, @@ -57,12 +56,14 @@ async function run() { } try { - const call = await client.dialPhone({ + const call = await client.voice.dialPhone({ to: '+1..', from: process.env.FROM_NUMBER as string, - }) - call.on('call.state', (call) => { - console.log(`call.state ${call.state}`) + listen: { + onStateChanged: (call) => { + console.log(`call.state ${call.state}`) + }, + }, }) const result = await prompt(call) diff --git a/internal/playground-realtime-api/src/voice-inbound/index.ts b/internal/playground-realtime-api/src/voice-inbound/index.ts index 6f7f4b0b7..aa9ac5a61 100644 --- a/internal/playground-realtime-api/src/voice-inbound/index.ts +++ b/internal/playground-realtime-api/src/voice-inbound/index.ts @@ -1,44 +1,46 @@ -import { Voice } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' async function run() { try { - const client = new Voice.Client({ + const client = await SignalWire({ 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, }, }) - client.on('call.received', async (call) => { - console.log('Got call', call.id, call.from, call.to, call.direction) + await client.voice.listen({ + topics: [process.env.RELAY_CONTEXT as string], + onCallReceived: async (call) => { + console.log('Got call', call.id, call.from, call.to, call.direction) - try { - await call.answer() - console.log('Inbound call answered') + try { + await call.answer() + console.log('Inbound call answered') - const pb = await call.playTTS({ - text: "Hello! Welcome to Knee Rub's Weather Helpline. What place would you like to know the weather of?", - gender: 'male', - }) - await pb.ended() - console.log('Welcome text ok') + const pb = await call.playTTS({ + text: "Hello! Welcome to Knee Rub's Weather Helpline. What place would you like to know the weather of?", + gender: 'male', + }) + await pb.ended() + console.log('Welcome text ok') - const prompt = await call.promptTTS({ - text: 'Please enter 1 for Washington, 2 for California, 3 for washington weather message, 4 for california weather message, 5 if your tribe beeds to do a rain dance, 6 for me to call your friends who need to rain dance.', - digits: { - max: 1, - digitTimeout: 15, - }, - }) - const { type, digits, terminator } = await prompt.ended() - console.log('Received digits', type, digits, terminator) - } catch (error) { - console.error('Error answering inbound call', error) - } + const prompt = await call.promptTTS({ + text: 'Please enter 1 for Washington, 2 for California, 3 for washington weather message, 4 for california weather message, 5 if your tribe beeds to do a rain dance, 6 for me to call your friends who need to rain dance.', + digits: { + max: 1, + digitTimeout: 15, + }, + }) + const { type, digits, terminator } = await prompt.ended() + console.log('Received digits', type, digits, terminator) + } catch (error) { + console.error('Error answering inbound call', error) + } + }, }) } catch (error) { console.log('', error) diff --git a/internal/playground-realtime-api/src/voice/index.ts b/internal/playground-realtime-api/src/voice/index.ts index 5e9b73ceb..6c5887a42 100644 --- a/internal/playground-realtime-api/src/voice/index.ts +++ b/internal/playground-realtime-api/src/voice/index.ts @@ -1,4 +1,4 @@ -import { Voice } from '@signalwire/realtime-api' +import { SignalWire, Voice } from '@signalwire/realtime-api' const sleep = (ms = 3000) => { return new Promise((r) => { @@ -11,263 +11,287 @@ const RUN_DETECTOR = false async function run() { try { - const client = new Voice.Client({ + const client = await SignalWire({ 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, - // }, + 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') - 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) - } + let inboundCall: Voice.Call + + const unsubVoice = await client.voice.listen({ + topics: [process.env.RELAY_CONTEXT as string], + async onCallReceived(call) { + console.log('Got call', call.id, call.from, call.to, call.direction) + + try { + inboundCall = call + 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) + } + }, }) + // 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.voice.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.ended() + console.log('Detect Result', result.type) + + await sleep() + } + try { - // 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, + const ringback = new Voice.Playlist().add( + Voice.Playlist.Ringtone({ + name: 'it', + }) + ) + console.log('call.connectPhone') + const peer = await call.connectPhone({ + from: process.env.FROM_NUMBER!, + to: process.env.CONNECT_NUMBER!, timeout: 30, + ringback, // optional + maxPricePerMinute: 10, }) - - console.log('Dial resolved!', call.id) - - if (RUN_DETECTOR) { - // See the `call.received` handler - const detect = await call.detectDigit() - const result = await detect.ended() - console.log('Detect Result', result.type) - - await sleep() - } - - try { - const peer = await call.connect({ - devices: new Voice.DeviceBuilder().add( - Voice.DeviceBuilder.Sip({ - from: 'sip:user1@domain.com', - to: 'sip:user2@domain.com', - timeout: 30, - }) - ), - ringback: new Voice.Playlist().add( - Voice.Playlist.Ringtone({ - name: 'it', - }) - ), + console.log('call.connectPhone resolve') + // const peer = await call.connect({ + // devices: new Voice.DeviceBuilder().add( + // Voice.DeviceBuilder.Sip({ + // from: 'sip:user1@domain.com', + // to: 'sip:user2@domain.com', + // timeout: 30, + // }) + // ), + // ringback, + // }) + + 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.disconnected() + + console.log('call.disconnected') + + const playlist = new Voice.Playlist({ volume: 2 }).add( + Voice.Playlist.TTS({ + text: 'Thank you, you are now disconnected from the peer', }) + ) + const pb = await call.play({ playlist }) - 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.disconnected() - - const playlist = new Voice.Playlist({ volume: 2 }).add( - Voice.Playlist.TTS({ - text: 'Thank you, you are now disconnected from the peer', - }) - ) - const pb = await call.play(playlist) - - await pb.ended() - } catch (error) { - console.error('Connect Error', error) - } + console.log('call.play') - call.on('tap.started', (p) => { - console.log('>> tap.started', p.id, p.state) - }) + await pb.ended() - call.on('tap.ended', (p) => { - console.log('>> tap.ended', p.id, p.state) - }) + console.log('pb.ended') + } catch (error) { + console.error('Connect Error', error) + } + try { const tap = await call.tapAudio({ direction: 'both', device: { type: 'ws', uri: 'wss://example.domain.com/endpoint', }, + listen: { + onStarted(p) { + console.log('>> tap.started', p.id, p.state) + }, + onEnded(p) { + console.log('>> tap.ended', p.id, p.state) + }, + }, }) await sleep(1000) console.log('>> Trying to stop', tap.id, tap.state) await tap.stop() + } catch (error) { + console.log('Tap failed', error) + } - 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({ - playlist: new Voice.Playlist({ volume: 1.0 }).add( - Voice.Playlist.TTS({ - text: 'Welcome to SignalWire! Please enter your 4 digits PIN', - }) - ), - digits: { - max: 4, - digitTimeout: 10, - terminators: '#', + const prompt = await call.prompt({ + playlist: new Voice.Playlist({ volume: 1.0 }).add( + Voice.Playlist.TTS({ + text: 'Welcome to SignalWire! Please enter your 4 digits PIN', + }) + ), + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, + listen: { + onStarted(p) { + console.log('>> prompt.started', p.id) }, - }) - - /** Wait for the result - sync way */ - // const { type, digits, terminator } = await prompt.ended() - // console.log('Prompt Output:', type, digits, terminator) - - 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) + onUpdated(p) { + console.log('>> prompt.updated', p.id) + }, + onFailed(p) { + console.log('>> prompt.failed', p.id, p.reason) + }, + onEnded(p) { + console.log( + '>> prompt.ended', + p.id, + p.type, + 'Digits: ', + p.digits, + 'Terminator', + p.terminator + ) + }, + }, + }) - 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) - }) + /** Wait for the result - sync way */ + // const { type, digits, terminator } = await prompt.ended() + // console.log('Prompt Output:', type, digits, terminator) - 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.ended() - - console.log('Playback STARTED!', playback.id) + console.log('Prompt STARTED!', prompt.id) + await prompt.setVolume(2.0) + await sleep() + await prompt.stop() + console.log('Prompt STOPPED!', prompt.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!') + const recording = await call.recordAudio({ + listen: { + onStarted(r) { + console.log('>> recording.started', r.id) + }, + onFailed(r) { + console.log('>> recording.failed', r.id, r.state) + }, + onEnded(r) { + console.log( + '>> recording.ended', + r.id, + r.state, + r.size, + r.duration, + r.url + ) + }, + }, + }) + console.log('Recording STARTED!', recording.id) - await sleep() - await recording.stop() - console.log( - 'Recording STOPPED!', - recording.id, - recording.state, - recording.size, - recording.duration, - recording.url + 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, + listen: { + onStarted(p) { + console.log('>> playback.started', p.id, p.state) + }, + onUpdated(p) { + console.log('>> playback.updated', p.id, p.state) + }, + onEnded(p) { + console.log('>> playback.ended', p.id, p.state) + }, + }, + }) - await call.hangup() - } catch (error) { - console.log('Error:', error) - } + // To wait for the playback to end (without pause/resume/stop it) + // await playback.ended() + + 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() client.disconnect() } catch (error) { diff --git a/internal/stack-tests/src/voice/app.ts b/internal/stack-tests/src/voice/app.ts index 2cc14520f..332cdece8 100644 --- a/internal/stack-tests/src/voice/app.ts +++ b/internal/stack-tests/src/voice/app.ts @@ -1,23 +1,20 @@ -import { Voice } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' import tap from 'tap' async function run() { try { - const voice = new Voice.Client({ + const client = await SignalWire({ host: process.env.RELAY_HOST || 'relay.swire.io', project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, - contexts: [process.env.RELAY_CONTEXT as string], }) - tap.ok(voice.on, 'voice.on is defined') - tap.ok(voice.once, 'voice.once is defined') - tap.ok(voice.off, 'voice.off is defined') - tap.ok(voice.removeAllListeners, 'voice.removeAllListeners is defined') - tap.ok(voice.dial, 'voice.dial is defined') - tap.ok(voice.dialPhone, 'voice.dialPhone is defined') - tap.ok(voice.dialSip, 'voice.dialSip is defined') - tap.ok(voice.disconnect, 'voice.disconnect is defined') + tap.ok(client.voice, 'client.voice is defined') + tap.ok(client.voice.listen, 'client.voice.listen is defined') + tap.ok(client.voice.dial, 'voice.dial is defined') + tap.ok(client.voice.dialPhone, 'voice.dialPhone is defined') + tap.ok(client.voice.dialSip, 'voice.dialSip is defined') + tap.ok(client.disconnect, 'voice.disconnect is defined') process.exit(0) } catch (error) { diff --git a/packages/core/src/types/voiceCall.ts b/packages/core/src/types/voiceCall.ts index bf87b16b4..2b4355f6c 100644 --- a/packages/core/src/types/voiceCall.ts +++ b/packages/core/src/types/voiceCall.ts @@ -416,11 +416,9 @@ export type VoiceCallDialSipMethodParams = OmitType & type VoiceRegion = string -export type VoiceDialerParams = - | VoiceDeviceBuilder - | ({ - devices: VoiceDeviceBuilder - } & VoiceCallDialRegionParams) +export type VoiceDialerParams = { + devices: VoiceDeviceBuilder +} & VoiceCallDialRegionParams export interface VoiceDeviceBuilder { devices: VoiceCallDialMethodParams['devices'] @@ -1513,6 +1511,27 @@ export type VoiceCallEventParams = export type VoiceCallAction = MapToPubSubShape +export type VoiceCallReceiveAction = MapToPubSubShape + +export type VoiceCallStateAction = MapToPubSubShape + +export type VoiceCallDialAction = MapToPubSubShape + +export type VoiceCallPlayAction = MapToPubSubShape + +export type VoiceCallRecordAction = MapToPubSubShape + +export type VoiceCallCollectAction = MapToPubSubShape + +export type VoiceCallSendDigitsAction = + MapToPubSubShape + +export type VoiceCallTapAction = MapToPubSubShape + +export type VoiceCallDetectAction = MapToPubSubShape + +export type VoiceCallConnectAction = MapToPubSubShape + export type VoiceCallJSONRPCMethod = | 'calling.dial' | 'calling.end' diff --git a/packages/realtime-api/src/BaseNamespace.test.ts b/packages/realtime-api/src/BaseNamespace.test.ts index 1ba14bdd2..884702ead 100644 --- a/packages/realtime-api/src/BaseNamespace.test.ts +++ b/packages/realtime-api/src/BaseNamespace.test.ts @@ -1,4 +1,3 @@ -import { EventEmitter } from '@signalwire/core' import { BaseNamespace } from './BaseNamespace' describe('BaseNamespace', () => { @@ -21,22 +20,12 @@ describe('BaseNamespace', () => { execute: jest.fn(), }, } - baseNamespace = new BaseNamespace({ swClient: swClientMock }) + baseNamespace = new BaseNamespace(swClientMock) + baseNamespace._eventMap = eventMap }) afterEach(() => { - jest.restoreAllMocks() - }) - - describe('constructor', () => { - it('should initialize the necessary properties', () => { - expect(baseNamespace._sw).toBe(swClientMock) - expect(baseNamespace._client).toBe(swClientMock.client) - expect(baseNamespace._eventMap).toEqual({}) - expect(baseNamespace._namespaceEmitter).toBeInstanceOf(EventEmitter) - expect(baseNamespace._listenerMap).toBeInstanceOf(Map) - expect(baseNamespace._listenerMap.size).toBe(0) - }) + jest.resetAllMocks() }) describe('addTopics', () => { @@ -233,17 +222,4 @@ describe('BaseNamespace', () => { expect(baseNamespace._listenerMap.size).toBe(0) }) }) - - describe('removeFromListenerMap', () => { - it('should remove the listener with the given UUID from the listener map', () => { - const idToRemove = 'uuid1' - baseNamespace._listenerMap.set('uuid1', {}) - baseNamespace._listenerMap.set('uuid2', {}) - - baseNamespace.removeFromListenerMap(idToRemove) - - expect(baseNamespace._listenerMap.size).toBe(1) - expect(baseNamespace._listenerMap.has(idToRemove)).toBe(false) - }) - }) }) diff --git a/packages/realtime-api/src/BaseNamespace.ts b/packages/realtime-api/src/BaseNamespace.ts index ece79f50f..d7b33f9db 100644 --- a/packages/realtime-api/src/BaseNamespace.ts +++ b/packages/realtime-api/src/BaseNamespace.ts @@ -1,37 +1,22 @@ import { EventEmitter, ExecuteParams, uuid } from '@signalwire/core' -import type { Client } from './client/Client' -import { SWClient } from './SWClient' import { prefixEvent } from './utils/internals' +import { ListenSubscriber } from './ListenSubscriber' +import { SWClient } from './SWClient' export interface ListenOptions { topics: string[] } -export type ListenersKeys = keyof Omit +export type Listeners = Omit -type ListenerMap = Map< - string, - { - topics: Set - listeners: Omit - unsub: () => Promise - } -> - -export class BaseNamespace { - protected _client: Client - protected _sw: SWClient - protected _eventMap: Record = {} - private _namespaceEmitter = new EventEmitter() - protected _listenerMap: ListenerMap = new Map() - - constructor(options: { swClient: SWClient }) { - this._sw = options.swClient - this._client = options.swClient.client - } +export type ListenersKeys = keyof Listeners - protected get emitter() { - return this._namespaceEmitter +export class BaseNamespace< + T extends ListenOptions, + EventTypes extends EventEmitter.ValidEventTypes +> extends ListenSubscriber { + constructor(options: SWClient) { + super({ swClient: options }) } protected addTopics(topics: string[]) { @@ -76,12 +61,18 @@ export class BaseNamespace { const _uuid = uuid() // Attach listeners - this._attachListeners(topics, listeners) + this._attachListenersWithTopics(topics, listeners) await this.addTopics(topics) const unsub = () => { return new Promise(async (resolve, reject) => { try { + // Detach listeners + this._detachListenersWithTopics(topics, listeners) + + // Remove topics from the listener map + this.removeFromListenerMap(_uuid) + // Remove the topics const topicsToRemove = topics.filter( (topic) => !this.hasOtherListeners(_uuid, topic) @@ -90,12 +81,6 @@ export class BaseNamespace { await this.removeTopics(topicsToRemove) } - // Detach listeners - this._detachListeners(topics, listeners) - - // Remove topics from the listener map - this.removeFromListenerMap(_uuid) - resolve() } catch (error) { reject(error) @@ -103,7 +88,8 @@ export class BaseNamespace { }) } - this._listenerMap.set(_uuid, { + // Add topics to the listener map + this.addToListenerMap(_uuid, { topics: new Set([...topics]), listeners, unsub, @@ -112,25 +98,27 @@ export class BaseNamespace { return unsub } - protected _attachListeners(topics: string[], listeners: Omit) { + protected _attachListenersWithTopics(topics: string[], listeners: Listeners) { const listenerKeys = Object.keys(listeners) as Array topics.forEach((topic) => { listenerKeys.forEach((key) => { if (typeof listeners[key] === 'function' && this._eventMap[key]) { const event = prefixEvent(topic, this._eventMap[key]) - this.emitter.on(event, listeners[key]) + // @ts-expect-error + this.on(event, listeners[key]) } }) }) } - protected _detachListeners(topics: string[], listeners: Omit) { + protected _detachListenersWithTopics(topics: string[], listeners: Listeners) { const listenerKeys = Object.keys(listeners) as Array topics.forEach((topic) => { listenerKeys.forEach((key) => { if (typeof listeners[key] === 'function' && this._eventMap[key]) { const event = prefixEvent(topic, this._eventMap[key]) - this.emitter.off(event, listeners[key]) + // @ts-expect-error + this.off(event, listeners[key]) } }) }) @@ -138,7 +126,9 @@ export class BaseNamespace { protected hasOtherListeners(uuid: string, topic: string) { for (const [key, listener] of this._listenerMap) { - if (key !== uuid && listener.topics.has(topic)) return true + if (key !== uuid && listener.topics?.has(topic)) { + return true + } } return false } @@ -149,8 +139,4 @@ export class BaseNamespace { ) this._listenerMap.clear() } - - protected removeFromListenerMap(id: string) { - return this._listenerMap.delete(id) - } } diff --git a/packages/realtime-api/src/ListenSubscriber.test.ts b/packages/realtime-api/src/ListenSubscriber.test.ts new file mode 100644 index 000000000..b65daf3f5 --- /dev/null +++ b/packages/realtime-api/src/ListenSubscriber.test.ts @@ -0,0 +1,127 @@ +import { EventEmitter } from '@signalwire/core' +import { ListenSubscriber } from './ListenSubscriber' + +describe('ListenSubscriber', () => { + // Using 'any' data type to bypass TypeScript checks for private or protected members. + let listentSubscriber: any + let swClientMock: any + const listeners = { + onEvent1: jest.fn(), + onEvent2: jest.fn(), + } + const eventMap: Record = { + onEvent1: 'event1', + onEvent2: 'event2', + } + + beforeEach(() => { + swClientMock = { + client: { + execute: jest.fn(), + }, + } + listentSubscriber = new ListenSubscriber({ swClient: swClientMock }) + listentSubscriber._eventMap = eventMap + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + describe('constructor', () => { + it('should initialize the necessary properties', () => { + expect(listentSubscriber._sw).toBe(swClientMock) + expect(listentSubscriber._client).toBe(swClientMock.client) + expect(listentSubscriber._eventMap).toBe(eventMap) + expect(listentSubscriber._emitter).toBeInstanceOf(EventEmitter) + expect(listentSubscriber._listenerMap).toBeInstanceOf(Map) + expect(listentSubscriber._listenerMap.size).toBe(0) + }) + }) + + describe('listen', () => { + it('should call the subscribe method with listen options', async () => { + const subscribeMock = jest.spyOn(listentSubscriber, 'subscribe') + + await listentSubscriber.listen(listeners) + + expect(subscribeMock).toHaveBeenCalledWith(listeners) + }) + + it('should resolve with a function to unsubscribe', async () => { + const unsubscribeMock = jest.fn().mockResolvedValue(undefined) + jest + .spyOn(listentSubscriber, 'subscribe') + .mockResolvedValue(unsubscribeMock) + + const unsub = await listentSubscriber.listen(listeners) + expect(typeof unsub).toBe('function') + + await unsub() + expect(unsubscribeMock).toHaveBeenCalled() + }) + }) + + describe('subscribe', () => { + let emitterOnMock: jest.Mock + let emitterOffMock: jest.Mock + + beforeEach(() => { + // Mock this._eventMap + listentSubscriber._eventMap = eventMap + + // Mock emitter.on method + emitterOnMock = jest.fn() + listentSubscriber.emitter.on = emitterOnMock + + // Mock emitter.off method + emitterOffMock = jest.fn() + listentSubscriber.emitter.off = emitterOffMock + }) + + it('should attach listeners and return an unsubscribe function', async () => { + const unsub = await listentSubscriber.subscribe(listeners) + + // Check if the listeners are attached + const listenerKeys = Object.keys(listeners) as Array< + keyof typeof listeners + > + listenerKeys.forEach((key) => { + expect(emitterOnMock).toHaveBeenCalledWith( + eventMap[key], + listeners[key] + ) + }) + + // Check if the listener is added to the listener map + expect(listentSubscriber._listenerMap.size).toBe(1) + const [[_, value]] = listentSubscriber._listenerMap.entries() + expect(value.listeners).toEqual(listeners) + + // Check if the returned unsubscribe function is valid + expect(unsub).toBeInstanceOf(Function) + await expect(unsub()).resolves.toBeUndefined() + + listenerKeys.forEach((key) => { + expect(emitterOffMock).toHaveBeenCalledWith( + eventMap[key], + listeners[key] + ) + }) + expect(listentSubscriber._listenerMap.size).toBe(0) + }) + }) + + describe('removeFromListenerMap', () => { + it('should remove the listener with the given UUID from the listener map', () => { + const idToRemove = 'uuid1' + listentSubscriber._listenerMap.set('uuid1', {}) + listentSubscriber._listenerMap.set('uuid2', {}) + + listentSubscriber.removeFromListenerMap(idToRemove) + + expect(listentSubscriber._listenerMap.size).toBe(1) + expect(listentSubscriber._listenerMap.has(idToRemove)).toBe(false) + }) + }) +}) diff --git a/packages/realtime-api/src/ListenSubscriber.ts b/packages/realtime-api/src/ListenSubscriber.ts new file mode 100644 index 000000000..b0a0737f9 --- /dev/null +++ b/packages/realtime-api/src/ListenSubscriber.ts @@ -0,0 +1,135 @@ +import { EventEmitter, getLogger, uuid } from '@signalwire/core' +import type { Client } from './client/Client' +import { SWClient } from './SWClient' + +export type ListenersKeys = keyof T + +export type ListenerMapValue = { + topics?: Set + listeners: T + unsub: () => Promise +} + +export type ListenerMap = Map> + +export class ListenSubscriber< + T extends {}, + EventTypes extends EventEmitter.ValidEventTypes +> { + /** @internal */ + _sw: SWClient + + protected _client: Client + protected _listenerMap: ListenerMap = new Map() + protected _eventMap: Record + private _emitter = new EventEmitter() + + constructor(options: { swClient: SWClient }) { + this._sw = options.swClient + this._client = options.swClient.client + } + + protected get emitter() { + return this._emitter + } + + emit>( + event: T, + ...args: EventEmitter.EventArgs + ) { + return this.emitter.emit(event, ...args) + } + + protected on>( + event: E, + fn: EventEmitter.EventListener + ) { + return this.emitter.on(event, fn) + } + + protected once>( + event: T, + fn: EventEmitter.EventListener + ) { + return this.emitter.once(event, fn) + } + + protected off>( + event: T, + fn?: EventEmitter.EventListener + ) { + return this.emitter.off(event, fn) + } + + public listen(listeners: T) { + return new Promise<() => Promise>(async (resolve, reject) => { + try { + const unsub = await this.subscribe(listeners) + resolve(unsub) + } catch (error) { + reject(error) + } + }) + } + + protected async subscribe(listeners: T) { + const _uuid = uuid() + + // Attach listeners + this._attachListeners(listeners) + + const unsub = () => { + return new Promise(async (resolve, reject) => { + try { + // Detach listeners + this._detachListeners(listeners) + + // Remove listeners from the listener map + this.removeFromListenerMap(_uuid) + + resolve() + } catch (error) { + reject(error) + } + }) + } + + // Add listeners to the listener map + this.addToListenerMap(_uuid, { + listeners, + unsub, + }) + + return unsub + } + + private _attachListeners(listeners: T) { + const listenerKeys = Object.keys(listeners) as Array> + listenerKeys.forEach((key) => { + if (typeof listeners[key] === 'function' && this._eventMap[key]) { + // @ts-expect-error + this.on(this._eventMap[key], listeners[key]) + } else { + getLogger().warn(`Unsupported listener: ${listeners[key]}`) + } + }) + } + + private _detachListeners(listeners: T) { + const listenerKeys = Object.keys(listeners) as Array> + listenerKeys.forEach((key) => { + if (typeof listeners[key] === 'function' && this._eventMap[key]) { + // @ts-expect-error + this.off(this._eventMap[key], listeners[key]) + } + }) + } + + protected addToListenerMap(id: string, value: ListenerMapValue) { + return this._listenerMap.set(id, value) + } + + protected removeFromListenerMap(id: string) { + return this._listenerMap.delete(id) + } +} diff --git a/packages/realtime-api/src/SWClient.ts b/packages/realtime-api/src/SWClient.ts index 9f0fb3c0f..b1fa5c861 100644 --- a/packages/realtime-api/src/SWClient.ts +++ b/packages/realtime-api/src/SWClient.ts @@ -4,6 +4,7 @@ import { clientConnect } from './client/clientConnect' import { Task } from './task/Task' import { PubSub } from './pubSub/PubSub' import { Chat } from './chat/Chat' +import { Voice } from './voice/Voice' export interface SWClientOptions { host?: string @@ -19,6 +20,7 @@ export class SWClient { private _task: Task private _pubSub: PubSub private _chat: Chat + private _voice: Voice public userOptions: SWClientOptions public client: Client @@ -56,4 +58,11 @@ export class SWClient { } return this._chat } + + get voice() { + if (!this._voice) { + this._voice = new Voice(this) + } + return this._voice + } } diff --git a/packages/realtime-api/src/SignalWire.ts b/packages/realtime-api/src/SignalWire.ts index ca3aeeecd..94d66f384 100644 --- a/packages/realtime-api/src/SignalWire.ts +++ b/packages/realtime-api/src/SignalWire.ts @@ -17,3 +17,4 @@ export type { SWClient } from './SWClient' export type { Chat } from './chat/Chat' export type { PubSub } from './pubSub/PubSub' export type { Task } from './task/Task' +export type { Voice } from './voice/Voice' diff --git a/packages/realtime-api/src/chat/BaseChat.test.ts b/packages/realtime-api/src/chat/BaseChat.test.ts index 78383e622..59873365d 100644 --- a/packages/realtime-api/src/chat/BaseChat.test.ts +++ b/packages/realtime-api/src/chat/BaseChat.test.ts @@ -65,7 +65,10 @@ describe('BaseChat', () => { const addChannelsMock = jest .spyOn(baseChat, 'addChannels') .mockResolvedValueOnce(null) - const attachListenersMock = jest.spyOn(baseChat, '_attachListeners') + const attachListenersMock = jest.spyOn( + baseChat, + '_attachListenersWithTopics' + ) await expect(baseChat.subscribe(listenOptions)).resolves.toBeInstanceOf( Function @@ -81,7 +84,10 @@ describe('BaseChat', () => { const removeChannelsMock = jest .spyOn(baseChat, 'removeChannels') .mockResolvedValueOnce(null) - const detachListenersMock = jest.spyOn(baseChat, '_detachListeners') + const detachListenersMock = jest.spyOn( + baseChat, + '_detachListenersWithTopics' + ) const unsub = await baseChat.subscribe({ channels, ...listeners }) expect(unsub).toBeInstanceOf(Function) diff --git a/packages/realtime-api/src/chat/BaseChat.ts b/packages/realtime-api/src/chat/BaseChat.ts index fb5010505..afc856c06 100644 --- a/packages/realtime-api/src/chat/BaseChat.ts +++ b/packages/realtime-api/src/chat/BaseChat.ts @@ -1,20 +1,26 @@ -import { ExecuteParams, PubSubPublishParams, uuid } from '@signalwire/core' -import { BaseNamespace } from '../BaseNamespace' -import { SWClient } from '../SWClient' - -export interface BaseChatListenOptions { +import { + EventEmitter, + ExecuteParams, + PubSubPublishParams, + uuid, +} from '@signalwire/core' +import { BaseNamespace, ListenOptions } from '../BaseNamespace' + +export interface BaseChatListenOptions extends ListenOptions { channels: string[] } -export type BaseChatListenerKeys = keyof Omit +export type BaseChatListeners = Omit< + BaseChatListenOptions, + 'channels' | 'topics' +> -export class BaseChat< - T extends BaseChatListenOptions -> extends BaseNamespace { - constructor(options: SWClient) { - super({ swClient: options }) - } +export type BaseChatListenerKeys = keyof BaseChatListeners +export class BaseChat< + T extends BaseChatListenOptions, + EventTypes extends EventEmitter.ValidEventTypes +> extends BaseNamespace { public listen(listenOptions: T) { return new Promise<() => Promise>(async (resolve, reject) => { try { @@ -38,7 +44,7 @@ export class BaseChat< const _uuid = uuid() // Attach listeners - this._attachListeners(channels, listeners) + this._attachListenersWithTopics(channels, listeners) const listenerKeys = Object.keys(listeners) as Array const events: string[] = [] @@ -59,7 +65,7 @@ export class BaseChat< } // Detach listeners - this._detachListeners(channels, listeners) + this._detachListenersWithTopics(channels, listeners) // Remove channels from the listener map this.removeFromListenerMap(_uuid) @@ -71,7 +77,8 @@ export class BaseChat< }) } - this._listenerMap.set(_uuid, { + // Add channels to the listener map + this.addToListenerMap(_uuid, { topics: new Set([...channels]), listeners, unsub, diff --git a/packages/realtime-api/src/chat/Chat.ts b/packages/realtime-api/src/chat/Chat.ts index 07d562dea..51ec7d52e 100644 --- a/packages/realtime-api/src/chat/Chat.ts +++ b/packages/realtime-api/src/chat/Chat.ts @@ -1,13 +1,13 @@ import { ChatMember, ChatMessage, - EventEmitter, ChatEvents, Chat as ChatCore, } from '@signalwire/core' import { BaseChat, BaseChatListenOptions } from './BaseChat' import { chatWorker } from './workers' import { SWClient } from '../SWClient' +import { RealTimeChatEvents } from '../types/chat' interface ChatListenOptions extends BaseChatListenOptions { onMessageReceived?: (message: ChatMessage) => unknown @@ -16,12 +16,11 @@ interface ChatListenOptions extends BaseChatListenOptions { onMemberLeft?: (member: ChatMember) => unknown } -type ChatListenersKeys = keyof Omit +type ChatListenersKeys = keyof Omit export class Chat extends ChatCore.applyCommonMethods( - BaseChat + BaseChat ) { - private _chatEmitter = new EventEmitter() protected _eventMap: Record = { onMessageReceived: 'chat.message', onMemberJoined: 'chat.member.joined', @@ -35,14 +34,10 @@ export class Chat extends ChatCore.applyCommonMethods( this._client.runWorker('chatWorker', { worker: chatWorker, initialState: { - chatEmitter: this._chatEmitter, + chat: this, }, }) } - - protected get emitter() { - return this._chatEmitter - } } export { ChatMember, ChatMessage } from '@signalwire/core' diff --git a/packages/realtime-api/src/chat/workers/chatWorker.ts b/packages/realtime-api/src/chat/workers/chatWorker.ts index 1c6117e1a..65497bbad 100644 --- a/packages/realtime-api/src/chat/workers/chatWorker.ts +++ b/packages/realtime-api/src/chat/workers/chatWorker.ts @@ -1,5 +1,4 @@ import { SagaIterator } from '@redux-saga/core' -import { Chat } from '../Chat' import { sagaEffects, SDKWorker, @@ -11,14 +10,22 @@ import { SDKActions, } from '@signalwire/core' import { prefixEvent } from '../../utils/internals' +import type { Client } from '../../client/Client' +import { Chat } from '../Chat' -export const chatWorker: SDKWorker = function* (options): SagaIterator { +interface ChatWorkerInitialState { + chat: Chat +} + +export const chatWorker: SDKWorker = function* (options): SagaIterator { getLogger().trace('chatWorker started') const { channels: { swEventChannel }, - initialState: { chatEmitter }, + initialState, } = options + const { chat } = initialState as ChatWorkerInitialState + function* worker(action: ChatAction) { const { type, payload } = action @@ -31,7 +38,8 @@ export const chatWorker: SDKWorker = function* (options): SagaIterator { }) const chatMessage = new ChatMessage(externalJSON) - chatEmitter.emit(prefixEvent(channel, 'chat.message'), chatMessage) + // @ts-expect-error + chat.emit(prefixEvent(channel, 'chat.message'), chatMessage) break } case 'chat.member.joined': @@ -41,7 +49,8 @@ export const chatWorker: SDKWorker = function* (options): SagaIterator { const externalJSON = toExternalJSON(member) const chatMember = new ChatMember(externalJSON) - chatEmitter.emit(prefixEvent(channel, type), chatMember) + // @ts-expect-error + chat.emit(prefixEvent(channel, type), chatMember) break } default: diff --git a/packages/realtime-api/src/index.ts b/packages/realtime-api/src/index.ts index 740b34b76..b030ba2f8 100644 --- a/packages/realtime-api/src/index.ts +++ b/packages/realtime-api/src/index.ts @@ -84,38 +84,49 @@ export * from './configure' */ export * as Messaging from './messaging/Messaging' +export * as Chat from './chat/Chat' + +export * as PubSub from './pubSub/PubSub' + +export * as Task from './task/Task' + +export * as Voice from './voice/Voice' + /** - * Access the Voice API. You can instantiate a {@link Voice.Client} to - * make or receive calls. Please check - * {@link Voice.VoiceClientApiEvents} for the full list of events that - * a {@link Voice.Client} can subscribe to. + * Access all the SignalWire APIs with a single instance. You can initiate a {@link SignalWire} to + * use Messaging, Chat, PubSub, Task, Voice, and Video APIs. * * @example * - * The following example answers any call in the "office" context, - * and immediately plays some speech. + * The following example creates a single client and uses Task and Voice APIs. * * ```javascript - * const client = new Voice.Client({ + * const client = await SignalWire({ * project: "", * token: "", - * contexts: ['office'] * }) * - * client.on('call.received', async (call) => { - * console.log('Got call', call.from, call.to) + * await client.task.listen({ + * topics: ['office'], + * onTaskReceived: (payload) => { + * console.log('message.received', payload)} + * }) + * + * await client.task.send({ + * topic: 'office', + * message: '+1yyy', + * }) * - * try { - * await call.answer() - * console.log('Inbound call answered') + * await client.voice.listen({ + * topics: ['office'], + * onCallReceived: (call) => { + * console.log('call.received', call)} + * }) * - * await call.playTTS({ text: "Hello! This is a test call."}) - * } catch (error) { - * console.error('Error answering inbound call', error) - * } + * await client.voice.dialPhone({ + * from: '+1xxx', + * to: '+1yyy', * }) * ``` */ -export * as Voice from './voice/Voice' - export * from './SignalWire' diff --git a/packages/realtime-api/src/pubSub/PubSub.ts b/packages/realtime-api/src/pubSub/PubSub.ts index e418f47d7..b62d893b0 100644 --- a/packages/realtime-api/src/pubSub/PubSub.ts +++ b/packages/realtime-api/src/pubSub/PubSub.ts @@ -1,5 +1,4 @@ import { - EventEmitter, PubSubMessageEventName, PubSubNamespace, PubSubMessage, @@ -7,15 +6,21 @@ import { import { SWClient } from '../SWClient' import { pubSubWorker } from './workers' import { BaseChat, BaseChatListenOptions } from '../chat/BaseChat' +import { RealTimePubSubEvents } from '../types/pubSub' interface PubSubListenOptions extends BaseChatListenOptions { onMessageReceived?: (message: PubSubMessage) => unknown } -type PubSubListenersKeys = keyof Omit +type PubSubListenersKeys = keyof Omit< + PubSubListenOptions, + 'channels' | 'topics' +> -export class PubSub extends BaseChat { - private _pubSubEmitter = new EventEmitter() +export class PubSub extends BaseChat< + PubSubListenOptions, + RealTimePubSubEvents +> { protected _eventMap: Record< PubSubListenersKeys, `${PubSubNamespace}.${PubSubMessageEventName}` @@ -29,14 +34,10 @@ export class PubSub extends BaseChat { this._client.runWorker('pubSubWorker', { worker: pubSubWorker, initialState: { - pubSubEmitter: this._pubSubEmitter, + pubSub: this, }, }) } - - protected get emitter() { - return this._pubSubEmitter - } } export type { PubSubMessageContract } from '@signalwire/core' diff --git a/packages/realtime-api/src/pubSub/workers/pubSubWorker.ts b/packages/realtime-api/src/pubSub/workers/pubSubWorker.ts index fbf0772e5..dc6aa4914 100644 --- a/packages/realtime-api/src/pubSub/workers/pubSubWorker.ts +++ b/packages/realtime-api/src/pubSub/workers/pubSubWorker.ts @@ -1,5 +1,4 @@ import { SagaIterator } from '@redux-saga/core' -import { PubSub } from '../PubSub' import { sagaEffects, PubSubEventAction, @@ -9,16 +8,24 @@ import { toExternalJSON, } from '@signalwire/core' import { prefixEvent } from '../../utils/internals' +import type { Client } from '../../client/Client' +import { PubSub } from '../PubSub' -export const pubSubWorker: SDKWorker = function* ( +interface PubSubWorkerInitialState { + pubSub: PubSub +} + +export const pubSubWorker: SDKWorker = function* ( options ): SagaIterator { getLogger().trace('pubSubWorker started') const { channels: { swEventChannel }, - initialState: { pubSubEmitter }, + initialState, } = options + const { pubSub } = initialState as PubSubWorkerInitialState + function* worker(action: PubSubEventAction) { const { type, payload } = action @@ -42,7 +49,8 @@ export const pubSubWorker: SDKWorker = function* ( }) const pubSubMessage = new PubSubMessage(externalJSON) - pubSubEmitter.emit(prefixEvent(channel, 'chat.message'), pubSubMessage) + // @ts-expect-error + pubSub.emit(prefixEvent(channel, 'chat.message'), pubSubMessage) break } default: diff --git a/packages/realtime-api/src/task/Task.ts b/packages/realtime-api/src/task/Task.ts index fe06d09ea..21a124319 100644 --- a/packages/realtime-api/src/task/Task.ts +++ b/packages/realtime-api/src/task/Task.ts @@ -1,8 +1,8 @@ import { request } from 'node:https' import { - EventEmitter, TaskInboundEvent, TaskReceivedEventName, + getLogger, } from '@signalwire/core' import { SWClient } from '../SWClient' import { taskWorker } from './workers' @@ -17,27 +17,27 @@ interface TaskListenOptions extends ListenOptions { type TaskListenersKeys = keyof Omit -export class Task extends BaseNamespace { - private _taskEmitter = new EventEmitter() +type TaskEvents = Record< + TaskReceivedEventName, + (task: TaskInboundEvent['message']) => void +> + +export class Task extends BaseNamespace { protected _eventMap: Record = { onTaskReceived: 'task.received', } constructor(options: SWClient) { - super({ swClient: options }) + super(options) this._client.runWorker('taskWorker', { worker: taskWorker, initialState: { - taskEmitter: this._taskEmitter, + task: this, }, }) } - protected get emitter() { - return this._taskEmitter - } - send({ topic, message, @@ -67,6 +67,8 @@ export class Task extends BaseNamespace { 'Content-Length': data.length, }, } + + getLogger().debug('Task send -', data) const req = request(options, ({ statusCode }) => { statusCode === 204 ? resolve() : reject() }) diff --git a/packages/realtime-api/src/task/workers/taskWorker.ts b/packages/realtime-api/src/task/workers/taskWorker.ts index e1fb9d123..1cf7bfe21 100644 --- a/packages/realtime-api/src/task/workers/taskWorker.ts +++ b/packages/realtime-api/src/task/workers/taskWorker.ts @@ -7,22 +7,27 @@ import { TaskAction, } from '@signalwire/core' import { prefixEvent } from '../../utils/internals' +import type { Client } from '../../client/Client' import { Task } from '../Task' -export const taskWorker: SDKWorker = function* (options): SagaIterator { +interface TaskWorkerInitialState { + task: Task +} + +export const taskWorker: SDKWorker = function* (options): SagaIterator { getLogger().trace('taskWorker started') const { channels: { swEventChannel }, - initialState: { taskEmitter }, + initialState, } = options + const { task } = initialState as TaskWorkerInitialState + function* worker(action: TaskAction) { const { context } = action.payload - taskEmitter.emit( - prefixEvent(context, 'task.received'), - action.payload.message - ) + // @ts-expect-error + task.emit(prefixEvent(context, 'task.received'), action.payload.message) } const isTaskEvent = (action: SDKActions) => diff --git a/packages/realtime-api/src/types/chat.ts b/packages/realtime-api/src/types/chat.ts index 06302d485..93319975a 100644 --- a/packages/realtime-api/src/types/chat.ts +++ b/packages/realtime-api/src/types/chat.ts @@ -10,3 +10,7 @@ export type RealTimeChatApiEventsHandlerMapping = Record< (message: ChatMessage) => void > & Record void> + +export type RealTimeChatEvents = { + [k in keyof RealTimeChatApiEventsHandlerMapping]: RealTimeChatApiEventsHandlerMapping[k] +} diff --git a/packages/realtime-api/src/types/pubSub.ts b/packages/realtime-api/src/types/pubSub.ts index 003d4d549..44bbdd3d2 100644 --- a/packages/realtime-api/src/types/pubSub.ts +++ b/packages/realtime-api/src/types/pubSub.ts @@ -4,3 +4,7 @@ export type RealTimePubSubApiEventsHandlerMapping = Record< PubSubMessageEventName, (message: PubSubMessage) => void > + +export type RealTimePubSubEvents = { + [k in keyof RealTimePubSubApiEventsHandlerMapping]: RealTimePubSubApiEventsHandlerMapping[k] +} diff --git a/packages/realtime-api/src/types/voice.ts b/packages/realtime-api/src/types/voice.ts index ca2b34d6b..7e8a1546b 100644 --- a/packages/realtime-api/src/types/voice.ts +++ b/packages/realtime-api/src/types/voice.ts @@ -20,6 +20,29 @@ import type { CallCollectUpdated, CallCollectEnded, CallCollectFailed, + VoiceCallPlayAudioMethodParams, + VoiceCallPlaySilenceMethodParams, + VoiceCallPlayRingtoneMethodParams, + VoiceCallPlayTTSMethodParams, + VoicePlaylist, + VoiceCallRecordMethodParams, + VoiceCallPromptTTSMethodParams, + VoiceCallPromptRingtoneMethodParams, + VoiceCallPromptAudioMethodParams, + VoiceCallPromptMethodParams, + VoiceCallCollectMethodParams, + VoiceCallTapMethodParams, + VoiceCallTapAudioMethodParams, + CallDetectStarted, + CallDetectEnded, + CallDetectUpdated, + VoiceCallDetectMethodParams, + VoiceCallDetectMachineParams, + VoiceCallDetectFaxParams, + VoiceCallDetectDigitParams, + VoiceDialerParams, + VoiceCallDialPhoneMethodParams, + VoiceCallDialSipMethodParams, } from '@signalwire/core' import type { Call } from '../voice/Call' import type { CallPlayback } from '../voice/CallPlayback' @@ -27,12 +50,53 @@ import type { CallRecording } from '../voice/CallRecording' import type { CallPrompt } from '../voice/CallPrompt' import type { CallTap } from '../voice/CallTap' import type { CallCollect } from '../voice/CallCollect' +import type { CallDetect } from '../voice/CallDetect' -export type RealTimeCallApiEventsHandlerMapping = Record< - CallReceived, +export type VoiceEvents = Record void> + +export interface VoiceMethodsListeners { + listen?: RealTimeCallListeners +} + +export type VoiceDialMethodParams = VoiceDialerParams & VoiceMethodsListeners + +export type VoiceDialPhonelMethodParams = VoiceCallDialPhoneMethodParams & + VoiceMethodsListeners + +export type VoiceDialSipMethodParams = VoiceCallDialSipMethodParams & + VoiceMethodsListeners + +export interface RealTimeCallListeners { + onStateChanged?: (call: Call) => unknown + onPlaybackStarted?: (playback: CallPlayback) => unknown + onPlaybackUpdated?: (playback: CallPlayback) => unknown + onPlaybackFailed?: (playback: CallPlayback) => unknown + onPlaybackEnded?: (playback: CallPlayback) => unknown + onRecordingStarted?: (recording: CallRecording) => unknown + onRecordingFailed?: (recording: CallRecording) => unknown + onRecordingEnded?: (recording: CallRecording) => unknown + onPromptStarted?: (prompt: CallPrompt) => unknown + onPromptUpdated?: (prompt: CallPrompt) => unknown + onPromptFailed?: (prompt: CallPrompt) => unknown + onPromptEnded?: (prompt: CallPrompt) => unknown + onCollectStarted?: (collect: CallCollect) => unknown + onCollectInputStarted?: (collect: CallCollect) => unknown + onCollectUpdated?: (collect: CallCollect) => unknown + onCollectFailed?: (collect: CallCollect) => unknown + onCollectEnded?: (collect: CallCollect) => unknown + onTapStarted?: (collect: CallTap) => unknown + onTapEnded?: (collect: CallTap) => unknown + onDetectStarted?: (collect: CallDetect) => unknown + onDetectUpdated?: (collect: CallDetect) => unknown + onDetectEnded?: (collect: CallDetect) => unknown +} + +export type RealTimeCallListenersKeys = keyof RealTimeCallListeners + +export type RealTimeCallEventsHandlerMapping = Record< + CallState, (call: Call) => void > & - Record void> & Record< | CallPlaybackStarted | CallPlaybackUpdated @@ -51,7 +115,6 @@ export type RealTimeCallApiEventsHandlerMapping = Record< CallPromptStarted | CallPromptUpdated | CallPromptEnded | CallPromptFailed, (prompt: CallPrompt) => void > & - Record void> & Record< | CallCollectStarted | CallCollectStartOfInput @@ -59,8 +122,264 @@ export type RealTimeCallApiEventsHandlerMapping = Record< | CallCollectEnded | CallCollectFailed, (callCollect: CallCollect) => void + > & + Record void> & + Record< + CallDetectStarted | CallDetectUpdated | CallDetectEnded, + (detect: CallDetect) => void > -export type RealTimeCallApiEvents = { - [k in keyof RealTimeCallApiEventsHandlerMapping]: RealTimeCallApiEventsHandlerMapping[k] +export type RealTimeCallEvents = { + [k in keyof RealTimeCallEventsHandlerMapping]: RealTimeCallEventsHandlerMapping[k] +} + +export type RealtimeCallListenersEventsMapping = Record< + 'onStateChanged', + CallState +> & + Record<'onPlaybackStarted', CallPlaybackStarted> & + Record<'onPlaybackUpdated', CallPlaybackUpdated> & + Record<'onPlaybackFailed', CallPlaybackFailed> & + Record<'onPlaybackEnded', CallPlaybackEnded> & + Record<'onRecordingStarted', CallRecordingStarted> & + Record<'onRecordingUpdated', CallRecordingUpdated> & + Record<'onRecordingFailed', CallRecordingFailed> & + Record<'onRecordingEnded', CallRecordingEnded> & + Record<'onPromptStarted', CallPromptStarted> & + Record<'onPromptUpdated', CallPromptUpdated> & + Record<'onPromptFailed', CallPromptFailed> & + Record<'onPromptEnded', CallPromptEnded> & + Record<'onCollectStarted', CallCollectStarted> & + Record<'onCollectInputStarted', CallCollectStartOfInput> & + Record<'onCollectUpdated', CallCollectUpdated> & + Record<'onCollectFailed', CallCollectFailed> & + Record<'onCollectEnded', CallCollectEnded> & + Record<'onTapStarted', CallTapStarted> & + Record<'onTapEnded', CallTapEnded> & + Record<'onDetectStarted', CallDetectStarted> & + Record<'onDetectUpdated', CallDetectUpdated> & + Record<'onDetectEnded', CallDetectEnded> + +/** + * Call Playback + */ +export type CallPlaybackEvents = Record< + | CallPlaybackStarted + | CallPlaybackUpdated + | CallPlaybackEnded + | CallPlaybackFailed, + (playback: CallPlayback) => void +> + +export interface CallPlaybackListeners { + onStarted?: (playback: CallPlayback) => unknown + onUpdated?: (playback: CallPlayback) => unknown + onFailed?: (playback: CallPlayback) => unknown + onEnded?: (playback: CallPlayback) => unknown +} + +export type CallPlaybackListenersEventsMapping = Record< + 'onStarted', + CallPlaybackStarted +> & + Record<'onUpdated', CallPlaybackUpdated> & + Record<'onFailed', CallPlaybackFailed> & + Record<'onEnded', CallPlaybackEnded> + +export interface CallPlayMethodParams { + playlist: VoicePlaylist + listen?: CallPlaybackListeners +} + +export interface CallPlayAudioMethodarams + extends VoiceCallPlayAudioMethodParams { + listen?: CallPlaybackListeners +} + +export interface CallPlaySilenceMethodParams + extends VoiceCallPlaySilenceMethodParams { + listen?: CallPlaybackListeners +} + +export interface CallPlayRingtoneMethodParams + extends VoiceCallPlayRingtoneMethodParams { + listen?: CallPlaybackListeners +} + +export interface CallPlayTTSMethodParams extends VoiceCallPlayTTSMethodParams { + listen?: CallPlaybackListeners +} + +/** + * Call Recording + */ +export type CallRecordingEvents = Record< + | CallRecordingStarted + | CallRecordingUpdated + | CallRecordingEnded + | CallRecordingFailed, + (recording: CallRecording) => void +> + +export interface CallRecordingListeners { + onStarted?: (recording: CallRecording) => unknown + onFailed?: (recording: CallRecording) => unknown + onEnded?: (recording: CallRecording) => unknown +} + +export type CallRecordingListenersEventsMapping = Record< + 'onStarted', + CallRecordingStarted +> & + Record<'onUpdated', CallRecordingUpdated> & + Record<'onFailed', CallRecordingFailed> & + Record<'onEnded', CallRecordingEnded> + +export interface CallRecordMethodParams extends VoiceCallRecordMethodParams { + listen?: CallRecordingListeners +} + +export type CallRecordAudioMethodParams = + VoiceCallRecordMethodParams['audio'] & { + listen?: CallRecordingListeners + } + +/** + * Call Prompt + */ +export type CallPromptEvents = Record< + CallPromptStarted | CallPromptUpdated | CallPromptEnded | CallPromptFailed, + (prompt: CallPrompt) => void +> + +export interface CallPromptListeners { + onStarted?: (prompt: CallPrompt) => unknown + onUpdated?: (prompt: CallPrompt) => unknown + onFailed?: (prompt: CallPrompt) => unknown + onEnded?: (prompt: CallPrompt) => unknown +} + +export type CallPromptListenersEventsMapping = Record< + 'onStarted', + CallPromptStarted +> & + Record<'onUpdated', CallPromptUpdated> & + Record<'onFailed', CallPromptFailed> & + Record<'onEnded', CallPromptEnded> + +export type CallPromptMethodParams = VoiceCallPromptMethodParams & { + listen?: CallPromptListeners +} + +export type CallPromptAudioMethodParams = VoiceCallPromptAudioMethodParams & { + listen?: CallPromptListeners +} + +export type CallPromptRingtoneMethodParams = + VoiceCallPromptRingtoneMethodParams & { + listen?: CallPromptListeners + } + +export type CallPromptTTSMethodParams = VoiceCallPromptTTSMethodParams & { + listen?: CallPromptListeners +} + +/** + * Call Collect + */ +export type CallCollectEvents = Record< + | CallCollectStarted + | CallCollectStartOfInput + | CallCollectUpdated + | CallCollectEnded + | CallCollectFailed, + (collect: CallCollect) => void +> + +export interface CallCollectListeners { + onStarted?: (collect: CallCollect) => unknown + onInputStarted?: (collect: CallCollect) => unknown + onUpdated?: (collect: CallCollect) => unknown + onFailed?: (collect: CallCollect) => unknown + onEnded?: (collect: CallCollect) => unknown +} + +export type CallCollectListenersEventsMapping = Record< + 'onStarted', + CallCollectStarted +> & + Record<'onInputStarted', CallCollectStartOfInput> & + Record<'onUpdated', CallCollectUpdated> & + Record<'onFailed', CallCollectFailed> & + Record<'onEnded', CallCollectEnded> + +export type CallCollectMethodParams = VoiceCallCollectMethodParams & { + listen?: CallCollectListeners +} + +/** + * Call Tap + */ +export type CallTapEvents = Record< + CallTapStarted | CallTapEnded, + (tap: CallTap) => void +> + +export interface CallTapListeners { + onStarted?: (tap: CallTap) => unknown + onEnded?: (tap: CallTap) => unknown +} + +export type CallTapListenersEventsMapping = Record< + 'onStarted', + CallTapStarted +> & + Record<'onEnded', CallTapEnded> + +export type CallTapMethodParams = VoiceCallTapMethodParams & { + listen?: CallTapListeners +} + +export type CallTapAudioMethodParams = VoiceCallTapAudioMethodParams & { + listen?: CallTapListeners +} + +/** + * Call Detect + */ +export type CallDetectEvents = Record< + CallDetectStarted | CallDetectUpdated | CallDetectEnded, + (tap: CallDetect) => void +> + +export interface CallDetectListeners { + onStarted?: (detect: CallDetect) => unknown + onUpdated?: (detect: CallDetect) => unknown + onEnded?: (detect: CallDetect) => unknown +} + +export type CallDetectListenersEventsMapping = Record< + 'onStarted', + CallDetectStarted +> & + Record<'onUpdated', CallDetectUpdated> & + Record<'onEnded', CallDetectEnded> + +export type CallDetectMethodParams = VoiceCallDetectMethodParams & { + listen?: CallDetectListeners +} + +export interface CallDetectMachineParams + extends Omit { + listen?: CallDetectListeners +} + +export interface CallDetectFaxParams + extends Omit { + listen?: CallDetectListeners +} + +export interface CallDetectDigitParams + extends Omit { + listen?: CallDetectListeners } diff --git a/packages/realtime-api/src/voice/Call.test.ts b/packages/realtime-api/src/voice/Call.test.ts new file mode 100644 index 000000000..6b37b56f5 --- /dev/null +++ b/packages/realtime-api/src/voice/Call.test.ts @@ -0,0 +1,116 @@ +import { EventEmitter } from '@signalwire/core' +import { createClient } from '../client/createClient' +import { Voice } from './Voice' +import { Call } from './Call' + +describe('Call', () => { + let voice: Voice + let call: Call + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + }) + + it('should have an event emitter', () => { + call = new Call({ voice }) + + expect(call['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + call = new Call({ voice }) + + const expectedEventMap = { + onStateChanged: 'call.state', + onPlaybackStarted: 'playback.started', + onPlaybackUpdated: 'playback.updated', + onPlaybackFailed: 'playback.failed', + onPlaybackEnded: 'playback.ended', + onRecordingStarted: 'recording.started', + onRecordingFailed: 'recording.failed', + onRecordingEnded: 'recording.ended', + onPromptStarted: 'prompt.started', + onPromptUpdated: 'prompt.updated', + onPromptFailed: 'prompt.failed', + onPromptEnded: 'prompt.ended', + onCollectStarted: 'collect.started', + onCollectInputStarted: 'collect.startOfInput', + onCollectUpdated: 'collect.updated', + onCollectFailed: 'collect.failed', + onCollectEnded: 'collect.ended', + onTapStarted: 'tap.started', + onTapEnded: 'tap.ended', + onDetectStarted: 'detect.started', + onDetectUpdated: 'detect.updated', + onDetectEnded: 'detect.ended', + } + expect(call['_eventMap']).toEqual(expectedEventMap) + }) + + it('should attach all listeners', () => { + call = new Call({ + voice, + listeners: { + onStateChanged: () => {}, + onPlaybackStarted: () => {}, + onPlaybackUpdated: () => {}, + onPlaybackFailed: () => {}, + onPlaybackEnded: () => {}, + onRecordingStarted: () => {}, + onRecordingFailed: () => {}, + onRecordingEnded: () => {}, + onPromptStarted: () => {}, + onPromptUpdated: () => {}, + onPromptFailed: () => {}, + onPromptEnded: () => {}, + onCollectStarted: () => {}, + onCollectInputStarted: () => {}, + onCollectUpdated: () => {}, + onCollectFailed: () => {}, + onCollectEnded: () => {}, + onTapStarted: () => {}, + onTapEnded: () => {}, + onDetectStarted: () => {}, + onDetectUpdated: () => {}, + onDetectEnded: () => {}, + }, + }) + + // @ts-expect-error + expect(call.emitter.eventNames()).toStrictEqual([ + 'call.state', + 'playback.started', + 'playback.updated', + 'playback.failed', + 'playback.ended', + 'recording.started', + 'recording.failed', + 'recording.ended', + 'prompt.started', + 'prompt.updated', + 'prompt.failed', + 'prompt.ended', + 'collect.started', + 'collect.startOfInput', + 'collect.updated', + 'collect.failed', + 'collect.ended', + 'tap.started', + 'tap.ended', + 'detect.started', + 'detect.updated', + 'detect.ended', + ]) + }) +}) diff --git a/packages/realtime-api/src/voice/Call.ts b/packages/realtime-api/src/voice/Call.ts index ba15daf3d..a24a43c0d 100644 --- a/packages/realtime-api/src/voice/Call.ts +++ b/packages/realtime-api/src/voice/Call.ts @@ -1,120 +1,114 @@ import { + CallingCallConnectEventParams, + CallingCall, uuid, - BaseComponentOptionsWithPayload, - connect, - EmitterContract, - extendComponent, - VoiceCallMethods, - VoiceCallContract, VoiceCallDisconnectReason, - VoicePlaylist, - VoiceCallPlayAudioMethodParams, - VoiceCallPlaySilenceMethodParams, - VoiceCallPlayRingtoneMethodParams, - VoiceCallPlayTTSMethodParams, - VoiceCallRecordMethodParams, - VoiceCallPromptMethodParams, - VoiceCallPromptAudioMethodParams, - VoiceCallPromptRingtoneMethodParams, - VoiceCallPromptTTSMethodParams, - VoiceCallCollectMethodParams, - toExternalJSON, toSnakeCaseKeys, - VoiceCallTapMethodParams, - VoiceCallTapAudioMethodParams, + CallingCallWaitForState, CallingCallState, VoiceCallConnectMethodParams, + toExternalJSON, VoiceCallConnectPhoneMethodParams, VoiceCallConnectSipMethodParams, - CallingCallConnectEventParams, - VoiceCallDetectMethodParams, - VoiceCallDetectMachineParams, - VoiceCallDetectFaxParams, - VoiceCallDetectDigitParams, - CallingCallWaitForState, - CallingCall, - configureStore, - BaseConsumer, + CallingCallConnectFailedEventParams, } from '@signalwire/core' -import { RealTimeCallApiEvents } from '../types' +import { ListenSubscriber } from '../ListenSubscriber' +import { + CallCollectMethodParams, + CallDetectDigitParams, + CallDetectFaxParams, + CallDetectMachineParams, + CallDetectMethodParams, + CallPlayAudioMethodarams, + CallPlayMethodParams, + CallPlayRingtoneMethodParams, + CallPlaySilenceMethodParams, + CallPlayTTSMethodParams, + CallPromptAudioMethodParams, + CallPromptMethodParams, + CallPromptRingtoneMethodParams, + CallPromptTTSMethodParams, + CallRecordAudioMethodParams, + CallRecordMethodParams, + CallTapAudioMethodParams, + CallTapMethodParams, + RealTimeCallEvents, + RealTimeCallListeners, + RealtimeCallListenersEventsMapping, +} from '../types' import { toInternalDevices, toInternalPlayParams } from './utils' +import { + voiceCallCollectWorker, + voiceCallConnectWorker, + voiceCallDetectWorker, + voiceCallPlayWorker, + voiceCallRecordWorker, + voiceCallSendDigitsWorker, + voiceCallTapWorker, +} from './workers' import { Playlist } from './Playlist' +import { Voice } from './Voice' import { CallPlayback } from './CallPlayback' import { CallRecording } from './CallRecording' -import { CallPrompt, createCallPromptObject } from './CallPrompt' +import { CallPrompt } from './CallPrompt' +import { CallCollect } from './CallCollect' import { CallTap } from './CallTap' -import { CallDetect, createCallDetectObject } from './CallDetect' -import { CallCollect, createCallCollectObject } from './CallCollect' import { DeviceBuilder } from './DeviceBuilder' +import { CallDetect } from './CallDetect' -export type EmitterTransformsEvents = - | 'calling.playback.start' - | 'calling.playback.started' - | 'calling.playback.updated' - | 'calling.playback.ended' - | 'calling.playback.failed' - | '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' - | 'calling.collect.started' - | 'calling.collect.updated' - | 'calling.collect.ended' - | 'calling.collect.failed' - | 'calling.call.state' - // events not exposed - | 'calling.detect.updated' - | 'calling.connect.connected' - -export interface CallOptions - extends BaseComponentOptionsWithPayload { +interface CallOptions { + voice: Voice + payload?: CallingCall connectPayload?: CallingCallConnectEventParams + listeners?: RealTimeCallListeners } -/** - * A Call object represents an active call. You can get instances of a Call - * object from a {@link Voice.Client}, by answering or initiating calls. - */ -export interface Call - extends VoiceCallContract, - EmitterContract { - store: ReturnType - setPayload: (payload: CallingCall) => void - setConnectPayload: (payload: CallingCallConnectEventParams) => void -} - -export class CallConsumer extends BaseConsumer { +export class Call extends ListenSubscriber< + RealTimeCallListeners, + RealTimeCallEvents +> { + private _voice: Voice + private _context: string | undefined private _peer: Call | undefined - private _payload: CallingCall - private _connectPayload: CallingCallConnectEventParams + private _payload: CallingCall | undefined + private _connectPayload: CallingCallConnectEventParams | undefined + protected _eventMap: RealtimeCallListenersEventsMapping = { + onStateChanged: 'call.state', + onPlaybackStarted: 'playback.started', + onPlaybackUpdated: 'playback.updated', + onPlaybackFailed: 'playback.failed', + onPlaybackEnded: 'playback.ended', + onRecordingStarted: 'recording.started', + onRecordingUpdated: 'recording.updated', + onRecordingFailed: 'recording.failed', + onRecordingEnded: 'recording.ended', + onPromptStarted: 'prompt.started', + onPromptUpdated: 'prompt.updated', + onPromptFailed: 'prompt.failed', + onPromptEnded: 'prompt.ended', + onCollectStarted: 'collect.started', + onCollectInputStarted: 'collect.startOfInput', + onCollectUpdated: 'collect.updated', + onCollectFailed: 'collect.failed', + onCollectEnded: 'collect.ended', + onTapStarted: 'tap.started', + onTapEnded: 'tap.ended', + onDetectStarted: 'detect.started', + onDetectUpdated: 'detect.updated', + onDetectEnded: 'detect.ended', + } constructor(options: CallOptions) { - super(options) + super({ swClient: options.voice._sw }) + this._voice = options.voice this._payload = options.payload + this._context = options.payload?.context + this._connectPayload = options.connectPayload - 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. - */ + if (options.listeners) { + this.listen(options.listeners) + } } /** Unique id for this voice call */ @@ -135,15 +129,15 @@ export class CallConsumer extends BaseConsumer { } get tag() { - return this.__uuid + return this._payload?.tag } get nodeId() { - return this._payload.node_id + return this._payload?.node_id } get device() { - return this._payload.device + return this._payload?.device } /** The type of call. Only phone and sip are currently supported. */ @@ -174,10 +168,8 @@ export class CallConsumer extends BaseConsumer { (this.device?.params?.to_number || this.device?.params?.toNumber) ?? '' ) } - return ( - // @ts-expect-error - this.device?.params?.to ?? '' - ) + // @ts-expect-error + return this.device?.params?.to ?? '' } get headers() { @@ -198,7 +190,7 @@ export class CallConsumer extends BaseConsumer { } get context() { - return this._payload.context + return this._context } get connectState() { @@ -209,18 +201,19 @@ export class CallConsumer extends BaseConsumer { return this._peer } + /** @internal */ set peer(callInstance: Call | undefined) { this._peer = callInstance } /** @internal */ - protected setPayload(payload: CallingCall) { + setPayload(payload: CallingCall) { this._payload = payload } /** @internal */ - protected setConnectPayload(payload: CallingCallConnectEventParams) { - this._connectPayload = payload + setConnectPayload(payload: CallingCallConnectEventParams) { + this._connectPayload = { ...this._connectPayload, ...payload } } /** @@ -234,7 +227,7 @@ export class CallConsumer extends BaseConsumer { * ``` */ hangup(reason: VoiceCallDisconnectReason = 'hangup') { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { if (!this.callId || !this.nodeId) { reject( new Error( @@ -244,21 +237,23 @@ export class CallConsumer extends BaseConsumer { } this.on('call.state', (params) => { - if (params.state === 'ended') { - resolve(new Error('Failed to hangup the call.')) + if (params.callState === 'ended') { + resolve() } }) - this.execute({ - method: 'calling.end', - params: { - node_id: this.nodeId, - call_id: this.callId, - reason: reason, - }, - }).catch((e) => { - reject(e) - }) + this._client + .execute({ + method: 'calling.end', + params: { + node_id: this.nodeId, + call_id: this.callId, + reason: reason, + }, + }) + .catch((e) => { + reject(e) + }) }) } @@ -277,13 +272,14 @@ export class CallConsumer extends BaseConsumer { reject(new Error(`Can't call pass() on a call without callId.`)) } - this.execute({ - method: 'calling.pass', - params: { - node_id: this.nodeId, - call_id: this.callId, - }, - }) + this._client + .execute({ + method: 'calling.pass', + params: { + node_id: this.nodeId, + call_id: this.callId, + }, + }) .then(() => { resolve() }) @@ -299,13 +295,16 @@ export class CallConsumer extends BaseConsumer { * @example * * ```js - * client.on('call.received', async (call) => { - * try { - * await call.answer() - * console.log('Inbound call answered') - * } catch (error) { - * console.error('Error answering inbound call', error) - * } + * voice.client.listen({ + * topics: ['home'], + * onCallReceived: async (call) => { + * try { + * await call.answer() + * console.log('Inbound call answered') + * } catch (error) { + * console.error('Error answering inbound call', error) + * } + * } * }) * ``` */ @@ -323,15 +322,17 @@ export class CallConsumer extends BaseConsumer { } }) - this.execute({ - method: 'calling.answer', - params: { - node_id: this.nodeId, - call_id: this.callId, - }, - }).catch((e) => { - reject(e) - }) + this._client + .execute({ + method: 'calling.answer', + params: { + node_id: this.nodeId, + call_id: this.callId, + }, + }) + .catch((e) => { + reject(e) + }) }) } @@ -354,8 +355,10 @@ export class CallConsumer extends BaseConsumer { * )) * ``` */ - play(params: VoicePlaylist) { + play(params: CallPlayMethodParams) { return new Promise((resolve, reject) => { + const { playlist, listen } = params + if (!this.callId || !this.nodeId) { reject(new Error(`Can't call play() on a call not established yet.`)) } @@ -375,16 +378,25 @@ export class CallConsumer extends BaseConsumer { const controlId = uuid() - this.execute({ - method: 'calling.play', - params: { - node_id: this.nodeId, - call_id: this.callId, - control_id: controlId, - volume: params.volume, - play: toInternalPlayParams(params.media), + this._client.runWorker('voiceCallPlayWorker', { + worker: voiceCallPlayWorker, + initialState: { + controlId, + listeners: listen, }, }) + + this._client + .execute({ + method: 'calling.play', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: controlId, + volume: playlist.volume, + play: toInternalPlayParams(playlist.media), + }, + }) .then(() => { // TODO: handle then? }) @@ -406,10 +418,10 @@ export class CallConsumer extends BaseConsumer { * await playback.ended(); * ``` */ - playAudio(params: VoiceCallPlayAudioMethodParams) { - const { volume, ...rest } = params + playAudio(params: CallPlayAudioMethodarams) { + const { volume, listen, ...rest } = params const playlist = new Playlist({ volume }).add(Playlist.Audio(rest)) - return this.play(playlist) + return this.play({ playlist, listen }) } /** @@ -422,9 +434,10 @@ export class CallConsumer extends BaseConsumer { * await playback.ended(); * ``` */ - playSilence(params: VoiceCallPlaySilenceMethodParams) { - const playlist = new Playlist().add(Playlist.Silence(params)) - return this.play(playlist) + playSilence(params: CallPlaySilenceMethodParams) { + const { listen, ...rest } = params + const playlist = new Playlist().add(Playlist.Silence(rest)) + return this.play({ playlist, listen }) } /** @@ -437,10 +450,10 @@ export class CallConsumer extends BaseConsumer { * await playback.ended(); * ``` */ - playRingtone(params: VoiceCallPlayRingtoneMethodParams) { - const { volume, ...rest } = params + playRingtone(params: CallPlayRingtoneMethodParams) { + const { volume, listen, ...rest } = params const playlist = new Playlist({ volume }).add(Playlist.Ringtone(rest)) - return this.play(playlist) + return this.play({ playlist, listen }) } /** @@ -453,17 +466,19 @@ export class CallConsumer extends BaseConsumer { * await playback.ended(); * ``` */ - playTTS(params: VoiceCallPlayTTSMethodParams) { - const { volume, ...rest } = params + playTTS(params: CallPlayTTSMethodParams) { + const { volume, listen, ...rest } = params const playlist = new Playlist({ volume }).add(Playlist.TTS(rest)) - return this.play(playlist) + return this.play({ playlist, listen }) } /** * Generic method to record a call. Please see {@link recordAudio}. */ - record(params: VoiceCallRecordMethodParams) { + record(params: CallRecordMethodParams) { return new Promise((resolve, reject) => { + const { audio, listen } = params + if (!this.callId || !this.nodeId) { reject(new Error(`Can't call record() on a call not established yet.`)) } @@ -482,17 +497,26 @@ export class CallConsumer extends BaseConsumer { this.once('recording.failed', rejectHandler) const controlId = uuid() - const record = toSnakeCaseKeys(params) - - this.execute({ - method: 'calling.record', - params: { - node_id: this.nodeId, - call_id: this.callId, - control_id: controlId, - record, + const record = toSnakeCaseKeys({ audio }) + + this._client.runWorker('voiceCallRecordWorker', { + worker: voiceCallRecordWorker, + initialState: { + controlId, + listeners: listen, }, }) + + this._client + .execute({ + method: 'calling.record', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: controlId, + record, + }, + }) .then(() => { // TODO: handle then? }) @@ -514,17 +538,21 @@ export class CallConsumer extends BaseConsumer { * await recording.stop() * ``` */ - recordAudio(params: VoiceCallRecordMethodParams['audio'] = {}) { + recordAudio(params: CallRecordAudioMethodParams = {}) { + const { listen, ...rest } = params return this.record({ - audio: params, + audio: rest, + listen, }) } /** * Generic method to prompt the user for input. Please see {@link promptAudio}, {@link promptRingtone}, {@link promptTTS}. */ - prompt(params: VoiceCallPromptMethodParams) { + prompt(params: CallPromptMethodParams) { return new Promise((resolve, reject) => { + const { listen, ...rest } = params + if (!this.callId || !this.nodeId) { reject(new Error(`Can't call record() on a call not established yet.`)) } @@ -536,36 +564,53 @@ export class CallConsumer extends BaseConsumer { const { volume, media } = params.playlist // TODO: move this to a method to build `collect` - const { initial_timeout, digits, speech } = toSnakeCaseKeys(params) + const { initial_timeout, digits, speech } = toSnakeCaseKeys(rest) const collect = { initial_timeout, 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, + this._client.runWorker('voiceCallPlayWorker', { + worker: voiceCallPlayWorker, + initialState: { + controlId, + }, + }) + + this._client.runWorker('voiceCallCollectWorker', { + worker: voiceCallCollectWorker, + initialState: { + controlId, }, }) + + this._client + .execute({ + method: 'calling.play_and_collect', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: controlId, + volume, + play: toInternalPlayParams(media), + collect, + }, + }) .then(() => { - const promptInstance = createCallPromptObject({ - store: this.store, + const promptInstance = new CallPrompt({ + call: this, + listeners: listen, // @ts-expect-error payload: { control_id: controlId, - call_id: this.id, - node_id: this.nodeId, + call_id: this.id!, + node_id: this.nodeId!, }, }) - this.instanceMap.set(controlId, promptInstance) + this._client.instanceMap.set(controlId, promptInstance) this.emit('prompt.started', promptInstance) + promptInstance.emit('prompt.started', promptInstance) resolve(promptInstance) }) .catch((e) => { @@ -594,7 +639,7 @@ export class CallConsumer extends BaseConsumer { * const { type, digits, terminator } = await prompt.ended() * ``` */ - promptAudio(params: VoiceCallPromptAudioMethodParams) { + promptAudio(params: CallPromptAudioMethodParams) { const { url, volume, ...rest } = params const playlist = new Playlist({ volume }).add(Playlist.Audio({ url })) @@ -624,7 +669,7 @@ export class CallConsumer extends BaseConsumer { * const { type, digits, terminator } = await prompt.ended() * ``` */ - promptRingtone(params: VoiceCallPromptRingtoneMethodParams) { + promptRingtone(params: CallPromptRingtoneMethodParams) { const { name, duration, volume, ...rest } = params const playlist = new Playlist({ volume }).add( Playlist.Ringtone({ name, duration }) @@ -655,7 +700,7 @@ export class CallConsumer extends BaseConsumer { * const { type, digits, terminator } = await prompt.ended() * ``` */ - promptTTS(params: VoiceCallPromptTTSMethodParams) { + promptTTS(params: CallPromptTTSMethodParams) { const { text, language, gender, volume, ...rest } = params const playlist = new Playlist({ volume }).add( Playlist.TTS({ text, language, gender }) @@ -677,7 +722,7 @@ export class CallConsumer extends BaseConsumer { * ``` */ sendDigits(digits: string) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { if (!this.callId || !this.nodeId) { reject( new Error(`Can't call sendDigits() on a call not established yet.`) @@ -721,17 +766,26 @@ export class CallConsumer extends BaseConsumer { const controlId = uuid() - this.execute({ - method: 'calling.send_digits', - params: { - node_id: this.nodeId, - call_id: this.callId, - control_id: controlId, - digits, + this._client.runWorker('voiceCallSendDigitsWorker', { + worker: voiceCallSendDigitsWorker, + initialState: { + controlId, }, - }).catch((e) => { - reject(e) }) + + this._client + .execute({ + method: 'calling.send_digits', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: controlId, + digits, + }, + }) + .catch((e) => { + reject(e) + }) }) } @@ -755,7 +809,7 @@ export class CallConsumer extends BaseConsumer { * await tap.stop() * ``` */ - tap(params: VoiceCallTapMethodParams) { + tap(params: CallTapMethodParams) { return new Promise((resolve, reject) => { if (!this.callId || !this.nodeId) { reject(new Error(`Can't call tap() on a call not established yet.`)) @@ -780,24 +834,34 @@ export class CallConsumer extends BaseConsumer { const { audio = {}, device: { type, ...rest }, + listen, } = 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, - }, + this._client.runWorker('voiceCallTapWorker', { + worker: voiceCallTapWorker, + initialState: { + controlId, + listeners: listen, }, }) + + this._client + .execute({ + method: 'calling.tap', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: controlId, + tap: { + type: 'audio', + params: audio, + }, + device: { + type, + params: rest, + }, + }, + }) .then(() => { // TODO: handle then? }) @@ -826,9 +890,9 @@ export class CallConsumer extends BaseConsumer { * await tap.stop() * ``` */ - tapAudio(params: VoiceCallTapAudioMethodParams) { - const { direction, device } = params - return this.tap({ audio: { direction }, device }) + tapAudio(params: CallTapAudioMethodParams) { + const { direction, ...rest } = params + return this.tap({ audio: { direction }, ...rest }) } /** @@ -862,11 +926,13 @@ export class CallConsumer extends BaseConsumer { reject(new Error(`Can't call connect() on a call not established yet.`)) } + const _tag = uuid() + // We can ignore the "ringback" error since we just want to cleanup "...rest" // @ts-expect-error const { devices, ringback, ...rest } = params const executeParams: Record = { - tag: this.__uuid, + tag: _tag, ...toSnakeCaseKeys(rest), } if ('ringback' in params) { @@ -889,7 +955,7 @@ export class CallConsumer extends BaseConsumer { resolve(payload) } - const rejectHandler = (payload: Call) => { + const rejectHandler = (payload: CallingCallConnectFailedEventParams) => { // @ts-expect-error this.off('connect.connected', resolveHandler) reject(toExternalJSON(payload)) @@ -900,22 +966,32 @@ export class CallConsumer extends BaseConsumer { // @ts-expect-error this.once('connect.failed', rejectHandler) - this.execute({ - method: 'calling.connect', - params: { - node_id: this.nodeId, - call_id: this.callId, - tag: this.__uuid, - ...executeParams, + this._client.runWorker('voiceCallConnectWorker', { + worker: voiceCallConnectWorker, + initialState: { + voice: this._voice, + tag: _tag, }, - }).catch((e) => { - // @ts-expect-error - this.off('connect.connected', resolveHandler) - // @ts-expect-error - this.off('connect.failed', rejectHandler) - - reject(e) }) + + this._client + .execute({ + method: 'calling.connect', + params: { + node_id: this.nodeId, + call_id: this.callId, + tag: _tag, + ...executeParams, + }, + }) + .catch((e) => { + // @ts-expect-error + this.off('connect.connected', resolveHandler) + // @ts-expect-error + this.off('connect.failed', rejectHandler) + + reject(e) + }) }) } @@ -979,18 +1055,20 @@ export class CallConsumer extends BaseConsumer { // @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) + this._client + .execute({ + method: 'calling.disconnect', + params: { + node_id: this.nodeId, + call_id: this.callId, + }, + }) + .catch((e) => { + // @ts-expect-error + this.off('connect.disconnected', resolveHandler) - reject(e) - }) + reject(e) + }) }) } @@ -1020,7 +1098,7 @@ export class CallConsumer extends BaseConsumer { /** * Generic method. Please see {@link amd}, {@link detectFax}, {@link detectDigit}. */ - detect(params: VoiceCallDetectMethodParams) { + detect(params: CallDetectMethodParams) { return new Promise((resolve, reject) => { if (!this.callId || !this.nodeId) { reject(new Error(`Can't call detect() on a call not established yet.`)) @@ -1029,38 +1107,47 @@ export class CallConsumer extends BaseConsumer { const controlId = uuid() // TODO: build params in a method - const { timeout, type, waitForBeep = false, ...rest } = params - - this.execute({ - method: 'calling.detect', - params: { - node_id: this.nodeId, - call_id: this.callId, - control_id: controlId, - timeout, - detect: { - type, - params: toSnakeCaseKeys(rest), - }, + const { listen, timeout, type, waitForBeep = false, ...rest } = params + + this._client.runWorker('voiceCallDetectWorker', { + worker: voiceCallDetectWorker, + initialState: { + controlId, + listeners: listen, }, }) + + this._client + .execute({ + method: 'calling.detect', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: controlId, + timeout, + detect: { + type, + params: toSnakeCaseKeys(rest), + }, + }, + }) .then(() => { - const detectInstance = createCallDetectObject({ - store: this.store, + const detectInstance = new CallDetect({ + call: this, payload: { control_id: controlId, - call_id: this.id, - node_id: this.nodeId, - waitForBeep, + call_id: this.id!, + node_id: this.nodeId!, + waitForBeep: params.waitForBeep ?? false, }, + listeners: listen, }) - this.instanceMap.set(controlId, detectInstance) - // @ts-expect-error + this._client.instanceMap.set(controlId, detectInstance) this.emit('detect.started', detectInstance) + detectInstance.emit('detect.started', detectInstance) resolve(detectInstance) }) .catch((e) => { - // @ts-expect-error this.emit('detect.ended', e) reject(e) }) @@ -1079,7 +1166,7 @@ export class CallConsumer extends BaseConsumer { * console.log('Detect result:', result.type) * ``` */ - amd(params: Omit = {}) { + amd(params: CallDetectMachineParams = {}) { return this.detect({ ...params, type: 'machine', @@ -1103,7 +1190,7 @@ export class CallConsumer extends BaseConsumer { * console.log('Detect result:', result.type) * ``` */ - detectFax(params: Omit = {}) { + detectFax(params: CallDetectFaxParams = {}) { return this.detect({ ...params, type: 'fax', @@ -1122,57 +1209,13 @@ export class CallConsumer extends BaseConsumer { * console.log('Detect result:', result.type) * ``` */ - detectDigit(params: Omit = {}) { + detectDigit(params: CallDetectDigitParams = {}) { return this.detect({ ...params, type: 'digit', }) } - /** - * Returns a promise that is resolved only after the current call is in one of - * the specified states. - * - * @returns true if the requested states have been reached, false if they - * won't be reached because the call ended. - * - * @example - * - * ```js - * await call.waitFor('ended') - * ``` - */ - waitFor(params: CallingCallWaitForState | CallingCallWaitForState[]) { - return new Promise((resolve) => { - if (!params) { - resolve(true) - } - - const events = Array.isArray(params) ? params : [params] - const emittedCallStates = new Set() - const shouldResolve = () => emittedCallStates.size === events.length - const shouldWaitForEnded = events.includes('ended') - // If the user is not awaiting for the `ended` state - // and we've got that from the server then we won't - // get the event/s the user was awaiting for - const shouldResolveUnsuccessful = (state: CallingCallState) => { - return !shouldWaitForEnded && state === 'ended' - } - - this.on('call.state', (params) => { - if (events.includes(params.state as CallingCallWaitForState)) { - emittedCallStates.add(params.state) - } else if (shouldResolveUnsuccessful(params.state)) { - return resolve(false) - } - - if (shouldResolve()) { - resolve(true) - } - }) - }) - } - /** * Collect user input from the call, such as `digits` or `speech`. * @@ -1191,8 +1234,10 @@ export class CallConsumer extends BaseConsumer { * const { digits, terminator } = await collectObj.ended() * ``` */ - collect(params: VoiceCallCollectMethodParams) { + collect(params: CallCollectMethodParams) { return new Promise((resolve, reject) => { + const { listen, ...rest } = params + if (!this.callId || !this.nodeId) { reject(new Error(`Can't call collect() on a call not established yet.`)) } @@ -1208,35 +1253,45 @@ export class CallConsumer extends BaseConsumer { continuous, send_start_of_input, start_input_timers, - } = toSnakeCaseKeys(params) - - this.execute({ - method: 'calling.collect', - params: { - node_id: this.nodeId, - call_id: this.callId, - control_id: controlId, - initial_timeout, - digits, - speech, - partial_results, - continuous, - send_start_of_input, - start_input_timers, + } = toSnakeCaseKeys(rest) + + this._client.runWorker('voiceCallCollectWorker', { + worker: voiceCallCollectWorker, + initialState: { + controlId, }, }) + + this._client + .execute({ + method: 'calling.collect', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: controlId, + initial_timeout, + digits, + speech, + partial_results, + continuous, + send_start_of_input, + start_input_timers, + }, + }) .then(() => { - const collectInstance = createCallCollectObject({ - store: this.store, + const collectInstance = new CallCollect({ + call: this, + listeners: listen, // @ts-expect-error payload: { control_id: controlId, - call_id: this.id, - node_id: this.nodeId, + call_id: this.id!, + node_id: this.nodeId!, }, }) - this.instanceMap.set(controlId, collectInstance) + this._client.instanceMap.set(controlId, collectInstance) this.emit('collect.started', collectInstance) + collectInstance.emit('collect.started', collectInstance) resolve(collectInstance) }) .catch((e) => { @@ -1245,19 +1300,48 @@ export class CallConsumer extends BaseConsumer { }) }) } -} -// FIXME: instead of Omit methods, i used "Partial" -export const CallAPI = extendComponent>( - CallConsumer, - {} -) + /** + * Returns a promise that is resolved only after the current call is in one of + * the specified states. + * + * @returns true if the requested states have been reached, false if they + * won't be reached because the call ended. + * + * @example + * + * ```js + * await call.waitFor('ended') + * ``` + */ + waitFor(params: CallingCallWaitForState | CallingCallWaitForState[]) { + return new Promise((resolve) => { + if (!params) { + resolve(true) + } -export const createCallObject = (params: CallOptions): Call => { - const call = connect({ - store: params.store, - Component: CallAPI, - })(params) + const events = Array.isArray(params) ? params : [params] + const emittedCallStates = new Set() + const shouldResolve = () => emittedCallStates.size === events.length + const shouldWaitForEnded = events.includes('ended') + // If the user is not awaiting for the `ended` state + // and we've got that from the server then we won't + // get the event/s the user was awaiting for + const shouldResolveUnsuccessful = (state: CallingCallState) => { + return !shouldWaitForEnded && state === 'ended' + } - return call + this.on('call.state', (params) => { + if (events.includes(params.state as CallingCallWaitForState)) { + emittedCallStates.add(params.state!) + } else if (shouldResolveUnsuccessful(params.state!)) { + return resolve(false) + } + + if (shouldResolve()) { + resolve(true) + } + }) + }) + } } diff --git a/packages/realtime-api/src/voice/CallCollect.test.ts b/packages/realtime-api/src/voice/CallCollect.test.ts index 180494654..6704f6a53 100644 --- a/packages/realtime-api/src/voice/CallCollect.test.ts +++ b/packages/realtime-api/src/voice/CallCollect.test.ts @@ -1,50 +1,108 @@ -import { configureJestStore } from '../testUtils' -import { createCallCollectObject, CallCollect } from './CallCollect' +import { createClient } from '../client/createClient' +import { CallCollect } from './CallCollect' +import { Call } from './Call' +import { Voice } from './Voice' +import { EventEmitter } from '@signalwire/core' describe('CallCollect', () => { - describe('createCallCollectObject', () => { - let instance: CallCollect - beforeEach(() => { - instance = createCallCollectObject({ - store: configureJestStore(), - // @ts-expect-error - payload: { - call_id: 'call_id', - node_id: 'node_id', - control_id: 'control_id', - }, - }) + let voice: Voice + let call: Call + let callCollect: CallCollect + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + + callCollect = new CallCollect({ + call, // @ts-expect-error - instance.execute = jest.fn() + payload: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, }) - it('should control an active collect action', async () => { - 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.collect.stop', - }) + // @ts-expect-error + callCollect._client.execute = jest.fn() + }) - await instance.startInputTimers() - // @ts-expect-error - expect(instance.execute).toHaveBeenLastCalledWith({ - method: 'calling.collect.start_input_timers', - params: { - call_id: 'call_id', - node_id: 'node_id', - control_id: 'control_id', - }, - }) + it('should have an event emitter', () => { + expect(callCollect['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'collect.started', + onInputStarted: 'collect.startOfInput', + onUpdated: 'collect.updated', + onFailed: 'collect.failed', + onEnded: 'collect.ended', + } + expect(callCollect['_eventMap']).toEqual(expectedEventMap) + }) + + it('should attach all listeners', () => { + // @ts-expect-error + callCollect = new CallCollect({ + call, + listeners: { + onStarted: () => {}, + onInputStarted: () => {}, + onUpdated: () => {}, + onFailed: () => {}, + onEnded: () => {}, + }, + }) + + // @ts-expect-error + expect(callCollect.emitter.eventNames()).toStrictEqual([ + 'collect.started', + 'collect.startOfInput', + 'collect.updated', + 'collect.failed', + 'collect.ended', + ]) + }) + + it('should control an active collect action', async () => { + const baseExecuteParams = { + method: '', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + } + + await callCollect.stop() + // @ts-expect-error + expect(callCollect._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.collect.stop', + }) + + await callCollect.startInputTimers() + // @ts-expect-error + expect(callCollect._client.execute).toHaveBeenLastCalledWith({ + method: 'calling.collect.start_input_timers', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, }) }) }) diff --git a/packages/realtime-api/src/voice/CallCollect.ts b/packages/realtime-api/src/voice/CallCollect.ts index a64c13efb..11a3f01cf 100644 --- a/packages/realtime-api/src/voice/CallCollect.ts +++ b/packages/realtime-api/src/voice/CallCollect.ts @@ -1,31 +1,22 @@ import { - connect, - BaseComponentOptionsWithPayload, VoiceCallCollectContract, CallingCallCollectEndState, - CallCollectEndedEvent, CallingCallCollectEventParams, - EventEmitter, - BaseConsumer, } 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 CallCollect extends VoiceCallCollectContract { - setPayload: (payload: CallingCallCollectEventParams) => void - /** @internal */ - emit(event: EventEmitter.EventNames, ...args: any[]): void +import { ListenSubscriber } from '../ListenSubscriber' +import { Call } from './Call' +import { + CallCollectEvents, + CallCollectListeners, + CallCollectListenersEventsMapping, +} from '../types' + +export interface CallCollectOptions { + call: Call + payload: CallingCallCollectEventParams + listeners?: CallCollectListeners } -export type CallCollectEventsHandlerMapping = {} - -export interface CallCollectOptions - extends BaseComponentOptionsWithPayload {} - const ENDED_STATES: CallingCallCollectEndState[] = [ 'error', 'no_input', @@ -34,16 +25,27 @@ const ENDED_STATES: CallingCallCollectEndState[] = [ 'speech', ] -export class CallCollectAPI - extends BaseConsumer +export class CallCollect + extends ListenSubscriber implements VoiceCallCollectContract { private _payload: CallingCallCollectEventParams + protected _eventMap: CallCollectListenersEventsMapping = { + onStarted: 'collect.started', + onInputStarted: 'collect.startOfInput', + onUpdated: 'collect.updated', + onFailed: 'collect.failed', + onEnded: 'collect.ended', + } constructor(options: CallCollectOptions) { - super(options) + super({ swClient: options.call._sw }) this._payload = options.payload + + if (options.listeners) { + this.listen(options.listeners) + } } get id() { @@ -122,14 +124,14 @@ export class CallCollectAPI } /** @internal */ - protected setPayload(payload: CallingCallCollectEventParams) { + setPayload(payload: CallingCallCollectEventParams) { this._payload = payload } async stop() { // Execute stop only if we don't have result yet if (!this.result) { - await this.execute({ + await this._client.execute({ method: 'calling.collect.stop', params: { node_id: this.nodeId, @@ -148,7 +150,7 @@ export class CallCollectAPI } async startInputTimers() { - await this.execute({ + await this._client.execute({ method: 'calling.collect.start_input_timers', params: { node_id: this.nodeId, @@ -163,47 +165,35 @@ export class CallCollectAPI ended() { // Resolve the promise if the collect has already ended if ( - this.state != 'collecting' && this.final !== false && + this.state != 'collecting' && + this.final !== false && ENDED_STATES.includes(this.result?.type as CallingCallCollectEndState) ) { return Promise.resolve(this) } return new Promise((resolve) => { - const handler = (_callCollect: CallCollectEndedEvent['params']) => { - // @ts-expect-error + const handler = () => { this.off('collect.ended', handler) - // @ts-expect-error this.off('collect.failed', handler) // It's important to notice that we're returning // `this` instead of creating a brand new instance - // using the payload + EventEmitter Transform - // pipeline. `this` is the instance created by the - // `Call` Emitter Transform pipeline (singleton per - // `Call.prompt()`) that gets auto updated (using + // using the payload. `this` is the instance created by the + // `voiceCallCollectWorker` (singleton per + // `call.play()`) that gets auto updated (using // the latest payload per event) by the // `voiceCallCollectWorker` resolve(this) } - // @ts-expect-error this.once('collect.ended', handler) - // @ts-expect-error this.once('collect.failed', handler) + + // Resolve the promise if the collect has already ended + if ( + ENDED_STATES.includes(this.result?.type as CallingCallCollectEndState) + ) { + handler() + } }) } } - -export const createCallCollectObject = ( - params: CallCollectOptions -): CallCollect => { - const collect = connect< - CallCollectEventsHandlerMapping, - CallCollectAPI, - CallCollect - >({ - store: params.store, - Component: CallCollectAPI, - })(params) - - return collect -} diff --git a/packages/realtime-api/src/voice/CallDetect.test.ts b/packages/realtime-api/src/voice/CallDetect.test.ts index 6ad9731fb..8136e976a 100644 --- a/packages/realtime-api/src/voice/CallDetect.test.ts +++ b/packages/realtime-api/src/voice/CallDetect.test.ts @@ -1,38 +1,91 @@ -import { configureJestStore } from '../testUtils' -import { createCallDetectObject, CallDetect } from './CallDetect' +import { EventEmitter } from '@signalwire/core' +import { createClient } from '../client/createClient' +import { CallDetect } from './CallDetect' +import { Call } from './Call' +import { Voice } from './Voice' describe('CallDetect', () => { - describe('createCallDetectObject', () => { - let instance: CallDetect - beforeEach(() => { - instance = createCallDetectObject({ - store: configureJestStore(), - payload: { - call_id: 'call_id', - node_id: 'node_id', - control_id: 'control_id', - }, - }) - // @ts-expect-error - instance.execute = jest.fn() + let voice: Voice + let call: Call + let callDetect: CallDetect + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + + callDetect = new CallDetect({ + call, + payload: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, }) - it('should control an active playback', async () => { - const baseExecuteParams = { - method: '', - params: { - call_id: 'call_id', - node_id: 'node_id', - control_id: 'control_id', - }, - } - - await instance.stop() + // @ts-expect-error + callDetect._client.execute = jest.fn() + }) + + it('should have an event emitter', () => { + expect(callDetect['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'detect.started', + onUpdated: 'detect.updated', + onEnded: 'detect.ended', + } + expect(callDetect['_eventMap']).toEqual(expectedEventMap) + }) + + it('should attach all listeners', () => { + callDetect = new CallDetect({ + call, // @ts-expect-error - expect(instance.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.detect.stop', - }) + payload: {}, + listeners: { + onStarted: () => {}, + onUpdated: () => {}, + onEnded: () => {}, + }, + }) + + // @ts-expect-error + expect(callDetect.emitter.eventNames()).toStrictEqual([ + 'detect.started', + 'detect.updated', + 'detect.ended', + ]) + }) + + it('should stop the detection', async () => { + const baseExecuteParams = { + method: '', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + } + + await callDetect.stop() + // @ts-expect-error + expect(callDetect._client.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 index d30fbc460..3cb9d5901 100644 --- a/packages/realtime-api/src/voice/CallDetect.ts +++ b/packages/realtime-api/src/voice/CallDetect.ts @@ -1,47 +1,46 @@ import { - connect, - BaseComponentOptionsWithPayload, VoiceCallDetectContract, CallingCallDetectEventParams, - BaseConsumer, - EventEmitter, type DetectorResult, } 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 { - setPayload: (payload: CallingCallDetectEventParams) => void - waitingForReady: boolean - waitForBeep: boolean - /** @internal */ - emit(event: EventEmitter.EventNames, ...args: any[]): void +import { ListenSubscriber } from '../ListenSubscriber' +import { Call } from './Call' +import { + CallDetectEvents, + CallDetectListeners, + CallDetectListenersEventsMapping, +} from '../types' + +export interface CallDetectOptions { + call: Call + payload: CallingCallDetectEventParams + listeners?: CallDetectListeners } -export type CallDetectEventsHandlerMapping = {} - -export interface CallDetectOptions - extends BaseComponentOptionsWithPayload {} - const ENDED_STATES: DetectorResult[] = ['finished', 'error'] -export class CallDetectAPI - extends BaseConsumer +export class CallDetect + extends ListenSubscriber implements VoiceCallDetectContract { - private _payload: CallingCallDetectEventParams private _waitForBeep: boolean private _result: DetectorResult = 'UNKNOWN' + private _payload: CallingCallDetectEventParams + protected _eventMap: CallDetectListenersEventsMapping = { + onStarted: 'detect.started', + onUpdated: 'detect.updated', + onEnded: 'detect.ended', + } constructor(options: CallDetectOptions) { - super(options) + super({ swClient: options.call._sw }) this._payload = options.payload this._waitForBeep = options.payload.waitForBeep + + if (options.listeners) { + this.listen(options.listeners) + } } get id() { @@ -84,7 +83,7 @@ export class CallDetectAPI } /** @internal */ - protected setPayload(payload: CallingCallDetectEventParams) { + setPayload(payload: CallingCallDetectEventParams) { this._payload = payload const lastEvent = this._lastEvent() @@ -95,7 +94,7 @@ export class CallDetectAPI async stop() { // if (this.state !== 'finished') { - await this.execute({ + await this._client.execute({ method: 'calling.detect.stop', params: { node_id: this.nodeId, @@ -122,20 +121,16 @@ export class CallDetectAPI return new Promise((resolve) => { const handler = () => { - // @ts-expect-error this.off('detect.ended', handler) // It's important to notice that we're returning // `this` instead of creating a brand new instance - // using the payload + EventEmitter Transform - // pipeline. `this` is the instance created by the - // `Call` Emitter Transform pipeline (singleton per - // `Call.detect()`) that gets auto updated (using + // using the payload. `this` is the instance created by the + // `voiceCallDetectWorker` (singleton per + // `call.play()`) that gets auto updated (using // the latest payload per event) by the // `voiceCallDetectWorker` resolve(this) } - - // @ts-expect-error this.once('detect.ended', handler) }) } @@ -144,18 +139,3 @@ export class CallDetectAPI return this.detect?.params.event } } - -export const createCallDetectObject = ( - params: CallDetectOptions -): CallDetect => { - const detect = connect< - CallDetectEventsHandlerMapping, - CallDetectAPI, - CallDetect - >({ - store: params.store, - Component: CallDetectAPI, - })(params) - - return detect -} diff --git a/packages/realtime-api/src/voice/CallPlayback.test.ts b/packages/realtime-api/src/voice/CallPlayback.test.ts index 3fe1162bd..8ac49a2b0 100644 --- a/packages/realtime-api/src/voice/CallPlayback.test.ts +++ b/packages/realtime-api/src/voice/CallPlayback.test.ts @@ -1,61 +1,118 @@ -import { configureJestStore } from '../testUtils' -import { createCallPlaybackObject, CallPlayback } from './CallPlayback' +import { EventEmitter } from '@signalwire/core' +import { createClient } from '../client/createClient' +import { CallPlayback } from './CallPlayback' +import { Call } from './Call' +import { Voice } from './Voice' describe('CallPlayback', () => { - describe('createCallPlaybackObject', () => { - let instance: CallPlayback - beforeEach(() => { - instance = createCallPlaybackObject({ - store: configureJestStore(), - // @ts-expect-error - payload: { - call_id: 'call_id', - node_id: 'node_id', - control_id: 'control_id', - }, - }) + let voice: Voice + let call: Call + let callPlayback: CallPlayback + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + + callPlayback = new CallPlayback({ + call, // @ts-expect-error - instance.execute = jest.fn() + payload: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, }) - it('should control an active playback', async () => { - 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 + callPlayback._client.execute = jest.fn() + }) + + it('should have an event emitter', () => { + expect(callPlayback['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'playback.started', + onUpdated: 'playback.updated', + onFailed: 'playback.failed', + onEnded: 'playback.ended', + } + expect(callPlayback['_eventMap']).toEqual(expectedEventMap) + }) + + it('should attach all listeners', () => { + callPlayback = new CallPlayback({ + call, // @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, - }, - }) + payload: {}, + listeners: { + onStarted: () => {}, + onUpdated: () => {}, + onFailed: () => {}, + onEnded: () => {}, + }, + }) + + // @ts-expect-error + expect(callPlayback.emitter.eventNames()).toStrictEqual([ + 'playback.started', + 'playback.updated', + 'playback.failed', + 'playback.ended', + ]) + }) + + it('should control an active playback', async () => { + const baseExecuteParams = { + method: '', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + } + await callPlayback.pause() + // @ts-expect-error + expect(callPlayback._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.play.pause', + }) + + await callPlayback.resume() + // @ts-expect-error + expect(callPlayback._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.play.resume', + }) + await callPlayback.stop() + // @ts-expect-error + expect(callPlayback._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.play.stop', + }) + await callPlayback.setVolume(2) + // @ts-expect-error + expect(callPlayback._client.execute).toHaveBeenLastCalledWith({ + method: 'calling.play.volume', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + volume: 2, + }, }) }) }) diff --git a/packages/realtime-api/src/voice/CallPlayback.ts b/packages/realtime-api/src/voice/CallPlayback.ts index f5616d3f7..2af17e6c0 100644 --- a/packages/realtime-api/src/voice/CallPlayback.ts +++ b/packages/realtime-api/src/voice/CallPlayback.ts @@ -1,50 +1,47 @@ import { - connect, - VoiceCallPlaybackContract, CallingCallPlayEndState, CallingCallPlayEventParams, - BaseConsumer, - BaseComponentOptionsWithPayload, - EventEmitter, + VoiceCallPlaybackContract, } 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 { - setPayload: (payload: CallingCallPlayEventParams) => void - _paused: boolean - /** @internal */ - emit(event: EventEmitter.EventNames, ...args: any[]): void +import { ListenSubscriber } from '../ListenSubscriber' +import { + CallPlaybackEvents, + CallPlaybackListeners, + CallPlaybackListenersEventsMapping, +} from '../types' +import { Call } from './Call' + +export interface CallPlaybackOptions { + call: Call + payload: CallingCallPlayEventParams + listeners?: CallPlaybackListeners } -// export type CallPlaybackEventsHandlerMapping = Record< -// VideoPlaybackEventNames, -// (playback: CallPlayback) => void -// > -export type CallPlaybackEventsHandlerMapping = {} - -export interface CallPlaybackOptions - extends BaseComponentOptionsWithPayload {} - const ENDED_STATES: CallingCallPlayEndState[] = ['finished', 'error'] -export class CallPlaybackAPI - extends BaseConsumer +export class CallPlayback + extends ListenSubscriber implements VoiceCallPlaybackContract { public _paused: boolean private _volume: number private _payload: CallingCallPlayEventParams + protected _eventMap: CallPlaybackListenersEventsMapping = { + onStarted: 'playback.started', + onUpdated: 'playback.updated', + onFailed: 'playback.failed', + onEnded: 'playback.ended', + } constructor(options: CallPlaybackOptions) { - super(options) + super({ swClient: options.call._sw }) this._payload = options.payload this._paused = false + + if (options.listeners) { + this.listen(options.listeners) + } } get id() { @@ -72,12 +69,12 @@ export class CallPlaybackAPI } /** @internal */ - protected setPayload(payload: CallingCallPlayEventParams) { + setPayload(payload: CallingCallPlayEventParams) { this._payload = payload } async pause() { - await this.execute({ + await this._client.execute({ method: 'calling.play.pause', params: { node_id: this.nodeId, @@ -90,7 +87,7 @@ export class CallPlaybackAPI } async resume() { - await this.execute({ + await this._client.execute({ method: 'calling.play.resume', params: { node_id: this.nodeId, @@ -103,7 +100,7 @@ export class CallPlaybackAPI } async stop() { - await this.execute({ + await this._client.execute({ method: 'calling.play.stop', params: { node_id: this.nodeId, @@ -118,7 +115,7 @@ export class CallPlaybackAPI async setVolume(volume: number) { this._volume = volume - await this.execute({ + await this._client.execute({ method: 'calling.play.volume', params: { node_id: this.nodeId, @@ -139,44 +136,24 @@ export class CallPlaybackAPI ended() { return new Promise((resolve) => { const handler = () => { - // @ts-expect-error this.off('playback.ended', handler) - // @ts-expect-error this.off('playback.failed', handler) // It's important to notice that we're returning // `this` instead of creating a brand new instance - // using the payload + EventEmitter Transform - // pipeline. `this` is the instance created by the - // `Call` Emitter Transform pipeline (singleton per - // `Call.play()`) that gets auto updated (using + // using the payload. `this` is the instance created by the + // `voiceCallPlayWorker` (singleton per + // `call.play()`) that gets auto updated (using // the latest payload per event) by the // `voiceCallPlayWorker` resolve(this) } - // @ts-expect-error this.once('playback.ended', handler) - // @ts-expect-error this.once('playback.failed', handler) - // Resolve the promise if the recording has already ended + // Resolve the promise if the play has already ended if (ENDED_STATES.includes(this.state as CallingCallPlayEndState)) { handler() } }) } } - -export const createCallPlaybackObject = ( - params: CallPlaybackOptions -): CallPlayback => { - const playback = connect< - CallPlaybackEventsHandlerMapping, - CallPlaybackAPI, - CallPlayback - >({ - store: params.store, - Component: CallPlaybackAPI, - })(params) - - return playback -} diff --git a/packages/realtime-api/src/voice/CallPrompt.test.ts b/packages/realtime-api/src/voice/CallPrompt.test.ts index d33241d1b..85960e9f8 100644 --- a/packages/realtime-api/src/voice/CallPrompt.test.ts +++ b/packages/realtime-api/src/voice/CallPrompt.test.ts @@ -1,51 +1,107 @@ -import { configureJestStore } from '../testUtils' -import { createCallPromptObject, CallPrompt } from './CallPrompt' +import { EventEmitter } from '@signalwire/core' +import { createClient } from '../client/createClient' +import { CallPrompt } from './CallPrompt' +import { Call } from './Call' +import { Voice } from './Voice' describe('CallPrompt', () => { - describe('createCallPromptObject', () => { - let instance: CallPrompt - beforeEach(() => { - instance = createCallPromptObject({ - store: configureJestStore(), - // @ts-expect-error - payload: { - call_id: 'call_id', - node_id: 'node_id', - control_id: 'control_id', - }, - }) + let voice: Voice + let call: Call + let callPrompt: CallPrompt + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + + callPrompt = new CallPrompt({ + call, // @ts-expect-error - instance.execute = jest.fn() + payload: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, }) - it('should control an active playback', async () => { - 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', - }) + // @ts-expect-error + callPrompt._client.execute = jest.fn() + }) + + it('should have an event emitter', () => { + expect(callPrompt['emitter']).toBeInstanceOf(EventEmitter) + }) - await instance.setVolume(5) + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'prompt.started', + onUpdated: 'prompt.updated', + onFailed: 'prompt.failed', + onEnded: 'prompt.ended', + } + expect(callPrompt['_eventMap']).toEqual(expectedEventMap) + }) + + it('should attach all listeners', () => { + callPrompt = new CallPrompt({ + call, // @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, - }, - }) + payload: {}, + listeners: { + onStarted: () => {}, + onUpdated: () => {}, + onFailed: () => {}, + onEnded: () => {}, + }, + }) + + // @ts-expect-error + expect(callPrompt.emitter.eventNames()).toStrictEqual([ + 'prompt.started', + 'prompt.updated', + 'prompt.failed', + 'prompt.ended', + ]) + }) + + it('should control an active prompt', async () => { + const baseExecuteParams = { + method: '', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + } + + await callPrompt.stop() + // @ts-expect-error + expect(callPrompt._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.play_and_collect.stop', + }) + + await callPrompt.setVolume(5) + // @ts-expect-error + expect(callPrompt._client.execute).toHaveBeenLastCalledWith({ + method: 'calling.play_and_collect.volume', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + volume: 5, + }, }) }) }) diff --git a/packages/realtime-api/src/voice/CallPrompt.ts b/packages/realtime-api/src/voice/CallPrompt.ts index 65cf5398c..b6d30ecd9 100644 --- a/packages/realtime-api/src/voice/CallPrompt.ts +++ b/packages/realtime-api/src/voice/CallPrompt.ts @@ -1,31 +1,22 @@ import { - connect, - BaseComponentOptionsWithPayload, VoiceCallPromptContract, CallingCallCollectEndState, - CallPromptEndedEvent, CallingCallCollectEventParams, - EventEmitter, - BaseConsumer, } 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 { - setPayload: (payload: CallingCallCollectEventParams) => void - /** @internal */ - emit(event: EventEmitter.EventNames, ...args: any[]): void +import { Call } from './Call' +import { + CallPromptEvents, + CallPromptListeners, + CallPromptListenersEventsMapping, +} from '../types' +import { ListenSubscriber } from '../ListenSubscriber' + +export interface CallPromptOptions { + call: Call + payload: CallingCallCollectEventParams + listeners?: CallPromptListeners } -export type CallPromptEventsHandlerMapping = {} - -export interface CallPromptOptions - extends BaseComponentOptionsWithPayload {} - const ENDED_STATES: CallingCallCollectEndState[] = [ 'no_input', 'error', @@ -34,16 +25,26 @@ const ENDED_STATES: CallingCallCollectEndState[] = [ 'speech', ] -export class CallPromptAPI - extends BaseConsumer +export class CallPrompt + extends ListenSubscriber implements VoiceCallPromptContract { private _payload: CallingCallCollectEventParams + protected _eventMap: CallPromptListenersEventsMapping = { + onStarted: 'prompt.started', + onUpdated: 'prompt.updated', + onFailed: 'prompt.failed', + onEnded: 'prompt.ended', + } constructor(options: CallPromptOptions) { - super(options) + super({ swClient: options.call._sw }) this._payload = options.payload + + if (options.listeners) { + this.listen(options.listeners) + } } get id() { @@ -114,14 +115,14 @@ export class CallPromptAPI } /** @internal */ - protected setPayload(payload: CallingCallCollectEventParams) { + setPayload(payload: CallingCallCollectEventParams) { this._payload = payload } async stop() { // Execute stop only if we don't have result yet if (!this.result) { - await this.execute({ + await this._client.execute({ method: 'calling.play_and_collect.stop', params: { node_id: this.nodeId, @@ -140,7 +141,7 @@ export class CallPromptAPI } async setVolume(volume: number): Promise { - await this.execute({ + await this._client.execute({ method: 'calling.play_and_collect.volume', params: { node_id: this.nodeId, @@ -159,48 +160,28 @@ export class CallPromptAPI } ended() { - // Resolve the promise if the prompt has already ended - if ( - ENDED_STATES.includes(this.result?.type as CallingCallCollectEndState) - ) { - return Promise.resolve(this) - } - return new Promise((resolve) => { - const handler = (_callPrompt: CallPromptEndedEvent['params']) => { - // @ts-expect-error + const handler = () => { this.off('prompt.ended', handler) - // @ts-expect-error this.off('prompt.failed', handler) // It's important to notice that we're returning // `this` instead of creating a brand new instance - // using the payload + EventEmitter Transform - // pipeline. `this` is the instance created by the - // `Call` Emitter Transform pipeline (singleton per - // `Call.prompt()`) that gets auto updated (using + // using the payload. `this` is the instance created by the + // `voiceCallPlayWorker` (singleton per + // `call.play()`) that gets auto updated (using // the latest payload per event) by the - // `voiceCallPromptWorker` + // `voiceCallPlayWorker` resolve(this) } - // @ts-expect-error this.once('prompt.ended', handler) - // @ts-expect-error this.once('prompt.failed', handler) + + // Resolve the promise if the prompt has already ended + if ( + ENDED_STATES.includes(this.result?.type as CallingCallCollectEndState) + ) { + handler() + } }) } } - -export const createCallPromptObject = ( - params: CallPromptOptions -): CallPrompt => { - const record = connect< - CallPromptEventsHandlerMapping, - CallPromptAPI, - CallPrompt - >({ - store: params.store, - Component: CallPromptAPI, - })(params) - - return record -} diff --git a/packages/realtime-api/src/voice/CallRecording.test.ts b/packages/realtime-api/src/voice/CallRecording.test.ts index 81abf27d8..c0a179aff 100644 --- a/packages/realtime-api/src/voice/CallRecording.test.ts +++ b/packages/realtime-api/src/voice/CallRecording.test.ts @@ -1,66 +1,120 @@ -import { configureJestStore } from '../testUtils' -import { createCallRecordingObject, CallRecording } from './CallRecording' +import { EventEmitter } from '@signalwire/core' +import { createClient } from '../client/createClient' +import { CallRecording } from './CallRecording' +import { Call } from './Call' +import { Voice } from './Voice' describe('CallRecording', () => { - describe('createCallRecordingObject', () => { - let instance: CallRecording - beforeEach(() => { - instance = createCallRecordingObject({ - store: configureJestStore(), - // @ts-expect-error - payload: { - call_id: 'call_id', - node_id: 'node_id', - control_id: 'control_id', - }, - }) + let voice: Voice + let call: Call + let callRecording: CallRecording + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + + callRecording = new CallRecording({ + call, // @ts-expect-error - instance.execute = jest.fn() + payload: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, }) - it('should control an active recording', async () => { - const baseExecuteParams = { - method: '', - params: { - call_id: 'call_id', - node_id: 'node_id', - control_id: 'control_id', - }, - } + // @ts-expect-error + callRecording._client.execute = jest.fn() + }) - await instance.pause() - // @ts-expect-error - expect(instance.execute).toHaveBeenLastCalledWith({ - method: 'calling.record.pause', - params: { - ...baseExecuteParams.params, - behavior: 'silence', - }, - }) + it('should have an event emitter', () => { + expect(callRecording['emitter']).toBeInstanceOf(EventEmitter) + }) - await instance.pause({ behavior: 'skip' }) - // @ts-expect-error - expect(instance.execute).toHaveBeenLastCalledWith({ - method: 'calling.record.pause', - params: { - ...baseExecuteParams.params, - behavior: 'skip', - }, - }) + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'recording.started', + onFailed: 'recording.failed', + onEnded: 'recording.ended', + } + expect(callRecording['_eventMap']).toEqual(expectedEventMap) + }) - await instance.resume() + it('should attach all listeners', () => { + callRecording = new CallRecording({ + call, // @ts-expect-error - expect(instance.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.record.resume', - }) + payload: {}, + listeners: { + onStarted: () => {}, + onFailed: () => {}, + onEnded: () => {}, + }, + }) - await instance.stop() - // @ts-expect-error - expect(instance.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.record.stop', - }) + // @ts-expect-error + expect(callRecording.emitter.eventNames()).toStrictEqual([ + 'recording.started', + 'recording.updated', + 'recording.failed', + 'recording.ended', + ]) + }) + + it('should control an active playback', async () => { + const baseExecuteParams = { + method: '', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + } + + await callRecording.pause() + // @ts-expect-error + expect(callRecording._client.execute).toHaveBeenLastCalledWith({ + method: 'calling.record.pause', + params: { + ...baseExecuteParams.params, + behavior: 'silence', + }, + }) + + await callRecording.pause({ behavior: 'skip' }) + // @ts-expect-error + expect(callRecording._client.execute).toHaveBeenLastCalledWith({ + method: 'calling.record.pause', + params: { + ...baseExecuteParams.params, + behavior: 'skip', + }, + }) + + await callRecording.resume() + // @ts-expect-error + expect(callRecording._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.record.resume', + }) + + await callRecording.stop() + // @ts-expect-error + expect(callRecording._client.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 index edc1b2951..11b33bcc6 100644 --- a/packages/realtime-api/src/voice/CallRecording.ts +++ b/packages/realtime-api/src/voice/CallRecording.ts @@ -1,93 +1,94 @@ import { - connect, - BaseComponentOptionsWithPayload, VoiceCallRecordingContract, CallingCallRecordEndState, CallingCallRecordEventParams, - EventEmitter, - BaseConsumer, CallingCallRecordPauseMethodParams, } 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 { - setPayload: (payload: CallingCallRecordEventParams) => void - _paused: boolean - /** @internal */ - emit(event: EventEmitter.EventNames, ...args: any[]): void +import { ListenSubscriber } from '../ListenSubscriber' +import { + CallRecordingEvents, + CallRecordingListeners, + CallRecordingListenersEventsMapping, +} from '../types' +import { Call } from './Call' + +export interface CallRecordingOptions { + call: Call + payload: CallingCallRecordEventParams + listeners?: CallRecordingListeners } -export type CallRecordingEventsHandlerMapping = {} - -export interface CallRecordingOptions - extends BaseComponentOptionsWithPayload {} - const ENDED_STATES: CallingCallRecordEndState[] = ['finished', 'no_input'] -export class CallRecordingAPI - extends BaseConsumer +export class CallRecording + extends ListenSubscriber implements VoiceCallRecordingContract { public _paused: boolean private _payload: CallingCallRecordEventParams + protected _eventMap: CallRecordingListenersEventsMapping = { + onStarted: 'recording.started', + onUpdated: 'recording.updated', + onFailed: 'recording.failed', + onEnded: 'recording.ended', + } constructor(options: CallRecordingOptions) { - super(options) + super({ swClient: options.call._sw }) this._payload = options.payload this._paused = false + + if (options.listeners) { + this.listen(options.listeners) + } } get id() { - return this._payload?.control_id + return this._payload.control_id } get callId() { - return this._payload?.call_id + return this._payload.call_id } get nodeId() { - return this._payload?.node_id + return this._payload.node_id } get controlId() { - return this._payload?.control_id + return this._payload.control_id } get state() { - return this._payload?.state + return this._payload.state } get url() { - return this._payload?.url + return this._payload.url } get size() { - return this._payload?.size + return this._payload.size } get duration() { - return this._payload?.duration + return this._payload.duration } get record() { - return this._payload?.record + return this._payload.record } /** @internal */ - protected setPayload(payload: CallingCallRecordEventParams) { + setPayload(payload: CallingCallRecordEventParams) { this._payload = payload } async pause(params?: CallingCallRecordPauseMethodParams) { const { behavior = 'silence' } = params || {} - await this.execute({ + await this._client.execute({ method: 'calling.record.pause', params: { node_id: this.nodeId, @@ -101,7 +102,7 @@ export class CallRecordingAPI } async resume() { - await this.execute({ + await this._client.execute({ method: 'calling.record.resume', params: { node_id: this.nodeId, @@ -114,7 +115,7 @@ export class CallRecordingAPI } async stop() { - await this.execute({ + await this._client.execute({ method: 'calling.record.stop', params: { node_id: this.nodeId, @@ -123,35 +124,24 @@ export class CallRecordingAPI }, }) - /** - * TODO: we should wait for the recording `finished` event to allow - * the CallRecording/Proxy object to update the payload properly - */ - return this } ended() { return new Promise((resolve) => { const handler = () => { - // @ts-expect-error this.off('recording.ended', handler) - // @ts-expect-error this.off('recording.failed', handler) // It's important to notice that we're returning // `this` instead of creating a brand new instance - // using the payload + EventEmitter Transform - // pipeline. `this` is the instance created by the - // `Call` Emitter Transform pipeline (singleton per - // `Call.record()`) that gets auto updated (using + // using the payload. `this` is the instance created by the + // `voiceCallRecordWorker` (singleton per + // `call.play()`) that gets auto updated (using // the latest payload per event) by the // `voiceCallRecordWorker` resolve(this) } - // @ts-expect-error this.once('recording.ended', handler) - // TODO: review what else to return when `recording.failed` happens. - // @ts-expect-error this.once('recording.failed', handler) // Resolve the promise if the recording has already ended @@ -161,18 +151,3 @@ export class CallRecordingAPI }) } } - -export const createCallRecordingObject = ( - params: CallRecordingOptions -): CallRecording => { - const record = connect< - CallRecordingEventsHandlerMapping, - CallRecordingAPI, - CallRecording - >({ - store: params.store, - Component: CallRecordingAPI, - })(params) - - return record -} diff --git a/packages/realtime-api/src/voice/CallTap.test.ts b/packages/realtime-api/src/voice/CallTap.test.ts index 3758f3da1..a71c90992 100644 --- a/packages/realtime-api/src/voice/CallTap.test.ts +++ b/packages/realtime-api/src/voice/CallTap.test.ts @@ -1,57 +1,106 @@ -import { configureJestStore } from '../testUtils' -import { createCallTapObject, CallTap } from './CallTap' +import { EventEmitter } from '@signalwire/core' +import { createClient } from '../client/createClient' +import { CallTap } from './CallTap' +import { Call } from './Call' +import { Voice } from './Voice' describe('CallTap', () => { - describe('createCallTapObject', () => { - let instance: CallTap - beforeEach(() => { - instance = createCallTapObject({ - store: configureJestStore(), - // @ts-expect-error - payload: { - call_id: 'call_id', - node_id: 'node_id', - control_id: 'control_id', - }, - }) + let voice: Voice + let call: Call + let callTap: CallTap + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + + callTap = new CallTap({ + call, // @ts-expect-error - instance.execute = jest.fn() + payload: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, }) - it('should control an active playback', async () => { - const baseExecuteParams = { - method: '', - params: { - call_id: 'call_id', - node_id: 'node_id', - control_id: 'control_id', - }, - } - - await instance.stop() + // @ts-expect-error + callTap._client.execute = jest.fn() + }) + + it('should have an event emitter', () => { + expect(callTap['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'tap.started', + onEnded: 'tap.ended', + } + expect(callTap['_eventMap']).toEqual(expectedEventMap) + }) + + it('should attach all listeners', () => { + callTap = new CallTap({ + call, // @ts-expect-error - expect(instance.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.tap.stop', - }) + payload: {}, + listeners: { + onStarted: () => {}, + onEnded: () => {}, + }, }) - it('should update the attributes on setPayload call', () => { - const newCallId = 'new_call_id' - const newNodeId = 'new_node_id' - const newControlId = 'new_control_id' + // @ts-expect-error + expect(callTap.emitter.eventNames()).toStrictEqual([ + 'tap.started', + 'tap.ended', + ]) + }) - // @ts-expect-error - instance.setPayload({ - call_id: newCallId, - node_id: newNodeId, - control_id: newControlId, - }) + it('should control an active playback', async () => { + const baseExecuteParams = { + method: '', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + } - expect(instance.callId).toBe(newCallId) - // @ts-expect-error - expect(instance.nodeId).toBe(newNodeId) - expect(instance.controlId).toBe(newControlId) + await callTap.stop() + // @ts-expect-error + expect(callTap._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.tap.stop', }) }) + + it('should update the attributes on setPayload call', () => { + const newCallId = 'new_call_id' + const newNodeId = 'new_node_id' + const newControlId = 'new_control_id' + + // @ts-expect-error + callTap.setPayload({ + call_id: newCallId, + node_id: newNodeId, + control_id: newControlId, + }) + + expect(callTap.callId).toBe(newCallId) + expect(callTap.nodeId).toBe(newNodeId) + expect(callTap.controlId).toBe(newControlId) + }) }) diff --git a/packages/realtime-api/src/voice/CallTap.ts b/packages/realtime-api/src/voice/CallTap.ts index 3f73d8187..96b09633e 100644 --- a/packages/realtime-api/src/voice/CallTap.ts +++ b/packages/realtime-api/src/voice/CallTap.ts @@ -1,43 +1,42 @@ import { - connect, - BaseComponentOptionsWithPayload, VoiceCallTapContract, CallingCallTapEndState, CallingCallTapEventParams, - EventEmitter, - BaseConsumer, } 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 { - setPayload: (payload: CallingCallTapEventParams) => void - _paused: boolean - /** @internal */ - emit(event: EventEmitter.EventNames, ...args: any[]): void +import { ListenSubscriber } from '../ListenSubscriber' +import { + CallTapEvents, + CallTapListeners, + CallTapListenersEventsMapping, +} from '../types' +import { Call } from './Call' + +export interface CallTapOptions { + call: Call + payload: CallingCallTapEventParams + listeners?: CallTapListeners } -export type CallTapEventsHandlerMapping = {} - -export interface CallTapOptions - extends BaseComponentOptionsWithPayload {} - const ENDED_STATES: CallingCallTapEndState[] = ['finished'] -export class CallTapAPI - extends BaseConsumer +export class CallTap + extends ListenSubscriber implements VoiceCallTapContract { private _payload: CallingCallTapEventParams + protected _eventMap: CallTapListenersEventsMapping = { + onStarted: 'tap.started', + onEnded: 'tap.ended', + } constructor(options: CallTapOptions) { - super(options) + super({ swClient: options.call._sw }) this._payload = options.payload + + if (options.listeners) { + this.listen(options.listeners) + } } get id() { @@ -61,13 +60,13 @@ export class CallTapAPI } /** @internal */ - protected setPayload(payload: CallingCallTapEventParams) { + setPayload(payload: CallingCallTapEventParams) { this._payload = payload } async stop() { if (this.state !== 'finished') { - await this.execute({ + await this._client.execute({ method: 'calling.tap.stop', params: { node_id: this.nodeId, @@ -81,36 +80,24 @@ export class CallTapAPI } ended() { - // Resolve the promise if the tap has already ended - if (ENDED_STATES.includes(this.state as CallingCallTapEndState)) { - return Promise.resolve(this) - } - return new Promise((resolve) => { const handler = () => { - // @ts-expect-error this.off('tap.ended', handler) // It's important to notice that we're returning // `this` instead of creating a brand new instance - // using the payload + EventEmitter Transform - // pipeline. `this` is the instance created by the - // `Call` Emitter Transform pipeline (singleton per - // `Call.tap()`) that gets auto updated (using + // using the payload. `this` is the instance created by the + // `voiceCallTapWorker` (singleton per + // `call.play()`) that gets auto updated (using // the latest payload per event) by the // `voiceCallTapWorker` resolve(this) } - // @ts-expect-error this.once('tap.ended', handler) + + // Resolve the promise if the tap has already ended + if (ENDED_STATES.includes(this.state as CallingCallTapEndState)) { + handler() + } }) } } - -export const createCallTapObject = (params: CallTapOptions): CallTap => { - const tap = connect({ - store: params.store, - Component: CallTapAPI, - })(params) - - return tap -} diff --git a/packages/realtime-api/src/voice/Voice.test.ts b/packages/realtime-api/src/voice/Voice.test.ts new file mode 100644 index 000000000..dfee20523 --- /dev/null +++ b/packages/realtime-api/src/voice/Voice.test.ts @@ -0,0 +1,63 @@ +import { EventEmitter } from '@signalwire/core' +import { Voice } from './Voice' +import { createClient } from '../client/createClient' + +describe('Voice', () => { + let voice: Voice + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + // @ts-expect-error + voice._client.execute = jest.fn() + // @ts-expect-error + voice._client.runWorker = jest.fn() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should have an event emitter', () => { + expect(voice['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onCallReceived: 'call.received', + } + expect(voice['_eventMap']).toEqual(expectedEventMap) + }) + + it('should dial a phone number', async () => { + // @ts-expect-error + voice._client.execute.mockResolvedValueOnce() + // @ts-expect-error + voice._client.runWorker.mockResolvedValueOnce() + + voice.dialPhone({ + to: '+1234567890', + from: '+1234567890', + }) + + // @ts-expect-error + expect(voice.emitter.eventNames()).toStrictEqual([ + 'dial.answered', + 'dial.failed', + ]) + + // @ts-expect-error + expect(voice._client.runWorker).toHaveBeenCalled() + // @ts-expect-error + expect(voice._client.execute).toHaveBeenCalled() + }) +}) diff --git a/packages/realtime-api/src/voice/Voice.ts b/packages/realtime-api/src/voice/Voice.ts index 0915dbe27..b58a8ddce 100644 --- a/packages/realtime-api/src/voice/Voice.ts +++ b/packages/realtime-api/src/voice/Voice.ts @@ -1,221 +1,57 @@ -import { - connect, - BaseComponentOptions, - toExternalJSON, - ClientContextContract, - uuid, - BaseConsumer, -} from '@signalwire/core' +import { toExternalJSON, uuid } from '@signalwire/core' import type { - DisconnectableClientContract, - VoiceDeviceBuilder, - VoiceCallDialPhoneMethodParams, - VoiceCallDialSipMethodParams, ToExternalJSONResult, CallingCallDialFailedEventParams, - VoiceDialerParams, + CallReceived, } from '@signalwire/core' -import { RealtimeClient } from '../client/index' import { Call } from './Call' -import { voiceCallingWroker } from './workers' +import { voiceCallReceiveWorker, voiceCallDialWorker } from './workers' import { DeviceBuilder } from './DeviceBuilder' -import type { RealTimeCallApiEvents } from '../types' +import type { + VoiceDialMethodParams, + VoiceDialPhonelMethodParams, + VoiceDialSipMethodParams, + VoiceEvents, +} from '../types' import { toInternalDevices } from './utils' +import { BaseNamespace, ListenOptions } from '../BaseNamespace' +import { SWClient } from '../SWClient' -export * from './VoiceClient' -export { Call } from './Call' -export type { RealTimeCallApiEvents } -export { DeviceBuilder } -export { Playlist } from './Playlist' -export type { CallPlayback } from './CallPlayback' -export type { CallPrompt } from './CallPrompt' -export type { CallRecording } from './CallRecording' -export type { CallTap } from './CallTap' -export type { - CallingCallDirection, - CallingCallState, - CallingCallWaitForState, - ClientEvents, - CollectDigitsConfig, - CollectSpeechConfig, - CreateVoicePlaylistParams, - NestedArray, - RingtoneName, - SipCodec, - SipHeader, - SpeechOrDigits, - TapDevice, - TapDeviceRTP, - TapDeviceWS, - TapDirection, - VoiceCallConnectMethodParams, - VoiceCallConnectPhoneMethodParams, - VoiceCallConnectSipMethodParams, - VoiceCallContract, - VoiceCallDetectContract, - VoiceCallDetectDigitParams, - VoiceCallDetectFaxParams, - VoiceCallDetectMachineParams, - VoiceCallDetectMethodParams, - VoiceCallCollectContract, - VoiceCallCollectMethodParams, - VoiceCallDeviceParams, - VoiceCallDialPhoneMethodParams, - VoiceCallDialRegionParams, - VoiceCallDialSipMethodParams, - VoiceCallDisconnectReason, - VoiceCallPhoneParams, - VoiceCallPlayAudioMethodParams, - VoiceCallPlayAudioParams, - VoiceCallPlaybackContract, - VoiceCallPlayParams, - VoiceCallPlayRingtoneMethodParams, - VoiceCallPlayRingtoneParams, - VoiceCallPlaySilenceMethodParams, - VoiceCallPlaySilenceParams, - VoiceCallPlayTTSMethodParams, - VoiceCallPlayTTSParams, - VoiceCallPromptAudioMethodParams, - VoiceCallPromptContract, - VoiceCallPromptMethodParams, - VoiceCallPromptRingtoneMethodParams, - VoiceCallPromptTTSMethodParams, - VoiceCallRecordingContract, - VoiceCallRecordMethodParams, - VoiceCallSipParams, - VoiceCallTapAudioMethodParams, - VoiceCallTapContract, - VoiceCallTapMethodParams, - VoiceDeviceBuilder, - VoiceDialerParams, - VoicePlaylist, -} from '@signalwire/core' - -/** - * List of events for {@link Voice.Call}. - */ -export interface VoiceClientApiEvents extends RealTimeCallApiEvents {} - -export interface Voice - extends DisconnectableClientContract, - ClientContextContract { - /** @internal */ - _session: RealtimeClient - - /** - * Disconnects this client. The client will stop receiving events and you will - * need to create a new instance if you want to use it again. - * - * @example - * - * ```js - * client.disconnect() - * ``` - */ - disconnect(): void - - /** - * Makes an outbound Call and waits until it has been answered or hung up. - * This is an advanced method that lets you call multiple devices in parallel - * or series: for simpler use cases, see {@link dialPhone} and - * {@link dialSip}. - * - * With this method you can specify a configuration of devices to call in - * series and/or in parallel: as soon as one device answers the call, the - * returned promise is resolved. You specify a configuration through a - * {@link VoiceDeviceBuilder} object. - * - * @param dialer - {@link VoiceDeviceBuilder} - * - * @example Calls a phone number. If the number doesn't answer within 30 - * seconds, calls two different SIP endpoints in parallel. - * - * ```js - * const devices = new Voice.DeviceBuilder() - * .add(Voice.DeviceBuilder.Phone({ from: '+XXXXXX', to: '+YYYYYY', timeout: 30 })) - * .add([ - * Voice.DeviceBuilder.Sip({ from: 'sip:aaa@bbb.cc', to: 'sip:xxx@yyy.zz' }), - * Voice.DeviceBuilder.Sip({ from: 'sip:aaa@bbb.cc', to: 'sip:ppp@qqq.rr' }) - * ]) - * - * try { - * const call = await client.dial(devices) - * console.log("Call answered") - * } catch (e) { - * console.log("Call not answered") - * } - * ``` - * - * @param dialer The Dialer specifying the devices to call. - * - * @returns A call object. - */ - dial(dialer: VoiceDeviceBuilder): Promise - /** - * Makes an outbound call to a PSTN number. - * - * @param params - {@link VoiceCallDialPhoneMethodParams} - * - * @example - * - * ```js - * try { - * const call = await client.dialPhone({ - * from: '+YYYYYYYYYY', - * to: '+XXXXXXXXXX', - * timeout: 30, - * }) - * } catch (e) { - * console.log("Call not answered.") - * } - * ``` - * - * @returns A call object. - */ - dialPhone(params: VoiceCallDialPhoneMethodParams): Promise - /** - * Makes an outbound call to a SIP endpoint. - * - * @param params - {@link VoiceCallDialSipMethodParams} - * - * @example - * - * ```js - * try { - * const call = await client.dialPhone({ - * from: 'sip:xxx@yyy.zz', - * to: 'sip:ppp@qqq.rr', - * timeout: 30, - * }) - * } catch (e) { - * console.log("Call not answered.") - * } - * ``` - * - * @returns A call object. - */ - dialSip(params: VoiceCallDialSipMethodParams): Promise +interface VoiceListenOptions extends ListenOptions { + onCallReceived?: (call: Call) => unknown } -/** @internal */ -class VoiceAPI extends BaseConsumer { - private _tag: string +type VoiceListenersKeys = keyof Omit - constructor(options: BaseComponentOptions) { - super(options) +export class Voice extends BaseNamespace { + protected _eventMap: Record = { + onCallReceived: 'call.received', + } - this._tag = uuid() + constructor(options: SWClient) { + super(options) - this.runWorker('voiceCallingWorker', { - worker: voiceCallingWroker, + this._client.runWorker('voiceCallReceiveWorker', { + worker: voiceCallReceiveWorker, initialState: { - tag: this._tag, + voice: this, }, }) } - dial(params: VoiceDialerParams) { - return new Promise((resolve, reject) => { + dial(params: VoiceDialMethodParams) { + return new Promise((resolve, reject) => { + const _tag = uuid() + + this._client.runWorker('voiceCallDialWorker', { + worker: voiceCallDialWorker, + initialState: { + voice: this, + tag: _tag, + listeners: params.listen, + }, + }) + const resolveHandler = (call: Call) => { // @ts-expect-error this.off('dial.failed', rejectHandler) @@ -239,13 +75,13 @@ class VoiceAPI extends BaseConsumer { if (params instanceof DeviceBuilder) { const { devices } = params executeParams = { - tag: this._tag, + tag: _tag, devices: toInternalDevices(devices), } } else if ('region' in params) { const { region, nodeId, devices: deviceBuilder } = params executeParams = { - tag: this._tag, + tag: _tag, region, node_id: nodeId, devices: toInternalDevices(deviceBuilder.devices), @@ -254,12 +90,14 @@ class VoiceAPI extends BaseConsumer { throw new Error('[dial] Invalid input') } - this.execute({ - method: 'calling.dial', - params: executeParams, - }).catch((e) => { - reject(e) - }) + this._client + .execute({ + method: 'calling.dial', + params: executeParams, + }) + .catch((e) => { + reject(e) + }) }) } @@ -267,15 +105,16 @@ class VoiceAPI extends BaseConsumer { region, maxPricePerMinute, nodeId, + listen, ...params - }: VoiceCallDialPhoneMethodParams) { + }: VoiceDialPhonelMethodParams) { const devices = new DeviceBuilder().add(DeviceBuilder.Phone(params)) - // dial is available through the VoiceClient Proxy return this.dial({ maxPricePerMinute, region, nodeId, devices, + listen, }) } @@ -283,25 +122,103 @@ class VoiceAPI extends BaseConsumer { region, maxPricePerMinute, nodeId, + listen, ...params - }: VoiceCallDialSipMethodParams) { + }: VoiceDialSipMethodParams) { const devices = new DeviceBuilder().add(DeviceBuilder.Sip(params)) - // dial is available through the VoiceClient Proxy return this.dial({ maxPricePerMinute, region, nodeId, devices, + listen, }) } } -/** @internal */ -export const createVoiceObject = (params: BaseComponentOptions): Voice => { - const voice = connect({ - store: params.store, - Component: VoiceAPI, - })(params) - - return voice -} +export { Call } from './Call' +export { DeviceBuilder } +export { Playlist } from './Playlist' +export type { CallPlayback } from './CallPlayback' +export type { CallPrompt } from './CallPrompt' +export type { CallRecording } from './CallRecording' +export type { CallTap } from './CallTap' +export type { + CallingCallDirection, + CallingCallState, + CallingCallWaitForState, + ClientEvents, + CollectDigitsConfig, + CollectSpeechConfig, + CreateVoicePlaylistParams, + NestedArray, + RingtoneName, + SipCodec, + SipHeader, + SpeechOrDigits, + TapDevice, + TapDeviceRTP, + TapDeviceWS, + TapDirection, + VoiceCallConnectMethodParams, + VoiceCallConnectPhoneMethodParams, + VoiceCallConnectSipMethodParams, + VoiceCallContract, + VoiceCallDetectContract, + VoiceCallDetectDigitParams, + VoiceCallDetectFaxParams, + VoiceCallDetectMachineParams, + VoiceCallDetectMethodParams, + VoiceCallCollectContract, + VoiceCallCollectMethodParams, + VoiceCallDeviceParams, + VoiceCallDialPhoneMethodParams, + VoiceCallDialRegionParams, + VoiceCallDialSipMethodParams, + VoiceCallDisconnectReason, + VoiceCallPhoneParams, + VoiceCallPlayAudioMethodParams, + VoiceCallPlayAudioParams, + VoiceCallPlaybackContract, + VoiceCallPlayParams, + VoiceCallPlayRingtoneMethodParams, + VoiceCallPlayRingtoneParams, + VoiceCallPlaySilenceMethodParams, + VoiceCallPlaySilenceParams, + VoiceCallPlayTTSMethodParams, + VoiceCallPlayTTSParams, + VoiceCallPromptAudioMethodParams, + VoiceCallPromptContract, + VoiceCallPromptMethodParams, + VoiceCallPromptRingtoneMethodParams, + VoiceCallPromptTTSMethodParams, + VoiceCallRecordingContract, + VoiceCallRecordMethodParams, + VoiceCallSipParams, + VoiceCallTapAudioMethodParams, + VoiceCallTapContract, + VoiceCallTapMethodParams, + VoiceDeviceBuilder, + VoiceDialerParams, + VoicePlaylist, +} from '@signalwire/core' +export type { + CallPlayMethodParams, + CallPlayAudioMethodarams, + CallPlaySilenceMethodParams, + CallPlayRingtoneMethodParams, + CallPlayTTSMethodParams, + CallRecordMethodParams, + CallRecordAudioMethodParams, + CallPromptMethodParams, + CallPromptAudioMethodParams, + CallPromptRingtoneMethodParams, + CallPromptTTSMethodParams, + CallCollectMethodParams, + CallTapMethodParams, + CallTapAudioMethodParams, + CallDetectMethodParams, + CallDetectMachineParams, + CallDetectFaxParams, + CallDetectDigitParams, +} from '../types/voice' diff --git a/packages/realtime-api/src/voice/VoiceClient.ts b/packages/realtime-api/src/voice/VoiceClient.ts deleted file mode 100644 index 22ddc4ab2..000000000 --- a/packages/realtime-api/src/voice/VoiceClient.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { UserOptions } from '@signalwire/core' -import { setupClient, clientConnect } from '../client/index' -import { createVoiceObject, Voice } from './Voice' -import { clientContextInterceptorsFactory } from '../common/clientContext' - -interface VoiceClient extends Voice { - new (opts: VoiceClientOptions): this -} - -export type VoiceClientOptions = Omit & - ( - | { - contexts: string[] - } - | { - topics: string[] - } - ) - -/** - * You can use instances of this class to initiate or receive calls. Please see - * {@link VoiceClientApiEvents} for the full list of events you can subscribe to. - * - * @params options - {@link VoiceClientOptions} - * - * @example - * - * The following example answers any call in the "office" context. - * - * ```javascript - * const client = new Voice.Client({ - * project: "", - * token: "", - * contexts: ['office'] - * }) - * - * client.on('call.received', async (call) => { - * console.log('Got call', call.from, call.to) - * - * try { - * await call.answer() - * console.log('Inbound call answered') - * } catch (error) { - * console.error('Error answering inbound call', error) - * } - * }) - * ``` - * - * @example - * - * The following example initiates a new call. - * - * ```javascript - * const client = new Voice.Client({ - * project: "", - * token: "", - * contexts: ['office'] - * }) - * - * try { - * const call = await client.dialPhone({ - * from: '+YYYYYYYYYY', - * to: '+XXXXXXXXXX', - * timeout: 30, - * }) - * } catch (e) { - * console.log("Call not answered.") - * } - * ``` - */ -const VoiceClient = function (options?: VoiceClientOptions) { - const { client, store } = setupClient(options) - - const voice = createVoiceObject({ - store, - ...options, - }) - - const dial: Voice['dial'] = async (dialer) => { - await clientConnect(client) - - return voice.dial(dialer) - } - const disconnect = () => client.disconnect() - - const interceptors = { - ...clientContextInterceptorsFactory(client), - dial, - _session: client, - disconnect, - } as const - - return new Proxy>(voice, { - get(target: Voice, prop: keyof Voice, receiver: any) { - if (prop in interceptors) { - // @ts-expect-error - return interceptors[prop] - } - - // Always connect the underlying client if the user call a function on the Proxy - if (typeof target[prop] === 'function') { - clientConnect(client) - } - - 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/workers/VoiceCallSendDigitWorker.ts b/packages/realtime-api/src/voice/workers/VoiceCallSendDigitWorker.ts index ae403fb96..9c1f24912 100644 --- a/packages/realtime-api/src/voice/workers/VoiceCallSendDigitWorker.ts +++ b/packages/realtime-api/src/voice/workers/VoiceCallSendDigitWorker.ts @@ -1,38 +1,65 @@ import { getLogger, + sagaEffects, SagaIterator, - CallingCallSendDigitsEventParams, + SDKWorker, + VoiceCallSendDigitsAction, } from '@signalwire/core' import type { Call } from '../Call' -import type { VoiceCallWorkerParams } from './voiceCallingWorker' +import type { Client } from '../../client/index' +import { SDKActions } from 'packages/core/dist/core/src' -export const voiceCallSendDigitsWorker = function* ( - options: VoiceCallWorkerParams +interface VoiceCallSendDigitsWorkerInitialState { + controlId: string +} + +export const voiceCallSendDigitsWorker: SDKWorker = function* ( + options ): SagaIterator { getLogger().trace('voiceCallSendDigitsWorker started') const { - payload, + channels: { swEventChannel }, instanceMap: { get }, + initialState, } = options - const callInstance = get(payload.call_id) - if (!callInstance) { - throw new Error('Missing call instance for send digits') - } + const { controlId } = initialState as VoiceCallSendDigitsWorkerInitialState - switch (payload.state) { - case 'finished': - // @ts-expect-error - callInstance.emit('send_digits.finished', callInstance) - break - default: { - const error = new Error( - `[voiceCallSendDigitsWorker] unhandled state: '${payload.state}'` - ) - // @ts-expect-error - callInstance.emit('send_digits.failed', error) - break + function* worker(action: VoiceCallSendDigitsAction) { + const { payload } = action + + if (payload.control_id !== controlId) return + + const callInstance = get(payload.call_id) + if (!callInstance) { + throw new Error('Missing call instance for send digits') } + + switch (payload.state) { + case 'finished': + // @ts-expect-error + callInstance.emit('send_digits.finished', callInstance) + return true + default: { + const error = new Error( + `[voiceCallSendDigitsWorker] unhandled state: '${payload.state}'` + ) + // @ts-expect-error + callInstance.emit('send_digits.failed', error) + return false + } + } + } + + while (true) { + const action = yield sagaEffects.take( + swEventChannel, + (action: SDKActions) => action.type === 'calling.call.send_digits' + ) + + const shouldStop = yield sagaEffects.fork(worker, action) + + if (shouldStop.result()) break } getLogger().trace('voiceCallSendDigitsWorker ended') diff --git a/packages/realtime-api/src/voice/workers/handlers/callConnectEventsHandler.ts b/packages/realtime-api/src/voice/workers/handlers/callConnectEventsHandler.ts new file mode 100644 index 000000000..065dfc685 --- /dev/null +++ b/packages/realtime-api/src/voice/workers/handlers/callConnectEventsHandler.ts @@ -0,0 +1,78 @@ +import { CallingCallConnectEventParams, InstanceMap } from '@signalwire/core' +import { Call } from '../../Call' +import { Voice } from '../../Voice' + +interface CallConnectEventsHandlerOptions { + payload: CallingCallConnectEventParams + instanceMap: InstanceMap + voice: Voice +} + +export function handleCallConnectEvents( + options: CallConnectEventsHandlerOptions +) { + const { payload, instanceMap, voice } = options + const { get, set } = instanceMap + + const callInstance = get(payload.call_id) + if (!callInstance) { + throw new Error('Missing call instance for connect') + } + callInstance.setConnectPayload(payload) + set(payload.call_id, callInstance) + + // TODO: The below events seems to be not documented in @RealTimeCallApiEvents. For now, ingoring TS issues + + callInstance.emit('call.state', callInstance) + + switch (payload.connect_state) { + case 'connecting': { + // @ts-expect-error + callInstance.emit('connect.connecting', callInstance) + return false + } + case 'connected': { + let peerCallInstance = get(payload.peer.call_id) + if (!peerCallInstance) { + peerCallInstance = new Call({ + voice, + connectPayload: payload, + }) + } else { + peerCallInstance.setConnectPayload(payload) + } + set(payload.peer.call_id, peerCallInstance) + callInstance.peer = peerCallInstance + peerCallInstance.peer = callInstance + // @ts-expect-error + callInstance.emit('connect.connected', peerCallInstance) + return false + } + case 'disconnected': { + console.log('emit disconnected', callInstance.callId) + // @ts-expect-error + callInstance.emit('connect.disconnected') + callInstance.peer = undefined + + const peerCallInstance = get(payload.peer.call_id) + // Add a check because peer call can be removed from the instance map throgh voiceCallStateWorker + if (peerCallInstance) { + console.log('emit peer disconnected', peerCallInstance.callId) + // @ts-expect-error + peerCallInstance.emit('connect.disconnected') + peerCallInstance.peer = undefined + } + return true + } + case 'failed': { + callInstance.peer = undefined + // @ts-expect-error + callInstance.emit('connect.failed', payload) + return true + } + default: + // @ts-expect-error + getLogger().warn(`Unknown connect state: "${payload.connect_state}"`) + return false + } +} diff --git a/packages/realtime-api/src/voice/workers/handlers/callDialEventsHandler.ts b/packages/realtime-api/src/voice/workers/handlers/callDialEventsHandler.ts new file mode 100644 index 000000000..611a8f330 --- /dev/null +++ b/packages/realtime-api/src/voice/workers/handlers/callDialEventsHandler.ts @@ -0,0 +1,31 @@ +import { CallingCallDialEventParams, InstanceMap } from '@signalwire/core' +import { Call } from '../../Call' +import { Voice } from '../../Voice' + +interface CallDialEventsHandlerOptions { + payload: CallingCallDialEventParams + instanceMap: InstanceMap + voice: Voice +} + +export function handleCallDialEvents(options: CallDialEventsHandlerOptions) { + const { payload, instanceMap, voice } = options + const { get } = instanceMap + + switch (payload.dial_state) { + case 'failed': { + // @ts-expect-error + voice.emit('dial.failed', payload) + return true + } + case 'answered': { + const callInstance = get(payload.call.call_id) + callInstance.setPayload(payload.call) + // @ts-expect-error + voice.emit('dial.answered', callInstance) + return true + } + default: + return false + } +} diff --git a/packages/realtime-api/src/voice/workers/voiceCallStateWorker.ts b/packages/realtime-api/src/voice/workers/handlers/callStateEventsHandler.ts similarity index 53% rename from packages/realtime-api/src/voice/workers/voiceCallStateWorker.ts rename to packages/realtime-api/src/voice/workers/handlers/callStateEventsHandler.ts index 93fb52227..a52adc705 100644 --- a/packages/realtime-api/src/voice/workers/voiceCallStateWorker.ts +++ b/packages/realtime-api/src/voice/workers/handlers/callStateEventsHandler.ts @@ -1,26 +1,29 @@ -import { - getLogger, - SagaIterator, - CallingCallStateEventParams, -} from '@signalwire/core' -import { Call, createCallObject } from '../Call' -import type { VoiceCallWorkerParams } from './voiceCallingWorker' +import { CallingCallStateEventParams, InstanceMap } from '@signalwire/core' +import { RealTimeCallListeners } from '../../../types' +import { Call } from '../../Call' +import { Voice } from '../../Voice' -export const voiceCallStateWorker = function* ( - options: VoiceCallWorkerParams -): SagaIterator { - getLogger().trace('voiceCallStateWorker started') +interface CallStateEventsHandlerOptions { + payload: CallingCallStateEventParams + voice: Voice + instanceMap: InstanceMap + listeners?: RealTimeCallListeners +} + +export function handleCallStateEvents(options: CallStateEventsHandlerOptions) { const { - instance: client, payload, + voice, + listeners, instanceMap: { get, set, remove }, } = options let callInstance = get(payload.call_id) if (!callInstance) { - callInstance = createCallObject({ - store: client.store, + callInstance = new Call({ + voice, payload, + listeners, }) } else { callInstance.setPayload(payload) @@ -35,12 +38,11 @@ export const voiceCallStateWorker = function* ( // @ts-expect-error callInstance.emit('connect.disconnected', callInstance) remove(payload.call_id) - break + + return true } default: callInstance.emit('call.state', callInstance) - break + return false } - - getLogger().trace('voiceCallStateWorker ended') } diff --git a/packages/realtime-api/src/voice/workers/handlers/index.ts b/packages/realtime-api/src/voice/workers/handlers/index.ts new file mode 100644 index 000000000..49296d122 --- /dev/null +++ b/packages/realtime-api/src/voice/workers/handlers/index.ts @@ -0,0 +1,3 @@ +export * from './callStateEventsHandler' +export * from './callConnectEventsHandler' +export * from './callDialEventsHandler' diff --git a/packages/realtime-api/src/voice/workers/index.ts b/packages/realtime-api/src/voice/workers/index.ts index aeffe22b1..9c5ccacca 100644 --- a/packages/realtime-api/src/voice/workers/index.ts +++ b/packages/realtime-api/src/voice/workers/index.ts @@ -1,4 +1,3 @@ -export * from './voiceCallStateWorker' export * from './voiceCallReceiveWorker' export * from './voiceCallPlayWorker' export * from './voiceCallRecordWorker' @@ -9,4 +8,3 @@ export * from './voiceCallDialWorker' export * from './VoiceCallSendDigitWorker' export * from './voiceCallDetectWorker' export * from './voiceCallCollectWorker' -export * from './voiceCallingWorker' diff --git a/packages/realtime-api/src/voice/workers/voiceCallCollectWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallCollectWorker.ts index 39e5c8b27..585219057 100644 --- a/packages/realtime-api/src/voice/workers/voiceCallCollectWorker.ts +++ b/packages/realtime-api/src/voice/workers/voiceCallCollectWorker.ts @@ -1,87 +1,131 @@ import { getLogger, SagaIterator, - CallingCallCollectEventParams, + sagaEffects, + SDKActions, + VoiceCallCollectAction, + SDKWorker, } from '@signalwire/core' +import type { Client } from '../../client/index' +import { CallCollectListeners } from '../../types' import type { Call } from '../Call' -import { CallPrompt, CallPromptAPI } from '../CallPrompt' +import { CallPrompt } from '../CallPrompt' import { CallCollect } from '../CallCollect' -import type { VoiceCallWorkerParams } from './voiceCallingWorker' -export const voiceCallCollectWorker = function* ( - options: VoiceCallWorkerParams +interface VoiceCallCollectWorkerInitialState { + controlId: string + listeners?: CallCollectListeners +} + +export const voiceCallCollectWorker: SDKWorker = function* ( + options ): SagaIterator { getLogger().trace('voiceCallCollectWorker started') const { - payload, + channels: { swEventChannel }, instanceMap: { get, set, remove }, + initialState, } = options - const callInstance = get(payload.call_id) - if (!callInstance) { - throw new Error('Missing call instance for collect') - } + const { controlId } = initialState as VoiceCallCollectWorkerInitialState - const actionInstance = get(payload.control_id) - if (!actionInstance) { - throw new Error('Missing the instance') - } - actionInstance.setPayload(payload) - set(payload.control_id, actionInstance) + function* worker(action: VoiceCallCollectAction) { + const { payload } = action - let eventPrefix = 'collect' as 'collect' | 'prompt' - if (actionInstance instanceof CallPromptAPI) { - eventPrefix = 'prompt' - } + if (payload.control_id !== controlId) return - /** - * Only when partial_results: true - */ - if (payload.final === false) { - callInstance.emit(`${eventPrefix}.updated`, actionInstance) - } else { - if (payload.result) { - switch (payload.result.type) { - case 'start_of_input': { - // @ts-expect-error - callInstance.emit(`${eventPrefix}.startOfInput`, actionInstance) - break - } - case 'no_input': - case 'no_match': - case 'error': { - if (payload.state !== 'collecting') { - callInstance.emit(`${eventPrefix}.failed`, actionInstance) - - // To resolve the ended() promise in CallPrompt or CallCollect - actionInstance.emit( - `${eventPrefix}.failed` as never, - actionInstance - ) - - remove(payload.control_id) - } - break + const callInstance = get(payload.call_id) + if (!callInstance) { + throw new Error('Missing call instance for collect') + } + + const actionInstance = get(payload.control_id) + if (!actionInstance) { + throw new Error('Missing the instance') + } + actionInstance.setPayload(payload) + set(payload.control_id, actionInstance) + + let eventPrefix = 'collect' as 'collect' | 'prompt' + if (actionInstance instanceof CallPrompt) { + eventPrefix = 'prompt' + } + + // These two variables are here to solve the TypeScript problems + const promptInstance: CallPrompt = actionInstance as CallPrompt + const collectInstance: CallCollect = actionInstance as CallCollect + + /** + * Only when partial_results: true + */ + if (payload.final === false) { + if (eventPrefix === 'prompt') { + callInstance.emit('prompt.updated', promptInstance) + promptInstance.emit('prompt.updated', promptInstance) + } else { + callInstance.emit('collect.updated', collectInstance) + collectInstance.emit('collect.updated', collectInstance) + } + return false + } + + switch (payload.result.type) { + case 'start_of_input': { + if (eventPrefix === 'prompt') return false + callInstance.emit('collect.startOfInput', collectInstance) + collectInstance.emit('collect.startOfInput', collectInstance) + return false + } + case 'no_input': + case 'no_match': + case 'error': { + if (payload.state === 'collecting') return false + + if (eventPrefix === 'prompt') { + callInstance.emit('prompt.failed', promptInstance) + promptInstance.emit('prompt.failed', promptInstance) + } else { + callInstance.emit('collect.failed', collectInstance) + collectInstance.emit('collect.failed', collectInstance) } - case 'speech': - case 'digit': { - if (payload.state !== 'collecting') { - callInstance.emit(`${eventPrefix}.ended`, actionInstance) - // To resolve the ended() promise in CallPrompt or CallCollect - actionInstance.emit(`${eventPrefix}.ended` as never, actionInstance) - remove(payload.control_id) - } - break + remove(payload.control_id) + + return true + } + case 'speech': + case 'digit': { + if (payload.state === 'collecting') return false + + if (eventPrefix === 'prompt') { + callInstance.emit('prompt.ended', promptInstance) + promptInstance.emit('prompt.ended', promptInstance) + } else { + callInstance.emit('collect.ended', collectInstance) + collectInstance.emit('collect.ended', collectInstance) } - default: - getLogger().warn( - // @ts-expect-error - `Unknown prompt result type: "${payload.result.type}"` - ) - break + remove(payload.control_id) + + return false } + default: + getLogger().warn( + // @ts-expect-error + `Unknown prompt result type: "${payload.result.type}"` + ) + return false } } + while (true) { + const action = yield sagaEffects.take( + swEventChannel, + (action: SDKActions) => action.type === 'calling.call.collect' + ) + + const shouldStop = yield sagaEffects.fork(worker, action) + + if (shouldStop.result()) break + } + getLogger().trace('voiceCallCollectWorker ended') } diff --git a/packages/realtime-api/src/voice/workers/voiceCallConnectWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallConnectWorker.ts index ca2c8cc30..77ba27521 100644 --- a/packages/realtime-api/src/voice/workers/voiceCallConnectWorker.ts +++ b/packages/realtime-api/src/voice/workers/voiceCallConnectWorker.ts @@ -1,81 +1,69 @@ import { getLogger, + sagaEffects, SagaIterator, - CallingCallConnectEventParams, + SDKActions, + SDKWorker, } from '@signalwire/core' -import { Call, createCallObject } from '../Call' -import type { VoiceCallWorkerParams } from './voiceCallingWorker' +import type { Client } from '../../client/index' +import { Voice } from '../Voice' +import { handleCallConnectEvents, handleCallStateEvents } from './handlers' -export const voiceCallConnectWorker = function* ( - options: VoiceCallWorkerParams +interface VoiceCallConnectWorkerInitialState { + voice: Voice + tag: string +} + +export const voiceCallConnectWorker: SDKWorker = function* ( + options ): SagaIterator { getLogger().trace('voiceCallConnectWorker started') const { - instance: client, - payload, - instanceMap: { get, set }, + channels: { swEventChannel }, + instanceMap, + initialState, } = options - const callInstance = get(payload.call_id) - if (!callInstance) { - throw new Error('Missing call instance for connect') - } - callInstance.setConnectPayload(payload) - set(payload.call_id, callInstance) + const { voice, tag } = initialState as VoiceCallConnectWorkerInitialState - // TODO: The below events seems to be not documented in @RealTimeCallApiEvents. For now, ingoring TS issues + const isCallConnectEvent = (action: SDKActions) => + action.type === 'calling.call.connect' - switch (payload.connect_state) { - case 'connecting': { - // @ts-expect-error - callInstance.emit('connect.connecting', callInstance) - break - } - case 'connected': { - let peerCallInstance = get(payload.peer.call_id) - if (!peerCallInstance) { - // @ts-expect-error - peerCallInstance = createCallObject({ - store: client.store, - connectPayload: payload, - }) - } else { - peerCallInstance.setConnectPayload(payload) - } - set(payload.peer.call_id, peerCallInstance) - callInstance.peer = peerCallInstance - peerCallInstance.peer = callInstance - // @ts-expect-error - callInstance.emit('connect.connected', peerCallInstance) - break - } - case 'disconnected': { - const peerCallInstance = get(payload.peer.call_id) - // @ts-expect-error - callInstance.emit('connect.disconnected') - callInstance.peer = undefined + const isCallStateEvent = (action: SDKActions) => + action.type === 'calling.call.state' && + action.payload.direction === 'outbound' && + action.payload.tag === tag + + function* callConnectWatcher(): SagaIterator { + while (true) { + const action = yield sagaEffects.take(swEventChannel, isCallConnectEvent) - // Add a check because peer call can be removed from the instance map throgh voiceCallStateWorker - if (peerCallInstance) { - // @ts-expect-error - peerCallInstance.emit('connect.disconnected') - peerCallInstance.peer = undefined - } - break + const shouldStop = handleCallConnectEvents({ + payload: action.payload, + instanceMap, + voice, + }) + + if (shouldStop) break } - case 'failed': { - callInstance.peer = undefined - // @ts-expect-error - callInstance.emit('connect.failed') - break + } + + function* callStateWatcher(): SagaIterator { + while (true) { + const action = yield sagaEffects.take(swEventChannel, isCallStateEvent) + + const shouldStop = handleCallStateEvents({ + payload: action.payload, + voice, + instanceMap, + }) + + if (shouldStop) break } - default: - // @ts-expect-error - getLogger().warn(`Unknown connect state: "${payload.connect_state}"`) - break } - callInstance.emit('call.state', callInstance) + yield sagaEffects.fork(callConnectWatcher) + yield sagaEffects.fork(callStateWatcher) getLogger().trace('voiceCallConnectWorker ended') } diff --git a/packages/realtime-api/src/voice/workers/voiceCallDetectWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallDetectWorker.ts index a0b27401c..7f7ffb079 100644 --- a/packages/realtime-api/src/voice/workers/voiceCallDetectWorker.ts +++ b/packages/realtime-api/src/voice/workers/voiceCallDetectWorker.ts @@ -1,77 +1,104 @@ import { getLogger, + sagaEffects, SagaIterator, - CallingCallDetectEventParams, + SDKActions, + SDKWorker, + VoiceCallDetectAction, } from '@signalwire/core' +import type { Client } from '../../client/index' +import { CallDetectListeners } from '../../types' import type { Call } from '../Call' -import { CallDetect, createCallDetectObject } from '../CallDetect' -import type { VoiceCallWorkerParams } from './voiceCallingWorker' +import { CallDetect } from '../CallDetect' -export const voiceCallDetectWorker = function* ( - options: VoiceCallWorkerParams +interface VoiceCallDetectWorkerInitialState { + controlId: string + listeners?: CallDetectListeners +} + +export const voiceCallDetectWorker: SDKWorker = function* ( + options ): SagaIterator { getLogger().trace('voiceCallDetectWorker started') const { - payload, + channels: { swEventChannel }, instanceMap: { get, set, remove }, + initialState, } = options - const callInstance = get(payload.call_id) - if (!callInstance) { - throw new Error('Missing call instance for collect') - } + const { controlId, listeners } = + initialState as VoiceCallDetectWorkerInitialState - let detectInstance = get(payload.control_id) - if (!detectInstance) { - detectInstance = createCallDetectObject({ - store: callInstance.store, - payload, - }) - } else { - detectInstance.setPayload(payload) - } - set(payload.control_id, detectInstance) + function* worker(action: VoiceCallDetectAction) { + const { payload } = action - const { detect } = payload - if (!detect) return + if (payload.control_id !== controlId) return - const { type, params } = detect - const { event } = params + const callInstance = get(payload.call_id) + if (!callInstance) { + throw new Error('Missing call instance for collect') + } - switch (event) { - case 'finished': - case 'error': { - // @ts-expect-error - callInstance.emit('detect.ended', detectInstance) + let detectInstance = get(payload.control_id) + if (!detectInstance) { + detectInstance = new CallDetect({ + call: callInstance, + payload, + listeners, + }) + } else { + detectInstance.setPayload(payload) + } + set(payload.control_id, detectInstance) - // To resolve the ended() promise in CallDetect - detectInstance.emit('detect.ended', detectInstance) + const { detect } = payload + if (!detect) return - remove(payload.control_id) - return - } - default: - // @ts-expect-error - callInstance.emit('detect.updated', detectInstance) - break - } + const { type, params } = detect + const { event } = params - switch (type) { - case 'machine': - if (params.beep && detectInstance.waitForBeep) { - // @ts-expect-error + switch (event) { + case 'finished': + case 'error': { callInstance.emit('detect.ended', detectInstance) - - // To resolve the ended() promise in CallDetect detectInstance.emit('detect.ended', detectInstance) + + remove(payload.control_id) + return true } - break - case 'digit': - case 'fax': - break - default: - getLogger().warn(`Unknown detect type: "${type}"`) - break + default: + callInstance.emit('detect.updated', detectInstance) + detectInstance.emit('detect.updated', detectInstance) + break + } + + switch (type) { + case 'machine': + if (params.beep && detectInstance.waitForBeep) { + callInstance.emit('detect.ended', detectInstance) + detectInstance.emit('detect.ended', detectInstance) + } + break + case 'digit': + case 'fax': + break + default: + getLogger().warn(`Unknown detect type: "${type}"`) + break + } + + return false + } + + while (true) { + const action = yield sagaEffects.take( + swEventChannel, + (action: SDKActions) => action.type === 'calling.call.detect' + ) + + const shouldStop = yield sagaEffects.fork(worker, action) + + if (shouldStop.result()) break } getLogger().trace('voiceCallDetectWorker ended') diff --git a/packages/realtime-api/src/voice/workers/voiceCallDialWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallDialWorker.ts index 76e578fb1..50e790cc1 100644 --- a/packages/realtime-api/src/voice/workers/voiceCallDialWorker.ts +++ b/packages/realtime-api/src/voice/workers/voiceCallDialWorker.ts @@ -1,41 +1,77 @@ import { getLogger, SagaIterator, - CallingCallDialEventParams, + SDKWorker, + sagaEffects, + SDKActions, } from '@signalwire/core' -import type { Call } from '../Call' -import type { VoiceCallWorkerParams } from './voiceCallingWorker' +import type { Client } from '../../client/index' +import { RealTimeCallListeners } from '../../types' +import { Voice } from '../Voice' +import { handleCallDialEvents, handleCallStateEvents } from './handlers' -export const voiceCallDialWorker = function* ( - options: VoiceCallWorkerParams +interface VoiceCallDialWorkerInitialState { + tag: string + voice: Voice + listeners?: RealTimeCallListeners +} + +export const voiceCallDialWorker: SDKWorker = function* ( + options ): SagaIterator { getLogger().trace('voiceCallDialWorker started') const { - instance: client, - payload, - instanceMap: { get }, + instanceMap, + channels: { swEventChannel }, initialState, } = options - // Inbound calls do not have the tag - if (payload.tag && payload.tag !== initialState.tag) return + const { tag, voice, listeners } = + initialState as VoiceCallDialWorkerInitialState + + const isCallDialEvent = (action: SDKActions) => { + return action.type === 'calling.call.dial' && action.payload.tag === tag + } + + const isCallStateEvent = (action: SDKActions) => { + return ( + action.type === 'calling.call.state' && + action.payload.direction === 'outbound' && + action.payload.tag === tag + ) + } + + function* callDialWatcher(): SagaIterator { + while (true) { + const action = yield sagaEffects.take(swEventChannel, isCallDialEvent) - switch (payload.dial_state) { - case 'failed': { - // @ts-expect-error - client.emit('dial.failed', payload) - break + const shouldStop = handleCallDialEvents({ + payload: action.payload, + instanceMap, + voice, + }) + + if (shouldStop) break } - case 'answered': { - const callInstance = get(payload.call.call_id) - callInstance.setPayload(payload.call) - // @ts-expect-error - client.emit('dial.answered', callInstance) - break + } + + function* callStateWatcher(): SagaIterator { + while (true) { + const action = yield sagaEffects.take(swEventChannel, isCallStateEvent) + + const shouldStop = handleCallStateEvents({ + payload: action.payload, + voice, + instanceMap, + listeners, + }) + + if (shouldStop) break } - default: - break } + yield sagaEffects.fork(callDialWatcher) + yield sagaEffects.fork(callStateWatcher) + getLogger().trace('voiceCallDialWorker ended') } diff --git a/packages/realtime-api/src/voice/workers/voiceCallPlayWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallPlayWorker.ts index f0650cbd8..144f26be9 100644 --- a/packages/realtime-api/src/voice/workers/voiceCallPlayWorker.ts +++ b/packages/realtime-api/src/voice/workers/voiceCallPlayWorker.ts @@ -1,79 +1,115 @@ import { getLogger, SagaIterator, - CallingCallPlayEventParams, + SDKWorker, + sagaEffects, + VoiceCallPlayAction, } from '@signalwire/core' -import { CallPlayback, createCallPlaybackObject } from '../CallPlayback' -import { Call } from '../Voice' -import type { VoiceCallWorkerParams } from './voiceCallingWorker' +import type { Client } from '../../client/index' +import { CallPlaybackListeners } from '../../types' +import { CallPlayback } from '../CallPlayback' +import { Call } from '../Call' +import { SDKActions } from 'packages/core/dist/core/src' -export const voiceCallPlayWorker = function* ( - options: VoiceCallWorkerParams +interface VoiceCallPlayWorkerInitialState { + controlId: string + listeners?: CallPlaybackListeners +} + +export const voiceCallPlayWorker: SDKWorker = function* ( + options ): SagaIterator { getLogger().trace('voiceCallPlayWorker started') const { - payload, + channels: { swEventChannel }, instanceMap: { get, set, remove }, + initialState, } = options - const callInstance = get(payload.call_id) - if (!callInstance) { - throw new Error('Missing call instance for playback') - } + const { controlId, listeners } = + initialState as VoiceCallPlayWorkerInitialState - // Playback events control id for prompt contains `.prompt` keyword at the end of the string - const [controlId] = payload.control_id.split('.') - getLogger().trace('voiceCallPlayWorker controlId', controlId) + /** + * Playback listeners can be attached to both Call and CallPlayback objects + * So, we emit the events for both objects + * Some events are also being used to resolve the promise such as playback.started and playback.failed + * This worker is also responsible to handle CallPrompt events + */ - let playbackInstance = get(controlId) - if (!playbackInstance) { - getLogger().trace('voiceCallPlayWorker create instance') - playbackInstance = createCallPlaybackObject({ - store: callInstance.store, - payload, - }) - } else { - getLogger().trace('voiceCallPlayWorker GOT instance') - playbackInstance.setPayload(payload) - } - set(controlId, playbackInstance) + function* worker(action: VoiceCallPlayAction) { + const { payload } = action + + if (payload.control_id !== controlId) return - switch (payload.state) { - case 'playing': { - const type = playbackInstance._paused - ? 'playback.updated' - : 'playback.started' - playbackInstance._paused = false + // CallPrompt events contains .prompt at the end of the control id + const [playbackControlId] = payload.control_id.split('.') - callInstance.emit(type, playbackInstance) - break + const removeFromInstanceMap = () => { + // Do not remove the CallPrompt instance. It will be removed by the @voiceCallCollectWorker + if (payload.control_id.includes('.prompt')) return + remove(playbackControlId) } - case 'paused': { - playbackInstance._paused = true - callInstance.emit('playback.updated', playbackInstance) - break + + const callInstance = get(payload.call_id) + if (!callInstance) { + throw new Error('Missing call instance for playback') } - case 'error': { - callInstance.emit('playback.failed', playbackInstance) - // To resolve the ended() promise in CallPlayback - playbackInstance.emit('playback.failed', playbackInstance) + let playbackInstance = get(playbackControlId) + if (!playbackInstance) { + playbackInstance = new CallPlayback({ + call: callInstance, + payload, + listeners, + }) + } else { + playbackInstance.setPayload(payload) + } + set(playbackControlId, playbackInstance) - remove(controlId) - break + switch (payload.state) { + case 'playing': { + const type = playbackInstance._paused + ? 'playback.updated' + : 'playback.started' + playbackInstance._paused = false + callInstance.emit(type, playbackInstance) + playbackInstance.emit(type, playbackInstance) + return false + } + case 'paused': { + playbackInstance._paused = true + callInstance.emit('playback.updated', playbackInstance) + playbackInstance.emit('playback.updated', playbackInstance) + return false + } + case 'error': { + callInstance.emit('playback.failed', playbackInstance) + playbackInstance.emit('playback.failed', playbackInstance) + removeFromInstanceMap() + return true + } + case 'finished': { + callInstance.emit('playback.ended', playbackInstance) + playbackInstance.emit('playback.ended', playbackInstance) + removeFromInstanceMap() + return true + } + default: + getLogger().warn(`Unknown playback state: "${payload.state}"`) + return false } - case 'finished': { - callInstance.emit('playback.ended', playbackInstance) + } - // To resolve the ended() promise in CallPlayback - playbackInstance.emit('playback.ended', playbackInstance) + while (true) { + const action = yield sagaEffects.take( + swEventChannel, + (action: SDKActions) => action.type === 'calling.call.play' + ) - remove(controlId) - break - } - default: - getLogger().warn(`Unknown playback state: "${payload.state}"`) - break + const shouldStop = yield sagaEffects.fork(worker, action) + + if (shouldStop.result()) break } getLogger().trace('voiceCallPlayWorker ended') diff --git a/packages/realtime-api/src/voice/workers/voiceCallReceiveWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallReceiveWorker.ts index 507ca6e0a..9c597e4f5 100644 --- a/packages/realtime-api/src/voice/workers/voiceCallReceiveWorker.ts +++ b/packages/realtime-api/src/voice/workers/voiceCallReceiveWorker.ts @@ -1,37 +1,85 @@ -import { CallingCall, getLogger, SagaIterator } from '@signalwire/core' -import { createCallObject } from '../Call' -import type { Call } from '../Call' -import type { VoiceCallWorkerParams } from './voiceCallingWorker' +import { + getLogger, + sagaEffects, + SagaIterator, + SDKActions, + SDKWorker, + VoiceCallReceiveAction, + VoiceCallStateAction, +} from '@signalwire/core' +import { prefixEvent } from '../../utils/internals' +import type { Client } from '../../client/index' +import { Call } from '../Call' +import { Voice } from '../Voice' +import { handleCallStateEvents } from './handlers' -export const voiceCallReceiveWorker = function* ( - options: VoiceCallWorkerParams +interface VoiceCallReceiveWorkerInitialState { + voice: Voice +} + +export const voiceCallReceiveWorker: SDKWorker = function* ( + options ): SagaIterator { getLogger().trace('voiceCallReceiveWorker started') const { - instance: client, - payload, - instanceMap: { get, set }, + channels: { swEventChannel }, + instanceMap, + initialState, } = options - // Contexts is required - const { contexts = [], topics = [] } = client?.options ?? {} - if (!contexts.length && !topics.length) { - throw new Error('Invalid contexts to receive inbound calls') + const { voice } = initialState as VoiceCallReceiveWorkerInitialState + + function* callReceiveWorker(action: VoiceCallReceiveAction) { + const { get, set } = instanceMap + const { payload } = action + + // Contexts is required + if (!payload.context || !payload.context.length) { + throw new Error('Invalid context to receive inbound call') + } + + let callInstance = get(payload.call_id) + if (!callInstance) { + callInstance = new Call({ + voice, + payload, + }) + } else { + callInstance.setPayload(payload) + } + + set(payload.call_id, callInstance) + + // @ts-expect-error + voice.emit(prefixEvent(payload.context, 'call.received'), callInstance) } - let callInstance = get(payload.call_id) - if (!callInstance) { - callInstance = createCallObject({ - store: client.store, - payload: payload, - }) - } else { - callInstance.setPayload(payload) + function* worker(action: VoiceCallReceiveAction | VoiceCallStateAction) { + if (action.type === 'calling.call.receive') { + yield sagaEffects.fork(callReceiveWorker, action) + } else { + handleCallStateEvents({ + payload: action.payload, + voice, + instanceMap, + }) + } } - set(payload.call_id, callInstance) - // @ts-expect-error - client.emit('call.received', callInstance) + while (true) { + const action = yield sagaEffects.take( + swEventChannel, + (action: SDKActions) => { + return ( + action.type === 'calling.call.receive' || + (action.type === 'calling.call.state' && + action.payload.direction === 'inbound') + ) + } + ) + + yield sagaEffects.fork(worker, action) + } getLogger().trace('voiceCallReceiveWorker ended') } diff --git a/packages/realtime-api/src/voice/workers/voiceCallRecordWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallRecordWorker.ts index e337aa3e7..6062ccbb6 100644 --- a/packages/realtime-api/src/voice/workers/voiceCallRecordWorker.ts +++ b/packages/realtime-api/src/voice/workers/voiceCallRecordWorker.ts @@ -1,67 +1,98 @@ import { getLogger, SagaIterator, - CallingCallRecordEventParams, + SDKWorker, + sagaEffects, + SDKActions, + VoiceCallRecordAction, } from '@signalwire/core' -import { CallRecording, createCallRecordingObject } from '../CallRecording' -import { Call } from '../Voice' -import type { VoiceCallWorkerParams } from './voiceCallingWorker' +import type { Client } from '../../client/index' +import { CallRecordingListeners } from '../../types' +import { Call } from '../Call' +import { CallRecording } from '../CallRecording' -export const voiceCallRecordWorker = function* ( - options: VoiceCallWorkerParams +interface VoiceCallRecordWorkerInitialState { + controlId: string + listeners?: CallRecordingListeners +} + +export const voiceCallRecordWorker: SDKWorker = function* ( + options ): SagaIterator { getLogger().trace('voiceCallRecordWorker started') const { - payload, + channels: { swEventChannel }, instanceMap: { get, set, remove }, + initialState, } = options - const callInstance = get(payload.call_id) - if (!callInstance) { - throw new Error('Missing call instance for recording') - } + const { controlId, listeners } = + initialState as VoiceCallRecordWorkerInitialState - let recordingInstance = get(payload.control_id) - if (!recordingInstance) { - recordingInstance = createCallRecordingObject({ - store: callInstance.store, - payload, - }) - } else { - recordingInstance.setPayload(payload) - } - set(payload.control_id, recordingInstance) + function* worker(action: VoiceCallRecordAction) { + const { payload } = action - switch (payload.state) { - case 'recording': { - const type = recordingInstance._paused - ? 'recording.updated' - : 'recording.started' - recordingInstance._paused = false + if (payload.control_id !== controlId) return - callInstance.emit(type, recordingInstance) - break + const callInstance = get(payload.call_id) + if (!callInstance) { + throw new Error('Missing call instance for recording') } - case 'paused': { - recordingInstance._paused = true - callInstance.emit('recording.updated', recordingInstance) - break + + let recordingInstance = get(payload.control_id) + if (!recordingInstance) { + recordingInstance = new CallRecording({ + call: callInstance, + payload, + listeners, + }) + } else { + recordingInstance.setPayload(payload) } - case 'no_input': - case 'finished': { - const type = - payload.state === 'finished' ? 'recording.ended' : 'recording.failed' - callInstance.emit(type, recordingInstance) + set(payload.control_id, recordingInstance) - // To resolve the ended() promise in CallRecording - recordingInstance.emit(type, recordingInstance) + switch (payload.state) { + case 'recording': { + const type = recordingInstance._paused + ? 'recording.updated' + : 'recording.started' + recordingInstance._paused = false - remove(payload.control_id) - break + callInstance.emit(type, recordingInstance) + recordingInstance.emit(type, recordingInstance) + return false + } + case 'paused': { + recordingInstance._paused = true + callInstance.emit('recording.updated', recordingInstance) + recordingInstance.emit('recording.updated', recordingInstance) + return false + } + case 'no_input': + case 'finished': { + const type = + payload.state === 'finished' ? 'recording.ended' : 'recording.failed' + callInstance.emit(type, recordingInstance) + recordingInstance.emit(type, recordingInstance) + + remove(payload.control_id) + return true + } + default: + getLogger().warn(`Unknown recording state: "${payload.state}"`) + return false } - default: - getLogger().warn(`Unknown recording state: "${payload.state}"`) - break + } + + while (true) { + const action = yield sagaEffects.take( + swEventChannel, + (action: SDKActions) => action.type === 'calling.call.record' + ) + + const shouldStop = yield sagaEffects.fork(worker, action) + + if (shouldStop.result()) break } getLogger().trace('voiceCallRecordWorker ended') diff --git a/packages/realtime-api/src/voice/workers/voiceCallTapWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallTapWorker.ts index 9c1a498ef..4a04ba5bb 100644 --- a/packages/realtime-api/src/voice/workers/voiceCallTapWorker.ts +++ b/packages/realtime-api/src/voice/workers/voiceCallTapWorker.ts @@ -1,52 +1,81 @@ import { getLogger, SagaIterator, - CallingCallTapEventParams, + sagaEffects, + SDKActions, + SDKWorker, + VoiceCallTapAction, } from '@signalwire/core' +import type { Client } from '../../client/index' +import { CallTapListeners } from '../../types' import type { Call } from '../Call' -import { CallTap, createCallTapObject } from '../CallTap' -import type { VoiceCallWorkerParams } from './voiceCallingWorker' +import { CallTap } from '../CallTap' -export const voiceCallTapWorker = function* ( - options: VoiceCallWorkerParams +interface VoiceCallTapWorkerInitialState { + controlId: string + listeners?: CallTapListeners +} + +export const voiceCallTapWorker: SDKWorker = function* ( + options ): SagaIterator { getLogger().trace('voiceCallTapWorker started') const { - payload, + channels: { swEventChannel }, instanceMap: { get, set, remove }, + initialState, } = options - const callInstance = get(payload.call_id) as Call - if (!callInstance) { - throw new Error('Missing call instance for tap') - } + const { controlId, listeners } = + initialState as VoiceCallTapWorkerInitialState + + function* worker(action: VoiceCallTapAction) { + const { payload } = action + + if (payload.control_id !== controlId) return - let tapInstance = get(payload.control_id) as CallTap - if (!tapInstance) { - tapInstance = createCallTapObject({ - store: callInstance.store, - payload, - }) - } else { - tapInstance.setPayload(payload) + const callInstance = get(payload.call_id) as Call + if (!callInstance) { + throw new Error('Missing call instance for tap') + } + + let tapInstance = get(payload.control_id) + if (!tapInstance) { + tapInstance = new CallTap({ + call: callInstance, + payload, + listeners, + }) + } else { + tapInstance.setPayload(payload) + } + set(payload.control_id, tapInstance) + + switch (payload.state) { + case 'tapping': + callInstance.emit('tap.started', tapInstance) + tapInstance.emit('tap.ended', tapInstance) + return false + case 'finished': + callInstance.emit('tap.ended', tapInstance) + tapInstance.emit('tap.ended', tapInstance) + remove(payload.control_id) + return true + default: + getLogger().warn(`Unknown tap state: "${payload.state}"`) + return false + } } - set(payload.control_id, tapInstance) - - switch (payload.state) { - case 'tapping': - callInstance.emit('tap.started', tapInstance) - break - case 'finished': - callInstance.emit('tap.ended', tapInstance) - - // To resolve the ended() promise in CallTap - tapInstance.emit('tap.ended', tapInstance) - - remove(payload.control_id) - break - default: - getLogger().warn(`Unknown tap state: "${payload.state}"`) - break + + while (true) { + const action = yield sagaEffects.take( + swEventChannel, + (action: SDKActions) => action.type === 'calling.call.tap' + ) + + const shouldStop = yield sagaEffects.fork(worker, action) + + if (shouldStop.result()) break } getLogger().trace('voiceCallTapWorker ended') diff --git a/packages/realtime-api/src/voice/workers/voiceCallingWorker.ts b/packages/realtime-api/src/voice/workers/voiceCallingWorker.ts deleted file mode 100644 index 9afc62948..000000000 --- a/packages/realtime-api/src/voice/workers/voiceCallingWorker.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - SagaIterator, - SDKWorker, - getLogger, - sagaEffects, - SDKActions, - VoiceCallAction, - SDKWorkerParams, - sagaHelpers, -} from '@signalwire/core' -import { fork } from '@redux-saga/core/effects' -import type { Client } from '../../client/index' -import { voiceCallReceiveWorker } from './voiceCallReceiveWorker' -import { voiceCallPlayWorker } from './voiceCallPlayWorker' -import { voiceCallRecordWorker } from './voiceCallRecordWorker' -import { voiceCallDialWorker } from './voiceCallDialWorker' -import { voiceCallStateWorker } from './voiceCallStateWorker' -import { voiceCallCollectWorker } from './voiceCallCollectWorker' -import { voiceCallSendDigitsWorker } from './VoiceCallSendDigitWorker' -import { voiceCallDetectWorker } from './voiceCallDetectWorker' -import { voiceCallTapWorker } from './voiceCallTapWorker' -import { voiceCallConnectWorker } from './voiceCallConnectWorker' - -export type VoiceCallWorkerParams = Omit< - SDKWorkerParams, - 'runSaga' | 'getSession' | 'payload' -> & { payload: T } - -export const voiceCallingWroker: SDKWorker = function* ( - options -): SagaIterator { - getLogger().trace('voiceCallingWroker started') - const { - channels: { swEventChannel }, - } = options - - function* worker(action: VoiceCallAction) { - const { type, payload } = action - - switch (type) { - case 'calling.call.state': - yield fork(voiceCallStateWorker, { - ...options, - payload, - }) - break - case 'calling.call.dial': - yield fork(voiceCallDialWorker, { - ...options, - payload, - }) - break - case 'calling.call.receive': - yield fork(voiceCallReceiveWorker, { - ...options, - payload, - }) - break - case 'calling.call.play': - yield fork(voiceCallPlayWorker, { - ...options, - payload, - }) - break - case 'calling.call.record': - yield fork(voiceCallRecordWorker, { - ...options, - payload, - }) - break - case 'calling.call.collect': - yield fork(voiceCallCollectWorker, { - ...options, - payload, - }) - break - case 'calling.call.send_digits': - yield fork(voiceCallSendDigitsWorker, { - ...options, - payload, - }) - break - case 'calling.call.detect': - yield fork(voiceCallDetectWorker, { - ...options, - payload, - }) - break - case 'calling.call.tap': - yield fork(voiceCallTapWorker, { - ...options, - payload, - }) - break - case 'calling.call.connect': - yield fork(voiceCallConnectWorker, { - ...options, - payload, - }) - break - default: - getLogger().warn(`Unknown call event: "${type}"`) - break - } - } - - const workerCatchable = sagaHelpers.createCatchableSaga( - worker, - (error) => { - getLogger().error('Voice calling event error', error) - } - ) - - const isCallingEvent = (action: SDKActions) => - action.type.startsWith('calling.') - - while (true) { - const action: VoiceCallAction = yield sagaEffects.take( - swEventChannel, - isCallingEvent - ) - - yield sagaEffects.fork(workerCatchable, action) - } - - getLogger().trace('voiceCallingWroker ended') -} From 73d2205f66db60b2dbc7a7836b4308afe4ff698a Mon Sep 17 00:00:00 2001 From: Ammar Ansari Date: Thu, 14 Sep 2023 12:04:15 +0200 Subject: [PATCH 04/15] Messaging namespace with new interface (#812) * Task namespace with new interface * taskworker include * extend task from applyeventlisteners * base namespace class to handle the listen method * topic attach to event name * type update * remove older Task api * refactor and e2e test case * Voice API with new interface * handle call.playback listeners with all the methods * run workers through methods * playback events with e2e test cases * remove old call playback class * fix test file names * improve playback tests * rename voice playback tests * voice call record events with e2e test cases * fix playback and record types * implement call.prompt with playback * e2e test cases for call prompt * Call tap with e2e test cases * Call Detect API with e2e test cases * improve base and listener class for instances * call connect implement with e2e test case * improve call state events logic * update payload set and extends base calls with EventEmitter * protect event emitter methods * Messaging namespace with new interface * message worker to handle the events * handle events through messaging api * fix typescript types * e2e test case for messagin api * fix stack test * unit test for messaging api * include changeset * promisify client disconnect * fix unit test cases * fix disconnect emitter * fix unit test * rebased with the dev * fix base name space class * connect payload fallback * Update internal/playground-realtime-api/src/voice/index.ts Co-authored-by: Edoardo Gallo --------- Co-authored-by: Edoardo Gallo --- .changeset/three-mails-think.md | 42 ++++ internal/e2e-realtime-api/src/chat.test.ts | 7 +- .../e2e-realtime-api/src/messaging.test.ts | 42 ++-- internal/e2e-realtime-api/src/pubSub.test.ts | 4 +- internal/e2e-realtime-api/src/task.test.ts | 2 +- .../src/voiceCollectAllListeners.test.ts | 2 +- .../src/voiceCollectCallListeners.test.ts | 2 +- .../src/voiceCollectDialListeners.test.ts | 2 +- .../src/voiceCollectListeners.test.ts | 2 +- .../src/voiceDetectAllListeners.test.ts | 2 +- .../src/voiceDetectCallListeners.test.ts | 2 +- .../src/voiceDetectDialListeners.test.ts | 2 +- .../src/voiceDetectListeners.test.ts | 2 +- .../src/voicePlaybackAllListeners.test.ts | 2 +- .../src/voicePlaybackCallListeners.test.ts | 2 +- .../src/voicePlaybackDialListeners.test.ts | 2 +- .../src/voicePlaybackListeners.test.ts | 2 +- .../src/voicePlaybackMultiple.test.ts | 2 +- .../src/voicePromptAllListeners.test.ts | 2 +- .../src/voicePromptCallListeners.test.ts | 2 +- .../src/voicePromptDialListeners.test.ts | 2 +- .../src/voicePromptListeners.test.ts | 2 +- .../src/voiceRecordAllListeners.test.ts | 2 +- .../src/voiceRecordCallListeners.test.ts | 2 +- .../src/voiceRecordDialListeners.test.ts | 2 +- .../src/voiceRecordListeners.test.ts | 2 +- .../src/voiceTapAllListeners.test.ts | 2 +- .../playground-realtime-api/src/chat/index.ts | 2 +- .../src/messaging/index.ts | 43 ++-- .../src/pubSub/index.ts | 2 +- .../playground-realtime-api/src/task/index.ts | 2 +- .../src/voice/index.ts | 2 +- internal/stack-tests/src/messaging/app.ts | 16 +- packages/core/src/BaseComponent.ts | 2 +- packages/core/src/types/messaging.ts | 4 +- packages/realtime-api/src/BaseNamespace.ts | 49 +++-- packages/realtime-api/src/SWClient.test.ts | 1 + packages/realtime-api/src/SWClient.ts | 18 +- packages/realtime-api/src/SignalWire.ts | 4 - packages/realtime-api/src/chat/BaseChat.ts | 22 +- packages/realtime-api/src/chat/Chat.ts | 5 +- .../src/messaging/Messaging.test.ts | 78 ++++++++ .../realtime-api/src/messaging/Messaging.ts | 128 ++++-------- .../src/messaging/MessagingClient.test.ts | 188 ------------------ .../src/messaging/MessagingClient.ts | 89 --------- .../src/messaging/workers/messagingWorker.ts | 24 ++- packages/realtime-api/src/pubSub/PubSub.ts | 5 +- packages/realtime-api/src/task/Task.ts | 5 +- packages/realtime-api/src/types/chat.ts | 8 +- packages/realtime-api/src/types/pubSub.ts | 8 +- packages/realtime-api/src/voice/Call.ts | 2 +- packages/realtime-api/src/voice/Voice.ts | 5 +- 52 files changed, 359 insertions(+), 494 deletions(-) create mode 100644 .changeset/three-mails-think.md create mode 100644 packages/realtime-api/src/messaging/Messaging.test.ts delete mode 100644 packages/realtime-api/src/messaging/MessagingClient.test.ts delete mode 100644 packages/realtime-api/src/messaging/MessagingClient.ts diff --git a/.changeset/three-mails-think.md b/.changeset/three-mails-think.md new file mode 100644 index 000000000..034124130 --- /dev/null +++ b/.changeset/three-mails-think.md @@ -0,0 +1,42 @@ +--- +'@signalwire/realtime-api': major +'@signalwire/core': major +--- + +New interface for the Messaging API + +The new interface contains a single SW client with Messaging namespace +```javascript + const client = await SignalWire({ + host: process.env.HOST || 'relay.swire.io', + project: process.env.PROJECT as string, + token: process.env.TOKEN as string, + }) + + const unsubOfficeListener = await client.messaging.listen({ + topics: ['office'], + onMessageReceived: (payload) => { + console.log('Message received under "office" context', payload) + }, + onMessageUpdated: (payload) => { + console.log('Message updated under "office" context', payload) + }, + }) + + try { + const response = await client.messaging.send({ + from: process.env.FROM_NUMBER_MSG as string, + to: process.env.TO_NUMBER_MSG as string, + body: 'Hello World!', + context: 'office', + }) + + await client.messaging.send({ + from: process.env.FROM_NUMBER_MSG as string, + to: process.env.TO_NUMBER_MSG as string, + body: 'Hello John Doe!', + }) + } catch (error) { + console.log('>> send error', error) + } +``` diff --git a/internal/e2e-realtime-api/src/chat.test.ts b/internal/e2e-realtime-api/src/chat.test.ts index 15eb4040c..32f3ce362 100644 --- a/internal/e2e-realtime-api/src/chat.test.ts +++ b/internal/e2e-realtime-api/src/chat.test.ts @@ -5,10 +5,9 @@ * and the consume all the methods asserting both SDKs receive the proper events. */ import { timeoutPromise, SWCloseEvent } from '@signalwire/core' -import { Chat as RealtimeAPIChat } from '@signalwire/realtime-api' import { SignalWire as RealtimeSignalWire } from '@signalwire/realtime-api' import type { - Chat as RealtimeChat, + Chat as RTChat, SWClient as RealtimeSWClient, } from '@signalwire/realtime-api' import { Chat as JSChat } from '@signalwire/js' @@ -44,11 +43,11 @@ const params = { }, } -type ChatClient = RealtimeChat | JSChat.Client +type ChatClient = RTChat.Chat | JSChat.Client interface TestChatOptions { jsChat: JSChat.Client - rtChat: RealtimeChat + rtChat: RTChat.Chat publisher?: 'JS' | 'RT' } diff --git a/internal/e2e-realtime-api/src/messaging.test.ts b/internal/e2e-realtime-api/src/messaging.test.ts index b3a14d74d..7e4438f85 100644 --- a/internal/e2e-realtime-api/src/messaging.test.ts +++ b/internal/e2e-realtime-api/src/messaging.test.ts @@ -1,32 +1,46 @@ -import { Messaging } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' import { createTestRunner } from './utils' const handler = () => { return new Promise(async (resolve, reject) => { const context = process.env.MESSAGING_CONTEXT - const client = new Messaging.Client({ + const client = await SignalWire({ host: process.env.RELAY_HOST as string, project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, - contexts: [context], }) - client.on('message.received', (message) => { - console.log('message.received', message) - if (message.state === 'received' && message.body === 'Hello e2e!') { - return resolve(0) - } - console.error('Invalid message on `message.received`', message) - return reject(4) + const unsub = await client.messaging.listen({ + topics: [process.env.MESSAGING_CONTEXT!], + async onMessageReceived(message) { + console.log('message.received', message) + if (message.body === 'Hello e2e!') { + await unsub() + + await client.disconnect() + + return resolve(0) + } + console.error('Invalid message on `message.received`', message) + return reject(4) + }, + onMessageUpdated(message) { + // TODO: Test message.updated + console.log('message.updated', message) + }, }) - client.on('message.updated', (message) => { - // TODO: Test message.updated - console.log('message.updated', message) + // This should never run since the topics are wrong + await client.messaging.listen({ + topics: ['wrong'], + onMessageReceived(message) { + console.error('Invalid message on `wrong` topic', message) + return reject(4) + }, }) - const response = await client.send({ + const response = await client.messaging.send({ context, from: process.env.MESSAGING_FROM_NUMBER as string, to: process.env.MESSAGING_TO_NUMBER as string, diff --git a/internal/e2e-realtime-api/src/pubSub.test.ts b/internal/e2e-realtime-api/src/pubSub.test.ts index 956fb3776..e987a6f28 100644 --- a/internal/e2e-realtime-api/src/pubSub.test.ts +++ b/internal/e2e-realtime-api/src/pubSub.test.ts @@ -9,7 +9,7 @@ import { timeoutPromise, SWCloseEvent } from '@signalwire/core' import { SignalWire as RealtimeSignalWire } from '@signalwire/realtime-api' import type { - PubSub as RealtimePubSub, + PubSub as RTPubSub, SWClient as RealtimeSWClient, } from '@signalwire/realtime-api' import { PubSub as JSPubSub } from '@signalwire/js' @@ -47,7 +47,7 @@ const params = { interface TestPubSubOptions { jsPubSub: JSPubSub.Client - rtPubSub: RealtimePubSub + rtPubSub: RTPubSub.PubSub publisher?: 'JS' | 'RT' } diff --git a/internal/e2e-realtime-api/src/task.test.ts b/internal/e2e-realtime-api/src/task.test.ts index cb5149ccf..0fa4042a2 100644 --- a/internal/e2e-realtime-api/src/task.test.ts +++ b/internal/e2e-realtime-api/src/task.test.ts @@ -77,7 +77,7 @@ const handler = () => { await unsubOffice() - client.disconnect() + await client.disconnect() return resolve(0) }, diff --git a/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts index 90d618683..0abe4ca42 100644 --- a/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts @@ -168,7 +168,7 @@ const handler = async () => { await unsubCollect() - client.disconnect() + await client.disconnect() resolve(0) } catch (error) { diff --git a/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts index ff3f6d170..209cab241 100644 --- a/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts @@ -135,7 +135,7 @@ const handler = async () => { await unsubCall() - client.disconnect() + await client.disconnect() resolve(0) } catch (error) { diff --git a/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts index 6d39e973f..c11cc72de 100644 --- a/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts @@ -132,7 +132,7 @@ const handler = async () => { await unsubVoice() - client.disconnect() + await client.disconnect() resolve(0) } catch (error) { diff --git a/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts index d7a9e2069..4f213f1f5 100644 --- a/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts @@ -186,7 +186,7 @@ const handler = async () => { await unsubCollect() - client.disconnect() + await client.disconnect() resolve(0) } catch (error) { diff --git a/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts index 60e1a7d4e..96c4b77d7 100644 --- a/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts @@ -60,7 +60,7 @@ const handler = () => { await unsubDetect?.() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts index b6f6384f1..33fb077a7 100644 --- a/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts @@ -60,7 +60,7 @@ const handler = () => { await unsubCall?.() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts index 830ce75ae..887ced24f 100644 --- a/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts @@ -58,7 +58,7 @@ const handler = () => { if (call.state === 'ended') { await unsubVoice() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts index 40d2fbd21..29216f5a8 100644 --- a/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts @@ -60,7 +60,7 @@ const handler = () => { await unsubDetect?.() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts index 9fba11d61..3f9c676f0 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts @@ -52,7 +52,7 @@ const handler = async () => { await unsubPlay?.() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts index 9c1b03fee..354820fac 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts @@ -45,7 +45,7 @@ const handler = async () => { await unsubCall?.() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts index 2d59c5f7c..5287062c7 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts @@ -40,7 +40,7 @@ const handler = async () => { if (call.state === 'ended') { await unsubVoice() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts index 49f30aa39..b853af512 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts @@ -42,7 +42,7 @@ const handler = async () => { await unsubPlay?.() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts b/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts index d5cff9a48..ac9c8a1f9 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts @@ -196,7 +196,7 @@ const handler: TestHandler = ({ domainApp }) => { } }) - client.disconnect() + await client.disconnect() resolve(0) } catch (error) { diff --git a/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts index 08c2c448a..27bcac57c 100644 --- a/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts @@ -182,7 +182,7 @@ const handler = async () => { await unsubPrompt() - client.disconnect() + await client.disconnect() resolve(0) } catch (error) { diff --git a/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts index b438d693e..297e0632f 100644 --- a/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts @@ -111,7 +111,7 @@ const handler = async () => { await unsubCall() - client.disconnect() + await client.disconnect() resolve(0) } catch (error) { diff --git a/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts index 99d8e3f2f..50d4eeeb1 100644 --- a/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts @@ -113,7 +113,7 @@ const handler = async () => { await unsubVoice() - client.disconnect() + await client.disconnect() resolve(0) } catch (error) { diff --git a/internal/e2e-realtime-api/src/voicePromptListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptListeners.test.ts index f7543bc3e..f7e610cca 100644 --- a/internal/e2e-realtime-api/src/voicePromptListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePromptListeners.test.ts @@ -141,7 +141,7 @@ const handler = async () => { await unsubPrompt() - client.disconnect() + await client.disconnect() resolve(0) } catch (error) { diff --git a/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts index 7bd9ebaec..b09c7246e 100644 --- a/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts @@ -52,7 +52,7 @@ const handler = async () => { await unsubRecord?.() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts index 378da27b4..4641d61bf 100644 --- a/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts @@ -45,7 +45,7 @@ const handler = async () => { await unsubCall?.() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts index f12dcc6ac..44d79b557 100644 --- a/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts @@ -40,7 +40,7 @@ const handler = async () => { if (call.state === 'ended') { await unsubVoice() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts index 924e53b6d..eb0cba522 100644 --- a/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts @@ -42,7 +42,7 @@ const handler = async () => { await unsubRecord?.() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts index 61e102a29..7f8f2069c 100644 --- a/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts @@ -102,7 +102,7 @@ const handler = () => { await call.hangup() - client.disconnect() + await client.disconnect() resolve(0) } diff --git a/internal/playground-realtime-api/src/chat/index.ts b/internal/playground-realtime-api/src/chat/index.ts index 1fabd3a22..4968c4a86 100644 --- a/internal/playground-realtime-api/src/chat/index.ts +++ b/internal/playground-realtime-api/src/chat/index.ts @@ -60,7 +60,7 @@ async function run() { await unsubHome() console.log('Client disconnecting..') - client.disconnect() + await client.disconnect() } catch (error) { console.log('', error) } diff --git a/internal/playground-realtime-api/src/messaging/index.ts b/internal/playground-realtime-api/src/messaging/index.ts index 822a7c280..cd464fa00 100644 --- a/internal/playground-realtime-api/src/messaging/index.ts +++ b/internal/playground-realtime-api/src/messaging/index.ts @@ -1,32 +1,49 @@ -import { Messaging } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' async function run() { try { - const client = new Messaging.Client({ + const client = await SignalWire({ host: process.env.HOST || 'relay.swire.io', project: process.env.PROJECT as string, token: process.env.TOKEN as string, - contexts: ['office'], debug: { - logWsTraffic: true, + // logWsTraffic: true, }, }) - client.on('message.received', (message) => { - console.log('message.received', message) + const unsubHomeListener = await client.messaging.listen({ + topics: ['home'], + onMessageReceived: (payload) => { + console.log('Message received under "home" context', payload) + }, + onMessageUpdated: (payload) => { + console.log('Message updated under "home" context', payload) + }, }) - client.on('message.updated', (message) => { - console.log('message.updated', message) + const unsubOfficeListener = await client.messaging.listen({ + topics: ['office'], + onMessageReceived: (payload) => { + console.log('Message received under "office" context', payload) + }, + onMessageUpdated: (payload) => { + console.log('Message updated under "office" context', payload) + }, }) try { - const response = await client.send({ - from: '+1xxx', - to: '+1yyy', + const response = await client.messaging.send({ + from: process.env.FROM_NUMBER_MSG as string, + to: process.env.TO_NUMBER_MSG as string, body: 'Hello World!', }) console.log('>> send response', response) + + await client.messaging.send({ + from: process.env.FROM_NUMBER_MSG as string, + to: process.env.TO_NUMBER_MSG as string, + body: 'Hello John Doe!', + }) } catch (error) { console.log('>> send error', error) } @@ -34,8 +51,10 @@ async function run() { console.log('Client Running..') setTimeout(async () => { + await unsubHomeListener() + await unsubOfficeListener() console.log('Disconnect the client..') - client.disconnect() + await client.disconnect() }, 10_000) } catch (error) { console.log('', error) diff --git a/internal/playground-realtime-api/src/pubSub/index.ts b/internal/playground-realtime-api/src/pubSub/index.ts index 3a002bbc3..aa7dcc8cd 100644 --- a/internal/playground-realtime-api/src/pubSub/index.ts +++ b/internal/playground-realtime-api/src/pubSub/index.ts @@ -57,7 +57,7 @@ async function run() { await unsubWorkplace() console.log('Disconnect the client..') - client.disconnect() + await client.disconnect() } catch (error) { console.log('', error) } diff --git a/internal/playground-realtime-api/src/task/index.ts b/internal/playground-realtime-api/src/task/index.ts index 6189fc4c4..7c8455332 100644 --- a/internal/playground-realtime-api/src/task/index.ts +++ b/internal/playground-realtime-api/src/task/index.ts @@ -47,6 +47,6 @@ import { SignalWire } from '@signalwire/realtime-api' setTimeout(async () => { console.log('Disconnect the client..') - client.disconnect() + await client.disconnect() }, 2000) })() diff --git a/internal/playground-realtime-api/src/voice/index.ts b/internal/playground-realtime-api/src/voice/index.ts index 6c5887a42..1a238b67a 100644 --- a/internal/playground-realtime-api/src/voice/index.ts +++ b/internal/playground-realtime-api/src/voice/index.ts @@ -293,7 +293,7 @@ async function run() { await call.hangup() - client.disconnect() + await client.disconnect() } catch (error) { console.log('', error) } diff --git a/internal/stack-tests/src/messaging/app.ts b/internal/stack-tests/src/messaging/app.ts index 805cb6e9d..318c81a37 100644 --- a/internal/stack-tests/src/messaging/app.ts +++ b/internal/stack-tests/src/messaging/app.ts @@ -1,22 +1,18 @@ -import { Messaging } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' import tap from 'tap' async function run() { try { - const message = new Messaging.Client({ + const client = await SignalWire({ host: process.env.RELAY_HOST || 'relay.swire.io', project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, - contexts: [process.env.RELAY_CONTEXT as string], }) - tap.ok(message.on, 'message.on is defined') - tap.ok(message.once, 'message.once is defined') - tap.ok(message.off, 'message.off is defined') - tap.ok(message.removeAllListeners, 'message.removeAllListeners is defined') - tap.ok(message.addContexts, 'message.addContexts is defined') - tap.ok(message.send, 'message.send is defined') - tap.ok(message.disconnect, 'message.disconnect is defined') + tap.ok(client.messaging, 'client.messaging is defined') + tap.ok(client.messaging.listen, 'client.messaging.listen is defined') + tap.ok(client.messaging.send, 'message.send is defined') + tap.ok(client.disconnect, 'client.disconnect is defined') process.exit(0) } catch (error) { diff --git a/packages/core/src/BaseComponent.ts b/packages/core/src/BaseComponent.ts index 762984511..ddc0e7727 100644 --- a/packages/core/src/BaseComponent.ts +++ b/packages/core/src/BaseComponent.ts @@ -57,7 +57,7 @@ export class BaseComponent< */ private _runningWorkers: Task[] = [] - protected get logger() { + public get logger() { return getLogger() } diff --git a/packages/core/src/types/messaging.ts b/packages/core/src/types/messaging.ts index 89967c49b..f1064521b 100644 --- a/packages/core/src/types/messaging.ts +++ b/packages/core/src/types/messaging.ts @@ -28,7 +28,9 @@ export type MessageUpdatedEventName = 'message.updated' export type MessagingState = 'messaging.state' export type MessagingReceive = 'messaging.receive' -export type MessagingEventNames = MessagingState | MessagingReceive +export type MessagingEventNames = + | MessageReceivedEventName + | MessageUpdatedEventName export interface MessagingContract {} diff --git a/packages/realtime-api/src/BaseNamespace.ts b/packages/realtime-api/src/BaseNamespace.ts index d7b33f9db..a9e14cf60 100644 --- a/packages/realtime-api/src/BaseNamespace.ts +++ b/packages/realtime-api/src/BaseNamespace.ts @@ -4,17 +4,18 @@ import { ListenSubscriber } from './ListenSubscriber' import { SWClient } from './SWClient' export interface ListenOptions { - topics: string[] + topics?: string[] + channels?: string[] } -export type Listeners = Omit +export type Listeners = Omit -export type ListenersKeys = keyof Listeners +export type ListenersKeys = keyof T export class BaseNamespace< T extends ListenOptions, EventTypes extends EventEmitter.ValidEventTypes -> extends ListenSubscriber { +> extends ListenSubscriber, EventTypes> { constructor(options: SWClient) { super({ swClient: options }) } @@ -61,20 +62,20 @@ export class BaseNamespace< const _uuid = uuid() // Attach listeners - this._attachListenersWithTopics(topics, listeners) - await this.addTopics(topics) + this._attachListenersWithTopics(topics!, listeners as Listeners) + await this.addTopics(topics!) const unsub = () => { return new Promise(async (resolve, reject) => { try { // Detach listeners - this._detachListenersWithTopics(topics, listeners) + this._detachListenersWithTopics(topics!, listeners as Listeners) // Remove topics from the listener map this.removeFromListenerMap(_uuid) // Remove the topics - const topicsToRemove = topics.filter( + const topicsToRemove = topics!.filter( (topic) => !this.hasOtherListeners(_uuid, topic) ) if (topicsToRemove.length > 0) { @@ -90,35 +91,43 @@ export class BaseNamespace< // Add topics to the listener map this.addToListenerMap(_uuid, { - topics: new Set([...topics]), - listeners, + topics: new Set([...topics!]), + listeners: listeners as Listeners, unsub, }) return unsub } - protected _attachListenersWithTopics(topics: string[], listeners: Listeners) { - const listenerKeys = Object.keys(listeners) as Array + protected _attachListenersWithTopics( + topics: string[], + listeners: Listeners + ) { + const listenerKeys = Object.keys(listeners) topics.forEach((topic) => { listenerKeys.forEach((key) => { - if (typeof listeners[key] === 'function' && this._eventMap[key]) { - const event = prefixEvent(topic, this._eventMap[key]) + const _key = key as keyof Listeners + if (typeof listeners[_key] === 'function' && this._eventMap[_key]) { + const event = prefixEvent(topic, this._eventMap[_key] as string) // @ts-expect-error - this.on(event, listeners[key]) + this.on(event, listeners[_key]) } }) }) } - protected _detachListenersWithTopics(topics: string[], listeners: Listeners) { - const listenerKeys = Object.keys(listeners) as Array + protected _detachListenersWithTopics( + topics: string[], + listeners: Listeners + ) { + const listenerKeys = Object.keys(listeners) topics.forEach((topic) => { listenerKeys.forEach((key) => { - if (typeof listeners[key] === 'function' && this._eventMap[key]) { - const event = prefixEvent(topic, this._eventMap[key]) + const _key = key as keyof Listeners + if (typeof listeners[_key] === 'function' && this._eventMap[_key]) { + const event = prefixEvent(topic, this._eventMap[_key] as string) // @ts-expect-error - this.off(event, listeners[key]) + this.off(event, listeners[_key]) } }) }) diff --git a/packages/realtime-api/src/SWClient.test.ts b/packages/realtime-api/src/SWClient.test.ts index a8270cf65..89973c12b 100644 --- a/packages/realtime-api/src/SWClient.test.ts +++ b/packages/realtime-api/src/SWClient.test.ts @@ -21,6 +21,7 @@ describe('SWClient', () => { clientMock = { disconnect: jest.fn(), runWorker: jest.fn(), + sessionEmitter: { on: jest.fn() }, } ;(createClient as any).mockReturnValue(clientMock) diff --git a/packages/realtime-api/src/SWClient.ts b/packages/realtime-api/src/SWClient.ts index b1fa5c861..4eddfe271 100644 --- a/packages/realtime-api/src/SWClient.ts +++ b/packages/realtime-api/src/SWClient.ts @@ -2,6 +2,7 @@ import { createClient } from './client/createClient' import type { Client } from './client/Client' import { clientConnect } from './client/clientConnect' import { Task } from './task/Task' +import { Messaging } from './messaging/Messaging' import { PubSub } from './pubSub/PubSub' import { Chat } from './chat/Chat' import { Voice } from './voice/Voice' @@ -21,6 +22,7 @@ export class SWClient { private _pubSub: PubSub private _chat: Chat private _voice: Voice + private _messaging: Messaging public userOptions: SWClientOptions public client: Client @@ -35,7 +37,14 @@ export class SWClient { } disconnect() { - this.client.disconnect() + return new Promise((resolve) => { + const { sessionEmitter } = this.client + sessionEmitter.on('session.disconnected', () => { + resolve() + }) + + this.client.disconnect() + }) } get task() { @@ -45,6 +54,13 @@ export class SWClient { return this._task } + get messaging() { + if (!this._messaging) { + this._messaging = new Messaging(this) + } + return this._messaging + } + get pubSub() { if (!this._pubSub) { this._pubSub = new PubSub(this) diff --git a/packages/realtime-api/src/SignalWire.ts b/packages/realtime-api/src/SignalWire.ts index 94d66f384..2c543c163 100644 --- a/packages/realtime-api/src/SignalWire.ts +++ b/packages/realtime-api/src/SignalWire.ts @@ -14,7 +14,3 @@ export const SignalWire = (options: SWClientOptions): Promise => { } export type { SWClient } from './SWClient' -export type { Chat } from './chat/Chat' -export type { PubSub } from './pubSub/PubSub' -export type { Task } from './task/Task' -export type { Voice } from './voice/Voice' diff --git a/packages/realtime-api/src/chat/BaseChat.ts b/packages/realtime-api/src/chat/BaseChat.ts index afc856c06..3310ecb58 100644 --- a/packages/realtime-api/src/chat/BaseChat.ts +++ b/packages/realtime-api/src/chat/BaseChat.ts @@ -4,19 +4,12 @@ import { PubSubPublishParams, uuid, } from '@signalwire/core' -import { BaseNamespace, ListenOptions } from '../BaseNamespace' +import { BaseNamespace, Listeners } from '../BaseNamespace' -export interface BaseChatListenOptions extends ListenOptions { +export interface BaseChatListenOptions { channels: string[] } -export type BaseChatListeners = Omit< - BaseChatListenOptions, - 'channels' | 'topics' -> - -export type BaseChatListenerKeys = keyof BaseChatListeners - export class BaseChat< T extends BaseChatListenOptions, EventTypes extends EventEmitter.ValidEventTypes @@ -44,12 +37,13 @@ export class BaseChat< const _uuid = uuid() // Attach listeners - this._attachListenersWithTopics(channels, listeners) + this._attachListenersWithTopics(channels, listeners as Listeners) - const listenerKeys = Object.keys(listeners) as Array + const listenerKeys = Object.keys(listeners) const events: string[] = [] listenerKeys.forEach((key) => { - if (this._eventMap[key]) events.push(this._eventMap[key]) + const _key = key as keyof Listeners + if (this._eventMap[_key]) events.push(this._eventMap[_key] as string) }) await this.addChannels(channels, events) @@ -65,7 +59,7 @@ export class BaseChat< } // Detach listeners - this._detachListenersWithTopics(channels, listeners) + this._detachListenersWithTopics(channels, listeners as Listeners) // Remove channels from the listener map this.removeFromListenerMap(_uuid) @@ -80,7 +74,7 @@ export class BaseChat< // Add channels to the listener map this.addToListenerMap(_uuid, { topics: new Set([...channels]), - listeners, + listeners: listeners as Listeners, unsub, }) diff --git a/packages/realtime-api/src/chat/Chat.ts b/packages/realtime-api/src/chat/Chat.ts index 51ec7d52e..0671c6dce 100644 --- a/packages/realtime-api/src/chat/Chat.ts +++ b/packages/realtime-api/src/chat/Chat.ts @@ -4,12 +4,13 @@ import { ChatEvents, Chat as ChatCore, } from '@signalwire/core' -import { BaseChat, BaseChatListenOptions } from './BaseChat' +import { BaseChat } from './BaseChat' import { chatWorker } from './workers' import { SWClient } from '../SWClient' import { RealTimeChatEvents } from '../types/chat' -interface ChatListenOptions extends BaseChatListenOptions { +interface ChatListenOptions { + channels: string[] onMessageReceived?: (message: ChatMessage) => unknown onMemberJoined?: (member: ChatMember) => unknown onMemberUpdated?: (member: ChatMember) => unknown diff --git a/packages/realtime-api/src/messaging/Messaging.test.ts b/packages/realtime-api/src/messaging/Messaging.test.ts new file mode 100644 index 000000000..c45c8f16c --- /dev/null +++ b/packages/realtime-api/src/messaging/Messaging.test.ts @@ -0,0 +1,78 @@ +import { EventEmitter } from '@signalwire/core' +import { Messaging } from './Messaging' +import { createClient } from '../client/createClient' + +describe('Messaging', () => { + let messaging: Messaging + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: { + ...createClient(userOptions), + execute: jest.fn(), + runWorker: jest.fn(), + logger: { error: jest.fn() }, + }, + } + + beforeEach(() => { + //@ts-expect-error + messaging = new Messaging(swClientMock) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should have an event emitter', () => { + expect(messaging['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onMessageReceived: 'message.received', + onMessageUpdated: 'message.updated', + } + expect(messaging['_eventMap']).toEqual(expectedEventMap) + }) + + it('should send a message', async () => { + const responseMock = {} + swClientMock.client.execute.mockResolvedValue(responseMock) + + const sendParams = { + from: '+1234', + to: '+5678', + body: 'Hello jest!', + } + + const result = await messaging.send(sendParams) + + expect(result).toEqual(responseMock) + expect(swClientMock.client.execute).toHaveBeenCalledWith({ + method: 'messaging.send', + params: { + body: 'Hello jest!', + from_number: sendParams.from, + to_number: sendParams.to, + }, + }) + }) + + it('should handle send error', async () => { + const errorMock = new Error('Send error') + swClientMock.client.execute.mockRejectedValue(errorMock) + + const sendParams = { + from: '+1234', + to: '+5678', + body: 'Hello jest!', + } + + await expect(messaging.send(sendParams)).rejects.toThrow(errorMock) + }) +}) diff --git a/packages/realtime-api/src/messaging/Messaging.ts b/packages/realtime-api/src/messaging/Messaging.ts index 363760c05..170136ddf 100644 --- a/packages/realtime-api/src/messaging/Messaging.ts +++ b/packages/realtime-api/src/messaging/Messaging.ts @@ -1,16 +1,20 @@ -import { - DisconnectableClientContract, - BaseComponentOptions, - toExternalJSON, - ClientContextContract, - BaseConsumer, -} from '@signalwire/core' -import { connect } from '@signalwire/core' -import type { MessagingClientApiEvents } from '../types' -import { RealtimeClient } from '../client/index' +import { MessagingEventNames, toExternalJSON } from '@signalwire/core' +import { BaseNamespace } from '../BaseNamespace' +import { SWClient } from '../SWClient' +import { Message } from './Message' import { messagingWorker } from './workers' -interface MessagingSendParams { +interface MessageListenOptions { + topics: string[] + onMessageReceived?: (message: Message) => unknown + onMessageUpdated?: (message: Message) => unknown +} + +type MessageListenerKeys = keyof Omit + +type MessageEvents = Record void> + +interface MessageSendMethodParams { context?: string from: string to: string @@ -20,13 +24,7 @@ interface MessagingSendParams { media?: string[] } -interface InternalMessagingSendParams - extends Omit { - from_number: string - to_number: string -} - -export interface MessagingSendResult { +interface MessagingSendResult { message: string code: string messageId: string @@ -38,97 +36,49 @@ interface MessagingSendError { errors: Record } -/** @internal */ -export interface Messaging - extends DisconnectableClientContract, - ClientContextContract { - /** @internal */ - _session: RealtimeClient - /** - * Disconnects this client. The client will stop receiving events and you will - * need to create a new instance if you want to use it again. - * - * @example - * - * ```js - * client.disconnect() - * ``` - */ - disconnect(): void - - /** - * Send an outbound SMS or MMS message. - * - * @param params - {@link MessagingSendParams} - * - * @returns - {@link MessagingSendResult} - * - * @example - * - * > Send a message. - * - * ```js - * try { - * const sendResult = await client.send({ - * from: '+1xxx', - * to: '+1yyy', - * body: 'Hello World!' - * }) - * console.log('Message ID: ', sendResult.messageId) - * } catch (e) { - * console.error(e.message) - * } - * ``` - */ - send(params: MessagingSendParams): Promise -} - -/** @internal */ -class MessagingAPI extends BaseConsumer { - /** @internal */ +export class Messaging extends BaseNamespace< + MessageListenOptions, + MessageEvents +> { + protected _eventMap: Record = { + onMessageReceived: 'message.received', + onMessageUpdated: 'message.updated', + } - constructor(options: BaseComponentOptions) { + constructor(options: SWClient) { super(options) - this.runWorker('messagingWorker', { + this._client.runWorker('messagingWorker', { worker: messagingWorker, + initialState: { + messaging: this, + }, }) } - async send(params: MessagingSendParams): Promise { + async send(params: MessageSendMethodParams): Promise { const { from = '', to = '', ...rest } = params - const sendParams: InternalMessagingSendParams = { + const sendParams = { ...rest, from_number: from, to_number: to, } try { - const response: any = await this.execute({ - method: 'messaging.send', - params: sendParams, - }) + const response = await this._client.execute( + { + method: 'messaging.send', + params: sendParams, + } + ) - return toExternalJSON(response) + return toExternalJSON(response) } catch (error) { - this.logger.error('Error sending message', error) + this._client.logger.error('Error sending message', error) throw error as MessagingSendError } } } -/** @internal */ -export const createMessagingObject = ( - params: BaseComponentOptions -): Messaging => { - const messaging = connect({ - store: params.store, - Component: MessagingAPI, - })(params) - - return messaging -} - -export * from './MessagingClient' export * from './Message' export type { MessagingMessageState } from '@signalwire/core' diff --git a/packages/realtime-api/src/messaging/MessagingClient.test.ts b/packages/realtime-api/src/messaging/MessagingClient.test.ts deleted file mode 100644 index 0fcd7c868..000000000 --- a/packages/realtime-api/src/messaging/MessagingClient.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import WS from 'jest-websocket-mock' -import { Client } from './MessagingClient' -import { Message } from './Message' - -describe('MessagingClient', () => { - describe('Client', () => { - const host = 'ws://localhost:1234' - let server: WS - const authError = { - code: -32002, - message: - 'Authentication service failed with status ProtocolError, 401 Unauthorized: {}', - } - - beforeEach(async () => { - server = new WS(host) - server.on('connection', (socket: any) => { - socket.on('message', (data: any) => { - const parsedData = JSON.parse(data) - - if ( - parsedData.method === 'signalwire.connect' && - parsedData.params.authentication.token === '' - ) { - socket.send( - JSON.stringify({ - jsonrpc: '2.0', - id: parsedData.id, - error: authError, - }) - ) - } - - socket.send( - JSON.stringify({ - jsonrpc: '2.0', - id: parsedData.id, - result: {}, - }) - ) - }) - }) - }) - - afterEach(() => { - WS.clean() - }) - - describe('Automatic connect', () => { - it('should handle messaging.receive payloads', (done) => { - const messagePayload = { - message_id: 'f6e0ee46-4bd4-4856-99bb-0f3bc3d3e787', - context: 'foo', - direction: 'inbound' as const, - tags: ['Custom', 'client', 'data'], - from_number: '+1234567890', - to_number: '+12345698764', - body: 'Message Body', - media: ['url1', 'url2'], - segments: 1, - message_state: 'received', - } - const messaging = new Client({ - host, - project: 'some-project', - token: 'some-token', - contexts: ['foo'], - }) - - messaging.on('message.received', (message) => { - expect(message).toBeInstanceOf(Message) - expect(message.id).toEqual(messagePayload.message_id) - expect(message.context).toEqual(messagePayload.context) - expect(message.body).toEqual(messagePayload.body) - expect(message.media).toStrictEqual(messagePayload.media) - expect(message.tags).toStrictEqual(messagePayload.tags) - expect(message.segments).toStrictEqual(messagePayload.segments) - - messaging._session.removeAllListeners() - messaging._session.disconnect() - done() - }) - - // @ts-expect-error - messaging.session.once('session.connected', () => { - server.send( - JSON.stringify({ - jsonrpc: '2.0', - id: 'd42a7c46-c6c7-4f56-b52d-c1cbbcdc8125', - method: 'signalwire.event', - params: { - event_type: 'messaging.receive', - context: 'foo', - timestamp: 123457.1234, - space_id: 'uuid', - project_id: 'uuid', - params: messagePayload, - }, - }) - ) - }) - }) - - it('should handle messaging.state payloads', (done) => { - const messagePayload = { - message_id: '145cceb8-d4ed-4056-9696-f6775f950f2e', - context: 'foo', - direction: 'outbound', - tag: null, - tags: [], - from_number: '+1xxx', - to_number: '+1yyy', - body: 'Hello World!', - media: [], - segments: 1, - message_state: 'queued', - } - const messaging = new Client({ - host, - project: 'some-project', - token: 'some-other-token', - contexts: ['foo'], - }) - - messaging.on('message.updated', (message) => { - expect(message).toBeInstanceOf(Message) - expect(message.id).toEqual(messagePayload.message_id) - expect(message.context).toEqual(messagePayload.context) - expect(message.body).toEqual(messagePayload.body) - expect(message.media).toStrictEqual(messagePayload.media) - expect(message.tags).toStrictEqual(messagePayload.tags) - expect(message.segments).toStrictEqual(messagePayload.segments) - - messaging._session.disconnect() - done() - }) - - // @ts-expect-error - messaging.session.once('session.connected', () => { - server.send( - JSON.stringify({ - jsonrpc: '2.0', - id: 'd42a7c46-c6c7-4f56-b52d-c1cbbcdc8125', - method: 'signalwire.event', - params: { - event_type: 'messaging.state', - context: 'foo', - timestamp: 123457.1234, - space_id: 'uuid', - project_id: 'uuid', - params: messagePayload, - }, - }) - ) - }) - }) - - it('should show an error if client.connect failed to connect', (done) => { - const logger = { - error: jest.fn(), - trace: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - } - const messaging = new Client({ - host, - project: 'some-project', - token: '', - logger: logger as any, - }) - - messaging.on('message.received', (_message) => {}) - - // @ts-expect-error - messaging.session.on('session.auth_error', () => { - expect(logger.error).toHaveBeenNthCalledWith(1, 'Auth Error', { - code: -32002, - message: - 'Authentication service failed with status ProtocolError, 401 Unauthorized: {}', - }) - - messaging._session.disconnect() - done() - }) - }) - }) - }) -}) diff --git a/packages/realtime-api/src/messaging/MessagingClient.ts b/packages/realtime-api/src/messaging/MessagingClient.ts deleted file mode 100644 index c2cd679d8..000000000 --- a/packages/realtime-api/src/messaging/MessagingClient.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { UserOptions } from '@signalwire/core' -import { setupClient, clientConnect } from '../client/index' -import type { Messaging } from './Messaging' -import { createMessagingObject } from './Messaging' -import { clientContextInterceptorsFactory } from '../common/clientContext' -export type { - MessagingClientApiEvents, - RealTimeMessagingApiEventsHandlerMapping, -} from '../types' -export type { - MessageReceivedEventName, - MessageUpdatedEventName, -} from '@signalwire/core' - -interface MessagingClient extends Messaging { - new (opts: MessagingClientOptions): this -} - -export interface MessagingClientOptions - extends Omit {} - -/** - * You can use instances of this class to send or receive messages. Please see - * {@link MessagingClientApiEvents} for the full list of events you can subscribe - * to. - * - * @param params - {@link MessagingClientOptions} - * - * @example - * - * ```javascript - * const client = new Messaging.Client({ - * project: "", - * token: "", - * contexts: ['office'] - * }) - * - * client.on('message.received', (message) => { - * console.log('message.received', message) - * }) - * - * await client.send({ - * context: 'office', - * from: '+1xxx', - * to: '+1yyy', - * body: 'Hello World!' - * }) - * ``` - */ -const MessagingClient = function (options?: MessagingClientOptions) { - const { client, store } = setupClient(options) - - const messaging = createMessagingObject({ - store, - }) - - const send: Messaging['send'] = async (...args) => { - await clientConnect(client) - - return messaging.send(...args) - } - const disconnect = () => client.disconnect() - - const interceptors = { - ...clientContextInterceptorsFactory(client), - send, - _session: client, - disconnect, - } as const - - return new Proxy>(messaging, { - get(target: MessagingClient, prop: keyof MessagingClient, receiver: any) { - if (prop in interceptors) { - // @ts-expect-error - return interceptors[prop] - } - - // Always connect the underlying client if the user call a function on the Proxy - if (typeof target[prop] === 'function') { - clientConnect(client) - } - - 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?: MessagingClientOptions): MessagingClient } - -export { MessagingClient as Client } diff --git a/packages/realtime-api/src/messaging/workers/messagingWorker.ts b/packages/realtime-api/src/messaging/workers/messagingWorker.ts index 889a42261..4c34ef913 100644 --- a/packages/realtime-api/src/messaging/workers/messagingWorker.ts +++ b/packages/realtime-api/src/messaging/workers/messagingWorker.ts @@ -6,17 +6,26 @@ import { getLogger, sagaEffects, } from '@signalwire/core' -import { Message, Messaging } from '../Messaging' +import type { Client } from '../../client/Client' +import { prefixEvent } from '../../utils/internals' +import { Message } from '../Messaging' +import { Messaging } from '../Messaging' -export const messagingWorker: SDKWorker = function* ( +interface MessagingWorkerInitialState { + messaging: Messaging +} + +export const messagingWorker: SDKWorker = function* ( options ): SagaIterator { getLogger().trace('messagingWorker started') const { - instance: client, channels: { swEventChannel }, + initialState, } = options + const { messaging } = initialState as MessagingWorkerInitialState + function* worker(action: MessagingAction) { const { payload, type } = action @@ -25,10 +34,15 @@ export const messagingWorker: SDKWorker = function* ( switch (type) { case 'messaging.receive': - client.emit('message.received', message) + messaging.emit( + // @ts-expect-error + prefixEvent(payload.context, 'message.received'), + message + ) break case 'messaging.state': - client.emit('message.updated', message) + // @ts-expect-error + messaging.emit(prefixEvent(payload.context, 'message.updated'), message) break default: getLogger().warn(`Unknown message event: "${action.type}"`) diff --git a/packages/realtime-api/src/pubSub/PubSub.ts b/packages/realtime-api/src/pubSub/PubSub.ts index b62d893b0..1b690ae46 100644 --- a/packages/realtime-api/src/pubSub/PubSub.ts +++ b/packages/realtime-api/src/pubSub/PubSub.ts @@ -5,10 +5,11 @@ import { } from '@signalwire/core' import { SWClient } from '../SWClient' import { pubSubWorker } from './workers' -import { BaseChat, BaseChatListenOptions } from '../chat/BaseChat' +import { BaseChat } from '../chat/BaseChat' import { RealTimePubSubEvents } from '../types/pubSub' -interface PubSubListenOptions extends BaseChatListenOptions { +interface PubSubListenOptions { + channels: string[] onMessageReceived?: (message: PubSubMessage) => unknown } diff --git a/packages/realtime-api/src/task/Task.ts b/packages/realtime-api/src/task/Task.ts index 21a124319..ad61845e3 100644 --- a/packages/realtime-api/src/task/Task.ts +++ b/packages/realtime-api/src/task/Task.ts @@ -6,12 +6,13 @@ import { } from '@signalwire/core' import { SWClient } from '../SWClient' import { taskWorker } from './workers' -import { ListenOptions, BaseNamespace } from '../BaseNamespace' +import { BaseNamespace } from '../BaseNamespace' export const PATH = '/api/relay/rest/tasks' const HOST = 'relay.signalwire.com' -interface TaskListenOptions extends ListenOptions { +interface TaskListenOptions { + topics: string[] onTaskReceived?: (payload: TaskInboundEvent['message']) => unknown } diff --git a/packages/realtime-api/src/types/chat.ts b/packages/realtime-api/src/types/chat.ts index 93319975a..66f6f1ef8 100644 --- a/packages/realtime-api/src/types/chat.ts +++ b/packages/realtime-api/src/types/chat.ts @@ -3,13 +3,17 @@ import type { ChatMemberEventNames, ChatMessage, ChatMessageEventName, + ChatNamespace, } from '@signalwire/core' export type RealTimeChatApiEventsHandlerMapping = Record< - ChatMessageEventName, + `${ChatNamespace}.${ChatMessageEventName}`, (message: ChatMessage) => void > & - Record void> + Record< + `${ChatNamespace}.${ChatMemberEventNames}`, + (member: ChatMember) => void + > export type RealTimeChatEvents = { [k in keyof RealTimeChatApiEventsHandlerMapping]: RealTimeChatApiEventsHandlerMapping[k] diff --git a/packages/realtime-api/src/types/pubSub.ts b/packages/realtime-api/src/types/pubSub.ts index 44bbdd3d2..e892d9466 100644 --- a/packages/realtime-api/src/types/pubSub.ts +++ b/packages/realtime-api/src/types/pubSub.ts @@ -1,7 +1,11 @@ -import type { PubSubMessage, PubSubMessageEventName } from '@signalwire/core' +import type { + PubSubMessage, + PubSubMessageEventName, + PubSubNamespace, +} from '@signalwire/core' export type RealTimePubSubApiEventsHandlerMapping = Record< - PubSubMessageEventName, + `${PubSubNamespace}.${PubSubMessageEventName}`, (message: PubSubMessage) => void > diff --git a/packages/realtime-api/src/voice/Call.ts b/packages/realtime-api/src/voice/Call.ts index a24a43c0d..47a81c353 100644 --- a/packages/realtime-api/src/voice/Call.ts +++ b/packages/realtime-api/src/voice/Call.ts @@ -213,7 +213,7 @@ export class Call extends ListenSubscriber< /** @internal */ setConnectPayload(payload: CallingCallConnectEventParams) { - this._connectPayload = { ...this._connectPayload, ...payload } + this._connectPayload = payload } /** diff --git a/packages/realtime-api/src/voice/Voice.ts b/packages/realtime-api/src/voice/Voice.ts index b58a8ddce..286c3220b 100644 --- a/packages/realtime-api/src/voice/Voice.ts +++ b/packages/realtime-api/src/voice/Voice.ts @@ -14,10 +14,11 @@ import type { VoiceEvents, } from '../types' import { toInternalDevices } from './utils' -import { BaseNamespace, ListenOptions } from '../BaseNamespace' +import { BaseNamespace } from '../BaseNamespace' import { SWClient } from '../SWClient' -interface VoiceListenOptions extends ListenOptions { +interface VoiceListenOptions { + topics: string[] onCallReceived?: (call: Call) => unknown } From 6ed5a0cd1c4bc8886d1fbf4cd2b31ae7b03572be Mon Sep 17 00:00:00 2001 From: Ammar Ansari Date: Thu, 14 Sep 2023 15:51:46 +0200 Subject: [PATCH 05/15] fix unit tests --- packages/realtime-api/src/types/voice.ts | 1 + packages/realtime-api/src/voice/Call.test.ts | 1 + packages/realtime-api/src/voice/CallRecording.test.ts | 2 ++ 3 files changed, 4 insertions(+) diff --git a/packages/realtime-api/src/types/voice.ts b/packages/realtime-api/src/types/voice.ts index 7e8a1546b..96aa7f4ba 100644 --- a/packages/realtime-api/src/types/voice.ts +++ b/packages/realtime-api/src/types/voice.ts @@ -223,6 +223,7 @@ export type CallRecordingEvents = Record< export interface CallRecordingListeners { onStarted?: (recording: CallRecording) => unknown + onUpdated?: (recording: CallRecording) => unknown onFailed?: (recording: CallRecording) => unknown onEnded?: (recording: CallRecording) => unknown } diff --git a/packages/realtime-api/src/voice/Call.test.ts b/packages/realtime-api/src/voice/Call.test.ts index 6b37b56f5..35a5344ce 100644 --- a/packages/realtime-api/src/voice/Call.test.ts +++ b/packages/realtime-api/src/voice/Call.test.ts @@ -38,6 +38,7 @@ describe('Call', () => { onPlaybackFailed: 'playback.failed', onPlaybackEnded: 'playback.ended', onRecordingStarted: 'recording.started', + onRecordingUpdated: 'recording.updated', onRecordingFailed: 'recording.failed', onRecordingEnded: 'recording.ended', onPromptStarted: 'prompt.started', diff --git a/packages/realtime-api/src/voice/CallRecording.test.ts b/packages/realtime-api/src/voice/CallRecording.test.ts index c0a179aff..a0706d700 100644 --- a/packages/realtime-api/src/voice/CallRecording.test.ts +++ b/packages/realtime-api/src/voice/CallRecording.test.ts @@ -46,6 +46,7 @@ describe('CallRecording', () => { it('should declare the correct event map', () => { const expectedEventMap = { onStarted: 'recording.started', + onUpdated: 'recording.updated', onFailed: 'recording.failed', onEnded: 'recording.ended', } @@ -59,6 +60,7 @@ describe('CallRecording', () => { payload: {}, listeners: { onStarted: () => {}, + onUpdated: () => {}, onFailed: () => {}, onEnded: () => {}, }, From a49896cbaffd5f17d3fcb16301d44dd81eba6da9 Mon Sep 17 00:00:00 2001 From: Ammar Ansari Date: Thu, 14 Sep 2023 17:17:36 +0200 Subject: [PATCH 06/15] fix e2e test cases --- .../src/voiceCollectAllListeners.test.ts | 31 +++++++++++---- .../src/voiceCollectCallListeners.test.ts | 29 +++++++++++--- .../src/voiceCollectDialListeners.test.ts | 29 +++++++++++--- .../src/voiceCollectListeners.test.ts | 29 +++++++++++--- .../src/voiceDetectAllListeners.test.ts | 39 +++++++++++++------ .../src/voiceDetectCallListeners.test.ts | 31 +++++++++++---- .../src/voiceDetectDialListeners.test.ts | 31 +++++++++++---- .../src/voiceDetectListeners.test.ts | 31 +++++++++++---- .../src/voicePlaybackAllListeners.test.ts | 35 ++++++++++++----- .../src/voicePlaybackCallListeners.test.ts | 31 +++++++++++---- .../src/voicePlaybackDialListeners.test.ts | 31 +++++++++++---- .../src/voicePlaybackListeners.test.ts | 31 +++++++++++---- .../src/voicePromptAllListeners.test.ts | 29 +++++++++++--- .../src/voicePromptCallListeners.test.ts | 23 ++++++++--- .../src/voicePromptDialListeners.test.ts | 22 ++++++++--- .../src/voicePromptListeners.test.ts | 22 ++++++++--- .../src/voiceRecordAllListeners.test.ts | 30 ++++++++++---- .../src/voiceRecordCallListeners.test.ts | 29 +++++++++++--- .../src/voiceRecordDialListeners.test.ts | 30 +++++++++++--- .../src/voiceRecordListeners.test.ts | 29 +++++++++++--- .../src/voiceTapAllListeners.test.ts | 37 ++++++++++++------ 21 files changed, 484 insertions(+), 145 deletions(-) diff --git a/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts index 0abe4ca42..0cfb72997 100644 --- a/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_COLLECT_PROPS, CALL_PROPS } from './utils' +import { + createTestRunner, + CALL_COLLECT_PROPS, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = async () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -24,7 +34,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -56,16 +66,22 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { onCollectInputStarted(collect) { tap.hasProps( collect, CALL_COLLECT_PROPS, - 'voice.dialPhone: Collect input started' + 'voice.dialSip: Collect input started' ) }, }, @@ -183,6 +199,7 @@ async function main() { name: 'Voice Collect with all Listeners E2E', testHandler: handler, executionTime: 60_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts index 209cab241..521b99ea1 100644 --- a/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_COLLECT_PROPS, CALL_PROPS } from './utils' +import { + createTestRunner, + CALL_COLLECT_PROPS, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = async () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -24,7 +34,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -56,9 +66,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, }) tap.ok(call.id, 'Outbound - Call resolved') @@ -150,6 +166,7 @@ async function main() { name: 'Voice Collect with Call Listeners E2E', testHandler: handler, executionTime: 60_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts index c11cc72de..76c5e07a5 100644 --- a/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_COLLECT_PROPS, CALL_PROPS } from './utils' +import { + createTestRunner, + CALL_COLLECT_PROPS, + CALL_PROPS, + makeSipDomainAppAddress, + TestHandler, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = async () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -24,7 +34,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -56,9 +66,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { onCollectStarted: (collect) => { @@ -147,6 +163,7 @@ async function main() { name: 'Voice Collect with Dial Listeners E2E', testHandler: handler, executionTime: 60_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts index 4f213f1f5..2607af82e 100644 --- a/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_COLLECT_PROPS, CALL_PROPS } from './utils' +import { + createTestRunner, + CALL_COLLECT_PROPS, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = async () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -24,7 +34,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -56,9 +66,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, }) tap.ok(call.id, 'Outbound - Call resolved') @@ -201,6 +217,7 @@ async function main() { name: 'Voice Collect Listeners E2E', testHandler: handler, executionTime: 60_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts index 96c4b77d7..722ce9164 100644 --- a/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { CALL_PROPS, CALL_DETECT_PROPS, createTestRunner } from './utils' +import { + CALL_PROPS, + CALL_DETECT_PROPS, + createTestRunner, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -20,7 +30,7 @@ const handler = () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -49,9 +59,15 @@ const handler = () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { async onStateChanged(call) { @@ -69,12 +85,12 @@ const handler = () => { tap.hasProps( detect, CALL_DETECT_PROPS, - 'voice.dialPhone: Detect started' + 'voice.dialSip: Detect started' ) tap.equal( detect.callId, call.id, - 'voice.dialPhone: Detect with correct call id' + 'voice.dialSip: Detect with correct call id' ) }, }, @@ -86,12 +102,12 @@ const handler = () => { tap.hasProps( detect, CALL_DETECT_PROPS, - 'voice.dialPhone: Detect started' + 'voice.dialSip: Detect started' ) tap.equal( detect.callId, call.id, - 'voice.dialPhone: Detect with correct call id' + 'voice.dialSip: Detect with correct call id' ) }, onDetectEnded: (detect) => { @@ -174,7 +190,8 @@ async function main() { const runner = createTestRunner({ name: 'Voice Detect with All Listeners E2E', testHandler: handler, - executionTime: 60_000, + executionTime: 30_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts index 33fb077a7..ca540081a 100644 --- a/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { CALL_PROPS, CALL_DETECT_PROPS, createTestRunner } from './utils' +import { + CALL_PROPS, + CALL_DETECT_PROPS, + createTestRunner, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -20,7 +30,7 @@ const handler = () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -49,9 +59,15 @@ const handler = () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { async onStateChanged(call) { @@ -108,7 +124,8 @@ async function main() { const runner = createTestRunner({ name: 'Voice Detect with Call Listeners E2E', testHandler: handler, - executionTime: 60_000, + executionTime: 30_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts index 887ced24f..f19bde4f5 100644 --- a/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { CALL_PROPS, CALL_DETECT_PROPS, createTestRunner } from './utils' +import { + CALL_PROPS, + CALL_DETECT_PROPS, + createTestRunner, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -20,7 +30,7 @@ const handler = () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -49,9 +59,15 @@ const handler = () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { async onStateChanged(call) { @@ -103,7 +119,8 @@ async function main() { const runner = createTestRunner({ name: 'Voice Detect with Dial Listeners E2E', testHandler: handler, - executionTime: 60_000, + executionTime: 30_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts index 29216f5a8..798aa6cee 100644 --- a/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { CALL_PROPS, CALL_DETECT_PROPS, createTestRunner } from './utils' +import { + CALL_PROPS, + CALL_DETECT_PROPS, + createTestRunner, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -20,7 +30,7 @@ const handler = () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -49,9 +59,15 @@ const handler = () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { async onStateChanged(call) { @@ -114,7 +130,8 @@ async function main() { const runner = createTestRunner({ name: 'Voice Detect Listeners E2E', testHandler: handler, - executionTime: 60_000, + executionTime: 30_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts index 3f9c676f0..3d151fc90 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_PROPS, CALL_PLAYBACK_PROPS } from './utils' +import { + createTestRunner, + CALL_PROPS, + CALL_PLAYBACK_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = async () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -15,7 +25,7 @@ const handler = async () => { }) const unsubVoiceOffice = await client.voice.listen({ - topics: ['office'], + topics: [domainApp.call_relay_context], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -39,9 +49,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { onStateChanged: async (call) => { @@ -61,12 +77,12 @@ const handler = async () => { tap.hasProps( playback, CALL_PLAYBACK_PROPS, - 'voice.dialPhone: Playback started' + 'voice.dialSip: Playback started' ) tap.equal( playback.state, 'playing', - 'voice.dialPhone: Playback correct state' + 'voice.dialSip: Playback correct state' ) }, }, @@ -147,7 +163,8 @@ async function main() { const runner = createTestRunner({ name: 'Voice Playback with all Listeners E2E', testHandler: handler, - executionTime: 30_000, + executionTime: 60_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts index 354820fac..0b2b7af46 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_PLAYBACK_PROPS, CALL_PROPS } from './utils' +import { + createTestRunner, + CALL_PLAYBACK_PROPS, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = async () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -15,7 +25,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -31,9 +41,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, }) tap.ok(call.id, 'Outbound - Call resolved') @@ -84,7 +100,8 @@ async function main() { const runner = createTestRunner({ name: 'Voice Playback with Call Listeners E2E', testHandler: handler, - executionTime: 30_000, + executionTime: 60_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts index 5287062c7..12d239f77 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_PLAYBACK_PROPS, CALL_PROPS } from './utils' +import { + createTestRunner, + CALL_PLAYBACK_PROPS, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = async () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -15,7 +25,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -31,9 +41,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { onStateChanged: async (call) => { @@ -81,7 +97,8 @@ async function main() { const runner = createTestRunner({ name: 'Voice Playback with Dial Listeners E2E', testHandler: handler, - executionTime: 30_000, + executionTime: 60_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts index b853af512..266bf52da 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_PLAYBACK_PROPS, CALL_PROPS } from './utils' +import { + createTestRunner, + CALL_PLAYBACK_PROPS, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = async () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -15,7 +25,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -31,9 +41,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { onStateChanged: async (call) => { @@ -104,7 +120,8 @@ async function main() { const runner = createTestRunner({ name: 'Voice Playback Listeners E2E', testHandler: handler, - executionTime: 30_000, + executionTime: 60_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts index 27bcac57c..9d33cdaa6 100644 --- a/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_PROPS, CALL_PROMPT_PROPS } from './utils' +import { + createTestRunner, + CALL_PROPS, + CALL_PROMPT_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = async () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -15,7 +25,7 @@ const handler = async () => { }) const unsubVoiceOffice = await client.voice.listen({ - topics: ['office'], + topics: [domainApp.call_relay_context], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -49,9 +59,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { onPromptStarted: (prompt) => { @@ -197,6 +213,7 @@ async function main() { name: 'Voice Prompt with all Listeners E2E', testHandler: handler, executionTime: 30_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts index 297e0632f..424febbf1 100644 --- a/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts @@ -5,9 +5,15 @@ import { CALL_PLAYBACK_PROPS, CALL_PROPS, CALL_PROMPT_PROPS, + TestHandler, + makeSipDomainAppAddress, } from './utils' -const handler = async () => { +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } + return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -20,7 +26,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -46,9 +52,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, }) tap.ok(call.id, 'Outbound - Call resolved') @@ -126,6 +138,7 @@ async function main() { name: 'Voice Prompt with Call Listeners E2E', testHandler: handler, executionTime: 30_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts index 50d4eeeb1..88b4ecf91 100644 --- a/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts @@ -5,9 +5,14 @@ import { CALL_PROMPT_PROPS, CALL_PROPS, CALL_PLAYBACK_PROPS, + TestHandler, + makeSipDomainAppAddress, } from './utils' -const handler = async () => { +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -20,7 +25,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -46,9 +51,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { onPlaybackStarted: (playback) => { @@ -128,6 +139,7 @@ async function main() { name: 'Voice Prompt with Dial Listeners E2E', testHandler: handler, executionTime: 30_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voicePromptListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptListeners.test.ts index f7e610cca..4f23637dc 100644 --- a/internal/e2e-realtime-api/src/voicePromptListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePromptListeners.test.ts @@ -5,9 +5,14 @@ import { CALL_PLAYBACK_PROPS, CALL_PROPS, CALL_PROMPT_PROPS, + TestHandler, + makeSipDomainAppAddress, } from './utils' -const handler = async () => { +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -20,7 +25,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -46,9 +51,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, }) tap.ok(call.id, 'Outbound - Call resolved') @@ -156,6 +167,7 @@ async function main() { name: 'Voice Prompt Listeners E2E', testHandler: handler, executionTime: 30_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts index b09c7246e..0316e9bf1 100644 --- a/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts @@ -1,8 +1,17 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_PROPS, CALL_RECORD_PROPS } from './utils' - -const handler = async () => { +import { + createTestRunner, + CALL_PROPS, + CALL_RECORD_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -15,7 +24,7 @@ const handler = async () => { }) const unsubVoiceOffice = await client.voice.listen({ - topics: ['office'], + topics: [domainApp.call_relay_context], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -39,9 +48,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { onStateChanged: async (call) => { @@ -150,6 +165,7 @@ async function main() { name: 'Voice Record with all Listeners E2E', testHandler: handler, executionTime: 30_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts index 4641d61bf..1821ec61a 100644 --- a/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_RECORD_PROPS, CALL_PROPS } from './utils' +import { + createTestRunner, + CALL_RECORD_PROPS, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = async () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -15,7 +25,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -31,9 +41,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, }) tap.ok(call.id, 'Outbound - Call resolved') @@ -80,6 +96,7 @@ async function main() { name: 'Voice Record with Call Listeners E2E', testHandler: handler, executionTime: 30_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts index 44d79b557..26e84c0b2 100644 --- a/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts @@ -1,8 +1,19 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_RECORD_PROPS, CALL_PROPS, sleep } from './utils' +import { + createTestRunner, + CALL_RECORD_PROPS, + CALL_PROPS, + sleep, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = async () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -15,7 +26,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -31,9 +42,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { onStateChanged: async (call) => { @@ -77,6 +94,7 @@ async function main() { name: 'Voice Record with Dial Listeners E2E', testHandler: handler, executionTime: 30_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts index eb0cba522..dc95f4d13 100644 --- a/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { createTestRunner, CALL_RECORD_PROPS, CALL_PROPS } from './utils' +import { + createTestRunner, + CALL_RECORD_PROPS, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = async () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -15,7 +25,7 @@ const handler = async () => { }) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -31,9 +41,15 @@ const handler = async () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { onStateChanged: async (call) => { @@ -98,6 +114,7 @@ async function main() { name: 'Voice Record Listeners E2E', testHandler: handler, executionTime: 30_000, + useDomainApp: true, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts index 7f8f2069c..9cef80fca 100644 --- a/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts @@ -1,8 +1,18 @@ import tap from 'tap' import { SignalWire } from '@signalwire/realtime-api' -import { CALL_PROPS, CALL_TAP_PROPS, createTestRunner } from './utils' +import { + CALL_PROPS, + CALL_TAP_PROPS, + TestHandler, + createTestRunner, + makeSipDomainAppAddress, +} from './utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } -const handler = () => { return new Promise(async (resolve, reject) => { try { const client = await SignalWire({ @@ -18,7 +28,7 @@ const handler = () => { tap.plan(4) const unsubVoice = await client.voice.listen({ - topics: ['office', 'home'], + topics: [domainApp.call_relay_context, 'home'], onCallReceived: async (call) => { try { const resultAnswer = await call.answer() @@ -34,21 +44,23 @@ const handler = () => { }, }) - const call = await client.voice.dialPhone({ - to: process.env.VOICE_DIAL_TO_NUMBER as string, - from: process.env.VOICE_DIAL_FROM_NUMBER as string, + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), timeout: 30, listen: { onTapStarted: (callTap) => { - tap.hasProps( - callTap, - CALL_TAP_PROPS, - 'voice.dialPhone: Tap started' - ) + tap.hasProps(callTap, CALL_TAP_PROPS, 'voice.dialSip: Tap started') tap.equal( callTap.callId, call.id, - 'voice.dialPhone: Tap with correct call id' + 'voice.dialSip: Tap with correct call id' ) }, }, @@ -118,6 +130,7 @@ async function main() { name: 'Voice Tap with All Listeners E2E', testHandler: handler, executionTime: 30_000, + useDomainApp: true, }) await runner.run() From 430b8b25009c19019484077b5c249ef64d86e554 Mon Sep 17 00:00:00 2001 From: Ammar Ansari Date: Wed, 20 Sep 2023 11:44:49 +0200 Subject: [PATCH 07/15] Decorated promise for Voice Call APIs (#880) * Decorated promise for Voice Call APIs * decorate recording promise * unit tests for decorated playback and recording promises * decorate prompt promise * generic decorate promise function * decorated promise for detect and tap * decorated call collect api promise * more unit test cases * generic decorate promise function with unit tests * e2e test cases update * update voice playgrounds * include changeset * prevent methods to be run if the action has ended * promisify action ended properties --- .changeset/tricky-ants-talk.md | 50 +++++ internal/e2e-realtime-api/src/voice.test.ts | 81 ++++--- .../src/voiceCollectAllListeners.test.ts | 50 +++-- .../src/voiceCollectCallListeners.test.ts | 26 ++- .../src/voiceCollectDialListeners.test.ts | 4 +- .../src/voiceCollectListeners.test.ts | 98 +++++---- .../src/voiceDetectAllListeners.test.ts | 60 ++--- .../src/voiceDetectCallListeners.test.ts | 6 +- .../src/voiceDetectDialListeners.test.ts | 14 +- .../src/voiceDetectListeners.test.ts | 4 +- .../src/voicePlaybackAllListeners.test.ts | 6 +- .../src/voicePlaybackCallListeners.test.ts | 6 +- .../src/voicePlaybackDialListeners.test.ts | 8 +- .../src/voicePlaybackListeners.test.ts | 40 ++-- .../src/voicePlaybackMultiple.test.ts | 116 +++++----- .../src/voicePromptAllListeners.test.ts | 70 +++--- .../src/voicePromptCallListeners.test.ts | 18 +- .../src/voicePromptDialListeners.test.ts | 27 ++- .../src/voicePromptListeners.test.ts | 24 +- .../src/voiceRecordAllListeners.test.ts | 8 +- .../src/voiceRecordCallListeners.test.ts | 6 +- .../src/voiceRecordDialListeners.test.ts | 2 +- .../src/voiceRecordListeners.test.ts | 10 +- .../src/voiceRecordMultiple.test.ts | 43 ++-- .../src/voiceTapAllListeners.test.ts | 42 ++-- .../src/voice-dtmf-loop/index.ts | 4 +- .../src/voice-inbound/index.ts | 3 +- .../src/voice/index.ts | 173 ++++++++------- packages/core/src/types/utils.ts | 7 + packages/core/src/types/voiceCall.ts | 2 +- packages/realtime-api/src/voice/Call.ts | 44 ++-- .../src/voice/CallCollect.test.ts | 108 --------- .../src/voice/CallCollect/CallCollect.test.ts | 193 ++++++++++++++++ .../voice/{ => CallCollect}/CallCollect.ts | 58 ++--- .../CallCollect/decorateCollectPromise.ts | 60 +++++ .../src/voice/CallCollect/index.ts | 3 + .../realtime-api/src/voice/CallDetect.test.ts | 91 -------- .../src/voice/CallDetect/CallDetect.test.ts | 169 ++++++++++++++ .../src/voice/{ => CallDetect}/CallDetect.ts | 20 +- .../voice/CallDetect/decorateDetectPromise.ts | 59 +++++ .../src/voice/CallDetect/index.ts | 3 + .../src/voice/CallPlayback.test.ts | 118 ---------- .../voice/CallPlayback/CallPlayback.test.ts | 206 +++++++++++++++++ .../voice/{ => CallPlayback}/CallPlayback.ts | 31 ++- .../CallPlayback/decoratePlaybackPromise.ts | 56 +++++ .../src/voice/CallPlayback/index.ts | 3 + .../realtime-api/src/voice/CallPrompt.test.ts | 107 --------- .../src/voice/CallPrompt/CallPrompt.test.ts | 191 ++++++++++++++++ .../src/voice/{ => CallPrompt}/CallPrompt.ts | 47 ++-- .../voice/CallPrompt/decoratePromptPromise.ts | 61 ++++++ .../src/voice/CallPrompt/index.ts | 3 + .../src/voice/CallRecording.test.ts | 122 ----------- .../voice/CallRecording/CallRecording.test.ts | 207 ++++++++++++++++++ .../{ => CallRecording}/CallRecording.ts | 27 ++- .../CallRecording/decorateRecordingPromise.ts | 61 ++++++ .../src/voice/CallRecording/index.ts | 3 + .../realtime-api/src/voice/CallTap.test.ts | 106 --------- .../src/voice/CallTap/CallTap.test.ts | 183 ++++++++++++++++ .../src/voice/{ => CallTap}/CallTap.ts | 35 +-- .../src/voice/CallTap/decorateTapPromise.ts | 42 ++++ .../realtime-api/src/voice/CallTap/index.ts | 3 + .../src/voice/decoratePromise.test.ts | 89 ++++++++ .../realtime-api/src/voice/decoratePromise.ts | 80 +++++++ 63 files changed, 2400 insertions(+), 1197 deletions(-) create mode 100644 .changeset/tricky-ants-talk.md delete mode 100644 packages/realtime-api/src/voice/CallCollect.test.ts create mode 100644 packages/realtime-api/src/voice/CallCollect/CallCollect.test.ts rename packages/realtime-api/src/voice/{ => CallCollect}/CallCollect.ts (82%) create mode 100644 packages/realtime-api/src/voice/CallCollect/decorateCollectPromise.ts create mode 100644 packages/realtime-api/src/voice/CallCollect/index.ts delete mode 100644 packages/realtime-api/src/voice/CallDetect.test.ts create mode 100644 packages/realtime-api/src/voice/CallDetect/CallDetect.test.ts rename packages/realtime-api/src/voice/{ => CallDetect}/CallDetect.ts (89%) create mode 100644 packages/realtime-api/src/voice/CallDetect/decorateDetectPromise.ts create mode 100644 packages/realtime-api/src/voice/CallDetect/index.ts delete mode 100644 packages/realtime-api/src/voice/CallPlayback.test.ts create mode 100644 packages/realtime-api/src/voice/CallPlayback/CallPlayback.test.ts rename packages/realtime-api/src/voice/{ => CallPlayback}/CallPlayback.ts (85%) create mode 100644 packages/realtime-api/src/voice/CallPlayback/decoratePlaybackPromise.ts create mode 100644 packages/realtime-api/src/voice/CallPlayback/index.ts delete mode 100644 packages/realtime-api/src/voice/CallPrompt.test.ts create mode 100644 packages/realtime-api/src/voice/CallPrompt/CallPrompt.test.ts rename packages/realtime-api/src/voice/{ => CallPrompt}/CallPrompt.ts (84%) create mode 100644 packages/realtime-api/src/voice/CallPrompt/decoratePromptPromise.ts create mode 100644 packages/realtime-api/src/voice/CallPrompt/index.ts delete mode 100644 packages/realtime-api/src/voice/CallRecording.test.ts create mode 100644 packages/realtime-api/src/voice/CallRecording/CallRecording.test.ts rename packages/realtime-api/src/voice/{ => CallRecording}/CallRecording.ts (86%) create mode 100644 packages/realtime-api/src/voice/CallRecording/decorateRecordingPromise.ts create mode 100644 packages/realtime-api/src/voice/CallRecording/index.ts delete mode 100644 packages/realtime-api/src/voice/CallTap.test.ts create mode 100644 packages/realtime-api/src/voice/CallTap/CallTap.test.ts rename packages/realtime-api/src/voice/{ => CallTap}/CallTap.ts (78%) create mode 100644 packages/realtime-api/src/voice/CallTap/decorateTapPromise.ts create mode 100644 packages/realtime-api/src/voice/CallTap/index.ts create mode 100644 packages/realtime-api/src/voice/decoratePromise.test.ts create mode 100644 packages/realtime-api/src/voice/decoratePromise.ts diff --git a/.changeset/tricky-ants-talk.md b/.changeset/tricky-ants-talk.md new file mode 100644 index 000000000..4e4d31fe1 --- /dev/null +++ b/.changeset/tricky-ants-talk.md @@ -0,0 +1,50 @@ +--- +'@signalwire/realtime-api': major +'@signalwire/core': major +--- + +Decorated promise for the following APIs: +- call.play() + - call.playAudio() + - call.playSilence() + - call.playRingtone() + - call.playTTS() +- call.record() + - call.recordAudio() +- call.prompt() + - call.promptAudio() + - call.promptRingtone() + - call.promptTTS() +- call.tap() + - call.tapAudio() +- call.detect() + - call.amd() + - call.detectFax() + - call.detectDigit +- call.collect() + +Playback example 1 - **Not resolving promise** +```js +const play = call.playAudio({ url: '...' }) +await play.id +``` + +Playback example 2 - **Resolving promise when playback starts** +```js +const play = await call.playAudio({ url: '...' }).onStarted() +play.id +``` + +Playback example 3 - **Resolving promise when playback ends** +```js +const play = await call.playAudio({ url: '...' }).onEnded() +play.id +``` + +Playback example 4 - **Resolving promise when playback ends - Default behavior** +```js +const play = await call.playAudio({ url: '...' }) +play.id +``` + +All the other APIs work in a similar way. diff --git a/internal/e2e-realtime-api/src/voice.test.ts b/internal/e2e-realtime-api/src/voice.test.ts index d62ed8f29..7e884da68 100644 --- a/internal/e2e-realtime-api/src/voice.test.ts +++ b/internal/e2e-realtime-api/src/voice.test.ts @@ -63,10 +63,12 @@ const handler: TestHandler = ({ domainApp }) => { return } - const recording = await call.recordAudio({ - direction: 'speak', - inputSensitivity: 60, - }) + const recording = await call + .recordAudio({ + direction: 'speak', + inputSensitivity: 60, + }) + .onStarted() tap.ok(recording.id, 'Recording started') tap.equal( recording.state, @@ -79,7 +81,7 @@ const handler: TestHandler = ({ domainApp }) => { text: 'Message is getting recorded', }) ) - const playback = await call.play({ playlist }) + const playback = await call.play({ playlist }).onStarted() tap.equal(playback.state, 'playing', 'Playback state is "playing"') const playbackEndedResult = await playback.ended() @@ -104,45 +106,41 @@ const handler: TestHandler = ({ domainApp }) => { 'Recording state is "finished"' ) - const prompt = await call.prompt({ - playlist: new Voice.Playlist({ volume: 1.0 }).add( - Voice.Playlist.TTS({ - text: 'Welcome to SignalWire! Please enter your 4 digits PIN', - }) - ), - digits: { - max: 4, - digitTimeout: 100, - terminators: '#', - }, - listen: { - onStarted: async (p) => { - tap.ok(p.id, 'Prompt has started') - - // Send digits from the outbound call - const sendDigitResult = await outboundCall.sendDigits( - '1w2w3w#' - ) - tap.equal( - outboundCall.id, - sendDigitResult.id, - 'OutboundCall - SendDigit returns the same instance' - ) + const prompt = await call + .prompt({ + playlist: new Voice.Playlist({ volume: 1.0 }).add( + Voice.Playlist.TTS({ + text: 'Welcome to SignalWire! Please enter your 4 digits PIN', + }) + ), + digits: { + max: 4, + digitTimeout: 100, + terminators: '#', }, - onEnded: (p) => { - tap.ok(p.id, 'Prompt has ended') + listen: { + onStarted: async (p) => { + tap.ok(p.id, 'Prompt has started') + + // Send digits from the outbound call + const sendDigitResult = await outboundCall.sendDigits( + '1w2w3w#' + ) + tap.equal( + outboundCall.id, + sendDigitResult.id, + 'OutboundCall - SendDigit returns the same instance' + ) + }, + onEnded: (p) => { + tap.ok(p.id, 'Prompt has ended') + }, }, - }, - }) + }) + .onEnded() - const promptEndedResult = await prompt.ended() - tap.equal( - prompt.id, - promptEndedResult.id, - 'Prompt instances are the same' - ) tap.equal( - promptEndedResult.digits, + prompt.digits, '123', 'Prompt - correct digits were entered' ) @@ -183,11 +181,10 @@ const handler: TestHandler = ({ domainApp }) => { digits: '1', }) - const resultDetector = await detector.ended() // TODO: update this once the backend can send us the actual result tap.equal( // @ts-expect-error - resultDetector.detect.params.event, + detector.detect.params.event, 'finished', 'Peer - Detect digit is finished' ) diff --git a/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts index 0cfb72997..0e8e49f07 100644 --- a/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceCollectAllListeners.test.ts @@ -103,32 +103,34 @@ const handler: TestHandler = ({ domainApp }) => { }) // Caller starts a collect - const collect = await call.collect({ - initialTimeout: 4.0, - digits: { - max: 4, - digitTimeout: 10, - terminators: '#', - }, - partialResults: true, - continuous: false, - sendStartOfInput: true, - startInputTimers: false, - listen: { - // onUpdated runs three times since callee sends 4 digits (1234) - // 4th (final) digit emits onEnded - onUpdated: (collect) => { - tap.hasProps( - collect, - CALL_COLLECT_PROPS, - 'call.collect: Collect updated' - ) + const collect = await call + .collect({ + initialTimeout: 4.0, + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', }, - onFailed: (collect) => { - tap.notOk(collect.id, 'call.collect: Collect failed') + partialResults: true, + continuous: false, + sendStartOfInput: true, + startInputTimers: false, + listen: { + // onUpdated runs three times since callee sends 4 digits (1234) + // 4th (final) digit emits onEnded + onUpdated: (collect) => { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'call.collect: Collect updated' + ) + }, + onFailed: (collect) => { + tap.notOk(collect.id, 'call.collect: Collect failed') + }, }, - }, - }) + }) + .onStarted() tap.equal( call.id, collect.callId, diff --git a/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts index 521b99ea1..e776cb3e0 100644 --- a/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceCollectCallListeners.test.ts @@ -101,18 +101,20 @@ const handler: TestHandler = ({ domainApp }) => { }) // Caller starts a collect - const collect = await call.collect({ - initialTimeout: 4.0, - digits: { - max: 4, - digitTimeout: 10, - terminators: '#', - }, - partialResults: true, - continuous: false, - sendStartOfInput: true, - startInputTimers: false, - }) + const collect = await call + .collect({ + initialTimeout: 4.0, + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, + partialResults: true, + continuous: false, + sendStartOfInput: true, + startInputTimers: false, + }) + .onStarted() tap.equal( call.id, collect.callId, diff --git a/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts index 76c5e07a5..dd775604b 100644 --- a/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceCollectDialListeners.test.ts @@ -100,7 +100,7 @@ const handler: TestHandler = ({ domainApp }) => { tap.ok(call.id, 'Outbound - Call resolved') // Caller starts a collect - const collect = await call.collect({ + const collect = call.collect({ initialTimeout: 4.0, digits: { max: 4, @@ -114,7 +114,7 @@ const handler: TestHandler = ({ domainApp }) => { }) tap.equal( call.id, - collect.callId, + await collect.callId, 'Outbound - Collect returns the same call instance' ) diff --git a/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts index 2607af82e..2b28a657b 100644 --- a/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceCollectListeners.test.ts @@ -80,54 +80,60 @@ const handler: TestHandler = ({ domainApp }) => { tap.ok(call.id, 'Outbound - Call resolved') // Caller starts a collect - const collect = await call.collect({ - initialTimeout: 4.0, - digits: { - max: 4, - digitTimeout: 10, - terminators: '#', - }, - partialResults: true, - continuous: false, - sendStartOfInput: true, - startInputTimers: false, - listen: { - onStarted: (collect) => { - tap.hasProps( - collect, - CALL_COLLECT_PROPS, - 'call.collect: Collect started' - ) - tap.equal(collect.callId, call.id, 'call.collect: Correct call id') - }, - onInputStarted: (collect) => { - tap.hasProps( - collect, - CALL_COLLECT_PROPS, - 'call.collect: Collect input started' - ) - }, - // onUpdated runs three times since callee sends 4 digits (1234) - // 4th (final) digit emits onEnded - onUpdated: (collect) => { - tap.hasProps( - collect, - CALL_COLLECT_PROPS, - 'call.collect: Collect updated' - ) - }, - onFailed: (collect) => { - tap.notOk(collect.id, 'call.collect: Collect failed') + const collect = await call + .collect({ + initialTimeout: 4.0, + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', }, - onEnded: (collect) => { - tap.hasProps( - collect, - CALL_COLLECT_PROPS, - 'call.collect: Collect ended' - ) + partialResults: true, + continuous: false, + sendStartOfInput: true, + startInputTimers: false, + listen: { + onStarted: (collect) => { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'call.collect: Collect started' + ) + tap.equal( + collect.callId, + call.id, + 'call.collect: Correct call id' + ) + }, + onInputStarted: (collect) => { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'call.collect: Collect input started' + ) + }, + // onUpdated runs three times since callee sends 4 digits (1234) + // 4th (final) digit emits onEnded + onUpdated: (collect) => { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'call.collect: Collect updated' + ) + }, + onFailed: (collect) => { + tap.notOk(collect.id, 'call.collect: Collect failed') + }, + onEnded: (collect) => { + tap.hasProps( + collect, + CALL_COLLECT_PROPS, + 'call.collect: Collect ended' + ) + }, }, - }, - }) + }) + .onStarted() tap.equal( call.id, collect.callId, diff --git a/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts index 722ce9164..0952594fd 100644 --- a/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceDetectAllListeners.test.ts @@ -117,36 +117,38 @@ const handler: TestHandler = ({ domainApp }) => { }) // Start a detect - const detectDigit = await call.detectDigit({ - digits: '1234', - listen: { - onStarted: (detect) => { - tap.hasProps( - detect, - CALL_DETECT_PROPS, - 'call.detectDigit: Detect started' - ) - tap.equal( - detect.callId, - call.id, - 'call.detectDigit: Detect with correct call id' - ) - }, - // Update runs 4 times since callee send 4 digits - onUpdated: (detect) => { - tap.hasProps( - detect, - CALL_DETECT_PROPS, - 'call.detectDigit: Detect updated' - ) - tap.equal( - detect.callId, - call.id, - 'call.detectDigit: Detect with correct call id' - ) + const detectDigit = await call + .detectDigit({ + digits: '1234', + listen: { + onStarted: (detect) => { + tap.hasProps( + detect, + CALL_DETECT_PROPS, + 'call.detectDigit: Detect started' + ) + tap.equal( + detect.callId, + call.id, + 'call.detectDigit: Detect with correct call id' + ) + }, + // Update runs 4 times since callee send 4 digits + onUpdated: (detect) => { + tap.hasProps( + detect, + CALL_DETECT_PROPS, + 'call.detectDigit: Detect updated' + ) + tap.equal( + detect.callId, + call.id, + 'call.detectDigit: Detect with correct call id' + ) + }, }, - }, - }) + }) + .onStarted() tap.equal( call.id, detectDigit.callId, diff --git a/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts index ca540081a..db5821127 100644 --- a/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceDetectCallListeners.test.ts @@ -89,6 +89,9 @@ const handler: TestHandler = ({ domainApp }) => { onDetectStarted: (detect) => { tap.hasProps(detect, CALL_DETECT_PROPS, 'Detect started') tap.equal(detect.callId, call.id, 'Detect with correct call id') + + // Resolve the detect start promise + waitForDetectStartResolve!() }, // Update runs 4 times since callee send 4 digits onDetectUpdated: (detect) => { @@ -110,9 +113,6 @@ const handler: TestHandler = ({ domainApp }) => { detectDigit.callId, 'Outbound - Detect returns the same instance' ) - - // Resolve the detect start promise - waitForDetectStartResolve!() } catch (error) { console.error('VoiceDetectDialListeners error', error) reject(4) diff --git a/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts index f19bde4f5..3b6f7a074 100644 --- a/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceDetectDialListeners.test.ts @@ -82,6 +82,9 @@ const handler: TestHandler = ({ domainApp }) => { onDetectStarted: (detect) => { tap.hasProps(detect, CALL_DETECT_PROPS, 'Detect started') tap.equal(detect.callId, call.id, 'Detect with correct call id') + + // Resolve the detect start promise + waitForDetectStartResolve!() }, // Update runs 4 times since callee send 4 digits onDetectUpdated: (detect) => { @@ -97,17 +100,16 @@ const handler: TestHandler = ({ domainApp }) => { tap.ok(call.id, 'Outbound - Call resolved') // Start a detect - const detectDigit = await call.detectDigit({ - digits: '1234', - }) + const detectDigit = await call + .detectDigit({ + digits: '1234', + }) + .onEnded() tap.equal( call.id, detectDigit.callId, 'Outbound - Detect returns the same instance' ) - - // Resolve the detect start promise - waitForDetectStartResolve!() } catch (error) { console.error('VoiceDetectDialListeners error', error) reject(4) diff --git a/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts index 798aa6cee..4145c45f7 100644 --- a/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceDetectListeners.test.ts @@ -86,7 +86,7 @@ const handler: TestHandler = ({ domainApp }) => { tap.ok(call.id, 'Outbound - Call resolved') // Start a detect - const detectDigit = await call.detectDigit({ + const detectDigit = call.detectDigit({ digits: '1234', listen: { onStarted: (detect) => { @@ -97,7 +97,7 @@ const handler: TestHandler = ({ domainApp }) => { }) tap.equal( call.id, - detectDigit.callId, + await detectDigit.callId, 'Outbound - Detect returns the same instance' ) diff --git a/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts index 3d151fc90..2540b0d93 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackAllListeners.test.ts @@ -109,7 +109,7 @@ const handler: TestHandler = ({ domainApp }) => { }) // Play an audio - const play = await call.playAudio({ + const play = call.playAudio({ url: 'https://cdn.signalwire.com/default-music/welcome.mp3', listen: { onStarted: async (playback) => { @@ -142,7 +142,9 @@ const handler: TestHandler = ({ domainApp }) => { CALL_PLAYBACK_PROPS, 'play.listen: Playback ended' ) - tap.equal(playback.id, play.id, 'play.listen: Playback correct id') + + const playId = await play.id + tap.equal(playback.id, playId, 'play.listen: Playback correct id') tap.equal( playback.state, 'finished', diff --git a/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts index 0b2b7af46..cd0599db7 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackCallListeners.test.ts @@ -84,11 +84,13 @@ const handler: TestHandler = ({ domainApp }) => { }, }) - const play = await call.playAudio({ + const play = call.playAudio({ url: 'https://cdn.signalwire.com/default-music/welcome.mp3', }) - await play.stop() + if ((await play.state) === 'playing') { + await play.stop() + } } catch (error) { console.error('VoicePlaybackCallListeners error', error) reject(4) diff --git a/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts index 12d239f77..0a79acda3 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackDialListeners.test.ts @@ -81,9 +81,11 @@ const handler: TestHandler = ({ domainApp }) => { }) tap.ok(call.id, 'Outbound - Call resolved') - const play = await call.playAudio({ - url: 'https://cdn.signalwire.com/default-music/welcome.mp3', - }) + const play = await call + .playAudio({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + }) + .onStarted() await play.stop() } catch (error) { diff --git a/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts b/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts index 266bf52da..6b1e84270 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackListeners.test.ts @@ -67,26 +67,28 @@ const handler: TestHandler = ({ domainApp }) => { }) tap.ok(call.id, 'Outbound - Call resolved') - const play = await call.playTTS({ - text: 'This is a custom text to speech for test.', - listen: { - onStarted: (playback) => { - tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback started') - tap.equal(playback.state, 'playing', 'Playback correct state') - }, - onUpdated: (playback) => { - tap.notOk(playback.id, 'Playback updated') - }, - onFailed: (playback) => { - tap.notOk(playback.id, 'Playback failed') + const play = await call + .playTTS({ + text: 'This is a custom text to speech for test.', + listen: { + onStarted: (playback) => { + tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback started') + tap.equal(playback.state, 'playing', 'Playback correct state') + }, + onUpdated: (playback) => { + tap.notOk(playback.id, 'Playback updated') + }, + onFailed: (playback) => { + tap.notOk(playback.id, 'Playback failed') + }, + onEnded: (playback) => { + tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback ended') + tap.equal(playback.id, play.id, 'Playback correct id') + tap.equal(playback.state, 'finished', 'Playback correct state') + }, }, - onEnded: (playback) => { - tap.hasProps(playback, CALL_PLAYBACK_PROPS, 'Playback ended') - tap.equal(playback.id, play.id, 'Playback correct id') - tap.equal(playback.state, 'finished', 'Playback correct state') - }, - }, - }) + }) + .onStarted() const unsubPlay = await play.listen({ onStarted: (playback) => { diff --git a/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts b/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts index ac9c8a1f9..632172f1c 100644 --- a/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts +++ b/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts @@ -52,19 +52,25 @@ const handler: TestHandler = ({ domainApp }) => { }, }) - const earlyMedia = await call.playTTS({ - text: 'This is early media. I repeat: This is early media.', - listen: { - onStarted: (playback) => { - tap.hasProps( - playback, - CALL_PLAYBACK_PROPS, - 'Inbound - Playback started' - ) - tap.equal(playback.state, 'playing', 'Playback correct state') + const earlyMedia = await call + .playTTS({ + text: 'This is early media. I repeat: This is early media.', + listen: { + onStarted: (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'Inbound - Playback started' + ) + tap.equal( + playback.state, + 'playing', + 'Playback correct state' + ) + }, }, - }, - }) + }) + .onStarted() tap.equal( call.id, earlyMedia.callId, @@ -87,39 +93,43 @@ const handler: TestHandler = ({ domainApp }) => { ) // Play an invalid audio - const fakeAudio = await call.playAudio({ - url: 'https://cdn.fake.com/default-music/fake.mp3', - listen: { - onFailed: (playback) => { - tap.hasProps( - playback, - CALL_PLAYBACK_PROPS, - 'Inbound - fakeAudio playback failed' - ) - tap.equal(playback.state, 'error', 'Playback correct state') + const fakeAudio = await call + .playAudio({ + url: 'https://cdn.fake.com/default-music/fake.mp3', + listen: { + onFailed: (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'Inbound - fakeAudio playback failed' + ) + tap.equal(playback.state, 'error', 'Playback correct state') + }, }, - }, - }) + }) + .onStarted() await fakeAudio.ended() - const playback = await call.playTTS({ - text: 'Random TTS message while the call is up. Thanks and good bye!', - listen: { - onEnded: (playback) => { - tap.hasProps( - playback, - CALL_PLAYBACK_PROPS, - 'Inbound - Playback ended' - ) - tap.equal( - playback.state, - 'finished', - 'Playback correct state' - ) + const playback = await call + .playTTS({ + text: 'Random TTS message while the call is up. Thanks and good bye!', + listen: { + onEnded: (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'Inbound - Playback ended' + ) + tap.equal( + playback.state, + 'finished', + 'Playback correct state' + ) + }, }, - }, - }) + }) + .onStarted() await playback.ended() tap.equal(startedPlaybacks, 3, 'Inbound - Started playback count') @@ -160,19 +170,21 @@ const handler: TestHandler = ({ domainApp }) => { }) tap.ok(call.id, 'Outbound - Call resolved') - const playAudio = await call.playAudio({ - url: 'https://cdn.signalwire.com/default-music/welcome.mp3', - listen: { - onEnded: (playback) => { - tap.hasProps( - playback, - CALL_PLAYBACK_PROPS, - 'Outbound - Playback ended' - ) - tap.equal(playback.state, 'finished', 'Playback correct state') + const playAudio = await call + .playAudio({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + listen: { + onEnded: (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'Outbound - Playback ended' + ) + tap.equal(playback.state, 'finished', 'Playback correct state') + }, }, - }, - }) + }) + .onStarted() tap.equal( call.id, playAudio.callId, diff --git a/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts index 9d33cdaa6..ea4a1b40e 100644 --- a/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePromptAllListeners.test.ts @@ -110,42 +110,44 @@ const handler: TestHandler = ({ domainApp }) => { }, }) - const prompt = await call.promptRingtone({ - name: 'it', - duration: 10, - digits: { - max: 5, - digitTimeout: 2, - terminators: '#*', - }, - listen: { - onStarted: (prompt) => { - tap.hasProps( - prompt, - CALL_PROMPT_PROPS, - 'call.promptRingtone: Prompt started' - ) + const prompt = await call + .promptRingtone({ + name: 'it', + duration: 10, + digits: { + max: 5, + digitTimeout: 2, + terminators: '#*', }, - onUpdated: (prompt) => { - tap.notOk(prompt.id, 'call.promptRingtone: Prompt updated') + listen: { + onStarted: (prompt) => { + tap.hasProps( + prompt, + CALL_PROMPT_PROPS, + 'call.promptRingtone: Prompt started' + ) + }, + onUpdated: (prompt) => { + tap.notOk(prompt.id, 'call.promptRingtone: Prompt updated') + }, + onFailed: (prompt) => { + tap.notOk(prompt.id, 'call.promptRingtone: Prompt failed') + }, + onEnded: (_prompt) => { + tap.hasProps( + _prompt, + CALL_PROMPT_PROPS, + 'call.promptRingtone: Prompt ended' + ) + tap.equal( + _prompt.id, + prompt.id, + 'call.promptRingtone: Prompt correct id' + ) + }, }, - onFailed: (prompt) => { - tap.notOk(prompt.id, 'call.promptRingtone: Prompt failed') - }, - onEnded: (_prompt) => { - tap.hasProps( - _prompt, - CALL_PROMPT_PROPS, - 'call.promptRingtone: Prompt ended' - ) - tap.equal( - _prompt.id, - prompt.id, - 'call.promptRingtone: Prompt correct id' - ) - }, - }, - }) + }) + .onStarted() const unsubPrompt = await prompt.listen({ onStarted: (prompt) => { diff --git a/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts index 424febbf1..723bc6f94 100644 --- a/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePromptCallListeners.test.ts @@ -83,14 +83,16 @@ const handler: TestHandler = ({ domainApp }) => { }, }) - const prompt = await call.promptAudio({ - url: 'https://cdn.signalwire.com/default-music/welcome.mp3', - digits: { - max: 4, - digitTimeout: 10, - terminators: '#', - }, - }) + const prompt = await call + .promptAudio({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, + }) + .onStarted() tap.equal( call.id, prompt.callId, diff --git a/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts index 88b4ecf91..01282024c 100644 --- a/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePromptDialListeners.test.ts @@ -82,18 +82,21 @@ const handler: TestHandler = ({ domainApp }) => { tap.ok(call.id, 'Outbound - Call resolved') // Caller starts a prompt - const prompt = await call.prompt({ - playlist: new Voice.Playlist({ volume: 1.0 }).add( - Voice.Playlist.TTS({ - text: 'Welcome to SignalWire! Please enter your 4 digits PIN', - }) - ), - digits: { - max: 4, - digitTimeout: 10, - terminators: '#', - }, - }) + const prompt = await call + .prompt({ + playlist: new Voice.Playlist({ volume: 1.0 }).add( + Voice.Playlist.TTS({ + text: 'Welcome to SignalWire! Please enter your 4 digits PIN', + }) + ), + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, + }) + .onStarted() + tap.equal( call.id, prompt.callId, diff --git a/internal/e2e-realtime-api/src/voicePromptListeners.test.ts b/internal/e2e-realtime-api/src/voicePromptListeners.test.ts index 4f23637dc..7e2e68a86 100644 --- a/internal/e2e-realtime-api/src/voicePromptListeners.test.ts +++ b/internal/e2e-realtime-api/src/voicePromptListeners.test.ts @@ -64,7 +64,7 @@ const handler: TestHandler = ({ domainApp }) => { }) tap.ok(call.id, 'Outbound - Call resolved') - const prompt = await call.promptTTS({ + const prompt = call.promptTTS({ text: 'Welcome to SignalWire! Please enter your 4 digits PIN', digits: { max: 4, @@ -79,13 +79,13 @@ const handler: TestHandler = ({ domainApp }) => { 'call.promptTTS: Prompt started' ) }, - onUpdated: (prompt) => { - tap.notOk(prompt.id, 'call.promptTTS: Prompt updated') + onUpdated: (_prompt) => { + tap.notOk(_prompt.id, 'call.promptTTS: Prompt updated') }, - onFailed: (prompt) => { - tap.notOk(prompt.id, 'call.promptTTS: Prompt failed') + onFailed: (_prompt) => { + tap.notOk(_prompt.id, 'call.promptTTS: Prompt failed') }, - onEnded: (_prompt) => { + onEnded: async (_prompt) => { tap.hasProps( _prompt, CALL_PROMPT_PROPS, @@ -93,7 +93,7 @@ const handler: TestHandler = ({ domainApp }) => { ) tap.equal( _prompt.id, - prompt.id, + await prompt.id, 'call.promptTTS: Prompt correct id' ) }, @@ -101,7 +101,7 @@ const handler: TestHandler = ({ domainApp }) => { }) tap.equal( call.id, - prompt.callId, + await prompt.callId, 'Outbound - Prompt returns the same call instance' ) @@ -116,13 +116,17 @@ const handler: TestHandler = ({ domainApp }) => { onFailed: (prompt) => { tap.notOk(prompt.id, 'prompt.listen: Prompt failed') }, - onEnded: (_prompt) => { + onEnded: async (_prompt) => { tap.hasProps( _prompt, CALL_PROMPT_PROPS, 'prompt.listen: Prompt ended' ) - tap.equal(_prompt.id, prompt.id, 'prompt.listen: Prompt correct id') + tap.equal( + _prompt.id, + await prompt.id, + 'prompt.listen: Prompt correct id' + ) }, }) diff --git a/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts index 0316e9bf1..bf95c3835 100644 --- a/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordAllListeners.test.ts @@ -19,7 +19,7 @@ const handler: TestHandler = ({ domainApp }) => { project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, debug: { - logWsTraffic: true, + // logWsTraffic: true, }, }) @@ -107,7 +107,7 @@ const handler: TestHandler = ({ domainApp }) => { }, }) - const record = await call.recordAudio({ + const record = call.recordAudio({ listen: { onStarted: async (recording) => { tap.hasProps( @@ -139,9 +139,11 @@ const handler: TestHandler = ({ domainApp }) => { CALL_RECORD_PROPS, 'record.listen: Recording ended' ) + + const recordId = await record.id tap.equal( recording.id, - record.id, + recordId, 'record.listen: Recording correct id' ) tap.equal( diff --git a/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts index 1821ec61a..49cab3f44 100644 --- a/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordCallListeners.test.ts @@ -81,9 +81,11 @@ const handler: TestHandler = ({ domainApp }) => { }, }) - const record = await call.recordAudio() + const record = call.recordAudio() - await record.stop() + if ((await record.state) === 'recording') { + await record.stop() + } } catch (error) { console.error('VoiceRecordCallListeners error', error) reject(4) diff --git a/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts index 26e84c0b2..ea783082e 100644 --- a/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordDialListeners.test.ts @@ -79,7 +79,7 @@ const handler: TestHandler = ({ domainApp }) => { }) tap.ok(call.id, 'Outbound - Call resolved') - const record = await call.recordAudio() + const record = await call.recordAudio().onStarted() await record.stop() } catch (error) { diff --git a/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts index dc95f4d13..8d5ad31b1 100644 --- a/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordListeners.test.ts @@ -67,7 +67,7 @@ const handler: TestHandler = ({ domainApp }) => { }) tap.ok(call.id, 'Outbound - Call resolved') - const record = await call.recordAudio({ + const record = call.recordAudio({ listen: { onStarted: (recording) => { tap.hasProps(recording, CALL_RECORD_PROPS, 'Recording started') @@ -78,7 +78,9 @@ const handler: TestHandler = ({ domainApp }) => { }, onEnded: async (recording) => { tap.hasProps(recording, CALL_RECORD_PROPS, 'Recording ended') - tap.equal(recording.id, record.id, 'Recording correct id') + + const recordId = await record.id + tap.equal(recording.id, recordId, 'Recording correct id') tap.equal(recording.state, 'finished', 'Recording correct state') }, }, @@ -94,7 +96,9 @@ const handler: TestHandler = ({ domainApp }) => { }, onEnded: async (recording) => { tap.hasProps(recording, CALL_RECORD_PROPS, 'Recording ended') - tap.equal(recording.id, record.id, 'Recording correct id') + + const recordId = await record.id + tap.equal(recording.id, recordId, 'Recording correct id') tap.equal(recording.state, 'finished', 'Recording correct state') await call.hangup() diff --git a/internal/e2e-realtime-api/src/voiceRecordMultiple.test.ts b/internal/e2e-realtime-api/src/voiceRecordMultiple.test.ts index 0899f5af1..2137aa995 100644 --- a/internal/e2e-realtime-api/src/voiceRecordMultiple.test.ts +++ b/internal/e2e-realtime-api/src/voiceRecordMultiple.test.ts @@ -36,23 +36,25 @@ const handler: TestHandler = ({ domainApp }) => { 'Inbound - Call answered gets the same instance' ) - const record = await call.recordAudio({ - terminators: '#', - listen: { - async onFailed(recording) { - tap.hasProps( - recording, - CALL_RECORD_PROPS, - 'Inbound - Recording failed' - ) - tap.equal( - recording.state, - 'no_input', - 'Recording correct state' - ) + const record = await call + .recordAudio({ + terminators: '#', + listen: { + async onFailed(recording) { + tap.hasProps( + recording, + CALL_RECORD_PROPS, + 'Inbound - Recording failed' + ) + tap.equal( + recording.state, + 'no_input', + 'Recording correct state' + ) + }, }, - }, - }) + }) + .onStarted() tap.equal( call.id, record.callId, @@ -93,9 +95,12 @@ const handler: TestHandler = ({ domainApp }) => { }) tap.ok(call.id, 'Outbound - Call resolved') - const record = await call.recordAudio({ - terminators: '*', - }) + const record = await call + .recordAudio({ + terminators: '*', + }) + .onStarted() + tap.equal( call.id, record.callId, diff --git a/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts index 9cef80fca..f3a26929e 100644 --- a/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts +++ b/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts @@ -20,7 +20,7 @@ const handler: TestHandler = ({ domainApp }) => { project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, debug: { - // logWsTraffic: true, + logWsTraffic: true, }, }) @@ -80,22 +80,24 @@ const handler: TestHandler = ({ domainApp }) => { try { // Start an audio tap - const tapAudio = await call.tapAudio({ - direction: 'both', - device: { - type: 'ws', - uri: 'wss://example.domain.com/endpoint', - }, - listen: { - onStarted(callTap) { - tap.hasProps( - callTap, - CALL_TAP_PROPS, - 'call.tapAudio: Tap started' - ) + const tapAudio = await call + .tapAudio({ + direction: 'both', + device: { + type: 'ws', + uri: 'wss://example.domain.com/endpoint', }, - }, - }) + listen: { + onStarted(callTap) { + tap.hasProps( + callTap, + CALL_TAP_PROPS, + 'call.tapAudio: Tap started' + ) + }, + }, + }) + .onStarted() const unsubTapAudio = await tapAudio.listen({ onEnded(callTap) { @@ -104,16 +106,10 @@ const handler: TestHandler = ({ domainApp }) => { }) // Tap should fail due to wrong WSS - reject() + reject(4) } catch (error) { tap.ok(error, 'Outbound - Tap error') - await unsubVoice() - - await unsubCall() - - await call.hangup() - await client.disconnect() resolve(0) diff --git a/internal/playground-realtime-api/src/voice-dtmf-loop/index.ts b/internal/playground-realtime-api/src/voice-dtmf-loop/index.ts index cf69c3d55..e9fe546ff 100644 --- a/internal/playground-realtime-api/src/voice-dtmf-loop/index.ts +++ b/internal/playground-realtime-api/src/voice-dtmf-loop/index.ts @@ -22,7 +22,7 @@ async function run() { digitTimeout: 5, }, }) - const { type, digits } = await prompt.ended() + const { type, digits } = prompt return [type, digits] } @@ -43,14 +43,12 @@ async function run() { const playback = await call.playTTS({ text: 'You have run out of attempts. Goodbye', }) - await playback.ended() await call.hangup() } } else { const playback = await call.playTTS({ text: 'Good choice! Goodbye and thanks', }) - await playback.ended() await call.hangup() } } diff --git a/internal/playground-realtime-api/src/voice-inbound/index.ts b/internal/playground-realtime-api/src/voice-inbound/index.ts index aa9ac5a61..14ef2cab9 100644 --- a/internal/playground-realtime-api/src/voice-inbound/index.ts +++ b/internal/playground-realtime-api/src/voice-inbound/index.ts @@ -25,7 +25,6 @@ async function run() { text: "Hello! Welcome to Knee Rub's Weather Helpline. What place would you like to know the weather of?", gender: 'male', }) - await pb.ended() console.log('Welcome text ok') const prompt = await call.promptTTS({ @@ -35,7 +34,7 @@ async function run() { digitTimeout: 15, }, }) - const { type, digits, terminator } = await prompt.ended() + const { type, digits, terminator } = prompt console.log('Received digits', type, digits, terminator) } catch (error) { console.error('Error answering inbound call', error) diff --git a/internal/playground-realtime-api/src/voice/index.ts b/internal/playground-realtime-api/src/voice/index.ts index 1a238b67a..0c25d6bcd 100644 --- a/internal/playground-realtime-api/src/voice/index.ts +++ b/internal/playground-realtime-api/src/voice/index.ts @@ -86,9 +86,8 @@ async function run() { if (RUN_DETECTOR) { // See the `call.received` handler - const detect = await call.detectDigit() - const result = await detect.ended() - console.log('Detect Result', result.type) + const detectResult = await call.detectDigit() + console.log('Detect Result', detectResult.type) await sleep() } @@ -132,11 +131,7 @@ async function run() { text: 'Thank you, you are now disconnected from the peer', }) ) - const pb = await call.play({ playlist }) - - console.log('call.play') - - await pb.ended() + const pb = await call.play({ playlist }).onEnded() console.log('pb.ended') } catch (error) { @@ -144,21 +139,23 @@ async function run() { } try { - const tap = await call.tapAudio({ - direction: 'both', - device: { - type: 'ws', - uri: 'wss://example.domain.com/endpoint', - }, - listen: { - onStarted(p) { - console.log('>> tap.started', p.id, p.state) + const tap = await call + .tapAudio({ + direction: 'both', + device: { + type: 'ws', + uri: 'wss://example.domain.com/endpoint', }, - onEnded(p) { - console.log('>> tap.ended', p.id, p.state) + listen: { + onStarted(p) { + console.log('>> tap.started', p.id, p.state) + }, + onEnded(p) { + console.log('>> tap.ended', p.id, p.state) + }, }, - }, - }) + }) + .onStarted() await sleep(1000) console.log('>> Trying to stop', tap.id, tap.state) @@ -167,40 +164,42 @@ async function run() { console.log('Tap failed', error) } - const prompt = await call.prompt({ - playlist: new Voice.Playlist({ volume: 1.0 }).add( - Voice.Playlist.TTS({ - text: 'Welcome to SignalWire! Please enter your 4 digits PIN', - }) - ), - digits: { - max: 4, - digitTimeout: 10, - terminators: '#', - }, - listen: { - onStarted(p) { - console.log('>> prompt.started', p.id) - }, - onUpdated(p) { - console.log('>> prompt.updated', p.id) - }, - onFailed(p) { - console.log('>> prompt.failed', p.id, p.reason) + const prompt = await call + .prompt({ + playlist: new Voice.Playlist({ volume: 1.0 }).add( + Voice.Playlist.TTS({ + text: 'Welcome to SignalWire! Please enter your 4 digits PIN', + }) + ), + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', }, - onEnded(p) { - console.log( - '>> prompt.ended', - p.id, - p.type, - 'Digits: ', - p.digits, - 'Terminator', - p.terminator - ) + listen: { + onStarted(p) { + console.log('>> prompt.started', p.id) + }, + onUpdated(p) { + console.log('>> prompt.updated', p.id) + }, + onFailed(p) { + console.log('>> prompt.failed', p.id, p.reason) + }, + onEnded(p) { + console.log( + '>> prompt.ended', + p.id, + p.type, + 'Digits: ', + p.digits, + 'Terminator', + p.terminator + ) + }, }, - }, - }) + }) + .onStarted() /** Wait for the result - sync way */ // const { type, digits, terminator } = await prompt.ended() @@ -212,26 +211,28 @@ async function run() { await prompt.stop() console.log('Prompt STOPPED!', prompt.id) - const recording = await call.recordAudio({ - listen: { - onStarted(r) { - console.log('>> recording.started', r.id) - }, - onFailed(r) { - console.log('>> recording.failed', r.id, r.state) - }, - onEnded(r) { - console.log( - '>> recording.ended', - r.id, - r.state, - r.size, - r.duration, - r.url - ) + const recording = await call + .recordAudio({ + listen: { + onStarted(r) { + console.log('>> recording.started', r.id) + }, + onFailed(r) { + console.log('>> recording.failed', r.id, r.state) + }, + onEnded(r) { + console.log( + '>> recording.ended', + r.id, + r.state, + r.size, + r.duration, + r.url + ) + }, }, - }, - }) + }) + .onStarted() console.log('Recording STARTED!', recording.id) const playlist = new Voice.Playlist({ volume: 2 }) @@ -250,20 +251,22 @@ async function run() { text: 'Thank you, you are now disconnected from the peer', }) ) - const playback = await call.play({ - playlist, - listen: { - onStarted(p) { - console.log('>> playback.started', p.id, p.state) - }, - onUpdated(p) { - console.log('>> playback.updated', p.id, p.state) - }, - onEnded(p) { - console.log('>> playback.ended', p.id, p.state) + const playback = await call + .play({ + playlist, + listen: { + onStarted(p) { + console.log('>> playback.started', p.id, p.state) + }, + onUpdated(p) { + console.log('>> playback.updated', p.id, p.state) + }, + onEnded(p) { + console.log('>> playback.ended', p.id, p.state) + }, }, - }, - }) + }) + .onStarted() // To wait for the playback to end (without pause/resume/stop it) // await playback.ended() diff --git a/packages/core/src/types/utils.ts b/packages/core/src/types/utils.ts index cb3866ebb..c8c713442 100644 --- a/packages/core/src/types/utils.ts +++ b/packages/core/src/types/utils.ts @@ -133,3 +133,10 @@ export type AllOrNone> = * Make one or more properties optional */ export type Optional = Pick, K> & Omit + +/** + * Promisify all the properties + */ +export type Promisify = { + [K in keyof T]: Promise +} diff --git a/packages/core/src/types/voiceCall.ts b/packages/core/src/types/voiceCall.ts index 2b4355f6c..1d43d71c9 100644 --- a/packages/core/src/types/voiceCall.ts +++ b/packages/core/src/types/voiceCall.ts @@ -1171,7 +1171,7 @@ export type Detector = export type DetectorResult = Detector['params']['event'] -type CallingCallDetectType = Detector['type'] +export type CallingCallDetectType = Detector['type'] export interface CallingCallDetectEventParams { node_id: string call_id: string diff --git a/packages/realtime-api/src/voice/Call.ts b/packages/realtime-api/src/voice/Call.ts index 47a81c353..413e893f2 100644 --- a/packages/realtime-api/src/voice/Call.ts +++ b/packages/realtime-api/src/voice/Call.ts @@ -48,13 +48,13 @@ import { } from './workers' import { Playlist } from './Playlist' import { Voice } from './Voice' -import { CallPlayback } from './CallPlayback' -import { CallRecording } from './CallRecording' -import { CallPrompt } from './CallPrompt' -import { CallCollect } from './CallCollect' -import { CallTap } from './CallTap' +import { CallPlayback, decoratePlaybackPromise } from './CallPlayback' +import { CallRecording, decorateRecordingPromise } from './CallRecording' +import { CallPrompt, decoratePromptPromise } from './CallPrompt' +import { CallCollect, decorateCollectPromise } from './CallCollect' +import { CallTap, decorateTapPromise } from './CallTap' import { DeviceBuilder } from './DeviceBuilder' -import { CallDetect } from './CallDetect' +import { CallDetect, decorateDetectPromise } from './CallDetect' interface CallOptions { voice: Voice @@ -356,7 +356,7 @@ export class Call extends ListenSubscriber< * ``` */ play(params: CallPlayMethodParams) { - return new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { const { playlist, listen } = params if (!this.callId || !this.nodeId) { @@ -406,6 +406,8 @@ export class Call extends ListenSubscriber< reject(e) }) }) + + return decoratePlaybackPromise.call(this, promise) } /** @@ -415,7 +417,6 @@ export class Call extends ListenSubscriber< * * ```js * const playback = await call.playAudio({ url: 'https://cdn.signalwire.com/default-music/welcome.mp3' }); - * await playback.ended(); * ``` */ playAudio(params: CallPlayAudioMethodarams) { @@ -431,7 +432,6 @@ export class Call extends ListenSubscriber< * * ```js * const playback = await call.playSilence({ duration: 3 }); - * await playback.ended(); * ``` */ playSilence(params: CallPlaySilenceMethodParams) { @@ -447,7 +447,6 @@ export class Call extends ListenSubscriber< * * ```js * const playback = await call.playRingtone({ name: 'it' }); - * await playback.ended(); * ``` */ playRingtone(params: CallPlayRingtoneMethodParams) { @@ -463,7 +462,6 @@ export class Call extends ListenSubscriber< * * ```js * const playback = await call.playTTS({ text: 'Welcome to SignalWire!' }); - * await playback.ended(); * ``` */ playTTS(params: CallPlayTTSMethodParams) { @@ -476,7 +474,7 @@ export class Call extends ListenSubscriber< * Generic method to record a call. Please see {@link recordAudio}. */ record(params: CallRecordMethodParams) { - return new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { const { audio, listen } = params if (!this.callId || !this.nodeId) { @@ -484,7 +482,6 @@ export class Call extends ListenSubscriber< } const resolveHandler = (callRecording: CallRecording) => { - this.off('recording.failed', rejectHandler) resolve(callRecording) } @@ -526,6 +523,8 @@ export class Call extends ListenSubscriber< reject(e) }) }) + + return decorateRecordingPromise.call(this, promise) } /** @@ -535,7 +534,6 @@ export class Call extends ListenSubscriber< * * ```js * const recording = await call.recordAudio({ direction: 'both' }) - * await recording.stop() * ``` */ recordAudio(params: CallRecordAudioMethodParams = {}) { @@ -550,7 +548,7 @@ export class Call extends ListenSubscriber< * Generic method to prompt the user for input. Please see {@link promptAudio}, {@link promptRingtone}, {@link promptTTS}. */ prompt(params: CallPromptMethodParams) { - return new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { const { listen, ...rest } = params if (!this.callId || !this.nodeId) { @@ -618,6 +616,8 @@ export class Call extends ListenSubscriber< reject(e) }) }) + + return decoratePromptPromise.call(this, promise) } /** @@ -805,12 +805,10 @@ export class Call extends ListenSubscriber< * uri: 'wss://example.domain.com/endpoint', * }, * }) - * - * await tap.stop() * ``` */ tap(params: CallTapMethodParams) { - return new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { if (!this.callId || !this.nodeId) { reject(new Error(`Can't call tap() on a call not established yet.`)) } @@ -871,6 +869,8 @@ export class Call extends ListenSubscriber< reject(e) }) }) + + return decorateTapPromise.call(this, promise) } /** @@ -1099,7 +1099,7 @@ export class Call extends ListenSubscriber< * Generic method. Please see {@link amd}, {@link detectFax}, {@link detectDigit}. */ detect(params: CallDetectMethodParams) { - return new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { if (!this.callId || !this.nodeId) { reject(new Error(`Can't call detect() on a call not established yet.`)) } @@ -1152,6 +1152,8 @@ export class Call extends ListenSubscriber< reject(e) }) }) + + return decorateDetectPromise.call(this, promise) } /** @@ -1235,7 +1237,7 @@ export class Call extends ListenSubscriber< * ``` */ collect(params: CallCollectMethodParams) { - return new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { const { listen, ...rest } = params if (!this.callId || !this.nodeId) { @@ -1299,6 +1301,8 @@ export class Call extends ListenSubscriber< reject(e) }) }) + + return decorateCollectPromise.call(this, promise) } /** diff --git a/packages/realtime-api/src/voice/CallCollect.test.ts b/packages/realtime-api/src/voice/CallCollect.test.ts deleted file mode 100644 index 6704f6a53..000000000 --- a/packages/realtime-api/src/voice/CallCollect.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { createClient } from '../client/createClient' -import { CallCollect } from './CallCollect' -import { Call } from './Call' -import { Voice } from './Voice' -import { EventEmitter } from '@signalwire/core' - -describe('CallCollect', () => { - let voice: Voice - let call: Call - let callCollect: CallCollect - - const userOptions = { - host: 'example.com', - project: 'example.project', - token: 'example.token', - } - const swClientMock = { - userOptions, - client: createClient(userOptions), - } - - beforeEach(() => { - // @ts-expect-error - voice = new Voice(swClientMock) - - call = new Call({ voice }) - - callCollect = new CallCollect({ - call, - // @ts-expect-error - payload: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - }) - - // @ts-expect-error - callCollect._client.execute = jest.fn() - }) - - it('should have an event emitter', () => { - expect(callCollect['emitter']).toBeInstanceOf(EventEmitter) - }) - - it('should declare the correct event map', () => { - const expectedEventMap = { - onStarted: 'collect.started', - onInputStarted: 'collect.startOfInput', - onUpdated: 'collect.updated', - onFailed: 'collect.failed', - onEnded: 'collect.ended', - } - expect(callCollect['_eventMap']).toEqual(expectedEventMap) - }) - - it('should attach all listeners', () => { - // @ts-expect-error - callCollect = new CallCollect({ - call, - listeners: { - onStarted: () => {}, - onInputStarted: () => {}, - onUpdated: () => {}, - onFailed: () => {}, - onEnded: () => {}, - }, - }) - - // @ts-expect-error - expect(callCollect.emitter.eventNames()).toStrictEqual([ - 'collect.started', - 'collect.startOfInput', - 'collect.updated', - 'collect.failed', - 'collect.ended', - ]) - }) - - it('should control an active collect action', async () => { - const baseExecuteParams = { - method: '', - params: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - } - - await callCollect.stop() - // @ts-expect-error - expect(callCollect._client.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.collect.stop', - }) - - await callCollect.startInputTimers() - // @ts-expect-error - expect(callCollect._client.execute).toHaveBeenLastCalledWith({ - method: 'calling.collect.start_input_timers', - params: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - }) - }) -}) diff --git a/packages/realtime-api/src/voice/CallCollect/CallCollect.test.ts b/packages/realtime-api/src/voice/CallCollect/CallCollect.test.ts new file mode 100644 index 000000000..7e558aa4f --- /dev/null +++ b/packages/realtime-api/src/voice/CallCollect/CallCollect.test.ts @@ -0,0 +1,193 @@ +import { createClient } from '../../client/createClient' +import { CallCollect } from './CallCollect' +import { Call } from '../Call' +import { Voice } from '../Voice' +import { EventEmitter } from '@signalwire/core' +import { + decorateCollectPromise, + methods, + getters, +} from './decorateCollectPromise' + +describe('CallCollect', () => { + let voice: Voice + let call: Call + let callCollect: CallCollect + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + + callCollect = new CallCollect({ + call, + // @ts-expect-error + payload: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + }) + + // @ts-expect-error + callCollect._client.execute = jest.fn() + }) + + it('should have an event emitter', () => { + expect(callCollect['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'collect.started', + onInputStarted: 'collect.startOfInput', + onUpdated: 'collect.updated', + onFailed: 'collect.failed', + onEnded: 'collect.ended', + } + expect(callCollect['_eventMap']).toEqual(expectedEventMap) + }) + + it('should attach all listeners', () => { + // @ts-expect-error + callCollect = new CallCollect({ + call, + listeners: { + onStarted: () => {}, + onInputStarted: () => {}, + onUpdated: () => {}, + onFailed: () => {}, + onEnded: () => {}, + }, + }) + + // @ts-expect-error + expect(callCollect.emitter.eventNames()).toStrictEqual([ + 'collect.started', + 'collect.startOfInput', + 'collect.updated', + 'collect.failed', + 'collect.ended', + ]) + }) + + it('should control an active collect action', async () => { + const baseExecuteParams = { + method: '', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + } + + await callCollect.stop() + // @ts-expect-error + expect(callCollect._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.collect.stop', + }) + + await callCollect.startInputTimers() + // @ts-expect-error + expect(callCollect._client.execute).toHaveBeenLastCalledWith({ + method: 'calling.collect.start_input_timers', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + }) + }) + + it('should throw an error on methods if collect has ended', async () => { + callCollect.setPayload({ + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + result: { + type: 'error', + }, + }) + + await expect(callCollect.stop()).rejects.toThrowError('Action has ended') + await expect(callCollect.startInputTimers()).rejects.toThrowError( + 'Action has ended' + ) + }) + + describe('decorateCollectPromise', () => { + it('expose correct properties before resolve', () => { + const innerPromise = Promise.resolve(callCollect) + + const decoratedPromise = decorateCollectPromise.call(call, innerPromise) + + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise.onStarted()).toBeInstanceOf(Promise) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise.onEnded()).toBeInstanceOf(Promise) + methods.forEach((method) => { + expect(decoratedPromise).toHaveProperty(method, expect.any(Function)) + // @ts-expect-error + expect(decoratedPromise[method]()).toBeInstanceOf(Promise) + }) + getters.forEach((getter) => { + expect(decoratedPromise).toHaveProperty(getter) + // @ts-expect-error + expect(decoratedPromise[getter]).toBeInstanceOf(Promise) + }) + }) + + it('expose correct properties after resolve', async () => { + const innerPromise = Promise.resolve(callCollect) + + const decoratedPromise = decorateCollectPromise.call(call, innerPromise) + + // Simulate the collect ended event + call.emit('collect.ended', callCollect) + + const ended = await decoratedPromise + + expect(ended).not.toHaveProperty('onStarted', expect.any(Function)) + expect(ended).not.toHaveProperty('onEnded', expect.any(Function)) + methods.forEach((method) => { + expect(ended).toHaveProperty(method, expect.any(Function)) + }) + getters.forEach((getter) => { + expect(ended).toHaveProperty(getter) + // @ts-expect-error + expect(ended[getter]).not.toBeInstanceOf(Promise) + }) + }) + + it('resolves when collect ends', async () => { + const innerPromise = Promise.resolve(callCollect) + + const decoratedPromise = decorateCollectPromise.call(call, innerPromise) + + // Simulate the collect ended event + call.emit('collect.ended', callCollect) + + await expect(decoratedPromise).resolves.toEqual(expect.any(CallCollect)) + }) + + it('rejects on inner promise rejection', async () => { + const innerPromise = Promise.reject(new Error('Tap failed')) + + const decoratedPromise = decorateCollectPromise.call(call, innerPromise) + + await expect(decoratedPromise).rejects.toThrow('Tap failed') + }) + }) +}) diff --git a/packages/realtime-api/src/voice/CallCollect.ts b/packages/realtime-api/src/voice/CallCollect/CallCollect.ts similarity index 82% rename from packages/realtime-api/src/voice/CallCollect.ts rename to packages/realtime-api/src/voice/CallCollect/CallCollect.ts index 11a3f01cf..606c97c67 100644 --- a/packages/realtime-api/src/voice/CallCollect.ts +++ b/packages/realtime-api/src/voice/CallCollect/CallCollect.ts @@ -3,13 +3,13 @@ import { CallingCallCollectEndState, CallingCallCollectEventParams, } from '@signalwire/core' -import { ListenSubscriber } from '../ListenSubscriber' -import { Call } from './Call' +import { ListenSubscriber } from '../../ListenSubscriber' import { CallCollectEvents, CallCollectListeners, CallCollectListenersEventsMapping, -} from '../types' +} from '../../types' +import { Call } from '../Call' export interface CallCollectOptions { call: Call @@ -123,33 +123,44 @@ export class CallCollect return this._payload.final } + get hasEnded() { + if ( + this.state !== 'collecting' && + this.final !== false && + ENDED_STATES.includes(this.result?.type as CallingCallCollectEndState) + ) { + return true + } + return false + } + /** @internal */ setPayload(payload: CallingCallCollectEventParams) { this._payload = payload } async stop() { - // Execute stop only if we don't have result yet - if (!this.result) { - await this._client.execute({ - method: 'calling.collect.stop', - params: { - node_id: this.nodeId, - call_id: this.callId, - control_id: this.controlId, - }, - }) + if (this.hasEnded) { + throw new Error('Action has ended') } - /** - * TODO: we should wait for the prompt to be finished to allow - * the CallCollect/Proxy object to update the payload properly - */ + await this._client.execute({ + method: 'calling.collect.stop', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: this.controlId, + }, + }) return this } async startInputTimers() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + await this._client.execute({ method: 'calling.collect.start_input_timers', params: { @@ -163,15 +174,6 @@ export class CallCollect } ended() { - // Resolve the promise if the collect has already ended - if ( - this.state != 'collecting' && - this.final !== false && - ENDED_STATES.includes(this.result?.type as CallingCallCollectEndState) - ) { - return Promise.resolve(this) - } - return new Promise((resolve) => { const handler = () => { this.off('collect.ended', handler) @@ -189,9 +191,7 @@ export class CallCollect this.once('collect.failed', handler) // Resolve the promise if the collect has already ended - if ( - ENDED_STATES.includes(this.result?.type as CallingCallCollectEndState) - ) { + if (this.hasEnded) { handler() } }) diff --git a/packages/realtime-api/src/voice/CallCollect/decorateCollectPromise.ts b/packages/realtime-api/src/voice/CallCollect/decorateCollectPromise.ts new file mode 100644 index 000000000..6453f485a --- /dev/null +++ b/packages/realtime-api/src/voice/CallCollect/decorateCollectPromise.ts @@ -0,0 +1,60 @@ +import { CallingCallCollectResult, Promisify } from '@signalwire/core' +import { Call } from '../Call' +import { CallCollect } from './CallCollect' +import { decoratePromise } from '../decoratePromise' +import { CallCollectListeners } from '../../types' + +export interface CallCollectEnded { + id: string + callId: string + nodeId: string + controlId: string + result?: CallingCallCollectResult + type?: CallingCallCollectResult['type'] + reason?: CallingCallCollectResult['type'] + digits?: string + speech?: string + terminator?: string + text?: string + confidence?: number +} +export interface CallCollectPromise + extends Promise, + Promisify { + onStarted: () => Promise + onEnded: () => Promise + listen: (listeners: CallCollectListeners) => Promise<() => Promise> + stop: () => Promise + startInputTimers: () => Promise + ended: () => Promise +} + +export const getters = [ + 'id', + 'callId', + 'nodeId', + 'controlId', + 'result', + 'type', + 'reason', + 'digits', + 'speech', + 'terminator', + 'text', + 'confidence', +] + +export const methods = ['stop', 'startInputTimers', 'ended'] + +export function decorateCollectPromise( + this: Call, + innerPromise: Promise +) { + // prettier-ignore + return (decoratePromise).call(this, { + promise: innerPromise, + namespace: 'collect', + methods, + getters, + }) as CallCollectPromise +} diff --git a/packages/realtime-api/src/voice/CallCollect/index.ts b/packages/realtime-api/src/voice/CallCollect/index.ts new file mode 100644 index 000000000..e1661d0e3 --- /dev/null +++ b/packages/realtime-api/src/voice/CallCollect/index.ts @@ -0,0 +1,3 @@ +export * from './CallCollect' +export * from './decorateCollectPromise' +export { decorateCollectPromise } from './decorateCollectPromise' diff --git a/packages/realtime-api/src/voice/CallDetect.test.ts b/packages/realtime-api/src/voice/CallDetect.test.ts deleted file mode 100644 index 8136e976a..000000000 --- a/packages/realtime-api/src/voice/CallDetect.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { EventEmitter } from '@signalwire/core' -import { createClient } from '../client/createClient' -import { CallDetect } from './CallDetect' -import { Call } from './Call' -import { Voice } from './Voice' - -describe('CallDetect', () => { - let voice: Voice - let call: Call - let callDetect: CallDetect - - const userOptions = { - host: 'example.com', - project: 'example.project', - token: 'example.token', - } - const swClientMock = { - userOptions, - client: createClient(userOptions), - } - - beforeEach(() => { - // @ts-expect-error - voice = new Voice(swClientMock) - - call = new Call({ voice }) - - callDetect = new CallDetect({ - call, - payload: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - }) - - // @ts-expect-error - callDetect._client.execute = jest.fn() - }) - - it('should have an event emitter', () => { - expect(callDetect['emitter']).toBeInstanceOf(EventEmitter) - }) - - it('should declare the correct event map', () => { - const expectedEventMap = { - onStarted: 'detect.started', - onUpdated: 'detect.updated', - onEnded: 'detect.ended', - } - expect(callDetect['_eventMap']).toEqual(expectedEventMap) - }) - - it('should attach all listeners', () => { - callDetect = new CallDetect({ - call, - // @ts-expect-error - payload: {}, - listeners: { - onStarted: () => {}, - onUpdated: () => {}, - onEnded: () => {}, - }, - }) - - // @ts-expect-error - expect(callDetect.emitter.eventNames()).toStrictEqual([ - 'detect.started', - 'detect.updated', - 'detect.ended', - ]) - }) - - it('should stop the detection', async () => { - const baseExecuteParams = { - method: '', - params: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - } - - await callDetect.stop() - // @ts-expect-error - expect(callDetect._client.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.detect.stop', - }) - }) -}) diff --git a/packages/realtime-api/src/voice/CallDetect/CallDetect.test.ts b/packages/realtime-api/src/voice/CallDetect/CallDetect.test.ts new file mode 100644 index 000000000..6f98b2c8b --- /dev/null +++ b/packages/realtime-api/src/voice/CallDetect/CallDetect.test.ts @@ -0,0 +1,169 @@ +import { EventEmitter } from '@signalwire/core' +import { createClient } from '../../client/createClient' +import { CallDetect } from './CallDetect' +import { Call } from '../Call' +import { Voice } from '../Voice' +import { + decorateDetectPromise, + methods, + getters, +} from './decorateDetectPromise' + +describe('CallDetect', () => { + let voice: Voice + let call: Call + let callDetect: CallDetect + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + + callDetect = new CallDetect({ + call, + payload: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + }) + + // @ts-expect-error + callDetect._client.execute = jest.fn() + }) + + it('should have an event emitter', () => { + expect(callDetect['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'detect.started', + onUpdated: 'detect.updated', + onEnded: 'detect.ended', + } + expect(callDetect['_eventMap']).toEqual(expectedEventMap) + }) + + it('should attach all listeners', () => { + callDetect = new CallDetect({ + call, + // @ts-expect-error + payload: {}, + listeners: { + onStarted: () => {}, + onUpdated: () => {}, + onEnded: () => {}, + }, + }) + + // @ts-expect-error + expect(callDetect.emitter.eventNames()).toStrictEqual([ + 'detect.started', + 'detect.updated', + 'detect.ended', + ]) + }) + + it('should stop the detection', async () => { + const baseExecuteParams = { + method: '', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + } + + await callDetect.stop() + // @ts-expect-error + expect(callDetect._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.detect.stop', + }) + }) + + it('should throw an error on methods if detect has ended', async () => { + const hasEndedGetter = jest.spyOn(callDetect, 'hasEnded', 'get') + + // Define the behavior you want for the getter + hasEndedGetter.mockReturnValue(true) + + await expect(callDetect.stop()).rejects.toThrowError('Action has ended') + }) + + describe('decorateDetectPromise', () => { + it('expose correct properties before resolve', () => { + const innerPromise = Promise.resolve(callDetect) + + const decoratedPromise = decorateDetectPromise.call(call, innerPromise) + + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise.onStarted()).toBeInstanceOf(Promise) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise.onEnded()).toBeInstanceOf(Promise) + methods.forEach((method) => { + expect(decoratedPromise).toHaveProperty(method, expect.any(Function)) + // @ts-expect-error + expect(decoratedPromise[method]()).toBeInstanceOf(Promise) + }) + getters.forEach((getter) => { + expect(decoratedPromise).toHaveProperty(getter) + // @ts-expect-error + expect(decoratedPromise[getter]).toBeInstanceOf(Promise) + }) + }) + + it('expose correct properties after resolve', async () => { + const innerPromise = Promise.resolve(callDetect) + + const decoratedPromise = decorateDetectPromise.call(call, innerPromise) + + // Simulate the detect ended event + call.emit('detect.ended', callDetect) + + const ended = await decoratedPromise + + expect(ended).not.toHaveProperty('onStarted', expect.any(Function)) + expect(ended).not.toHaveProperty('onEnded', expect.any(Function)) + methods.forEach((method) => { + expect(ended).toHaveProperty(method, expect.any(Function)) + }) + getters.forEach((getter) => { + expect(ended).toHaveProperty(getter) + // @ts-expect-error + expect(ended[getter]).not.toBeInstanceOf(Promise) + }) + }) + + it('resolves when detect ends', async () => { + const innerPromise = Promise.resolve(callDetect) + + const decoratedPromise = decorateDetectPromise.call(call, innerPromise) + + // Simulate the detect ended event + call.emit('detect.ended', callDetect) + + await expect(decoratedPromise).resolves.toEqual(expect.any(CallDetect)) + }) + + it('rejects on inner promise rejection', async () => { + const innerPromise = Promise.reject(new Error('Tap failed')) + + const decoratedPromise = decorateDetectPromise.call(call, innerPromise) + + await expect(decoratedPromise).rejects.toThrow('Tap failed') + }) + }) +}) diff --git a/packages/realtime-api/src/voice/CallDetect.ts b/packages/realtime-api/src/voice/CallDetect/CallDetect.ts similarity index 89% rename from packages/realtime-api/src/voice/CallDetect.ts rename to packages/realtime-api/src/voice/CallDetect/CallDetect.ts index 3cb9d5901..b9c8cb536 100644 --- a/packages/realtime-api/src/voice/CallDetect.ts +++ b/packages/realtime-api/src/voice/CallDetect/CallDetect.ts @@ -3,13 +3,13 @@ import { CallingCallDetectEventParams, type DetectorResult, } from '@signalwire/core' -import { ListenSubscriber } from '../ListenSubscriber' -import { Call } from './Call' +import { ListenSubscriber } from '../../ListenSubscriber' import { CallDetectEvents, CallDetectListeners, CallDetectListenersEventsMapping, -} from '../types' +} from '../../types' +import { Call } from '../Call' export interface CallDetectOptions { call: Call @@ -82,6 +82,14 @@ export class CallDetect return undefined } + get hasEnded() { + const lastEvent = this._lastEvent() + if (lastEvent && ENDED_STATES.includes(lastEvent)) { + return true + } + return false + } + /** @internal */ setPayload(payload: CallingCallDetectEventParams) { this._payload = payload @@ -93,7 +101,10 @@ export class CallDetect } async stop() { - // if (this.state !== 'finished') { + if (this.hasEnded) { + throw new Error('Action has ended') + } + await this._client.execute({ method: 'calling.detect.stop', params: { @@ -102,7 +113,6 @@ export class CallDetect control_id: this.controlId, }, }) - // } return this } diff --git a/packages/realtime-api/src/voice/CallDetect/decorateDetectPromise.ts b/packages/realtime-api/src/voice/CallDetect/decorateDetectPromise.ts new file mode 100644 index 000000000..aa982a14f --- /dev/null +++ b/packages/realtime-api/src/voice/CallDetect/decorateDetectPromise.ts @@ -0,0 +1,59 @@ +import { + CallingCallDetectType, + Detector, + DetectorResult, + Promisify, +} from '@signalwire/core' +import { Call } from '../Call' +import { CallDetect } from './CallDetect' +import { decoratePromise } from '../decoratePromise' +import { CallDetectListeners } from '../../types' + +export interface CallDetectEnded { + id: string + callId: string + nodeId: string + controlId: string + detect?: Detector + type?: CallingCallDetectType + result: DetectorResult + waitForBeep: boolean + beep?: boolean +} + +export interface CallDetectPromise + extends Promise, + Promisify { + onStarted: () => Promise + onEnded: () => Promise + listen: (listeners: CallDetectListeners) => Promise<() => Promise> + stop: () => Promise + ended: () => Promise +} + +export const getters = [ + 'id', + 'callId', + 'nodeId', + 'controlId', + 'detect', + 'type', + 'result', + 'waitForBeep', + 'beep', +] + +export const methods = ['stop', 'ended'] + +export function decorateDetectPromise( + this: Call, + innerPromise: Promise +) { + // prettier-ignore + return (decoratePromise).call(this, { + promise: innerPromise, + namespace: 'detect', + methods, + getters, + }) as CallDetectPromise +} diff --git a/packages/realtime-api/src/voice/CallDetect/index.ts b/packages/realtime-api/src/voice/CallDetect/index.ts new file mode 100644 index 000000000..6a51b8048 --- /dev/null +++ b/packages/realtime-api/src/voice/CallDetect/index.ts @@ -0,0 +1,3 @@ +export * from './CallDetect' +export * from './decorateDetectPromise' +export { decorateDetectPromise } from './decorateDetectPromise' diff --git a/packages/realtime-api/src/voice/CallPlayback.test.ts b/packages/realtime-api/src/voice/CallPlayback.test.ts deleted file mode 100644 index 8ac49a2b0..000000000 --- a/packages/realtime-api/src/voice/CallPlayback.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { EventEmitter } from '@signalwire/core' -import { createClient } from '../client/createClient' -import { CallPlayback } from './CallPlayback' -import { Call } from './Call' -import { Voice } from './Voice' - -describe('CallPlayback', () => { - let voice: Voice - let call: Call - let callPlayback: CallPlayback - - const userOptions = { - host: 'example.com', - project: 'example.project', - token: 'example.token', - } - const swClientMock = { - userOptions, - client: createClient(userOptions), - } - - beforeEach(() => { - // @ts-expect-error - voice = new Voice(swClientMock) - - call = new Call({ voice }) - - callPlayback = new CallPlayback({ - call, - // @ts-expect-error - payload: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - }) - - // @ts-expect-error - callPlayback._client.execute = jest.fn() - }) - - it('should have an event emitter', () => { - expect(callPlayback['emitter']).toBeInstanceOf(EventEmitter) - }) - - it('should declare the correct event map', () => { - const expectedEventMap = { - onStarted: 'playback.started', - onUpdated: 'playback.updated', - onFailed: 'playback.failed', - onEnded: 'playback.ended', - } - expect(callPlayback['_eventMap']).toEqual(expectedEventMap) - }) - - it('should attach all listeners', () => { - callPlayback = new CallPlayback({ - call, - // @ts-expect-error - payload: {}, - listeners: { - onStarted: () => {}, - onUpdated: () => {}, - onFailed: () => {}, - onEnded: () => {}, - }, - }) - - // @ts-expect-error - expect(callPlayback.emitter.eventNames()).toStrictEqual([ - 'playback.started', - 'playback.updated', - 'playback.failed', - 'playback.ended', - ]) - }) - - it('should control an active playback', async () => { - const baseExecuteParams = { - method: '', - params: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - } - await callPlayback.pause() - // @ts-expect-error - expect(callPlayback._client.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.play.pause', - }) - - await callPlayback.resume() - // @ts-expect-error - expect(callPlayback._client.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.play.resume', - }) - await callPlayback.stop() - // @ts-expect-error - expect(callPlayback._client.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.play.stop', - }) - await callPlayback.setVolume(2) - // @ts-expect-error - expect(callPlayback._client.execute).toHaveBeenLastCalledWith({ - method: 'calling.play.volume', - params: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - volume: 2, - }, - }) - }) -}) diff --git a/packages/realtime-api/src/voice/CallPlayback/CallPlayback.test.ts b/packages/realtime-api/src/voice/CallPlayback/CallPlayback.test.ts new file mode 100644 index 000000000..cb57b688c --- /dev/null +++ b/packages/realtime-api/src/voice/CallPlayback/CallPlayback.test.ts @@ -0,0 +1,206 @@ +import { EventEmitter } from '@signalwire/core' +import { createClient } from '../../client/createClient' +import { CallPlayback } from './CallPlayback' +import { Call } from '../Call' +import { Voice } from '../Voice' +import { + decoratePlaybackPromise, + methods, + getters, +} from './decoratePlaybackPromise' + +describe('CallPlayback', () => { + let voice: Voice + let call: Call + let callPlayback: CallPlayback + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + + callPlayback = new CallPlayback({ + call, + // @ts-expect-error + payload: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + }) + + // @ts-expect-error + callPlayback._client.execute = jest.fn() + }) + + it('should have an event emitter', () => { + expect(callPlayback['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'playback.started', + onUpdated: 'playback.updated', + onFailed: 'playback.failed', + onEnded: 'playback.ended', + } + expect(callPlayback['_eventMap']).toEqual(expectedEventMap) + }) + + it('should attach all listeners', () => { + callPlayback = new CallPlayback({ + call, + // @ts-expect-error + payload: {}, + listeners: { + onStarted: () => {}, + onUpdated: () => {}, + onFailed: () => {}, + onEnded: () => {}, + }, + }) + + // @ts-expect-error + expect(callPlayback.emitter.eventNames()).toStrictEqual([ + 'playback.started', + 'playback.updated', + 'playback.failed', + 'playback.ended', + ]) + }) + + it('should control an active playback', async () => { + const baseExecuteParams = { + method: '', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + } + await callPlayback.pause() + // @ts-expect-error + expect(callPlayback._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.play.pause', + }) + + await callPlayback.resume() + + // @ts-expect-error + expect(callPlayback._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.play.resume', + }) + + await callPlayback.stop() + // @ts-expect-error + expect(callPlayback._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.play.stop', + }) + + await callPlayback.setVolume(2) + // @ts-expect-error + expect(callPlayback._client.execute).toHaveBeenLastCalledWith({ + method: 'calling.play.volume', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + volume: 2, + }, + }) + }) + + it('should throw an error on methods if playback has ended', async () => { + callPlayback.setPayload({ + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + state: 'finished', + }) + + await expect(callPlayback.pause()).rejects.toThrowError('Action has ended') + await expect(callPlayback.resume()).rejects.toThrowError('Action has ended') + await expect(callPlayback.stop()).rejects.toThrowError('Action has ended') + await expect(callPlayback.setVolume(1)).rejects.toThrowError( + 'Action has ended' + ) + }) + + describe('decoratePlaybackPromise', () => { + it('expose correct properties before resolve', () => { + const innerPromise = Promise.resolve(callPlayback) + + const decoratedPromise = decoratePlaybackPromise.call(call, innerPromise) + + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise.onStarted()).toBeInstanceOf(Promise) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise.onEnded()).toBeInstanceOf(Promise) + methods.forEach((method) => { + expect(decoratedPromise).toHaveProperty(method, expect.any(Function)) + // @ts-expect-error + expect(decoratedPromise[method]()).toBeInstanceOf(Promise) + }) + getters.forEach((getter) => { + expect(decoratedPromise).toHaveProperty(getter) + // @ts-expect-error + expect(decoratedPromise[getter]).toBeInstanceOf(Promise) + }) + }) + + it('expose correct properties after resolve', async () => { + const innerPromise = Promise.resolve(callPlayback) + + const decoratedPromise = decoratePlaybackPromise.call(call, innerPromise) + + // Simulate the playback ended event + call.emit('playback.ended', callPlayback) + + const ended = await decoratedPromise + + expect(ended).not.toHaveProperty('onStarted', expect.any(Function)) + expect(ended).not.toHaveProperty('onEnded', expect.any(Function)) + methods.forEach((method) => { + expect(ended).toHaveProperty(method, expect.any(Function)) + }) + getters.forEach((getter) => { + expect(ended).toHaveProperty(getter) + // @ts-expect-error + expect(ended[getter]).not.toBeInstanceOf(Promise) + }) + }) + + it('resolves when playback ends', async () => { + const innerPromise = Promise.resolve(callPlayback) + + const decoratedPromise = decoratePlaybackPromise.call(call, innerPromise) + + // Simulate the playback ended event + call.emit('playback.ended', callPlayback) + + await expect(decoratedPromise).resolves.toEqual(expect.any(CallPlayback)) + }) + + it('rejects on inner promise rejection', async () => { + const innerPromise = Promise.reject(new Error('Recording failed')) + + const decoratedPromise = decoratePlaybackPromise.call(call, innerPromise) + + await expect(decoratedPromise).rejects.toThrow('Recording failed') + }) + }) +}) diff --git a/packages/realtime-api/src/voice/CallPlayback.ts b/packages/realtime-api/src/voice/CallPlayback/CallPlayback.ts similarity index 85% rename from packages/realtime-api/src/voice/CallPlayback.ts rename to packages/realtime-api/src/voice/CallPlayback/CallPlayback.ts index 2af17e6c0..eb91a9f61 100644 --- a/packages/realtime-api/src/voice/CallPlayback.ts +++ b/packages/realtime-api/src/voice/CallPlayback/CallPlayback.ts @@ -3,13 +3,13 @@ import { CallingCallPlayEventParams, VoiceCallPlaybackContract, } from '@signalwire/core' -import { ListenSubscriber } from '../ListenSubscriber' +import { ListenSubscriber } from '../../ListenSubscriber' import { CallPlaybackEvents, CallPlaybackListeners, CallPlaybackListenersEventsMapping, -} from '../types' -import { Call } from './Call' +} from '../../types' +import { Call } from '../Call' export interface CallPlaybackOptions { call: Call @@ -68,12 +68,23 @@ export class CallPlayback return this._payload?.state } + get hasEnded() { + if (ENDED_STATES.includes(this.state as CallingCallPlayEndState)) { + return true + } + return false + } + /** @internal */ setPayload(payload: CallingCallPlayEventParams) { this._payload = payload } async pause() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + await this._client.execute({ method: 'calling.play.pause', params: { @@ -87,6 +98,10 @@ export class CallPlayback } async resume() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + await this._client.execute({ method: 'calling.play.resume', params: { @@ -100,6 +115,10 @@ export class CallPlayback } async stop() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + await this._client.execute({ method: 'calling.play.stop', params: { @@ -113,6 +132,10 @@ export class CallPlayback } async setVolume(volume: number) { + if (this.hasEnded) { + throw new Error('Action has ended') + } + this._volume = volume await this._client.execute({ @@ -151,7 +174,7 @@ export class CallPlayback this.once('playback.failed', handler) // Resolve the promise if the play has already ended - if (ENDED_STATES.includes(this.state as CallingCallPlayEndState)) { + if (this.hasEnded) { handler() } }) diff --git a/packages/realtime-api/src/voice/CallPlayback/decoratePlaybackPromise.ts b/packages/realtime-api/src/voice/CallPlayback/decoratePlaybackPromise.ts new file mode 100644 index 000000000..db2dbe9c5 --- /dev/null +++ b/packages/realtime-api/src/voice/CallPlayback/decoratePlaybackPromise.ts @@ -0,0 +1,56 @@ +import { + CallingCallPlayEndState, + CallingCallPlayState, + Promisify, +} from '@signalwire/core' +import { Call } from '../Call' +import { CallPlayback } from './CallPlayback' +import { decoratePromise } from '../decoratePromise' +import { CallPlaybackListeners } from '../../types' + +export interface CallPlaybackEnded { + id: string + volume: number + callId: string + nodeId: string + controlId: string + state: CallingCallPlayEndState +} + +export interface CallPlaybackPromise + extends Promise, + Omit, 'state'> { + onStarted: () => Promise + onEnded: () => Promise + listen: (listeners: CallPlaybackListeners) => Promise<() => Promise> + pause: () => Promise + resume: () => Promise + stop: () => Promise + setVolume: () => Promise + ended: () => Promise + state: Promise +} + +export const getters = [ + 'id', + 'volume', + 'callId', + 'nodeId', + 'controlId', + 'state', +] + +export const methods = ['pause', 'resume', 'stop', 'setVolume', 'ended'] + +export function decoratePlaybackPromise( + this: Call, + innerPromise: Promise +) { + // prettier-ignore + return (decoratePromise).call(this, { + promise: innerPromise, + namespace: 'playback', + methods, + getters, + }) as CallPlaybackPromise +} diff --git a/packages/realtime-api/src/voice/CallPlayback/index.ts b/packages/realtime-api/src/voice/CallPlayback/index.ts new file mode 100644 index 000000000..65ea6de48 --- /dev/null +++ b/packages/realtime-api/src/voice/CallPlayback/index.ts @@ -0,0 +1,3 @@ +export * from './CallPlayback' +export * from './decoratePlaybackPromise' +export { decoratePlaybackPromise } from './decoratePlaybackPromise' diff --git a/packages/realtime-api/src/voice/CallPrompt.test.ts b/packages/realtime-api/src/voice/CallPrompt.test.ts deleted file mode 100644 index 85960e9f8..000000000 --- a/packages/realtime-api/src/voice/CallPrompt.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { EventEmitter } from '@signalwire/core' -import { createClient } from '../client/createClient' -import { CallPrompt } from './CallPrompt' -import { Call } from './Call' -import { Voice } from './Voice' - -describe('CallPrompt', () => { - let voice: Voice - let call: Call - let callPrompt: CallPrompt - - const userOptions = { - host: 'example.com', - project: 'example.project', - token: 'example.token', - } - const swClientMock = { - userOptions, - client: createClient(userOptions), - } - - beforeEach(() => { - // @ts-expect-error - voice = new Voice(swClientMock) - - call = new Call({ voice }) - - callPrompt = new CallPrompt({ - call, - // @ts-expect-error - payload: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - }) - - // @ts-expect-error - callPrompt._client.execute = jest.fn() - }) - - it('should have an event emitter', () => { - expect(callPrompt['emitter']).toBeInstanceOf(EventEmitter) - }) - - it('should declare the correct event map', () => { - const expectedEventMap = { - onStarted: 'prompt.started', - onUpdated: 'prompt.updated', - onFailed: 'prompt.failed', - onEnded: 'prompt.ended', - } - expect(callPrompt['_eventMap']).toEqual(expectedEventMap) - }) - - it('should attach all listeners', () => { - callPrompt = new CallPrompt({ - call, - // @ts-expect-error - payload: {}, - listeners: { - onStarted: () => {}, - onUpdated: () => {}, - onFailed: () => {}, - onEnded: () => {}, - }, - }) - - // @ts-expect-error - expect(callPrompt.emitter.eventNames()).toStrictEqual([ - 'prompt.started', - 'prompt.updated', - 'prompt.failed', - 'prompt.ended', - ]) - }) - - it('should control an active prompt', async () => { - const baseExecuteParams = { - method: '', - params: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - } - - await callPrompt.stop() - // @ts-expect-error - expect(callPrompt._client.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.play_and_collect.stop', - }) - - await callPrompt.setVolume(5) - // @ts-expect-error - expect(callPrompt._client.execute).toHaveBeenLastCalledWith({ - method: 'calling.play_and_collect.volume', - params: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - volume: 5, - }, - }) - }) -}) diff --git a/packages/realtime-api/src/voice/CallPrompt/CallPrompt.test.ts b/packages/realtime-api/src/voice/CallPrompt/CallPrompt.test.ts new file mode 100644 index 000000000..94f4a7ea7 --- /dev/null +++ b/packages/realtime-api/src/voice/CallPrompt/CallPrompt.test.ts @@ -0,0 +1,191 @@ +import { EventEmitter } from '@signalwire/core' +import { createClient } from '../../client/createClient' +import { CallPrompt } from './CallPrompt' +import { Call } from '../Call' +import { Voice } from '../Voice' +import { + decoratePromptPromise, + getters, + methods, +} from './decoratePromptPromise' + +describe('CallPrompt', () => { + let voice: Voice + let call: Call + let callPrompt: CallPrompt + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + + callPrompt = new CallPrompt({ + call, + // @ts-expect-error + payload: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + }) + + // @ts-expect-error + callPrompt._client.execute = jest.fn() + }) + + it('should have an event emitter', () => { + expect(callPrompt['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'prompt.started', + onUpdated: 'prompt.updated', + onFailed: 'prompt.failed', + onEnded: 'prompt.ended', + } + expect(callPrompt['_eventMap']).toEqual(expectedEventMap) + }) + + it('should attach all listeners', () => { + callPrompt = new CallPrompt({ + call, + // @ts-expect-error + payload: {}, + listeners: { + onStarted: () => {}, + onUpdated: () => {}, + onFailed: () => {}, + onEnded: () => {}, + }, + }) + + // @ts-expect-error + expect(callPrompt.emitter.eventNames()).toStrictEqual([ + 'prompt.started', + 'prompt.updated', + 'prompt.failed', + 'prompt.ended', + ]) + }) + + it('should control an active prompt', async () => { + const baseExecuteParams = { + method: '', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + } + + await callPrompt.stop() + // @ts-expect-error + expect(callPrompt._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.play_and_collect.stop', + }) + + await callPrompt.setVolume(5) + // @ts-expect-error + expect(callPrompt._client.execute).toHaveBeenLastCalledWith({ + method: 'calling.play_and_collect.volume', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + volume: 5, + }, + }) + }) + + it('should throw an error on methods if prompt has ended', async () => { + callPrompt.setPayload({ + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + // @ts-expect-error + result: { type: 'speech' }, + }) + + await expect(callPrompt.stop()).rejects.toThrowError('Action has ended') + await expect(callPrompt.setVolume(1)).rejects.toThrowError( + 'Action has ended' + ) + }) + + describe('decoratePromptPromise', () => { + it('expose correct properties before resolve', () => { + const innerPromise = Promise.resolve(callPrompt) + + const decoratedPromise = decoratePromptPromise.call(call, innerPromise) + + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise.onStarted()).toBeInstanceOf(Promise) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise.onEnded()).toBeInstanceOf(Promise) + methods.forEach((method) => { + expect(decoratedPromise).toHaveProperty(method, expect.any(Function)) + // @ts-expect-error + expect(decoratedPromise[method]()).toBeInstanceOf(Promise) + }) + getters.forEach((getter) => { + expect(decoratedPromise).toHaveProperty(getter) + // @ts-expect-error + expect(decoratedPromise[getter]).toBeInstanceOf(Promise) + }) + }) + + it('expose correct properties after resolve', async () => { + const innerPromise = Promise.resolve(callPrompt) + + const decoratedPromise = decoratePromptPromise.call(call, innerPromise) + + // Simulate the prompt ended event + call.emit('prompt.ended', callPrompt) + + const ended = await decoratedPromise + + expect(ended).not.toHaveProperty('onStarted', expect.any(Function)) + expect(ended).not.toHaveProperty('onEnded', expect.any(Function)) + methods.forEach((method) => { + expect(ended).toHaveProperty(method, expect.any(Function)) + }) + getters.forEach((getter) => { + expect(ended).toHaveProperty(getter) + // @ts-expect-error + expect(ended[getter]).not.toBeInstanceOf(Promise) + }) + }) + + it('resolves when prompt ends', async () => { + const innerPromise = Promise.resolve(callPrompt) + + const decoratedPromise = decoratePromptPromise.call(call, innerPromise) + + // Simulate the prompt ended event + call.emit('prompt.ended', callPrompt) + + await expect(decoratedPromise).resolves.toEqual(expect.any(CallPrompt)) + }) + + it('rejects on inner promise rejection', async () => { + const innerPromise = Promise.reject(new Error('Recording failed')) + + const decoratedPromise = decoratePromptPromise.call(call, innerPromise) + + await expect(decoratedPromise).rejects.toThrow('Recording failed') + }) + }) +}) diff --git a/packages/realtime-api/src/voice/CallPrompt.ts b/packages/realtime-api/src/voice/CallPrompt/CallPrompt.ts similarity index 84% rename from packages/realtime-api/src/voice/CallPrompt.ts rename to packages/realtime-api/src/voice/CallPrompt/CallPrompt.ts index b6d30ecd9..d8c3584af 100644 --- a/packages/realtime-api/src/voice/CallPrompt.ts +++ b/packages/realtime-api/src/voice/CallPrompt/CallPrompt.ts @@ -3,13 +3,13 @@ import { CallingCallCollectEndState, CallingCallCollectEventParams, } from '@signalwire/core' -import { Call } from './Call' +import { Call } from '../Call' import { CallPromptEvents, CallPromptListeners, CallPromptListenersEventsMapping, -} from '../types' -import { ListenSubscriber } from '../ListenSubscriber' +} from '../../types' +import { ListenSubscriber } from '../../ListenSubscriber' export interface CallPromptOptions { call: Call @@ -114,33 +114,42 @@ export class CallPrompt return undefined } + get hasEnded() { + if ( + ENDED_STATES.includes(this.result?.type as CallingCallCollectEndState) + ) { + return true + } + return false + } + /** @internal */ setPayload(payload: CallingCallCollectEventParams) { this._payload = payload } async stop() { - // Execute stop only if we don't have result yet - if (!this.result) { - await this._client.execute({ - method: 'calling.play_and_collect.stop', - params: { - node_id: this.nodeId, - call_id: this.callId, - control_id: this.controlId, - }, - }) + if (this.hasEnded) { + throw new Error('Action has ended') } - /** - * TODO: we should wait for the prompt to be finished to allow - * the CallPrompt/Proxy object to update the payload properly - */ + await this._client.execute({ + method: 'calling.play_and_collect.stop', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: this.controlId, + }, + }) return this } async setVolume(volume: number): Promise { + if (this.hasEnded) { + throw new Error('Action has ended') + } + await this._client.execute({ method: 'calling.play_and_collect.volume', params: { @@ -177,9 +186,7 @@ export class CallPrompt this.once('prompt.failed', handler) // Resolve the promise if the prompt has already ended - if ( - ENDED_STATES.includes(this.result?.type as CallingCallCollectEndState) - ) { + if (this.hasEnded) { handler() } }) diff --git a/packages/realtime-api/src/voice/CallPrompt/decoratePromptPromise.ts b/packages/realtime-api/src/voice/CallPrompt/decoratePromptPromise.ts new file mode 100644 index 000000000..37268b2cc --- /dev/null +++ b/packages/realtime-api/src/voice/CallPrompt/decoratePromptPromise.ts @@ -0,0 +1,61 @@ +import { CallingCallCollectResult, Promisify } from '@signalwire/core' +import { Call } from '../Call' +import { CallPrompt } from './CallPrompt' +import { decoratePromise } from '../decoratePromise' +import { CallPromptListeners } from '../../types' + +export interface CallPromptEnded { + id: string + controlId: string + callId: string + nodeId: string + result: CallingCallCollectResult + type: CallingCallCollectResult['type'] + reason: CallingCallCollectResult['type'] + digits?: string + speech?: string + terminator?: string + text?: string + confidence?: number +} + +export interface CallPromptPromise + extends Promise, + Promisify { + onStarted: () => Promise + onEnded: () => Promise + listen: (listeners: CallPromptListeners) => Promise<() => Promise> + stop: () => Promise + setVolume: () => Promise + ended: () => Promise +} + +export const getters = [ + 'id', + 'controlId', + 'callId', + 'nodeId', + 'result', + 'type', + 'reason', + 'digits', + 'speech', + 'terminator', + 'text', + 'confidence', +] + +export const methods = ['stop', 'setVolume', 'ended'] + +export function decoratePromptPromise( + this: Call, + innerPromise: Promise +) { + // prettier-ignore + return (decoratePromise).call(this, { + promise: innerPromise, + namespace: 'prompt', + methods, + getters, + }) as CallPromptPromise +} diff --git a/packages/realtime-api/src/voice/CallPrompt/index.ts b/packages/realtime-api/src/voice/CallPrompt/index.ts new file mode 100644 index 000000000..90dcf33f8 --- /dev/null +++ b/packages/realtime-api/src/voice/CallPrompt/index.ts @@ -0,0 +1,3 @@ +export * from './CallPrompt' +export * from './decoratePromptPromise' +export { decoratePromptPromise } from './decoratePromptPromise' diff --git a/packages/realtime-api/src/voice/CallRecording.test.ts b/packages/realtime-api/src/voice/CallRecording.test.ts deleted file mode 100644 index a0706d700..000000000 --- a/packages/realtime-api/src/voice/CallRecording.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { EventEmitter } from '@signalwire/core' -import { createClient } from '../client/createClient' -import { CallRecording } from './CallRecording' -import { Call } from './Call' -import { Voice } from './Voice' - -describe('CallRecording', () => { - let voice: Voice - let call: Call - let callRecording: CallRecording - - const userOptions = { - host: 'example.com', - project: 'example.project', - token: 'example.token', - } - const swClientMock = { - userOptions, - client: createClient(userOptions), - } - - beforeEach(() => { - // @ts-expect-error - voice = new Voice(swClientMock) - - call = new Call({ voice }) - - callRecording = new CallRecording({ - call, - // @ts-expect-error - payload: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - }) - - // @ts-expect-error - callRecording._client.execute = jest.fn() - }) - - it('should have an event emitter', () => { - expect(callRecording['emitter']).toBeInstanceOf(EventEmitter) - }) - - it('should declare the correct event map', () => { - const expectedEventMap = { - onStarted: 'recording.started', - onUpdated: 'recording.updated', - onFailed: 'recording.failed', - onEnded: 'recording.ended', - } - expect(callRecording['_eventMap']).toEqual(expectedEventMap) - }) - - it('should attach all listeners', () => { - callRecording = new CallRecording({ - call, - // @ts-expect-error - payload: {}, - listeners: { - onStarted: () => {}, - onUpdated: () => {}, - onFailed: () => {}, - onEnded: () => {}, - }, - }) - - // @ts-expect-error - expect(callRecording.emitter.eventNames()).toStrictEqual([ - 'recording.started', - 'recording.updated', - 'recording.failed', - 'recording.ended', - ]) - }) - - it('should control an active playback', async () => { - const baseExecuteParams = { - method: '', - params: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - } - - await callRecording.pause() - // @ts-expect-error - expect(callRecording._client.execute).toHaveBeenLastCalledWith({ - method: 'calling.record.pause', - params: { - ...baseExecuteParams.params, - behavior: 'silence', - }, - }) - - await callRecording.pause({ behavior: 'skip' }) - // @ts-expect-error - expect(callRecording._client.execute).toHaveBeenLastCalledWith({ - method: 'calling.record.pause', - params: { - ...baseExecuteParams.params, - behavior: 'skip', - }, - }) - - await callRecording.resume() - // @ts-expect-error - expect(callRecording._client.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.record.resume', - }) - - await callRecording.stop() - // @ts-expect-error - expect(callRecording._client.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.record.stop', - }) - }) -}) diff --git a/packages/realtime-api/src/voice/CallRecording/CallRecording.test.ts b/packages/realtime-api/src/voice/CallRecording/CallRecording.test.ts new file mode 100644 index 000000000..0f059029a --- /dev/null +++ b/packages/realtime-api/src/voice/CallRecording/CallRecording.test.ts @@ -0,0 +1,207 @@ +import { EventEmitter } from '@signalwire/core' +import { createClient } from '../../client/createClient' +import { CallRecording } from './CallRecording' +import { Call } from '../Call' +import { Voice } from '../Voice' +import { + decorateRecordingPromise, + methods, + getters, +} from './decorateRecordingPromise' + +describe('CallRecording', () => { + let voice: Voice + let call: Call + let callRecording: CallRecording + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + + callRecording = new CallRecording({ + call, + // @ts-expect-error + payload: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + }) + + // @ts-expect-error + callRecording._client.execute = jest.fn() + }) + + it('should have an event emitter', () => { + expect(callRecording['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'recording.started', + onUpdated: 'recording.updated', + onFailed: 'recording.failed', + onEnded: 'recording.ended', + } + expect(callRecording['_eventMap']).toEqual(expectedEventMap) + }) + + it('should attach all listeners', () => { + callRecording = new CallRecording({ + call, + // @ts-expect-error + payload: {}, + listeners: { + onStarted: () => {}, + onUpdated: () => {}, + onFailed: () => {}, + onEnded: () => {}, + }, + }) + + // @ts-expect-error + expect(callRecording.emitter.eventNames()).toStrictEqual([ + 'recording.started', + 'recording.updated', + 'recording.failed', + 'recording.ended', + ]) + }) + + it('should control an active playback', async () => { + const baseExecuteParams = { + method: '', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + } + + await callRecording.pause() + // @ts-expect-error + expect(callRecording._client.execute).toHaveBeenLastCalledWith({ + method: 'calling.record.pause', + params: { + ...baseExecuteParams.params, + behavior: 'silence', + }, + }) + + await callRecording.pause({ behavior: 'skip' }) + // @ts-expect-error + expect(callRecording._client.execute).toHaveBeenLastCalledWith({ + method: 'calling.record.pause', + params: { + ...baseExecuteParams.params, + behavior: 'skip', + }, + }) + + await callRecording.resume() + // @ts-expect-error + expect(callRecording._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.record.resume', + }) + + await callRecording.stop() + // @ts-expect-error + expect(callRecording._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.record.stop', + }) + }) + + it('should throw an error on methods if recording has ended', async () => { + // @ts-expect-error + callRecording.setPayload({ + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + state: 'finished', + }) + + await expect(callRecording.pause()).rejects.toThrowError('Action has ended') + await expect(callRecording.resume()).rejects.toThrowError( + 'Action has ended' + ) + await expect(callRecording.stop()).rejects.toThrowError('Action has ended') + }) + + describe('decorateRecordingPromise', () => { + it('expose correct properties before resolve', () => { + const innerPromise = Promise.resolve(callRecording) + + const decoratedPromise = decorateRecordingPromise.call(call, innerPromise) + + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise.onStarted()).toBeInstanceOf(Promise) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise.onEnded()).toBeInstanceOf(Promise) + methods.forEach((method) => { + expect(decoratedPromise).toHaveProperty(method, expect.any(Function)) + // @ts-expect-error + expect(decoratedPromise[method]()).toBeInstanceOf(Promise) + }) + getters.forEach((getter) => { + expect(decoratedPromise).toHaveProperty(getter) + // @ts-expect-error + expect(decoratedPromise[getter]).toBeInstanceOf(Promise) + }) + }) + + it('expose correct properties after resolve', async () => { + const innerPromise = Promise.resolve(callRecording) + + const decoratedPromise = decorateRecordingPromise.call(call, innerPromise) + + // Simulate the recording ended event + call.emit('recording.ended', callRecording) + + const ended = await decoratedPromise + + expect(ended).not.toHaveProperty('onStarted', expect.any(Function)) + expect(ended).not.toHaveProperty('onEnded', expect.any(Function)) + methods.forEach((method) => { + expect(ended).toHaveProperty(method, expect.any(Function)) + }) + getters.forEach((getter) => { + expect(ended).toHaveProperty(getter) + // @ts-expect-error + expect(ended[getter]).not.toBeInstanceOf(Promise) + }) + }) + + it('resolves when recording ends', async () => { + const innerPromise = Promise.resolve(callRecording) + + const decoratedPromise = decorateRecordingPromise.call(call, innerPromise) + + // Simulate the recording ended event + call.emit('recording.ended', callRecording) + + await expect(decoratedPromise).resolves.toEqual(expect.any(CallRecording)) + }) + + it('rejects on inner promise rejection', async () => { + const innerPromise = Promise.reject(new Error('Recording failed')) + + const decoratedPromise = decorateRecordingPromise.call(call, innerPromise) + + await expect(decoratedPromise).rejects.toThrow('Recording failed') + }) + }) +}) diff --git a/packages/realtime-api/src/voice/CallRecording.ts b/packages/realtime-api/src/voice/CallRecording/CallRecording.ts similarity index 86% rename from packages/realtime-api/src/voice/CallRecording.ts rename to packages/realtime-api/src/voice/CallRecording/CallRecording.ts index 11b33bcc6..a0836beb9 100644 --- a/packages/realtime-api/src/voice/CallRecording.ts +++ b/packages/realtime-api/src/voice/CallRecording/CallRecording.ts @@ -4,13 +4,13 @@ import { CallingCallRecordEventParams, CallingCallRecordPauseMethodParams, } from '@signalwire/core' -import { ListenSubscriber } from '../ListenSubscriber' +import { ListenSubscriber } from '../../ListenSubscriber' import { CallRecordingEvents, CallRecordingListeners, CallRecordingListenersEventsMapping, -} from '../types' -import { Call } from './Call' +} from '../../types' +import { Call } from '../Call' export interface CallRecordingOptions { call: Call @@ -80,12 +80,23 @@ export class CallRecording return this._payload.record } + get hasEnded() { + if (ENDED_STATES.includes(this.state as CallingCallRecordEndState)) { + return true + } + return false + } + /** @internal */ setPayload(payload: CallingCallRecordEventParams) { this._payload = payload } async pause(params?: CallingCallRecordPauseMethodParams) { + if (this.hasEnded) { + throw new Error('Action has ended') + } + const { behavior = 'silence' } = params || {} await this._client.execute({ @@ -102,6 +113,10 @@ export class CallRecording } async resume() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + await this._client.execute({ method: 'calling.record.resume', params: { @@ -115,6 +130,10 @@ export class CallRecording } async stop() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + await this._client.execute({ method: 'calling.record.stop', params: { @@ -145,7 +164,7 @@ export class CallRecording this.once('recording.failed', handler) // Resolve the promise if the recording has already ended - if (ENDED_STATES.includes(this.state as CallingCallRecordEndState)) { + if (this.hasEnded) { handler() } }) diff --git a/packages/realtime-api/src/voice/CallRecording/decorateRecordingPromise.ts b/packages/realtime-api/src/voice/CallRecording/decorateRecordingPromise.ts new file mode 100644 index 000000000..1df7c0d7c --- /dev/null +++ b/packages/realtime-api/src/voice/CallRecording/decorateRecordingPromise.ts @@ -0,0 +1,61 @@ +import { + CallingCallRecordEndState, + CallingCallRecordState, + Promisify, +} from '@signalwire/core' +import { Call } from '../Call' +import { CallRecording } from './CallRecording' +import { decoratePromise } from '../decoratePromise' +import { CallRecordingListeners } from '../../types' + +export interface CallRecordingEnded { + id: string + callId: string + nodeId: string + controlId: string + state: CallingCallRecordEndState + url: string | undefined + duration: number | undefined + record: any +} + +export interface CallRecordingPromise + extends Promise, + Omit, 'state'> { + onStarted: () => Promise + onEnded: () => Promise + listen: (listeners: CallRecordingListeners) => Promise<() => Promise> + pause: () => Promise + resume: () => Promise + stop: () => Promise + setVolume: () => Promise + ended: () => Promise + state: Promise +} + +export const getters = [ + 'id', + 'callId', + 'nodeId', + 'controlId', + 'state', + 'url', + 'size', + 'duration', + 'record', +] + +export const methods = ['pause', 'resume', 'stop', 'ended'] + +export function decorateRecordingPromise( + this: Call, + innerPromise: Promise +) { + // prettier-ignore + return (decoratePromise).call(this, { + promise: innerPromise, + namespace: 'recording', + methods, + getters, + }) as CallRecordingPromise +} diff --git a/packages/realtime-api/src/voice/CallRecording/index.ts b/packages/realtime-api/src/voice/CallRecording/index.ts new file mode 100644 index 000000000..db1d131c7 --- /dev/null +++ b/packages/realtime-api/src/voice/CallRecording/index.ts @@ -0,0 +1,3 @@ +export * from './CallRecording' +export * from './decorateRecordingPromise' +export { decorateRecordingPromise } from './decorateRecordingPromise' diff --git a/packages/realtime-api/src/voice/CallTap.test.ts b/packages/realtime-api/src/voice/CallTap.test.ts deleted file mode 100644 index a71c90992..000000000 --- a/packages/realtime-api/src/voice/CallTap.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { EventEmitter } from '@signalwire/core' -import { createClient } from '../client/createClient' -import { CallTap } from './CallTap' -import { Call } from './Call' -import { Voice } from './Voice' - -describe('CallTap', () => { - let voice: Voice - let call: Call - let callTap: CallTap - - const userOptions = { - host: 'example.com', - project: 'example.project', - token: 'example.token', - } - const swClientMock = { - userOptions, - client: createClient(userOptions), - } - - beforeEach(() => { - // @ts-expect-error - voice = new Voice(swClientMock) - - call = new Call({ voice }) - - callTap = new CallTap({ - call, - // @ts-expect-error - payload: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - }) - - // @ts-expect-error - callTap._client.execute = jest.fn() - }) - - it('should have an event emitter', () => { - expect(callTap['emitter']).toBeInstanceOf(EventEmitter) - }) - - it('should declare the correct event map', () => { - const expectedEventMap = { - onStarted: 'tap.started', - onEnded: 'tap.ended', - } - expect(callTap['_eventMap']).toEqual(expectedEventMap) - }) - - it('should attach all listeners', () => { - callTap = new CallTap({ - call, - // @ts-expect-error - payload: {}, - listeners: { - onStarted: () => {}, - onEnded: () => {}, - }, - }) - - // @ts-expect-error - expect(callTap.emitter.eventNames()).toStrictEqual([ - 'tap.started', - 'tap.ended', - ]) - }) - - it('should control an active playback', async () => { - const baseExecuteParams = { - method: '', - params: { - control_id: 'test_control_id', - call_id: 'test_call_id', - node_id: 'test_node_id', - }, - } - - await callTap.stop() - // @ts-expect-error - expect(callTap._client.execute).toHaveBeenLastCalledWith({ - ...baseExecuteParams, - method: 'calling.tap.stop', - }) - }) - - it('should update the attributes on setPayload call', () => { - const newCallId = 'new_call_id' - const newNodeId = 'new_node_id' - const newControlId = 'new_control_id' - - // @ts-expect-error - callTap.setPayload({ - call_id: newCallId, - node_id: newNodeId, - control_id: newControlId, - }) - - expect(callTap.callId).toBe(newCallId) - expect(callTap.nodeId).toBe(newNodeId) - expect(callTap.controlId).toBe(newControlId) - }) -}) diff --git a/packages/realtime-api/src/voice/CallTap/CallTap.test.ts b/packages/realtime-api/src/voice/CallTap/CallTap.test.ts new file mode 100644 index 000000000..9a4d79c5d --- /dev/null +++ b/packages/realtime-api/src/voice/CallTap/CallTap.test.ts @@ -0,0 +1,183 @@ +import { EventEmitter } from '@signalwire/core' +import { createClient } from '../../client/createClient' +import { CallTap } from './CallTap' +import { Call } from '../Call' +import { Voice } from '../Voice' +import { decorateTapPromise, methods, getters } from './decorateTapPromise' + +describe('CallTap', () => { + let voice: Voice + let call: Call + let callTap: CallTap + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + + callTap = new CallTap({ + call, + // @ts-expect-error + payload: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + }) + + // @ts-expect-error + callTap._client.execute = jest.fn() + }) + + it('should have an event emitter', () => { + expect(callTap['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'tap.started', + onEnded: 'tap.ended', + } + expect(callTap['_eventMap']).toEqual(expectedEventMap) + }) + + it('should attach all listeners', () => { + callTap = new CallTap({ + call, + // @ts-expect-error + payload: {}, + listeners: { + onStarted: () => {}, + onEnded: () => {}, + }, + }) + + // @ts-expect-error + expect(callTap.emitter.eventNames()).toStrictEqual([ + 'tap.started', + 'tap.ended', + ]) + }) + + it('should control an active playback', async () => { + const baseExecuteParams = { + method: '', + params: { + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + }, + } + + await callTap.stop() + // @ts-expect-error + expect(callTap._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'calling.tap.stop', + }) + }) + + it('should update the attributes on setPayload call', () => { + const newCallId = 'new_call_id' + const newNodeId = 'new_node_id' + const newControlId = 'new_control_id' + + // @ts-expect-error + callTap.setPayload({ + call_id: newCallId, + node_id: newNodeId, + control_id: newControlId, + }) + + expect(callTap.callId).toBe(newCallId) + expect(callTap.nodeId).toBe(newNodeId) + expect(callTap.controlId).toBe(newControlId) + }) + + it('should throw an error on methods if tap has ended', async () => { + // @ts-expect-error + callTap.setPayload({ + control_id: 'test_control_id', + call_id: 'test_call_id', + node_id: 'test_node_id', + state: 'finished', + }) + + await expect(callTap.stop()).rejects.toThrowError('Action has ended') + }) + + describe('decorateTapPromise', () => { + it('expose correct properties before resolve', () => { + const innerPromise = Promise.resolve(callTap) + + const decoratedPromise = decorateTapPromise.call(call, innerPromise) + + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise.onStarted()).toBeInstanceOf(Promise) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise.onEnded()).toBeInstanceOf(Promise) + methods.forEach((method) => { + expect(decoratedPromise).toHaveProperty(method, expect.any(Function)) + // @ts-expect-error + expect(decoratedPromise[method]()).toBeInstanceOf(Promise) + }) + getters.forEach((getter) => { + expect(decoratedPromise).toHaveProperty(getter) + // @ts-expect-error + expect(decoratedPromise[getter]).toBeInstanceOf(Promise) + }) + }) + + it('expose correct properties after resolve', async () => { + const innerPromise = Promise.resolve(callTap) + + const decoratedPromise = decorateTapPromise.call(call, innerPromise) + + // Simulate the tap ended event + call.emit('tap.ended', callTap) + + const ended = await decoratedPromise + + expect(ended).not.toHaveProperty('onStarted', expect.any(Function)) + expect(ended).not.toHaveProperty('onEnded', expect.any(Function)) + methods.forEach((method) => { + expect(ended).toHaveProperty(method, expect.any(Function)) + }) + getters.forEach((getter) => { + expect(ended).toHaveProperty(getter) + // @ts-expect-error + expect(ended[getter]).not.toBeInstanceOf(Promise) + }) + }) + + it('resolves when tap ends', async () => { + const innerPromise = Promise.resolve(callTap) + + const decoratedPromise = decorateTapPromise.call(call, innerPromise) + + // Simulate the tap ended event + call.emit('tap.ended', callTap) + + await expect(decoratedPromise).resolves.toEqual(expect.any(CallTap)) + }) + + it('rejects on inner promise rejection', async () => { + const innerPromise = Promise.reject(new Error('Tap failed')) + + const decoratedPromise = decorateTapPromise.call(call, innerPromise) + + await expect(decoratedPromise).rejects.toThrow('Tap failed') + }) + }) +}) diff --git a/packages/realtime-api/src/voice/CallTap.ts b/packages/realtime-api/src/voice/CallTap/CallTap.ts similarity index 78% rename from packages/realtime-api/src/voice/CallTap.ts rename to packages/realtime-api/src/voice/CallTap/CallTap.ts index 96b09633e..f2f298be6 100644 --- a/packages/realtime-api/src/voice/CallTap.ts +++ b/packages/realtime-api/src/voice/CallTap/CallTap.ts @@ -3,13 +3,13 @@ import { CallingCallTapEndState, CallingCallTapEventParams, } from '@signalwire/core' -import { ListenSubscriber } from '../ListenSubscriber' +import { ListenSubscriber } from '../../ListenSubscriber' import { CallTapEvents, CallTapListeners, CallTapListenersEventsMapping, -} from '../types' -import { Call } from './Call' +} from '../../types' +import { Call } from '../Call' export interface CallTapOptions { call: Call @@ -59,23 +59,32 @@ export class CallTap return this._payload.state } + get hasEnded() { + if (ENDED_STATES.includes(this.state as CallingCallTapEndState)) { + return true + } + return false + } + /** @internal */ setPayload(payload: CallingCallTapEventParams) { this._payload = payload } async stop() { - if (this.state !== 'finished') { - await this._client.execute({ - method: 'calling.tap.stop', - params: { - node_id: this.nodeId, - call_id: this.callId, - control_id: this.controlId, - }, - }) + if (this.hasEnded) { + throw new Error('Action has ended') } + await this._client.execute({ + method: 'calling.tap.stop', + params: { + node_id: this.nodeId, + call_id: this.callId, + control_id: this.controlId, + }, + }) + return this } @@ -95,7 +104,7 @@ export class CallTap this.once('tap.ended', handler) // Resolve the promise if the tap has already ended - if (ENDED_STATES.includes(this.state as CallingCallTapEndState)) { + if (this.hasEnded) { handler() } }) diff --git a/packages/realtime-api/src/voice/CallTap/decorateTapPromise.ts b/packages/realtime-api/src/voice/CallTap/decorateTapPromise.ts new file mode 100644 index 000000000..33d407b5c --- /dev/null +++ b/packages/realtime-api/src/voice/CallTap/decorateTapPromise.ts @@ -0,0 +1,42 @@ +import { + CallingCallTapEndState, + CallingCallTapState, + Promisify, +} from '@signalwire/core' +import { Call } from '../Call' +import { CallTap } from './CallTap' +import { decoratePromise } from '../decoratePromise' +import { CallTapListeners } from '../../types' + +export interface CallTapEnded { + id: string + callId: string + nodeId: string + controlId: string + state: CallingCallTapEndState +} + +export interface CallTapPromise + extends Promise, + Omit, 'state'> { + onStarted: () => Promise + onEnded: () => Promise + listen: (listeners: CallTapListeners) => Promise<() => Promise> + stop: () => Promise + ended: () => Promise + state: Promise +} + +export const getters = ['id', 'callId', 'nodeId', 'controlId', 'state'] + +export const methods = ['stop', 'ended'] + +export function decorateTapPromise(this: Call, innerPromise: Promise) { + // prettier-ignore + return (decoratePromise).call(this, { + promise: innerPromise, + namespace: 'tap', + methods, + getters, + }) as CallTapPromise +} diff --git a/packages/realtime-api/src/voice/CallTap/index.ts b/packages/realtime-api/src/voice/CallTap/index.ts new file mode 100644 index 000000000..e29741871 --- /dev/null +++ b/packages/realtime-api/src/voice/CallTap/index.ts @@ -0,0 +1,3 @@ +export * from './CallTap' +export * from './decorateTapPromise' +export { decorateTapPromise } from './decorateTapPromise' diff --git a/packages/realtime-api/src/voice/decoratePromise.test.ts b/packages/realtime-api/src/voice/decoratePromise.test.ts new file mode 100644 index 000000000..4cf642cc0 --- /dev/null +++ b/packages/realtime-api/src/voice/decoratePromise.test.ts @@ -0,0 +1,89 @@ +import { Voice } from './Voice' +import { Call } from './Call' +import { decoratePromise, DecoratePromiseOptions } from './decoratePromise' +import { createClient } from '../client/createClient' + +class MockApi { + _ended: boolean = false + + get hasEnded() { + return this._ended + } + + get getter1() { + return 'getter1' + } + + get getter2() { + return 'getter2' + } + + method1() {} + + method2() {} +} + +describe('decoratePromise', () => { + let voice: Voice + let call: Call + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + }) + + it('should decorate a promise correctly', async () => { + const mockInnerPromise = Promise.resolve(new MockApi()) + + const options: DecoratePromiseOptions = { + promise: mockInnerPromise, + namespace: 'playback', + methods: ['method1', 'method2'], + getters: ['getter1', 'getter2'], + } + + const decoratedPromise = decoratePromise.call(call, options) + + // All properties before the promise resolve + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('method1', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('method2', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('getter1') + expect(decoratedPromise).toHaveProperty('getter2') + + // @ts-expect-error + const onStarted = decoratedPromise.onStarted() + expect(onStarted).toBeInstanceOf(Promise) + expect(await onStarted).toBeInstanceOf(MockApi) + + // @ts-expect-error + const onEnded = decoratedPromise.onEnded() + expect(onEnded).toBeInstanceOf(Promise) + // @ts-expect-error + call.emit('playback.ended', new MockApi()) + expect(await onEnded).toBeInstanceOf(MockApi) + + const resolved = await decoratedPromise + + // All properties after the promise resolve + expect(resolved).not.toHaveProperty('onStarted', expect.any(Function)) + expect(resolved).not.toHaveProperty('onEnded', expect.any(Function)) + expect(resolved).toHaveProperty('method1', expect.any(Function)) + expect(resolved).toHaveProperty('method2', expect.any(Function)) + expect(resolved).toHaveProperty('getter1') + expect(resolved).toHaveProperty('getter2') + }) +}) diff --git a/packages/realtime-api/src/voice/decoratePromise.ts b/packages/realtime-api/src/voice/decoratePromise.ts new file mode 100644 index 000000000..1b6b50eb6 --- /dev/null +++ b/packages/realtime-api/src/voice/decoratePromise.ts @@ -0,0 +1,80 @@ +import { Call } from './Call' + +export interface DecoratePromiseOptions { + promise: Promise + namespace: 'playback' | 'recording' | 'prompt' | 'tap' | 'detect' | 'collect' + methods: string[] + getters: string[] +} + +export function decoratePromise( + this: Call, + options: DecoratePromiseOptions +): Promise { + const { promise: innerPromise, namespace, methods, getters } = options + + const promise = new Promise((resolve, reject) => { + const endedHandler = (instance: U) => { + // @ts-expect-error + this.off(`${namespace}.ended`, endedHandler) + resolve(instance) + } + + // @ts-expect-error + this.once(`${namespace}.ended`, endedHandler) + + innerPromise.catch((error) => { + // @ts-expect-error + this.off(`${namespace}.ended`, endedHandler) + reject(error) + }) + }) + + Object.defineProperties(promise, { + onStarted: { + value: async function () { + return await innerPromise + }, + enumerable: true, + }, + onEnded: { + value: async function () { + const instance = await this.onStarted() + if (instance.hasEnded) { + return this + } + return await promise + }, + enumerable: true, + }, + listen: { + value: async function (...args: any) { + const instance = await this.onStarted() + return instance.listen(...args) + }, + enumerable: true, + }, + ...methods.reduce((acc: Record, method) => { + acc[method] = { + value: async function (...args: any) { + const instance = await this.onStarted() + return instance[method](...args) + }, + enumerable: true, + } + return acc + }, {}), + ...getters.reduce((acc: Record, gettter) => { + acc[gettter] = { + get: async function () { + const instance = await this.onStarted() + return instance[gettter] + }, + enumerable: true, + } + return acc + }, {}), + }) + + return promise +} From 514777537c10299a174dbc0937702dcd6740e18f Mon Sep 17 00:00:00 2001 From: Ammar Ansari Date: Mon, 2 Oct 2023 12:46:49 +0300 Subject: [PATCH 08/15] Realtime Video SDK with new interface (#886) * Realtime Video SDK with new interface * room session with the new interface * remove auto subscribe consumer * fix unit tests for video and room session * room member instance * unit tests for room session member * fix stack test * room session playback realtime-api instance * room session recording realtime-api instance * room session stream realtime-api instance * explicit methods for the realtime-api * fix build issue * separate workers for playback, recording and stream * video playground with the new interface * decorated promise for room session playback api * decorated promise for room session recording api * decorated promise for room session stream api * fix unit test cases * unit tests for decorated promises * update video play ground with decorated promise * fix e2e test case for the video * fix unit test * do not unsubscribe events * fix unit test * include changeset * streaming getter for room session * rename types --- .changeset/fluffy-birds-yawn.md | 33 + .../src/playwright/video.test.ts | 109 +- .../src/with-events/index.ts | 102 +- internal/stack-tests/src/video/app.ts | 18 +- packages/core/src/index.ts | 1 + packages/core/src/types/videoLayout.ts | 6 + packages/core/src/types/videoMember.ts | 41 +- packages/core/src/types/videoPlayback.ts | 22 +- packages/core/src/types/videoRecording.ts | 18 + packages/core/src/types/videoRoomSession.ts | 32 + packages/core/src/types/videoStream.ts | 14 + packages/core/src/utils/interfaces.ts | 6 + .../realtime-api/src/AutoSubscribeConsumer.ts | 54 - packages/realtime-api/src/Client.ts | 100 -- .../realtime-api/src/ListenSubscriber.test.ts | 9 + packages/realtime-api/src/ListenSubscriber.ts | 17 +- packages/realtime-api/src/SWClient.ts | 11 +- .../realtime-api/src/client/createClient.ts | 5 +- .../realtime-api/src/decoratePromise.test.ts | 158 +++ .../src/{voice => }/decoratePromise.ts | 14 +- packages/realtime-api/src/index.ts | 83 +- packages/realtime-api/src/types/video.ts | 993 +++++++++++++++++- packages/realtime-api/src/types/voice.ts | 12 + packages/realtime-api/src/video/BaseVideo.ts | 80 ++ .../src/video/RoomSession.test.ts | 155 +-- .../realtime-api/src/video/RoomSession.ts | 279 +++-- .../RoomSessionMember.test.ts | 114 +- .../RoomSessionMember.ts | 72 +- .../src/video/RoomSessionMember/index.ts | 1 + .../RoomSessionPlayback.test.ts | 213 ++++ .../RoomSessionPlayback.ts | 217 ++++ .../decoratePlaybackPromise.ts | 71 ++ .../src/video/RoomSessionPlayback/index.ts | 3 + .../RoomSessionRecording.test.ts | 199 ++++ .../RoomSessionRecording.ts | 138 +++ .../decorateRecordingPromise.ts | 53 + .../src/video/RoomSessionRecording/index.ts | 3 + .../RoomSessionStream.test.ts | 185 ++++ .../RoomSessionStream/RoomSessionStream.ts | 111 ++ .../decorateStreamPromise.ts | 53 + .../src/video/RoomSessionStream/index.ts | 3 + packages/realtime-api/src/video/Video.test.ts | 258 ++--- packages/realtime-api/src/video/Video.ts | 236 ++--- .../src/video/VideoClient.test.ts | 130 --- .../realtime-api/src/video/VideoClient.ts | 84 -- .../realtime-api/src/video/methods/index.ts | 38 + .../src/video/methods/methods.test.ts | 287 +++++ .../realtime-api/src/video/methods/methods.ts | 854 +++++++++++++++ .../src/video/workers/videoCallingWorker.ts | 73 +- .../src/video/workers/videoMemberWorker.ts | 8 +- .../src/video/workers/videoPlaybackWorker.ts | 16 +- .../src/video/workers/videoRecordingWorker.ts | 16 +- .../video/workers/videoRoomAudienceWorker.ts | 1 + .../src/video/workers/videoRoomWorker.ts | 54 +- .../src/video/workers/videoStreamWorker.ts | 16 +- .../CallCollect/decorateCollectPromise.ts | 2 +- .../voice/CallDetect/decorateDetectPromise.ts | 2 +- .../CallPlayback/decoratePlaybackPromise.ts | 2 +- .../voice/CallPrompt/decoratePromptPromise.ts | 2 +- .../CallRecording/decorateRecordingPromise.ts | 2 +- .../src/voice/CallTap/decorateTapPromise.ts | 2 +- packages/realtime-api/src/voice/Voice.ts | 10 +- .../src/voice/decoratePromise.test.ts | 89 -- 63 files changed, 4624 insertions(+), 1366 deletions(-) create mode 100644 .changeset/fluffy-birds-yawn.md delete mode 100644 packages/realtime-api/src/AutoSubscribeConsumer.ts delete mode 100644 packages/realtime-api/src/Client.ts create mode 100644 packages/realtime-api/src/decoratePromise.test.ts rename packages/realtime-api/src/{voice => }/decoratePromise.ts (88%) create mode 100644 packages/realtime-api/src/video/BaseVideo.ts rename packages/realtime-api/src/video/{ => RoomSessionMember}/RoomSessionMember.test.ts (61%) rename packages/realtime-api/src/video/{ => RoomSessionMember}/RoomSessionMember.ts (65%) create mode 100644 packages/realtime-api/src/video/RoomSessionMember/index.ts create mode 100644 packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.test.ts create mode 100644 packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.ts create mode 100644 packages/realtime-api/src/video/RoomSessionPlayback/decoratePlaybackPromise.ts create mode 100644 packages/realtime-api/src/video/RoomSessionPlayback/index.ts create mode 100644 packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.test.ts create mode 100644 packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.ts create mode 100644 packages/realtime-api/src/video/RoomSessionRecording/decorateRecordingPromise.ts create mode 100644 packages/realtime-api/src/video/RoomSessionRecording/index.ts create mode 100644 packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.test.ts create mode 100644 packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.ts create mode 100644 packages/realtime-api/src/video/RoomSessionStream/decorateStreamPromise.ts create mode 100644 packages/realtime-api/src/video/RoomSessionStream/index.ts delete mode 100644 packages/realtime-api/src/video/VideoClient.test.ts delete mode 100644 packages/realtime-api/src/video/VideoClient.ts create mode 100644 packages/realtime-api/src/video/methods/index.ts create mode 100644 packages/realtime-api/src/video/methods/methods.test.ts create mode 100644 packages/realtime-api/src/video/methods/methods.ts delete mode 100644 packages/realtime-api/src/voice/decoratePromise.test.ts diff --git a/.changeset/fluffy-birds-yawn.md b/.changeset/fluffy-birds-yawn.md new file mode 100644 index 000000000..213470334 --- /dev/null +++ b/.changeset/fluffy-birds-yawn.md @@ -0,0 +1,33 @@ +--- +'@signalwire/realtime-api': major +'@signalwire/core': major +--- + +- New interface for the realtime-api Video SDK. +- Listen function with _video_, _room_, _playback_, _recording_, and _stream_ objects. +- Listen param with `room.play`, `room.startRecording`, and `room.startStream` functions. +- Decorated promise for `room.play`, `room.startRecording`, and `room.startStream` functions. + +```js +import { SignalWire } from '@signalwire/realtime-api' + +const client = await SignalWire({ project, token }) + +const unsub = await client.video.listen({ + onRoomStarted: async (roomSession) => { + console.log('room session started', roomSession) + + await roomSession.listen({ + onPlaybackStarted: (playback) => { + console.log('plyaback started', playback) + } + }) + + // Promise resolves when playback ends. + await roomSession.play({ url: "http://.....", listen: { onEnded: () => {} } }) + }, + onRoomEnded: (roomSession) => { + console.log('room session ended', roomSession) + } +}) +``` \ No newline at end of file diff --git a/internal/e2e-realtime-api/src/playwright/video.test.ts b/internal/e2e-realtime-api/src/playwright/video.test.ts index e052d0fdf..21d54c287 100644 --- a/internal/e2e-realtime-api/src/playwright/video.test.ts +++ b/internal/e2e-realtime-api/src/playwright/video.test.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test' import { uuid } from '@signalwire/core' -import { Video } from '@signalwire/realtime-api' +import { SignalWire, Video } from '@signalwire/realtime-api' import { createRoomAndRecordPlay, createRoomSession, @@ -10,8 +10,7 @@ import { SERVER_URL } from '../../utils' test.describe('Video', () => { test('should join the room and listen for events', async ({ browser }) => { - const videoClient = new Video.Client({ - // @ts-expect-error + const client = await SignalWire({ host: process.env.RELAY_HOST, project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, @@ -23,19 +22,20 @@ test.describe('Video', () => { const roomSessionCreated = new Map() const findRoomSessionsByPrefix = async () => { - const { roomSessions } = await videoClient.getRoomSessions() + const { roomSessions } = await client.video.getRoomSessions() return roomSessions.filter((r) => r.name.startsWith(prefix)) } - videoClient.on('room.started', async (roomSession) => { - console.log('Room started', roomSession.id) - if (roomSession.name.startsWith(prefix)) { - roomSessionCreated.set(roomSession.id, roomSession) - } - }) - - videoClient.on('room.ended', async (roomSession) => { - console.log('Room ended', roomSession.id) + await client.video.listen({ + onRoomStarted: (roomSession) => { + console.log('Room started', roomSession.id) + if (roomSession.name.startsWith(prefix)) { + roomSessionCreated.set(roomSession.id, roomSession) + } + }, + onRoomEnded: (roomSession) => { + console.log('Room ended', roomSession.id) + }, }) const roomSessionsAtStart = await findRoomSessionsByPrefix() @@ -77,47 +77,55 @@ test.describe('Video', () => { for (let index = 0; index < roomSessionsRunning.length; index++) { const rs = roomSessionsRunning[index] - await new Promise((resolve) => { - rs.on('recording.ended', noop) - rs.on('playback.ended', noop) - rs.on('room.updated', noop) - rs.on('room.subscribed', resolve) + await new Promise(async (resolve) => { + await rs.listen({ + onRecordingEnded: noop, + onPlaybackEnded: noop, + onRoomUpdated: noop, + onRoomSubscribed: resolve, + }) }) await new Promise(async (resolve) => { - rs.on('recording.ended', () => { - resolve() + await rs.listen({ + onRecordingEnded: () => resolve(), }) const { recordings } = await rs.getRecordings() await Promise.all(recordings.map((r) => r.stop())) }) await new Promise(async (resolve) => { - rs.on('playback.ended', () => { - resolve() + await rs.listen({ + onPlaybackEnded: () => resolve(), }) const { playbacks } = await rs.getPlaybacks() await Promise.all(playbacks.map((p) => p.stop())) }) await new Promise(async (resolve, reject) => { - rs.on('room.updated', (roomSession) => { - if (roomSession.locked === true) { - resolve() - } else { - reject(new Error('Not locked')) - } + const unsub = await rs.listen({ + onRoomUpdated: async (roomSession) => { + if (roomSession.locked === true) { + resolve() + await unsub() + } else { + reject(new Error('Not locked')) + } + }, }) await rs.lock() }) await new Promise(async (resolve, reject) => { - rs.on('room.updated', (roomSession) => { - if (roomSession.locked === false) { - resolve() - } else { - reject(new Error('Still locked')) - } + const unsub = await rs.listen({ + onRoomUpdated: async (roomSession) => { + if (roomSession.locked === false) { + resolve() + await unsub() + } else { + reject(new Error('Not locked')) + } + }, }) await rs.unlock() }) @@ -132,32 +140,35 @@ test.describe('Video', () => { test('should join the room and set hand raise priority', async ({ browser, }) => { - const page = await browser.newPage() - await page.goto(SERVER_URL) - enablePageLogs(page, '[pageOne]') - - // Create a realtime-api Video client - const videoClient = new Video.Client({ - // @ts-expect-error + const client = await SignalWire({ host: process.env.RELAY_HOST, project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, debug: { logWsTraffic: true }, }) + const page = await browser.newPage() + await page.goto(SERVER_URL) + enablePageLogs(page, '[pageOne]') + const prefix = uuid() const roomName = `${prefix}-hand-raise-priority-e2e` const findRoomSession = async () => { - const { roomSessions } = await videoClient.getRoomSessions() + const { roomSessions } = await client.video.getRoomSessions() return roomSessions.filter((r) => r.name.startsWith(prefix)) } // Listen for realtime-api event - videoClient.on('room.started', (room) => { - room.on('room.updated', (room) => { - console.log('>> room.updated', room.name) - }) + await client.video.listen({ + onRoomStarted: async (roomSession) => { + console.log('>> room.started', roomSession.name) + await roomSession.listen({ + onRoomUpdated: (room) => { + console.log('>> room.updated', room.name) + }, + }) + }, }) // Room length should be 0 before start @@ -203,8 +214,10 @@ test.describe('Video', () => { // Set the hand raise prioritization via Node SDK const roomSessionNodeUpdated = await new Promise( async (resolve, _reject) => { - roomSessionNode.on('room.updated', (room) => { - resolve(room) + await roomSessionNode.listen({ + onRoomUpdated: (room) => { + resolve(room) + }, }) await roomSessionNode.setPrioritizeHandraise(true) } diff --git a/internal/playground-realtime-api/src/with-events/index.ts b/internal/playground-realtime-api/src/with-events/index.ts index 59a10c702..4fb271ec8 100644 --- a/internal/playground-realtime-api/src/with-events/index.ts +++ b/internal/playground-realtime-api/src/with-events/index.ts @@ -1,50 +1,100 @@ -import { Video } from '@signalwire/realtime-api' +import { Video, SignalWire } from '@signalwire/realtime-api' async function run() { try { - const video = new Video.Client({ - // @ts-expect-error + const client = await SignalWire({ host: process.env.HOST || 'relay.swire.io', project: process.env.PROJECT as string, token: process.env.TOKEN as string, debug: { - logWsTraffic: true, + // logWsTraffic: true, }, }) - const roomSessionHandler = (room: Video.RoomSession) => { - console.log('Room started --->', room.id, room.name, room.members) - room.on('room.subscribed', (room) => { - console.log('Room Subscribed --->', room.id, room.members) - }) + const unsubVideo = await client.video.listen({ + onRoomStarted(room) { + console.log('🟢 onRoomStarted 🟢', room.id, room.name) + roomSessionHandler(room) + }, + onRoomEnded(room) { + console.log('🔴 onRoomEnded 🔴', room.id, room.name) + }, + }) - room.on('member.updated', () => { - console.log('Member updated --->') - }) + const roomSessionHandler = async (room: Video.RoomSession) => { + const unsubRoom = await room.listen({ + onRoomSubscribed: (room) => { + console.log('onRoomSubscribed', room.id, room.name) + }, + onRoomStarted: (room) => { + console.log('onRoomStarted', room.id, room.name) + }, + onRoomUpdated: (room) => { + console.log('onRoomUpdated', room.id, room.name) + }, + onRoomEnded: (room) => { + console.log('onRoomEnded', room.id, room.name) + }, + onMemberJoined: async (member) => { + console.log('onMemberJoined --->', member.id, member.name) - room.on('member.joined', (member) => { - console.log('Member joined --->', member.id, member.name) - }) + const play = await room + .play({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + listen: { + onStarted: (playback) => { + console.log('onStarted', playback.id, playback.url) + }, + onUpdated: (playback) => { + console.log('onUpdated', playback.id, playback.url) + }, + onEnded: (playback) => { + console.log('onEnded', playback.id, playback.url) + }, + }, + }) + .onStarted() + console.log('play', play.id) + + setTimeout(async () => { + await play.pause() - room.on('member.left', (member) => { - console.log('Member left --->', member.id, member.name) + setTimeout(async () => { + await play.stop() + }, 5000) + }, 10000) + }, + onMemberUpdated: (member) => { + console.log('onMemberUpdated', member.id, member.name) + }, + onMemberTalking: (member) => { + console.log('onMemberTalking', member.id, member.name) + }, + onMemberLeft: (member) => { + console.log('onMemberLeft', member.id, member.name) + }, + onPlaybackStarted: (playback) => { + console.log('onPlaybackStarted', playback.id, playback.url) + }, + onPlaybackUpdated: (playback) => { + console.log('onPlaybackUpdated', playback.id, playback.url) + }, + onPlaybackEnded: (playback) => { + console.log('onPlaybackEnded', playback.id, playback.url) + }, }) } - video.on('room.started', roomSessionHandler) - - video.on('room.ended', (room) => { - console.log('🔴 ROOOM ENDED 🔴', `${room}`, room.name) - }) - video._session.on('session.connected', () => { + // @ts-expect-error + client.video._client.session.on('session.connected', () => { console.log('SESSION CONNECTED!') }) console.log('Client Running..') - const { roomSessions } = await video.getRoomSessions() + const { roomSessions } = await client.video.getRoomSessions() - roomSessions.forEach(async (room: any) => { + roomSessions.forEach(async (room: Video.RoomSession) => { console.log('>> Room Session: ', room.id, room.displayName) roomSessionHandler(room) @@ -52,7 +102,7 @@ async function run() { console.log('Members:', r) // await room.removeAllMembers() - const { roomSession } = await video.getRoomSessionById(room.id) + const { roomSession } = await client.video.getRoomSessionById(room.id) console.log('Room Session By ID:', roomSession.displayName) }) } catch (error) { diff --git a/internal/stack-tests/src/video/app.ts b/internal/stack-tests/src/video/app.ts index e63cf4bd5..458fc45d9 100644 --- a/internal/stack-tests/src/video/app.ts +++ b/internal/stack-tests/src/video/app.ts @@ -1,20 +1,22 @@ -import { Video } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' import tap from 'tap' async function run() { try { - const video = new Video.Client({ - // @ts-expect-error + const client = await SignalWire({ host: process.env.RELAY_HOST || 'relay.swire.io', project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, }) - tap.ok(video.on, 'video.on is defined') - tap.ok(video.once, 'video.once is defined') - tap.ok(video.off, 'video.off is defined') - tap.ok(video.subscribe, 'video.subscribe is defined') - tap.ok(video.removeAllListeners, 'video.removeAllListeners is defined') + tap.ok(client.video, 'client.video is defined') + tap.ok(client.video.listen, 'client.video.listen is defined') + tap.ok(client.video.getRoomSessions, 'video.getRoomSessions is defined') + tap.ok( + client.video.getRoomSessionById, + 'video.getRoomSessionById is defined' + ) + tap.ok(client.disconnect, 'video.disconnect is defined') process.exit(0) } catch (error) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 420b27ee9..dcfe75468 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -89,6 +89,7 @@ export type { SDKActions, ReduxComponent, } from './redux/interfaces' +export type { SDKStore } from './redux' export type { ToExternalJSONResult } from './utils' export * as actions from './redux/actions' export * as sagaHelpers from './redux/utils/sagaHelpers' diff --git a/packages/core/src/types/videoLayout.ts b/packages/core/src/types/videoLayout.ts index cfe83b671..f9be81738 100644 --- a/packages/core/src/types/videoLayout.ts +++ b/packages/core/src/types/videoLayout.ts @@ -3,12 +3,18 @@ import { VideoPosition } from '..' import type { CamelToSnakeCase, ToInternalVideoEvent } from './utils' export type LayoutChanged = 'layout.changed' +export type OnLayoutChanged = 'onLayoutChanged' /** * List of public event names */ export type VideoLayoutEventNames = LayoutChanged +/** + * List of public listener names + */ +export type VideoLayoutListenerNames = OnLayoutChanged + /** * List of internal events * @internal diff --git a/packages/core/src/types/videoMember.ts b/packages/core/src/types/videoMember.ts index 9196f1cf6..1423ba016 100644 --- a/packages/core/src/types/videoMember.ts +++ b/packages/core/src/types/videoMember.ts @@ -61,7 +61,15 @@ export type MemberTalking = 'member.talking' export type MemberPromoted = 'member.promoted' export type MemberDemoted = 'member.demoted' -// Generated by the SDK +/** + * Public listener types + */ +export type OnMemberJoined = 'onMemberJoined' +export type OnMemberLeft = 'onMemberLeft' +export type OnMemberUpdated = 'onMemberUpdated' +export type OnMemberTalking = 'onMemberTalking' +export type OnMemberPromoted = 'onMemberPromoted' +export type OnMemberDemoted = 'onMemberDemoted' /** * @privateRemarks @@ -72,6 +80,7 @@ export type MemberDemoted = 'member.demoted' * room. */ export type MemberListUpdated = 'memberList.updated' +export type OnMemberListUpdated = 'onMemberListUpdated' /** * See {@link MEMBER_UPDATED_EVENTS} for the full list of events. @@ -79,6 +88,17 @@ export type MemberListUpdated = 'memberList.updated' export type MemberUpdatedEventNames = (typeof MEMBER_UPDATED_EVENTS)[number] export type MemberTalkingStarted = 'member.talking.started' export type MemberTalkingEnded = 'member.talking.ended' + +export type OnMemberDeaf = 'onMemberDeaf' +export type OnMemberVisible = 'onMemberVisible' +export type OnMemberAudioMuted = 'onMemberAudioMuted' +export type OnMemberVideoMuted = 'onMemberVideoMuted' +export type OnMemberInputVolume = 'onMemberInputVolume' +export type OnMemberOutputVolume = 'onMemberOutputVolume' +export type OnMemberInputSensitivity = 'onMemberInputSensitivity' +export type OnMemberTalkingStarted = 'onMemberTalkingStarted' +export type OnMemberTalkingEnded = 'onMemberTalkingEnded' + /** * Use `member.talking.started` instead * @deprecated @@ -97,6 +117,11 @@ export type MemberTalkingEventNames = | MemberTalkingStart | MemberTalkingStop +export type MemberTalkingListenerNames = + | OnMemberTalking + | OnMemberTalkingStarted + | OnMemberTalkingEnded + /** * List of public events */ @@ -108,6 +133,20 @@ export type VideoMemberEventNames = | MemberTalkingEventNames | MemberListUpdated +export type VideoMemberListenerNames = + | OnMemberJoined + | OnMemberLeft + | OnMemberUpdated + | OnMemberDeaf + | OnMemberVisible + | OnMemberAudioMuted + | OnMemberVideoMuted + | OnMemberInputVolume + | OnMemberOutputVolume + | OnMemberInputSensitivity + | MemberTalkingListenerNames + | OnMemberListUpdated + export type InternalMemberUpdatedEventNames = (typeof INTERNAL_MEMBER_UPDATED_EVENTS)[number] diff --git a/packages/core/src/types/videoPlayback.ts b/packages/core/src/types/videoPlayback.ts index 1670bfa69..97bc903f9 100644 --- a/packages/core/src/types/videoPlayback.ts +++ b/packages/core/src/types/videoPlayback.ts @@ -1,4 +1,5 @@ import type { SwEvent } from '.' +import { MapToPubSubShape } from '..' import type { CamelToSnakeCase, ToInternalVideoEvent, @@ -13,6 +14,13 @@ export type PlaybackStarted = 'playback.started' export type PlaybackUpdated = 'playback.updated' export type PlaybackEnded = 'playback.ended' +/** + * Public listener types + */ +export type OnPlaybackStarted = 'onPlaybackStarted' +export type OnPlaybackUpdated = 'onPlaybackUpdated' +export type OnPlaybackEnded = 'onPlaybackEnded' + /** * List of public event names */ @@ -21,6 +29,14 @@ export type VideoPlaybackEventNames = | PlaybackUpdated | PlaybackEnded +/** + * List of public listener names + */ +export type VideoPlaybackListenerNames = + | OnPlaybackStarted + | OnPlaybackUpdated + | OnPlaybackEnded + /** * List of internal events * @internal @@ -42,7 +58,7 @@ export interface VideoPlaybackContract { state: 'playing' | 'paused' | 'completed' /** The current playback position, in milliseconds. */ - position: number; + position: number /** Whether the seek function can be used for this playback. */ seekable: boolean @@ -54,7 +70,7 @@ export interface VideoPlaybackContract { volume: number /** Start time, if available */ - startedAt: Date + startedAt?: Date /** End time, if available */ endedAt?: Date @@ -162,3 +178,5 @@ export type VideoPlaybackEventParams = | VideoPlaybackStartedEventParams | VideoPlaybackUpdatedEventParams | VideoPlaybackEndedEventParams + +export type VideoPlaybackAction = MapToPubSubShape diff --git a/packages/core/src/types/videoRecording.ts b/packages/core/src/types/videoRecording.ts index 22aa21620..9a2decf88 100644 --- a/packages/core/src/types/videoRecording.ts +++ b/packages/core/src/types/videoRecording.ts @@ -1,4 +1,5 @@ import type { SwEvent } from '.' +import { MapToPubSubShape } from '..' import type { CamelToSnakeCase, ConvertToInternalTypes, @@ -14,6 +15,13 @@ export type RecordingStarted = 'recording.started' export type RecordingUpdated = 'recording.updated' export type RecordingEnded = 'recording.ended' +/** + * Public listener types + */ +export type OnRecordingStarted = 'onRecordingStarted' +export type OnRecordingUpdated = 'onRecordingUpdated' +export type OnRecordingEnded = 'onRecordingEnded' + /** * List of public event names */ @@ -22,6 +30,14 @@ export type VideoRecordingEventNames = | RecordingUpdated | RecordingEnded +/** + * List of public listener names + */ +export type VideoRecordingListenerNames = + | OnRecordingStarted + | OnRecordingUpdated + | OnRecordingEnded + /** * List of internal events * @internal @@ -150,3 +166,5 @@ export type VideoRecordingEventParams = | VideoRecordingStartedEventParams | VideoRecordingUpdatedEventParams | VideoRecordingEndedEventParams + +export type VideoRecordingAction = MapToPubSubShape diff --git a/packages/core/src/types/videoRoomSession.ts b/packages/core/src/types/videoRoomSession.ts index 9bb80344b..271e7187b 100644 --- a/packages/core/src/types/videoRoomSession.ts +++ b/packages/core/src/types/videoRoomSession.ts @@ -11,6 +11,7 @@ import type { } from './utils' import type { InternalVideoMemberEntity } from './videoMember' import * as Rooms from '../rooms' +import { MapToPubSubShape } from '../redux/interfaces' /** * Public event types @@ -26,6 +27,17 @@ export type RoomJoined = 'room.joined' export type RoomLeft = 'room.left' export type RoomAudienceCount = 'room.audienceCount' +/** + * Public listener types + */ +export type OnRoomStarted = 'onRoomStarted' +export type OnRoomSubscribed = 'onRoomSubscribed' +export type OnRoomUpdated = 'onRoomUpdated' +export type OnRoomEnded = 'onRoomEnded' +export type OnRoomAudienceCount = 'onRoomAudienceCount' +export type OnRoomJoined = 'onRoomJoined' +export type OnRoomLeft = 'onRoomLeft' + export type RoomLeftEventParams = { reason?: BaseConnectionContract['leaveReason'] } @@ -45,6 +57,17 @@ export type VideoRoomSessionEventNames = | RoomJoined // only used in `js` (emitted by `webrtc`) | RoomLeft // only used in `js` +/** + * List of public listener names + */ +export type VideoRoomSessionListenerNames = + | OnRoomStarted + | OnRoomSubscribed + | OnRoomUpdated + | OnRoomEnded + | OnRoomJoined // only used in `js` (emitted by `webrtc`) + | OnRoomLeft // only used in `js` + /** * List of internal events * @internal @@ -946,3 +969,12 @@ export type VideoRoomEventParams = | VideoRoomSubscribedEventParams | VideoRoomUpdatedEventParams | VideoRoomEndedEventParams + +export type VideoRoomStartedAction = MapToPubSubShape + +export type VideoRoomEndedAction = MapToPubSubShape + +export type VideoRoomUpdatedAction = MapToPubSubShape + +export type VideoRoomSubscribedAction = + MapToPubSubShape diff --git a/packages/core/src/types/videoStream.ts b/packages/core/src/types/videoStream.ts index 09af7a6e6..e02b62c0d 100644 --- a/packages/core/src/types/videoStream.ts +++ b/packages/core/src/types/videoStream.ts @@ -1,4 +1,5 @@ import type { SwEvent } from '.' +import { MapToPubSubShape } from '..' import type { CamelToSnakeCase, ConvertToInternalTypes, @@ -13,11 +14,22 @@ import type { export type StreamStarted = 'stream.started' export type StreamEnded = 'stream.ended' +/** + * Public listener types + */ +export type OnStreamStarted = 'onStreamStarted' +export type OnStreamEnded = 'onStreamEnded' + /** * List of public event names */ export type VideoStreamEventNames = StreamStarted | StreamEnded +/** + * List of public listener names + */ +export type VideoStreamListenerNames = OnStreamStarted | OnStreamEnded + /** * List of internal events * @internal @@ -124,3 +136,5 @@ export type VideoStreamEvent = VideoStreamStartedEvent | VideoStreamEndedEvent export type VideoStreamEventParams = | VideoStreamStartedEventParams | VideoStreamEndedEventParams + +export type VideoStreamAction = MapToPubSubShape diff --git a/packages/core/src/utils/interfaces.ts b/packages/core/src/utils/interfaces.ts index 2ad0d4600..df5ec78a9 100644 --- a/packages/core/src/utils/interfaces.ts +++ b/packages/core/src/utils/interfaces.ts @@ -56,6 +56,7 @@ export type JSONRPCMethod = | 'signalwire.event' | 'signalwire.reauthenticate' | 'signalwire.subscribe' + | 'signalwire.unsubscribe' | WebRTCMethod | RoomMethod | VertoMethod @@ -69,6 +70,11 @@ export type JSONRPCSubscribeMethod = Extract< 'signalwire.subscribe' | 'chat.subscribe' > +export type JSONRPCUnSubscribeMethod = Extract< + JSONRPCMethod, + 'signalwire.unsubscribe' +> + export interface JSONRPCRequest { jsonrpc: '2.0' id: string diff --git a/packages/realtime-api/src/AutoSubscribeConsumer.ts b/packages/realtime-api/src/AutoSubscribeConsumer.ts deleted file mode 100644 index 8607e1357..000000000 --- a/packages/realtime-api/src/AutoSubscribeConsumer.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - BaseComponentOptions, - BaseConsumer, - EventEmitter, - debounce, - validateEventsToSubscribe, -} from '@signalwire/core' - -export class AutoSubscribeConsumer< - EventTypes extends EventEmitter.ValidEventTypes -> extends BaseConsumer { - /** @internal */ - private debouncedSubscribe: ReturnType - - constructor(options: BaseComponentOptions) { - super(options) - - this.debouncedSubscribe = debounce(this.subscribe, 100) - } - - /** @internal */ - protected override getSubscriptions() { - const eventNamesWithPrefix = this.eventNames().map( - (event) => `video.${String(event)}` - ) as EventEmitter.EventNames[] - return validateEventsToSubscribe(eventNamesWithPrefix) - } - - override on>( - event: T, - fn: EventEmitter.EventListener - ) { - const instance = super.on(event, fn) - this.debouncedSubscribe() - return instance - } - - override once>( - event: T, - fn: EventEmitter.EventListener - ) { - const instance = super.once(event, fn) - this.debouncedSubscribe() - return instance - } - - override off>( - event: T, - fn: EventEmitter.EventListener - ) { - const instance = super.off(event, fn) - return instance - } -} diff --git a/packages/realtime-api/src/Client.ts b/packages/realtime-api/src/Client.ts deleted file mode 100644 index 1da7c31d5..000000000 --- a/packages/realtime-api/src/Client.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - BaseClient, - EventsPrefix, - SessionState, - ClientContract, - ClientEvents, -} from '@signalwire/core' -import { createVideoObject, Video } from './video/Video' - -/** - * A real-time Client. - * - * To construct an instance of this class, please use {@link createClient}. - * - * Example usage: - * ```typescript - * import {createClient} from '@signalwire/realtime-api' - * - * // Obtain a client: - * const client = await createClient({ project, token }) - * - * // Listen on events: - * client.video.on('room.started', async (room) => { }) - * - * // Connect: - * await client.connect() - * ``` - * @deprecated It's no longer needed to create the client - * manually. You can use the product constructors, like - * Video.Client, to access the same functionality. - */ -export interface RealtimeClient - extends ClientContract { - /** - * Connects this client to the SignalWire network. - * - * As a general best practice, it is suggested to connect the event listeners - * *before* connecting the client, so that no events are lost. - * - * @returns Upon connection, asynchronously returns an instance of this same - * object. - * - * @example - * ```typescript - * const client = await createClient({project, token}) - * client.video.on('room.started', async (roomSession) => { }) // connect events - * await client.connect() - * ``` - */ - connect(): Promise - - /** - * Disconnects this client from the SignalWire network. - */ - disconnect(): void - - /** - * Access the Video API Consumer - */ - video: Video -} - -type ClientNamespaces = Video - -export class Client extends BaseClient { - private _consumers: Map = new Map() - - async onAuth(session: SessionState) { - try { - if (session.authStatus === 'authorized') { - this._consumers.forEach((consumer) => { - consumer.subscribe() - }) - } - } catch (error) { - this.logger.error('Client subscription failed.') - this.disconnect() - - /** - * TODO: This error is not being catched by us so it's - * gonna appear as `UnhandledPromiseRejectionWarning`. - * The reason we are re-throwing here is because if - * this happens something serious happened and the app - * won't work anymore since subscribes aren't working. - */ - throw error - } - } - - get video(): Video { - if (this._consumers.has('video')) { - return this._consumers.get('video')! - } - const video = createVideoObject({ - store: this.store, - }) - this._consumers.set('video', video) - return video - } -} diff --git a/packages/realtime-api/src/ListenSubscriber.test.ts b/packages/realtime-api/src/ListenSubscriber.test.ts index b65daf3f5..e48ef5d9f 100644 --- a/packages/realtime-api/src/ListenSubscriber.test.ts +++ b/packages/realtime-api/src/ListenSubscriber.test.ts @@ -40,6 +40,15 @@ describe('ListenSubscriber', () => { }) describe('listen', () => { + it.each([undefined, {}, false, 'blah'])( + 'should throw an error on wrong listen params', + async (param) => { + await expect(listentSubscriber.listen(param)).rejects.toThrow( + 'Invalid params!' + ) + } + ) + it('should call the subscribe method with listen options', async () => { const subscribeMock = jest.spyOn(listentSubscriber, 'subscribe') diff --git a/packages/realtime-api/src/ListenSubscriber.ts b/packages/realtime-api/src/ListenSubscriber.ts index b0a0737f9..7ac9826e6 100644 --- a/packages/realtime-api/src/ListenSubscriber.ts +++ b/packages/realtime-api/src/ListenSubscriber.ts @@ -33,6 +33,11 @@ export class ListenSubscriber< return this._emitter } + protected eventNames() { + return this.emitter.eventNames() + } + + /** @internal */ emit>( event: T, ...args: EventEmitter.EventArgs @@ -64,6 +69,14 @@ export class ListenSubscriber< public listen(listeners: T) { return new Promise<() => Promise>(async (resolve, reject) => { try { + if ( + !listeners || + listeners?.constructor !== Object || + Object.keys(listeners).length < 1 + ) { + throw new Error('Invalid params!') + } + const unsub = await this.subscribe(listeners) resolve(unsub) } catch (error) { @@ -103,7 +116,7 @@ export class ListenSubscriber< return unsub } - private _attachListeners(listeners: T) { + protected _attachListeners(listeners: T) { const listenerKeys = Object.keys(listeners) as Array> listenerKeys.forEach((key) => { if (typeof listeners[key] === 'function' && this._eventMap[key]) { @@ -115,7 +128,7 @@ export class ListenSubscriber< }) } - private _detachListeners(listeners: T) { + protected _detachListeners(listeners: T) { const listenerKeys = Object.keys(listeners) as Array> listenerKeys.forEach((key) => { if (typeof listeners[key] === 'function' && this._eventMap[key]) { diff --git a/packages/realtime-api/src/SWClient.ts b/packages/realtime-api/src/SWClient.ts index 4eddfe271..b0faae615 100644 --- a/packages/realtime-api/src/SWClient.ts +++ b/packages/realtime-api/src/SWClient.ts @@ -6,6 +6,7 @@ import { Messaging } from './messaging/Messaging' import { PubSub } from './pubSub/PubSub' import { Chat } from './chat/Chat' import { Voice } from './voice/Voice' +import { Video } from './video/Video' export interface SWClientOptions { host?: string @@ -19,10 +20,11 @@ export interface SWClientOptions { export class SWClient { private _task: Task + private _messaging: Messaging private _pubSub: PubSub private _chat: Chat private _voice: Voice - private _messaging: Messaging + private _video: Video public userOptions: SWClientOptions public client: Client @@ -81,4 +83,11 @@ export class SWClient { } return this._voice } + + get video() { + if (!this._video) { + this._video = new Video(this) + } + return this._video + } } diff --git a/packages/realtime-api/src/client/createClient.ts b/packages/realtime-api/src/client/createClient.ts index fc46a8460..792ee0d7c 100644 --- a/packages/realtime-api/src/client/createClient.ts +++ b/packages/realtime-api/src/client/createClient.ts @@ -1,4 +1,4 @@ -import { connect, ClientEvents } from '@signalwire/core' +import { connect, ClientEvents, SDKStore } from '@signalwire/core' import { setupInternals } from '../utils/internals' import { Client } from './Client' @@ -6,10 +6,11 @@ export const createClient = (userOptions: { project: string token: string logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' + store?: SDKStore }) => { const { emitter, store } = setupInternals(userOptions) const client = connect({ - store, + store: userOptions.store ?? store, Component: Client, })({ ...userOptions, store, emitter }) diff --git a/packages/realtime-api/src/decoratePromise.test.ts b/packages/realtime-api/src/decoratePromise.test.ts new file mode 100644 index 000000000..c23d8fd9f --- /dev/null +++ b/packages/realtime-api/src/decoratePromise.test.ts @@ -0,0 +1,158 @@ +import { Voice } from './voice/Voice' +import { Call } from './voice/Call' +import { decoratePromise, DecoratePromiseOptions } from './decoratePromise' +import { createClient } from './client/createClient' +import { Video } from './video/Video' +import { RoomSession } from './video/RoomSession' + +class MockApi { + _ended: boolean = false + + get hasEnded() { + return this._ended + } + + get getter1() { + return 'getter1' + } + + get getter2() { + return 'getter2' + } + + method1() {} + + method2() {} +} + +describe('decoratePromise', () => { + describe('Voice Call', () => { + let voice: Voice + let call: Call + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + }) + + it('should decorate a promise correctly', async () => { + const mockInnerPromise = Promise.resolve(new MockApi()) + + const options: DecoratePromiseOptions = { + promise: mockInnerPromise, + namespace: 'playback', + methods: ['method1', 'method2'], + getters: ['getter1', 'getter2'], + } + + const decoratedPromise = decoratePromise.call(call, options) + + // All properties before the promise resolve + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('method1', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('method2', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('getter1') + expect(decoratedPromise).toHaveProperty('getter2') + + // @ts-expect-error + const onStarted = decoratedPromise.onStarted() + expect(onStarted).toBeInstanceOf(Promise) + expect(await onStarted).toBeInstanceOf(MockApi) + + // @ts-expect-error + const onEnded = decoratedPromise.onEnded() + expect(onEnded).toBeInstanceOf(Promise) + // @ts-expect-error + call.emit('playback.ended', new MockApi()) + expect(await onEnded).toBeInstanceOf(MockApi) + + const resolved = await decoratedPromise + + // All properties after the promise resolve + expect(resolved).not.toHaveProperty('onStarted', expect.any(Function)) + expect(resolved).not.toHaveProperty('onEnded', expect.any(Function)) + expect(resolved).toHaveProperty('method1', expect.any(Function)) + expect(resolved).toHaveProperty('method2', expect.any(Function)) + expect(resolved).toHaveProperty('getter1') + expect(resolved).toHaveProperty('getter2') + }) + }) + + describe('Video RoomSession', () => { + let video: Video + let roomSession: RoomSession + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + video = new Voice(swClientMock) + // @ts-expect-error + roomSession = new RoomSession({ video, payload: {} }) + }) + + it('should decorate a promise correctly', async () => { + const mockInnerPromise = Promise.resolve(new MockApi()) + + const options: DecoratePromiseOptions = { + promise: mockInnerPromise, + namespace: 'playback', + methods: ['method1', 'method2'], + getters: ['getter1', 'getter2'], + } + + const decoratedPromise = decoratePromise.call(roomSession, options) + + // All properties before the promise resolve + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('method1', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('method2', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('getter1') + expect(decoratedPromise).toHaveProperty('getter2') + + // @ts-expect-error + const onStarted = decoratedPromise.onStarted() + expect(onStarted).toBeInstanceOf(Promise) + expect(await onStarted).toBeInstanceOf(MockApi) + + // @ts-expect-error + const onEnded = decoratedPromise.onEnded() + expect(onEnded).toBeInstanceOf(Promise) + // @ts-expect-error + roomSession.emit('playback.ended', new MockApi()) + expect(await onEnded).toBeInstanceOf(MockApi) + + const resolved = await decoratedPromise + + // All properties after the promise resolve + expect(resolved).not.toHaveProperty('onStarted', expect.any(Function)) + expect(resolved).not.toHaveProperty('onEnded', expect.any(Function)) + expect(resolved).toHaveProperty('method1', expect.any(Function)) + expect(resolved).toHaveProperty('method2', expect.any(Function)) + expect(resolved).toHaveProperty('getter1') + expect(resolved).toHaveProperty('getter2') + }) + }) +}) diff --git a/packages/realtime-api/src/voice/decoratePromise.ts b/packages/realtime-api/src/decoratePromise.ts similarity index 88% rename from packages/realtime-api/src/voice/decoratePromise.ts rename to packages/realtime-api/src/decoratePromise.ts index 1b6b50eb6..189e169b2 100644 --- a/packages/realtime-api/src/voice/decoratePromise.ts +++ b/packages/realtime-api/src/decoratePromise.ts @@ -1,14 +1,22 @@ -import { Call } from './Call' +import { Call } from './voice/Call' +import { RoomSession } from './video/RoomSession' export interface DecoratePromiseOptions { promise: Promise - namespace: 'playback' | 'recording' | 'prompt' | 'tap' | 'detect' | 'collect' + namespace: + | 'playback' + | 'recording' + | 'prompt' + | 'tap' + | 'detect' + | 'collect' + | 'stream' methods: string[] getters: string[] } export function decoratePromise( - this: Call, + this: Call | RoomSession, options: DecoratePromiseOptions ): Promise { const { promise: innerPromise, namespace, methods, getters } = options diff --git a/packages/realtime-api/src/index.ts b/packages/realtime-api/src/index.ts index b030ba2f8..ee72b4e7a 100644 --- a/packages/realtime-api/src/index.ts +++ b/packages/realtime-api/src/index.ts @@ -1,87 +1,6 @@ -/** - * You can use the realtime SDK to listen for and react to events from - * SignalWire's RealTime APIs. - * - * To get started, create a realtime client, for example with - * {@link Video.Client} and listen for events. For example: - * - * ```javascript - * import { Video } from '@signalwire/realtime-api' - * - * const video = new Video.Client({ - * project: '', - * token: '' - * }) - * - * video.on('room.started', async (roomSession) => { - * console.log("Room started") - * - * roomSession.on('member.joined', async (member) => { - * console.log(member) - * }) - * }); - * ``` - * - * @module - */ - -/** - * Access the Video API Consumer. You can instantiate a {@link Video.Client} to - * subscribe to Video events. Please check {@link Video.VideoClientApiEvents} - * for the full list of events that a {@link Video.Client} can subscribe to. - * - * @example - * - * The following example logs whenever a room session is started or a user joins - * it: - * - * ```javascript - * const video = new Video.Client({ project, token }) - * - * // Listen for events: - * video.on('room.started', async (roomSession) => { - * console.log('Room has started:', roomSession.name) - * - * roomSession.on('member.joined', async (member) => { - * console.log('Member joined:', member.name) - * }) - * }) - * ``` - */ -export * as Video from './video/Video' - /** @ignore */ export * from './configure' -/** - * Access the Messaging API. You can instantiate a {@link Messaging.Client} to - * send or receive SMS and MMS. Please check - * {@link Messaging.MessagingClientApiEvents} for the full list of events that - * a {@link Messaging.Client} can subscribe to. - * - * @example - * - * The following example listens for incoming SMSs over an "office" context, - * and also sends an SMS. - * - * ```javascript - * const client = new Messaging.Client({ - * project: "", - * token: "", - * contexts: ['office'] - * }) - * - * client.on('message.received', (message) => { - * console.log('message.received', message) - * }) - * - * await client.send({ - * from: '+1xxx', - * to: '+1yyy', - * body: 'Hello World!' - * }) - * ``` - */ export * as Messaging from './messaging/Messaging' export * as Chat from './chat/Chat' @@ -92,6 +11,8 @@ export * as Task from './task/Task' export * as Voice from './voice/Voice' +export * as Video from './video/Video' + /** * Access all the SignalWire APIs with a single instance. You can initiate a {@link SignalWire} to * use Messaging, Chat, PubSub, Task, Voice, and Video APIs. diff --git a/packages/realtime-api/src/types/video.ts b/packages/realtime-api/src/types/video.ts index 4678482da..882b08a2a 100644 --- a/packages/realtime-api/src/types/video.ts +++ b/packages/realtime-api/src/types/video.ts @@ -7,33 +7,845 @@ import type { RoomEnded, VideoLayoutEventNames, MemberTalkingEventNames, - Rooms, MemberUpdated, MemberUpdatedEventNames, RoomAudienceCount, VideoRoomAudienceCountEventParams, + OnRoomStarted, + OnRoomEnded, + OnRoomUpdated, + OnRoomAudienceCount, + OnRoomSubscribed, + OnMemberUpdated, + OnLayoutChanged, + OnMemberJoined, + MemberJoined, + OnMemberLeft, + MemberLeft, + OnMemberTalking, + MemberTalking, + OnMemberListUpdated, + MemberListUpdated, + PlaybackStarted, + OnPlaybackStarted, + OnPlaybackUpdated, + PlaybackUpdated, + OnPlaybackEnded, + PlaybackEnded, + OnRecordingStarted, + RecordingStarted, + OnRecordingUpdated, + RecordingUpdated, + OnRecordingEnded, + RecordingEnded, + OnStreamStarted, + OnStreamEnded, + StreamStarted, + StreamEnded, + OnMemberTalkingStarted, + MemberTalkingStarted, + OnMemberTalkingEnded, + MemberTalkingEnded, + OnMemberDeaf, + OnMemberVisible, + OnMemberAudioMuted, + OnMemberVideoMuted, + OnMemberInputVolume, + OnMemberOutputVolume, + OnMemberInputSensitivity, + VideoPlaybackEventNames, + VideoRecordingEventNames, + VideoStreamEventNames, + MemberCommandParams, + MemberCommandWithVolumeParams, + MemberCommandWithValueParams, } from '@signalwire/core' -import type { - RoomSession, - RoomSessionUpdated, - RoomSessionFullState, -} from '../video/RoomSession' +import type { RoomSession } from '../video/RoomSession' import type { RoomSessionMember, RoomSessionMemberUpdated, } from '../video/RoomSessionMember' +import { + RoomSessionPlayback, + RoomSessionPlaybackPromise, +} from '../video/RoomSessionPlayback' +import { + RoomSessionRecording, + RoomSessionRecordingPromise, +} from '../video/RoomSessionRecording' +import { + RoomSessionStream, + RoomSessionStreamPromise, +} from '../video/RoomSessionStream' +import { RoomMethods } from '../video/methods' + +/** + * Public Contract for a realtime VideoRoomSession + */ +export interface VideoRoomSessionContract { + /** Unique id for this room session */ + id: string + /** Display name for this room. Defaults to the value of `name` */ + displayName: string + /** Id of the room associated to this room session */ + roomId: string + /** @internal */ + eventChannel: string + /** Name of this room */ + name: string + /** Whether recording is active */ + recording: boolean + /** Whether muted videos are shown in the room layout. See {@link setHideVideoMuted} */ + hideVideoMuted: boolean + /** URL to the room preview. */ + previewUrl?: string + /** Current layout name used in the room. */ + layoutName: string + /** Whether the room is locked */ + locked: boolean + /** Metadata associated to this room session. */ + meta?: Record + /** Fields that have changed in this room session */ + updated?: Array> + /** Whether the room is streaming */ + streaming: boolean + + /** + * Puts the microphone on mute. The other participants will not hear audio + * from the muted participant anymore. You can use this method to mute + * either yourself or another participant in the room. + * @param params + * @param params.memberId id of the member to mute. If omitted, mutes the + * default device in the local client. + * + * @permissions + * - `room.self.audio_mute`: to mute a local device + * - `room.member.audio_mute`: to mute a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Muting your own microphone: + * ```typescript + * await room.audioMute() + * ``` + * + * @example Muting the microphone of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.audioMute({memberId: id}) + * ``` + */ + audioMute(params?: MemberCommandParams): RoomMethods.AudioMuteMember + /** + * Unmutes the microphone if it had been previously muted. You can use this + * method to unmute either yourself or another participant in the room. + * @param params + * @param params.memberId id of the member to unmute. If omitted, unmutes + * the default device in the local client. + * + * @permissions + * - `room.self.audio_unmute`: to unmute a local device + * - `room.member.audio_unmute`: to unmute a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Unmuting your own microphone: + * ```typescript + * await room.audioUnmute() + * ``` + * + * @example Unmuting the microphone of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.audioUnmute({memberId: id}) + * ``` + */ + audioUnmute(params?: MemberCommandParams): RoomMethods.AudioUnmuteMember + /** + * Puts the video on mute. Participants will see a mute image instead of the + * video stream. You can use this method to mute either yourself or another + * participant in the room. + * @param params + * @param params.memberId id of the member to mute. If omitted, mutes the + * default device in the local client. + * + * @permissions + * - `room.self.video_mute`: to unmute a local device + * - `room.member.video_mute`: to unmute a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Muting your own video: + * ```typescript + * await room.videoMute() + * ``` + * + * @example Muting the video of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.videoMute({memberId: id}) + * ``` + */ + videoMute(params?: MemberCommandParams): RoomMethods.VideoMuteMember + /** + * Unmutes the video if it had been previously muted. Participants will + * start seeing the video stream again. You can use this method to unmute + * either yourself or another participant in the room. + * @param params + * @param params.memberId id of the member to unmute. If omitted, unmutes + * the default device in the local client. + * + * @permissions + * - `room.self.video_mute`: to unmute a local device + * - `room.member.video_mute`: to unmute a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Unmuting your own video: + * ```typescript + * await room.videoUnmute() + * ``` + * + * @example Unmuting the video of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.videoUnmute({memberId: id}) + * ``` + */ + videoUnmute(params?: MemberCommandParams): RoomMethods.VideoUnmuteMember + /** @deprecated Use {@link setInputVolume} instead. */ + setMicrophoneVolume( + params: MemberCommandWithVolumeParams + ): RoomMethods.SetInputVolumeMember + /** + * Sets the input volume level (e.g. for the microphone). You can use this + * method to set the input volume for either yourself or another participant + * in the room. + * + * @param params + * @param params.memberId id of the member for which to set input volume. If + * omitted, sets the volume of the default device in the local client. + * @param params.volume desired volume. Values range from -50 to 50, with a + * default of 0. + * + * @permissions + * - `room.self.set_input_volume`: to set the volume for a local device + * - `room.member.set_input_volume`: to set the volume for a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Setting your own microphone volume: + * ```typescript + * await room.setInputVolume({volume: -10}) + * ``` + * + * @example Setting the microphone volume of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.setInputVolume({memberId: id, volume: -10}) + * ``` + */ + setInputVolume( + params: MemberCommandWithVolumeParams + ): RoomMethods.SetInputVolumeMember + /** + * Sets the input level at which the participant is identified as currently + * speaking. You can use this method to set the input sensitivity for either + * yourself or another participant in the room. + * @param params + * @param params.memberId id of the member to affect. If omitted, affects + * the default device in the local client. + * @param params.value desired sensitivity. The default value is 30 and the + * scale goes from 0 (lowest sensitivity, essentially muted) to 100 (highest + * sensitivity). + * + * @permissions + * - `room.self.set_input_sensitivity`: to set the sensitivity for a local + * device + * - `room.member.set_input_sensitivity`: to set the sensitivity for a + * remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Setting your own input sensitivity: + * ```typescript + * await room.setInputSensitivity({value: 80}) + * ``` + * + * @example Setting the input sensitivity of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.setInputSensitivity({memberId: id, value: 80}) + * ``` + */ + setInputSensitivity( + params: MemberCommandWithValueParams + ): RoomMethods.SetInputSensitivityMember + /** + * Returns a list of members currently in the room. + * + * @example + * ```typescript + * await room.getMembers() + * ``` + */ + getMembers(): RoomMethods.GetMembers + /** + * Mutes the incoming audio. The affected participant will not hear audio + * from the other participants anymore. You can use this method to make deaf + * either yourself or another participant in the room. + * + * Note that in addition to making a participant deaf, this will also + * automatically mute the microphone of the target participant (even if + * there is no `audio_mute` permission). If you want, you can then manually + * unmute it by calling {@link audioUnmute}. + * @param params + * @param params.memberId id of the member to affect. If omitted, affects + * the default device in the local client. + * + * @permissions + * - `room.self.deaf`: to make yourself deaf + * - `room.member.deaf`: to make deaf a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Making yourself deaf: + * ```typescript + * await room.deaf() + * ``` + * + * @example Making another participant deaf: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.deaf({memberId: id}) + * ``` + */ + deaf(params?: MemberCommandParams): RoomMethods.DeafMember + /** + * Unmutes the incoming audio. The affected participant will start hearing + * audio from the other participants again. You can use this method to + * undeaf either yourself or another participant in the room. + * + * Note that in addition to allowing a participants to hear the others, this + * will also automatically unmute the microphone of the target participant + * (even if there is no `audio_unmute` permission). If you want, you can then + * manually mute it by calling {@link audioMute}. + * @param params + * @param params.memberId id of the member to affect. If omitted, affects + * the default device in the local client. + * + * @permissions + * - `room.self.deaf`: to make yourself deaf + * - `room.member.deaf`: to make deaf a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Undeaf yourself: + * ```typescript + * await room.undeaf() + * ``` + * + * @example Undeaf another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.undeaf({memberId: id}) + * ``` + */ + undeaf(params?: MemberCommandParams): RoomMethods.UndeafMember + /** @deprecated Use {@link setOutputVolume} instead. */ + setSpeakerVolume( + params: MemberCommandWithVolumeParams + ): RoomMethods.SetOutputVolumeMember + /** + * Sets the output volume level (e.g., for the speaker). You can use this + * method to set the output volume for either yourself or another participant + * in the room. + * @param params + * @param params.memberId id of the member to affect. If omitted, affects the + * default device in the local client. + * @param params.volume desired volume. Values range from -50 to 50, with a + * default of 0. + * + * @permissions + * - `room.self.set_output_volume`: to set the speaker volume for yourself + * - `room.member.set_output_volume`: to set the speaker volume for a remote + * member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Setting your own output volume: + * ```typescript + * await room.setOutputVolume({volume: -10}) + * ``` + * + * @example Setting the output volume of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.setOutputVolume({memberId: id, volume: -10}) + * ``` + */ + setOutputVolume( + params: MemberCommandWithVolumeParams + ): RoomMethods.SetOutputVolumeMember + /** + * Removes a specific participant from the room. + * @param params + * @param params.memberId id of the member to remove + * + * @permissions + * - `room.member.remove`: to remove a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.removeMember({memberId: id}) + * ``` + */ + removeMember(params: Required): RoomMethods.RemoveMember + /** + * Removes all the participants from the room. + * + * @permissions + * - `room.member.remove`: to remove a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.removeAllMembers() + * ``` + */ + removeAllMembers(): RoomMethods.RemoveAllMembers + /** + * Show or hide muted videos in the room layout. Members that have been muted + * via {@link videoMute} will display a mute image instead of the video, if + * this setting is enabled. + * + * @param value whether to show muted videos in the room layout. + * + * @permissions + * - `room.hide_video_muted` + * - `room.show_video_muted` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await roomSession.setHideVideoMuted(false) + * ``` + */ + setHideVideoMuted(value: boolean): RoomMethods.SetHideVideoMuted + /** + * Returns a list of available layouts for the room. To set a room layout, + * use {@link setLayout}. + * + * @permissions + * - `room.list_available_layouts` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.getLayouts() + * ``` + */ + getLayouts(): RoomMethods.GetLayouts + /** + * Sets a layout for the room. You can obtain a list of available layouts + * with {@link getLayouts}. + * + * @permissions + * - `room.set_layout` + * - `room.set_position` (if you need to assign positions) + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Set the 6x6 layout: + * ```typescript + * await room.setLayout({name: "6x6"}) + * ``` + */ + setLayout(params: RoomMethods.SetLayoutParams): RoomMethods.SetLayout + /** + * Assigns a position in the layout for multiple members. + * + * @permissions + * - `room.set_position` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```js + * await roomSession.setPositions({ + * positions: { + * "1bf4d4fb-a3e4-4d46-80a8-3ebfdceb2a60": "reserved-1", + * "e0c5be44-d6c7-438f-8cda-f859a1a0b1e7": "auto" + * } + * }) + * ``` + */ + setPositions(params: RoomMethods.SetPositionsParams): RoomMethods.SetPositions + /** + * Assigns a position in the layout to the specified member. + * + * @permissions + * - `room.self.set_position`: to set the position for the local member + * - `room.member.set_position`: to set the position for a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```js + * await roomSession.setMemberPosition({ + * memberId: "1bf4d4fb-a3e4-4d46-80a8-3ebfdceb2a60", + * position: "off-canvas" + * }) + * ``` + */ + setMemberPosition( + params: RoomMethods.SetMemberPositionParams + ): RoomMethods.SetMemberPosition + /** + * Obtains a list of recordings for the current room session. To download the + * actual mp4 file, please use the [REST + * API](https://developer.signalwire.com/apis/reference/overview). + * + * @permissions + * - `room.recording` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.getRecordings() + * ``` + * + * From your server, you can obtain the mp4 file using the [REST API](https://developer.signalwire.com/apis/reference/overview): + * ```typescript + * curl --request GET \ + * --url https://.signalwire.com/api/video/room_recordings/ \ + * --header 'Accept: application/json' \ + * --header 'Authorization: Basic ' + * ``` + */ + getRecordings(): RoomMethods.GetRecordings + /** + * Starts the recording of the room. You can use the returned + * {@link RoomSessionRecording} object to control the recording (e.g., pause, + * resume, stop). + * + * @permissions + * - `room.recording` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * const rec = await room.startRecording().onStarted() + * await rec.stop() + * ``` + */ + startRecording( + params?: RoomMethods.StartRecordingParams + ): RoomSessionRecordingPromise + /** + * Obtains a list of recordings for the current room session. + * + * @permissions + * - `room.playback` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @returns The returned objects contain all the properties of a + * {@link RoomSessionPlayback}, but no methods. + */ + getPlaybacks(): RoomMethods.GetPlaybacks + /** + * Starts a playback in the room. You can use the returned + * {@link RoomSessionPlayback} object to control the playback (e.g., pause, + * resume, setVolume and stop). + * + * @permissions + * - `room.playback` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * const playback = await roomSession.play({ url: 'rtmp://example.com/foo' }).onStarted() + * await playback.stop() + * ``` + */ + play(params: RoomMethods.PlayParams): RoomSessionPlaybackPromise + /** + * Assigns custom metadata to the RoomSession. You can use this to store + * metadata whose meaning is entirely defined by your application. + * + * Note that calling this method overwrites any metadata that had been + * previously set on this RoomSession. + * + * @param meta The medatada object to assign to the RoomSession. + * + * @permissions + * - `room.set_meta` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```js + * await roomSession.setMeta({ foo: 'bar' }) + * ``` + */ + setMeta(params: RoomMethods.SetMetaParams): RoomMethods.SetMeta + /** + * Retrieve the custom metadata for the RoomSession. + * + * @example + * ```js + * const { meta } = await roomSession.getMeta() + * ``` + */ + getMeta(): RoomMethods.GetMeta + updateMeta(params: RoomMethods.UpdateMetaParams): RoomMethods.UpdateMeta + deleteMeta(params: RoomMethods.DeleteMetaParams): RoomMethods.DeleteMeta + /** + * Assigns custom metadata to the specified RoomSession member. You can use + * this to store metadata whose meaning is entirely defined by your + * application. + * + * Note that calling this method overwrites any metadata that had been + * previously set on the specified member. + * + * @param params.memberId Id of the member to affect. If omitted, affects the + * default device in the local client. + * @param params.meta The medatada object to assign to the member. + * + * @permissions + * - `room.self.set_meta`: to set the metadata for the local member + * - `room.member.set_meta`: to set the metadata for a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * Setting metadata for the current member: + * ```js + * await roomSession.setMemberMeta({ + * meta: { + * email: 'joe@example.com' + * } + * }) + * ``` + * + * @example + * Setting metadata for another member: + * ```js + * await roomSession.setMemberMeta({ + * memberId: 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * meta: { + * email: 'joe@example.com' + * } + * }) + * ``` + */ + setMemberMeta( + params: RoomMethods.SetMemberMetaParams + ): RoomMethods.SetMemberMeta + /** + * Retrieve the custom metadata for the specified RoomSession member. + * + * @param params.memberId Id of the member to retrieve the meta. If omitted, fallback to the current memberId. + * + * @example + * ```js + * const { meta } = await roomSession.getMemberMeta({ memberId: 'de550c0c-3fac-4efd-b06f-b5b8614b8966' }) + * ``` + */ + getMemberMeta(params?: MemberCommandParams): RoomMethods.GetMemberMeta + updateMemberMeta( + params: RoomMethods.UpdateMemberMetaParams + ): RoomMethods.UpdateMemberMeta + deleteMemberMeta( + params: RoomMethods.DeleteMemberMetaParams + ): RoomMethods.DeleteMemberMeta + promote(params: RoomMethods.PromoteMemberParams): RoomMethods.PromoteMember + demote(params: RoomMethods.DemoteMemberParams): RoomMethods.DemoteMember + /** + * Obtains a list of streams for the current room session. + * + * @permissions + * - `room.stream` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.getStreams() + * ``` + */ + getStreams(): RoomMethods.GetStreams + /** + * Starts to stream the room to the provided URL. You can use the returned + * {@link RoomSessionStream} object to then stop the stream. + * + * @permissions + * - `room.stream.start` or `room.stream` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * const stream = await room.startStream({ url: 'rtmp://example.com' }).onStarted() + * await stream.stop() + * ``` + */ + startStream(params: RoomMethods.StartStreamParams): RoomSessionStreamPromise + /** + * Lock the room + * + * @permissions + * - `room.lock` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.lock() + * ``` + */ + lock(): RoomMethods.Lock + /** + * Unlock the room + * + * @permissions + * - `room.unlock` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.unlock() + * ``` + */ + unlock(): RoomMethods.Unlock + /** + * Raise or lower hand of a member + * + * @permissions + * - `room.member.raisehand` and `room.member.lowerhand` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.setRaisedHand({ raised: true, memberId: '123...' }) + * ``` + */ + setRaisedHand( + params: RoomMethods.SetRaisedHandRoomParams + ): RoomMethods.SetRaisedHand + /** + * Set hand raise prioritization + * + * @permissions + * - `room.self.prioritize_handraise` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.setPrioritizeHandraise(true) + * ``` + */ + setPrioritizeHandraise( + prioritize: boolean + ): RoomMethods.SetPrioritizeHandraise +} -export type RealTimeVideoApiEventsHandlerMapping = Record< +/** + * RealTime Video API + */ +export type RealTimeVideoEventsHandlerMapping = Record< GlobalVideoEvents, (room: RoomSession) => void > -export type RealTimeVideoApiEvents = { - [k in keyof RealTimeVideoApiEventsHandlerMapping]: RealTimeVideoApiEventsHandlerMapping[k] +export type RealTimeVideoEvents = { + [k in keyof RealTimeVideoEventsHandlerMapping]: RealTimeVideoEventsHandlerMapping[k] +} + +export interface RealTimeVideoListeners { + onRoomStarted?: (room: RoomSession) => unknown + onRoomEnded?: (room: RoomSession) => unknown } +export type RealTimeVideoListenersEventsMapping = { + onRoomStarted: RoomStarted + onRoomEnded: RoomEnded +} + +/** + * RealTime Video Room API + */ // TODO: replace `any` with proper types. -export type RealTimeRoomApiEventsHandlerMapping = Record< +export type RealTimeRoomEventsHandlerMapping = Record< VideoLayoutEventNames, (layout: any) => void > & @@ -47,16 +859,163 @@ export type RealTimeRoomApiEventsHandlerMapping = Record< > & Record void> & Record void> & - Record void> & + Record void> & Record< RoomAudienceCount, (params: VideoRoomAudienceCountEventParams) => void > & - Record void> & - Rooms.RoomSessionRecordingEventsHandlerMapping & - Rooms.RoomSessionPlaybackEventsHandlerMapping & - Rooms.RoomSessionStreamEventsHandlerMapping + Record void> & + Record void> & + Record void> & + Record void> & + Record void> & + Record void> & + Record void> & + Record void> & + Record void> -export type RealTimeRoomApiEvents = { - [k in keyof RealTimeRoomApiEventsHandlerMapping]: RealTimeRoomApiEventsHandlerMapping[k] +export type RealTimeRoomEvents = { + [k in keyof RealTimeRoomEventsHandlerMapping]: RealTimeRoomEventsHandlerMapping[k] } + +export interface RealTimeRoomListeners { + onRoomSubscribed?: (room: RoomSession) => unknown + onRoomStarted?: (room: RoomSession) => unknown + onRoomUpdated?: (room: RoomSession) => unknown + onRoomEnded?: (room: RoomSession) => unknown + onRoomAudienceCount?: (params: VideoRoomAudienceCountEventParams) => unknown + onLayoutChanged?: (layout: any) => unknown + onMemberJoined?: (member: RoomSessionMember) => unknown + onMemberUpdated?: (member: RoomSessionMember) => unknown + onMemberListUpdated?: (member: RoomSessionMember) => unknown + onMemberLeft?: (member: RoomSessionMember) => unknown + onMemberDeaf?: (member: RoomSessionMember) => unknown + onMemberVisible?: (member: RoomSessionMember) => unknown + onMemberAudioMuted?: (member: RoomSessionMember) => unknown + onMemberVideoMuted?: (member: RoomSessionMember) => unknown + onMemberInputVolume?: (member: RoomSessionMember) => unknown + onMemberOutputVolume?: (member: RoomSessionMember) => unknown + onMemberInputSensitivity?: (member: RoomSessionMember) => unknown + onMemberTalking?: (member: RoomSessionMember) => unknown + onMemberTalkingStarted?: (member: RoomSessionMember) => unknown + onMemberTalkingEnded?: (member: RoomSessionMember) => unknown + onPlaybackStarted?: (playback: RoomSessionPlayback) => unknown + onPlaybackUpdated?: (playback: RoomSessionPlayback) => unknown + onPlaybackEnded?: (playback: RoomSessionPlayback) => unknown + onRecordingStarted?: (recording: RoomSessionRecording) => unknown + onRecordingUpdated?: (recording: RoomSessionRecording) => unknown + onRecordingEnded?: (recording: RoomSessionRecording) => unknown + onStreamStarted?: (stream: RoomSessionStream) => unknown + onStreamEnded?: (stream: RoomSessionStream) => unknown +} + +type MemberUpdatedEventMapping = { + [K in MemberUpdatedEventNames]: K +} + +export type RealtimeRoomListenersEventsMapping = Record< + OnRoomSubscribed, + RoomSubscribed +> & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record< + OnMemberAudioMuted, + MemberUpdatedEventMapping['member.updated.audioMuted'] + > & + Record< + OnMemberVideoMuted, + MemberUpdatedEventMapping['member.updated.videoMuted'] + > & + Record< + OnMemberInputVolume, + MemberUpdatedEventMapping['member.updated.inputVolume'] + > & + Record< + OnMemberOutputVolume, + MemberUpdatedEventMapping['member.updated.outputVolume'] + > & + Record< + OnMemberInputSensitivity, + MemberUpdatedEventMapping['member.updated.inputSensitivity'] + > & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record + +/** + * RealTime Room CallPlayback API + */ +export type RealTimeRoomPlaybackEvents = Record< + VideoPlaybackEventNames, + (playback: RoomSessionPlayback) => void +> + +export interface RealTimeRoomPlaybackListeners { + onStarted?: (playback: RoomSessionPlayback) => unknown + onUpdated?: (playback: RoomSessionPlayback) => unknown + onEnded?: (playback: RoomSessionPlayback) => unknown +} + +export type RealtimeRoomPlaybackListenersEventsMapping = Record< + 'onStarted', + PlaybackStarted +> & + Record<'onUpdated', PlaybackUpdated> & + Record<'onEnded', PlaybackEnded> + +/** + * RealTime Room CallRecording API + */ +export type RealTimeRoomRecordingEvents = Record< + VideoRecordingEventNames, + (recording: RoomSessionRecording) => void +> +export interface RealTimeRoomRecordingListeners { + onStarted?: (recording: RoomSessionRecording) => unknown + onUpdated?: (recording: RoomSessionRecording) => unknown + onEnded?: (recording: RoomSessionRecording) => unknown +} + +export type RealtimeRoomRecordingListenersEventsMapping = Record< + 'onStarted', + RecordingStarted +> & + Record<'onUpdated', RecordingUpdated> & + Record<'onEnded', RecordingEnded> + +/** + * RealTime Room CallStream API + */ +export type RealTimeRoomStreamEvents = Record< + VideoStreamEventNames, + (stream: RoomSessionStream) => void +> + +export interface RealTimeRoomStreamListeners { + onStarted?: (stream: RoomSessionStream) => unknown + onEnded?: (stream: RoomSessionStream) => unknown +} + +export type RealtimeRoomStreamListenersEventsMapping = Record< + 'onStarted', + StreamStarted +> & + Record<'onEnded', StreamEnded> diff --git a/packages/realtime-api/src/types/voice.ts b/packages/realtime-api/src/types/voice.ts index 96aa7f4ba..3c3c7f384 100644 --- a/packages/realtime-api/src/types/voice.ts +++ b/packages/realtime-api/src/types/voice.ts @@ -52,8 +52,17 @@ import type { CallTap } from '../voice/CallTap' import type { CallCollect } from '../voice/CallCollect' import type { CallDetect } from '../voice/CallDetect' +/** + * Voice API + */ +export interface VoiceListeners { + onCallReceived?: (call: Call) => unknown +} + export type VoiceEvents = Record void> +export type VoiceListenersEventsMapping = Record<'onCallReceived', CallReceived> + export interface VoiceMethodsListeners { listen?: RealTimeCallListeners } @@ -66,6 +75,9 @@ export type VoiceDialPhonelMethodParams = VoiceCallDialPhoneMethodParams & export type VoiceDialSipMethodParams = VoiceCallDialSipMethodParams & VoiceMethodsListeners +/** + * Call API + */ export interface RealTimeCallListeners { onStateChanged?: (call: Call) => unknown onPlaybackStarted?: (playback: CallPlayback) => unknown diff --git a/packages/realtime-api/src/video/BaseVideo.ts b/packages/realtime-api/src/video/BaseVideo.ts new file mode 100644 index 000000000..67f4ce2d5 --- /dev/null +++ b/packages/realtime-api/src/video/BaseVideo.ts @@ -0,0 +1,80 @@ +import { + ExecuteParams, + EventEmitter, + JSONRPCSubscribeMethod, + validateEventsToSubscribe, + uuid, +} from '@signalwire/core' +import { ListenSubscriber } from '../ListenSubscriber' +import { SWClient } from '../SWClient' + +export class BaseVideo< + T extends {}, + EventTypes extends EventEmitter.ValidEventTypes +> extends ListenSubscriber { + protected subscribeMethod: JSONRPCSubscribeMethod = 'signalwire.subscribe' + protected _subscribeParams?: Record = {} + protected _eventChannel?: string = '' + + constructor(options: SWClient) { + super({ swClient: options }) + } + + protected get eventChannel() { + return this._eventChannel + } + + protected getSubscriptions() { + return validateEventsToSubscribe(this.eventNames()) + } + + protected async subscribe(listeners: T) { + const _uuid = uuid() + + // Attach listeners + this._attachListeners(listeners) + + // Subscribe to video events + await this.addEvents() + + const unsub = () => { + return new Promise(async (resolve, reject) => { + try { + // Detach listeners + this._detachListeners(listeners) + + // Remove listeners from the listener map + this.removeFromListenerMap(_uuid) + + resolve() + } catch (error) { + reject(error) + } + }) + } + + // Add listeners to the listener map + this.addToListenerMap(_uuid, { + listeners, + unsub, + }) + + return unsub + } + + protected async addEvents() { + const subscriptions = this.getSubscriptions() + + // TODO: Do not send already sent events + + const executeParams: ExecuteParams = { + method: this.subscribeMethod, + params: { + get_initial_state: true, + event_channel: this.eventChannel, + events: subscriptions, + }, + } + return this._client.execute(executeParams) + } +} diff --git a/packages/realtime-api/src/video/RoomSession.test.ts b/packages/realtime-api/src/video/RoomSession.test.ts index ef8f72c7a..31f8a4ec1 100644 --- a/packages/realtime-api/src/video/RoomSession.test.ts +++ b/packages/realtime-api/src/video/RoomSession.test.ts @@ -1,43 +1,58 @@ import { actions } from '@signalwire/core' import { configureFullStack } from '../testUtils' -import { createVideoObject } from './Video' -import { createRoomSessionObject } from './RoomSession' +import { Video } from './Video' +import { RoomSession } from './RoomSession' +import { createClient } from '../client/createClient' +import { RoomSessionRecording } from './RoomSessionRecording' +import { RoomSessionPlayback } from './RoomSessionPlayback' describe('RoomSession Object', () => { - let roomSession: ReturnType + let video: Video + let roomSession: RoomSession const roomSessionId = 'roomSessionId' - const { store, session, emitter, destroy } = configureFullStack() + const { store, destroy } = configureFullStack() + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + store, + } beforeEach(() => { - // remove all listeners before each run - emitter.removeAllListeners() + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + // @ts-expect-error + video = new Video(swClientMock) + // @ts-expect-error + video._client.execute = jest.fn() + // @ts-expect-error + video._client.runWorker = jest.fn() return new Promise(async (resolve) => { - const video = createVideoObject({ - store, - // @ts-expect-error - emitter, - }) - // @ts-expect-error - video.execute = jest.fn() + await video.listen({ + onRoomStarted: (room) => { + // @ts-expect-error + room._client.execute = jest.fn() - video.on('room.started', async (newRoom) => { - // @ts-expect-error - newRoom.execute = jest.fn() + roomSession = room - roomSession = newRoom - - resolve(roomSession) + resolve(roomSession) + }, }) - await video.subscribe() - const eventChannelOne = 'room.' const firstRoom = JSON.parse( `{"jsonrpc":"2.0","id":"uuid1","method":"signalwire.event","params":{"params":{"room":{"recording":false,"room_session_id":"${roomSessionId}","name":"First Room","hide_video_muted":false,"music_on_hold":false,"room_id":"room_id","event_channel":"${eventChannelOne}"},"room_session_id":"${roomSessionId}","room_id":"room_id","room_session":{"recording":false,"name":"First Room","hide_video_muted":false,"id":"${roomSessionId}","music_on_hold":false,"room_id":"room_id","event_channel":"${eventChannelOne}"}},"timestamp":1631692502.1308,"event_type":"video.room.started","event_channel":"video.rooms.4b7ae78a-d02e-4889-a63b-08b156d5916e"}}` ) - session.dispatch(actions.socketMessageAction(firstRoom)) + + // @ts-expect-error + video._client.store.channels.sessionChannel.put( + actions.socketMessageAction(firstRoom) + ) }) }) @@ -86,7 +101,7 @@ describe('RoomSession Object', () => { ] // @ts-expect-error - ;(roomSession.execute as jest.Mock).mockResolvedValueOnce({ + ;(roomSession._client.execute as jest.Mock).mockResolvedValueOnce({ recordings: recordingList, }) @@ -112,23 +127,33 @@ describe('RoomSession Object', () => { it('startRecording should return a recording object', async () => { // @ts-expect-error - roomSession.execute = jest.fn().mockResolvedValue({ - room_session_id: roomSessionId, - room_id: 'roomId', - recording: { - id: 'recordingId', - state: 'recording', + roomSession._client.execute = jest.fn().mockResolvedValue({}) + + const mockRecording = new RoomSessionRecording({ + roomSession, + payload: { + room_session_id: roomSessionId, + // @ts-expect-error + recording: { + id: 'recordingId', + state: 'recording', + }, }, }) - const recording = await roomSession.startRecording() + const recordingPromise = roomSession.startRecording() + + // @TODO: Mock server event + roomSession.emit('recording.started', mockRecording) + + const recording = await recordingPromise.onStarted() // @ts-expect-error - recording.execute = jest.fn() + recording._client.execute = jest.fn() await recording.pause() // @ts-ignore - expect(recording.execute).toHaveBeenLastCalledWith({ + expect(recording._client.execute).toHaveBeenLastCalledWith({ method: 'video.recording.pause', params: { room_session_id: roomSessionId, @@ -137,7 +162,7 @@ describe('RoomSession Object', () => { }) await recording.resume() // @ts-ignore - expect(recording.execute).toHaveBeenLastCalledWith({ + expect(recording._client.execute).toHaveBeenLastCalledWith({ method: 'video.recording.resume', params: { room_session_id: roomSessionId, @@ -146,7 +171,7 @@ describe('RoomSession Object', () => { }) await recording.stop() // @ts-ignore - expect(recording.execute).toHaveBeenLastCalledWith({ + expect(recording._client.execute).toHaveBeenLastCalledWith({ method: 'video.recording.stop', params: { room_session_id: roomSessionId, @@ -158,29 +183,39 @@ describe('RoomSession Object', () => { describe('playback apis', () => { it('play() should return a playback object', async () => { // @ts-expect-error - roomSession.execute = jest.fn().mockResolvedValue({ - room_session_id: roomSessionId, - room_id: 'roomId', - playback: { - id: 'playbackId', - state: 'playing', - url: 'rtmp://example.com/foo', - volume: 10, - started_at: 1629460916, + roomSession._client.execute = jest.fn().mockResolvedValue() + + const mockPlayback = new RoomSessionPlayback({ + roomSession, + payload: { + room_session_id: roomSessionId, + playback: { + id: 'playbackId', + state: 'playing', + url: 'rtmp://example.com/foo', + volume: 10, + // @ts-expect-error + started_at: 1629460916, + }, }, }) - const playback = await roomSession.play({ + const playbackPromise = roomSession.play({ url: 'rtmp://example.com/foo', volume: 10, }) + // @TODO: Mock server event + roomSession.emit('playback.started', mockPlayback) + + const playback = await playbackPromise.onStarted() + // @ts-expect-error - playback.execute = jest.fn() + playback._client.execute = jest.fn() await playback.pause() // @ts-ignore - expect(playback.execute).toHaveBeenLastCalledWith({ + expect(playback._client.execute).toHaveBeenLastCalledWith({ method: 'video.playback.pause', params: { room_session_id: roomSessionId, @@ -189,7 +224,7 @@ describe('RoomSession Object', () => { }) await playback.resume() // @ts-ignore - expect(playback.execute).toHaveBeenLastCalledWith({ + expect(playback._client.execute).toHaveBeenLastCalledWith({ method: 'video.playback.resume', params: { room_session_id: roomSessionId, @@ -198,7 +233,7 @@ describe('RoomSession Object', () => { }) await playback.setVolume(20) // @ts-ignore - expect(playback.execute).toHaveBeenLastCalledWith({ + expect(playback._client.execute).toHaveBeenLastCalledWith({ method: 'video.playback.set_volume', params: { room_session_id: roomSessionId, @@ -208,7 +243,7 @@ describe('RoomSession Object', () => { }) await playback.stop() // @ts-ignore - expect(playback.execute).toHaveBeenLastCalledWith({ + expect(playback._client.execute).toHaveBeenLastCalledWith({ method: 'video.playback.stop', params: { room_session_id: roomSessionId, @@ -217,26 +252,4 @@ describe('RoomSession Object', () => { }) }) }) - - describe('automatic subscribe', () => { - it('should automatically call subscribe when attaching events', async () => { - const { store, emitter, destroy } = configureFullStack() - const room = createRoomSessionObject({ - store, - // @ts-expect-error - emitter, - }) - - // @ts-expect-error - room.debouncedSubscribe = jest.fn() - - room.on('member.joined', () => {}) - room.on('member.left', () => {}) - - // @ts-expect-error - expect(room.debouncedSubscribe).toHaveBeenCalledTimes(2) - - destroy() - }) - }) }) diff --git a/packages/realtime-api/src/video/RoomSession.ts b/packages/realtime-api/src/video/RoomSession.ts index dbdb6b1ad..b334ee2f0 100644 --- a/packages/realtime-api/src/video/RoomSession.ts +++ b/packages/realtime-api/src/video/RoomSession.ts @@ -1,30 +1,35 @@ import { - BaseComponentOptionsWithPayload, - connect, extendComponent, - Rooms, - VideoRoomSessionContract, VideoRoomSessionMethods, - ConsumerContract, - EntityUpdated, - BaseConsumer, EventEmitter, - debounce, VideoRoomEventParams, Optional, validateEventsToSubscribe, + VideoMemberEntity, } from '@signalwire/core' -import { RealTimeRoomApiEvents } from '../types' +import { + RealTimeRoomEvents, + RealTimeRoomListeners, + RealtimeRoomListenersEventsMapping, + VideoRoomSessionContract, +} from '../types' import { RoomSessionMember, + RoomSessionMemberAPI, RoomSessionMemberEventParams, - createRoomSessionMemberObject, } from './RoomSessionMember' +import { RoomMethods } from './methods' +import { BaseVideo } from './BaseVideo' +import { Video } from './Video' + +export interface RoomSessionFullState extends Omit { + /** List of members that are part of this room session */ + members?: RoomSessionMember[] +} export interface RoomSession extends VideoRoomSessionContract, - ConsumerContract { - setPayload(payload: Optional): void + BaseVideo { /** * Returns a list of members currently in the room. * @@ -34,35 +39,57 @@ export interface RoomSession * ``` */ getMembers(): Promise<{ members: RoomSessionMember[] }> -} - -export type RoomSessionUpdated = EntityUpdated -export interface RoomSessionFullState extends Omit { - /** List of members that are part of this room session */ - members?: RoomSessionMember[] + /** @internal */ + setPayload(payload: Optional): void } type RoomSessionPayload = Optional -export interface RoomSessionConsumerOptions - extends BaseComponentOptionsWithPayload {} -export class RoomSessionConsumer extends BaseConsumer { - private _payload: RoomSessionPayload +export interface RoomSessionOptions { + video: Video + payload: RoomSessionPayload +} - /** @internal */ - protected subscribeParams = { - get_initial_state: true, +export class RoomSession extends BaseVideo< + RealTimeRoomListeners, + RealTimeRoomEvents +> { + private _payload: RoomSessionPayload + protected _eventMap: RealtimeRoomListenersEventsMapping = { + onRoomSubscribed: 'room.subscribed', + onRoomStarted: 'room.started', + onRoomUpdated: 'room.updated', + onRoomEnded: 'room.ended', + onRoomAudienceCount: 'room.audienceCount', + onLayoutChanged: 'layout.changed', + onMemberJoined: 'member.joined', + onMemberUpdated: 'member.updated', + onMemberLeft: 'member.left', + onMemberListUpdated: 'memberList.updated', + onMemberTalking: 'member.talking', + onMemberTalkingStarted: 'member.talking.started', + onMemberTalkingEnded: 'member.talking.ended', + onMemberDeaf: 'member.updated.deaf', + onMemberVisible: 'member.updated.visible', + onMemberAudioMuted: 'member.updated.audioMuted', + onMemberVideoMuted: 'member.updated.videoMuted', + onMemberInputVolume: 'member.updated.inputVolume', + onMemberOutputVolume: 'member.updated.outputVolume', + onMemberInputSensitivity: 'member.updated.inputSensitivity', + onPlaybackStarted: 'playback.started', + onPlaybackUpdated: 'playback.updated', + onPlaybackEnded: 'playback.ended', + onRecordingStarted: 'recording.started', + onRecordingUpdated: 'recording.updated', + onRecordingEnded: 'recording.ended', + onStreamStarted: 'stream.started', + onStreamEnded: 'stream.ended', } - /** @internal */ - private debouncedSubscribe: ReturnType - - constructor(options: RoomSessionConsumerOptions) { - super(options) + constructor(options: RoomSessionOptions) { + super(options.video._sw) this._payload = options.payload - - this.debouncedSubscribe = debounce(this.subscribe, 100) } get id() { @@ -105,6 +132,10 @@ export class RoomSessionConsumer extends BaseConsumer { return this._payload.room_session.recording } + get streaming() { + return this._payload.room_session.streaming + } + get locked() { return this._payload.room_session.locked } @@ -117,88 +148,31 @@ export class RoomSessionConsumer extends BaseConsumer { return this._payload.room_session.prioritize_handraise } + get updated() { + // TODO: Fix type issue + return this._payload.room_session + .updated as VideoRoomSessionContract['updated'] + } + /** @internal */ protected override getSubscriptions() { const eventNamesWithPrefix = this.eventNames().map( (event) => `video.${String(event)}` - ) as EventEmitter.EventNames[] + ) as EventEmitter.EventNames[] return validateEventsToSubscribe(eventNamesWithPrefix) } /** @internal */ - protected _internal_on( - event: keyof RealTimeRoomApiEvents, - fn: EventEmitter.EventListener - ) { - return super.on(event, fn) - } - - on( - event: T, - fn: EventEmitter.EventListener - ) { - const instance = super.on(event, fn) - this.debouncedSubscribe() - return instance - } - - once( - event: T, - fn: EventEmitter.EventListener - ) { - const instance = super.once(event, fn) - this.debouncedSubscribe() - return instance - } - - off( - event: T, - fn: EventEmitter.EventListener - ) { - const instance = super.off(event, fn) - return instance - } - - /** - * @privateRemarks - * - * Override BaseConsumer `subscribe` to resolve the promise when the 'room.subscribed' - * event comes. This way we can return to the user the room full state. - * Note: the payload will go through an EventTrasform - see the `type: roomSessionSubscribed` - * below. - */ - subscribe() { - return new Promise(async (resolve, reject) => { - const handler = (payload: RoomSessionFullState) => { - resolve(payload) - } - const subscriptions = this.getSubscriptions() - if (subscriptions.length === 0) { - this.logger.debug( - '`subscribe()` was called without any listeners attached.' - ) - return - } - - try { - super.once('room.subscribed', handler) - await super.subscribe() - } catch (error) { - super.off('room.subscribed', handler) - return reject(error) - } - }) - } - - /** @internal */ - protected setPayload(payload: Optional) { + setPayload(payload: Optional) { this._payload = payload } getMembers() { - return new Promise(async (resolve, reject) => { + return new Promise<{ + members: VideoMemberEntity[] + }>(async (resolve, reject) => { try { - const { members } = await this.execute< + const { members } = await this._client.execute< void, { members: RoomSessionMemberEventParams['member'][] } >({ @@ -210,12 +184,12 @@ export class RoomSessionConsumer extends BaseConsumer { const memberInstances: RoomSessionMember[] = [] members.forEach((member) => { - let memberInstance = this.instanceMap.get( + let memberInstance = this._client.instanceMap.get( member.id ) if (!memberInstance) { - memberInstance = createRoomSessionMemberObject({ - store: this.store, + memberInstance = new RoomSessionMemberAPI({ + roomSession: this, payload: { room_id: this.roomId, room_session_id: this.roomSessionId, @@ -228,7 +202,7 @@ export class RoomSessionConsumer extends BaseConsumer { } as RoomSessionMemberEventParams) } memberInstances.push(memberInstance) - this.instanceMap.set( + this._client.instanceMap.set( memberInstance.id, memberInstance ) @@ -243,60 +217,45 @@ export class RoomSessionConsumer extends BaseConsumer { } export const RoomSessionAPI = extendComponent< - RoomSessionConsumer, + RoomSession, Omit ->(RoomSessionConsumer, { - videoMute: Rooms.videoMuteMember, - videoUnmute: Rooms.videoUnmuteMember, - audioMute: Rooms.audioMuteMember, - audioUnmute: Rooms.audioUnmuteMember, - deaf: Rooms.deafMember, - undeaf: Rooms.undeafMember, - setInputVolume: Rooms.setInputVolumeMember, - setOutputVolume: Rooms.setOutputVolumeMember, - setMicrophoneVolume: Rooms.setInputVolumeMember, - setSpeakerVolume: Rooms.setOutputVolumeMember, - setInputSensitivity: Rooms.setInputSensitivityMember, - removeMember: Rooms.removeMember, - removeAllMembers: Rooms.removeAllMembers, - setHideVideoMuted: Rooms.setHideVideoMuted, - getLayouts: Rooms.getLayouts, - setLayout: Rooms.setLayout, - setPositions: Rooms.setPositions, - setMemberPosition: Rooms.setMemberPosition, - getRecordings: Rooms.getRecordings, - startRecording: Rooms.startRecording, - getPlaybacks: Rooms.getPlaybacks, - play: Rooms.play, - getMeta: Rooms.getMeta, - setMeta: Rooms.setMeta, - updateMeta: Rooms.updateMeta, - deleteMeta: Rooms.deleteMeta, - getMemberMeta: Rooms.getMemberMeta, - setMemberMeta: Rooms.setMemberMeta, - updateMemberMeta: Rooms.updateMemberMeta, - deleteMemberMeta: Rooms.deleteMemberMeta, - promote: Rooms.promote, - demote: Rooms.demote, - getStreams: Rooms.getStreams, - startStream: Rooms.startStream, - lock: Rooms.lock, - unlock: Rooms.unlock, - setRaisedHand: Rooms.setRaisedHand, - setPrioritizeHandraise: Rooms.setPrioritizeHandraise, +>(RoomSession, { + videoMute: RoomMethods.videoMuteMember, + videoUnmute: RoomMethods.videoUnmuteMember, + audioMute: RoomMethods.audioMuteMember, + audioUnmute: RoomMethods.audioUnmuteMember, + deaf: RoomMethods.deafMember, + undeaf: RoomMethods.undeafMember, + setInputVolume: RoomMethods.setInputVolumeMember, + setOutputVolume: RoomMethods.setOutputVolumeMember, + setMicrophoneVolume: RoomMethods.setInputVolumeMember, + setSpeakerVolume: RoomMethods.setOutputVolumeMember, + setInputSensitivity: RoomMethods.setInputSensitivityMember, + removeMember: RoomMethods.removeMember, + removeAllMembers: RoomMethods.removeAllMembers, + setHideVideoMuted: RoomMethods.setHideVideoMuted, + getLayouts: RoomMethods.getLayouts, + setLayout: RoomMethods.setLayout, + setPositions: RoomMethods.setPositions, + setMemberPosition: RoomMethods.setMemberPosition, + getRecordings: RoomMethods.getRecordings, + startRecording: RoomMethods.startRecording, + getPlaybacks: RoomMethods.getPlaybacks, + play: RoomMethods.play, + getMeta: RoomMethods.getMeta, + setMeta: RoomMethods.setMeta, + updateMeta: RoomMethods.updateMeta, + deleteMeta: RoomMethods.deleteMeta, + getMemberMeta: RoomMethods.getMemberMeta, + setMemberMeta: RoomMethods.setMemberMeta, + updateMemberMeta: RoomMethods.updateMemberMeta, + deleteMemberMeta: RoomMethods.deleteMemberMeta, + promote: RoomMethods.promote, + demote: RoomMethods.demote, + getStreams: RoomMethods.getStreams, + startStream: RoomMethods.startStream, + lock: RoomMethods.lock, + unlock: RoomMethods.unlock, + setRaisedHand: RoomMethods.setRaisedHand, + setPrioritizeHandraise: RoomMethods.setPrioritizeHandraise, }) - -export const createRoomSessionObject = ( - params: RoomSessionConsumerOptions -): RoomSession => { - const roomSession = connect< - RealTimeRoomApiEvents, - RoomSessionConsumer, - RoomSession - >({ - store: params.store, - Component: RoomSessionAPI, - })(params) - - return roomSession -} diff --git a/packages/realtime-api/src/video/RoomSessionMember.test.ts b/packages/realtime-api/src/video/RoomSessionMember/RoomSessionMember.test.ts similarity index 61% rename from packages/realtime-api/src/video/RoomSessionMember.test.ts rename to packages/realtime-api/src/video/RoomSessionMember/RoomSessionMember.test.ts index 10c52b779..2051293d8 100644 --- a/packages/realtime-api/src/video/RoomSessionMember.test.ts +++ b/packages/realtime-api/src/video/RoomSessionMember/RoomSessionMember.test.ts @@ -1,62 +1,75 @@ import { actions } from '@signalwire/core' -import { configureFullStack } from '../testUtils' -import { createRoomSessionObject } from './RoomSession' +import { configureFullStack } from '../../testUtils' +import { RoomSession, RoomSessionAPI } from '../RoomSession' import { RoomSessionMember } from './RoomSessionMember' -import { Video, createVideoObject } from './Video' +import { Video } from '../Video' +import { createClient } from '../../client/createClient' describe('Member Object', () => { - let member: RoomSessionMember let video: Video + let roomSession: RoomSession + let member: RoomSessionMember const roomSessionId = '3b36a747-e33a-409d-bbb9-1ddffc543b6d' const memberId = '483c60ba-b776-4051-834a-5575c4b7cffe' - const { store, session, emitter, destroy } = configureFullStack() + const { store, destroy } = configureFullStack() - beforeEach(() => { - // remove all listeners before each run - emitter.removeAllListeners() + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + store, + } - video = createVideoObject({ - store, - // @ts-expect-error - emitter, - }) + beforeEach(() => { + const swClientMock = { + userOptions, + client: createClient(userOptions), + } // @ts-expect-error - video.execute = jest.fn() + video = new Video(swClientMock) + // @ts-expect-error + video._client.execute = jest.fn() + // @ts-expect-error + video._client.runWorker = jest.fn() return new Promise(async (resolve) => { - const roomSession = createRoomSessionObject({ - store, - emitter, + roomSession = new RoomSessionAPI({ + video, payload: { - // @ts-expect-error room_session: { id: roomSessionId, event_channel: 'room.e4b8baff-865d-424b-a210-4a182a3b1451', }, }, }) - store.instanceMap.set(roomSessionId, roomSession) - roomSession.on('member.joined', (newMember) => { - // @ts-expect-error - newMember.execute = jest.fn() - member = newMember - resolve(member) - }) // @ts-expect-error - roomSession.execute = jest.fn() - roomSession.subscribe().then(() => { - // Trigger a member.joined event to resolve the main Promise - const memberJoinedEvent = JSON.parse( - `{"jsonrpc":"2.0","id":"uuid","method":"signalwire.event","params":{"params":{"room_session_id":"${roomSessionId}","room_id":"03b71e19-1ed2-4417-a544-7d0ca01186ed","member":{"visible":false,"room_session_id":"${roomSessionId}","input_volume":0,"id":"${memberId}","input_sensitivity":44,"audio_muted":false,"output_volume":0,"name":"edoardo","deaf":false,"video_muted":false,"room_id":"03b71e19-1ed2-4417-a544-7d0ca01186ed","type":"member"}},"timestamp":1234,"event_type":"video.member.joined","event_channel":"${roomSession.eventChannel}"}}` - ) - session.dispatch(actions.socketMessageAction(memberJoinedEvent)) + roomSession._client.store.instanceMap.set(roomSessionId, roomSession) + // @ts-expect-error + roomSession._client.execute = jest.fn() + + await roomSession.listen({ + onMemberJoined: (newMember) => { + member = newMember + resolve(member) + }, }) + const memberJoinedEvent = JSON.parse( + `{"jsonrpc":"2.0","id":"uuid","method":"signalwire.event","params":{"params":{"room_session_id":"${roomSessionId}","room_id":"03b71e19-1ed2-4417-a544-7d0ca01186ed","member":{"visible":false,"room_session_id":"${roomSessionId}","input_volume":0,"id":"${memberId}","input_sensitivity":44,"audio_muted":false,"output_volume":0,"name":"edoardo","deaf":false,"video_muted":false,"room_id":"03b71e19-1ed2-4417-a544-7d0ca01186ed","type":"member"}},"timestamp":1234,"event_type":"video.member.joined","event_channel":"${roomSession.eventChannel}"}}` + ) + // @ts-expect-error + video._client.store.channels.sessionChannel.put( + actions.socketMessageAction(memberJoinedEvent) + ) + // Emit room.subscribed event to resolve the promise above. const roomSubscribedEvent = JSON.parse( `{"jsonrpc":"2.0","id":"4198ee12-ec98-4002-afc5-e031fc32bb8a","method":"signalwire.event","params":{"params":{"room_session":{"recording":false,"name":"behindTheWire","hide_video_muted":false,"id":"${roomSessionId}","members":[{"visible":false,"room_session_id":"${roomSessionId}","input_volume":0,"id":"b3b0cfd6-2382-4ac6-a8c9-9182584697ae","input_sensitivity":44,"audio_muted":false,"output_volume":0,"name":"edoardo","deaf":false,"video_muted":false,"room_id":"297ec3bb-fdc5-4995-ae75-c40a43c272ee","type":"member"}],"room_id":"297ec3bb-fdc5-4995-ae75-c40a43c272ee","event_channel":"${roomSession.eventChannel}"}},"timestamp":1632738590.6955,"event_type":"video.room.subscribed","event_channel":"${roomSession.eventChannel}"}}` ) - session.dispatch(actions.socketMessageAction(roomSubscribedEvent)) + // @ts-expect-error + video._client.store.channels.sessionChannel.put( + actions.socketMessageAction(roomSubscribedEvent) + ) }) }) @@ -66,11 +79,11 @@ describe('Member Object', () => { const expectExecute = (payload: any) => { // @ts-expect-error - expect(member.execute).toHaveBeenLastCalledWith(payload, { + expect(member._client.execute).toHaveBeenLastCalledWith(payload, { transformResolve: expect.anything(), }) // @ts-expect-error - member.execute.mockClear() + member._client.execute.mockClear() } it('should have all the custom methods defined', async () => { @@ -149,42 +162,15 @@ describe('Member Object', () => { value: 10, }, }) + await member.remove() // @ts-expect-error - expect(member.execute).toHaveBeenLastCalledWith({ + expect(member._client.execute).toHaveBeenLastCalledWith({ method: 'video.member.remove', params: { room_session_id: member.roomSessionId, member_id: member.id, }, }) - await member.setRaisedHand() - // @ts-expect-error - expect(member.execute).toHaveBeenLastCalledWith( - { - method: 'video.member.raisehand', - params: { - room_session_id: member.roomSessionId, - member_id: member.id, - }, - }, - { - transformResolve: expect.anything(), - } - ) - await member.setRaisedHand({ raised: false }) - // @ts-expect-error - expect(member.execute).toHaveBeenLastCalledWith( - { - method: 'video.member.lowerhand', - params: { - room_session_id: member.roomSessionId, - member_id: member.id, - }, - }, - { - transformResolve: expect.anything(), - } - ) }) }) diff --git a/packages/realtime-api/src/video/RoomSessionMember.ts b/packages/realtime-api/src/video/RoomSessionMember/RoomSessionMember.ts similarity index 65% rename from packages/realtime-api/src/video/RoomSessionMember.ts rename to packages/realtime-api/src/video/RoomSessionMember/RoomSessionMember.ts index 1ab00777e..8a74e8c88 100644 --- a/packages/realtime-api/src/video/RoomSessionMember.ts +++ b/packages/realtime-api/src/video/RoomSessionMember/RoomSessionMember.ts @@ -1,9 +1,5 @@ import { - connect, - BaseComponent, - BaseComponentOptionsWithPayload, extendComponent, - Rooms, VideoMemberContract, VideoMemberMethods, EntityUpdated, @@ -12,6 +8,9 @@ import { VideoMemberUpdatedEventParams, VideoMemberTalkingEventParams, } from '@signalwire/core' +import { RoomSession } from '../RoomSession' +import { RoomMethods } from '../methods' +import type { Client } from '../../client/Client' /** * Represents a member of a room session. You receive instances of this type by @@ -37,17 +36,17 @@ export type RoomSessionMemberEventParams = ) & VideoMemberTalkingEventParams -export interface RoomSessionMemberOptions - extends BaseComponentOptionsWithPayload {} +export interface RoomSessionOptions { + roomSession: RoomSession + payload: RoomSessionMemberEventParams +} -// TODO: Extend from a variant of `BaseComponent` that -// doesn't expose EventEmitter methods -class RoomSessionMemberComponent extends BaseComponent<{}> { +export class RoomSessionMember { + private _client: Client private _payload: RoomSessionMemberEventParams - constructor(options: RoomSessionMemberOptions) { - super(options) - + constructor(options: RoomSessionOptions) { + this._client = options.roomSession._sw.client this._payload = options.payload } @@ -124,7 +123,7 @@ class RoomSessionMemberComponent extends BaseComponent<{}> { } /** @internal */ - protected setPayload(payload: RoomSessionMemberEventParams) { + setPayload(payload: RoomSessionMemberEventParams) { // Reshape the payload since the `video.member.talking` event does not return all the parameters of a member const newPayload = { ...payload, @@ -137,41 +136,30 @@ class RoomSessionMemberComponent extends BaseComponent<{}> { } async remove() { - await this.execute({ + await this._client.execute({ method: 'video.member.remove', params: { - room_session_id: this.getStateProperty('roomSessionId'), - member_id: this.getStateProperty('memberId'), + room_session_id: this.roomSessionId, + member_id: this.memberId, }, }) } } -const RoomSessionMemberAPI = extendComponent< - RoomSessionMemberComponent, - // `remove` is defined by `RoomSessionMemberComponent` +export const RoomSessionMemberAPI = extendComponent< + RoomSessionMember, + // `remove` is defined by `RoomSessionMember` Omit ->(RoomSessionMemberComponent, { - audioMute: Rooms.audioMuteMember, - audioUnmute: Rooms.audioUnmuteMember, - videoMute: Rooms.videoMuteMember, - videoUnmute: Rooms.videoUnmuteMember, - setDeaf: Rooms.setDeaf, - setMicrophoneVolume: Rooms.setInputVolumeMember, - setInputVolume: Rooms.setInputVolumeMember, - setSpeakerVolume: Rooms.setOutputVolumeMember, - setOutputVolume: Rooms.setOutputVolumeMember, - setInputSensitivity: Rooms.setInputSensitivityMember, - setRaisedHand: Rooms.setRaisedHand, +>(RoomSessionMember, { + audioMute: RoomMethods.audioMuteMember, + audioUnmute: RoomMethods.audioUnmuteMember, + videoMute: RoomMethods.videoMuteMember, + videoUnmute: RoomMethods.videoUnmuteMember, + setDeaf: RoomMethods.setDeaf, + setMicrophoneVolume: RoomMethods.setInputVolumeMember, + setInputVolume: RoomMethods.setInputVolumeMember, + setSpeakerVolume: RoomMethods.setOutputVolumeMember, + setOutputVolume: RoomMethods.setOutputVolumeMember, + setInputSensitivity: RoomMethods.setInputSensitivityMember, + setRaisedHand: RoomMethods.setRaisedHand, }) - -export const createRoomSessionMemberObject = ( - params: RoomSessionMemberOptions -): RoomSessionMember => { - const member = connect<{}, RoomSessionMemberComponent, RoomSessionMember>({ - store: params.store, - Component: RoomSessionMemberAPI, - })(params) - - return member -} diff --git a/packages/realtime-api/src/video/RoomSessionMember/index.ts b/packages/realtime-api/src/video/RoomSessionMember/index.ts new file mode 100644 index 000000000..bedee71bd --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionMember/index.ts @@ -0,0 +1 @@ +export * from './RoomSessionMember' diff --git a/packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.test.ts b/packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.test.ts new file mode 100644 index 000000000..cc351043b --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.test.ts @@ -0,0 +1,213 @@ +import { EventEmitter } from '@signalwire/core' +import { configureFullStack } from '../../testUtils' +import { createClient } from '../../client/createClient' +import { Video } from '../Video' +import { RoomSessionAPI, RoomSession } from '../RoomSession' +import { RoomSessionPlayback } from './RoomSessionPlayback' +import { + decoratePlaybackPromise, + methods, + getters, +} from './decoratePlaybackPromise' + +describe('RoomSessionPlayback', () => { + let video: Video + let roomSession: RoomSession + let playback: RoomSessionPlayback + + const roomSessionId = 'room-session-id' + const { store, destroy } = configureFullStack() + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + store, + } + + beforeEach(() => { + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + // @ts-expect-error + video = new Video(swClientMock) + // @ts-expect-error + video._client.execute = jest.fn() + // @ts-expect-error + video._client.runWorker = jest.fn() + + roomSession = new RoomSessionAPI({ + video, + payload: { + room_session: { + id: roomSessionId, + event_channel: 'room.e4b8baff-865d-424b-a210-4a182a3b1451', + }, + }, + }) + + playback = new RoomSessionPlayback({ + payload: { + //@ts-expect-error + playback: { + id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + }, + room_session_id: roomSessionId, + }, + roomSession, + }) + // @ts-expect-error + playback._client.execute = jest.fn() + }) + + afterAll(() => { + destroy() + }) + + it('should have an event emitter', () => { + expect(playback['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'playback.started', + onUpdated: 'playback.updated', + onEnded: 'playback.ended', + } + expect(playback['_eventMap']).toEqual(expectedEventMap) + }) + + it('should control an active playback', async () => { + const baseExecuteParams = { + method: '', + params: { + room_session_id: roomSessionId, + playback_id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + }, + } + await playback.pause() + // @ts-expect-error + expect(playback._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.playback.pause', + }) + await playback.resume() + // @ts-expect-error + expect(playback._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.playback.resume', + }) + await playback.stop() + // @ts-expect-error + expect(playback._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.playback.stop', + }) + await playback.setVolume(30) + // @ts-expect-error + expect(playback._client.execute).toHaveBeenLastCalledWith({ + method: 'video.playback.set_volume', + params: { + room_session_id: roomSessionId, + playback_id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + volume: 30, + }, + }) + }) + + it('should throw an error on methods if playback has ended', async () => { + playback.setPayload({ + // @ts-expect-error + playback: { + state: 'completed', + }, + }) + + await expect(playback.pause()).rejects.toThrowError('Action has ended') + await expect(playback.resume()).rejects.toThrowError('Action has ended') + await expect(playback.stop()).rejects.toThrowError('Action has ended') + await expect(playback.setVolume(1)).rejects.toThrowError('Action has ended') + await expect(playback.seek(1)).rejects.toThrowError('Action has ended') + await expect(playback.forward(1)).rejects.toThrowError('Action has ended') + await expect(playback.rewind(1)).rejects.toThrowError('Action has ended') + }) + + describe('decoratePlaybackPromise', () => { + it('expose correct properties before resolve', () => { + const innerPromise = Promise.resolve(playback) + + const decoratedPromise = decoratePlaybackPromise.call( + roomSession, + innerPromise + ) + + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise.onStarted()).toBeInstanceOf(Promise) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise.onEnded()).toBeInstanceOf(Promise) + methods.forEach((method) => { + expect(decoratedPromise).toHaveProperty(method, expect.any(Function)) + // @ts-expect-error + expect(decoratedPromise[method]()).toBeInstanceOf(Promise) + }) + getters.forEach((getter) => { + expect(decoratedPromise).toHaveProperty(getter) + // @ts-expect-error + expect(decoratedPromise[getter]).toBeInstanceOf(Promise) + }) + }) + + it('expose correct properties after resolve', async () => { + const innerPromise = Promise.resolve(playback) + + const decoratedPromise = decoratePlaybackPromise.call( + roomSession, + innerPromise + ) + + // Simulate the playback ended event + roomSession.emit('playback.ended', playback) + + const ended = await decoratedPromise + + expect(ended).not.toHaveProperty('onStarted', expect.any(Function)) + expect(ended).not.toHaveProperty('onEnded', expect.any(Function)) + methods.forEach((method) => { + expect(ended).toHaveProperty(method, expect.any(Function)) + }) + getters.forEach((getter) => { + expect(ended).toHaveProperty(getter) + // @ts-expect-error + expect(ended[getter]).not.toBeInstanceOf(Promise) + }) + }) + + it('resolves when playback ends', async () => { + const innerPromise = Promise.resolve(playback) + + const decoratedPromise = decoratePlaybackPromise.call( + roomSession, + innerPromise + ) + + // Simulate the playback ended event + roomSession.emit('playback.ended', playback) + + await expect(decoratedPromise).resolves.toEqual( + expect.any(RoomSessionPlayback) + ) + }) + + it('rejects on inner promise rejection', async () => { + const innerPromise = Promise.reject(new Error('Recording failed')) + + const decoratedPromise = decoratePlaybackPromise.call( + roomSession, + innerPromise + ) + + await expect(decoratedPromise).rejects.toThrow('Recording failed') + }) + }) +}) diff --git a/packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.ts b/packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.ts new file mode 100644 index 000000000..5eb72365d --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.ts @@ -0,0 +1,217 @@ +/** + * Once we have new interface for Browser SDK; + * RoomSessionPlayback in core should be removed + * RoomSessionPlayback in realtime-api should be moved to core + */ + +import type { + VideoPlaybackContract, + VideoPlaybackEventParams, +} from '@signalwire/core' +import { ListenSubscriber } from '../../ListenSubscriber' +import { + RealTimeRoomPlaybackEvents, + RealTimeRoomPlaybackListeners, + RealtimeRoomPlaybackListenersEventsMapping, +} from '../../types' +import { RoomSession } from '../RoomSession' + +/** + * Instances of this class allow you to control (e.g., pause, resume, stop) the + * playback inside a room session. You can obtain instances of this class by + * starting a playback from the desired {@link RoomSession} (see + * {@link RoomSession.play}) + */ + +export interface RoomSessionPlaybackOptions { + roomSession: RoomSession + payload: VideoPlaybackEventParams +} + +export class RoomSessionPlayback + extends ListenSubscriber< + RealTimeRoomPlaybackListeners, + RealTimeRoomPlaybackEvents + > + implements VideoPlaybackContract +{ + private _payload: VideoPlaybackEventParams + protected _eventMap: RealtimeRoomPlaybackListenersEventsMapping = { + onStarted: 'playback.started', + onUpdated: 'playback.updated', + onEnded: 'playback.ended', + } + + constructor(options: RoomSessionPlaybackOptions) { + super({ swClient: options.roomSession._sw }) + + this._payload = options.payload + } + + get id() { + return this._payload.playback.id + } + + get roomId() { + return this._payload.room_id + } + + get roomSessionId() { + return this._payload.room_session_id + } + + get url() { + return this._payload.playback.url + } + + get state() { + return this._payload.playback.state + } + + get volume() { + return this._payload.playback.volume + } + + get startedAt() { + if (!this._payload.playback.started_at) return undefined + return new Date( + (this._payload.playback.started_at as unknown as number) * 1000 + ) + } + + get endedAt() { + if (!this._payload.playback.ended_at) return undefined + return new Date( + (this._payload.playback.ended_at as unknown as number) * 1000 + ) + } + + get position() { + return this._payload.playback.position + } + + get seekable() { + return this._payload.playback.seekable + } + + get hasEnded() { + if (this.state === 'completed') { + return true + } + return false + } + + /** @internal */ + setPayload(payload: VideoPlaybackEventParams) { + this._payload = payload + } + + /** @internal */ + attachListeners(listeners?: RealTimeRoomPlaybackListeners) { + if (listeners) { + this.listen(listeners) + } + } + + async pause() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.pause', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + }, + }) + } + + async resume() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.resume', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + }, + }) + } + + async stop() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.stop', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + }, + }) + } + + async setVolume(volume: number) { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.set_volume', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + volume, + }, + }) + } + + async seek(timecode: number) { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.seek_absolute', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + position: Math.abs(timecode), + }, + }) + } + + async forward(offset: number = 5000) { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.seek_relative', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + position: Math.abs(offset), + }, + }) + } + + async rewind(offset: number = 5000) { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.seek_relative', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + position: -Math.abs(offset), + }, + }) + } +} diff --git a/packages/realtime-api/src/video/RoomSessionPlayback/decoratePlaybackPromise.ts b/packages/realtime-api/src/video/RoomSessionPlayback/decoratePlaybackPromise.ts new file mode 100644 index 000000000..210858b2d --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionPlayback/decoratePlaybackPromise.ts @@ -0,0 +1,71 @@ +import { Promisify } from '@signalwire/core' +import { RoomSession } from '../RoomSession' +import { RoomSessionPlayback } from './RoomSessionPlayback' +import { decoratePromise } from '../../decoratePromise' +import { RealTimeRoomPlaybackListeners } from '../../types' + +export interface RoomSessionPlaybackEnded { + id: string + roomId: string + roomSessionId: string + url: string + state: RoomSessionPlayback['state'] + volume: number + startedAt?: Date + endedAt?: Date + position: number + seekable: boolean +} + +export interface RoomSessionPlaybackPromise + extends Promise, + Promisify { + onStarted: () => Promise + onEnded: () => Promise + listen: ( + listeners: RealTimeRoomPlaybackListeners + ) => Promise<() => Promise> + pause: () => Promise + resume: () => Promise + stop: () => Promise + setVolume: (volume: number) => Promise + seek: (timecode: number) => Promise + forward: (offset: number) => Promise + rewind: (offset: number) => Promise +} + +export const getters = [ + 'id', + 'roomId', + 'roomSessionId', + 'url', + 'state', + 'volume', + 'startedAt', + 'endedAt', + 'position', + 'seekable', +] + +export const methods = [ + 'pause', + 'resume', + 'stop', + 'setVolume', + 'seek', + 'forward', + 'rewind', +] + +export function decoratePlaybackPromise( + this: RoomSession, + innerPromise: Promise +) { + // prettier-ignore + return (decoratePromise).call(this, { + promise: innerPromise, + namespace: 'playback', + methods, + getters, + }) as RoomSessionPlaybackPromise +} diff --git a/packages/realtime-api/src/video/RoomSessionPlayback/index.ts b/packages/realtime-api/src/video/RoomSessionPlayback/index.ts new file mode 100644 index 000000000..05973e5b0 --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionPlayback/index.ts @@ -0,0 +1,3 @@ +export * from './RoomSessionPlayback' +export * from './decoratePlaybackPromise' +export { decoratePlaybackPromise } from './decoratePlaybackPromise' diff --git a/packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.test.ts b/packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.test.ts new file mode 100644 index 000000000..1fecc3828 --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.test.ts @@ -0,0 +1,199 @@ +import { EventEmitter } from '@signalwire/core' +import { configureFullStack } from '../../testUtils' +import { createClient } from '../../client/createClient' +import { Video } from '../Video' +import { RoomSessionAPI, RoomSession } from '../RoomSession' +import { RoomSessionRecording } from './RoomSessionRecording' +import { + decorateRecordingPromise, + methods, + getters, +} from './decorateRecordingPromise' + +describe('RoomSessionRecording', () => { + let video: Video + let roomSession: RoomSession + let recording: RoomSessionRecording + + const roomSessionId = 'room-session-id' + const { store, destroy } = configureFullStack() + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + store, + } + + beforeEach(() => { + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + // @ts-expect-error + video = new Video(swClientMock) + // @ts-expect-error + video._client.execute = jest.fn() + // @ts-expect-error + video._client.runWorker = jest.fn() + + roomSession = new RoomSessionAPI({ + video, + payload: { + room_session: { + id: roomSessionId, + event_channel: 'room.e4b8baff-865d-424b-a210-4a182a3b1451', + }, + }, + }) + + recording = new RoomSessionRecording({ + payload: { + //@ts-expect-error + recording: { + id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + }, + room_session_id: roomSessionId, + }, + roomSession, + }) + // @ts-expect-error + recording._client.execute = jest.fn() + }) + + afterAll(() => { + destroy() + }) + + it('should have an event emitter', () => { + expect(recording['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'recording.started', + onUpdated: 'recording.updated', + onEnded: 'recording.ended', + } + expect(recording['_eventMap']).toEqual(expectedEventMap) + }) + + it('should control an active recording', async () => { + const baseExecuteParams = { + method: '', + params: { + room_session_id: 'room-session-id', + recording_id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + }, + } + await recording.pause() + // @ts-expect-error + expect(recording._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.recording.pause', + }) + await recording.resume() + // @ts-expect-error + expect(recording._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.recording.resume', + }) + await recording.stop() + // @ts-expect-error + expect(recording._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.recording.stop', + }) + }) + + it('should throw an error on methods if recording has ended', async () => { + recording.setPayload({ + // @ts-expect-error + recording: { + state: 'completed', + }, + }) + + await expect(recording.pause()).rejects.toThrowError('Action has ended') + await expect(recording.resume()).rejects.toThrowError('Action has ended') + await expect(recording.stop()).rejects.toThrowError('Action has ended') + }) + + describe('decorateRecordingPromise', () => { + it('expose correct properties before resolve', () => { + const innerPromise = Promise.resolve(recording) + + const decoratedPromise = decorateRecordingPromise.call( + roomSession, + innerPromise + ) + + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise.onStarted()).toBeInstanceOf(Promise) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise.onEnded()).toBeInstanceOf(Promise) + methods.forEach((method) => { + expect(decoratedPromise).toHaveProperty(method, expect.any(Function)) + // @ts-expect-error + expect(decoratedPromise[method]()).toBeInstanceOf(Promise) + }) + getters.forEach((getter) => { + expect(decoratedPromise).toHaveProperty(getter) + // @ts-expect-error + expect(decoratedPromise[getter]).toBeInstanceOf(Promise) + }) + }) + + it('expose correct properties after resolve', async () => { + const innerPromise = Promise.resolve(recording) + + const decoratedPromise = decorateRecordingPromise.call( + roomSession, + innerPromise + ) + + // Simulate the recording ended event + roomSession.emit('recording.ended', recording) + + const ended = await decoratedPromise + + expect(ended).not.toHaveProperty('onStarted', expect.any(Function)) + expect(ended).not.toHaveProperty('onEnded', expect.any(Function)) + methods.forEach((method) => { + expect(ended).toHaveProperty(method, expect.any(Function)) + }) + getters.forEach((getter) => { + expect(ended).toHaveProperty(getter) + // @ts-expect-error + expect(ended[getter]).not.toBeInstanceOf(Promise) + }) + }) + + it('resolves when recording ends', async () => { + const innerPromise = Promise.resolve(recording) + + const decoratedPromise = decorateRecordingPromise.call( + roomSession, + innerPromise + ) + + // Simulate the recording ended event + roomSession.emit('recording.ended', recording) + + await expect(decoratedPromise).resolves.toEqual( + expect.any(RoomSessionRecording) + ) + }) + + it('rejects on inner promise rejection', async () => { + const innerPromise = Promise.reject(new Error('Recording failed')) + + const decoratedPromise = decorateRecordingPromise.call( + roomSession, + innerPromise + ) + + await expect(decoratedPromise).rejects.toThrow('Recording failed') + }) + }) +}) diff --git a/packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.ts b/packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.ts new file mode 100644 index 000000000..3af7a92db --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.ts @@ -0,0 +1,138 @@ +/** + * Once we have new interface for Browser SDK; + * RoomSessionRecording in core should be removed + * RoomSessionRecording in realtime-api should be moved to core + */ + +import type { + VideoRecordingEventParams, + VideoRecordingMethods, +} from '@signalwire/core' +import { + RealTimeRoomRecordingEvents, + RealTimeRoomRecordingListeners, + RealtimeRoomRecordingListenersEventsMapping, +} from '../../types' +import { ListenSubscriber } from '../../ListenSubscriber' +import { RoomSession } from '../RoomSession' + +export interface RoomSessionRecordingOptions { + roomSession: RoomSession + payload: VideoRecordingEventParams +} + +export class RoomSessionRecording + extends ListenSubscriber< + RealTimeRoomRecordingListeners, + RealTimeRoomRecordingEvents + > + implements VideoRecordingMethods +{ + private _payload: VideoRecordingEventParams + protected _eventMap: RealtimeRoomRecordingListenersEventsMapping = { + onStarted: 'recording.started', + onUpdated: 'recording.updated', + onEnded: 'recording.ended', + } + + constructor(options: RoomSessionRecordingOptions) { + super({ swClient: options.roomSession._sw }) + + this._payload = options.payload + } + + get id() { + return this._payload.recording.id + } + + get roomId() { + return this._payload.room_id + } + + get roomSessionId() { + return this._payload.room_session_id + } + + get state() { + return this._payload.recording.state + } + + get duration() { + return this._payload.recording.duration + } + + get startedAt() { + if (!this._payload.recording.started_at) return undefined + return new Date( + (this._payload.recording.started_at as unknown as number) * 1000 + ) + } + + get endedAt() { + if (!this._payload.recording.ended_at) return undefined + return new Date( + (this._payload.recording.ended_at as unknown as number) * 1000 + ) + } + + get hasEnded() { + if (this.state === 'completed') { + return true + } + return false + } + + /** @internal */ + setPayload(payload: VideoRecordingEventParams) { + this._payload = payload + } + + /** @internal */ + attachListeners(listeners?: RealTimeRoomRecordingListeners) { + if (listeners) { + this.listen(listeners) + } + } + + async pause() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.recording.pause', + params: { + room_session_id: this.roomSessionId, + recording_id: this.id, + }, + }) + } + + async resume() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.recording.resume', + params: { + room_session_id: this.roomSessionId, + recording_id: this.id, + }, + }) + } + + async stop() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.recording.stop', + params: { + room_session_id: this.roomSessionId, + recording_id: this.id, + }, + }) + } +} diff --git a/packages/realtime-api/src/video/RoomSessionRecording/decorateRecordingPromise.ts b/packages/realtime-api/src/video/RoomSessionRecording/decorateRecordingPromise.ts new file mode 100644 index 000000000..f996fe1c5 --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionRecording/decorateRecordingPromise.ts @@ -0,0 +1,53 @@ +import { Promisify } from '@signalwire/core' +import { RoomSession } from '../RoomSession' +import { RoomSessionRecording } from './RoomSessionRecording' +import { decoratePromise } from '../../decoratePromise' +import { RealTimeRoomRecordingListeners } from '../../types' + +export interface RoomSessionRecordingEnded { + id: string + roomId: string + roomSessionId: string + state: RoomSessionRecording['state'] + duration?: number + startedAt?: Date + endedAt?: Date +} + +export interface RoomSessionRecordingPromise + extends Promise, + Promisify { + onStarted: () => Promise + onEnded: () => Promise + listen: ( + listeners: RealTimeRoomRecordingListeners + ) => Promise<() => Promise> + pause: () => Promise + resume: () => Promise + stop: () => Promise +} + +export const getters = [ + 'id', + 'roomId', + 'roomSessionId', + 'state', + 'duration', + 'startedAt', + 'endedAt', +] + +export const methods = ['pause', 'resume', 'stop'] + +export function decorateRecordingPromise( + this: RoomSession, + innerPromise: Promise +) { + // prettier-ignore + return (decoratePromise).call(this, { + promise: innerPromise, + namespace: 'recording', + methods, + getters, + }) as RoomSessionRecordingPromise +} diff --git a/packages/realtime-api/src/video/RoomSessionRecording/index.ts b/packages/realtime-api/src/video/RoomSessionRecording/index.ts new file mode 100644 index 000000000..5d16d6db4 --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionRecording/index.ts @@ -0,0 +1,3 @@ +export * from './RoomSessionRecording' +export * from './decorateRecordingPromise' +export { decorateRecordingPromise } from './decorateRecordingPromise' diff --git a/packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.test.ts b/packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.test.ts new file mode 100644 index 000000000..65825b96d --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.test.ts @@ -0,0 +1,185 @@ +import { EventEmitter } from '@signalwire/core' +import { configureFullStack } from '../../testUtils' +import { createClient } from '../../client/createClient' +import { Video } from '../Video' +import { RoomSessionAPI, RoomSession } from '../RoomSession' +import { RoomSessionStream } from './RoomSessionStream' +import { + decorateStreamPromise, + getters, + methods, +} from './decorateStreamPromise' + +describe('RoomSessionStream', () => { + let video: Video + let roomSession: RoomSession + let stream: RoomSessionStream + + const roomSessionId = 'room-session-id' + const { store, destroy } = configureFullStack() + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + store, + } + + beforeEach(() => { + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + // @ts-expect-error + video = new Video(swClientMock) + // @ts-expect-error + video._client.execute = jest.fn() + // @ts-expect-error + video._client.runWorker = jest.fn() + + roomSession = new RoomSessionAPI({ + video, + payload: { + room_session: { + id: roomSessionId, + event_channel: 'room.e4b8baff-865d-424b-a210-4a182a3b1451', + }, + }, + }) + + stream = new RoomSessionStream({ + payload: { + // @ts-expect-error + stream: { + id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + }, + room_session_id: roomSessionId, + }, + roomSession, + }) + // @ts-expect-error + stream._client.execute = jest.fn() + }) + + afterAll(() => { + destroy() + }) + + it('should have an event emitter', () => { + expect(stream['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'stream.started', + onEnded: 'stream.ended', + } + expect(stream['_eventMap']).toEqual(expectedEventMap) + }) + + it('should control an active stream', async () => { + const baseExecuteParams = { + method: '', + params: { + room_session_id: 'room-session-id', + stream_id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + }, + } + + await stream.stop() + // @ts-expect-error + expect(stream._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.stream.stop', + }) + }) + + it('should throw an error on methods if stream has ended', async () => { + stream.setPayload({ + // @ts-expect-error + stream: { + state: 'completed', + }, + }) + + await expect(stream.stop()).rejects.toThrowError('Action has ended') + }) + + describe('decorateStreamPromise', () => { + it('expose correct properties before resolve', () => { + const innerPromise = Promise.resolve(stream) + + const decoratedPromise = decorateStreamPromise.call( + roomSession, + innerPromise + ) + + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise.onStarted()).toBeInstanceOf(Promise) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise.onEnded()).toBeInstanceOf(Promise) + methods.forEach((method) => { + expect(decoratedPromise).toHaveProperty(method, expect.any(Function)) + // @ts-expect-error + expect(decoratedPromise[method]()).toBeInstanceOf(Promise) + }) + getters.forEach((getter) => { + expect(decoratedPromise).toHaveProperty(getter) + // @ts-expect-error + expect(decoratedPromise[getter]).toBeInstanceOf(Promise) + }) + }) + + it('expose correct properties after resolve', async () => { + const innerPromise = Promise.resolve(stream) + + const decoratedPromise = decorateStreamPromise.call( + roomSession, + innerPromise + ) + + // Simulate the stream ended event + roomSession.emit('stream.ended', stream) + + const ended = await decoratedPromise + + expect(ended).not.toHaveProperty('onStarted', expect.any(Function)) + expect(ended).not.toHaveProperty('onEnded', expect.any(Function)) + methods.forEach((method) => { + expect(ended).toHaveProperty(method, expect.any(Function)) + }) + getters.forEach((getter) => { + expect(ended).toHaveProperty(getter) + // @ts-expect-error + expect(ended[getter]).not.toBeInstanceOf(Promise) + }) + }) + + it('resolves when stream ends', async () => { + const innerPromise = Promise.resolve(stream) + + const decoratedPromise = decorateStreamPromise.call( + roomSession, + innerPromise + ) + + // Simulate the stream ended event + roomSession.emit('stream.ended', stream) + + await expect(decoratedPromise).resolves.toEqual( + expect.any(RoomSessionStream) + ) + }) + + it('rejects on inner promise rejection', async () => { + const innerPromise = Promise.reject(new Error('Recording failed')) + + const decoratedPromise = decorateStreamPromise.call( + roomSession, + innerPromise + ) + + await expect(decoratedPromise).rejects.toThrow('Recording failed') + }) + }) +}) diff --git a/packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.ts b/packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.ts new file mode 100644 index 000000000..1046f8399 --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.ts @@ -0,0 +1,111 @@ +/** + * Once we have new interface for Browser SDK; + * RoomSessionStream in core should be removed + * RoomSessionStream in realtime-api should be moved to core + */ + +import type { + VideoStreamEventParams, + VideoStreamMethods, +} from '@signalwire/core' +import { RoomSession } from '../RoomSession' +import { + RealTimeRoomStreamEvents, + RealTimeRoomStreamListeners, + RealtimeRoomStreamListenersEventsMapping, +} from '../../types' +import { ListenSubscriber } from '../../ListenSubscriber' + +export interface RoomSessionStreamOptions { + roomSession: RoomSession + payload: VideoStreamEventParams +} + +export class RoomSessionStream + extends ListenSubscriber< + RealTimeRoomStreamListeners, + RealTimeRoomStreamEvents + > + implements VideoStreamMethods +{ + private _payload: VideoStreamEventParams + protected _eventMap: RealtimeRoomStreamListenersEventsMapping = { + onStarted: 'stream.started', + onEnded: 'stream.ended', + } + + constructor(options: RoomSessionStreamOptions) { + super({ swClient: options.roomSession._sw }) + + this._payload = options.payload + } + + get id() { + return this._payload.stream.id + } + + get roomId() { + return this._payload.room_id + } + + get roomSessionId() { + return this._payload.room_session_id + } + + get state() { + return this._payload.stream.state + } + + get duration() { + return this._payload.stream.duration + } + + get url() { + return this._payload.stream.url + } + + get startedAt() { + if (!this._payload.stream.started_at) return undefined + return new Date( + (this._payload.stream.started_at as unknown as number) * 1000 + ) + } + + get endedAt() { + if (!this._payload.stream.ended_at) return undefined + return new Date((this._payload.stream.ended_at as unknown as number) * 1000) + } + + get hasEnded() { + if (this.state === 'completed') { + return true + } + return false + } + + /** @internal */ + setPayload(payload: VideoStreamEventParams) { + this._payload = payload + } + + /** @internal */ + attachListeners(listeners?: RealTimeRoomStreamListeners) { + if (listeners) { + this.listen(listeners) + } + } + + async stop() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.stream.stop', + params: { + room_session_id: this.roomSessionId, + stream_id: this.id, + }, + }) + } +} diff --git a/packages/realtime-api/src/video/RoomSessionStream/decorateStreamPromise.ts b/packages/realtime-api/src/video/RoomSessionStream/decorateStreamPromise.ts new file mode 100644 index 000000000..794c9513d --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionStream/decorateStreamPromise.ts @@ -0,0 +1,53 @@ +import { Promisify } from '@signalwire/core' +import { RoomSession } from '../RoomSession' +import { RoomSessionStream } from './RoomSessionStream' +import { decoratePromise } from '../../decoratePromise' +import { RealTimeRoomStreamListeners } from '../../types' + +export interface RoomSessionStreamEnded { + id: string + roomId: string + roomSessionId: string + state: RoomSessionStream['state'] + duration?: number + url?: string + startedAt?: Date + endedAt?: Date +} + +export interface RoomSessionStreamPromise + extends Promise, + Promisify { + onStarted: () => Promise + onEnded: () => Promise + listen: ( + listeners: RealTimeRoomStreamListeners + ) => Promise<() => Promise> + stop: () => Promise +} + +export const getters = [ + 'id', + 'roomId', + 'roomSessionId', + 'url', + 'state', + 'duration', + 'startedAt', + 'endedAt', +] + +export const methods = ['stop'] + +export function decorateStreamPromise( + this: RoomSession, + innerPromise: Promise +) { + // prettier-ignore + return (decoratePromise).call(this, { + promise: innerPromise, + namespace: 'stream', + methods, + getters, + }) as RoomSessionStreamPromise +} diff --git a/packages/realtime-api/src/video/RoomSessionStream/index.ts b/packages/realtime-api/src/video/RoomSessionStream/index.ts new file mode 100644 index 000000000..9ec319bd7 --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionStream/index.ts @@ -0,0 +1,3 @@ +export * from './RoomSessionStream' +export * from './decorateStreamPromise' +export { decorateStreamPromise } from './decorateStreamPromise' diff --git a/packages/realtime-api/src/video/Video.test.ts b/packages/realtime-api/src/video/Video.test.ts index dbf752b36..c4a438976 100644 --- a/packages/realtime-api/src/video/Video.test.ts +++ b/packages/realtime-api/src/video/Video.test.ts @@ -1,45 +1,67 @@ -import { actions } from '@signalwire/core' +import { EventEmitter, actions } from '@signalwire/core' +import { Video } from './Video' +import { RoomSession } from './RoomSession' +import { createClient } from '../client/createClient' import { configureFullStack } from '../testUtils' -import { RoomSessionConsumer } from './RoomSession' -import { createVideoObject, Video } from './Video' describe('Video Object', () => { let video: Video - const { store, session, emitter, destroy } = configureFullStack() - beforeEach(() => { - // remove all listeners before each run - emitter.removeAllListeners() + const { store, destroy } = configureFullStack() - video = createVideoObject({ - store, - // @ts-expect-error - emitter, - }) + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + store, + } + + beforeEach(() => { + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + // @ts-expect-error + video = new Video(swClientMock) + // @ts-expect-error + video._client.execute = jest.fn() // @ts-expect-error - video.execute = jest.fn() + video._client.runWorker = jest.fn() + }) + + afterEach(() => { + jest.clearAllMocks() }) afterAll(() => { destroy() }) - it('should not invoke execute without event listeners', async () => { - await video.subscribe() - // @ts-expect-error - expect(video.execute).not.toHaveBeenCalled() + it('should have an event emitter', () => { + expect(video['emitter']).toBeInstanceOf(EventEmitter) }) - it('should invoke execute with event listeners', async () => { - video.on('room.started', jest.fn) - await video.subscribe() + it('should declare the correct event map', () => { + const expectedEventMap = { + onRoomStarted: 'room.started', + onRoomEnded: 'room.ended', + } + expect(video['_eventMap']).toEqual(expectedEventMap) + }) + + it('should subscribe to events', async () => { + await video.listen({ + onRoomStarted: jest.fn(), + onRoomEnded: jest.fn(), + }) + // @ts-expect-error - expect(video.execute).toHaveBeenCalledWith({ + expect(video._client.execute).toHaveBeenCalledWith({ method: 'signalwire.subscribe', params: { get_initial_state: true, event_channel: 'video.rooms', - events: ['video.room.started'], + events: ['video.room.started', 'video.room.ended'], }, }) }) @@ -54,142 +76,90 @@ describe('Video Object', () => { `{"jsonrpc":"2.0","id":"uuid1","method":"signalwire.event","params":{"params":{"room":{"recording":false,"room_session_id":"session-two","name":"Second Room","hide_video_muted":false,"music_on_hold":false,"room_id":"room_id","event_channel":"${eventChannelTwo}"},"room_session_id":"session-two","room_id":"room_id","room_session":{"recording":false,"name":"Second Room","hide_video_muted":false,"id":"session-two","music_on_hold":false,"room_id":"room_id","event_channel":"${eventChannelTwo}"}},"timestamp":1631692502.1308,"event_type":"video.room.started","event_channel":"video.rooms.4b7ae78a-d02e-4889-a63b-08b156d5916e"}}` ) - it('should pass a Room obj to the handler', (done) => { - video.on('room.started', (room) => { - expect(room.id).toBe('session-one') - expect(room.name).toBe('First Room') - expect(room.videoMute).toBeDefined() - expect(room.videoUnmute).toBeDefined() - expect(room.getMembers).toBeDefined() - expect(room.subscribe).toBeDefined() - done() - }) - - video.subscribe().then(() => { - session.dispatch(actions.socketMessageAction(firstRoom)) + it('should pass a room object to the listener', async () => { + const promise = new Promise(async (resolve) => { + await video.listen({ + onRoomStarted: (room) => { + expect(room.id).toBe('session-one') + expect(room.name).toBe('First Room') + expect(room.videoMute).toBeDefined() + expect(room.videoUnmute).toBeDefined() + expect(room.getMembers).toBeDefined() + resolve() + }, + }) }) - }) - - it('should *not* destroy the cached obj when an event has no longer handlers attached', async () => { - const destroyer = jest.fn() - const h = (room: any) => { - room._destroyer = destroyer - } - video.on('room.started', h) - - await video.subscribe() - session.dispatch(actions.socketMessageAction(firstRoom)) - - video.off('room.started', h) - expect(destroyer).not.toHaveBeenCalled() - }) - - it('should *not* destroy the cached obj when there are existing listeners attached', async () => { - const destroyer = jest.fn() - const h = (room: any) => { - room._destroyer = destroyer - } - video.on('room.started', h) - video.on('room.started', () => {}) - - await video.subscribe() - session.dispatch(actions.socketMessageAction(firstRoom)) - - video.off('room.started', h) - expect(destroyer).not.toHaveBeenCalled() - }) - - it('should *not* destroy the cached obj when .off is called with no handler', async () => { - const destroyer = jest.fn() - const h = (room: any) => { - room._destroyer = destroyer - } - video.on('room.started', h) - video.on('room.started', () => {}) - video.on('room.started', () => {}) - await video.subscribe() - session.dispatch(actions.socketMessageAction(firstRoom)) + // @ts-expect-error + video._client.store.channels.sessionChannel.put( + actions.socketMessageAction(firstRoom) + ) - video.off('room.started') - expect(destroyer).not.toHaveBeenCalled() + await promise }) it('each room object should use its own payload from the Proxy', async () => { - const mockExecute = jest.fn() - const mockNameCheck = jest.fn() - const promise = new Promise((resolve) => { - video.on('room.started', (room) => { - expect(room.videoMute).toBeDefined() - expect(room.videoUnmute).toBeDefined() - expect(room.getMembers).toBeDefined() - expect(room.subscribe).toBeDefined() - - room.on('member.joined', jest.fn) - // @ts-expect-error - room.execute = mockExecute - room.subscribe() - mockNameCheck(room.name) - - if (room.id === 'session-two') { - resolve(undefined) - } + const promise = new Promise(async (resolve) => { + await video.listen({ + onRoomStarted: (room) => { + expect(room.videoMute).toBeDefined() + expect(room.videoUnmute).toBeDefined() + expect(room.getMembers).toBeDefined() + expect(room.listen).toBeDefined() + if (room.id === 'session-two') { + resolve() + } + }, + onRoomEnded: () => {}, }) }) - await video.subscribe() - - session.dispatch(actions.socketMessageAction(firstRoom)) - session.dispatch(actions.socketMessageAction(secondRoom)) - - await promise + // @ts-expect-error + video._client.store.channels.sessionChannel.put( + actions.socketMessageAction(firstRoom) + ) + // @ts-expect-error + video._client.store.channels.sessionChannel.put( + actions.socketMessageAction(secondRoom) + ) - expect(mockExecute).toHaveBeenCalledTimes(2) - expect(mockExecute).toHaveBeenNthCalledWith(1, { - method: 'signalwire.subscribe', - params: { - event_channel: eventChannelOne, - events: ['video.member.joined', 'video.room.subscribed'], - get_initial_state: true, - }, - }) - expect(mockExecute).toHaveBeenNthCalledWith(2, { + // @ts-expect-error + expect(video._client.execute).toHaveBeenCalledTimes(1) + // @ts-expect-error + expect(video._client.execute).toHaveBeenNthCalledWith(1, { method: 'signalwire.subscribe', params: { - event_channel: eventChannelTwo, - events: ['video.member.joined', 'video.room.subscribed'], + event_channel: 'video.rooms', + events: ['video.room.started', 'video.room.ended'], get_initial_state: true, }, }) - // Check room.name exposed - expect(mockNameCheck).toHaveBeenCalledTimes(2) - expect(mockNameCheck).toHaveBeenNthCalledWith(1, 'First Room') - expect(mockNameCheck).toHaveBeenNthCalledWith(2, 'Second Room') + await promise }) }) - describe('video.room.ended event', () => { - const roomEndedEvent = JSON.parse( - `{"jsonrpc":"2.0","id":"uuid2","method":"signalwire.event","params":{"params":{"room":{"recording":false,"room_session_id":"session-one","name":"First Room","hide_video_muted":false,"music_on_hold":false,"room_id":"room_id","event_channel":"room."},"room_session_id":"session-one","room_id":"room_id","room_session":{"recording":false,"name":"First Room","hide_video_muted":false,"id":"session-one","music_on_hold":false,"room_id":"room_id","event_channel":"room."}},"timestamp":1631692510.415,"event_type":"video.room.ended","event_channel":"video.rooms.4b7ae78a-d02e-4889-a63b-08b156d5916e"}}` - ) - - it('should pass a Room obj to the handler', (done) => { - video.on('room.ended', (room) => { - expect(room.id).toBe('session-one') - expect(room.name).toBe('First Room') - expect(room.videoMute).toBeDefined() - expect(room.videoUnmute).toBeDefined() - expect(room.getMembers).toBeDefined() - expect(room.subscribe).toBeDefined() - done() - }) - - video.subscribe().then(() => { - session.dispatch(actions.socketMessageAction(roomEndedEvent)) - }) - }) - }) + // describe('video.room.ended event', () => { + // const roomEndedEvent = JSON.parse( + // `{"jsonrpc":"2.0","id":"uuid2","method":"signalwire.event","params":{"params":{"room":{"recording":false,"room_session_id":"session-one","name":"First Room","hide_video_muted":false,"music_on_hold":false,"room_id":"room_id","event_channel":"room."},"room_session_id":"session-one","room_id":"room_id","room_session":{"recording":false,"name":"First Room","hide_video_muted":false,"id":"session-one","music_on_hold":false,"room_id":"room_id","event_channel":"room."}},"timestamp":1631692510.415,"event_type":"video.room.ended","event_channel":"video.rooms.4b7ae78a-d02e-4889-a63b-08b156d5916e"}}` + // ) + + // it('should pass a Room obj to the handler', (done) => { + // video.listen({ + // onRoomEnded: (room) => { + // expect(room.id).toBe('session-one') + // expect(room.name).toBe('First Room') + // expect(room.videoMute).toBeDefined() + // expect(room.videoUnmute).toBeDefined() + // expect(room.getMembers).toBeDefined() + // done() + // }, + // }) + + // // @ts-expect-error + // video._client.store.dispatch(actions.socketMessageAction(roomEndedEvent)) + // }) + // }) describe('getRoomSessions()', () => { it('should be defined', () => { @@ -199,7 +169,7 @@ describe('Video Object', () => { it('should return an obj with a list of RoomSession objects', async () => { // @ts-expect-error - ;(video.execute as jest.Mock).mockResolvedValueOnce({ + ;(video._client.execute as jest.Mock).mockResolvedValueOnce({ code: '200', message: 'OK', rooms: [ @@ -269,7 +239,7 @@ describe('Video Object', () => { const result = await video.getRoomSessions() expect(result.roomSessions).toHaveLength(2) - expect(result.roomSessions[0]).toBeInstanceOf(RoomSessionConsumer) + expect(result.roomSessions[0]).toBeInstanceOf(RoomSession) expect(result.roomSessions[0].id).toBe( '25ab8daa-2639-45ed-bc73-69b664f55eff' ) @@ -280,7 +250,7 @@ describe('Video Object', () => { expect(result.roomSessions[0].recording).toBe(true) expect(result.roomSessions[0].getMembers).toBeDefined() - expect(result.roomSessions[1]).toBeInstanceOf(RoomSessionConsumer) + expect(result.roomSessions[1]).toBeInstanceOf(RoomSession) expect(result.roomSessions[1].id).toBe( 'c22fa141-a3f0-4923-b44c-e49aa318c3dd' ) @@ -301,7 +271,7 @@ describe('Video Object', () => { it('should return a RoomSession object', async () => { // @ts-expect-error - ;(video.execute as jest.Mock).mockResolvedValueOnce({ + ;(video._client.execute as jest.Mock).mockResolvedValueOnce({ room: { room_id: '776f0ece-75ce-4f84-8ce6-bd5677f2cbb9', id: '25ab8daa-2639-45ed-bc73-69b664f55eff', @@ -340,7 +310,7 @@ describe('Video Object', () => { '25ab8daa-2639-45ed-bc73-69b664f55eff' ) - expect(result.roomSession).toBeInstanceOf(RoomSessionConsumer) + expect(result.roomSession).toBeInstanceOf(RoomSession) expect(result.roomSession.id).toBe('25ab8daa-2639-45ed-bc73-69b664f55eff') expect(result.roomSession.roomId).toBe( '776f0ece-75ce-4f84-8ce6-bd5677f2cbb9' diff --git a/packages/realtime-api/src/video/Video.ts b/packages/realtime-api/src/video/Video.ts index a5de02c05..af10f9c2d 100644 --- a/packages/realtime-api/src/video/Video.ts +++ b/packages/realtime-api/src/video/Video.ts @@ -1,134 +1,71 @@ import { - BaseComponentOptions, - connect, - ConsumerContract, RoomSessionRecording, RoomSessionPlayback, + validateEventsToSubscribe, + EventEmitter, } from '@signalwire/core' -import { AutoSubscribeConsumer } from '../AutoSubscribeConsumer' -import type { RealtimeClient } from '../client/Client' import { - RealTimeRoomApiEvents, - RealTimeVideoApiEvents, - RealTimeVideoApiEventsHandlerMapping, - RealTimeRoomApiEventsHandlerMapping, + RealTimeRoomEvents, + RealTimeVideoEvents, + RealTimeVideoEventsHandlerMapping, + RealTimeRoomEventsHandlerMapping, + RealTimeVideoListenersEventsMapping, + RealTimeVideoListeners, } from '../types/video' -import { - RoomSession, - RoomSessionFullState, - RoomSessionUpdated, - createRoomSessionObject, -} from './RoomSession' +import { RoomSession, RoomSessionAPI } from './RoomSession' import type { RoomSessionMember, RoomSessionMemberUpdated, } from './RoomSessionMember' import { videoCallingWorker } from './workers' +import { SWClient } from '../SWClient' +import { BaseVideo } from './BaseVideo' + +export class Video extends BaseVideo< + RealTimeVideoListeners, + RealTimeVideoEvents +> { + protected _eventChannel = 'video.rooms' + protected _eventMap: RealTimeVideoListenersEventsMapping = { + onRoomStarted: 'room.started', + onRoomEnded: 'room.ended', + } -export interface Video extends ConsumerContract { - /** @internal */ - subscribe(): Promise - /** @internal */ - _session: RealtimeClient - /** - * Disconnects this client. The client will stop receiving events and you will - * need to create a new instance if you want to use it again. - * - * @example - * - * ```js - * client.disconnect() - * ``` - */ - disconnect(): void - - getRoomSessions(): Promise<{ roomSessions: RoomSession[] }> - getRoomSessionById(id: string): Promise<{ roomSession: RoomSession }> -} -export type { - RealTimeRoomApiEvents, - RealTimeRoomApiEventsHandlerMapping, - RealTimeVideoApiEvents, - RealTimeVideoApiEventsHandlerMapping, - RoomSession, - RoomSessionFullState, - RoomSessionMember, - RoomSessionMemberUpdated, - RoomSessionPlayback, - RoomSessionRecording, - RoomSessionUpdated, -} - -export type { - ClientEvents, - EmitterContract, - EntityUpdated, - GlobalVideoEvents, - InternalVideoMemberEntity, - LayoutChanged, - MEMBER_UPDATED_EVENTS, - MemberCommandParams, - MemberCommandWithValueParams, - MemberCommandWithVolumeParams, - MemberJoined, - MemberLeft, - MemberListUpdated, - MemberTalking, - MemberTalkingEnded, - MemberTalkingEventNames, - MemberTalkingStart, - MemberTalkingStarted, - MemberTalkingStop, - MemberUpdated, - MemberUpdatedEventNames, - PlaybackEnded, - PlaybackStarted, - PlaybackUpdated, - RecordingEnded, - RecordingStarted, - RecordingUpdated, - RoomEnded, - RoomStarted, - RoomSubscribed, - RoomUpdated, - SipCodec, - VideoLayoutEventNames, - VideoMemberContract, - VideoMemberEntity, - VideoMemberEventNames, - VideoMemberType, - VideoPlaybackEventNames, - VideoPosition, - VideoRecordingEventNames, -} from '@signalwire/core' - -class VideoAPI extends AutoSubscribeConsumer { - constructor(options: BaseComponentOptions) { + constructor(options: SWClient) { super(options) - this.runWorker('videoCallWorker', { worker: videoCallingWorker }) + this._client.runWorker('videoCallingWorker', { + worker: videoCallingWorker, + initialState: { + video: this, + }, + }) } - /** @internal */ - protected subscribeParams = { - get_initial_state: true, + protected override getSubscriptions() { + const eventNamesWithPrefix = this.eventNames().map( + (event) => `video.${String(event)}` + ) as EventEmitter.EventNames[] + return validateEventsToSubscribe(eventNamesWithPrefix) } async getRoomSessions() { return new Promise<{ roomSessions: RoomSession[] }>( async (resolve, reject) => { try { - const { rooms = [] }: any = await this.execute({ + const { rooms = [] }: any = await this._client.execute({ method: 'video.rooms.get', params: {}, }) const roomInstances: RoomSession[] = [] rooms.forEach((room: any) => { - let roomInstance = this.instanceMap.get(room.id) + let roomInstance = this._client.instanceMap.get( + room.id + ) if (!roomInstance) { - roomInstance = createRoomSessionObject({ - store: this.store, + roomInstance = new RoomSessionAPI({ + video: this, payload: { room_session: room }, }) } else { @@ -137,7 +74,10 @@ class VideoAPI extends AutoSubscribeConsumer { }) } roomInstances.push(roomInstance) - this.instanceMap.set(roomInstance.id, roomInstance) + this._client.instanceMap.set( + roomInstance.id, + roomInstance + ) }) resolve({ roomSessions: roomInstances }) @@ -153,17 +93,17 @@ class VideoAPI extends AutoSubscribeConsumer { return new Promise<{ roomSession: RoomSession }>( async (resolve, reject) => { try { - const { room }: any = await this.execute({ + const { room }: any = await this._client.execute({ method: 'video.room.get', params: { room_session_id: id, }, }) - let roomInstance = this.instanceMap.get(room.id) + let roomInstance = this._client.instanceMap.get(room.id) if (!roomInstance) { - roomInstance = createRoomSessionObject({ - store: this.store, + roomInstance = new RoomSessionAPI({ + video: this, payload: { room_session: room }, }) } else { @@ -171,7 +111,10 @@ class VideoAPI extends AutoSubscribeConsumer { room_session: room, }) } - this.instanceMap.set(roomInstance.id, roomInstance) + this._client.instanceMap.set( + roomInstance.id, + roomInstance + ) resolve({ roomSession: roomInstance }) } catch (error) { @@ -183,30 +126,57 @@ class VideoAPI extends AutoSubscribeConsumer { } } -/** @internal */ -export const createVideoObject = (params: BaseComponentOptions): Video => { - const video = connect({ - store: params.store, - Component: VideoAPI, - })(params) - - const proxy = new Proxy