├── .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 |
--------------------------------------------------------------------------------