├── .ackrc ├── .editorconfig ├── .env.sample ├── .eslintrc.json ├── .gitignore ├── .prettierrc.js ├── .vscode └── settings.json ├── README.md ├── demos ├── browser │ ├── index.html │ └── index.ts └── node │ ├── answer-and-save.ts │ ├── answer-and-talk.js │ ├── call-and-save.js │ ├── caller-cancel.js │ ├── high-concurrency.js │ ├── ignore.js │ ├── outbound-call.ts │ ├── send-audio.ts │ ├── text-to-speech.ts │ └── to-voicemail.js ├── package.json ├── src ├── index.ts ├── rc-message │ ├── call-control-commands.ts │ ├── rc-message.ts │ └── rc-requests.ts ├── sip-message │ ├── inbound │ │ └── inbound-sip-message.ts │ ├── index.ts │ ├── outbound │ │ ├── outbound-sip-message.ts │ │ ├── request-sip-message.ts │ │ └── response-sip-message.ts │ ├── response-codes.ts │ └── sip-message.ts ├── types │ ├── node-webrtc-media-devices │ │ └── index.d.ts │ ├── wrtc │ │ └── index.d.ts │ └── ws │ │ └── index.d.ts └── utils.ts ├── test ├── rc-message.spec.js └── sip-message │ ├── inbound-sip-message.spec.js │ ├── request-sip-message.spec.js │ └── response-sip-message.spec.js ├── tsconfig.json ├── webpack.config.babel.ts └── yarn.lock /.ackrc: -------------------------------------------------------------------------------- 1 | --ignore-file=match:/^yarn\.lock$/ 2 | --ignore-dir=dist/ 3 | --ignore-dir=test/ 4 | --ignore-dir=build/ 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | RINGCENTRAL_SERVER_URL=https://platform.ringcentral.com 2 | RINGCENTRAL_CLIENT_ID= 3 | RINGCENTRAL_CLIENT_SECRET= 4 | RINGCENTRAL_USERNAME= 5 | RINGCENTRAL_EXTENSION= 6 | RINGCENTRAL_PASSWORD= 7 | CALLEE_FOR_TESTING= 8 | WEB_SOCKET_DEBUGGING=true 9 | WEB_RTC_DEBUGGING=false 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | *.raw 4 | dist/ 5 | *.tiff 6 | *.wav 7 | .env.* 8 | fixed-device-id.js 9 | build/ 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('gts/.prettierrc.json') 3 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "filewatcher.commands": [ 3 | { 4 | "match": "\\.tsx?", 5 | "isAsync": true, 6 | "cmd": "cd '${workspaceRoot}' && yarn gts fix '${file}'", 7 | "event": "onFileChange" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Project moved 2 | 3 | Please check [RingCentral Softphone SDK for TypeScript](https://github.com/ringcentral/ringcentral-softphone-ts) instead. 4 | 5 | ## This repo has been abandoned 6 | 7 | ## Please use [RingCentral Softphone SDK for TypeScript](https://github.com/ringcentral/ringcentral-softphone-ts) instead 8 | 9 | ## What are the differences between ringcentral-web-phone and this project? 10 | 11 | [ringcentral-web-phone](https://github.com/ringcentral/ringcentral-web-phone) is designed for client side and only works with browsers. 12 | 13 | This project was originally designed for server and desktop. It doesn't require a browser to run. It could run in browser too. 14 | 15 | 16 | ## Supported features: 17 | 18 | - Answer inbound call 19 | - Make outbound call 20 | - Speak and listen, two way communication 21 | - Call control features 22 | - Redirect inbound call to voicemail 23 | - Ignore inbound call 24 | 25 | 26 | ## Demos 27 | 28 | - [browser demo](./demos/browser) 29 | - node.js 30 | - [answer inbound call](./demos/node/answer-and-talk.js) 31 | - [make outbound call](./demos/node/outbound-call.js) 32 | - [redirect inbound call to voicemail](./demos/node/to-voicemail.js) 33 | - [ignore inbound call](./demos/node/ignore.js) 34 | - [call supervise](https://github.com/tylerlong/ringcentral-call-supervise-demo) 35 | - supervise an existing phone call and get real time audio stream 36 | 37 | 38 | ## Install 39 | 40 | ``` 41 | yarn add ringcentral-softphone @rc-ex/core 42 | ``` 43 | 44 | For node.js you also need to: 45 | 46 | ``` 47 | yarn add ws wrtc 48 | ``` 49 | 50 | 51 | ## Usage 52 | 53 | - for node.js, check [here](./demos/node) 54 | - for browser, check [here](./demos/browser) 55 | 56 | 57 | ## Get realtime inbound audio 58 | 59 | ```js 60 | import { nonstandard } from 'wrtc' 61 | 62 | softphone.once('track', e => { 63 | const audioSink = new nonstandard.RTCAudioSink(e.track) 64 | audioSink.ondata = data => { 65 | // here you have the `data` 66 | } 67 | softphone.once('BYE', () => { 68 | audioSink.stop() 69 | }) 70 | }) 71 | ``` 72 | 73 | The data you got via `audioSink.ondata` is of the following structure: 74 | 75 | ```js 76 | { 77 | samples: Int16Array [ ... ], 78 | bitsPerSample: 16, 79 | sampleRate: 48000, 80 | channelCount: 1, 81 | numberOfFrames: 480, 82 | type: 'data' 83 | } 84 | ``` 85 | 86 | Please note that, you may get different numbers, for example, `sampleRate` you get might be 16000 instead of 48000. 87 | 88 | 89 | ## Official demos 90 | 91 | ### Setup 92 | 93 | ``` 94 | yarn install 95 | cp .env.sample .env 96 | ``` 97 | 98 | Edit `.env` file to specify credentials. 99 | 100 | - `CALLEE_FOR_TESTING` is a phone number to receive testing phone calls. You don't need to specify it if you do not make outbound calls. 101 | - If you have `WEB_SOCKET_DEBUGGING=true`, then all WebSocket traffic will be printed to console. 102 | 103 | 104 | ### Run 105 | 106 | - for node.js `yarn server` 107 | - for browser `yarn browser` 108 | 109 | 110 | ### Test 111 | 112 | Make a phone call to the phone number you configured in `.env` file. The demo app will answer the call and you can speak and listen. 113 | 114 | 115 | ## Interesting Use cases 116 | 117 | ### Call supervision 118 | 119 | Let's say there is a phone call ongoing between a customer and the call agent. 120 | You can use this library to supervise this phone call to get live audio stream. 121 | You can analyze the audio stream using some AI algorithm and provide tips to the call agent in real time. 122 | 123 | 124 | ### Live transcription 125 | 126 | Use this library to supervise an existing phone call to get live audio stream. 127 | Translate the audio stream into text by invoking some speech-to-text service. 128 | Show the text to the caller and/or callee so they can see live transcription. 129 | 130 | 131 | ### Play recorded audio 132 | 133 | You can create a program to make a phone call or answer a phone call and play recorded audio. 134 | This is good for announcement purpose. This is also good for quick voicemail drop. 135 | Or you can use text-to-speech service to read text to the callee. 136 | 137 | 138 | ## Todo 139 | 140 | - How to create a publish message 141 | - How to forward a call 142 | -------------------------------------------------------------------------------- /demos/browser/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | RingCentral Softphone Demo 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /demos/browser/index.ts: -------------------------------------------------------------------------------- 1 | import RingCentral from '@rc-ex/core'; 2 | 3 | import Softphone from '../../src/index'; 4 | 5 | const rc = new RingCentral({ 6 | server: process.env.RINGCENTRAL_SERVER_URL, 7 | clientId: process.env.RINGCENTRAL_CLIENT_ID, 8 | clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET, 9 | }); 10 | 11 | (async () => { 12 | await rc.authorize({ 13 | username: process.env.RINGCENTRAL_USERNAME!, 14 | extension: process.env.RINGCENTRAL_EXTENSION, 15 | password: process.env.RINGCENTRAL_PASSWORD!, 16 | }); 17 | const softphone = new Softphone(rc); 18 | await softphone.register(); 19 | await rc.revoke(); // rc is no longer needed 20 | 21 | const audioElement = document.getElementById('audio') as HTMLAudioElement; 22 | softphone.on('INVITE', async sipMessage => { 23 | const inputAudioStream = await navigator.mediaDevices.getUserMedia({ 24 | audio: true, 25 | video: false, 26 | }); 27 | softphone.answer(sipMessage, inputAudioStream); 28 | softphone.once('track', e => { 29 | audioElement.srcObject = e.streams[0]; 30 | softphone.once('BYE', () => { 31 | audioElement.srcObject = null; 32 | }); 33 | }); 34 | }); 35 | })(); 36 | -------------------------------------------------------------------------------- /demos/node/answer-and-save.ts: -------------------------------------------------------------------------------- 1 | import RingCentral from '@rc-ex/core'; 2 | // eslint-disable-next-line node/no-unpublished-import 3 | import wrtc from 'wrtc'; 4 | import fs from 'fs'; 5 | 6 | import Softphone from '../../src/index'; 7 | 8 | const rc = new RingCentral({ 9 | server: process.env.RINGCENTRAL_SERVER_URL, 10 | clientId: process.env.RINGCENTRAL_CLIENT_ID, 11 | clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET, 12 | }); 13 | 14 | (async () => { 15 | await rc.authorize({ 16 | username: process.env.RINGCENTRAL_USERNAME!, 17 | extension: process.env.RINGCENTRAL_EXTENSION, 18 | password: process.env.RINGCENTRAL_PASSWORD!, 19 | }); 20 | const softphone = new Softphone(rc); 21 | await softphone.register(); 22 | await rc.revoke(); // rc is no longer needed 23 | softphone.on('INVITE', async sipMessage => { 24 | softphone.answer(sipMessage); 25 | softphone.once('track', e => { 26 | const audioFilePath = 'audio.raw'; 27 | if (fs.existsSync(audioFilePath)) { 28 | fs.unlinkSync(audioFilePath); 29 | } 30 | const writeStream = fs.createWriteStream(audioFilePath, {flags: 'a'}); 31 | const audioSink = new wrtc.nonstandard.RTCAudioSink(e.track); 32 | audioSink.ondata = (data: any) => { 33 | writeStream.write(Buffer.from(data.samples.buffer)); 34 | }; 35 | softphone.once('BYE', () => { 36 | audioSink.stop(); 37 | writeStream.end(); 38 | }); 39 | }); 40 | }); 41 | })(); 42 | // You can play the saved audio by: play -b 16 -e signed -c 1 -r 48000 audio.raw 43 | -------------------------------------------------------------------------------- /demos/node/answer-and-talk.js: -------------------------------------------------------------------------------- 1 | import RingCentral from '@rc-ex/core' 2 | import { nonstandard } from 'wrtc' 3 | import mediaDevices from 'node-webrtc-media-devices' 4 | import Speaker from 'speaker' 5 | 6 | import Softphone from '../../src/index' 7 | 8 | const rc = new RingCentral({ 9 | server: process.env.RINGCENTRAL_SERVER_URL, 10 | clientId: process.env.RINGCENTRAL_CLIENT_ID, 11 | clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET 12 | }) 13 | 14 | ;(async () => { 15 | await rc.authorize({ 16 | username: process.env.RINGCENTRAL_USERNAME, 17 | extension: process.env.RINGCENTRAL_EXTENSION, 18 | password: process.env.RINGCENTRAL_PASSWORD 19 | }) 20 | const softphone = new Softphone(rc) 21 | await softphone.register() 22 | await rc.revoke() // rc is no longer needed 23 | softphone.on('INVITE', async sipMessage => { 24 | const inputAudioStream = await mediaDevices.getUserMedia({ audio: true, video: false }) 25 | softphone.answer(sipMessage, inputAudioStream) 26 | softphone.once('track', e => { 27 | const speaker = new Speaker({ channels: 1, bitDepth: 16, sampleRate: 48000, signed: true }) 28 | const audioSink = new nonstandard.RTCAudioSink(e.track) 29 | audioSink.ondata = data => { 30 | speaker.write(Buffer.from(data.samples.buffer)) 31 | } 32 | softphone.once('BYE', () => { 33 | audioSink.stop() 34 | speaker.close() 35 | }) 36 | }) 37 | }) 38 | })() 39 | -------------------------------------------------------------------------------- /demos/node/call-and-save.js: -------------------------------------------------------------------------------- 1 | import RingCentral from '@rc-ex/core' 2 | import { nonstandard, MediaStream } from 'wrtc' 3 | import fs from 'fs' 4 | import RTCAudioStreamSource from 'node-webrtc-audio-stream-source' 5 | 6 | import Softphone from '../../src/index' 7 | 8 | const rc = new RingCentral({ 9 | server: process.env.RINGCENTRAL_SERVER_URL, 10 | clientId: process.env.RINGCENTRAL_CLIENT_ID, 11 | clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET 12 | }) 13 | 14 | ;(async () => { 15 | await rc.authorize({ 16 | username: process.env.RINGCENTRAL_USERNAME, 17 | extension: process.env.RINGCENTRAL_EXTENSION, 18 | password: process.env.RINGCENTRAL_PASSWORD 19 | }) 20 | const softphone = new Softphone(rc) 21 | await softphone.register() 22 | await rc.revoke() // rc is no longer needed 23 | 24 | const rtcAudioStreamSource = new RTCAudioStreamSource() 25 | const track = rtcAudioStreamSource.createTrack() 26 | const inputAudioStream = new MediaStream() 27 | inputAudioStream.addTrack(track) 28 | softphone.invite(process.env.CALLEE_FOR_TESTING, inputAudioStream) 29 | 30 | softphone.once('track', e => { 31 | const audioFilePath = 'audio.raw' 32 | if (fs.existsSync(audioFilePath)) { 33 | fs.unlinkSync(audioFilePath) 34 | } 35 | const writeStream = fs.createWriteStream(audioFilePath, { flags: 'a' }) 36 | const audioSink = new nonstandard.RTCAudioSink(e.track) 37 | audioSink.ondata = data => { 38 | writeStream.write(Buffer.from(data.samples.buffer)) 39 | } 40 | softphone.once('BYE', () => { 41 | audioSink.stop() 42 | writeStream.end() 43 | }) 44 | }) 45 | })() 46 | // You can play the saved audio by: play -b 16 -e signed -c 1 -r 48000 audio.raw 47 | -------------------------------------------------------------------------------- /demos/node/caller-cancel.js: -------------------------------------------------------------------------------- 1 | import RingCentral from '@rc-ex/core' 2 | 3 | import Softphone from '../../src/index' 4 | 5 | const rc = new RingCentral({ 6 | server: process.env.RINGCENTRAL_SERVER_URL, 7 | clientId: process.env.RINGCENTRAL_CLIENT_ID, 8 | clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET 9 | }) 10 | 11 | ;(async () => { 12 | await rc.authorize({ 13 | username: process.env.RINGCENTRAL_USERNAME, 14 | extension: process.env.RINGCENTRAL_EXTENSION, 15 | password: process.env.RINGCENTRAL_PASSWORD 16 | }) 17 | const softphone = new Softphone(rc) 18 | await softphone.register() 19 | await rc.revoke() // rc is no longer needed 20 | // do nothing, caller should cancel the call 21 | })() 22 | -------------------------------------------------------------------------------- /demos/node/high-concurrency.js: -------------------------------------------------------------------------------- 1 | import RingCentral from '@rc-ex/core' 2 | import { nonstandard } from 'wrtc' 3 | import fs from 'fs' 4 | 5 | import Softphone from '../../src/index' 6 | 7 | // This softphone demo can take multiple incoming call simultaneously! 8 | // Each incoming calll will generate an audio file on the disk, with IDs as the file name. 9 | 10 | const rc = new RingCentral({ 11 | server: process.env.RINGCENTRAL_SERVER_URL, 12 | clientId: process.env.RINGCENTRAL_CLIENT_ID, 13 | clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET 14 | }) 15 | 16 | ;(async () => { 17 | await rc.authorize({ 18 | username: process.env.RINGCENTRAL_USERNAME, 19 | extension: process.env.RINGCENTRAL_EXTENSION, 20 | password: process.env.RINGCENTRAL_PASSWORD 21 | }) 22 | const softphone = new Softphone(rc) 23 | await softphone.register() 24 | await rc.revoke() // rc is no longer needed 25 | softphone.on('INVITE', async sipMessage => { 26 | console.log(sipMessage.headers) 27 | softphone.answer(sipMessage) 28 | softphone.once('track', e => { 29 | const audioFilePath = `${sipMessage.headers['p-rc-api-ids']}.raw` 30 | if (fs.existsSync(audioFilePath)) { 31 | fs.unlinkSync(audioFilePath) 32 | } 33 | const writeStream = fs.createWriteStream(audioFilePath, { flags: 'a' }) 34 | const audioSink = new nonstandard.RTCAudioSink(e.track) 35 | audioSink.ondata = data => { 36 | writeStream.write(Buffer.from(data.samples.buffer)) 37 | } 38 | softphone.once('BYE', () => { 39 | audioSink.stop() 40 | writeStream.end() 41 | }) 42 | }) 43 | }) 44 | })() 45 | // You can play the saved audio by: play -b 16 -e signed -c 1 -r 48000 audio.raw 46 | -------------------------------------------------------------------------------- /demos/node/ignore.js: -------------------------------------------------------------------------------- 1 | import RingCentral from '@rc-ex/core' 2 | 3 | import Softphone from '../../src/index' 4 | 5 | const rc = new RingCentral({ 6 | server: process.env.RINGCENTRAL_SERVER_URL, 7 | clientId: process.env.RINGCENTRAL_CLIENT_ID, 8 | clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET 9 | }) 10 | 11 | ;(async () => { 12 | await rc.authorize({ 13 | username: process.env.RINGCENTRAL_USERNAME, 14 | extension: process.env.RINGCENTRAL_EXTENSION, 15 | password: process.env.RINGCENTRAL_PASSWORD 16 | }) 17 | const softphone = new Softphone(rc) 18 | await softphone.register() 19 | await rc.revoke() // rc is no longer needed 20 | softphone.on('INVITE', async sipMessage => { 21 | softphone.ignore(sipMessage) 22 | }) 23 | })() 24 | -------------------------------------------------------------------------------- /demos/node/outbound-call.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-unpublished-import */ 2 | import RingCentral from '@rc-ex/core'; 3 | import mediaDevices from 'node-webrtc-media-devices'; 4 | import Speaker from 'speaker'; 5 | import wrtc from 'wrtc'; 6 | 7 | import Softphone from '../../src/index'; 8 | 9 | const {RTCAudioSink} = wrtc.nonstandard; 10 | 11 | const rc = new RingCentral({ 12 | server: process.env.RINGCENTRAL_SERVER_URL, 13 | clientId: process.env.RINGCENTRAL_CLIENT_ID, 14 | clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET, 15 | }); 16 | 17 | (async () => { 18 | await rc.authorize({ 19 | username: process.env.RINGCENTRAL_USERNAME!, 20 | extension: process.env.RINGCENTRAL_EXTENSION, 21 | password: process.env.RINGCENTRAL_PASSWORD!, 22 | }); 23 | const softphone = new Softphone(rc); 24 | await softphone.register(); 25 | await rc.revoke(); // rc is no longer needed 26 | softphone.on('track', e => { 27 | const speaker = new Speaker({ 28 | channels: 1, 29 | bitDepth: 16, 30 | sampleRate: 48000, 31 | // signed: true, 32 | }); 33 | const audioSink = new RTCAudioSink(e.track); 34 | audioSink.ondata = (data: any) => { 35 | speaker.write(Buffer.from(data.samples.buffer)); 36 | }; 37 | softphone.once('BYE', () => { 38 | audioSink.stop(); 39 | speaker.close(true); 40 | }); 41 | }); 42 | const inputAudioStream = await mediaDevices.getUserMedia({ 43 | audio: true, 44 | video: false, 45 | }); 46 | softphone.invite(process.env.CALLEE_FOR_TESTING!, inputAudioStream); 47 | })(); 48 | -------------------------------------------------------------------------------- /demos/node/send-audio.ts: -------------------------------------------------------------------------------- 1 | import RingCentral from '@rc-ex/core'; 2 | // eslint-disable-next-line node/no-unpublished-import 3 | import wrtc from 'wrtc'; 4 | // eslint-disable-next-line node/no-unpublished-import 5 | import RTCAudioStreamSource from 'node-webrtc-audio-stream-source'; 6 | import fs from 'fs'; 7 | 8 | import Softphone from '../../src/index'; 9 | 10 | const rc = new RingCentral({ 11 | server: process.env.RINGCENTRAL_SERVER_URL, 12 | clientId: process.env.RINGCENTRAL_CLIENT_ID, 13 | clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET, 14 | }); 15 | 16 | (async () => { 17 | await rc.authorize({ 18 | username: process.env.RINGCENTRAL_USERNAME!, 19 | extension: process.env.RINGCENTRAL_EXTENSION, 20 | password: process.env.RINGCENTRAL_PASSWORD!, 21 | }); 22 | const softphone = new Softphone(rc); 23 | await softphone.register(); 24 | await rc.revoke(); // rc is no longer needed 25 | 26 | const rtcAudioStreamSource = new RTCAudioStreamSource(); 27 | const track = rtcAudioStreamSource.createTrack(); 28 | const inputAudioStream = new wrtc.MediaStream(); 29 | inputAudioStream.addTrack(track); 30 | softphone.invite(process.env.CALLEE_FOR_TESTING!, inputAudioStream); 31 | 32 | softphone.once('track', (e: any) => { 33 | rtcAudioStreamSource.addStream( 34 | fs.createReadStream('test.wav'), 35 | 16, 36 | 48000, 37 | 1 38 | ); 39 | }); 40 | })(); 41 | -------------------------------------------------------------------------------- /demos/node/text-to-speech.ts: -------------------------------------------------------------------------------- 1 | import RingCentral from '@rc-ex/core'; 2 | // eslint-disable-next-line node/no-unpublished-import 3 | import wrtc from 'wrtc'; 4 | // eslint-disable-next-line node/no-unpublished-import 5 | import RTCAudioStreamSource from 'node-webrtc-audio-stream-source'; 6 | import fs from 'fs'; 7 | import {exec} from 'child_process'; 8 | 9 | import Softphone from '../../src/index'; 10 | 11 | const rc = new RingCentral({ 12 | server: process.env.RINGCENTRAL_SERVER_URL, 13 | clientId: process.env.RINGCENTRAL_CLIENT_ID, 14 | clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET, 15 | }); 16 | 17 | (async () => { 18 | await rc.authorize({ 19 | username: process.env.RINGCENTRAL_USERNAME!, 20 | extension: process.env.RINGCENTRAL_EXTENSION, 21 | password: process.env.RINGCENTRAL_PASSWORD!, 22 | }); 23 | const softphone = new Softphone(rc); 24 | await softphone.register(); 25 | await rc.revoke(); // rc is no longer needed 26 | 27 | const rtcAudioStreamSource = new RTCAudioStreamSource(); 28 | const track = rtcAudioStreamSource.createTrack(); 29 | const inputAudioStream = new wrtc.MediaStream(); 30 | inputAudioStream.addTrack(track); 31 | softphone.invite(process.env.CALLEE_FOR_TESTING!, inputAudioStream); 32 | softphone.on('track', e => { 33 | const text = 34 | "Hello Tyler, you need to give a talk to the AI RTC conference today at 3PM, don't forget!"; 35 | const process = exec( 36 | `say -o temp.wav --data-format=LEI16@48000 "${text + ' ' + text}"` 37 | ); 38 | process.on('exit', () => { 39 | rtcAudioStreamSource.addStream( 40 | fs.createReadStream('temp.wav'), 41 | 16, 42 | 48000, 43 | 1 44 | ); 45 | }); 46 | }); 47 | })(); 48 | -------------------------------------------------------------------------------- /demos/node/to-voicemail.js: -------------------------------------------------------------------------------- 1 | import RingCentral from '@rc-ex/core' 2 | 3 | import Softphone from '../../src/index' 4 | 5 | const rc = new RingCentral({ 6 | server: process.env.RINGCENTRAL_SERVER_URL, 7 | clientId: process.env.RINGCENTRAL_CLIENT_ID, 8 | clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET 9 | }) 10 | 11 | ;(async () => { 12 | await rc.authorize({ 13 | username: process.env.RINGCENTRAL_USERNAME, 14 | extension: process.env.RINGCENTRAL_EXTENSION, 15 | password: process.env.RINGCENTRAL_PASSWORD 16 | }) 17 | const softphone = new Softphone(rc) 18 | await softphone.register() 19 | await rc.revoke() // rc is no longer needed 20 | softphone.on('INVITE', async sipMessage => { 21 | softphone.toVoicemail(sipMessage) 22 | }) 23 | })() 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ringcentral-softphone", 3 | "version": "0.5.1", 4 | "license": "MIT", 5 | "main": "dist/src/index.js", 6 | "engines": { 7 | "node": ">=12" 8 | }, 9 | "scripts": { 10 | "server": "ts-node --project tsconfig.json -r dotenv-override-true/config demos/node/send-audio.ts", 11 | "browser": "webpack-dev-server --progress --colors --open", 12 | "test": "jest", 13 | "prepublishOnly": "tsc" 14 | }, 15 | "dependencies": { 16 | "@types/blueimp-md5": "^2.18.0", 17 | "@types/uuid": "^8.3.4", 18 | "@types/xmldom": "^0.1.31", 19 | "blueimp-md5": "^2.19.0", 20 | "core-js": "^3.23.4", 21 | "isomorphic-webrtc": "^0.2.2", 22 | "isomorphic-ws": "^5.0.0", 23 | "uuid": "^8.3.2", 24 | "xmldom": "^0.6.0" 25 | }, 26 | "devDependencies": { 27 | "@rc-ex/core": "^0.14.0", 28 | "@types/node": "^18.0.4", 29 | "dotenv-override-true": "^6.2.2", 30 | "gts": "^3.1.0", 31 | "html-webpack-plugin": "^5.5.0", 32 | "husky": "^8.0.1", 33 | "jest": "^28.1.3", 34 | "node-webrtc-audio-stream-source": "^0.3.0", 35 | "node-webrtc-media-devices": "^0.1.4", 36 | "speaker": "^0.5.4", 37 | "ts-loader": "^9.3.1", 38 | "ts-node": "^10.9.1", 39 | "typescript": "^4.7.4", 40 | "webpack": "^5.73.0", 41 | "webpack-cli": "^4.10.0", 42 | "webpack-dev-server": "^4.9.3", 43 | "wrtc": "^0.4.7", 44 | "ws": "^8.8.0", 45 | "yarn-upgrade-all": "^0.7.1" 46 | }, 47 | "peerDependencies": { 48 | "@rc-ex/core": "^0.14.0" 49 | }, 50 | "husky": { 51 | "hooks": { 52 | "pre-push": "yarn test" 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {v4 as uuid} from 'uuid'; 2 | import WebSocket from 'isomorphic-ws'; 3 | import EventEmitter from 'events'; 4 | import irtc from 'isomorphic-webrtc'; 5 | import RingCentral from '@rc-ex/core'; 6 | 7 | const {RTCSessionDescription, RTCPeerConnection} = irtc; 8 | 9 | import { 10 | RequestSipMessage, 11 | ResponseSipMessage, 12 | InboundSipMessage, 13 | } from './sip-message'; 14 | import { 15 | generateAuthorization, 16 | generateProxyAuthorization, 17 | branch, 18 | enableWebSocketDebugging, 19 | enableWebRtcDebugging, 20 | } from './utils'; 21 | import RcMessage from './rc-message/rc-message'; 22 | import {SipInfo} from './utils'; 23 | import SipRegistrationDeviceInfo from '@rc-ex/core/lib/definitions/SipRegistrationDeviceInfo'; 24 | 25 | class Softphone extends EventEmitter.EventEmitter { 26 | fakeDomain: string; 27 | fakeEmail: string; 28 | fromTag: string; 29 | callId: string; 30 | rc: RingCentral; 31 | sipInfo?: SipInfo; 32 | device?: SipRegistrationDeviceInfo; 33 | ws?: WebSocket; 34 | 35 | constructor(rc: RingCentral) { 36 | super(); 37 | this.rc = rc; 38 | this.fakeDomain = uuid() + '.invalid'; 39 | this.fakeEmail = uuid() + '@' + this.fakeDomain; 40 | this.fromTag = uuid(); 41 | this.callId = uuid(); 42 | } 43 | 44 | newCallId() { 45 | this.callId = uuid(); 46 | } 47 | 48 | async handleSipMessage(inboundSipMessage: InboundSipMessage) { 49 | if (inboundSipMessage.subject.startsWith('INVITE sip:')) { 50 | // invite 51 | await this.response(inboundSipMessage, 180, { 52 | Contact: ``, 53 | }); 54 | // await this.sendRcMessage(inboundSipMessage, '17'); 55 | this.emit('INVITE', inboundSipMessage); 56 | } else if (inboundSipMessage.subject.startsWith('CANCEL ')) { 57 | // caller cancel 58 | await this.response(inboundSipMessage, 200); 59 | await this.response(inboundSipMessage, 487, { 60 | CSeq: inboundSipMessage.headers.CSeq.replace(/CANCEL/, 'INVITE'), 61 | }); 62 | this.emit('CANCEL', inboundSipMessage); 63 | } else if (inboundSipMessage.subject.startsWith('BYE ')) { 64 | // bye 65 | await this.response(inboundSipMessage, 200); 66 | this.emit('BYE', inboundSipMessage); 67 | } else if ( 68 | inboundSipMessage.subject.startsWith('MESSAGE ') && 69 | inboundSipMessage.body.includes(' Cmd="7"') 70 | ) { 71 | // server side: already processed 72 | await this.response(inboundSipMessage, 200); 73 | } 74 | } 75 | 76 | async sendRcMessage(inboundSipMessage: InboundSipMessage, reqid: string) { 77 | if (!inboundSipMessage.headers['P-rc']) { 78 | return; 79 | } 80 | const rcMessage = RcMessage.fromXml(inboundSipMessage.headers['P-rc']); 81 | const newRcMessage = new RcMessage( 82 | { 83 | SID: rcMessage.Hdr.SID, 84 | Req: rcMessage.Hdr.Req, 85 | From: rcMessage.Hdr.To, 86 | To: rcMessage.Hdr.From, 87 | Cmd: reqid, 88 | }, 89 | { 90 | Cln: this.sipInfo!.authorizationId, 91 | } 92 | ); 93 | const requestSipMessage = new RequestSipMessage( 94 | `MESSAGE sip:${newRcMessage.Hdr.To} SIP/2.0`, 95 | { 96 | Via: `SIP/2.0/WSS ${this.fakeDomain};branch=${branch()}`, 97 | To: ``, 98 | From: `;tag=${ 99 | this.fromTag 100 | }`, 101 | 'Call-ID': this.callId, 102 | 'Content-Type': 'x-rc/agent', 103 | }, 104 | newRcMessage.toXml() 105 | ); 106 | await this.send(requestSipMessage); 107 | } 108 | 109 | async send(sipMessage: RequestSipMessage | ResponseSipMessage) { 110 | return new Promise((resolve, reject) => { 111 | if (sipMessage.subject.startsWith('SIP/2.0 ')) { 112 | // response message, no waiting for response from server side 113 | this.ws!.send(sipMessage.toString()); 114 | resolve(undefined); 115 | return; 116 | } 117 | const responseHandler = (inboundSipMessage: InboundSipMessage) => { 118 | if (inboundSipMessage.headers.CSeq !== sipMessage.headers.CSeq) { 119 | return; // message not for this send 120 | } 121 | if ( 122 | inboundSipMessage.subject === 'SIP/2.0 100 Trying' || 123 | inboundSipMessage.subject === 'SIP/2.0 183 Session Progress' 124 | ) { 125 | return; // ignore 126 | } 127 | this.off('sipMessage', responseHandler); 128 | if ( 129 | inboundSipMessage.subject.startsWith('SIP/2.0 5') || 130 | inboundSipMessage.subject.startsWith('SIP/2.0 6') || 131 | inboundSipMessage.subject.startsWith('SIP/2.0 403') 132 | ) { 133 | reject(inboundSipMessage); 134 | return; 135 | } 136 | resolve(inboundSipMessage); 137 | }; 138 | this.on('sipMessage', responseHandler); 139 | this.ws!.send(sipMessage.toString()); 140 | }); 141 | } 142 | 143 | async response( 144 | inboundSipMessage: InboundSipMessage, 145 | responseCode: number, 146 | headers = {}, 147 | body = '' 148 | ) { 149 | await this.send( 150 | new ResponseSipMessage(inboundSipMessage, responseCode, headers, body) 151 | ); 152 | } 153 | 154 | async sipRegister(nonce?: string, reqMsg?: RequestSipMessage): Promise { 155 | let requestSipMessage: RequestSipMessage; 156 | if (!nonce && !reqMsg) { 157 | requestSipMessage = new RequestSipMessage( 158 | `REGISTER sip:${this.sipInfo!.domain} SIP/2.0`, 159 | { 160 | 'Call-ID': this.callId, 161 | Contact: `;expires=600`, 162 | From: `;tag=${ 163 | this.fromTag 164 | }`, 165 | To: ``, 166 | Via: `SIP/2.0/WSS ${this.fakeDomain};branch=${branch()}`, 167 | } 168 | ); 169 | } else { 170 | requestSipMessage = reqMsg!.fork(); 171 | requestSipMessage.headers.Authorization = generateAuthorization( 172 | this.sipInfo!, 173 | 'REGISTER', 174 | nonce! 175 | ); 176 | } 177 | const inboundSipMessage = await this.send(requestSipMessage); 178 | const wwwAuth = 179 | inboundSipMessage!.headers['Www-Authenticate'] || 180 | inboundSipMessage!.headers['WWW-Authenticate']; 181 | if (wwwAuth && wwwAuth.includes(', nonce="')) { 182 | // authorization required 183 | const nonce = wwwAuth.match(/, nonce="(.+?)"/)![1]; 184 | return this.sipRegister(nonce, requestSipMessage); 185 | } 186 | setTimeout(async () => { 187 | await this.sipRegister(nonce, requestSipMessage); 188 | }, 50000); // refresh session every 50 seconds 189 | } 190 | 191 | async register() { 192 | const json = await this.rc 193 | .restapi() 194 | .clientInfo() 195 | .sipProvision() 196 | .post({ 197 | sipInfo: [{transport: 'WSS'}], 198 | }); 199 | this.device = json.device; 200 | this.sipInfo = json.sipInfo![0] as SipInfo; 201 | this.ws = new WebSocket('wss://' + this.sipInfo!.outboundProxy, 'sip', { 202 | rejectUnauthorized: false, 203 | }); 204 | if (process.env.WEB_SOCKET_DEBUGGING === 'true') { 205 | enableWebSocketDebugging(this.ws!); 206 | } 207 | this.ws!.addEventListener('message', (e: any) => { 208 | const sipMessage = InboundSipMessage.fromString(e.data); 209 | this.emit('sipMessage', sipMessage); 210 | this.handleSipMessage(sipMessage); 211 | }); 212 | return new Promise((resolve, reject) => { 213 | const openHandler = async (e: any) => { 214 | this.ws!.removeEventListener('open', openHandler); 215 | await this.sipRegister(); 216 | resolve(true); 217 | }; 218 | this.ws!.addEventListener('open', openHandler); 219 | }); 220 | } 221 | 222 | async answer( 223 | inviteSipMessage: InboundSipMessage, 224 | inputAudioStream: any = undefined 225 | ) { 226 | const sdp = inviteSipMessage.body; 227 | const remoteRtcSd = new RTCSessionDescription({type: 'offer', sdp}); 228 | const peerConnection = new RTCPeerConnection({ 229 | iceServers: [{urls: 'stun:74.125.194.127:19302'}], 230 | }); 231 | if (process.env.WEB_RTC_DEBUGGING === 'true') { 232 | enableWebRtcDebugging(peerConnection); 233 | } 234 | peerConnection.addEventListener('track', (e: any) => { 235 | this.emit('track', e); 236 | }); 237 | peerConnection.setRemoteDescription(remoteRtcSd); 238 | if (inputAudioStream) { 239 | const track = inputAudioStream!.getAudioTracks()[0]; 240 | peerConnection.addTrack(track, inputAudioStream); 241 | } 242 | const localRtcSd = await peerConnection.createAnswer(); 243 | peerConnection.setLocalDescription(localRtcSd); 244 | await this.response( 245 | inviteSipMessage, 246 | 200, 247 | { 248 | Contact: ``, 249 | 'Content-Type': 'application/sdp', 250 | }, 251 | localRtcSd.sdp 252 | ); 253 | } 254 | 255 | async toVoicemail(inviteSipMessage: InboundSipMessage) { 256 | await this.sendRcMessage(inviteSipMessage, '11'); 257 | } 258 | 259 | async ignore(inviteSipMessage: InboundSipMessage) { 260 | await this.response(inviteSipMessage, 480); 261 | } 262 | 263 | async invite(callee: string, inputAudioStream: any = undefined) { 264 | this.newCallId(); 265 | const peerConnection = new RTCPeerConnection({ 266 | iceServers: [{urls: 'stun:74.125.194.127:19302'}], 267 | }); 268 | if (process.env.WEB_RTC_DEBUGGING === 'true') { 269 | enableWebRtcDebugging(peerConnection); 270 | } 271 | if (inputAudioStream) { 272 | const track = inputAudioStream!.getAudioTracks()[0]; 273 | peerConnection.addTrack(track, inputAudioStream); 274 | } 275 | const localRtcSd = await peerConnection.createOffer(); 276 | peerConnection.setLocalDescription(localRtcSd); 277 | const requestSipMessage = new RequestSipMessage( 278 | `INVITE sip:${callee}@${this.sipInfo!.domain} SIP/2.0`, 279 | { 280 | Via: `SIP/2.0/WSS ${this.fakeDomain};branch=${branch()}`, 281 | To: ``, 282 | From: `;tag=${ 283 | this.fromTag 284 | }`, 285 | 'Call-ID': this.callId, 286 | Contact: ``, 287 | 'Content-Type': 'application/sdp', 288 | }, 289 | localRtcSd.sdp 290 | ); 291 | let inboundSipMessage = await this.send(requestSipMessage); 292 | const wwwAuth = inboundSipMessage!.headers['Proxy-Authenticate']; 293 | if (wwwAuth && wwwAuth.includes(', nonce="')) { 294 | // authorization required 295 | let ackMessage = new RequestSipMessage( 296 | `ACK ${ 297 | inboundSipMessage!.headers.Contact.match(/<(.+?)>/)![1] 298 | } SIP/2.0`, 299 | { 300 | Via: `SIP/2.0/WSS ${this.fakeDomain};branch=${branch()}`, 301 | To: inboundSipMessage!.headers.To, 302 | From: inboundSipMessage!.headers.From, 303 | 'Call-ID': this.callId, 304 | } 305 | ); 306 | ackMessage.reuseCseq(); 307 | this.send(ackMessage); 308 | const nonce = wwwAuth.match(/, nonce="(.+?)"/)![1]; 309 | const newRequestSipMessage = requestSipMessage.fork(); 310 | newRequestSipMessage.headers['Proxy-Authorization'] = 311 | generateProxyAuthorization(this.sipInfo!, 'INVITE', callee, nonce); 312 | inboundSipMessage = await this.send(newRequestSipMessage); 313 | ackMessage = new RequestSipMessage( 314 | `ACK ${ 315 | inboundSipMessage!.headers.Contact.match(/<(.+?)>/)![1] 316 | } SIP/2.0`, 317 | { 318 | Via: `SIP/2.0/WSS ${this.fakeDomain};branch=${branch()}`, 319 | To: inboundSipMessage!.headers.To, 320 | From: inboundSipMessage!.headers.From, 321 | 'Call-ID': this.callId, 322 | } 323 | ); 324 | ackMessage.reuseCseq(); 325 | this.send(ackMessage); 326 | const remoteRtcSd = new RTCSessionDescription({ 327 | type: 'answer', 328 | sdp: inboundSipMessage!.body, 329 | }); 330 | peerConnection.addEventListener('track', (e: any) => { 331 | this.emit('track', e); 332 | }); 333 | peerConnection.setRemoteDescription(remoteRtcSd); 334 | } 335 | } 336 | } 337 | 338 | export default Softphone; 339 | -------------------------------------------------------------------------------- /src/rc-message/call-control-commands.ts: -------------------------------------------------------------------------------- 1 | const callControlCommands = { 2 | // one way messages from server to client 3 | ChangeMessage: 1, 4 | ServerFreeResources: 2, 5 | NewMsg: 3, 6 | ReLogin: 4, 7 | ChangePhones: 5, 8 | // Call control messages from server to client 9 | IncomingCall: 6, 10 | AlreadyProcessed: 7, 11 | ClientMinimize: 8, 12 | SessionClose: 9, 13 | // Call control messages from client to server 14 | ClientForward: 10, 15 | ClientVoiceMail: 11, 16 | ClientReject: 12, 17 | ClientStartReply: 13, 18 | ClientReply: 14, 19 | ClientNotProcessed: 15, 20 | ClientClosed: 16, 21 | ClientReceiveConfirm: 17, 22 | }; 23 | 24 | export const callActionNames = [ 25 | 'callhold', 26 | 'callunold', 27 | 'calltransfer', 28 | 'callpark', 29 | 'startcallrecord', 30 | 'stopcallrecord', 31 | 'callflip', 32 | ]; 33 | 34 | export default callControlCommands; 35 | -------------------------------------------------------------------------------- /src/rc-message/rc-message.ts: -------------------------------------------------------------------------------- 1 | import {DOMParser} from 'xmldom'; 2 | 3 | type HDR = { 4 | [key: string]: string | null; 5 | }; 6 | type BDY = { 7 | [key: string]: string | null; 8 | }; 9 | 10 | class RcMessage { 11 | Hdr: HDR; 12 | Bdy: BDY; 13 | [key: string]: HDR | BDY | Function; 14 | 15 | constructor(Hdr: HDR, Bdy: BDY) { 16 | this.Hdr = Hdr; 17 | this.Bdy = Bdy; 18 | } 19 | 20 | static fromXml(xmlStr: string) { 21 | if (xmlStr.startsWith('P-rc: ')) { 22 | xmlStr = xmlStr.substring(6); 23 | } 24 | const xmlDoc = new DOMParser().parseFromString(xmlStr, 'text/xml'); 25 | const rcMessage = new RcMessage({}, {}); 26 | for (const tag of ['Hdr', 'Bdy']) { 27 | rcMessage[tag] = {}; 28 | const element = xmlDoc.getElementsByTagName(tag)[0]; 29 | for (let i = 0; i < element.attributes.length; i++) { 30 | const attr = element.attributes[i]; 31 | (rcMessage[tag] as HDR | BDY)[attr.nodeName] = attr.nodeValue; 32 | } 33 | } 34 | return rcMessage; 35 | } 36 | 37 | toXml() { 38 | return ( 39 | '' + 40 | ['Hdr', 'Bdy'] 41 | .map( 42 | Tag => 43 | `<${Tag} ${Object.keys(this[Tag]) 44 | .map(Attr => `${Attr}="${(this[Tag] as HDR | BDY)[Attr]}"`) 45 | .join(' ')}/>` 46 | ) 47 | .join('') + 48 | '' 49 | ); 50 | } 51 | } 52 | 53 | export default RcMessage; 54 | -------------------------------------------------------------------------------- /src/rc-message/rc-requests.ts: -------------------------------------------------------------------------------- 1 | const rcRequests = { 2 | park: {reqid: 1, command: 'callpark'}, 3 | startRecord: {reqid: 2, command: 'startcallrecord'}, 4 | stopRecord: {reqid: 3, command: 'stopcallrecord'}, 5 | flip: {reqid: 3, command: 'callflip', target: ''}, 6 | monitor: {reqid: 4, command: 'monitor'}, 7 | barge: {reqid: 5, command: 'barge'}, 8 | whisper: {reqid: 6, command: 'whisper'}, 9 | takeover: {reqid: 7, command: 'takeover'}, 10 | toVoicemail: {reqid: 11, command: 'toVoicemail'}, 11 | ignore: {reqid: 12, command: 'ignore'}, 12 | receiveConfirm: {reqid: 17, command: 'receiveConfirm'}, 13 | replyWithMessage: {reqid: 14, command: 'replyWithMessage'}, 14 | }; 15 | 16 | export default rcRequests; 17 | -------------------------------------------------------------------------------- /src/sip-message/inbound/inbound-sip-message.ts: -------------------------------------------------------------------------------- 1 | import SipMessage from '../sip-message'; 2 | 3 | class InboundSipMessage extends SipMessage { 4 | static fromString(str: string) { 5 | const sipMessage = new SipMessage(); 6 | const [init, ...body] = str.split('\r\n\r\n'); 7 | sipMessage.body = body.join('\r\n\r\n'); 8 | const [subject, ...headers] = init.split('\r\n'); 9 | sipMessage.subject = subject; 10 | sipMessage.headers = Object.fromEntries( 11 | headers.map(line => line.split(': ')) 12 | ); 13 | return sipMessage; 14 | } 15 | } 16 | 17 | export default InboundSipMessage; 18 | -------------------------------------------------------------------------------- /src/sip-message/index.ts: -------------------------------------------------------------------------------- 1 | export {default as RequestSipMessage} from './outbound/request-sip-message'; 2 | export {default as ResponseSipMessage} from './outbound/response-sip-message'; 3 | export {default as InboundSipMessage} from './inbound/inbound-sip-message'; 4 | -------------------------------------------------------------------------------- /src/sip-message/outbound/outbound-sip-message.ts: -------------------------------------------------------------------------------- 1 | import SipMessage from '../sip-message'; 2 | import {version} from '../../../package.json'; 3 | 4 | class OutboundSipMessage extends SipMessage { 5 | constructor(subject = '', headers = {}, body = '') { 6 | super(subject, headers, body); 7 | this.headers['Content-Length'] = this.body.length.toString(); 8 | this.headers['User-Agent'] = `ringcentral-softphone-js/${version}`; 9 | } 10 | } 11 | 12 | export default OutboundSipMessage; 13 | -------------------------------------------------------------------------------- /src/sip-message/outbound/request-sip-message.ts: -------------------------------------------------------------------------------- 1 | import OutboundSipMessage from './outbound-sip-message'; 2 | import {branch} from '../../utils'; 3 | 4 | let cseq = Math.floor(Math.random() * 10000); 5 | 6 | class RequestSipMessage extends OutboundSipMessage { 7 | constructor(subject = '', headers = {}, body = '') { 8 | super(subject, headers, body); 9 | this.headers['Max-Forwards'] = '70'; 10 | this.newCseq(); 11 | } 12 | 13 | newCseq() { 14 | this.headers.CSeq = `${++cseq} ${this.subject.split(' ')[0]}`; 15 | } 16 | 17 | reuseCseq() { 18 | this.headers.CSeq = `${--cseq} ${this.subject.split(' ')[0]}`; 19 | } 20 | 21 | fork() { 22 | const newMessage = new RequestSipMessage( 23 | this.subject, 24 | {...this.headers}, 25 | this.body 26 | ); 27 | newMessage.newCseq(); 28 | if (newMessage.headers.Via) { 29 | newMessage.headers.Via = newMessage.headers.Via.replace( 30 | /;branch=z9hG4bK.+?$/, 31 | `;branch=${branch()}` 32 | ); 33 | } 34 | return newMessage; 35 | } 36 | } 37 | 38 | export default RequestSipMessage; 39 | -------------------------------------------------------------------------------- /src/sip-message/outbound/response-sip-message.ts: -------------------------------------------------------------------------------- 1 | import {v4 as uuid} from 'uuid'; 2 | 3 | import OutboundSipMessage from './outbound-sip-message'; 4 | import responseCodes from '../response-codes'; 5 | import InboundSipMessage from '../inbound/inbound-sip-message'; 6 | 7 | const toTag = uuid(); 8 | 9 | class ResponseSipMessage extends OutboundSipMessage { 10 | constructor( 11 | inboundSipMessage: InboundSipMessage, 12 | responseCode: number, 13 | headers = {}, 14 | body = '' 15 | ) { 16 | super(undefined, {...headers}, body); 17 | this.subject = `SIP/2.0 ${responseCode} ${responseCodes[responseCode]}`; 18 | for (const key of ['Via', 'From', 'Call-ID', 'CSeq']) { 19 | this.headers[key] = inboundSipMessage.headers[key]; 20 | } 21 | this.headers.To = `${inboundSipMessage.headers.To};tag=${toTag}`; 22 | this.headers.Supported = 'outbound'; 23 | this.headers = {...this.headers, ...headers}; // user provided headers override auto headers 24 | } 25 | } 26 | 27 | export default ResponseSipMessage; 28 | -------------------------------------------------------------------------------- /src/sip-message/response-codes.ts: -------------------------------------------------------------------------------- 1 | // Ref: https://en.wikipedia.org/wiki/List_of_SIP_response_codes' 2 | type ResponseCodes = { 3 | [key: number]: string; 4 | }; 5 | 6 | const responseCodes: ResponseCodes = { 7 | 100: 'Trying', 8 | 180: 'Ringing', 9 | 181: 'Call is Being Forwarded', 10 | 182: 'Queued', 11 | 183: 'Session Progress', 12 | 199: 'Early Dialog Terminated', 13 | 200: 'OK', 14 | 202: 'Accepted', 15 | 204: 'No Notification', 16 | 300: 'Multiple Choices', 17 | 301: 'Moved Permanently', 18 | 302: 'Moved Temporarily', 19 | 305: 'Use Proxy', 20 | 380: 'Alternative Service', 21 | 400: 'Bad Request', 22 | 401: 'Unauthorized', 23 | 402: 'Payment Required', 24 | 403: 'Forbidden', 25 | 404: 'Not Found', 26 | 405: 'Method Not Allowed', 27 | 406: 'Not Acceptable', 28 | 407: 'Proxy Authentication Required', 29 | 408: 'Request Timeout', 30 | 409: 'Conflict', 31 | 410: 'Gone', 32 | 411: 'Length Required', 33 | 412: 'Conditional Request Failed', 34 | 413: 'Request Entity Too Large', 35 | 414: 'Request-URI Too Long', 36 | 415: 'Unsupported Media Type', 37 | 416: 'Unsupported URI Scheme', 38 | 417: 'Unknown Resource-Priority', 39 | 420: 'Bad Extension', 40 | 421: 'Extension Required', 41 | 422: 'Session Interval Too Small', 42 | 423: 'Interval Too Brief', 43 | 424: 'Bad Location Information', 44 | 428: 'Use Identity Header', 45 | 429: 'Provide Referrer Identity', 46 | 433: 'Anonymity Disallowed', 47 | 436: 'Bad Identity-Info', 48 | 437: 'Unsupported Certificate', 49 | 438: 'Invalid Identity Header', 50 | 439: 'First Hop Lacks Outbound Support', 51 | 440: 'Max-Breadth Exceeded', 52 | 469: 'Bad Info Package', 53 | 470: 'Consent Needed', 54 | 480: 'Temporarily Unavailable', 55 | 481: 'Call/Transaction Does Not Exist', 56 | 482: 'Loop Detected', 57 | 483: 'Too Many Hops', 58 | 484: 'Address Incomplete', 59 | 485: 'Ambiguous', 60 | 486: 'Busy Here', 61 | 487: 'Request Terminated', 62 | 488: 'Not Acceptable Here', 63 | 489: 'Bad Event', 64 | 491: 'Request Pending', 65 | 493: 'Undecipherable', 66 | 494: 'Security Agreement Required', 67 | 500: 'Server Internal Error', 68 | 501: 'Not Implemented', 69 | 502: 'Bad Gateway', 70 | 503: 'Service Unavailable', 71 | 504: 'Server Time-out', 72 | 505: 'Version Not Supported', 73 | 513: 'Message Too Large', 74 | 580: 'Precondition Failure', 75 | 600: 'Busy Everywhere', 76 | 603: 'Decline', 77 | 604: 'Does Not Exist Anywhere', 78 | 606: 'Not Acceptable', 79 | 607: 'Unwanted', 80 | }; 81 | 82 | export default responseCodes; 83 | -------------------------------------------------------------------------------- /src/sip-message/sip-message.ts: -------------------------------------------------------------------------------- 1 | type Dict = { 2 | [key: string]: string; 3 | }; 4 | 5 | class SipMessage { 6 | subject: string; 7 | headers: Dict; 8 | body: string; 9 | 10 | constructor(subject = '', headers = {}, body = '') { 11 | this.subject = subject; 12 | this.headers = headers; 13 | this.body = body; 14 | } 15 | 16 | toString() { 17 | return [ 18 | this.subject, 19 | ...Object.keys(this.headers).map(key => `${key}: ${this.headers[key]}`), 20 | '', 21 | this.body, 22 | ].join('\r\n'); 23 | } 24 | } 25 | 26 | export default SipMessage; 27 | -------------------------------------------------------------------------------- /src/types/node-webrtc-media-devices/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'node-webrtc-media-devices' { 2 | const mediaDevices: any; 3 | export default mediaDevices; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/wrtc/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'wrtc' { 2 | const wrtc: any; 3 | export default wrtc; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/ws/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'ws' { 2 | const ws: any; 3 | export default ws; 4 | } 5 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import md5 from 'blueimp-md5'; 2 | import {v4 as uuid} from 'uuid'; 3 | import WebSocket from 'isomorphic-ws'; 4 | 5 | const generateResponse = ( 6 | username: string, 7 | password: string, 8 | realm: string, 9 | method: string, 10 | uri: string, 11 | nonce: string 12 | ) => { 13 | const ha1 = md5(username + ':' + realm + ':' + password); 14 | const ha2 = md5(method + ':' + uri); 15 | const response = md5(ha1 + ':' + nonce + ':' + ha2); 16 | return response; 17 | }; 18 | 19 | /* 20 | Sample input: 21 | const username = '802396666666' 22 | const password = 'xxxxxx' 23 | const realm = 'sip.ringcentral.com' 24 | const method = 'REGISTER' 25 | const nonce = 'yyyyyy' 26 | */ 27 | export type SipInfo = { 28 | authorizationId: string; 29 | password: string; 30 | domain: string; 31 | username: string; 32 | outboundProxy: string; 33 | }; 34 | export const generateAuthorization = ( 35 | sipInfo: SipInfo, 36 | method: string, 37 | nonce: string 38 | ) => { 39 | const {authorizationId: username, password, domain: realm} = sipInfo; 40 | return `Digest algorithm=MD5, username="${username}", realm="${realm}", nonce="${nonce}", uri="sip:${realm}", response="${generateResponse( 41 | username, 42 | password, 43 | realm, 44 | method, 45 | `sip:${realm}`, 46 | nonce 47 | )}"`; 48 | }; 49 | 50 | /* 51 | Sample output: 52 | Proxy-Authorization: Digest algorithm=MD5, username="802396666666", realm="sip.ringcentral.com", nonce="yyyyyyy", uri="sip:+16508888888@sip.ringcentral.com", response="zzzzzzzzz" 53 | */ 54 | export const generateProxyAuthorization = ( 55 | sipInfo: SipInfo, 56 | method: string, 57 | targetUser: string, 58 | nonce: string 59 | ) => { 60 | const {authorizationId: username, password, domain: realm} = sipInfo; 61 | return `Digest algorithm=MD5, username="${username}", realm="${realm}", nonce="${nonce}", uri="sip:${targetUser}@${realm}", response="${generateResponse( 62 | username, 63 | password, 64 | realm, 65 | method, 66 | `sip:${targetUser}@${realm}`, 67 | nonce 68 | )}"`; 69 | }; 70 | 71 | export const branch = () => 'z9hG4bK' + uuid(); 72 | 73 | export const enableWebSocketDebugging = (ws: WebSocket) => { 74 | ws.addEventListener('message', (e: any) => { 75 | console.log( 76 | `\n***** WebSocket Receive - ${new Date()} - ${Date.now()} *****` 77 | ); 78 | console.log(e.data); 79 | console.log('***** WebSocket Receive - end *****\n'); 80 | }); 81 | const send = ws.send.bind(ws); 82 | ws.send = (arg: any) => { 83 | console.log(`\n***** WebSocket Send - ${new Date()} - ${Date.now()} *****`); 84 | console.log(arg); 85 | console.log('***** WebSocket Send - end *****\n'); 86 | send(arg); 87 | }; 88 | }; 89 | 90 | export const enableWebRtcDebugging = (rtcPeerConnection: RTCPeerConnection) => { 91 | const eventNames = [ 92 | 'addstream', 93 | 'connectionstatechange', 94 | 'datachannel', 95 | 'icecandidate', 96 | 'iceconnectionstatechange', 97 | 'icegatheringstatechange', 98 | 'identityresult', 99 | 'negotiationneeded', 100 | 'removestream', 101 | 'signalingstatechange', 102 | 'track', 103 | ]; 104 | for (const eventName of eventNames) { 105 | rtcPeerConnection.addEventListener(eventName, (...args) => { 106 | console.log( 107 | `\n****** RTCPeerConnection "${eventName}" event - ${new Date()} - ${Date.now()} *****` 108 | ); 109 | console.log(...args); 110 | console.log( 111 | `****** RTCPeerConnection "${eventName}" event - end *****\n` 112 | ); 113 | }); 114 | } 115 | }; 116 | -------------------------------------------------------------------------------- /test/rc-message.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import RcMessage from '../src/rc-message/rc-message' 3 | 4 | describe('RcMessage', () => { 5 | test('fromXml and toXml', async () => { 6 | const xmlStr1 = 'P-rc: ' 7 | const rcMessage = RcMessage.fromXml(xmlStr1) 8 | expect(rcMessage.Hdr.From).toBe('#1336666@sip.ringcentral.com:6666') 9 | expect(rcMessage.Bdy.Nm).toBe('WIRELESS CALLER') 10 | const xmlStr2 = rcMessage.toXml() 11 | expect(xmlStr2).toBe(xmlStr1.substring(6)) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /test/sip-message/inbound-sip-message.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import InboundSipMessage from '../../src/sip-message/inbound/inbound-sip-message' 3 | 4 | describe('InboundSipMessage', () => { 5 | test('Trying', async () => { 6 | const tryingString = `SIP/2.0 100 Trying 7 | Via: SIP/2.0/WSS d798d849-0447-4b28-ad5e-008889cfaabc.invalid;branch=z9hG4bK34f246ae-c6ca-417f-8a73-3a518309b2be 8 | From: ;tag=9b77dd92-6a64-4e91-8831-b00f7ef9597c 9 | To: 10 | Call-ID: 3b556547-776e-4bed-967a-176b374f4de9 11 | CSeq: 8016 REGISTER 12 | Content-Length: 0 13 | 14 | `.split('\n').join('\r\n') 15 | const sipMessage = InboundSipMessage.fromString(tryingString) 16 | expect(sipMessage.subject).toBe('SIP/2.0 100 Trying') 17 | expect(sipMessage.headers).toEqual({ 18 | Via: 'SIP/2.0/WSS d798d849-0447-4b28-ad5e-008889cfaabc.invalid;branch=z9hG4bK34f246ae-c6ca-417f-8a73-3a518309b2be', 19 | From: ';tag=9b77dd92-6a64-4e91-8831-b00f7ef9597c', 20 | To: '', 21 | 'Call-ID': '3b556547-776e-4bed-967a-176b374f4de9', 22 | CSeq: '8016 REGISTER', 23 | 'Content-Length': '0' 24 | }) 25 | expect(sipMessage.body).toBe('') 26 | expect(tryingString).toBe(sipMessage.toString()) 27 | }) 28 | 29 | test('Unauthorized', async () => { 30 | const tryingString = `SIP/2.0 401 Unauthorized 31 | Via: SIP/2.0/WSS d798d849-0447-4b28-ad5e-008889cfaabc.invalid;branch=z9hG4bK34f246ae-c6ca-417f-8a73-3a518309b2be 32 | From: ;tag=9b77dd92-6a64-4e91-8831-b00f7ef9597c 33 | To: ;tag=45rk14 34 | Call-ID: 3b556547-776e-4bed-967a-176b374f4de9 35 | CSeq: 8016 REGISTER 36 | Content-Length: 0 37 | Www-Authenticate: Digest realm="sip.ringcentral.com", nonce="XXvrW1176i9sn4clcQVdEk6ezAYxEy/P" 38 | 39 | `.split('\n').join('\r\n') 40 | const sipMessage = InboundSipMessage.fromString(tryingString) 41 | 42 | expect(sipMessage.subject).toBe('SIP/2.0 401 Unauthorized') 43 | expect(sipMessage.headers).toEqual({ 44 | Via: 'SIP/2.0/WSS d798d849-0447-4b28-ad5e-008889cfaabc.invalid;branch=z9hG4bK34f246ae-c6ca-417f-8a73-3a518309b2be', 45 | From: ';tag=9b77dd92-6a64-4e91-8831-b00f7ef9597c', 46 | To: ';tag=45rk14', 47 | 'Call-ID': '3b556547-776e-4bed-967a-176b374f4de9', 48 | CSeq: '8016 REGISTER', 49 | 'Content-Length': '0', 50 | 'Www-Authenticate': 'Digest realm="sip.ringcentral.com", nonce="XXvrW1176i9sn4clcQVdEk6ezAYxEy/P"' 51 | }) 52 | expect(sipMessage.body).toBe('') 53 | 54 | expect(tryingString).toBe(sipMessage.toString()) 55 | }) 56 | 57 | test('OK', async () => { 58 | const tryingString = `SIP/2.0 200 OK 59 | Via: SIP/2.0/WSS a710736f-55c8-4a5b-8c6c-bae7993a4c0e.invalid;branch=z9hG4bK5354601a-c6f9-44e0-900b-dc33c2bb1194 60 | From: ;tag=4a103f65-ddb0-471f-8f12-8eac08c75aa9 61 | To: ;tag=1fMQdC 62 | Call-ID: 37a4a494-b220-4dc9-8171-80491a46226c 63 | CSeq: 709 REGISTER 64 | Content-Length: 0 65 | Contact: ;expires=47 66 | 67 | `.split('\n').join('\r\n') 68 | const sipMessage = InboundSipMessage.fromString(tryingString) 69 | expect(sipMessage.subject).toBe('SIP/2.0 200 OK') 70 | expect(sipMessage.headers).toEqual({ 71 | Via: 'SIP/2.0/WSS a710736f-55c8-4a5b-8c6c-bae7993a4c0e.invalid;branch=z9hG4bK5354601a-c6f9-44e0-900b-dc33c2bb1194', 72 | From: ';tag=4a103f65-ddb0-471f-8f12-8eac08c75aa9', 73 | To: ';tag=1fMQdC', 74 | 'Call-ID': '37a4a494-b220-4dc9-8171-80491a46226c', 75 | CSeq: '709 REGISTER', 76 | 'Content-Length': '0', 77 | Contact: ';expires=47' 78 | }) 79 | expect(sipMessage.body).toBe('') 80 | expect(tryingString).toBe(sipMessage.toString()) 81 | }) 82 | 83 | test('INVITE', async () => { 84 | const tryingString = `INVITE sip:4a4d40b5-247c-4330-bf09-2395bdf0337e@604faea0-3b32-476c-8271-9e58e1f96448.invalid;transport=ws SIP/2.0 85 | Via: SIP/2.0/WSS 104.245.57.165:8083;rport;branch=z9hG4bK305I2S-2qHqTu 86 | From: "WIRELESS CALLER" ;tag=10.13.22.241-5070-4b7881ea95b447 87 | To: "WIRELESS CALLER" 88 | Call-ID: 8677929a431a4244aa159ce3ec10bd92 89 | CSeq: 218449525 INVITE 90 | Max-Forwards: 67 91 | Content-Length: 873 92 | Contact: 93 | Content-Type: application/sdp 94 | User-Agent: RC_SIPWRP_22.241 95 | p-rc-api-ids: party-id=p-33f727da499c463aac3cf8294de576c0-2;session-id=s-33f727da499c463aac3cf8294de576c0 96 | p-rc-api-call-info: callAttributes=reject,send-vm 97 | P-rc: 98 | Call-Info: <201625796_120575908@10.22.66.84>;purpose=info 99 | 100 | v=0 101 | o=- 7066956197903366029 7997658033229634982 IN IP4 104.245.57.182 102 | s=SmcSip 103 | c=IN IP4 104.245.57.182 104 | t=0 0 105 | m=audio 29768 RTP/SAVPF 109 111 18 0 8 9 96 101 106 | a=rtpmap:109 OPUS/16000 107 | a=fmtp:109 useinbandfec=1 108 | a=rtcp-fb:109 ccm tmmbr 109 | a=rtpmap:111 OPUS/48000/2 110 | a=fmtp:111 useinbandfec=1 111 | a=rtcp-fb:111 ccm tmmbr 112 | a=rtpmap:18 g729/8000 113 | a=fmtp:18 annexb=no 114 | a=rtpmap:0 pcmu/8000 115 | a=rtpmap:8 pcma/8000 116 | a=rtpmap:9 g722/8000 117 | a=rtpmap:96 ilbc/8000 118 | a=fmtp:96 mode=20 119 | a=rtpmap:101 telephone-event/8000 120 | a=fmtp:101 0-15 121 | a=sendrecv 122 | a=rtcp:29769 123 | a=rtcp-mux 124 | a=setup:actpass 125 | a=fingerprint:sha-1 8E:C8:DB:21:CE:E5:F8:5A:F9:73:EC:08:B8:4D:5C:2A:96:BA:27:26 126 | a=ice-ufrag:mqlmHMAU 127 | a=ice-pwd:QTEEF1lt81VyKvJvEHhabIh9JZ 128 | a=candidate:Mjq5KtrBWhOTFixu 1 UDP 2130706431 104.245.57.182 29768 typ host 129 | a=candidate:Mjq5KtrBWhOTFixu 2 UDP 2130706430 104.245.57.182 29769 typ host 130 | `.split('\n').join('\r\n') 131 | const sipMessage = InboundSipMessage.fromString(tryingString) 132 | expect(sipMessage.subject).toBe('INVITE sip:4a4d40b5-247c-4330-bf09-2395bdf0337e@604faea0-3b32-476c-8271-9e58e1f96448.invalid;transport=ws SIP/2.0') 133 | expect(sipMessage.headers).toEqual({ 134 | Via: 'SIP/2.0/WSS 104.245.57.165:8083;rport;branch=z9hG4bK305I2S-2qHqTu', 135 | From: '"WIRELESS CALLER" ;tag=10.13.22.241-5070-4b7881ea95b447', 136 | To: '"WIRELESS CALLER" ', 137 | 'Call-ID': '8677929a431a4244aa159ce3ec10bd92', 138 | CSeq: '218449525 INVITE', 139 | 'Max-Forwards': '67', 140 | 'Content-Length': sipMessage.body.length + '', 141 | Contact: '', 142 | 'Content-Type': 'application/sdp', 143 | 'User-Agent': 'RC_SIPWRP_22.241', 144 | 'p-rc-api-ids': 'party-id=p-33f727da499c463aac3cf8294de576c0-2;session-id=s-33f727da499c463aac3cf8294de576c0', 145 | 'p-rc-api-call-info': 'callAttributes=reject,send-vm', 146 | 'P-rc': '', 147 | 'Call-Info': '<201625796_120575908@10.22.66.84>;purpose=info' 148 | }) 149 | expect(sipMessage.body).toBe(`v=0 150 | o=- 7066956197903366029 7997658033229634982 IN IP4 104.245.57.182 151 | s=SmcSip 152 | c=IN IP4 104.245.57.182 153 | t=0 0 154 | m=audio 29768 RTP/SAVPF 109 111 18 0 8 9 96 101 155 | a=rtpmap:109 OPUS/16000 156 | a=fmtp:109 useinbandfec=1 157 | a=rtcp-fb:109 ccm tmmbr 158 | a=rtpmap:111 OPUS/48000/2 159 | a=fmtp:111 useinbandfec=1 160 | a=rtcp-fb:111 ccm tmmbr 161 | a=rtpmap:18 g729/8000 162 | a=fmtp:18 annexb=no 163 | a=rtpmap:0 pcmu/8000 164 | a=rtpmap:8 pcma/8000 165 | a=rtpmap:9 g722/8000 166 | a=rtpmap:96 ilbc/8000 167 | a=fmtp:96 mode=20 168 | a=rtpmap:101 telephone-event/8000 169 | a=fmtp:101 0-15 170 | a=sendrecv 171 | a=rtcp:29769 172 | a=rtcp-mux 173 | a=setup:actpass 174 | a=fingerprint:sha-1 8E:C8:DB:21:CE:E5:F8:5A:F9:73:EC:08:B8:4D:5C:2A:96:BA:27:26 175 | a=ice-ufrag:mqlmHMAU 176 | a=ice-pwd:QTEEF1lt81VyKvJvEHhabIh9JZ 177 | a=candidate:Mjq5KtrBWhOTFixu 1 UDP 2130706431 104.245.57.182 29768 typ host 178 | a=candidate:Mjq5KtrBWhOTFixu 2 UDP 2130706430 104.245.57.182 29769 typ host 179 | `.split('\n').join('\r\n')) 180 | expect(tryingString).toBe(sipMessage.toString()) 181 | }) 182 | 183 | test('MESSAGE', async () => { 184 | const tryingString = `MESSAGE sip:f234d4ad-aa84-4953-b7fe-475829102b56@f653434b-7cfa-4bc4-ba5a-52fb637395d1.invalid;transport=ws SIP/2.0 185 | Via: SIP/2.0/WSS 104.245.57.165:8083;rport;branch=z9hG4bK3DmKRO-atQnvs 186 | From: ;tag=93c4a86e6fad4fb4a5f10b698d35b1c6 187 | To: 188 | Call-ID: 0037695cd4ec46dcb6da8e59edc1cfa9 189 | CSeq: 218346883 MESSAGE 190 | Max-Forwards: 67 191 | Content-Length: 200 192 | Content-Type: x-rc/agent 193 | 194 | `.split('\n').join('\r\n') 195 | const sipMessage = InboundSipMessage.fromString(tryingString) 196 | expect(sipMessage.subject).toBe('MESSAGE sip:f234d4ad-aa84-4953-b7fe-475829102b56@f653434b-7cfa-4bc4-ba5a-52fb637395d1.invalid;transport=ws SIP/2.0') 197 | expect(sipMessage.headers).toEqual({ 198 | Via: 'SIP/2.0/WSS 104.245.57.165:8083;rport;branch=z9hG4bK3DmKRO-atQnvs', 199 | From: ';tag=93c4a86e6fad4fb4a5f10b698d35b1c6', 200 | To: '', 201 | 'Call-ID': '0037695cd4ec46dcb6da8e59edc1cfa9', 202 | CSeq: '218346883 MESSAGE', 203 | 'Max-Forwards': '67', 204 | 'Content-Length': sipMessage.body.length + '', 205 | 'Content-Type': 'x-rc/agent' 206 | }) 207 | expect(sipMessage.body).toBe('') 208 | expect(tryingString).toBe(sipMessage.toString()) 209 | }) 210 | }) 211 | -------------------------------------------------------------------------------- /test/sip-message/request-sip-message.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import * as R from 'ramda' 3 | 4 | import RequestSipMessage from '../../src/sip-message/outbound/request-sip-message' 5 | import { version } from '../../package.json' 6 | 7 | describe('RequestSipMessage', () => { 8 | test('Register', async () => { 9 | const requestSipMessage = new RequestSipMessage('REGISTER sip:sip.ringcentral.com SIP/2.0', { 10 | 'Call-ID': '9968cdcb-70a2-4275-9cd2-f50d42343b6e', 11 | Contact: ';expires=600', 12 | From: ';tag=ab6de166-c075-4fc8-9f83-1129199d1b25', 13 | To: '', 14 | Via: 'SIP/2.0/WSS f00b012e-4b95-45dc-a530-27e04537b158.invalid;branch=z9hG4bKf91a2558-5eb4-4e6a-a8db-d23f2b8d59a3' 15 | }) 16 | expect(requestSipMessage.subject).toBe('REGISTER sip:sip.ringcentral.com SIP/2.0') 17 | expect(R.dissoc('CSeq', requestSipMessage.headers)).toEqual({ 18 | 'User-Agent': `ringcentral-softphone-js/${version}`, 19 | 'Max-Forwards': 70, 20 | Via: 'SIP/2.0/WSS f00b012e-4b95-45dc-a530-27e04537b158.invalid;branch=z9hG4bKf91a2558-5eb4-4e6a-a8db-d23f2b8d59a3', 21 | From: ';tag=ab6de166-c075-4fc8-9f83-1129199d1b25', 22 | To: '', 23 | 'Call-ID': '9968cdcb-70a2-4275-9cd2-f50d42343b6e', 24 | Contact: ';expires=600', 25 | 'Content-Length': 0 26 | }) 27 | expect(requestSipMessage.body).toBe('') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /test/sip-message/response-sip-message.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import * as R from 'ramda' 3 | 4 | import ResponseSipMessage from '../../src/sip-message/outbound/response-sip-message' 5 | import InboundSipMessage from '../../src/sip-message/inbound/inbound-sip-message' 6 | import { version } from '../../package.json' 7 | 8 | const inboundInviteMessage = InboundSipMessage.fromString(`INVITE sip:db228b2d-10a2-4a61-aeb1-3e5697c4348e@f00b012e-4b95-45dc-a530-27e04537b158.invalid;transport=ws SIP/2.0 9 | Via: SIP/2.0/WSS 104.245.57.165:8083;rport;branch=z9hG4bK1HlzGT-2WxVW2 10 | From: "WIRELESS CALLER" ;tag=10.13.22.242-5070-26c2b3fce8a242 11 | To: "WIRELESS CALLER" 12 | Call-ID: 7661f03e2b374012b8cfe8e7f1442261 13 | CSeq: 218658393 INVITE 14 | Max-Forwards: 67 15 | Content-Length: 873 16 | Contact: 17 | Content-Type: application/sdp 18 | User-Agent: RC_SIPWRP_22.242 19 | p-rc-api-ids: party-id=p-e9d16ea3dabd47f59e40b1f92d8515f3-2;session-id=s-e9d16ea3dabd47f59e40b1f92d8515f3 20 | p-rc-api-call-info: callAttributes=reject,send-vm 21 | P-rc: 22 | Call-Info: <906531240_133538243@10.13.116.50>;purpose=info 23 | 24 | v=0 25 | o=- 7028228953198384563 8403357933122626934 IN IP4 104.245.57.182 26 | s=SmcSip 27 | c=IN IP4 104.245.57.182 28 | t=0 0 29 | m=audio 37216 RTP/SAVPF 109 111 18 0 8 9 96 101 30 | a=rtpmap:109 OPUS/16000 31 | a=fmtp:109 useinbandfec=1 32 | a=rtcp-fb:109 ccm tmmbr 33 | a=rtpmap:111 OPUS/48000/2 34 | a=fmtp:111 useinbandfec=1 35 | a=rtcp-fb:111 ccm tmmbr 36 | a=rtpmap:18 g729/8000 37 | a=fmtp:18 annexb=no 38 | a=rtpmap:0 pcmu/8000 39 | a=rtpmap:8 pcma/8000 40 | a=rtpmap:9 g722/8000 41 | a=rtpmap:96 ilbc/8000 42 | a=fmtp:96 mode=20 43 | a=rtpmap:101 telephone-event/8000 44 | a=fmtp:101 0-15 45 | a=sendrecv 46 | a=rtcp:37217 47 | a=rtcp-mux 48 | a=setup:actpass 49 | a=fingerprint:sha-1 46:81:F7:FD:FB:15:4E:75:EC:6A:D1:4C:69:DA:21:EB:B3:B3:52:16 50 | a=ice-ufrag:r7cg0L7k 51 | a=ice-pwd:zUgXquxBbhEansWDjaVyQA3rmW 52 | a=candidate:mQ0FXtgcx3Jp54R9 1 UDP 2130706431 104.245.57.182 37216 typ host 53 | a=candidate:mQ0FXtgcx3Jp54R9 2 UDP 2130706430 104.245.57.182 37217 typ host 54 | `.split('\n').join('\r\n')) 55 | 56 | describe('ResponseSipMessage', () => { 57 | test('Trying', async () => { 58 | const sipMessage = new ResponseSipMessage(inboundInviteMessage, 100) 59 | expect(sipMessage.subject).toBe('SIP/2.0 100 Trying') 60 | expect(R.dissoc('To', sipMessage.headers)).toEqual({ 61 | 'User-Agent': `ringcentral-softphone-js/${version}`, 62 | Supported: 'outbound', 63 | CSeq: '218658393 INVITE', 64 | 'Call-ID': '7661f03e2b374012b8cfe8e7f1442261', 65 | From: '"WIRELESS CALLER" ;tag=10.13.22.242-5070-26c2b3fce8a242', 66 | Via: 'SIP/2.0/WSS 104.245.57.165:8083;rport;branch=z9hG4bK1HlzGT-2WxVW2', 67 | 'Content-Length': 0 68 | }) 69 | }) 70 | 71 | test('Ringing', async () => { 72 | const sipMessage = new ResponseSipMessage(inboundInviteMessage, 180, { 73 | Contact: '' 74 | }) 75 | expect(sipMessage.subject).toBe('SIP/2.0 180 Ringing') 76 | expect(R.dissoc('To', sipMessage.headers)).toEqual({ 77 | 'User-Agent': `ringcentral-softphone-js/${version}`, 78 | Supported: 'outbound', 79 | CSeq: '218658393 INVITE', 80 | 'Call-ID': '7661f03e2b374012b8cfe8e7f1442261', 81 | From: '"WIRELESS CALLER" ;tag=10.13.22.242-5070-26c2b3fce8a242', 82 | Via: 'SIP/2.0/WSS 104.245.57.165:8083;rport;branch=z9hG4bK1HlzGT-2WxVW2', 83 | Contact: '', 84 | 'Content-Length': 0 85 | }) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/tsconfig-google.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "esModuleInterop": true, 6 | "typeRoots": [ 7 | "src/@types", 8 | "node_modules/@types" 9 | ], 10 | "lib": [ 11 | "ES2019" 12 | ], 13 | "resolveJsonModule": true 14 | }, 15 | "include": [ 16 | "src/**/*.ts", 17 | "demos/**/*.ts" 18 | ] 19 | } -------------------------------------------------------------------------------- /webpack.config.babel.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-unpublished-import */ 2 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 3 | import {HotModuleReplacementPlugin, DefinePlugin} from 'webpack'; 4 | import dotenv from 'dotenv-override-true'; 5 | 6 | const config = { 7 | entry: { 8 | index: './demos/browser/index.ts', 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.ts$/, 14 | loader: 'ts-loader', 15 | }, 16 | ], 17 | }, 18 | plugins: [ 19 | new HtmlWebpackPlugin({ 20 | template: 'demos/browser/index.html', 21 | }), 22 | new HotModuleReplacementPlugin(), 23 | new DefinePlugin({ 24 | 'process.env': JSON.stringify(dotenv.config().parsed), 25 | }), 26 | ], 27 | }; 28 | 29 | export default [config]; 30 | --------------------------------------------------------------------------------