diff --git a/.gitignore b/.gitignore index 0f3760dd..2f85a3f7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ extension/token.js node_modules docs lib/twilio/constants.js +nodemon.json diff --git a/lib/twilio/call.ts b/lib/twilio/call.ts index b72dc8f2..a03e9ff2 100644 --- a/lib/twilio/call.ts +++ b/lib/twilio/call.ts @@ -215,6 +215,12 @@ class Call extends EventEmitter { */ private _mediaStatus: Call.State = Call.State.Pending; + /** + * A map of messages sent via sendMessage API using voiceEventSid as the key. + * The message will be deleted once an 'ack' or an error is received from the server. + */ + private _messages: Map = new Map(); + /** * A batch of metrics samples to send to Insights. Gets cleared after * each send and appended to on each new sample. @@ -524,11 +530,13 @@ class Call extends EventEmitter { }; this._pstream = config.pstream; + this._pstream.on('ack', this._onAck); this._pstream.on('cancel', this._onCancel); + this._pstream.on('error', this._onSignalingError); this._pstream.on('ringing', this._onRinging); this._pstream.on('transportClose', this._onTransportClose); this._pstream.on('connected', this._onConnected); - this._pstream.on('message', this._onMessage); + this._pstream.on('message', this._onMessageReceived); this.on('error', error => { this._publisher.error('connection', 'error', { @@ -576,14 +584,6 @@ class Call extends EventEmitter { const rtcConfiguration = options.rtcConfiguration || this._options.rtcConfiguration; const rtcConstraints = options.rtcConstraints || this._options.rtcConstraints || { }; const audioConstraints = rtcConstraints.audio || { audio: true }; - const messagesToRegisterFor = options.messagesToRegisterFor || []; - - if ( - !Array.isArray(messagesToRegisterFor) || - messagesToRegisterFor.some((event) => typeof event !== 'string') - ) { - throw new Error('`messagesToRegisterFor` option must be an array of strings.'); - } this._status = Call.State.Connecting; @@ -638,7 +638,7 @@ class Call extends EventEmitter { `${encodeURIComponent(pair[0])}=${encodeURIComponent(pair[1])}`).join('&'); this._pstream.on('answer', this._onAnswer.bind(this)); this._mediaHandler.makeOutgoingCall(this._pstream.token, params, this.outboundConnectionId, - rtcConstraints, rtcConfiguration, messagesToRegisterFor, onAnswer); + rtcConstraints, rtcConfiguration, onAnswer); } }; @@ -863,19 +863,15 @@ class Call extends EventEmitter { } /** - * Send a message to Twilio. - * - * @example - * ```ts - * call.sendMessage(Call.MessageType.UserDefinedMessage, { - * ahoy: 'world!, - * }); - * ``` - * - * @param messageType - The type of the message to send to Twilio. - * @param content - The content of the message to send to Twilio. + * TODO */ - sendMessage(messageType: Call.MessageType, content: any) { + sendMessage(message: Call.Message): string { + const { content, contentType, messageType } = message; + + if (typeof content === 'undefined' || content === null) { + throw new InvalidArgumentError('`content` is empty'); + } + if (typeof messageType !== 'string') { throw new InvalidArgumentError( '`messageType` must be an enumeration value of `Call.MessageType` or ' + @@ -903,7 +899,9 @@ class Call extends EventEmitter { } const voiceEventSid = this._voiceEventSidGenerator(); - this._pstream.sendMessage(callSid, voiceEventSid, messageType, content); + this._messages.set(voiceEventSid, { content, contentType, messageType, voiceEventSid }); + this._pstream.sendMessage(callSid, content, contentType, messageType, voiceEventSid); + return voiceEventSid; } /** @@ -954,13 +952,15 @@ class Call extends EventEmitter { const cleanup = () => { if (!this._pstream) { return; } + this._pstream.removeListener('ack', this._onAck); this._pstream.removeListener('answer', this._onAnswer); this._pstream.removeListener('cancel', this._onCancel); + this._pstream.removeListener('error', this._onSignalingError); this._pstream.removeListener('hangup', this._onHangup); this._pstream.removeListener('ringing', this._onRinging); this._pstream.removeListener('transportClose', this._onTransportClose); this._pstream.removeListener('connected', this._onConnected); - this._pstream.removeListener('message', this._onMessage); + this._pstream.removeListener('message', this._onMessageReceived); }; // This is kind of a hack, but it lets us avoid rewriting more code. @@ -1094,6 +1094,21 @@ class Call extends EventEmitter { } } + /** + * Called when the {@link Call} receives an ack from signaling + * @param payload + */ + private _onAck = (payload: Record): void => { + const { acktype, callsid, voiceeventsid } = payload; + if (this.parameters.CallSid !== callsid) { + this._log.warn(`Received ack from a different callsid: ${callsid}`); + return; + } + if (acktype === 'message') { + this._onMessageSent(voiceeventsid); + } + } + /** * Called when the {@link Call} is answered. * @param payload @@ -1280,7 +1295,7 @@ class Call extends EventEmitter { /** * Raised when a Call receives a message from the backend. - * + * TODO * @remarks * Note that in this context a "message" is limited to a * "User Defined Message" from the Voice User Defined Message Service (VUDMS) @@ -1290,12 +1305,34 @@ class Call extends EventEmitter { * @param payload - A record representing the payload of the message from the * Twilio backend. */ - private _onMessage = (payload: Record): void => { - if (this.parameters.CallSid !== payload.callsid) { + private _onMessageReceived = (payload: Record): void => { + const { callsid, content, contenttype, messagetype, voiceeventsid } = payload; + + if (this.parameters.CallSid !== callsid) { + this._log.warn(`Received a message from a different callsid: ${callsid}`); return; } - this.emit('message', payload); + this.emit('messageReceived', { + content, + contentType: contenttype, + messageType: messagetype, + voiceEventSid: voiceeventsid, + }); + } + + /** + * TODO + * @param voiceEventSid + */ + private _onMessageSent = (voiceEventSid: string): void => { + if (!this._messages.has(voiceEventSid)) { + this._log.warn(`Received a messageSent with a voiceEventSid that doesn't exists: ${voiceEventSid}`); + return; + } + const message = this._messages.get(voiceEventSid); + this._messages.delete(voiceEventSid); + this.emit('messageSent', message); } /** @@ -1338,6 +1375,22 @@ class Call extends EventEmitter { this.emit('sample', sample); } + /** + * Called when an 'error' event is received from the signaling stream. + */ + private _onSignalingError = (payload: Record): void => { + const { callsid, voiceeventsid } = payload; + if (this.parameters.CallSid !== callsid) { + this._log.warn(`Received an error from a different callsid: ${callsid}`); + return; + } + if (voiceeventsid && this._messages.has(voiceeventsid)) { + // Do not emit an error here. Device is handling all signaling related errors. + this._messages.delete(voiceeventsid); + this._log.warn(`Received an error while sending a message.`, payload); + } + } + /** * Called when signaling is restored */ @@ -1480,7 +1533,7 @@ namespace Call { /** * Emitted when a Call receives a message from the backend. - * + * TODO * @remarks * Note that in this context a "message" is limited to a * "User Defined Message" from the Voice User Defined Message Service (VUDMS) @@ -1492,7 +1545,12 @@ namespace Call { * @example `call.on('message', (payload) => { })` * @event */ - declare function messageEvent(payload: Record): void; + declare function messageReceivedEvent(message: Call.Message): void; + + /** + * TODO + */ + declare function messageSentEvent(message: Call.Message): void; /** * Emitted when the {@link Call} is muted or unmuted. @@ -1626,14 +1684,6 @@ namespace Call { * Options to be used to acquire media tracks and connect media. */ export interface AcceptOptions { - /** - * An array containing strings representing events that this call is - * registering for. For example, "dial-callprogress-events" so that this - * call will raise such events to the end-user when the stream receives - * them. - */ - messagesToRegisterFor?: Call.MessageType[]; - /** * An RTCConfiguration to pass to the RTCPeerConnection constructor. */ @@ -1699,6 +1749,34 @@ namespace Call { soundcache: Map; } + /** + * A Call Message represents the data that is being transferred between + * the signaling server and the SDK. + */ + export interface Message { + /** + * The content of the message which should match the contentType parameter. + */ + content: any; + + /** + * The MIME type of the content. Default is application/json. + */ + contentType?: string; + + /** + * The type of message + */ + messageType: MessageType; + + /** + * An autogenerated id that uniquely identifies the instance of this message. + * This is not required when sending a message from the SDK as this is autogenerated. + * But it will be available after the message is sent, or when a message is received. + */ + voiceEventSid?: string; + } + /** * Options to be passed to the {@link Call} constructor. * @private diff --git a/lib/twilio/device.ts b/lib/twilio/device.ts index acf750fb..6954b0ec 100644 --- a/lib/twilio/device.ts +++ b/lib/twilio/device.ts @@ -564,14 +564,6 @@ class Device extends EventEmitter { throw new InvalidStateError('A Call is already active'); } - const messagesToRegisterFor = options.messagesToRegisterFor || []; - if ( - !Array.isArray(messagesToRegisterFor) || - messagesToRegisterFor.some((event) => typeof event !== 'string') - ) { - throw new InvalidArgumentError('`messagesToRegisterFor` option must be an array of strings.'); - } - const activeCall = this._activeCall = await this._makeCall(options.params || { }, { rtcConfiguration: options.rtcConfiguration, voiceEventSidGenerator: this._options.voiceEventSidGenerator, @@ -583,7 +575,7 @@ class Device extends EventEmitter { // Stop the incoming sound if it's playing this._soundcache.get(Device.SoundName.Incoming).stop(); - activeCall.accept({ rtcConstraints: options.rtcConstraints, messagesToRegisterFor }); + activeCall.accept({ rtcConstraints: options.rtcConstraints }); this._publishNetworkChange(); return activeCall; } diff --git a/lib/twilio/pstream.js b/lib/twilio/pstream.js index 7d0e99a4..045deb27 100644 --- a/lib/twilio/pstream.js +++ b/lib/twilio/pstream.js @@ -178,17 +178,18 @@ PStream.prototype.setToken = function(token) { }; PStream.prototype.sendMessage = function( - callSid, - voiceEventSid, - messageType, - content + callsid, + content, + contenttype = 'application/json', + messagetype, + voiceeventsid ) { const payload = { - contenttype: 'application/json', - messagetype: messageType, + callsid, content, - callsid: callSid, - voiceeventsid: voiceEventSid, + contenttype, + messagetype, + voiceeventsid, }; this._publish('message', payload, true); }; @@ -200,12 +201,11 @@ PStream.prototype.register = function(mediaCapabilities) { this._publish('register', regPayload, true); }; -PStream.prototype.invite = function(sdp, callsid, preflight, params, registerFor) { +PStream.prototype.invite = function(sdp, callsid, preflight, params) { const payload = { callsid, sdp, preflight: !!preflight, - registerFor, twilio: params ? { params } : {} }; this._publish('invite', payload, true); diff --git a/lib/twilio/rtc/peerconnection.js b/lib/twilio/rtc/peerconnection.js index 7667697b..f0fd8e30 100644 --- a/lib/twilio/rtc/peerconnection.js +++ b/lib/twilio/rtc/peerconnection.js @@ -852,15 +852,7 @@ PeerConnection.prototype.iceRestart = function() { }); }; -PeerConnection.prototype.makeOutgoingCall = function( - token, - params, - callsid, - rtcConstraints, - rtcConfiguration, - messagesToRegisterFor, - onMediaStarted -) { +PeerConnection.prototype.makeOutgoingCall = function(token, params, callsid, rtcConstraints, rtcConfiguration, onMediaStarted) { if (!this._initializeMediaStream(rtcConstraints, rtcConfiguration)) { return; } @@ -897,13 +889,7 @@ PeerConnection.prototype.makeOutgoingCall = function( function onOfferSuccess() { if (self.status !== 'closed') { - self.pstream.invite( - self.version.getSDP(), - self.callSid, - self.options.preflight, - params, - messagesToRegisterFor - ); + self.pstream.invite(self.version.getSDP(), self.callSid, self.options.preflight, params); self._setupRTCDtlsTransportListener(); } } diff --git a/lib/twilio/uuid.ts b/lib/twilio/uuid.ts index 896caa84..6dc6e45f 100644 --- a/lib/twilio/uuid.ts +++ b/lib/twilio/uuid.ts @@ -28,7 +28,7 @@ function generateUuid(): string { const generateRandomValues: () => string = typeof crypto.randomUUID === 'function' - ? crypto.randomUUID + ? () => crypto.randomUUID!() : () => crypto.getRandomValues(new Uint32Array(32)).toString(); return md5(generateRandomValues()); diff --git a/package.json b/package.json index 4bf66406..b97dc793 100644 --- a/package.json +++ b/package.json @@ -122,10 +122,10 @@ "dependencies": { "@twilio/audioplayer": "1.0.6", "@twilio/voice-errors": "1.1.1", - "@types/md5": "^2.3.2", + "@types/md5": "2.3.2", "backoff": "2.5.0", "loglevel": "1.6.7", - "md5": "^2.3.0", + "md5": "2.3.0", "rtcpeerconnection-shim": "1.2.8", "ws": "7.4.6", "xmlhttprequest": "1.8.0" diff --git a/tests/peerconnection.js b/tests/peerconnection.js index 982a48c2..a8531354 100644 --- a/tests/peerconnection.js +++ b/tests/peerconnection.js @@ -783,7 +783,6 @@ describe('PeerConnection', () => { const eIceServers = 'iceServers'; const eIss = 'this is iss'; const eSDP = 'sdp'; - const messagesToRegisterFor = ['messagesToRegisterFor-foo', 'messagesToRegisterFor-bar']; let context = null; let version = null; @@ -825,7 +824,6 @@ describe('PeerConnection', () => { eCallSid, eConstraints, eIceServers, - messagesToRegisterFor, callback, ); }); @@ -867,7 +865,7 @@ describe('PeerConnection', () => { assert(version.createOffer.calledWithExactly(undefined, undefined, {audio: true}, sinon.match.func, sinon.match.func)); assert.equal(callback.called, false); assert(context.pstream.invite.calledOnce); - assert(context.pstream.invite.calledWithExactly(eSDP, eCallSid, true, eParams, messagesToRegisterFor)); + assert(context.pstream.invite.calledWithExactly(eSDP, eCallSid, true, eParams)); assert(version.getSDP.calledOnce); assert(version.getSDP.calledWithExactly()); assert(context.pstream.on.calledWithExactly('answer', sinon.match.func)); @@ -884,7 +882,7 @@ describe('PeerConnection', () => { assert(version.createOffer.calledWithExactly(undefined, undefined, {audio: true}, sinon.match.func, sinon.match.func)); assert.equal(callback.called, false); assert(context.pstream.invite.calledOnce); - assert(context.pstream.invite.calledWithExactly(eSDP, eCallSid, true, eParams, messagesToRegisterFor)); + assert(context.pstream.invite.calledWithExactly(eSDP, eCallSid, true, eParams)); assert(version.getSDP.calledOnce); assert(version.getSDP.calledWithExactly()); assert(context.pstream.on.calledWithExactly('answer', sinon.match.func)); diff --git a/tests/pstream.js b/tests/pstream.js index 40103f04..41d883a2 100644 --- a/tests/pstream.js +++ b/tests/pstream.js @@ -249,6 +249,47 @@ describe('PStream', () => { }); }); + describe('sendMessage', () => { + const callsid = 'testcallsid'; + const content = { foo: 'content' }; + const messagetype = 'user-defined-message'; + const voiceeventsid = 'testvoiceeventsid'; + + it('should send a message with the provided info', () => { + pstream.sendMessage(callsid, content, undefined, messagetype, voiceeventsid); + assert.equal(pstream.transport.send.callCount, 1); + console.log(pstream.transport.send.args[0][0]) + assert.deepEqual(JSON.parse(pstream.transport.send.args[0][0]), { + type: 'message', + version:EXPECTED_PSTREAM_VERSION, + payload: { + callsid, + content, + contenttype: 'application/json', + messagetype, + voiceeventsid, + } + }); + }); + + it('should override contenttype', () => { + pstream.sendMessage(callsid, content, 'text/plain', messagetype, voiceeventsid); + assert.equal(pstream.transport.send.callCount, 1); + console.log(pstream.transport.send.args[0][0]) + assert.deepEqual(JSON.parse(pstream.transport.send.args[0][0]), { + type: 'message', + version:EXPECTED_PSTREAM_VERSION, + payload: { + callsid, + content, + contenttype: 'text/plain', + messagetype, + voiceeventsid, + } + }); + }); + }); + describe('destroy', () => { it('should return this', () => { assert.equal(pstream.destroy(), pstream); @@ -315,24 +356,19 @@ describe('PStream', () => { ]], ['invite', [ { - args: ['bar', 'foo', true, '', []], - payload: { callsid: 'foo', sdp: 'bar', preflight: true, twilio: {}, registerFor: [] }, + args: ['bar', 'foo', true, ''], + payload: { callsid: 'foo', sdp: 'bar', preflight: true, twilio: {} }, scenario: 'called with empty params' }, { - args: ['bar', 'foo', true, 'baz=zee&foo=2', []], - payload: { callsid: 'foo', sdp: 'bar', preflight: true, twilio: { params: 'baz=zee&foo=2' }, registerFor: [] }, + args: ['bar', 'foo', true, 'baz=zee&foo=2'], + payload: { callsid: 'foo', sdp: 'bar', preflight: true, twilio: { params: 'baz=zee&foo=2' } }, scenario: 'called with non-empty params' }, { - args: ['bar', 'foo', false, '', []], - payload: { callsid: 'foo', sdp: 'bar', preflight: false, twilio: {}, registerFor: [] }, + args: ['bar', 'foo', false, ''], + payload: { callsid: 'foo', sdp: 'bar', preflight: false, twilio: {} }, scenario: 'called with preflight = false' - }, - { - args: ['bar', 'foo', false, '', ['registerFor-foo', 'registerFor-bar']], - payload: { callsid: 'foo', sdp: 'bar', preflight: false, twilio: {}, registerFor: ['registerFor-foo', 'registerFor-bar'] }, - scenario: 'passing events to "registerFor"' } ]], ['answer', [ diff --git a/tests/unit/call.ts b/tests/unit/call.ts index a5cf9ef6..3e5b3793 100644 --- a/tests/unit/call.ts +++ b/tests/unit/call.ts @@ -26,6 +26,8 @@ describe('Call', function() { let soundcache: Map; let voiceEventSidGenerator: () => string; + const wait = (timeout?: number) => new Promise(r => setTimeout(r, timeout || 0)); + const MediaHandler = () => { mediaHandler = createEmitterStub(require('../../lib/twilio/rtc/peerconnection')); mediaHandler.setInputTracksFromStream = sinon.spy((rejectCode?: number) => { @@ -520,7 +522,7 @@ describe('Call', function() { return p; }); - mediaHandler.makeOutgoingCall = sinon.spy((a: any, b: any, c: any, d: any, e: any, f: any, _callback: Function) => { + mediaHandler.makeOutgoingCall = sinon.spy((a: any, b: any, c: any, d: any, e: any, _callback: Function) => { callback = _callback; }); }); @@ -544,14 +546,6 @@ describe('Call', function() { }); }); - it('should call mediaHandler.makeOutgoingCall with an array of messages to `messagesToRegisterFor`', () => { - const messagesToRegisterFor = ['messagesToRegisterFor-foo', 'messagesToRegisterFor-bar'] as any; - conn.accept({ messagesToRegisterFor }); - return wait.then(() => { - assert.deepEqual(mediaHandler.makeOutgoingCall.args[0][5], messagesToRegisterFor); - }); - }); - context('when the success callback is called', () => { it('should publish an accepted-by-remote event', () => { conn.accept(); @@ -988,6 +982,15 @@ describe('Call', function() { }); describe('.sendMessage()', () => { + let message: Call.Message; + + beforeEach(() => { + message = { + content: { foo: 'foo' }, + messageType: Call.MessageType.UserDefinedMessage, + }; + }); + context('when messageType is invalid', () => { [ undefined, @@ -998,7 +1001,7 @@ describe('Call', function() { ].forEach((messageType: any) => { it(`should throw on ${JSON.stringify(messageType)}`, () => { assert.throws( - () => conn.sendMessage(messageType, {}), + () => conn.sendMessage({ ...message, messageType }), new InvalidArgumentError( '`messageType` must be an enumeration value of ' + '`Call.MessageType` or a string.', @@ -1006,62 +1009,67 @@ describe('Call', function() { ); }); }); + }); - context('when messageType is valid', () => { - ['foo', 'bar'].forEach((messageType: string) => { - it(`should not throw on '${messageType}'`, () => { - conn['_status'] = Call.State.Open; - conn.parameters.CallSid = 'foobar'; - assert.doesNotThrow(() => { - conn.sendMessage(messageType as Call.MessageType, {}); - }); - }); + context('when content is invalid', () => { + [ + undefined, + null, + ].forEach((content: any) => { + it(`should throw on ${JSON.stringify(content)}`, () => { + assert.throws( + () => conn.sendMessage({ ...message, content }), + new InvalidArgumentError('`content` is empty'), + ); }); }); + }); - it('should throw if pstream is unavailable', () => { - // @ts-ignore - conn._pstream = null; - assert.throws( - () => conn.sendMessage('foobar' as Call.MessageType, {}), - new InvalidStateError( - 'Could not send CallMessage; Signaling channel is disconnected', - ), - ); - }); + it('should throw if pstream is unavailable', () => { + // @ts-ignore + conn._pstream = null; + assert.throws( + () => conn.sendMessage(message), + new InvalidStateError( + 'Could not send CallMessage; Signaling channel is disconnected', + ), + ); + }); - it('should invoke pstream.sendMessage', () => { - conn['_status'] = Call.State.Open; - const mockCallSid = conn.parameters.CallSid = 'foobar-callsid'; - const mockVoiceEventSid = 'foobar-voice-event-sid'; - const mockContent = {}; - conn.sendMessage(Call.MessageType.UserDefinedMessage, mockContent); - sinon.assert.calledOnceWithExactly( - pstream.sendMessage, - mockCallSid, - mockVoiceEventSid, - Call.MessageType.UserDefinedMessage, - mockContent, - ); - }); + it('should throw if the call sid is not set', () => { + assert.throws( + () => conn.sendMessage(message), + new InvalidStateError( + 'Could not send CallMessage; Call has no CallSid', + ), + ); + }); - it('should generate a voiceEventSid', () => { - conn['_status'] = Call.State.Open; - conn.parameters.CallSid = 'foobar-callsid'; - conn.sendMessage(Call.MessageType.UserDefinedMessage, {}); - sinon.assert.calledOnceWithExactly( - voiceEventSidGenerator as sinon.SinonStub, - ); - }); + it('should invoke pstream.sendMessage', () => { + const mockCallSid = conn.parameters.CallSid = 'foobar-callsid'; + conn.sendMessage(message); + sinon.assert.calledOnceWithExactly( + pstream.sendMessage, + mockCallSid, + message.content, + undefined, + Call.MessageType.UserDefinedMessage, + 'foobar-voice-event-sid', + ); + }); - it('should throw if the call sid is not set', () => { - assert.throws( - () => conn.sendMessage(Call.MessageType.UserDefinedMessage, {}), - new InvalidStateError( - 'Could not send CallMessage; Call has no CallSid', - ), - ); - }); + it('should return voiceEventSid', () => { + conn.parameters.CallSid = 'foobar-callsid'; + const sid = conn.sendMessage(message); + assert.strictEqual(sid, 'foobar-voice-event-sid'); + }); + + it('should generate a voiceEventSid', () => { + conn.parameters.CallSid = 'foobar-callsid'; + conn.sendMessage(message); + sinon.assert.calledOnceWithExactly( + voiceEventSidGenerator as sinon.SinonStub, + ); }); }); @@ -1692,44 +1700,144 @@ describe('Call', function() { }); }); - describe('pstream.message event', () => { - const wait = (timeout?: number) => new Promise(r => { - setTimeout(r, timeout || 0); - clock.tick(0); - }); - + describe('pstream.ack event', () => { const mockcallsid = 'mock-callsid'; + let mockMessagePayload: any; + let mockAckPayload: any; beforeEach(() => { + mockMessagePayload = { + content: { + foo: 'bar', + }, + contentType: 'application/json', + messageType: 'foo-bar', + }; + + mockAckPayload = { + acktype: 'message', + callsid: mockcallsid, + voiceeventsid: 'mockvoiceeventsid', + }; + conn.parameters = { ...conn.parameters, CallSid: mockcallsid, }; + clock.restore(); }); - it('emits the payload', async () => { - const mockPayload = { - callsid: mockcallsid, - content: { - foo: 'bar', - }, - contenttype: 'application/json', - messagetype: 'foo-bar', - voiceeventsid: 'mock-voiceeventsid-foobar', - }; + it('should emit messageSent', async () => { + const sid = conn.sendMessage(mockMessagePayload); + const payloadPromise = new Promise((resolve) => { + conn.on('messageSent', resolve); + }); + pstream.emit('ack', { ...mockAckPayload, voiceeventsid: sid }); + assert.deepEqual(await payloadPromise, { ...mockMessagePayload, voiceEventSid: sid }); + }); + + it('should ignore ack when callSids do not match', async () => { + const sid = conn.sendMessage(mockMessagePayload); + const payloadPromise = new Promise((resolve, reject) => { + conn.on('messageSent', reject); + }); + pstream.emit('ack', { ...mockAckPayload, voiceeventsid: sid, callsid: 'foo' }); + await Promise.race([ + wait(1), + payloadPromise, + ]); + }); + + it('should not emit messageSent when acktype is not `message`', async () => { + const sid = conn.sendMessage(mockMessagePayload); + const payloadPromise = new Promise((resolve, reject) => { + conn.on('messageSent', reject); + }); + pstream.emit('ack', { ...mockAckPayload, voiceeventsid: sid, acktype: 'foo' }); + await Promise.race([ + wait(1), + payloadPromise, + ]); + }); + + it('should not emit messageSent if voiceEventSid was not previously sent', async () => { + const sid = conn.sendMessage(mockMessagePayload); + const payloadPromise = new Promise((resolve, reject) => { + conn.on('messageSent', reject); + }); + pstream.emit('ack', { ...mockAckPayload, voiceeventsid: 'foo' }); + await Promise.race([ + wait(1), + payloadPromise, + ]); + }); + it('should emit messageSent only once', async () => { + const sid = conn.sendMessage(mockMessagePayload); + const handler = sinon.stub(); const payloadPromise = new Promise((resolve) => { - conn.on('message', resolve); + conn.on('messageSent', () => { + handler(); + resolve(null); + }); }); + pstream.emit('ack', { ...mockAckPayload, voiceeventsid: sid }); + pstream.emit('ack', { ...mockAckPayload, voiceeventsid: sid }); + await payloadPromise; + sinon.assert.calledOnce(handler); + }); - pstream.emit('message', mockPayload); + it('should not emit messageSent after an error', async () => { + const sid = conn.sendMessage(mockMessagePayload); + const payloadPromise = new Promise((resolve, reject) => { + conn.on('messageSent', reject); + }); + pstream.emit('error', { callsid: mockcallsid, voiceeventsid: sid }); + pstream.emit('ack', { ...mockAckPayload, voiceeventsid: sid }); + await Promise.race([ + wait(1), + payloadPromise, + ]); + }); + + it('should ignore errors with different callsid', async () => { + const sid = conn.sendMessage(mockMessagePayload); + const payloadPromise = new Promise((resolve) => { + conn.on('messageSent', resolve); + }); + pstream.emit('error', { callsid: 'foo', voiceeventsid: sid }); + pstream.emit('ack', { ...mockAckPayload, voiceeventsid: sid }); + await payloadPromise; + }); - assert.deepEqual(mockPayload, await payloadPromise); + it('should ignore errors with different voiceeventsid', async () => { + const sid = conn.sendMessage(mockMessagePayload); + const payloadPromise = new Promise((resolve) => { + conn.on('messageSent', resolve); + }); + pstream.emit('error', { callsid: mockcallsid, voiceeventsid: 'foo' }); + pstream.emit('ack', { ...mockAckPayload, voiceeventsid: sid }); + await payloadPromise; }); - it('ignores messages when callSids do not match', async () => { - const mockPayload = { - callsid: 'mock-callsid-foobar', + it('should ignore errors with missing voiceeventsid', async () => { + const sid = conn.sendMessage(mockMessagePayload); + const payloadPromise = new Promise((resolve) => { + conn.on('messageSent', resolve); + }); + pstream.emit('error', { callsid: mockcallsid }); + pstream.emit('ack', { ...mockAckPayload, voiceeventsid: sid }); + await payloadPromise; + }); + }); + + describe('pstream.message event', () => { + const mockcallsid = 'mock-callsid'; + let mockPayload: any; + + beforeEach(() => { + mockPayload = { + callsid: mockcallsid, content: { foo: 'bar', }, @@ -1737,15 +1845,33 @@ describe('Call', function() { messagetype: 'foo-bar', voiceeventsid: 'mock-voiceeventsid-foobar', }; + conn.parameters = { + ...conn.parameters, + CallSid: mockcallsid, + }; + clock.restore(); + }); - const messagePromise = new Promise((resolve, reject) => { - conn.on('message', reject); + it('should emit messageReceived', async () => { + const payloadPromise = new Promise((resolve) => { + conn.on('messageReceived', resolve); }); - pstream.emit('message', mockPayload); + assert.deepEqual(await payloadPromise, { + content: mockPayload.content, + contentType: mockPayload.contenttype, + messageType: mockPayload.messagetype, + voiceEventSid: mockPayload.voiceeventsid, + }); + }); + it('should ignore messages when callSids do not match', async () => { + const messagePromise = new Promise((resolve, reject) => { + conn.on('messageReceived', reject); + }); + pstream.emit('message', { ...mockPayload, callsid: 'foo' }); await Promise.race([ - wait(), + wait(1), messagePromise, ]); });