From b39b82feed94950ef21883ba9dfe8c8f25220b99 Mon Sep 17 00:00:00 2001 From: Ammar Ansari Date: Tue, 5 Dec 2023 20:43:19 +0500 Subject: [PATCH] Review dev (#881) * 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 * 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 * 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 * 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 * fix unit tests * fix e2e test cases * 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 * 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 * fix playwright e2e test cases * fix call fabric relay application test * include log level debug for task e2e test * Fix e2e test cases with v4 SDK (#916) * fail test if error code is null * increase timeout limit for pubsub e2e test * unsub chat event * update node version in github actions * debug enable for prompt tests * remove logs from the SDK * send digits once prompt starts * end the call when caller ends the prompt * fix action onStarted promise * update voice speech test with v4 interface * enable logs for chat test * kill all node process before running tests * run only realtime tests * debug the ci * child.stderr.write remove * remove process.stderr in voiceSpeechCollect * enable all the tests * categorize tests by action type * voice collect speech test with continuous true and partial results false * voice collect speech test with continuous true and partial results true * more simplified collect speech tests * include changeset * increase tap timeout and fix race conditions in voice.test.ts * increase tap timeout * update possible results for collect * tap setTimeout update * Release 2023-11-23 (#913) * Expose Chat types from the JS SDK (#919) * Expose Chat types from the JS SDK * include changeset * Release 2023-12-05 (#920) --------- Co-authored-by: Edoardo Gallo --- .changeset/cuddly-carrots-bathe.md | 95 ++ .changeset/fluffy-birds-yawn.md | 33 + .changeset/hip-bobcats-hear.md | 76 ++ .changeset/lovely-tigers-breathe.md | 5 + .changeset/three-mails-think.md | 42 + .changeset/tricky-ants-talk.md | 50 + .changeset/violet-boats-count.md | 6 + .github/workflows/unit-tests.yml | 2 +- .../e2e-js/tests/callfabric/relayApp.spec.ts | 88 +- internal/e2e-realtime-api/src/chat.test.ts | 262 +++-- .../src/disconnectClient.test.ts | 68 -- .../e2e-realtime-api/src/messaging.test.ts | 42 +- .../src/playwright/video.test.ts | 109 +- .../src/playwright/videoHandRaise.test.ts | 30 +- .../src/playwright/videoUtils.ts | 18 +- internal/e2e-realtime-api/src/pubSub.test.ts | 153 ++- internal/e2e-realtime-api/src/task.test.ts | 139 ++- internal/e2e-realtime-api/src/utils.ts | 123 ++- internal/e2e-realtime-api/src/voice.test.ts | 431 ++++---- .../e2e-realtime-api/src/voiceCollect.test.ts | 153 --- .../src/voiceCollect/withAllListeners.test.ts | 210 ++++ .../voiceCollect/withCallListeners.test.ts | 177 ++++ .../voiceCollect/withCollectListeners.test.ts | 232 ++++ .../withContinuousFalsePartialFalse.test.ts | 164 +++ ...inuousFalsePartialTrue&EarlyHangup.test.ts | 168 +++ .../withContinuousFalsePartialTrue.test.ts | 163 +++ .../withContinuousTruePartialFalse.test.ts | 170 +++ .../withContinuousTruePartialTrue.test.ts | 170 +++ .../voiceCollect/withDialListeners.test.ts | 172 +++ .../e2e-realtime-api/src/voiceDetect.test.ts | 128 --- .../src/voiceDetect/withAllListeners.test.ts | 202 ++++ .../src/voiceDetect/withCallListeners.test.ts | 134 +++ .../voiceDetect/withDetectListeners.test.ts | 140 +++ .../src/voiceDetect/withDialListeners.test.ts | 131 +++ .../e2e-realtime-api/src/voicePass.test.ts | 116 +- .../src/voicePlayback.test.ts | 169 --- .../voicePlayback/withAllListeners.test.ts | 175 +++ .../voicePlayback/withCallListeners.test.ts | 112 ++ .../voicePlayback/withDialListeners.test.ts | 109 ++ .../withMultiplePlaybacks.test.ts | 232 ++++ .../withPlaybackListeners.test.ts | 132 +++ .../src/voicePlaybackMultiple.test.ts | 198 ---- .../e2e-realtime-api/src/voicePrompt.test.ts | 161 --- .../src/voicePrompt/withAllListeners.test.ts | 246 +++++ .../src/voicePrompt/withCallListeners.test.ts | 171 +++ .../src/voicePrompt/withDialListeners.test.ts | 174 +++ .../voicePrompt/withPromptListeners.test.ts | 202 ++++ .../src/voiceRecord/withAllListeners.test.ts | 176 ++++ .../src/voiceRecord/withCallListeners.test.ts | 107 ++ .../src/voiceRecord/withDialListeners.test.ts | 103 ++ .../voiceRecord/withMultipleRecords.test.ts | 153 +++ .../voiceRecord/withRecordListeners.test.ts | 127 +++ .../src/voiceRecordMultiple.test.ts | 160 --- .../src/voiceRecording.test.ts | 191 ---- .../src/voiceSpeechCollect.test.ts | 227 ---- .../e2e-realtime-api/src/voiceTap.test.ts | 111 -- .../src/voiceTapAllListeners.test.ts | 135 +++ .../playground-realtime-api/src/chat/index.ts | 72 +- .../src/messaging/index.ts | 43 +- .../src/pubSub/index.ts | 56 +- .../playground-realtime-api/src/task/index.ts | 65 +- .../src/voice-dtmf-loop/index.ts | 19 +- .../src/voice-inbound/index.ts | 53 +- .../src/voice/index.ts | 483 +++++---- .../src/with-events/index.ts | 102 +- internal/stack-tests/src/chat/app.ts | 21 +- internal/stack-tests/src/messaging/app.ts | 16 +- internal/stack-tests/src/pubSub/app.ts | 16 +- internal/stack-tests/src/task/app.ts | 15 +- internal/stack-tests/src/video/app.ts | 18 +- internal/stack-tests/src/voice/app.ts | 19 +- packages/core/src/BaseComponent.ts | 4 +- packages/core/src/BaseSession.ts | 6 + packages/core/src/chat/applyCommonMethods.ts | 109 ++ packages/core/src/chat/index.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/types/chat.ts | 2 + packages/core/src/types/messaging.ts | 4 +- packages/core/src/types/utils.ts | 7 + 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/types/voiceCall.ts | 31 +- packages/core/src/utils/interfaces.ts | 6 + .../realtime-api/src/AutoSubscribeConsumer.ts | 54 - .../realtime-api/src/BaseNamespace.test.ts | 225 ++++ packages/realtime-api/src/BaseNamespace.ts | 151 +++ packages/realtime-api/src/Client.ts | 100 -- .../realtime-api/src/ListenSubscriber.test.ts | 136 +++ packages/realtime-api/src/ListenSubscriber.ts | 148 +++ packages/realtime-api/src/SWClient.test.ts | 67 ++ packages/realtime-api/src/SWClient.ts | 93 ++ packages/realtime-api/src/SignalWire.ts | 16 + .../realtime-api/src/chat/BaseChat.test.ts | 116 ++ packages/realtime-api/src/chat/BaseChat.ts | 140 +++ packages/realtime-api/src/chat/Chat.test.ts | 39 + packages/realtime-api/src/chat/Chat.ts | 45 +- .../realtime-api/src/chat/ChatClient.test.ts | 115 -- packages/realtime-api/src/chat/ChatClient.ts | 114 -- .../src/chat/workers/chatWorker.ts | 74 ++ .../realtime-api/src/chat/workers/index.ts | 1 + .../src/{ => client}/createClient.test.ts | 6 +- .../realtime-api/src/client/createClient.ts | 18 + packages/realtime-api/src/createClient.ts | 61 -- .../realtime-api/src/decoratePromise.test.ts | 158 +++ packages/realtime-api/src/decoratePromise.ts | 96 ++ packages/realtime-api/src/index.ts | 198 +--- .../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 +- .../realtime-api/src/pubSub/PubSub.test.ts | 36 + packages/realtime-api/src/pubSub/PubSub.ts | 46 +- .../realtime-api/src/pubSub/PubSubClient.ts | 96 -- .../realtime-api/src/pubSub/workers/index.ts | 1 + .../src/pubSub/workers/pubSubWorker.ts | 74 ++ packages/realtime-api/src/task/Task.test.ts | 78 ++ packages/realtime-api/src/task/Task.ts | 109 +- 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 | 43 + packages/realtime-api/src/types/chat.ts | 12 +- packages/realtime-api/src/types/pubSub.ts | 12 +- packages/realtime-api/src/types/video.ts | 993 +++++++++++++++++- packages/realtime-api/src/types/voice.ts | 344 +++++- packages/realtime-api/src/utils/internals.ts | 5 + 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 +- packages/realtime-api/src/voice/Call.test.ts | 117 +++ packages/realtime-api/src/voice/Call.ts | 816 +++++++------- .../src/voice/CallCollect.test.ts | 50 - .../src/voice/CallCollect/CallCollect.test.ts | 193 ++++ .../voice/{ => CallCollect}/CallCollect.ts | 134 ++- .../CallCollect/decorateCollectPromise.ts | 60 ++ .../src/voice/CallCollect/index.ts | 3 + .../realtime-api/src/voice/CallDetect.test.ts | 38 - .../src/voice/CallDetect/CallDetect.test.ts | 169 +++ .../src/voice/{ => CallDetect}/CallDetect.ts | 94 +- .../voice/CallDetect/decorateDetectPromise.ts | 59 ++ .../src/voice/CallDetect/index.ts | 3 + .../src/voice/CallPlayback.test.ts | 61 -- .../voice/CallPlayback/CallPlayback.test.ts | 206 ++++ .../voice/{ => CallPlayback}/CallPlayback.ts | 118 +-- .../CallPlayback/decoratePlaybackPromise.ts | 56 + .../src/voice/CallPlayback/index.ts | 3 + .../realtime-api/src/voice/CallPrompt.test.ts | 51 - .../src/voice/CallPrompt/CallPrompt.test.ts | 191 ++++ .../src/voice/{ => CallPrompt}/CallPrompt.ts | 132 ++- .../voice/CallPrompt/decoratePromptPromise.ts | 61 ++ .../src/voice/CallPrompt/index.ts | 3 + .../src/voice/CallRecording.test.ts | 66 -- .../realtime-api/src/voice/CallRecording.ts | 178 ---- .../voice/CallRecording/CallRecording.test.ts | 207 ++++ .../src/voice/CallRecording/CallRecording.ts | 172 +++ .../CallRecording/decorateRecordingPromise.ts | 61 ++ .../src/voice/CallRecording/index.ts | 3 + .../realtime-api/src/voice/CallTap.test.ts | 57 - packages/realtime-api/src/voice/CallTap.ts | 116 -- .../src/voice/CallTap/CallTap.test.ts | 183 ++++ .../realtime-api/src/voice/CallTap/CallTap.ts | 112 ++ .../src/voice/CallTap/decorateTapPromise.ts | 42 + .../realtime-api/src/voice/CallTap/index.ts | 3 + packages/realtime-api/src/voice/Voice.test.ts | 63 ++ packages/realtime-api/src/voice/Voice.ts | 358 +++---- .../realtime-api/src/voice/VoiceClient.ts | 111 -- .../voice/workers/VoiceCallSendDigitWorker.ts | 69 +- .../handlers/callConnectEventsHandler.ts | 77 ++ .../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 --- scripts/sw-test/runNodeScript.js | 43 +- 217 files changed, 16692 insertions(+), 7005 deletions(-) create mode 100644 .changeset/cuddly-carrots-bathe.md create mode 100644 .changeset/fluffy-birds-yawn.md create mode 100644 .changeset/hip-bobcats-hear.md create mode 100644 .changeset/lovely-tigers-breathe.md create mode 100644 .changeset/three-mails-think.md create mode 100644 .changeset/tricky-ants-talk.md create mode 100644 .changeset/violet-boats-count.md delete mode 100644 internal/e2e-realtime-api/src/disconnectClient.test.ts delete mode 100644 internal/e2e-realtime-api/src/voiceCollect.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollect/withAllListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollect/withCallListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollect/withCollectListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollect/withContinuousFalsePartialFalse.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollect/withContinuousFalsePartialTrue&EarlyHangup.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollect/withContinuousFalsePartialTrue.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollect/withContinuousTruePartialFalse.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollect/withContinuousTruePartialTrue.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceCollect/withDialListeners.test.ts delete mode 100644 internal/e2e-realtime-api/src/voiceDetect.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceDetect/withAllListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceDetect/withCallListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceDetect/withDetectListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceDetect/withDialListeners.test.ts delete mode 100644 internal/e2e-realtime-api/src/voicePlayback.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePlayback/withAllListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePlayback/withCallListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePlayback/withDialListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePlayback/withMultiplePlaybacks.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePlayback/withPlaybackListeners.test.ts delete mode 100644 internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts delete mode 100644 internal/e2e-realtime-api/src/voicePrompt.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePrompt/withAllListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePrompt/withCallListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePrompt/withDialListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voicePrompt/withPromptListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceRecord/withAllListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceRecord/withCallListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceRecord/withDialListeners.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceRecord/withMultipleRecords.test.ts create mode 100644 internal/e2e-realtime-api/src/voiceRecord/withRecordListeners.test.ts delete mode 100644 internal/e2e-realtime-api/src/voiceRecordMultiple.test.ts delete mode 100644 internal/e2e-realtime-api/src/voiceRecording.test.ts delete mode 100644 internal/e2e-realtime-api/src/voiceSpeechCollect.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/core/src/chat/applyCommonMethods.ts delete mode 100644 packages/realtime-api/src/AutoSubscribeConsumer.ts create mode 100644 packages/realtime-api/src/BaseNamespace.test.ts create mode 100644 packages/realtime-api/src/BaseNamespace.ts delete mode 100644 packages/realtime-api/src/Client.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/SWClient.test.ts create mode 100644 packages/realtime-api/src/SWClient.ts create mode 100644 packages/realtime-api/src/SignalWire.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 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 create mode 100644 packages/realtime-api/src/decoratePromise.test.ts create mode 100644 packages/realtime-api/src/decoratePromise.ts 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 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 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 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 create mode 100644 packages/realtime-api/src/voice/Call.test.ts 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 (54%) 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 (59%) 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 (55%) 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 (54%) 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 delete mode 100644 packages/realtime-api/src/voice/CallRecording.ts create mode 100644 packages/realtime-api/src/voice/CallRecording/CallRecording.test.ts create mode 100644 packages/realtime-api/src/voice/CallRecording/CallRecording.ts 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 delete mode 100644 packages/realtime-api/src/voice/CallTap.ts create mode 100644 packages/realtime-api/src/voice/CallTap/CallTap.test.ts create mode 100644 packages/realtime-api/src/voice/CallTap/CallTap.ts 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/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/.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/.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/.changeset/lovely-tigers-breathe.md b/.changeset/lovely-tigers-breathe.md new file mode 100644 index 000000000..af612a872 --- /dev/null +++ b/.changeset/lovely-tigers-breathe.md @@ -0,0 +1,5 @@ +--- +'@signalwire/realtime-api': patch +--- + +Fix `onStarted` function in decorated promises 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/.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/.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/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 58f9dd214..fb4bed545 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -1,4 +1,4 @@ -name: Unit and stack tests +name: Tests on: push: diff --git a/internal/e2e-js/tests/callfabric/relayApp.spec.ts b/internal/e2e-js/tests/callfabric/relayApp.spec.ts index eaf743091..252298d20 100644 --- a/internal/e2e-js/tests/callfabric/relayApp.spec.ts +++ b/internal/e2e-js/tests/callfabric/relayApp.spec.ts @@ -1,4 +1,4 @@ -import { Voice } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' import { createCFClient, expectPageReceiveAudio, @@ -11,30 +11,34 @@ test.describe('CallFabric Relay Application', () => { test('should connect to the relay app and expect an audio playback', async ({ createCustomPage, }) => { - const client = new Voice.Client({ + const client = await SignalWire({ host: process.env.RELAY_HOST, project: process.env.CF_RELAY_PROJECT as string, token: process.env.CF_RELAY_TOKEN as string, - topics: ['cf-e2e-test-relay'], debug: { logWsTraffic: true, }, }) - client.on('call.received', async (call) => { - try { - console.log('Call received', call.id) - - await call.answer() - console.log('Inbound call answered') - - const playback = await call.playAudio({ - url: 'https://cdn.signalwire.com/default-music/welcome.mp3', - }) - await playback.setVolume(10) - } catch (error) { - console.error('Inbound call error', error) - } + await client.voice.listen({ + topics: ['cf-e2e-test-relay'], + onCallReceived: async (call) => { + try { + console.log('Call received', call.id) + + await call.answer() + console.log('Inbound call answered') + + const playback = await call + .playAudio({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + }) + .onStarted() + await playback.setVolume(10) + } catch (error) { + console.error('Inbound call error', error) + } + }, }) try { @@ -84,28 +88,30 @@ test.describe('CallFabric Relay Application', () => { test('should connect to the relay app and expect a silence', async ({ createCustomPage, }) => { - const client = new Voice.Client({ + const client = await SignalWire({ host: process.env.RELAY_HOST, project: process.env.CF_RELAY_PROJECT as string, token: process.env.CF_RELAY_TOKEN as string, - topics: ['cf-e2e-test-relay'], debug: { logWsTraffic: true, }, }) - client.on('call.received', async (call) => { - try { - console.log('Call received', call.id) + await client.voice.listen({ + topics: ['cf-e2e-test-relay'], + onCallReceived: async (call) => { + try { + console.log('Call received', call.id) - await call.answer() - console.log('Inbound call answered') + await call.answer() + console.log('Inbound call answered') - const playback = await call.playSilence({ duration: 60 }) - await playback.setVolume(10) - } catch (error) { - console.error('Inbound call error', error) - } + const playback = await call.playSilence({ duration: 60 }).onStarted() + await playback.setVolume(10) + } catch (error) { + console.error('Inbound call error', error) + } + }, }) try { @@ -157,27 +163,29 @@ test.describe('CallFabric Relay Application', () => { test('should connect to the relay app and expect a hangup', async ({ createCustomPage, }) => { - const client = new Voice.Client({ + const client = await SignalWire({ host: process.env.RELAY_HOST, project: process.env.CF_RELAY_PROJECT as string, token: process.env.CF_RELAY_TOKEN as string, - topics: ['cf-e2e-test-relay'], debug: { logWsTraffic: true, }, }) - client.on('call.received', async (call) => { - try { - console.log('Call received', call.id) + await client.voice.listen({ + topics: ['cf-e2e-test-relay'], + onCallReceived: async (call) => { + try { + console.log('Call received', call.id) - await call.answer() - console.log('Inbound call answered') + await call.answer() + console.log('Inbound call answered') - await call.hangup() - } catch (error) { - console.error('Inbound call error', error) - } + await call.hangup() + } catch (error) { + console.error('Inbound call error', error) + } + }, }) const page = await createCustomPage({ name: '[page]' }) diff --git a/internal/e2e-realtime-api/src/chat.test.ts b/internal/e2e-realtime-api/src/chat.test.ts index 9a592b194..f222ef19d 100644 --- a/internal/e2e-realtime-api/src/chat.test.ts +++ b/internal/e2e-realtime-api/src/chat.test.ts @@ -5,7 +5,11 @@ * 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 RTChat, + 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 +43,56 @@ const params = { }, } -type ChatClient = RealtimeAPIChat.ChatClient | JSChat.Client -const testChatClientSubscribe = ( - firstClient: ChatClient, - secondClient: ChatClient -) => { +type ChatClient = RTChat.Chat | JSChat.Client + +interface TestChatOptions { + jsChat: JSChat.Client + rtChat: RTChat.Chat + publisher?: 'JS' | 'RT' +} + +const testSubscribe = ({ jsChat, rtChat }: TestChatOptions) => { const promise = new Promise(async (resolve) => { console.log('Running subscribe..') let events = 0 - const resolveIfDone = () => { + + let unsubRTChannel: Promise<() => Promise> + + const resolveIfDone = async () => { // 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') + await ( + await unsubRTChannel + )() 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() + + unsubRTChannel = rtChat.listen({ + channels: [channel], + onMemberJoined(member) { + // TODO: Check the member payload + console.log('rtChat member.joined') + events += 1 + resolveIfDone() + }, }) - await Promise.all([ - firstClient.subscribe(channel), - secondClient.subscribe(channel), - ]) + await Promise.all([unsubRTChannel, 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 +103,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 +140,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 +194,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 +208,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 +216,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 +229,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 +256,37 @@ 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) { + console.log('Client disconnected okay!') + resolve(0) + } + }) + + return timeoutPromise(promise, promiseTimeout, promiseException) +} + const handler = async () => { // Create JS Chat Client const CRT = await createCRT(params) @@ -248,66 +294,88 @@ const handler = async () => { host: process.env.RELAY_HOST, // @ts-expect-error token: CRT.token, + debug: { + logWsTraffic: true, + }, }) - 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, + // debug: { + // logWsTraffic: true, + // }, }) + 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 } @@ -315,7 +383,7 @@ async function main() { const runner = createTestRunner({ name: 'Chat E2E', testHandler: handler, - executionTime: 15_000, + executionTime: 30_000, }) await runner.run() 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/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/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/e2e-realtime-api/src/playwright/videoHandRaise.test.ts b/internal/e2e-realtime-api/src/playwright/videoHandRaise.test.ts index 6c926a25c..29232be5d 100644 --- a/internal/e2e-realtime-api/src/playwright/videoHandRaise.test.ts +++ b/internal/e2e-realtime-api/src/playwright/videoHandRaise.test.ts @@ -38,10 +38,12 @@ test.describe('Video room hand raise/lower', () => { // Raise a hand of memberOne using Node SDK const memberOneUpdatedNode = await new Promise( async (resolve, _reject) => { - roomSession.on('member.updated', (member) => { - if (member.name === memberOne.name) { - resolve(member) - } + await roomSession.listen({ + onMemberUpdated: (member) => { + if (member.name === memberOne.name) { + resolve(member) + } + }, }) await roomSession.setRaisedHand({ memberId: memberOne.id }) } @@ -75,10 +77,12 @@ test.describe('Video room hand raise/lower', () => { // Raise memberTwo hand using a member object via Node SDK const memberTwoUpdatedNode = await new Promise( async (resolve, _reject) => { - roomSession.on('member.updated', (member) => { - if (member.name === memberTwo.name) { - resolve(member) - } + await roomSession.listen({ + onMemberUpdated: (member) => { + if (member.name === memberTwo.name) { + resolve(member) + } + }, }) await memberTwo.setRaisedHand() } @@ -110,10 +114,12 @@ test.describe('Video room hand raise/lower', () => { // Expect member.updated event via Node SDK for memberOne const memberOneNode = new Promise( async (resolve, _reject) => { - roomSession.on('member.updated', (member) => { - if (member.name === memberOne.name) { - resolve(member) - } + await roomSession.listen({ + onMemberUpdated: (member) => { + if (member.name === memberOne.name) { + resolve(member) + } + }, }) } ) diff --git a/internal/e2e-realtime-api/src/playwright/videoUtils.ts b/internal/e2e-realtime-api/src/playwright/videoUtils.ts index ac436ae43..e9b70476b 100644 --- a/internal/e2e-realtime-api/src/playwright/videoUtils.ts +++ b/internal/e2e-realtime-api/src/playwright/videoUtils.ts @@ -1,6 +1,6 @@ import { Page, Browser, expect } from '@playwright/test' import { uuid } from '@signalwire/core' -import { Video } from '@signalwire/realtime-api' +import { SWClient, SignalWire } from '@signalwire/realtime-api' import { SERVER_URL } from '../../utils' const PERMISSIONS = [ @@ -207,7 +207,7 @@ export const expectMemberUpdated = async ({ page, memberName }) => { } interface FindRoomSessionByPrefixParams { - client: Video.Client + client: SWClient prefix: string } @@ -215,7 +215,7 @@ export const findRoomSessionByPrefix = async ({ client, prefix, }: FindRoomSessionByPrefixParams) => { - const { roomSessions } = await client.getRoomSessions() + const { roomSessions } = await client.video.getRoomSessions() return roomSessions.filter((r) => r.name.startsWith(prefix)) } @@ -229,8 +229,7 @@ export const createRoomAndJoinTwoMembers = async (browser: Browser) => { enablePageLogs(pageTwo, '[pageTwo]') // 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, @@ -242,14 +241,9 @@ export const createRoomAndJoinTwoMembers = async (browser: Browser) => { const memberOneName = `${prefix}-member-one` const memberTwoName = `${prefix}-member-two` - // TODO: This is not needed with new interface due to listen method - videoClient.on('room.started', (room) => { - room.on('member.updated', () => {}) - }) - // Room length should be 0 before start const roomSessionsBeforeStart = await findRoomSessionByPrefix({ - client: videoClient, + client, prefix, }) expect(roomSessionsBeforeStart).toHaveLength(0) @@ -272,7 +266,7 @@ export const createRoomAndJoinTwoMembers = async (browser: Browser) => { // Room length should be 1 after start const roomSessionsAfterStart = await findRoomSessionByPrefix({ - client: videoClient, + client, prefix, }) expect(roomSessionsAfterStart).toHaveLength(1) diff --git a/internal/e2e-realtime-api/src/pubSub.test.ts b/internal/e2e-realtime-api/src/pubSub.test.ts index 7da663124..62b3e8e10 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 RTPubSub, + 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: RTPubSub.PubSub + 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 } @@ -194,6 +234,7 @@ async function main() { const runner = createTestRunner({ name: 'PubSub E2E', testHandler: handler, + executionTime: 20_000, }) await runner.run() diff --git a/internal/e2e-realtime-api/src/task.test.ts b/internal/e2e-realtime-api/src/task.test.ts index f41e64d24..8dcb6f7b1 100644 --- a/internal/e2e-realtime-api/src/task.test.ts +++ b/internal/e2e-realtime-api/src/task.test.ts @@ -1,58 +1,102 @@ import { randomUUID } from 'node:crypto' -import { Task } from '@signalwire/realtime-api' +import tap from 'tap' +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, + logLevel: 'debug', + debug: { + logWsTraffic: true, + }, + }) - 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], - }) - - 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 homeTopic = `home-${randomUUID()}` + const officeTopic = `office-${randomUUID()}` - if (counter === 2) { - return resolve(0) + const firstPayload = { + id: 1, + topic: homeTopic, + } + const secondPayload = { + id: 2, + topic: homeTopic, + } + const thirdPayload = { + id: 3, + topic: officeTopic, } - }) - - 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, - }) - - 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, - }) + + let unsubHomeOfficeCount = 0 + + const unsubHomeOffice = await client.task.listen({ + topics: [homeTopic, officeTopic], + onTaskReceived: async (payload) => { + if ( + payload.topic !== homeTopic || + (payload.id !== firstPayload.id && payload.id !== secondPayload.id) + ) { + 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, + }) + } + }, + }) + + const unsubOffice = await client.task.listen({ + topics: [officeTopic], + onTaskReceived: async (payload) => { + if (payload.topic !== officeTopic || payload.id !== thirdPayload.id) { + tap.notOk(payload, "Message received on wrong ['office'] listener") + } + + tap.ok(payload, 'Message received on ["office"] topics') + + await unsubOffice() + + await client.disconnect() + + return resolve(0) + }, + }) + + await client.task.send({ + topic: homeTopic, + message: firstPayload, + }) + + await client.task.send({ + topic: homeTopic, + message: secondPayload, + }) + } catch (error) { + console.log('Task test error', error) + reject(error) + } }) } @@ -60,6 +104,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..414bd51a7 100644 --- a/internal/e2e-realtime-api/src/utils.ts +++ b/internal/e2e-realtime-api/src/utils.ts @@ -1,3 +1,4 @@ +import tap from 'tap' import { request } from 'node:https' import { randomUUID, randomBytes } from 'node:crypto' @@ -43,7 +44,6 @@ interface CreateTestRunnerParams { testHandler: TestHandler executionTime?: number useDomainApp?: boolean - exitOnSuccess?: boolean } export const createTestRunner = ({ @@ -52,7 +52,6 @@ export const createTestRunner = ({ testHandler, executionTime = MAX_EXECUTION_TIME, useDomainApp = false, - exitOnSuccess = true, }: CreateTestRunnerParams) => { let timer: ReturnType @@ -61,15 +60,13 @@ export const createTestRunner = ({ console.error(`Test Runner ${name} ran out of time (${executionTime})`) process.exit(2) }, executionTime) + tap.setTimeout(executionTime) } const done = (exitCode: number) => { clearTimeout(timer) if (exitCode === 0) { console.log(`Test Runner ${name} Passed!`) - if (!exitOnSuccess) { - return; - } } else { console.log(`Test Runner ${name} finished with exitCode: ${exitCode}`) } @@ -87,14 +84,13 @@ 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) if (params.domainApp) { console.log('Delete domain app..') await deleteDomainApp({ id: params.domainApp.id }) - delete params.domainApp } done(exitCode) } catch (error) { @@ -103,7 +99,6 @@ export const createTestRunner = ({ if (params.domainApp) { console.log('Delete domain app..') await deleteDomainApp({ id: params.domainApp.id }) - delete params.domainApp } done(1) } @@ -261,3 +256,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..71909a4cc 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,216 @@ 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') + 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, + }, + }) - console.log('Stopping the recording') - recording.stop() - const recordingEndedResult = await recording.ended() - tap.equal( - recordingEndedResult.state, - 'finished', - 'Recording state is "finished"' - ) + let outboundCall: Voice.Call + let callsReceived = new Set() - call.on('prompt.started', (p) => { - tap.ok(p.id, 'Prompt has started') - }) - call.on('prompt.ended', (p) => { - tap.ok(p.id, 'Prompt has ended') - }) + let waitForDetectStartResolve: () => void + const waitForDetectStart = new Promise((resolve) => { + waitForDetectStartResolve = resolve + }) - 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', + 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) { + // Wait until the outbound peer starts digit detection + await waitForDetectStart + + const sendDigitResult = await call.sendDigits('1#') + tap.equal( + call.id, + sendDigitResult.id, + 'PeerInboundCall - SendDigit returns the same instance' + ) + + return + } + + const recording = await call + .recordAudio({ + direction: 'speak', + inputSensitivity: 60, + }) + .onStarted() + 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 }).onStarted() + 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') + }, + }, + }) + .onEnded() + + tap.equal( + prompt.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', + }) + .onStarted() + + // Inform inbound peer that the detector has started + waitForDetectStartResolve() + + const detected = await detector.ended() + + // TODO: update this once the backend can send us the actual result + tap.equal( + detected.detect?.params.event, + 'finished', + 'PeerOutboundCall - 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"') }) - ), - 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' - ) - - 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 +231,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 +253,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/voiceCollect/withAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollect/withAllListeners.test.ts new file mode 100644 index 000000000..58a6b5f00 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollect/withAllListeners.test.ts @@ -0,0 +1,210 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_COLLECT_PROPS, + CALL_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({ + 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: [domainApp.call_relay_context, '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.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.dialSip: 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') + }, + }, + }) + .onStarted() + 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() + + await 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, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceCollect/withCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollect/withCallListeners.test.ts new file mode 100644 index 000000000..884c6f256 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollect/withCallListeners.test.ts @@ -0,0 +1,177 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_COLLECT_PROPS, + CALL_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({ + 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: [domainApp.call_relay_context, '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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + 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, + }) + .onStarted() + 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() + + await 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, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceCollect/withCollectListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollect/withCollectListeners.test.ts new file mode 100644 index 000000000..8e62f5ddc --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollect/withCollectListeners.test.ts @@ -0,0 +1,232 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_COLLECT_PROPS, + CALL_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({ + 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: [domainApp.call_relay_context, '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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + 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' + ) + }, + }, + }) + .onStarted() + 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() + + await 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, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceCollect/withContinuousFalsePartialFalse.test.ts b/internal/e2e-realtime-api/src/voiceCollect/withContinuousFalsePartialFalse.test.ts new file mode 100644 index 000000000..cbed56583 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollect/withContinuousFalsePartialFalse.test.ts @@ -0,0 +1,164 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from '../utils' + +const possibleExpectedTexts = [ + '123456789 10:00 11:00 12:00', + 'one two three four five six seven eight nine ten', + '1112', +] + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } + + 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 + const waitForCollectStart = new Promise((resolve) => { + waitForCollectStartResolve = resolve + }) + + let waitForPlaybackEndResolve + const waitForPlaybackEnd = new Promise((resolve) => { + waitForPlaybackEndResolve = resolve + }) + + 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 callCollect = await call + .collect({ + initialTimeout: 10.0, + speech: { + endSilenceTimeout: 2.0, + speechTimeout: 20.0, + language: 'en-US', + model: 'enhanced.phone_call', + }, + partialResults: false, + continuous: false, + sendStartOfInput: true, + listen: { + onStarted: () => { + console.log('>>> collect.started') + }, + onUpdated: (_collect) => { + console.log('>>> collect.updated', _collect.text) + tap.notOk(_collect, 'Should not receive partial results') + }, + onEnded: (_collect) => { + console.log('>>> collect.ended', _collect.text) + }, + onFailed: (_collect) => { + console.log('>>> collect.failed', _collect.reason) + }, + }, + }) + .onStarted() + + // Inform caller that collect has started + waitForCollectStartResolve() + + // Wait until the caller ends sending the speech + await waitForPlaybackEnd + + const collected = await callCollect.ended() + tap.ok( + possibleExpectedTexts.includes(collected.text!), + 'Received Correct Text' + ) + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + }) + tap.ok(call.id, 'Outbound - Call resolved') + + // Wait until the callee starts collecting speech + await waitForCollectStart + + await call.playAudio({ + url: 'https://amaswtest.s3-accelerate.amazonaws.com/newrecording2.mp3', + }) + + // Inform callee that speech has completed + waitForPlaybackEndResolve() + + // 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 client.disconnect() + + resolve(0) + } catch (error) { + console.error('voiceCollect/withContinuousFalsePartialFalse error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Collect with Continuous false & Partial false', + testHandler: handler, + executionTime: 60_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceCollect/withContinuousFalsePartialTrue&EarlyHangup.test.ts b/internal/e2e-realtime-api/src/voiceCollect/withContinuousFalsePartialTrue&EarlyHangup.test.ts new file mode 100644 index 000000000..3dd163f23 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollect/withContinuousFalsePartialTrue&EarlyHangup.test.ts @@ -0,0 +1,168 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from '../utils' + +const possibleExpectedTexts = [ + '123456789 10:00 11:00 12:00', + 'one two three four five six seven eight nine ten', + '1112', +] + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } + + 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 + const waitForCollectStart = new Promise((resolve) => { + waitForCollectStartResolve = resolve + }) + + let waitForPlaybackEndResolve + const waitForPlaybackEnd = new Promise((resolve) => { + waitForPlaybackEndResolve = resolve + }) + + 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 callCollect = await call + .collect({ + initialTimeout: 10.0, + speech: { + endSilenceTimeout: 2.0, + speechTimeout: 20.0, + language: 'en-US', + model: 'enhanced.phone_call', + }, + partialResults: true, + continuous: false, + sendStartOfInput: true, + listen: { + onStarted: () => { + console.log('>>> collect.started') + }, + onUpdated: (_collect) => { + console.log('>>> collect.updated', _collect.text) + }, + onEnded: (_collect) => { + console.log('>>> collect.ended', _collect.text) + }, + onFailed: (_collect) => { + console.log('>>> collect.failed', _collect.reason) + }, + }, + }) + .onStarted() + + // Inform caller that collect has started + waitForCollectStartResolve() + + // Wait until the caller ends sending the speech + await waitForPlaybackEnd + + const collected = await callCollect.ended() + tap.ok( + possibleExpectedTexts.includes(collected.text!), + 'Received Correct Text' + ) + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + }) + tap.ok(call.id, 'Outbound - Call resolved') + + // Wait until the callee starts collecting speech + await waitForCollectStart + + // Play an speech but do not let it complete + call.playAudio({ + url: 'https://amaswtest.s3-accelerate.amazonaws.com/newrecording2.mp3', + }) + await new Promise((resolve) => setTimeout(resolve, 5_000)) + + // Inform callee that speech has completed + waitForPlaybackEndResolve() + + // 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 client.disconnect() + + resolve(0) + } catch (error) { + console.error( + 'voiceCollect/withContinuousFalsePartialTrue&EarlyHangup error', + error + ) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Collect with Continuous false, Partial true & early hangup', + testHandler: handler, + executionTime: 60_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceCollect/withContinuousFalsePartialTrue.test.ts b/internal/e2e-realtime-api/src/voiceCollect/withContinuousFalsePartialTrue.test.ts new file mode 100644 index 000000000..8ae4f7bff --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollect/withContinuousFalsePartialTrue.test.ts @@ -0,0 +1,163 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from '../utils' + +const possibleExpectedTexts = [ + '123456789 10:00 11:00 12:00', + 'one two three four five six seven eight nine ten', + '1112', +] + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } + + 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 + const waitForCollectStart = new Promise((resolve) => { + waitForCollectStartResolve = resolve + }) + + let waitForPlaybackEndResolve + const waitForPlaybackEnd = new Promise((resolve) => { + waitForPlaybackEndResolve = resolve + }) + + 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 callCollect = await call + .collect({ + initialTimeout: 10.0, + speech: { + endSilenceTimeout: 2.0, + speechTimeout: 20.0, + language: 'en-US', + model: 'enhanced.phone_call', + }, + partialResults: true, + continuous: false, + sendStartOfInput: true, + listen: { + onStarted: () => { + console.log('>>> collect.started') + }, + onUpdated: (_collect) => { + console.log('>>> collect.updated', _collect.text) + }, + onEnded: (_collect) => { + console.log('>>> collect.ended', _collect.text) + }, + onFailed: (_collect) => { + console.log('>>> collect.failed', _collect.reason) + }, + }, + }) + .onStarted() + + // Inform caller that collect has started + waitForCollectStartResolve() + + // Wait until the caller ends sending the speech + await waitForPlaybackEnd + + const collected = await callCollect.ended() + tap.ok( + possibleExpectedTexts.includes(collected.text!), + 'Received Correct Text' + ) + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + }) + tap.ok(call.id, 'Outbound - Call resolved') + + // Wait until the callee starts collecting speech + await waitForCollectStart + + await call.playAudio({ + url: 'https://amaswtest.s3-accelerate.amazonaws.com/newrecording2.mp3', + }) + + // Inform callee that speech has completed + waitForPlaybackEndResolve() + + // 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 client.disconnect() + + resolve(0) + } catch (error) { + console.error('voiceCollect/withContinuousFalsePartialTrue error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Collect with Continuous false & Partial true', + testHandler: handler, + executionTime: 60_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceCollect/withContinuousTruePartialFalse.test.ts b/internal/e2e-realtime-api/src/voiceCollect/withContinuousTruePartialFalse.test.ts new file mode 100644 index 000000000..4943d0cea --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollect/withContinuousTruePartialFalse.test.ts @@ -0,0 +1,170 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from '../utils' + +const possibleExpectedTexts = [ + '123456789 10:00 11:00 12:00', + 'one two three four five six seven eight nine ten', + '1112', +] + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } + + 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 + const waitForCollectStart = new Promise((resolve) => { + waitForCollectStartResolve = resolve + }) + + let waitForPlaybackEndResolve + const waitForPlaybackEnd = new Promise((resolve) => { + waitForPlaybackEndResolve = resolve + }) + + 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 callCollect = await call + .collect({ + initialTimeout: 10.0, + speech: { + endSilenceTimeout: 2.0, + speechTimeout: 20.0, + language: 'en-US', + model: 'enhanced.phone_call', + }, + partialResults: false, + continuous: true, + sendStartOfInput: true, + listen: { + onStarted: () => { + console.log('>>> collect.started') + }, + onUpdated: (_collect) => { + console.log('>>> collect.updated', _collect.text) + tap.notOk(_collect, 'Should not receive partial results') + }, + onEnded: (_collect) => { + console.log('>>> collect.ended', _collect.text) + }, + onFailed: (_collect) => { + console.log('>>> collect.failed', _collect.reason) + }, + }, + }) + .onStarted() + + // Inform caller that collect has started + waitForCollectStartResolve() + + // Wait until the caller ends sending the speech + await waitForPlaybackEnd + + // FIXME: Failing due to server side issue + // With continuous true, the collect will never stop, user needs to stop it using this function + // await callCollect.stop() + + setTimeout(() => call.hangup(), 100) + + const collected = await callCollect.ended() + tap.ok( + possibleExpectedTexts.includes(collected.text!), + 'Received Correct Text' + ) + + // await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + }) + tap.ok(call.id, 'Outbound - Call resolved') + + // Wait until the callee starts collecting speech + await waitForCollectStart + + await call.playAudio({ + url: 'https://amaswtest.s3-accelerate.amazonaws.com/newrecording2.mp3', + }) + + // Inform callee that speech has completed + waitForPlaybackEndResolve() + + // 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 client.disconnect() + + resolve(0) + } catch (error) { + console.error('voiceCollect/withContinuousTruePartialFalse error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Collect with Continuous true & Partial false', + testHandler: handler, + executionTime: 60_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceCollect/withContinuousTruePartialTrue.test.ts b/internal/e2e-realtime-api/src/voiceCollect/withContinuousTruePartialTrue.test.ts new file mode 100644 index 000000000..512d48a40 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollect/withContinuousTruePartialTrue.test.ts @@ -0,0 +1,170 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PROPS, + TestHandler, + makeSipDomainAppAddress, +} from '../utils' + +const possibleExpectedTexts = [ + '123456789 10:00 11:00 12:00', + 'one two three four five six seven eight nine ten', + '1112', + 'yes', +] + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } + + 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 + const waitForCollectStart = new Promise((resolve) => { + waitForCollectStartResolve = resolve + }) + + let waitForPlaybackEndResolve + const waitForPlaybackEnd = new Promise((resolve) => { + waitForPlaybackEndResolve = resolve + }) + + 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 callCollect = await call + .collect({ + initialTimeout: 10.0, + speech: { + endSilenceTimeout: 2.0, + speechTimeout: 20.0, + language: 'en-US', + model: 'enhanced.phone_call', + }, + partialResults: true, + continuous: true, + sendStartOfInput: true, + listen: { + onStarted: () => { + console.log('>>> collect.started') + }, + onUpdated: (_collect) => { + console.log('>>> collect.updated', _collect.text) + }, + onEnded: (_collect) => { + console.log('>>> collect.ended', _collect.text) + }, + onFailed: (_collect) => { + console.log('>>> collect.failed', _collect.reason) + }, + }, + }) + .onStarted() + + // Inform caller that collect has started + waitForCollectStartResolve() + + // Wait until the caller ends sending the speech + await waitForPlaybackEnd + + // FIXME: Failing due to server side issue + // With continuous true, the collect will never stop, user needs to stop it using this function + // await callCollect.stop() + + setTimeout(() => call.hangup(), 100) + + const collected = await callCollect.ended() + tap.ok( + possibleExpectedTexts.includes(collected.text!), + 'Received Correct Text' + ) + + // await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + }) + tap.ok(call.id, 'Outbound - Call resolved') + + // Wait until the callee starts collecting speech + await waitForCollectStart + + await call.playAudio({ + url: 'https://amaswtest.s3-accelerate.amazonaws.com/newrecording2.mp3', + }) + + // Inform callee that speech has completed + waitForPlaybackEndResolve() + + // 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 client.disconnect() + + resolve(0) + } catch (error) { + console.error('voiceCollect/withContinuousTruePartialTrue error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Collect with Continuous true & Partial true', + testHandler: handler, + executionTime: 60_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceCollect/withDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceCollect/withDialListeners.test.ts new file mode 100644 index 000000000..e3aeee215 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceCollect/withDialListeners.test.ts @@ -0,0 +1,172 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_COLLECT_PROPS, + CALL_PROPS, + makeSipDomainAppAddress, + TestHandler, +} from '../utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } + + 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: [domainApp.call_relay_context, '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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + 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 = call.collect({ + initialTimeout: 4.0, + digits: { + max: 4, + digitTimeout: 10, + terminators: '#', + }, + partialResults: true, + continuous: false, + sendStartOfInput: true, + startInputTimers: false, + }) + tap.equal( + call.id, + await 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() + + await 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, + useDomainApp: true, + }) + + 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/voiceDetect/withAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetect/withAllListeners.test.ts new file mode 100644 index 000000000..79ed1086d --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceDetect/withAllListeners.test.ts @@ -0,0 +1,202 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + CALL_PROPS, + CALL_DETECT_PROPS, + createTestRunner, + 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({ + 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: [domainApp.call_relay_context, '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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + timeout: 30, + listen: { + async onStateChanged(call) { + if (call.state === 'ended') { + await unsubVoice() + + await unsubDetect?.() + + await client.disconnect() + + resolve(0) + } + }, + onDetectStarted: (detect) => { + tap.hasProps( + detect, + CALL_DETECT_PROPS, + 'voice.dialSip: Detect started' + ) + tap.equal( + detect.callId, + call.id, + 'voice.dialSip: 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.dialSip: Detect started' + ) + tap.equal( + detect.callId, + call.id, + 'voice.dialSip: 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' + ) + }, + }, + }) + .onStarted() + 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: 30_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceDetect/withCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetect/withCallListeners.test.ts new file mode 100644 index 000000000..b27aa1565 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceDetect/withCallListeners.test.ts @@ -0,0 +1,134 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + CALL_PROPS, + CALL_DETECT_PROPS, + createTestRunner, + 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({ + 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: [domainApp.call_relay_context, '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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + timeout: 30, + listen: { + async onStateChanged(call) { + if (call.state === 'ended') { + await unsubVoice() + + await unsubCall?.() + + await 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') + + // Resolve the detect start promise + waitForDetectStartResolve!() + }, + // 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' + ) + } 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: 30_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceDetect/withDetectListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetect/withDetectListeners.test.ts new file mode 100644 index 000000000..f5ea367d9 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceDetect/withDetectListeners.test.ts @@ -0,0 +1,140 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + CALL_PROPS, + CALL_DETECT_PROPS, + createTestRunner, + 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({ + 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: [domainApp.call_relay_context, '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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + timeout: 30, + listen: { + async onStateChanged(call) { + if (call.state === 'ended') { + await unsubVoice() + + await unsubDetect?.() + + await client.disconnect() + + resolve(0) + } + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + // Start a detect + const detectDigit = 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, + await 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: 30_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceDetect/withDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceDetect/withDialListeners.test.ts new file mode 100644 index 000000000..953cf2b04 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceDetect/withDialListeners.test.ts @@ -0,0 +1,131 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + CALL_PROPS, + CALL_DETECT_PROPS, + createTestRunner, + 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({ + 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: [domainApp.call_relay_context, '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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + timeout: 30, + listen: { + async onStateChanged(call) { + if (call.state === 'ended') { + await unsubVoice() + + await 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') + + // Resolve the detect start promise + waitForDetectStartResolve!() + }, + // 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', + }) + .onEnded() + tap.equal( + call.id, + detectDigit.callId, + 'Outbound - Detect returns the same instance' + ) + } 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: 30_000, + useDomainApp: true, + }) + + 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/voicePlayback/withAllListeners.test.ts b/internal/e2e-realtime-api/src/voicePlayback/withAllListeners.test.ts new file mode 100644 index 000000000..b9159f898 --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePlayback/withAllListeners.test.ts @@ -0,0 +1,175 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PROPS, + CALL_PLAYBACK_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({ + 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: [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' + ) + } 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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + timeout: 30, + listen: { + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoiceOffice() + + await unsubVoiceHome() + + await unsubPlay?.() + + await client.disconnect() + + resolve(0) + } + }, + onPlaybackStarted: (playback) => { + tap.hasProps( + playback, + CALL_PLAYBACK_PROPS, + 'voice.dialSip: Playback started' + ) + tap.equal( + playback.state, + 'playing', + 'voice.dialSip: 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 = 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' + ) + + const playId = await play.id + tap.equal(playback.id, playId, '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: 60_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePlayback/withCallListeners.test.ts b/internal/e2e-realtime-api/src/voicePlayback/withCallListeners.test.ts new file mode 100644 index 000000000..948436e64 --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePlayback/withCallListeners.test.ts @@ -0,0 +1,112 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PLAYBACK_PROPS, + CALL_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({ + 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: [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' + ) + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + 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') + + const unsubCall = await call.listen({ + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoice() + + await unsubCall?.() + + await 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 = call.playAudio({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + }) + + if ((await play.state) === 'playing') { + 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: 60_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePlayback/withDialListeners.test.ts b/internal/e2e-realtime-api/src/voicePlayback/withDialListeners.test.ts new file mode 100644 index 000000000..d3a52b877 --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePlayback/withDialListeners.test.ts @@ -0,0 +1,109 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PLAYBACK_PROPS, + CALL_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({ + 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: [domainApp.call_relay_context, '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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + timeout: 30, + listen: { + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoice() + + await 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', + }) + .onStarted() + + 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: 60_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePlayback/withMultiplePlaybacks.test.ts b/internal/e2e-realtime-api/src/voicePlayback/withMultiplePlaybacks.test.ts new file mode 100644 index 000000000..cd8318a2b --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePlayback/withMultiplePlaybacks.test.ts @@ -0,0 +1,232 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + type TestHandler, + createTestRunner, + makeSipDomainAppAddress, + CALL_PLAYBACK_PROPS, +} from '../utils' + +const handler: TestHandler = ({ domainApp }) => { + if (!domainApp) { + throw new Error('Missing domainApp') + } + 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 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++ + }, + }) + + 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, + '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') + }, + }, + }) + .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' + ) + }, + }, + }) + .onStarted() + 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, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + 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') + + 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, + 'Outbound - Playback returns the same instance' + ) + + await unsubVoice() + + 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 client.disconnect() + + resolve(0) + } catch (error) { + console.error('VoicePlaybackMultiple error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Playback multiple E2E', + testHandler: handler, + executionTime: 60_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePlayback/withPlaybackListeners.test.ts b/internal/e2e-realtime-api/src/voicePlayback/withPlaybackListeners.test.ts new file mode 100644 index 000000000..c32e1bbc2 --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePlayback/withPlaybackListeners.test.ts @@ -0,0 +1,132 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PLAYBACK_PROPS, + CALL_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({ + 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: [domainApp.call_relay_context, '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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + timeout: 30, + listen: { + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoice() + + await unsubPlay?.() + + await 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') + }, + }, + }) + .onStarted() + + 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: 60_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts b/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts deleted file mode 100644 index f8d67c701..000000000 --- a/internal/e2e-realtime-api/src/voicePlaybackMultiple.test.ts +++ /dev/null @@ -1,198 +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 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 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) - }) - }) - // 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) - } - }) - - 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') - - 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', - }) - tap.equal( - call.id, - playAudio.callId, - '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 - - 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 - voicePlaybackMultiple error', error) - reject(4) - } - }) -} - -async function main() { - const runner = createTestRunner({ - name: 'Voice Playback multiple E2E', - testHandler: handler, - executionTime: 60_000, - useDomainApp: true, - }) - - await runner.run() -} - -main() 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/voicePrompt/withAllListeners.test.ts b/internal/e2e-realtime-api/src/voicePrompt/withAllListeners.test.ts new file mode 100644 index 000000000..a79a7762c --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePrompt/withAllListeners.test.ts @@ -0,0 +1,246 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PROPS, + CALL_PROMPT_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({ + 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 waitForPromptStartResolve: () => void + const waitForPromptStart = new Promise((resolve) => { + waitForPromptStartResolve = resolve + }) + + let waitForPromptEndResolve: () => void + const waitForPromptEnd = new Promise((resolve) => { + waitForPromptEndResolve = resolve + }) + + const unsubVoiceOffice = 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' + ) + + // Wait until the caller starts the prompt + 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' + ) + + // Wait until the caller ends the prompt + await waitForPromptEnd + + 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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + 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' + ) + }, + }, + }) + .onStarted() + + 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() + + // Resolve the prompt start to inform callee + waitForPromptStartResolve!() + + 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 the prompt end to inform callee + waitForPromptEndResolve!() + + // 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() + + await 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, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePrompt/withCallListeners.test.ts b/internal/e2e-realtime-api/src/voicePrompt/withCallListeners.test.ts new file mode 100644 index 000000000..8f4571922 --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePrompt/withCallListeners.test.ts @@ -0,0 +1,171 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PLAYBACK_PROPS, + CALL_PROPS, + CALL_PROMPT_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({ + 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 waitForPromptStartResolve: () => void + const waitForPromptStart = new Promise((resolve) => { + waitForPromptStartResolve = resolve + }) + + let waitForPromptEndResolve: () => void + const waitForPromptEnd = new Promise((resolve) => { + waitForPromptEndResolve = resolve + }) + + const unsubVoice = await client.voice.listen({ + topics: [domainApp.call_relay_context, '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 prompt + 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' + ) + + // Wait until the caller ends the prompt + await waitForPromptEnd + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + 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') + + 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: '#', + }, + }) + .onStarted() + tap.equal( + call.id, + prompt.callId, + 'Outbound - Prompt returns the same call instance' + ) + + // Resolve the prompt start to inform callee + waitForPromptStartResolve!() + + 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 the prompt end to inform callee + waitForPromptEndResolve!() + + // 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() + + await 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, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePrompt/withDialListeners.test.ts b/internal/e2e-realtime-api/src/voicePrompt/withDialListeners.test.ts new file mode 100644 index 000000000..200c77459 --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePrompt/withDialListeners.test.ts @@ -0,0 +1,174 @@ +import tap from 'tap' +import { SignalWire, Voice } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PROMPT_PROPS, + CALL_PROPS, + CALL_PLAYBACK_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({ + 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 waitForPromptStartResolve: () => void + const waitForPromptStart = new Promise((resolve) => { + waitForPromptStartResolve = resolve + }) + + let waitForPromptEndResolve: () => void + const waitForPromptEnd = new Promise((resolve) => { + waitForPromptEndResolve = resolve + }) + + const unsubVoice = await client.voice.listen({ + topics: [domainApp.call_relay_context, '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 prompt + 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' + ) + + // Wait until the caller ends the prompt + await waitForPromptEnd + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + 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) => { + 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: '#', + }, + }) + .onStarted() + + tap.equal( + call.id, + prompt.callId, + 'Outbound - Prompt returns the same call instance' + ) + + // Resolve the prompt start to inform callee + waitForPromptStartResolve!() + + console.log('Waiting for the digits from the inbound call') + + // Compare what caller has received + const recDigits = await prompt.ended() + tap.ok(recDigits.digits, 'Outbound - Digits received ' + recDigits.digits) + tap.equal(recDigits.digits, '1234', 'Outbound - Received the same digit') + + // Resolve the prompt end to inform callee + waitForPromptEndResolve!() + + // 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 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, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voicePrompt/withPromptListeners.test.ts b/internal/e2e-realtime-api/src/voicePrompt/withPromptListeners.test.ts new file mode 100644 index 000000000..6f58686f6 --- /dev/null +++ b/internal/e2e-realtime-api/src/voicePrompt/withPromptListeners.test.ts @@ -0,0 +1,202 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_PLAYBACK_PROPS, + CALL_PROPS, + CALL_PROMPT_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({ + 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 waitForPromptStartResolve: () => void + const waitForPromptStart = new Promise((resolve) => { + waitForPromptStartResolve = resolve + }) + + let waitForPromptEndResolve: () => void + const waitForPromptEnd = new Promise((resolve) => { + waitForPromptEndResolve = resolve + }) + + const unsubVoice = await client.voice.listen({ + topics: [domainApp.call_relay_context, '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 prompt + 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' + ) + + // Wait until the caller ends the prompt + await waitForPromptEnd + + await call.hangup() + } catch (error) { + console.error('Error answering inbound call', error) + } + }, + }) + + 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') + + const prompt = 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: async (_prompt) => { + tap.hasProps( + _prompt, + CALL_PROMPT_PROPS, + 'call.promptTTS: Prompt ended' + ) + tap.equal( + _prompt.id, + await prompt.id, + 'call.promptTTS: Prompt correct id' + ) + }, + }, + }) + tap.equal( + call.id, + await 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: async (_prompt) => { + tap.hasProps( + _prompt, + CALL_PROMPT_PROPS, + 'prompt.listen: Prompt ended' + ) + tap.equal( + _prompt.id, + await prompt.id, + 'prompt.listen: Prompt correct id' + ) + }, + }) + + // Resolve the prompt start to inform callee + waitForPromptStartResolve!() + + 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 the prompt end to inform callee + waitForPromptEndResolve!() + + // 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() + + await 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, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceRecord/withAllListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecord/withAllListeners.test.ts new file mode 100644 index 000000000..2a0db033c --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceRecord/withAllListeners.test.ts @@ -0,0 +1,176 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +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({ + 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: [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' + ) + } 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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + timeout: 30, + listen: { + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoiceOffice() + + await unsubVoiceHome() + + await unsubRecord?.() + + await 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 = 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' + ) + + const recordId = await record.id + tap.equal( + recording.id, + recordId, + '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, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceRecord/withCallListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecord/withCallListeners.test.ts new file mode 100644 index 000000000..e5d959c7b --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceRecord/withCallListeners.test.ts @@ -0,0 +1,107 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_RECORD_PROPS, + CALL_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({ + 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: [domainApp.call_relay_context, '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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + 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?.() + + await 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 = call.recordAudio() + + if ((await record.state) === 'recording') { + 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, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceRecord/withDialListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecord/withDialListeners.test.ts new file mode 100644 index 000000000..5250b8337 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceRecord/withDialListeners.test.ts @@ -0,0 +1,103 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_RECORD_PROPS, + CALL_PROPS, + sleep, + 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({ + 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: [domainApp.call_relay_context, '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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + timeout: 30, + listen: { + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoice() + + await 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().onStarted() + + 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, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceRecord/withMultipleRecords.test.ts b/internal/e2e-realtime-api/src/voiceRecord/withMultipleRecords.test.ts new file mode 100644 index 000000000..8b565b667 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceRecord/withMultipleRecords.test.ts @@ -0,0 +1,153 @@ +import tap from 'tap' +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) => { + 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: [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' + ) + }, + }, + }) + .onStarted() + 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) + } + }, + }) + + const call = await client.voice.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + 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') + + const record = await call + .recordAudio({ + terminators: '*', + }) + .onStarted() + + tap.equal( + call.id, + record.callId, + 'Outbound - Recording returns the same call instance' + ) + + await call.sendDigits('*') + + await record.ended() + tap.match( + record.state, + /finished|no_input/, + 'Outbound - Recording state is "finished"' + ) + + 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('VoiceRecordMultiple error', error) + reject(4) + } + }) +} + +async function main() { + const runner = createTestRunner({ + name: 'Voice Recording multiple E2E', + testHandler: handler, + executionTime: 60_000, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceRecord/withRecordListeners.test.ts b/internal/e2e-realtime-api/src/voiceRecord/withRecordListeners.test.ts new file mode 100644 index 000000000..b037afbf9 --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceRecord/withRecordListeners.test.ts @@ -0,0 +1,127 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + createTestRunner, + CALL_RECORD_PROPS, + CALL_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({ + 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: [domainApp.call_relay_context, '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.dialSip({ + to: makeSipDomainAppAddress({ + name: 'to', + domain: domainApp.domain, + }), + from: makeSipDomainAppAddress({ + name: 'from', + domain: domainApp.domain, + }), + timeout: 30, + listen: { + onStateChanged: async (call) => { + if (call.state === 'ended') { + await unsubVoice() + + await unsubRecord?.() + + await client.disconnect() + + resolve(0) + } + }, + }, + }) + tap.ok(call.id, 'Outbound - Call resolved') + + const record = 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') + + const recordId = await record.id + tap.equal(recording.id, recordId, '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') + + const recordId = await record.id + tap.equal(recording.id, recordId, '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, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/e2e-realtime-api/src/voiceRecordMultiple.test.ts b/internal/e2e-realtime-api/src/voiceRecordMultiple.test.ts deleted file mode 100644 index 8101d62dd..000000000 --- a/internal/e2e-realtime-api/src/voiceRecordMultiple.test.ts +++ /dev/null @@ -1,160 +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 - }) - 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 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) - } - }) - - 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 - - const secondRecording = 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"' - ) - - await call.sendDigits('*') - - await secondRecording.ended() - tap.match( - secondRecording.state, - /finished|no_input/, - 'Outbound - secondRecording 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)) - ) - 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 - voiceRecordMultiple error', error) - reject(4) - } - }) -} - -async function main() { - const runner = createTestRunner({ - name: 'Voice Recording multiple E2E', - testHandler: handler, - executionTime: 60_000, - useDomainApp: true, - }) - - await runner.run() -} - -main() 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/voiceSpeechCollect.test.ts b/internal/e2e-realtime-api/src/voiceSpeechCollect.test.ts deleted file mode 100644 index be22bbcc9..000000000 --- a/internal/e2e-realtime-api/src/voiceSpeechCollect.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import tap from 'tap' -import { Voice } from '@signalwire/realtime-api' -import { - type TestHandler, - createTestRunner, - makeSipDomainAppAddress, -} from './utils' - -const getHandlers= (): TestHandler[] => { - const cases = { - 'continuous: true and partialResults: true': { - continuous: true, - partialResults: true, - hangupAfter: 16_000, - possibleExpectedTexts: [ - '123456789 10:00 11:00 12:00', - 'one two three four five six seven eight nine ten', - '1112', - ], - }, - 'continuous: true and partialResults: false': { - continuous: true, - partialResults: false, - hangupAfter: 16_000, - possibleExpectedTexts: [ - '123456789 10:00 11:00 12:00', - 'one two three four five six seven eight nine ten', - '1112', - ], - }, - 'continuous: false and partialResults: true': { - continuous: false, - partialResults: true, - hangupAfter: 16_000, - possibleExpectedTexts: [ - '123456789 10:00 11:00 12:00', - 'one two three four five six seven eight nine ten', - '1112', - ], - }, - 'continuous: false and partialResults: false': { - continuous: false, - partialResults: false, - hangupAfter: 16_000, - possibleExpectedTexts: [ - '123456789 10:00 11:00 12:00', - 'one two three four five six seven eight nine ten', - '1112', - ], - }, - 'continuous: false and partialResults: true (hangup before palying the audio file finish)': - { - continuous: false, - partialResults: true, - hangupAfter: 5_000, - possibleExpectedTexts: [ - '123', - '1234' - ], - }, - } - - const handlers: TestHandler[] = [] - for (let [testCase, options] of Object.entries(cases)) { - handlers.push(({ domainApp }) => { - return new Promise(async (resolve, reject) => { - if (!domainApp) { - throw new Error('Missing domainApp') - } - console.log(`Running collect test with ${testCase}`) - console.log(domainApp.call_relay_context) - let missingTheInstanceErrorWasThrown = false; - process.stderr.on('data', (data) => { - const err = data.toString(); - missingTheInstanceErrorWasThrown = err.includes( - 'Voice calling event error Error: Missing the instance' - ) - }) - 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, 'Inbound 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.text) - }) - call.on('collect.ended', (collect) => { - console.log('>>> collect.ended', collect.text) - }) - call.on('collect.failed', (collect) => { - console.log('>>> collect.failed', collect.reason) - }) - - const callCollect = await call.collect({ - initialTimeout: 10.0, - speech: { - endSilenceTimeout: 2.0, - speechTimeout: 20.0, - language:'en-US', - model: 'enhanced.phone_call', - }, - partialResults: options.partialResults, - continuous: options.continuous, - sendStartOfInput: true - }) - - // Resolve the answer promise to inform the caller - waitForCollectStartResolve() - - // Wait until the caller ends entring the digits - await waitForCollectEnd - setTimeout(() => call.hangup(), 100) - const collected = await callCollect.ended() // block the script until the collect ended - tap.ok( - // @ts-ignore - options.possibleExpectedTexts.includes(collected.text), - 'Received Correct Text' - ) - } 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, - }), - }) - tap.ok(call.id, 'Call resolved') - // Wait until the callee answers the call and start collecting digits - await waitForCollectStart - - call.playAudio({ - url: 'https://amaswtest.s3-accelerate.amazonaws.com/newrecording2.mp3', - }) - await new Promise((resolve) => setTimeout(resolve, options.hangupAfter)) - 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.` - ) - } - }) - tap.ok(!missingTheInstanceErrorWasThrown, 'No Missing the Instance error was thrown') - client.disconnect() - resolve(0) - } catch (error) { - console.error('Outbound - voiceDetect error', error) - reject(4) - } - }) - }) - } - return handlers; -} - -async function main() { - const handlers = getHandlers() - for (let i = 0; i < handlers.length; i++) { - - const runner = createTestRunner({ - name: 'Voice Speech Collect E2E', - testHandler: handlers[i], - executionTime: 60_000, - useDomainApp: true, - // only exit process on success for last test case - exitOnSuccess: (i + 1 == handlers.length) - }) - - await runner.run() - // delay 20 sec between each test case so file server hosting the mp3 file won't trigger rate limit - await new Promise(resolve => setTimeout(resolve, 20_000)) - } -} - -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..f3a26929e --- /dev/null +++ b/internal/e2e-realtime-api/src/voiceTapAllListeners.test.ts @@ -0,0 +1,135 @@ +import tap from 'tap' +import { SignalWire } from '@signalwire/realtime-api' +import { + CALL_PROPS, + CALL_TAP_PROPS, + TestHandler, + createTestRunner, + 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({ + 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: [domainApp.call_relay_context, '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.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.dialSip: Tap started') + tap.equal( + callTap.callId, + call.id, + 'voice.dialSip: 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' + ) + }, + }, + }) + .onStarted() + + 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(4) + } catch (error) { + tap.ok(error, 'Outbound - Tap error') + + await 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, + useDomainApp: true, + }) + + await runner.run() +} + +main() diff --git a/internal/playground-realtime-api/src/chat/index.ts b/internal/playground-realtime-api/src/chat/index.ts index a824dde0f..4968c4a86 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..') + 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 9f2024483..aa7dcc8cd 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..') + 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 62a014fef..7c8455332 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() + await client.disconnect() }, 2000) -}, 2000) +})() 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..e9fe546ff 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, @@ -23,7 +22,7 @@ async function run() { digitTimeout: 5, }, }) - const { type, digits } = await prompt.ended() + const { type, digits } = prompt return [type, digits] } @@ -44,25 +43,25 @@ 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() } } 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..14ef2cab9 100644 --- a/internal/playground-realtime-api/src/voice-inbound/index.ts +++ b/internal/playground-realtime-api/src/voice-inbound/index.ts @@ -1,44 +1,45 @@ -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', + }) + 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 } = prompt + 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..0c25d6bcd 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,163 +11,161 @@ 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) + } + }, }) - 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, - 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 { - 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('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() + // 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) - 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) + if (RUN_DETECTOR) { + // See the `call.received` handler + const detectResult = await call.detectDigit() + console.log('Detect Result', detectResult.type) - await pb.ended() - } catch (error) { - console.error('Connect Error', error) - } + await sleep() + } - call.on('tap.started', (p) => { - console.log('>> tap.started', p.id, p.state) + try { + 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('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 }).onEnded() - call.on('tap.ended', (p) => { - console.log('>> tap.ended', p.id, p.state) - }) + console.log('pb.ended') + } catch (error) { + console.error('Connect Error', error) + } - const tap = await call.tapAudio({ - direction: 'both', - device: { - type: 'ws', - uri: 'wss://example.domain.com/endpoint', - }, - }) + 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) + }, + }, + }) + .onStarted() 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({ + 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', @@ -178,98 +176,127 @@ async function run() { 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) + }, + onEnded(p) { + console.log( + '>> prompt.ended', + p.id, + p.type, + 'Digits: ', + p.digits, + 'Terminator', + p.terminator + ) + }, + }, }) - - /** 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) - - 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) + .onStarted() + + /** 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) + + 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 }) - .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) - - 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 + const playlist = new Voice.Playlist({ volume: 2 }) + .add( + Voice.Playlist.Audio({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + }) ) - - await call.hangup() - } catch (error) { - console.log('Error:', error) - } - - client.disconnect() + .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) + }, + }, + }) + .onStarted() + + // 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() + + await client.disconnect() } catch (error) { console.log('', error) } 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/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/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/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/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/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/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/BaseComponent.ts b/packages/core/src/BaseComponent.ts index 919187267..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() } @@ -301,7 +301,7 @@ export class BaseComponent< } /** @internal */ - protected runWorker( + public runWorker( name: string, def: SDKWorkerDefinition ) { 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/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/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/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/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/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/types/voiceCall.ts b/packages/core/src/types/voiceCall.ts index bf87b16b4..1d43d71c9 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'] @@ -1173,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 @@ -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/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/BaseNamespace.test.ts b/packages/realtime-api/src/BaseNamespace.test.ts new file mode 100644 index 000000000..884702ead --- /dev/null +++ b/packages/realtime-api/src/BaseNamespace.test.ts @@ -0,0 +1,225 @@ +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(swClientMock) + baseNamespace._eventMap = eventMap + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + 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) + }) + }) +}) diff --git a/packages/realtime-api/src/BaseNamespace.ts b/packages/realtime-api/src/BaseNamespace.ts new file mode 100644 index 000000000..a9e14cf60 --- /dev/null +++ b/packages/realtime-api/src/BaseNamespace.ts @@ -0,0 +1,151 @@ +import { EventEmitter, ExecuteParams, uuid } from '@signalwire/core' +import { prefixEvent } from './utils/internals' +import { ListenSubscriber } from './ListenSubscriber' +import { SWClient } from './SWClient' + +export interface ListenOptions { + topics?: string[] + channels?: string[] +} + +export type Listeners = Omit + +export type ListenersKeys = keyof T + +export class BaseNamespace< + T extends ListenOptions, + EventTypes extends EventEmitter.ValidEventTypes +> extends ListenSubscriber, EventTypes> { + constructor(options: SWClient) { + super({ swClient: options }) + } + + protected addTopics(topics: string[]) { + const executeParams: ExecuteParams = { + method: 'signalwire.receive', + params: { + contexts: topics, + }, + } + return this._client.execute(executeParams) + } + + protected 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 (!Array.isArray(topics) || 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._attachListenersWithTopics(topics!, listeners as Listeners) + await this.addTopics(topics!) + + const unsub = () => { + return new Promise(async (resolve, reject) => { + try { + // Detach listeners + this._detachListenersWithTopics(topics!, listeners as Listeners) + + // Remove topics from the listener map + this.removeFromListenerMap(_uuid) + + // Remove the topics + const topicsToRemove = topics!.filter( + (topic) => !this.hasOtherListeners(_uuid, topic) + ) + if (topicsToRemove.length > 0) { + await this.removeTopics(topicsToRemove) + } + + resolve() + } catch (error) { + reject(error) + } + }) + } + + // Add topics to the listener map + this.addToListenerMap(_uuid, { + topics: new Set([...topics!]), + listeners: listeners as Listeners, + unsub, + }) + + return unsub + } + + protected _attachListenersWithTopics( + topics: string[], + listeners: Listeners + ) { + const listenerKeys = Object.keys(listeners) + topics.forEach((topic) => { + listenerKeys.forEach((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]) + } + }) + }) + } + + protected _detachListenersWithTopics( + topics: string[], + listeners: Listeners + ) { + const listenerKeys = Object.keys(listeners) + topics.forEach((topic) => { + listenerKeys.forEach((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]) + } + }) + }) + } + + protected hasOtherListeners(uuid: string, topic: string) { + for (const [key, listener] of this._listenerMap) { + if (key !== uuid && listener.topics?.has(topic)) { + return true + } + } + return false + } + + protected async unsubscribeAll() { + await Promise.all( + [...this._listenerMap.values()].map(({ unsub }) => unsub()) + ) + this._listenerMap.clear() + } +} 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 new file mode 100644 index 000000000..e48ef5d9f --- /dev/null +++ b/packages/realtime-api/src/ListenSubscriber.test.ts @@ -0,0 +1,136 @@ +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.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') + + 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..7ac9826e6 --- /dev/null +++ b/packages/realtime-api/src/ListenSubscriber.ts @@ -0,0 +1,148 @@ +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 + } + + protected eventNames() { + return this.emitter.eventNames() + } + + /** @internal */ + 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 { + 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) { + 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 + } + + protected _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]}`) + } + }) + } + + protected _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.test.ts b/packages/realtime-api/src/SWClient.test.ts new file mode 100644 index 000000000..89973c12b --- /dev/null +++ b/packages/realtime-api/src/SWClient.test.ts @@ -0,0 +1,67 @@ +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(), + sessionEmitter: { on: 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 new file mode 100644 index 000000000..b0faae615 --- /dev/null +++ b/packages/realtime-api/src/SWClient.ts @@ -0,0 +1,93 @@ +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' +import { Video } from './video/Video' + +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 + private _messaging: Messaging + private _pubSub: PubSub + private _chat: Chat + private _voice: Voice + private _video: Video + + public userOptions: SWClientOptions + public client: Client + + constructor(options: SWClientOptions) { + this.userOptions = options + this.client = createClient(options) + } + + async connect() { + await clientConnect(this.client) + } + + disconnect() { + return new Promise((resolve) => { + const { sessionEmitter } = this.client + sessionEmitter.on('session.disconnected', () => { + resolve() + }) + + this.client.disconnect() + }) + } + + get task() { + if (!this._task) { + this._task = new Task(this) + } + 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) + } + return this._pubSub + } + + get chat() { + if (!this._chat) { + this._chat = new Chat(this) + } + return this._chat + } + + get voice() { + if (!this._voice) { + this._voice = new Voice(this) + } + return this._voice + } + + get video() { + if (!this._video) { + this._video = new Video(this) + } + return this._video + } +} diff --git a/packages/realtime-api/src/SignalWire.ts b/packages/realtime-api/src/SignalWire.ts new file mode 100644 index 000000000..2c543c163 --- /dev/null +++ b/packages/realtime-api/src/SignalWire.ts @@ -0,0 +1,16 @@ +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) + } + }) +} + +export type { SWClient } from './SWClient' 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..59873365d --- /dev/null +++ b/packages/realtime-api/src/chat/BaseChat.test.ts @@ -0,0 +1,116 @@ +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, + '_attachListenersWithTopics' + ) + + 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, + '_detachListenersWithTopics' + ) + + 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..3310ecb58 --- /dev/null +++ b/packages/realtime-api/src/chat/BaseChat.ts @@ -0,0 +1,140 @@ +import { + EventEmitter, + ExecuteParams, + PubSubPublishParams, + uuid, +} from '@signalwire/core' +import { BaseNamespace, Listeners } from '../BaseNamespace' + +export interface BaseChatListenOptions { + channels: string[] +} + +export class BaseChat< + T extends BaseChatListenOptions, + EventTypes extends EventEmitter.ValidEventTypes +> extends BaseNamespace { + 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._attachListenersWithTopics(channels, listeners as Listeners) + + const listenerKeys = Object.keys(listeners) + const events: string[] = [] + listenerKeys.forEach((key) => { + const _key = key as keyof Listeners + if (this._eventMap[_key]) events.push(this._eventMap[_key] as string) + }) + 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._detachListenersWithTopics(channels, listeners as Listeners) + + // Remove channels from the listener map + this.removeFromListenerMap(_uuid) + + resolve() + } catch (error) { + reject(error) + } + }) + } + + // Add channels to the listener map + this.addToListenerMap(_uuid, { + topics: new Set([...channels]), + listeners: listeners as 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..0671c6dce 100644 --- a/packages/realtime-api/src/chat/Chat.ts +++ b/packages/realtime-api/src/chat/Chat.ts @@ -1,3 +1,46 @@ +import { + ChatMember, + ChatMessage, + ChatEvents, + Chat as ChatCore, +} from '@signalwire/core' +import { BaseChat } from './BaseChat' +import { chatWorker } from './workers' +import { SWClient } from '../SWClient' +import { RealTimeChatEvents } from '../types/chat' + +interface ChatListenOptions { + channels: string[] + 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 +) { + 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: { + chat: this, + }, + }) + } +} + export { ChatMember, ChatMessage } from '@signalwire/core' export type { ChatAction, @@ -34,5 +77,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..65497bbad --- /dev/null +++ b/packages/realtime-api/src/chat/workers/chatWorker.ts @@ -0,0 +1,74 @@ +import { SagaIterator } from '@redux-saga/core' +import { + sagaEffects, + SDKWorker, + getLogger, + ChatAction, + toExternalJSON, + ChatMessage, + ChatMember, + SDKActions, +} from '@signalwire/core' +import { prefixEvent } from '../../utils/internals' +import type { Client } from '../../client/Client' +import { Chat } from '../Chat' + +interface ChatWorkerInitialState { + chat: Chat +} + +export const chatWorker: SDKWorker = function* (options): SagaIterator { + getLogger().trace('chatWorker started') + const { + channels: { swEventChannel }, + initialState, + } = options + + const { chat } = initialState as ChatWorkerInitialState + + 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) + + // @ts-expect-error + chat.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) + + // @ts-expect-error + chat.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/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..792ee0d7c --- /dev/null +++ b/packages/realtime-api/src/client/createClient.ts @@ -0,0 +1,18 @@ +import { connect, ClientEvents, SDKStore } 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' + store?: SDKStore +}) => { + const { emitter, store } = setupInternals(userOptions) + const client = connect({ + store: userOptions.store ?? 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/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/decoratePromise.ts b/packages/realtime-api/src/decoratePromise.ts new file mode 100644 index 000000000..d017e55d7 --- /dev/null +++ b/packages/realtime-api/src/decoratePromise.ts @@ -0,0 +1,96 @@ +import { Call } from './voice/Call' +import { RoomSession } from './video/RoomSession' + +export interface DecoratePromiseOptions { + promise: Promise + namespace: + | 'playback' + | 'recording' + | 'prompt' + | 'tap' + | 'detect' + | 'collect' + | 'stream' + methods: string[] + getters: string[] +} + +export function decoratePromise( + this: Call | RoomSession, + 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: function () { + /** + * Since onStarted is a property of the outer promise. + * In case the inner promise rejects, the outer promise goes unhandled. + * Due to this, we need to catch both of the promises. + */ + return new Promise((resolve, reject) => { + promise.catch(reject) + innerPromise.then(resolve).catch(reject) + }) + }, + 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 +} diff --git a/packages/realtime-api/src/index.ts b/packages/realtime-api/src/index.ts index 1657810a6..ee72b4e7a 100644 --- a/packages/realtime-api/src/index.ts +++ b/packages/realtime-api/src/index.ts @@ -1,201 +1,53 @@ -/** - * 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' -export * from './createClient' +export * as Messaging from './messaging/Messaging' -/** - * 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' +export * as Task from './task/Task' + +export * as Voice from './voice/Voice' + +export * as Video from './video/Video' /** - * 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. + * 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 listens for incoming tasks. + * The following example creates a single client and uses Task and Voice APIs. * * ```javascript - * const client = new Task.Client({ + * const client = await SignalWire({ * project: "", * token: "", - * contexts: ['office'] * }) * - * client.on('task.received', (payload) => { - * console.log('Task Received', payload) - * // Do something with the payload... + * await client.task.listen({ + * topics: ['office'], + * onTaskReceived: (payload) => { + * console.log('message.received', 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] }, + * await client.task.send({ + * topic: 'office', + * message: '+1yyy', * }) - * ``` - */ -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 - * {@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.voice.listen({ + * topics: ['office'], + * onCallReceived: (call) => { + * console.log('call.received', call)} * }) * - * await client.send({ + * await client.voice.dialPhone({ * from: '+1xxx', * to: '+1yyy', - * body: 'Hello World!' - * }) - * ``` - */ -export * as Messaging from './messaging/Messaging' - -/** - * 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. - * - * @example - * - * The following example answers any call in the "office" context, - * and immediately plays some speech. - * - * ```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') - * - * await call.playTTS({ text: "Hello! This is a test call."}) - * } catch (error) { - * console.error('Error answering inbound call', error) - * } * }) * ``` */ -export * as Voice from './voice/Voice' +export * from './SignalWire' 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.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..1b690ae46 100644 --- a/packages/realtime-api/src/pubSub/PubSub.ts +++ b/packages/realtime-api/src/pubSub/PubSub.ts @@ -1,4 +1,44 @@ -export { PubSubMessage } from '@signalwire/core' -export { Client } from './PubSubClient' +import { + PubSubMessageEventName, + PubSubNamespace, + PubSubMessage, +} from '@signalwire/core' +import { SWClient } from '../SWClient' +import { pubSubWorker } from './workers' +import { BaseChat } from '../chat/BaseChat' +import { RealTimePubSubEvents } from '../types/pubSub' + +interface PubSubListenOptions { + channels: string[] + onMessageReceived?: (message: PubSubMessage) => unknown +} + +type PubSubListenersKeys = keyof Omit< + PubSubListenOptions, + 'channels' | 'topics' +> + +export class PubSub extends BaseChat< + PubSubListenOptions, + RealTimePubSubEvents +> { + protected _eventMap: Record< + PubSubListenersKeys, + `${PubSubNamespace}.${PubSubMessageEventName}` + > = { + onMessageReceived: 'chat.message', + } + + constructor(options: SWClient) { + super(options) + + this._client.runWorker('pubSubWorker', { + worker: pubSubWorker, + initialState: { + pubSub: this, + }, + }) + } +} + 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..dc6aa4914 --- /dev/null +++ b/packages/realtime-api/src/pubSub/workers/pubSubWorker.ts @@ -0,0 +1,74 @@ +import { SagaIterator } from '@redux-saga/core' +import { + sagaEffects, + PubSubEventAction, + SDKWorker, + getLogger, + PubSubMessage, + toExternalJSON, +} from '@signalwire/core' +import { prefixEvent } from '../../utils/internals' +import type { Client } from '../../client/Client' +import { PubSub } from '../PubSub' + +interface PubSubWorkerInitialState { + pubSub: PubSub +} + +export const pubSubWorker: SDKWorker = function* ( + options +): SagaIterator { + getLogger().trace('pubSubWorker started') + const { + channels: { swEventChannel }, + initialState, + } = options + + const { pubSub } = initialState as PubSubWorkerInitialState + + 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) + + // @ts-expect-error + pubSub.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 3970c3b2c..ad61845e3 100644 --- a/packages/realtime-api/src/task/Task.ts +++ b/packages/realtime-api/src/task/Task.ts @@ -1,55 +1,90 @@ +import { request } from 'node:https' import { - DisconnectableClientContract, - BaseComponentOptions, - BaseComponent, - ClientContextContract, + TaskInboundEvent, + TaskReceivedEventName, + getLogger, } 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 { 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 +export const PATH = '/api/relay/rest/tasks' +const HOST = 'relay.signalwire.com' + +interface TaskListenOptions { + topics: string[] + onTaskReceived?: (payload: TaskInboundEvent['message']) => unknown } -/** @internal */ -class TaskAPI extends BaseComponent { - constructor(options: BaseComponentOptions) { +type TaskListenersKeys = keyof Omit + +type TaskEvents = Record< + TaskReceivedEventName, + (task: TaskInboundEvent['message']) => void +> + +export class Task extends BaseNamespace { + protected _eventMap: Record = { + onTaskReceived: 'task.received', + } + + constructor(options: SWClient) { super(options) - this.runWorker('taskWorker', { + this._client.runWorker('taskWorker', { worker: taskWorker, + initialState: { + task: this, + }, }) } -} -/** @internal */ -export const createTaskObject = (params: BaseComponentOptions): Task => { - const task = connect({ - store: params.store, - Component: TaskAPI, - })(params) + 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')}` - return task + 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, + }, + } + + getLogger().debug('Task send -', data) + 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..1cf7bfe21 --- /dev/null +++ b/packages/realtime-api/src/task/workers/taskWorker.ts @@ -0,0 +1,43 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, + SDKActions, + TaskAction, +} from '@signalwire/core' +import { prefixEvent } from '../../utils/internals' +import type { Client } from '../../client/Client' +import { Task } from '../Task' + +interface TaskWorkerInitialState { + task: Task +} + +export const taskWorker: SDKWorker = function* (options): SagaIterator { + getLogger().trace('taskWorker started') + const { + channels: { swEventChannel }, + initialState, + } = options + + const { task } = initialState as TaskWorkerInitialState + + function* worker(action: TaskAction) { + const { context } = action.payload + + // @ts-expect-error + task.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/types/chat.ts b/packages/realtime-api/src/types/chat.ts index 06302d485..66f6f1ef8 100644 --- a/packages/realtime-api/src/types/chat.ts +++ b/packages/realtime-api/src/types/chat.ts @@ -3,10 +3,18 @@ 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 003d4d549..e892d9466 100644 --- a/packages/realtime-api/src/types/pubSub.ts +++ b/packages/realtime-api/src/types/pubSub.ts @@ -1,6 +1,14 @@ -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 > + +export type RealTimePubSubEvents = { + [k in keyof RealTimePubSubApiEventsHandlerMapping]: RealTimePubSubApiEventsHandlerMapping[k] +} 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 ca2b34d6b..3c3c7f384 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,65 @@ 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, +/** + * 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 +} + +export type VoiceDialMethodParams = VoiceDialerParams & VoiceMethodsListeners + +export type VoiceDialPhonelMethodParams = VoiceCallDialPhoneMethodParams & + VoiceMethodsListeners + +export type VoiceDialSipMethodParams = VoiceCallDialSipMethodParams & + VoiceMethodsListeners + +/** + * Call API + */ +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 +127,6 @@ export type RealTimeCallApiEventsHandlerMapping = Record< CallPromptStarted | CallPromptUpdated | CallPromptEnded | CallPromptFailed, (prompt: CallPrompt) => void > & - Record void> & Record< | CallCollectStarted | CallCollectStartOfInput @@ -59,8 +134,265 @@ 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 + onUpdated?: (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/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}` +} 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