├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .jshintrc ├── LICENSE ├── README.md ├── conf.json ├── config └── test.json ├── data └── nullsdp.js ├── examples ├── record-session │ └── app.js └── record │ └── app.js ├── lib ├── conference.js ├── endpoint.js ├── mediaserver.js ├── mrf.js └── utils.js ├── package-lock.json ├── package.json └── test ├── cafile.pem ├── conference.js ├── docker-compose-testbed.yaml ├── docker_start.js ├── docker_stop.js ├── endpoint.js ├── index.js ├── mediaserver.js ├── recordings └── .keep ├── scripts └── call-generator.js ├── sounds └── en │ └── us │ └── callie │ ├── endpoint_record.wav │ ├── endpoint_record2.wav │ └── voicemail │ ├── 8000 │ └── vm-record_message.wav │ └── 16000 │ └── vm-record_message.wav └── tls ├── chain.pem ├── dh1024.pem ├── server.crt └── server.key /.eslintignore: -------------------------------------------------------------------------------- 1 | test/* 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "parserOptions": { 7 | "ecmaFeatures": { 8 | "jsx": false, 9 | "modules": false 10 | }, 11 | "ecmaVersion": 2018 12 | }, 13 | "plugins": ["promise"], 14 | "rules": { 15 | "promise/always-return": "error", 16 | "promise/no-return-wrap": "error", 17 | "promise/param-names": "error", 18 | "promise/catch-or-return": "error", 19 | "promise/no-native": "off", 20 | "promise/no-nesting": "warn", 21 | "promise/no-promise-in-callback": "warn", 22 | "promise/no-callback-in-promise": "warn", 23 | "promise/no-return-in-finally": "warn", 24 | 25 | // Possible Errors 26 | // http://eslint.org/docs/rules/#possible-errors 27 | "comma-dangle": [2, "only-multiline"], 28 | "no-control-regex": 2, 29 | "no-debugger": 2, 30 | "no-dupe-args": 2, 31 | "no-dupe-keys": 2, 32 | "no-duplicate-case": 2, 33 | "no-empty-character-class": 2, 34 | "no-ex-assign": 2, 35 | "no-extra-boolean-cast" : 2, 36 | "no-extra-parens": [2, "functions"], 37 | "no-extra-semi": 2, 38 | "no-func-assign": 2, 39 | "no-invalid-regexp": 2, 40 | "no-irregular-whitespace": 2, 41 | "no-negated-in-lhs": 2, 42 | "no-obj-calls": 2, 43 | "no-proto": 2, 44 | "no-unexpected-multiline": 2, 45 | "no-unreachable": 2, 46 | "use-isnan": 2, 47 | "valid-typeof": 2, 48 | 49 | // Best Practices 50 | // http://eslint.org/docs/rules/#best-practices 51 | "no-fallthrough": 2, 52 | "no-octal": 2, 53 | "no-redeclare": 2, 54 | "no-self-assign": 2, 55 | "no-unused-labels": 2, 56 | 57 | // Strict Mode 58 | // http://eslint.org/docs/rules/#strict-mode 59 | "strict": [2, "never"], 60 | 61 | // Variables 62 | // http://eslint.org/docs/rules/#variables 63 | "no-delete-var": 2, 64 | "no-undef": 2, 65 | "no-unused-vars": [2, {"args": "none"}], 66 | 67 | // Node.js and CommonJS 68 | // http://eslint.org/docs/rules/#nodejs-and-commonjs 69 | "no-mixed-requires": 2, 70 | "no-new-require": 2, 71 | "no-path-concat": 2, 72 | "no-restricted-modules": [2, "sys", "_linklist"], 73 | 74 | // Stylistic Issues 75 | // http://eslint.org/docs/rules/#stylistic-issues 76 | "comma-spacing": 2, 77 | "eol-last": 2, 78 | "indent": [2, 2, {"SwitchCase": 1}], 79 | "keyword-spacing": 2, 80 | "max-len": [2, 120, 2], 81 | "new-parens": 2, 82 | "no-mixed-spaces-and-tabs": 2, 83 | "no-multiple-empty-lines": [2, {"max": 2}], 84 | "no-trailing-spaces": [2, {"skipBlankLines": false }], 85 | "quotes": [2, "single", "avoid-escape"], 86 | "semi": 2, 87 | "space-before-blocks": [2, "always"], 88 | "space-before-function-paren": [2, "never"], 89 | "space-in-parens": [2, "never"], 90 | "space-infix-ops": 2, 91 | "space-unary-ops": 2, 92 | 93 | // ECMAScript 6 94 | // http://eslint.org/docs/rules/#ecmascript-6 95 | "arrow-parens": [2, "always"], 96 | "arrow-spacing": [2, {"before": true, "after": true}], 97 | "constructor-super": 2, 98 | "no-class-assign": 2, 99 | "no-confusing-arrow": 2, 100 | "no-const-assign": 2, 101 | "no-dupe-class-members": 2, 102 | "no-new-symbol": 2, 103 | "no-this-before-super": 2, 104 | "prefer-const": 2 105 | }, 106 | "globals": { 107 | "DTRACE_HTTP_CLIENT_REQUEST" : false, 108 | "LTTNG_HTTP_CLIENT_REQUEST" : false, 109 | "COUNTER_HTTP_CLIENT_REQUEST" : false, 110 | "DTRACE_HTTP_CLIENT_RESPONSE" : false, 111 | "LTTNG_HTTP_CLIENT_RESPONSE" : false, 112 | "COUNTER_HTTP_CLIENT_RESPONSE" : false, 113 | "DTRACE_HTTP_SERVER_REQUEST" : false, 114 | "LTTNG_HTTP_SERVER_REQUEST" : false, 115 | "COUNTER_HTTP_SERVER_REQUEST" : false, 116 | "DTRACE_HTTP_SERVER_RESPONSE" : false, 117 | "LTTNG_HTTP_SERVER_RESPONSE" : false, 118 | "COUNTER_HTTP_SERVER_RESPONSE" : false, 119 | "DTRACE_NET_STREAM_END" : false, 120 | "LTTNG_NET_STREAM_END" : false, 121 | "COUNTER_NET_SERVER_CONNECTION_CLOSE" : false, 122 | "DTRACE_NET_SERVER_CONNECTION" : false, 123 | "LTTNG_NET_SERVER_CONNECTION" : false, 124 | "COUNTER_NET_SERVER_CONNECTION" : false 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version: 20.x 13 | - run: npm install 14 | - run: npm run jslint 15 | - name: Install Docker Compose 16 | run: | 17 | sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose 18 | sudo chmod +x /usr/local/bin/docker-compose 19 | docker-compose --version 20 | - run: npm test 21 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: npm-publish 2 | 3 | # run when a tag is pushed or kick off manually 4 | on: 5 | push: 6 | tags: 7 | - '*' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: lts/* 19 | registry-url: 'https://registry.npmjs.org' 20 | - run: npm install 21 | - run: npm publish --access public 22 | env: 23 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # github pages site 11 | _site 12 | 13 | #transient test cases 14 | examples/nosave.*.js 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | #jsdoc 23 | out 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directory 32 | # Deployed apps should consider commenting this line out: 33 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 34 | node_modules 35 | 36 | test/recordings/*.wav 37 | test/recordings/*.raw 38 | .DS_Store 39 | 40 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "curly": true, 3 | "eqeqeq": true, 4 | "immed": true, 5 | "latedef": "nofunc", 6 | "newcap": true, 7 | "noarg": true, 8 | "sub": true, 9 | "undef": true, 10 | "unused": true, 11 | "boss": true, 12 | "eqnull": true, 13 | "node": true, 14 | "esversion": 6 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Dave Horton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # drachtio-fsmrf ![Build Status](https://github.com/drachtio/drachtio-fsmrf/workflows/CI/badge.svg) 2 | 3 | 4 | [![drachtio logo](http://drachtio.github.io/drachtio-srf/img/definition-only-cropped.png)](http://drachtio.github.io/drachtio-srf) 5 | 6 | Welcome to the Drachtio Media Resource framework, a partner module to [drachtio-srf](http://drachtio.github.io/drachtio-srf) for building high-performance [SIP](https://www.ietf.org/rfc/rfc3261.txt) server applications in pure javascript. 7 | 8 | drachtio-fsmrf implements common media server functions on top of Freeswitch and enables rich media applications involving IVR, conferencing and other features to be built in pure javascript without requiring in-depth knowledge of freeswitch configuration. 9 | 10 | **Note**, drachtio-fsmrf applications require a freeswitch media server, configured as per [this ansible role](https://github.com/drachtio/ansible-role-fsmrf). 11 | 12 | [API documentation for drachtio-fsmrf can be found here](http://drachtio.github.io/drachtio-fsmrf/api/index.html). 13 | 14 | # Data Model 15 | This module exports a single class, **Mrf** (aka Media Resource Framework). 16 | 17 | Invoking the constructor creates an instance of the Mrf class that can then be used to connect to **Mediaservers**; once connected to a Mediaserver you can create and manipulate instances of **Endpoints** and **Conferences**. 18 | 19 | That's it -- those are all the classes you need to work with. You can connect calls to a Mediaserver, producing an Endpoint. You can then perform operations like *play*, *say*, *bridge*, *park* etc on the Endpoint (which equates to a Freeswitch channel). You can create Conferences, join Endpoints into Conferences, and perform operations on the Conference. And you can call any of the myriad freeswitch applications or api methods via the Endpoint and Conference classes. 20 | 21 | Let's dive in. 22 | 23 | # Getting Started 24 | First, create an instance of both the drachtio signaling resource framework and the media resource framework, as per below. 25 | 26 | ```js 27 | const Srf = require('drachtio-srf'); 28 | const Mrf = require('drachtio-fsmrf'); 29 | 30 | const srf = new Srf() ; 31 | srf.connect(host: '127.0.0.1'); 32 | 33 | srf.on('connect', (err, hostport) => { 34 | console.log(`successfully connected to drachtio listening on ${hostport}`); 35 | }); 36 | 37 | const mrf = new Mrf(srf) ; 38 | ``` 39 | At that point, the mrf instance can be used to connect to and produce instances of MediaServers 40 | ```js 41 | mrf.connect({address: '127.0.0.1', port: 8021, secret: 'ClueCon'}) 42 | .then((mediaserver) => { 43 | console.log('successfully connected to mediaserver'); 44 | }) 45 | .catch ((err) => { 46 | console.error(`error connecting to mediaserver: ${err}`); 47 | }); 48 | ``` 49 | In the example above, we see the `mrf#connect` method returns a Promise that resolves with an instance of the media server. As with all public methods, a callback variant is available as well: 50 | ```js 51 | // we're connecting to the Freeswitch event socket 52 | mrf.connect({address: '127.0.0.1', port: 8021, secret: 'ClueCon'}, (err, mediaserver) => { 53 | if (err) { 54 | return console.log(`error connecting to mediaserver: ${err}`); 55 | } 56 | console.log(`connected to mediaserver listening on ${JSON.stringify(ms.sip)}`); 57 | /* 58 | { 59 | "ipv4": { 60 | "udp": { 61 | "address":"172.28.0.11:5060" 62 | }, 63 | "dtls": { 64 | "address":"172.28.0.11:5081" 65 | } 66 | }, 67 | "ipv6":{ 68 | "udp":{}, 69 | "dtls":{} 70 | } 71 | } 72 | */ 73 | } 74 | }); 75 | ``` 76 | Having a media server instance, we can now create instances of Endpoints and Conferences and invoke operations on those objects. 77 | 78 | # Performing Media Operations 79 | 80 | We can create an Endpoint when we have an incoming call, by connecting it to a Mediaserver. 81 | ```js 82 | srf.invite((req, res) => { 83 | mediaserver.connectCaller(req, res) 84 | .then(({endpoint, dialog}) => { 85 | console.log('successfully connected call to media server'); 86 | 87 | ``` 88 | In the example above, we use `MediaServer#connectCaller()` to connect a call to a Mediaserver, producing both an Endpoint (represening the channel on Freeswitch) and a Dialog (representing the UAS dialog). 89 | 90 | Again, note that a callback version is also available: 91 | ```js 92 | srf.invite((req, res) => { 93 | mediaserver.connectCaller(req, res, (err, {endpoint, dialog} => { 94 | if (err) return console.log(`Error connecting ${err}`); 95 | console.log('successfully connected call to media server'); 96 | }); 97 | 98 | ``` 99 | We can also create an Endpoint outside of any inbound call by calling `MediaServer#createEndpoint()`. This will give us an initially inactive Endpoint that we can later modify to stream to a remote destination: 100 | ```js 101 | mediaserver.createEndpoint() 102 | .then((endpoint) => { 103 | 104 | // some time later... 105 | endpoint.modify(remoteSdp); 106 | 107 | }); 108 | 109 | ``` 110 | Once we have an Endpoint, we can do things like play a prompt and collect dtmf: 111 | ```js 112 | endpoint.playCollect({file: myFile, min: 1, max: 4}) 113 | .then((obj) => { 114 | console.log(`collected digits: ${obj.digits}`); 115 | }); 116 | ``` 117 | Conferences work similarly - we create them and then can join Endpoints to them. 118 | ```js 119 | mediaserver.createConference('my_conf', {maxMembers: 50}) 120 | .then((conference) => { 121 | return endpoint.join(conference) 122 | }) 123 | .then(() => { 124 | console.log('endpoint joined to conference') 125 | }); 126 | ``` 127 | When an Endpoint is joined to a Conference, we have an additional set of operations we can invoke on the Endpoint -- things like mute/unmute, turn on or off automatic gain control, playing a file directly to the participant on that Endpoint, etc. These actions are performed by methods that all begin with *conf*: 128 | ```js 129 | endpoint.join(conference, (err) => { 130 | if (err) return console.log(`Error ${err}`); 131 | 132 | endpoint.confMute(); 133 | endpoint.confPlay(myFile); 134 | } 135 | ``` 136 | 137 | # Execute any Freeswitch application or api 138 | As shown above, some methods have been added to the `Endpoint` and `Conference` class to provide syntactic sugar over freeswitch aplications and apis. However, any Freeswitch application or api can also be called directly. 139 | 140 | `Endpoint#execute` executes a Freeswitch application and returns in either the callback or the Prompise the contents of the associated CHANNEL_EXECUTE_COMPLETE event that Freeswitch returns. The event structure [is defined here](https://github.com/englercj/node-esl/blob/master/lib/esl/Event.js): 141 | 142 | ```js 143 | // generate dtmf from an Endpoint 144 | endpoint.execute('send_dtmf', `${digits}@125`, (err, evt) => { 145 | if (err) return console.error(err); 146 | 147 | console.log(`last dtmf duration was ${evt.getHeader('variable_last_dtmf_duration')}`); 148 | }) 149 | ``` 150 | `Endpoint#api` executes a Freeswitch api call and returns in either the callback or the Promise the response that Freeswitch returns to the command. 151 | ```js 152 | endpoint.api('uuid_dump', endpoint.uuid) 153 | .then((response) => { 154 | console.log(`${JSON.stringify(response)}`); 155 | // 156 | // { 157 | // "headers": [{ 158 | // "name": "Content-Type", 159 | // "value": "api/response" 160 | // }, { 161 | // "name": "Content-Length", 162 | // "value": 8475 163 | // }], 164 | // "hPtr": null, 165 | // "body": "Event-Name: CHANNEL_DATA\n.. 166 | 167 | }); 168 | ``` 169 | Note that Content-Type api/response returned by api requests return a body consisting of plain text separated by newlines. To parse this body into a plain javascript object with named properties, use the `Mrf#utils#parseBodyText` method, as per below: 170 | ```js 171 | endpoint.api('uuid_dump', endpoint.uuid) 172 | .then((evt) => { 173 | const vars = Mrf.utils.parseBodyText(evt.getBody()); 174 | console.log(`${JSON.stringify(vars)}`); 175 | // { 176 | // "Event-Name": "CHANNEL_DATA", 177 | // "Core-UUID": "de006bc8-f892-11e7-a989-3b397b4b8083", 178 | // ... 179 | // } 180 | }); 181 | ``` 182 | # Tests 183 | To run tests you will need Docker and docker-compose installed on your host, as the test suite runs in a docker network created by [docker-compose-testbed.yaml](test/docker-compose-testbed.yaml). The first time you run the tests, it will take a while since docker images will be downloaded to your host. 184 | ```js 185 | $ npm test 186 | 187 | starting docker network.. 188 | 189 | docker network started, giving extra time for freeswitch to initialize... 190 | 191 | Mrf#connect using Promise 192 | 193 | ✔ mrf.localAddresses is an array 194 | ✔ socket connected 195 | ✔ mediaserver.srf is an Srf 196 | 197 | ...etc... 198 | ``` 199 | # License 200 | [MIT](https://github.com/drachtio/drachtio-fsmrf/blob/master/LICENSE) 201 | -------------------------------------------------------------------------------- /conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "tags": { 3 | "allowUnknownTags": true, 4 | "dictionaries": ["jsdoc"] 5 | }, 6 | "source": { 7 | "includePattern": ".+\\.js(doc)?$", 8 | "excludePattern": "(^|\\/|\\\\)_" 9 | }, 10 | "plugins": [ 11 | "plugins/markdown" 12 | ], 13 | "templates": { 14 | "cleverLinks": false, 15 | "monospaceLinks": false 16 | }, 17 | "opts": { 18 | "recurse": true, 19 | "template": "node_modules/minami" 20 | } 21 | } -------------------------------------------------------------------------------- /config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "logger": { 3 | "level": "fatal" 4 | }, 5 | "drachtio-uac": { 6 | "host": "127.0.0.1", 7 | "port": 9060, 8 | "secret": "cymru" 9 | }, 10 | "freeswitch-uac": { 11 | "address": "127.0.0.1", 12 | "port": 9070, 13 | "secret": "ClueCon" 14 | }, 15 | "drachtio-sut": { 16 | "host": "127.0.0.1", 17 | "port": 9061, 18 | "secret": "cymru" 19 | }, 20 | "freeswitch-sut": { 21 | "address": "127.0.0.1", 22 | "port": 9071, 23 | "secret": "ClueCon" 24 | }, 25 | "freeswitch-uac-fail": { 26 | "address": "127.0.0.1", 27 | "port": 11070, 28 | "secret": "ClueCon" 29 | }, 30 | "freeswitch-custom-profile-uac": { 31 | "address": "127.0.0.1", 32 | "port": 9081, 33 | "secret": "ClueCon", 34 | "profile": "custom_drachtio_mrf" 35 | }, 36 | "call-generator": { 37 | "uri": "sip:drachtio-sut", 38 | "drachtio": { 39 | "host": "127.0.0.1", 40 | "port": 9060, 41 | "secret": "cymru" 42 | }, 43 | "freeswitch": { 44 | "address": "127.0.0.1", 45 | "port": 9070, 46 | "secret": "ClueCon" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /data/nullsdp.js: -------------------------------------------------------------------------------- 1 | exports = module.exports = produceSdp ; 2 | 3 | function produceSdp( address, port ) { 4 | 5 | var sdp = ['v=0', 6 | 'o=- 1111 0 IN IP4 ip-address', 7 | 's=drachtio session', 8 | 'c=IN IP4 ip-address', 9 | 't=0 0', 10 | 'm=audio 50000 RTP/AVP 0 9 113 101', 11 | 'a=rtpmap:9 G722/8000', 12 | 'a=rtpmap:113 opus/48000/2', 13 | 'a=fmtp:113 useinbandfec=1', 14 | 'a=rtpmap:101 telephone-event/8000', 15 | 'a=fmtp:101 0-15', 16 | 'a=inactive\r\n'] ; 17 | 18 | return sdp.join('\r\n').replace(/ip-address/g, address).replace(/port/g, port) ; 19 | } 20 | 21 | -------------------------------------------------------------------------------- /examples/record-session/app.js: -------------------------------------------------------------------------------- 1 | const argv = require('minimist')(process.argv.slice(2)); 2 | const Srf = require('drachtio-srf'); 3 | const Mrf = require('../..'); 4 | 5 | const optsDrachtio = { 6 | host: argv['drachtio-address'] || '127.0.0.1', 7 | port: argv['drachtio-port'] || 9022, 8 | secret: argv['drachtio-secret'] || 'cymru' 9 | } ; 10 | const optsFreeswitch = { 11 | address: argv['freeswitch-address'] || '127.0.0.1', 12 | port: argv['freeswitch-port'] || 8021, 13 | secret: argv['freeswitch-secret'] || 'ClueCon' 14 | }; 15 | 16 | const srf = new Srf() ; 17 | srf.connect(optsDrachtio); 18 | 19 | srf.on('connect', (err, hostport) => { 20 | console.log(`successfully connected to drachtio listening on ${hostport}`); 21 | }); 22 | 23 | const mrf = new Mrf(srf) ; 24 | mrf.connect(optsFreeswitch) 25 | .then((mediaserver) => { 26 | console.log('successfully connected to mediaserver'); 27 | return srf.locals.ms = mediaserver; 28 | }) 29 | .catch ((err) => { 30 | console.error(`error connecting to mediaserver: ${err}`); 31 | }); 32 | srf.invite((req, res) => { 33 | const ms = req.app.locals.ms ; 34 | let ep; 35 | ms.connectCaller(req, res) 36 | .then(({endpoint, dialog}) => { 37 | console.log('successfully connected call'); 38 | dialog.on('destroy', () => { endpoint.destroy(); }); 39 | ep = endpoint ; 40 | return ep.set('RECORD_STEREO', true); 41 | }) 42 | .then(() => { 43 | return ep.recordSession('$${base_dir}/recordings/name_and_reason.wav') ; 44 | }) 45 | .then((evt) => { 46 | return ep.play(['silence_stream://1000', 'ivr/8000/ivr-please_state_your_name_and_reason_for_calling.wav']); 47 | }) 48 | .then((res) => { 49 | console.log(`finished playing: ${JSON.stringify(res$)}`); 50 | return ; 51 | }) 52 | .catch ((err) => { 53 | console.log(`error connecting call to media server: ${err}`); 54 | }); 55 | }) ; 56 | 57 | 58 | -------------------------------------------------------------------------------- /examples/record/app.js: -------------------------------------------------------------------------------- 1 | const argv = require('minimist')(process.argv.slice(2)); 2 | const Srf = require('drachtio-srf'); 3 | const Mrf = require('../..'); 4 | 5 | const optsDrachtio = { 6 | host: argv['drachtio-address'] || '127.0.0.1', 7 | port: argv['drachtio-port'] || 9022, 8 | secret: argv['drachtio-secret'] || 'cymru' 9 | } ; 10 | const optsFreeswitch = { 11 | address: argv['freeswitch-address'] || '127.0.0.1', 12 | port: argv['freeswitch-port'] || 8021, 13 | secret: argv['freeswitch-secret'] || 'ClueCon' 14 | }; 15 | 16 | const srf = new Srf() ; 17 | srf.connect(optsDrachtio); 18 | 19 | srf.on('connect', (err, hostport) => { 20 | console.log(`successfully connected to drachtio listening on ${hostport}`); 21 | }); 22 | 23 | const mrf = new Mrf(srf) ; 24 | mrf.connect(optsFreeswitch) 25 | .then((mediaserver) => { 26 | console.log('successfully connected to mediaserver'); 27 | return srf.locals.ms = mediaserver; 28 | }) 29 | .catch ((err) => { 30 | console.error(`error connecting to mediaserver: ${err}`); 31 | }); 32 | 33 | 34 | srf.invite((req, res) => { 35 | const ms = req.app.locals.ms ; 36 | let ep, dlg; 37 | ms.connectCaller(req, res) 38 | .then(({endpoint, dialog}) => { 39 | console.log('successfully connected call'); 40 | ep = endpoint ; 41 | dlg = dialog ; 42 | dlg.on('destroy', () => { if (ep) ep.destroy(); }); 43 | return ep.set('playback_terminators', '123456789#*'); 44 | }) 45 | .then(() => { 46 | ep.play(['silence_stream://1000', 'voicemail/8000/vm-record_message.wav']); 47 | return ep.record('$${base_dir}/recordings/record_message.wav', { 48 | timeLimitSecs: 20 49 | }) ; 50 | }) 51 | .then((evt) => { 52 | console.log(`record returned ${JSON.stringify(evt)}`); 53 | return ep.play(['ivr/8000/ivr-thank_you.wav']); 54 | }) 55 | .then(() => { 56 | return Promise.all([ep.destroy(), dlg.destroy()]); 57 | }) 58 | .then(() => { 59 | ep = null ; 60 | dlg = null ; 61 | return console.log('call completed'); 62 | }) 63 | .catch ((err) => { 64 | console.log(`error connecting call to media server: ${err}`); 65 | }); 66 | }) ; 67 | 68 | 69 | -------------------------------------------------------------------------------- /lib/conference.js: -------------------------------------------------------------------------------- 1 | const Emitter = require('events').EventEmitter ; 2 | const assert = require('assert') ; 3 | const only = require('only') ; 4 | const {camelCase} = require('camel-case'); 5 | const {upperFirst} = require('./utils'); 6 | const debug = require('debug')('drachtio:fsmrf') ; 7 | 8 | const State = { 9 | NOT_CREATED: 1, 10 | CREATED: 2, 11 | DESTROYED: 3 12 | }; 13 | 14 | function unhandled(evt) { 15 | debug(`unhandled conference event: ${evt.getHeader('Action')}`) ; 16 | } 17 | 18 | /** 19 | * An audio or video conference mixer. Conferences may be created on the fly by simply joining an endpoint 20 | * to a named conference without explicitly creating a Conference object. The main purpose of the Conference 21 | * object is to enable the ability to create a conference on the media server without having an inbound call 22 | * (e.g., to create a scheduled conference at a particular point in time). 23 | * 24 | * Note: This constructor should not be called directly: rather, call MediaServer#createConference 25 | * to create an instance of an Endpoint on a MediaServer 26 | * @constructor 27 | * @param {String} name conference name 28 | * @param {String} uuid conference uuid 29 | * @param {Endpoint} endpoint - endpoint that provides the control connection for the conference 30 | * @param {Conference~createOptions} [opts] - conference-level configuration options 31 | */ 32 | class Conference extends Emitter { 33 | constructor(name, uuid, endpoint, opts) { 34 | super() ; 35 | 36 | debug('Conference#ctor'); 37 | opts = opts || {} ; 38 | 39 | this._endpoint = endpoint ; 40 | 41 | /** 42 | * conference name 43 | * @type {string} 44 | */ 45 | 46 | this.name = name ; 47 | 48 | /** 49 | * conference unique id 50 | * @type {string} 51 | */ 52 | this.uuid = uuid ; 53 | 54 | /** 55 | * file that conference is currently being recorded to 56 | * @type {String} 57 | */ 58 | this.recordFile = null ; 59 | 60 | /** 61 | * conference state 62 | * @type {Number} 63 | */ 64 | this.state = State.CREATED ; 65 | 66 | /** 67 | * true if conference is locked 68 | * @type {Boolean} 69 | */ 70 | this.locked = false ; 71 | 72 | /** 73 | * member ID of the conference control leg 74 | * @type {Number} 75 | */ 76 | this.memberId = this.endpoint.conf.memberId ; 77 | 78 | /** 79 | * current participants in the conference, keyed by member ID 80 | * @type {Map} 81 | */ 82 | this.participants = new Map() ; 83 | 84 | /** 85 | * max number of members allowed (-1 means no limit) 86 | * @type {Number} 87 | */ 88 | this.maxMembers = -1 ; 89 | 90 | // used to track play commands in progress 91 | this._playCommands = {} ; 92 | 93 | this.endpoint.filter('Conference-Unique-ID', this.uuid); 94 | 95 | this.endpoint.conn.on('esl::event::CUSTOM::*', this.__onConferenceEvent.bind(this)) ; 96 | 97 | if (opts.maxMembers) { 98 | this.endpoint.api('conference', `${name} set max_members ${opts.maxMembers}`) ; 99 | this.maxMembers = opts.maxMembers ; 100 | } 101 | } 102 | 103 | get endpoint() { 104 | return this._endpoint ; 105 | } 106 | 107 | get mediaserver() { 108 | return this.endpoint.mediaserver ; 109 | } 110 | 111 | /** 112 | * destroy the conference, releasing all legs 113 | * @param {Conference~operationCallback} callback - callback invoked when conference has been destroyed 114 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 115 | * a reference to the Conference object 116 | */ 117 | destroy(callback) { 118 | debug(`Conference#destroy - destroying conference ${this.name}`); 119 | const __x = (callback) => { 120 | this.endpoint.destroy(callback) ; 121 | }; 122 | 123 | if (callback) { 124 | __x(callback) ; 125 | return this ; 126 | } 127 | 128 | return new Promise((resolve, reject) => { 129 | __x((err) => { 130 | if (err) return reject(err); 131 | resolve(); 132 | }); 133 | }); 134 | } 135 | 136 | /** 137 | * retrieve the current number of participants in the conference 138 | * @return {Promise} promise that resolves with the count of participants (including control leg) 139 | */ 140 | getSize() { 141 | return this.list('count') 142 | .then((evt) => { 143 | try { 144 | return parseInt(evt.getBody()) ; 145 | } catch (err) { 146 | throw new Error(`unexpected (non-integer) response to conference list summary: ${err}`); 147 | } 148 | }); 149 | } 150 | 151 | /** 152 | * get a conference parameter value 153 | * @param {String} param - parameter to retrieve 154 | * @param {Conference~mediaOperationCallback} [callback] - callback invoked when operation completes 155 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 156 | * a reference to the Conference object 157 | */ 158 | get(...args) { return confOperations.get(this, ...args); } 159 | /** 160 | * set a conference parameter value 161 | * @param {String} param - parameter to set 162 | * @param {String} value - value 163 | * @param {Conference~mediaOperationCallback} [callback] - callback invoked when operation completes 164 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 165 | * a reference to the Conference object 166 | */ 167 | set(...args) { return confOperations.set(this, ...args); } 168 | 169 | /** 170 | * adjust the automatic gain control for the conference 171 | * @param {Number|String} level - 'on', 'off', or a numeric level 172 | * @param {Conference~mediaOperationCallback} [callback] - callback invoked when operation completes 173 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 174 | * a reference to the Conference object 175 | */ 176 | agc(...args) { return confOperations.agc(this, ...args); } 177 | 178 | /** 179 | * check the status of the conference recording 180 | * @param {Conference~mediaOperationsCallback} [callback] - callback invoked when media operations completes 181 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 182 | * a reference to the Conference object 183 | */ 184 | chkRecord(...args) { return confOperations.chkRecord(this, ...args); } 185 | 186 | /** 187 | * deaf all the non-moderators in the conference 188 | * @param {Conference~mediaOperationsCallback} [callback] - callback invoked when media operations completes 189 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 190 | * a reference to the Conference object 191 | */ 192 | deaf(...args) { return confOperations.deaf(this, ...args); } 193 | 194 | /** 195 | * undeaf all the non-moderators in the conference 196 | * @param {Conference~mediaOperationsCallback} [callback] - callback invoked when media operations completes 197 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 198 | * a reference to the Conference object 199 | */ 200 | undeaf(...args) { return confOperations.undeaf(this, ...args); } 201 | 202 | /** 203 | * mute all the non-moderators in the conference 204 | * @param {Conference~mediaOperationsCallback} [cb] - callback invoked when media operations completes 205 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 206 | * a reference to the Conference object 207 | */ 208 | mute(...args) { return confOperations.mute(this, ...args); } 209 | /** 210 | * unmute all the non-moderators in the conference 211 | * @param {Conference~mediaOperationsCallback} [cb] - callback invoked when media operations completes 212 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 213 | * a reference to the Conference object 214 | */ 215 | unmute(...args) { return confOperations.unmute(this, ...args); } 216 | 217 | /** 218 | * lock the conference 219 | * @param {Conference~mediaOperationsCallback} [callback] - callback invoked when media operations completes 220 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 221 | * a reference to the Conference object 222 | */ 223 | lock(...args) { return confOperations.lock(this, ...args); } 224 | 225 | /** 226 | * unlock the conference 227 | * @param {Conference~mediaOperationsCallback} [cb] - callback invoked when media operations completes 228 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 229 | * a reference to the Conference object 230 | */ 231 | unlock(...args) { return confOperations.unlock(this, ...args); } 232 | 233 | /** 234 | * list members 235 | * @param {Conference~mediaOperationsCallback} [cb] - callback invoked when media operations completes 236 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 237 | * a reference to the Conference object 238 | */ 239 | list(...args) { return confOperations.list(this, ...args); } 240 | 241 | /** 242 | * start recording the conference 243 | * @param {String} file - filepath to record to 244 | * @param {Conference~mediaOperationsCallback} [callback] - callback invoked when media operations completes 245 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 246 | * a reference to the Conference object 247 | */ 248 | startRecording(file, callback) { 249 | assert.ok(typeof file === 'string', '\'file\' parameter must be provided') ; 250 | 251 | const __x = (callback) => { 252 | this.recordFile = file ; 253 | this.endpoint.api('conference ', `${this.name} recording start ${file}`, (err, evt) => { 254 | if (err) return callback(err, evt); 255 | const body = evt.getBody() ; 256 | const regexp = new RegExp(`Record file ${file}`); 257 | if (regexp.test(body)) { 258 | return callback(null, body); 259 | } 260 | callback(new Error(body)); 261 | }) ; 262 | }; 263 | 264 | if (callback) { 265 | __x(callback) ; 266 | return this ; 267 | } 268 | 269 | return new Promise((resolve, reject) => { 270 | __x((err, ...results) => { 271 | if (err) return reject(err); 272 | resolve(...results); 273 | }); 274 | }); 275 | } 276 | 277 | /** 278 | * pause the recording 279 | * @param {Conference~mediaOperationsCallback} [callback] - callback invoked when media operations completes 280 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 281 | * a reference to the Conference object 282 | */ 283 | pauseRecording(file, callback) { 284 | const __x = (callback) => { 285 | this.recordFile = file ; 286 | this.endpoint.api('conference ', `${this.name} recording pause ${this.recordFile}`, (err, evt) => { 287 | if (err) return callback(err, evt); 288 | const body = evt.getBody() ; 289 | const regexp = new RegExp(`Pause recording file ${file}\n$`); 290 | if (regexp.test(body)) { 291 | return callback(null, body); 292 | } 293 | callback(new Error(body)); 294 | }) ; 295 | }; 296 | 297 | if (callback) { 298 | __x(callback) ; 299 | return this ; 300 | } 301 | 302 | return new Promise((resolve, reject) => { 303 | __x((err, ...results) => { 304 | if (err) return reject(err); 305 | resolve(...results); 306 | }); 307 | }); 308 | } 309 | 310 | /** 311 | * resume the recording 312 | * @param {Conference~mediaOperationsCallback} [callback] - callback invoked when media operations completes 313 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 314 | * a reference to the Conference object 315 | */ 316 | resumeRecording(file, callback) { 317 | const __x = (callback) => { 318 | this.recordFile = file ; 319 | this.endpoint.api('conference ', `${this.name} recording resume ${this.recordFile}`, (err, evt) => { 320 | if (err) return callback(err, evt); 321 | const body = evt.getBody() ; 322 | const regexp = new RegExp(`Resume recording file ${file}\n$`); 323 | if (regexp.test(body)) { 324 | return callback(null, body); 325 | } 326 | callback(new Error(body)); 327 | }); 328 | }; 329 | 330 | if (callback) { 331 | __x(callback) ; 332 | return this ; 333 | } 334 | 335 | return new Promise((resolve, reject) => { 336 | __x((err, ...results) => { 337 | if (err) return reject(err); 338 | resolve(...results); 339 | }); 340 | }); 341 | } 342 | 343 | /** 344 | * stop the conference recording 345 | * @param {Conference~mediaOperationsCallback} [callback] - callback invoked when media operations completes 346 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 347 | * a reference to the Conference object 348 | */ 349 | stopRecording(file, callback) { 350 | const __x = (callback) => { 351 | this.endpoint.api('conference ', `${this.name} recording stop ${this.recordFile}`, (err, evt) => { 352 | if (err) return callback(err, evt); 353 | const body = evt.getBody() ; 354 | const regexp = new RegExp(`Stopped recording file ${file}`); 355 | if (regexp.test(body)) { 356 | return callback(null, body); 357 | } 358 | callback(new Error(body)); 359 | }) ; 360 | this.recordFile = null ; 361 | }; 362 | 363 | if (callback) { 364 | __x(callback) ; 365 | return this ; 366 | } 367 | 368 | return new Promise((resolve, reject) => { 369 | __x((err, ...results) => { 370 | if (err) return reject(err); 371 | resolve(...results); 372 | }); 373 | }); 374 | } 375 | 376 | /** 377 | * play an audio file or files into the conference 378 | * @param {string|Array} file file (or array of files) to play 379 | * @param {Conference~playOperationCallback} [callback] - callback invoked when the files have completed playing 380 | * @return {Promise|Conference} returns a Promise if no callback supplied; otherwise 381 | * a reference to the Conference object 382 | */ 383 | play(file, callback) { 384 | assert.ok('string' === typeof file || Array.isArray(file), 385 | 'file param is required and must be a string or array') ; 386 | 387 | const __x = async(callback) => { 388 | const files = typeof file === 'string' ? [file] : file ; 389 | 390 | // each call to conference play queues the file up; 391 | // i.e. the callback returns immediately upon successful queueing, 392 | // not when the file has finished playing 393 | const queued = [] ; 394 | 395 | for (const f of files) { 396 | try { 397 | const result = await this.endpoint.api('conference', `${this.name} play ${f}`); 398 | if (result && result.body && -1 !== result.body.indexOf(' not found.')) { 399 | debug(`file ${f} was not queued because it was not found, or conference is empty`); 400 | } 401 | else { 402 | queued.push(f) ; 403 | } 404 | } catch (err) { 405 | } 406 | } 407 | if (queued.length > 0) { 408 | const firstFile = queued[0] ; 409 | const obj = { 410 | remainingFiles: queued.slice(1), 411 | seconds: 0, 412 | milliseconds: 0, 413 | samples: 0, 414 | done: callback 415 | }; 416 | this._playCommands[firstFile] = this._playCommands[firstFile] || [] ; 417 | this._playCommands[firstFile].push(obj) ; 418 | } 419 | else { 420 | // no files actually got queued, so execute the callback 421 | debug('Conference#play: no files were queued for callback, so invoking callback immediately') ; 422 | callback(null, { 423 | seconds: 0, 424 | milliseconds: 0, 425 | samples: 0 426 | }) ; 427 | } 428 | }; 429 | 430 | if (callback) { 431 | __x(callback) ; 432 | return this ; 433 | } 434 | 435 | return new Promise((resolve, reject) => { 436 | __x((err, ...results) => { 437 | if (err) return reject(err); 438 | resolve(...results); 439 | }); 440 | }); 441 | } 442 | 443 | _onAddMember(evt) { 444 | debug(`Conference#_onAddMember: ${JSON.stringify(this)}`) ; 445 | const size = parseInt(evt.getHeader('Conference-Size')); //includes control leg 446 | const newMemberId = parseInt(evt.getHeader('Member-ID')) ; 447 | const memberType = evt.getHeader('Member-Type') ; 448 | const memberGhost = evt.getHeader('Member-Ghost') ; 449 | const channelUuid = evt.getHeader('Channel-Call-UUID') ; 450 | const obj = { 451 | memberId: newMemberId, 452 | type: memberType, 453 | ghost: memberGhost, 454 | channelUuid: channelUuid 455 | } ; 456 | this.participants.set(newMemberId, obj) ; 457 | 458 | debug(`Conference#_onAddMember: added member ${newMemberId} to ${this.name} size is ${size}`) ; 459 | } 460 | 461 | _onDelMember(evt) { 462 | const memberId = parseInt(evt.getHeader('Member-ID')) ; 463 | const size = parseInt(evt.getHeader('Conference-Size')); // includes control leg 464 | this.participants.delete(memberId) ; 465 | debug(`Conference#_onDelMember: removed member ${memberId} from ${this.name} size is ${size}`) ; 466 | } 467 | _onStartTalking(evt) { 468 | debug(`Conf ${this.name}:${this.uuid} member ${evt.getHeader('Member-ID')} started talking`) ; 469 | } 470 | _onStopTalking(evt) { 471 | debug(`Conf ${this.name}:${this.uuid} member ${evt.getHeader('Member-ID')} stopped talking`) ; 472 | } 473 | _onMuteDetect(evt) { 474 | debug(`Conf ${this.name}:${this.uuid} muted member ${evt.getHeader('Member-ID')} is talking`) ; 475 | } 476 | _onUnmuteMember(evt) { 477 | debug(`Conf ${this.name}:${this.uuid} member ${evt.getHeader('Member-ID')} has been unmuted`) ; 478 | } 479 | _onMuteMember(evt) { 480 | debug(`Conf ${this.name}:${this.uuid} member ${evt.getHeader('Member-ID')} has been muted`) ; 481 | } 482 | _onKickMember(evt) { 483 | debug(`Conf ${this.name}:${this.uuid} member ${evt.getHeader('Member-ID')} has been kicked`) ; 484 | } 485 | _onDtmfMember(evt) { 486 | debug(`Conf ${this.name}:${this.uuid} member ${evt.getHeader('Member-ID')} has entered DTMF`) ; 487 | } 488 | _onStartRecording(evt) { 489 | debug(`Conference#_onStartRecording: ${this.name}:${this.uuid} ${JSON.stringify(evt)}`); 490 | const err = evt.getHeader('Error'); 491 | if (err) { 492 | const path = evt.getHeader('Path'); 493 | console.log(`Conference#_onStartRecording: failed to start recording to ${path}: ${err}`); 494 | } 495 | } 496 | _onStopRecording(evt) { 497 | debug(`Conference#_onStopRecording: ${this.name}:${this.uuid} ${JSON.stringify(evt)}`); 498 | } 499 | _onPlayFile(evt) { 500 | const confName = evt.getHeader('Conference-Name') ; 501 | const file = evt.getHeader('File') ; 502 | debug(`conference-level play has started: ${confName}: ${file}`); 503 | } 504 | _onPlayFileMember(evt) { 505 | debug(`member-level play for member ${evt.getHeader('Member-ID')} has completed`) ; 506 | } 507 | _onPlayFileDone(evt) { 508 | const confName = evt.getHeader('Conference-Name') ; 509 | const file = evt.getHeader('File') ; 510 | const seconds = parseInt(evt.getHeader('seconds')) ; 511 | const milliseconds = parseInt(evt.getHeader('milliseconds')) ; 512 | const samples = parseInt(evt.getHeader('samples')) ; 513 | 514 | debug(`conference-level play has completed: ${confName}: ${file} 515 | ${seconds} seconds, ${milliseconds} milliseconds, ${samples} samples`); 516 | 517 | // check if the caller registered a callback for this play done 518 | const el = this._playCommands[file] ; 519 | if (el) { 520 | assert(Array.isArray(el), 'Conference#onPlayFileDone: this._playCommands must be an array') ; 521 | const obj = el[0] ; 522 | obj.seconds += seconds ; 523 | obj.milliseconds += milliseconds ; 524 | obj.samples += samples ; 525 | 526 | if (0 === obj.remainingFiles.length) { 527 | 528 | // done playing all files in this request 529 | obj.done(null, { 530 | seconds: obj.seconds, 531 | milliseconds: obj.milliseconds, 532 | samples: obj.samples 533 | }) ; 534 | } 535 | else { 536 | const firstFile = obj.remainingFiles[0] ; 537 | obj.remainingFiles = obj.remainingFiles.slice(1) ; 538 | this._playCommands[firstFile] = this._playCommands[firstFile] || [] ; 539 | this._playCommands[firstFile].push(obj) ; 540 | } 541 | 542 | this._playCommands[file] = this._playCommands[file].slice(1) ; 543 | if (0 === this._playCommands[file].length) { 544 | //done with all queued requests for this file 545 | delete this._playCommands[file] ; 546 | } 547 | } 548 | } 549 | 550 | _onLock(evt) { 551 | debug(`conference has been locked: ${JSON.stringify(evt)}`) ; 552 | } 553 | _onUnlock(evt) { 554 | debug(`conference has been unlocked: ${JSON.stringify(evt)}`) ; 555 | } 556 | _onTransfer(evt) { 557 | debug(`member has been transferred to another conference: ${JSON.stringify(evt)}`) ; 558 | } 559 | _onRecord(evt) { 560 | debug(`conference record has started or stopped: ${evt}`) ; 561 | } 562 | 563 | __onConferenceEvent(evt) { 564 | const eventName = evt.getHeader('Event-Subclass') ; 565 | if (eventName === 'conference::maintenance') { 566 | const action = evt.getHeader('Action') ; 567 | debug(`Conference#__onConferenceEvent: conference event action: ${action}`) ; 568 | 569 | //invoke a handler for this action, if we have defined one 570 | const eventName = camelCase(action); 571 | this.emit(eventName, evt); 572 | (Conference.prototype[`_on${upperFirst(camelCase(action))}`] || unhandled).bind(this, evt)() ; 573 | 574 | } 575 | else { 576 | debug(`Conference#__onConferenceEvent: got unhandled custom event: ${eventName}`) ; 577 | } 578 | } 579 | 580 | toJSON() { 581 | return only(this, 'name state uuid memberId confConn endpoint maxMembers locked recordFile') ; 582 | } 583 | toString() { 584 | return this.toJSON().toString() ; 585 | } 586 | } 587 | /** 588 | * This callback is invoked whenever any media command has completed 589 | * @callback Conference~mediaOperationCallback 590 | * @param {Error} err error returned, if any 591 | * @param {String} response - response to the command 592 | */ 593 | /** 594 | * This callback is invoked when a command has completed 595 | * @callback Conference~operationCallback 596 | * @param {Error} err error returned, if any 597 | */ 598 | 599 | /** 600 | * This callback is invoked when a playback to conference command completes with a play done event of the final file. 601 | * @callback Conference~playOperationCallback 602 | * @param {Error} err error returned, if any 603 | * @param {Conference~playbackResults} [results] - results describing the duration of media played 604 | */ 605 | /** 606 | * This object describes the options when creating a conference 607 | * @typedef {Object} Conference~createOptions 608 | * @property {number} maxMembers - maximum number of members to allow in the conference 609 | */ 610 | /** 611 | * This object describes the results of a playback into conference operation 612 | * @typedef {Object} Conference~playbackResults 613 | * @property {number} seconds - total seconds of media played 614 | * @property {number} milliseconds - total milliseconds of media played 615 | * @property {number} samples - total number of samples played 616 | */ 617 | 618 | exports = module.exports = Conference ; 619 | 620 | const confOperations = {} ; 621 | 622 | // conference unary operations 623 | ['agc', 'list', 'lock', 'unlock', 'mute', 'deaf', 'unmute', 'undeaf', 'chkRecord'].forEach((op) => { 624 | confOperations[op] = (conference, args, callback) => { 625 | assert(conference instanceof Conference); 626 | if (typeof args === 'function') { 627 | callback = args ; 628 | args = '' ; 629 | } 630 | args = args || ''; 631 | if (Array.isArray(args)) args = args.join(' '); 632 | 633 | const __x = (callback) => { 634 | conference.endpoint.api('conference', `${conference.name} ${op} ${args}`, (err, evt) => { 635 | if (err) return callback(err, evt); 636 | const body = evt.getBody() ; 637 | if (-1 !== ['lock', 'unlock', 'mute', 'deaf', 'unmute', 'undeaf'].indexOf(op)) { 638 | if (/OK\s+/.test(body)) return callback(err, body); 639 | return callback(new Error(body)); 640 | } 641 | return callback(err, evt); 642 | }) ; 643 | }; 644 | 645 | if (callback) { 646 | __x(callback) ; 647 | return this ; 648 | } 649 | 650 | return new Promise((resolve, reject) => { 651 | __x((err, result) => { 652 | if (err) return reject(err); 653 | resolve(result); 654 | }); 655 | }); 656 | }; 657 | }); 658 | 659 | confOperations.set = (conference, param, value, callback) => { 660 | assert(conference instanceof Conference); 661 | debug(`Conference#setParam: conference ${conference.name} set ${param} ${value}`); 662 | const __x = (callback) => { 663 | conference.endpoint.api('conference', `${conference.name} set ${param} ${value}`, (err, evt) => { 664 | if (err) return callback(err, evt); 665 | const body = evt.getBody() ; 666 | return callback(err, body); 667 | }) ; 668 | }; 669 | 670 | if (callback) { 671 | __x(callback) ; 672 | return this ; 673 | } 674 | 675 | return new Promise((resolve, reject) => { 676 | __x((err, result) => { 677 | if (err) return reject(err); 678 | resolve(result); 679 | }); 680 | }); 681 | }; 682 | 683 | 684 | confOperations.get = (conference, param, value, callback) => { 685 | assert(conference instanceof Conference); 686 | debug(`Conference#getParam: conference ${conference.name} get ${param} ${value}`); 687 | const __x = (callback) => { 688 | conference.endpoint.api('conference', `${conference.name} get ${param}`, (err, evt) => { 689 | if (err) return callback(err, evt); 690 | const body = evt.getBody() ; 691 | const res = /^\d+$/.test(body) ? parseInt(body) : body; 692 | return callback(err, res); 693 | }) ; 694 | }; 695 | 696 | if (callback) { 697 | __x(callback) ; 698 | return this ; 699 | } 700 | 701 | return new Promise((resolve, reject) => { 702 | __x((err, result) => { 703 | if (err) return reject(err); 704 | resolve(result); 705 | }); 706 | }); 707 | }; 708 | 709 | -------------------------------------------------------------------------------- /lib/endpoint.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') ; 2 | const delegate = require('delegates') ; 3 | const Emitter = require('events').EventEmitter ; 4 | const Conference = require('./conference') ; 5 | const only = require('only') ; 6 | const {parseBodyText, upperFirst, parseDecibels} = require('./utils'); 7 | const {snakeCase} = require('snake-case'); 8 | const {camelCase} = require('camel-case'); 9 | const debug = require('debug')('drachtio:fsmrf') ; 10 | 11 | const State = { 12 | NOT_CONNECTED: 1, 13 | EARLY: 2, 14 | CONNECTED: 3, 15 | DISCONNECTED: 4 16 | }; 17 | 18 | const EVENTS_OF_INTEREST = [ 19 | 'CHANNEL_EXECUTE', 20 | 'CHANNEL_EXECUTE_COMPLETE', 21 | 'CHANNEL_PROGRESS_MEDIA', 22 | 'CHANNEL_CALLSTATE', 23 | 'CHANNEL_ANSWER', 24 | 'DTMF', 25 | 'DETECTED_TONE', 26 | 'SWITCH_EVENT_PLAYBACK_START', 27 | 'SWITCH_EVENT_PLAYBACK_STOP', 28 | 'CUSTOM conference::maintenance' 29 | ]; 30 | 31 | /** 32 | * A media resource on a freeswitch-based MediaServer that is capable of play, 33 | * record, signal detection, and signal generation 34 | * Note: This constructor should not be called directly: rather, call MediaServer#createEndpoint 35 | * to create an instance of an Endpoint on a MediaServer 36 | * @constructor 37 | * @param {esl.Connection} conn - outbound connection from a media server for one session 38 | * @param {Dialog} dialog - SIP Dialog to Freeswitch 39 | * @param {MediaServer} ms - MediaServer that contains this Endpoint 40 | * @param {Endpoint~createOptions} [opts] configuration options 41 | */ 42 | class Endpoint extends Emitter { 43 | constructor(conn, dialog, ms, opts) { 44 | super() ; 45 | 46 | 47 | opts = opts || {} ; 48 | this._customEvents = (opts.customEvents = opts.customEvents || []) 49 | .map((ev) => `CUSTOM ${ev}`); 50 | assert(Array.isArray(this._customEvents)); 51 | 52 | this._conn = conn ; 53 | this._ms = ms ; 54 | this._dialog = dialog ; 55 | 56 | //this._dialog.on('destroy', this._onBye.bind(this)); 57 | 58 | this.uuid = conn.getInfo().getHeader('Channel-Unique-ID') ; 59 | 60 | /** 61 | * is secure media being transmitted (i.e. DLTS-SRTP) 62 | * @type Boolean 63 | */ 64 | this.secure = /^m=audio\s\d*\sUDP\/TLS\/RTP\/SAVPF/m.test(conn.getInfo().getHeader('variable_switch_r_sdp')) ; 65 | 66 | /** 67 | * defines the local network connection of the Endpoint 68 | * @type {Endpoint~NetworkConnection} 69 | */ 70 | this.local = {} ; 71 | /** 72 | * defines the remote network connection of the Endpoint 73 | * @type {Endpoint~NetworkConnection} 74 | */ 75 | this.remote = {} ; 76 | /** 77 | * defines the SIP signaling parameters of the Endpoint 78 | * @type {Endpoint~SipInfo} 79 | */ 80 | this.sip = {} ; 81 | 82 | /** 83 | * conference name and memberId associated with the conference that the endpoint is currently joined to 84 | * @type {Object} 85 | */ 86 | this.conf = {} ; 87 | this.state = State.NOT_CONNECTED ; 88 | this._muted = false; 89 | 90 | debug(`Endpoint#ctor creating endpoint with uuid ${this.uuid}, is3pcc: ${opts.is3pcc}`); 91 | 92 | this.conn.subscribe(EVENTS_OF_INTEREST.concat(this._customEvents).join(' ')); 93 | this.filter('Unique-ID', this.uuid); 94 | 95 | this.conn.on(`esl::event::CHANNEL_HANGUP::${this.uuid}`, this._onHangup.bind(this)) ; 96 | this.conn.on(`esl::event::CHANNEL_CALLSTATE::${this.uuid}`, this._onChannelCallState.bind(this)) ; 97 | this.conn.on(`esl::event::DTMF::${this.uuid}`, this._onDtmf.bind(this)) ; 98 | this.conn.on(`esl::event::DETECTED_TONE::${this.uuid}`, this._onToneDetect.bind(this)) ; 99 | this.conn.on(`esl::event::PLAYBACK_START::${this.uuid}`, this._onPlaybackStart.bind(this)) ; 100 | this.conn.on(`esl::event::PLAYBACK_STOP::${this.uuid}`, this._onPlaybackStop.bind(this)) ; 101 | 102 | this.conn.on(`esl::event::CUSTOM::${this.uuid}`, this._onCustomEvent.bind(this)) ; 103 | 104 | this.conn.on('error', this._onError.bind(this)) ; 105 | 106 | this.conn.on('esl::end', () => { 107 | debug(`got esl::end for ${this.uuid}`); 108 | if (this.state !== State.DISCONNECTED) { 109 | debug(`got unexpected esl::end in state ${this.state} for ${this.uuid}`); 110 | this.state = State.DISCONNECTED; 111 | this.emit('destroy'); 112 | } 113 | this.removeAllListeners(); 114 | this._conn = null; 115 | }); 116 | 117 | // TODO: handle in invite by massaging codec list 118 | if (!opts.is3pcc) { 119 | if (opts.codecs) { 120 | if (typeof opts.codecs === 'string') opts.codecs = [opts.codecs]; 121 | if (opts.codecs.length > 0) { 122 | this.execute('set', `codec_string=${opts.codecs.join(',')}`) ; 123 | } 124 | } 125 | } 126 | 127 | this.local.sdp = conn.getInfo().getHeader('variable_rtp_local_sdp_str') ; 128 | this.local.mediaIp = conn.getInfo().getHeader('variable_local_media_ip') ; 129 | this.local.mediaPort = conn.getInfo().getHeader('variable_local_media_port') ; 130 | 131 | this.remote.sdp = conn.getInfo().getHeader('variable_switch_r_sdp') ; 132 | this.remote.mediaIp = conn.getInfo().getHeader('variable_remote_media_ip') ; 133 | this.remote.mediaPort = conn.getInfo().getHeader('variable_remote_media_port') ; 134 | 135 | this.dtmfType = conn.getInfo().getHeader('variable_dtmf_type') ; 136 | this.sip.callId = conn.getInfo().getHeader('variable_sip_call_id') ; 137 | 138 | this.state = State.CONNECTED ; 139 | this._emitReady(); 140 | } 141 | 142 | /** 143 | * @return {MediaServer} the mediaserver that contains this endpoint 144 | */ 145 | get mediaserver() { 146 | return this._ms ; 147 | } 148 | 149 | /** 150 | * @return {Srf} the Srf instance used to send SIP signaling to this endpoint and associated mediaserver 151 | */ 152 | get srf() { 153 | return this.ms.srf ; 154 | } 155 | 156 | /** 157 | * @return {esl.Connection} the Freeswitch outbound connection used to control this Endpoint 158 | */ 159 | get conn() { 160 | return this._conn ; 161 | } 162 | 163 | get dialog() { 164 | return this._dialog ; 165 | } 166 | 167 | set dialog(dlg) { 168 | this._dialog = dlg; 169 | //if (this._dialog) this._dialog.on('destroy', this._onBye.bind(this)) ; 170 | return this ; 171 | } 172 | 173 | get connected() { 174 | return this.state === State.CONNECTED; 175 | } 176 | 177 | get muted() { 178 | return this._muted; 179 | } 180 | 181 | /** 182 | * set a parameter on the Endpoint 183 | * @param {String|Object} param parameter name or dictionary of param-value pairs 184 | * @param {String} value parameter value 185 | * @param {Endpoint~operationCallback} [callback] callback return results 186 | * @returns {Promise} a promise is returned if no callback is supplied 187 | */ 188 | set(param, value, callback) { return setOrExport('set', this, param, value, callback); } 189 | 190 | /** 191 | * export a parameter on the Endpoint 192 | * @param {String|Object} param parameter name or dictionary of param-value pairs 193 | * @param {String} value parameter value 194 | * @param {Endpoint~operationCallback} [callback] callback return results 195 | * @returns {Promise} a promise is returned if no callback is supplied 196 | */ 197 | export(param, value, callback) { return setOrExport('export', this, param, value, callback); } 198 | /** 199 | * When the endpoint is used for conference, the esl::event::CUSTOM:: will be overide to conference 200 | * listener. This API allow top application reset the esl::event::CUSTOM event to recover the endpoint functionality 201 | */ 202 | 203 | resetEslCustomEvent() { 204 | this.conn.removeAllListeners('esl::event::CUSTOM::*'); 205 | this.conn.on(`esl::event::CUSTOM::${this.uuid}`, this._onCustomEvent.bind(this)); 206 | } 207 | 208 | /** 209 | * subscribe for custom events 210 | * @param {String} custom event name (not including 'CUSTOM ' prefix) 211 | * @param {Funtion} event listener 212 | */ 213 | addCustomEventListener(event, handler) { 214 | assert.ok(typeof event === 'string', 'event name must be string type'); 215 | assert.ok(typeof handler === 'function', 'handler must be a function type'); 216 | assert.ok(event.indexOf('CUSTOM ') !== 0, 217 | 'event name should not include \'CUSTOM \' prefix (it is added automatically)'); 218 | 219 | const fullEventName = `CUSTOM ${event}`; 220 | if (-1 === this._customEvents.indexOf(fullEventName)) { 221 | this._customEvents.push(fullEventName); 222 | this.conn.subscribe(fullEventName); 223 | } 224 | // Make sure we don't re-add listener 225 | this.removeListener(event, handler); 226 | // Now safe to add 227 | this.on(event, handler); 228 | } 229 | 230 | /** 231 | * remove a custom event listener 232 | * @param {String} event name 233 | * @param {Funtion} handler listener, if handler is null, remove all listeners 234 | */ 235 | removeCustomEventListener(event, handler) { 236 | let del = false; 237 | // only remove _customEvent completely if there is no more listener is available. 238 | // there is a usecase that if there 2 call-sessions control same endpoint, 239 | // 1st call session is being killed and still waiting for final transcription, 240 | // 2nd call session initiated transcribe 241 | // 1st call session received final transcription will cleanup listener from 2nd call session. 242 | 243 | if (handler) { 244 | this.removeListener(event, handler); 245 | del === this.listenerCount(event) === 0; 246 | } else { 247 | this.removeAllListeners(event); 248 | del = true; 249 | } 250 | const fullEventName = `CUSTOM ${event}`; 251 | const idx = this._customEvents.indexOf(fullEventName); 252 | if (-1 !== idx && del) this._customEvents.splice(idx, 1); 253 | } 254 | 255 | /** 256 | * retrieve channel variables for the endpoint 257 | * @param {boolean} [includeMedia] if true, retrieve rtp counters (e.g. variable_rtp_audio_in_raw_bytes, etc) 258 | * @param {Endpoint~getChannelVariablesCallback} [callback] callback function invoked when operation completes 259 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 260 | * a reference to the Endpoint object 261 | */ 262 | getChannelVariables(includeMedia, callback) { 263 | if (typeof includeMedia === 'function') { 264 | callback = includeMedia ; 265 | includeMedia = false ; 266 | } 267 | 268 | const __x = async(callback) => { 269 | try { 270 | if (includeMedia === true) await this.api('uuid_set_media_stats', this.uuid); 271 | const {headers, body} = await this.api('uuid_dump', this.uuid); 272 | const hdrs = {}; 273 | headers.forEach((h) => hdrs[h.name] = h.value); 274 | if (hdrs['Content-Type'] === 'api/response' && 'Content-Length' in hdrs) { 275 | var bodyLen = parseInt(hdrs['Content-Length'], 10) ; 276 | return callback(null, parseBodyText(body.slice(0, bodyLen))) ; 277 | } 278 | callback(null, {}); 279 | } catch (err) { 280 | callback(err); 281 | } 282 | }; 283 | 284 | if (callback) { 285 | __x(callback) ; 286 | return this ; 287 | } 288 | 289 | return new Promise((resolve, reject) => { 290 | __x((err, results) => { 291 | if (err) return reject(err); 292 | resolve(results); 293 | }); 294 | }); 295 | } 296 | 297 | _onCustomEvent(evt) { 298 | const eventName = evt.getHeader('Event-Subclass') ; 299 | const fullEventName = `CUSTOM ${eventName}`; 300 | const ev = this._customEvents.find((ev) => ev === fullEventName); 301 | if (ev) { 302 | try { 303 | const args = JSON.parse(evt.getBody()); 304 | debug(`Endpoint#__onCustomEvent: ${ev} - emitting JSON argument ${evt.getBody()}`) ; 305 | this.emit(eventName, args, evt); 306 | } 307 | catch (err) { 308 | if (err instanceof SyntaxError) { 309 | this.emit(eventName, evt.getBody(), evt); 310 | debug(`Endpoint#__onCustomEvent: ${ev} - emitting text argument ${evt.getBody()}`); 311 | } 312 | else { 313 | console.error(err, `Error emitting event ${eventName}`); 314 | } 315 | } 316 | } 317 | } 318 | 319 | /** 320 | * play an audio file on the endpoint 321 | * @param {string|Array|Endpoint~PlaybackOptions} [file] file, array of files or PlaybackOptions object to play 322 | * @param {Endpoint~playOperationCallback} [cb] callback function invoked when operation completes 323 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 324 | * a reference to the Endpoint object 325 | */ 326 | play(file, callback) { 327 | assert.ok('string' === typeof file || 'object' === typeof file || Array.isArray(file), 328 | 'file param is required and must be a string, array or PlaybackOptions object'); 329 | 330 | let timeoutSecs = -1; 331 | if (file.file) { 332 | assert.ok('string' === typeof file.file, 'file is required for PlaybackOptions object'); 333 | if (file.seekOffset && file.seekOffset > 0) file.file = `${file.file}@@${file.seekOffset}`; 334 | if (file.timeoutSecs) timeoutSecs = file.timeoutSecs; 335 | file = file.file; 336 | } 337 | const files = Array.isArray(file) ? file : [file] ; 338 | 339 | const __x = async(callback) => { 340 | try { 341 | if (files.length !== 1) await this.execute('set', 'playback_delimiter=!'); 342 | if (timeoutSecs > 0) await this.execute('set', `playback_timeout_sec=${timeoutSecs}`); 343 | const evt = await this.execute('playback', files.join('!')); 344 | if (evt.getHeader('Application-Response') === 'FILE NOT FOUND') { 345 | throw new Error('File Not Found'); 346 | } else { 347 | callback(null, { 348 | playbackSeconds: evt.getHeader('variable_playback_seconds'), 349 | playbackMilliseconds: evt.getHeader('variable_playback_ms'), 350 | playbackLastOffsetPos: evt.getHeader('variable_playback_last_offset_pos') 351 | }); 352 | } 353 | } catch (err) { 354 | callback(err); 355 | } 356 | }; 357 | 358 | if (callback) { 359 | __x(callback) ; 360 | return this ; 361 | } 362 | 363 | return new Promise((resolve, reject) => { 364 | __x((err, result) => { 365 | if (err) return reject(err); 366 | resolve(result); 367 | }); 368 | }); 369 | } 370 | 371 | /** 372 | * This callback is invoked when a media operation has completed 373 | * @callback Endpoint~playOperationCallback 374 | * @param {Error} err - error returned from play request 375 | * @param {object} results - results of the operation 376 | * @param {String} results.playbackSeconds - number of seconds of audio played 377 | * @param {String} results.playbackMilliseconds - number of fractional milliseconds of audio played 378 | */ 379 | 380 | /** 381 | * play an audio file and collect digits 382 | * @param {Endpoint~playCollectOptions} opts - playcollect options 383 | * @param {Endpoint~playCollectOperationCallback} [callback] - callback function invoked when operation completes 384 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 385 | * a reference to the Endpoint object 386 | */ 387 | playCollect(opts, callback) { 388 | assert(typeof opts, 'object', '\'opts\' param is required') ; 389 | assert(typeof opts.file, 'string', '\'opts.file\' param is required') ; 390 | 391 | 392 | const __x = (callback) => { 393 | opts.min = opts.min || 0 ; 394 | opts.max = opts.max || 128 ; 395 | opts.tries = opts.tries || 1 ; 396 | opts.timeout = opts.timeout || 120000 ; 397 | opts.terminators = opts.terminators || '#' ; 398 | opts.invalidFile = opts.invalidFile || 'silence_stream://250' ; 399 | opts.varName = opts.varName || 'myDigitBuffer' ; 400 | opts.regexp = opts.regexp || '\\d+' ; 401 | opts.digitTimeout = opts.digitTimeout || 8000 ; 402 | 403 | const args = [] ; 404 | ['min', 'max', 'tries', 'timeout', 'terminators', 'file', 'invalidFile', 'varName', 'regexp', 'digitTimeout'] 405 | .forEach((prop) => { 406 | args.push(opts[prop]) ; 407 | }) ; 408 | 409 | this.execute('play_and_get_digits', args.join(' '), (err, evt) => { 410 | const application = evt.getHeader('Application'); 411 | if ('play_and_get_digits' !== application) { 412 | return callback(new Error(`Unexpected application: ${application}`)) ; 413 | } 414 | callback(null, { 415 | digits: evt.getHeader(`variable_${opts.varName}`), 416 | invalidDigits: evt.getHeader(`variable_${opts.varName}_invalid`), 417 | terminatorUsed: evt.getHeader('variable_read_terminator_used'), 418 | playbackSeconds: evt.getHeader('variable_playback_seconds'), 419 | playbackMilliseconds: evt.getHeader('variable_playback_ms'), 420 | }) ; 421 | }) ; 422 | }; 423 | 424 | if (callback) { 425 | __x(callback) ; 426 | return this ; 427 | } 428 | 429 | return new Promise((resolve, reject) => { 430 | __x((err, result) => { 431 | if (err) return reject(err); 432 | resolve(result); 433 | }); 434 | }); 435 | 436 | } 437 | 438 | /** 439 | * Speak a phrase that requires grammar rules 440 | * @param {string} text phrase to speak 441 | * @param {Endpoint~sayOptions} opts - say command options 442 | * @param {Endpoint~playOperationCallback} [callback] - callback function invoked when operation completes 443 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 444 | * a reference to the Endpoint object 445 | */ 446 | 447 | say(text, opts, callback) { 448 | assert(typeof text, 'string', '\'text\' is required') ; 449 | assert(typeof opts, 'object', '\'opts\' param is required') ; 450 | assert(typeof opts.sayType, 'string', '\'opts.sayType\' param is required') ; 451 | assert(typeof opts.sayMethod, 'string', '\'opts.sayMethod\' param is required') ; 452 | 453 | opts.lang = opts.lang || 'en' ; 454 | opts.sayType = opts.sayType.toUpperCase() ; 455 | opts.sayMethod = opts.sayMethod.toLowerCase() ; 456 | 457 | assert.ok(!(opts.sayType in [ 458 | 'NUMBER', 459 | 'ITEMS', 460 | 'PERSONS', 461 | 'MESSAGES', 462 | 'CURRENCY', 463 | 'TIME_MEASUREMENT', 464 | 'CURRENT_DATE', 465 | 'CURRENT_TIME', 466 | 'CURRENT_DATE_TIME', 467 | 'TELEPHONE_NUMBER', 468 | 'TELEPHONE_EXTENSION', 469 | 'URL', 470 | 'IP_ADDRESS', 471 | 'EMAIL_ADDRESS', 472 | 'POSTAL_ADDRESS', 473 | 'ACCOUNT_NUMBER', 474 | 'NAME_SPELLED', 475 | 'NAME_PHONETIC', 476 | 'SHORT_DATE_TIME']), 'invalid value for \'sayType\' param: ' + opts.sayType) ; 477 | 478 | assert.ok(!(opts.sayMethod in ['pronounced', 'iterated', 'counted']), 479 | 'invalid value for \'sayMethod\' param: ' + opts.sayMethod) ; 480 | 481 | if (opts.gender) { 482 | opts.gender = opts.gender.toUpperCase() ; 483 | assert.ok(opts.gender in ['FEMININE', 'MASCULINE', 'NEUTER'], 484 | 'invalid value for \'gender\' param: ' + opts.gender) ; 485 | } 486 | 487 | const args = [] ; 488 | ['lang', 'sayType', 'sayMethod', 'gender'].forEach((prop) => { 489 | if (opts[prop]) { 490 | args.push(opts[prop]) ; 491 | } 492 | }); 493 | args.push(text) ; 494 | 495 | const __x = (callback) => { 496 | this.execute('say', args.join(' '), (err, evt) => { 497 | const application = evt.getHeader('Application'); 498 | if ('say' !== application) return callback(new Error(`Unexpected application: ${application}`)) ; 499 | var result = { 500 | playbackSeconds: evt.getHeader('variable_playback_seconds'), 501 | playbackMilliseconds: evt.getHeader('variable_playback_ms'), 502 | } ; 503 | callback(null, result) ; 504 | }) ; 505 | }; 506 | 507 | if (callback) { 508 | __x(callback) ; 509 | return this ; 510 | } 511 | 512 | return new Promise((resolve, reject) => { 513 | __x((err, result) => { 514 | if (err) return reject(err); 515 | resolve(result); 516 | }); 517 | }); 518 | 519 | } 520 | 521 | /** 522 | * Use text-to-speech to speak. 523 | * @param {string} [opts.ttsEngine] name of the tts engine to use 524 | * @param {string} [opts.voice] name of the tts voice to use 525 | * @param {string} [opts.text] text to speak 526 | * @param {function} [callback] if provided, callback with signature (err) 527 | * @return {Endpoint|Promise} if a callback is supplied, a reference to the Endpoint instance. 528 | *
If no callback is supplied, then a Promise that is resolved 529 | * when the speak command completes. 530 | */ 531 | speak(opts, callback) { 532 | assert(typeof opts, 'object', '\'opts\' param is required') ; 533 | assert(typeof opts.ttsEngine, 'string', '\'opts.ttsEngine\' param is required') ; 534 | assert(typeof opts.voice, 'string', '\'opts.voice\' param is required') ; 535 | assert(typeof opts.text, 'string', '\'opts.text\' param is required') ; 536 | 537 | const __x = (callback) => { 538 | const args = [opts.ttsEngine, opts.voice, opts.text].join('|'); 539 | 540 | this.execute('speak', args, (err, evt) => { 541 | if (err) return callback(err); 542 | const application = evt.getHeader('Application'); 543 | if ('speak' !== application) return callback(new Error(`Unexpected application: ${application}`)); 544 | callback(null) ; 545 | }) ; 546 | }; 547 | 548 | if (callback) { 549 | __x(callback) ; 550 | return this ; 551 | } 552 | 553 | return new Promise((resolve, reject) => { 554 | __x((err, result) => { 555 | if (err) return reject(err); 556 | resolve(result); 557 | }); 558 | }); 559 | 560 | } 561 | /** 562 | * join an endpoint into a conference 563 | * @param {String|Conference} conf - name of a conference or a Conference instance 564 | * @param {Endpoint~confJoinOptions} [opts] - options governing the connection 565 | * between the endpoint and the conference 566 | * @param {Endpoint~confJoinCallback} [callback] - callback invoked when join operation is completed 567 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 568 | * a reference to the Endpoint object 569 | */ 570 | join(conf, opts, callback) { 571 | assert.ok(typeof conf === 'string' || conf instanceof Conference, 572 | 'argument \'conf\' must be either a conference name or a Conference object') ; 573 | 574 | const confName = typeof conf === 'string' ? conf : conf.name ; 575 | if (typeof opts === 'function') { 576 | callback = opts ; 577 | opts = {} ; 578 | } 579 | opts = opts || {} ; 580 | opts.flags = opts.flags || {} ; 581 | 582 | const flags = [] ; 583 | for (const [key, value] of Object.entries(opts.flags)) { 584 | if (true === value) flags.push(snakeCase(key).replace(/_/g, '-')); 585 | } 586 | 587 | let args = confName ; 588 | if (opts.profile) args += '@' + opts.profile; 589 | if (!!opts.pin || flags.length > 0) args += '+' ; 590 | if (opts.pin) args += opts.pin ; 591 | if (flags.length > 0) args += '+flags{' + flags.join('|') + '}' ; 592 | 593 | const __x = (callback) => { 594 | const listener = this.__onConferenceEvent.bind(this); 595 | debug(`Endpoint#join: ${this.uuid} executing conference with args: ${args}`) ; 596 | 597 | this.conn.on('esl::event::CUSTOM::*', listener) ; 598 | 599 | this.execute('conference', args) ; 600 | 601 | assert(!this._joinCallback); 602 | 603 | this._joinCallback = (memberId, confUuid) => { 604 | debug(`Endpoint#joinConference: ${this.uuid} joined ${confName}:${confUuid} with memberId ${memberId}`) ; 605 | this._joinCallback = null ; 606 | this.conf.memberId = memberId ; 607 | this.conf.name = confName; 608 | this.conf.uuid = confUuid; 609 | 610 | this.conn.removeListener('esl::event::CUSTOM::*', listener) ; 611 | 612 | callback(null, {memberId, confUuid}); 613 | }; 614 | }; 615 | 616 | if (callback) { 617 | __x(callback) ; 618 | return this ; 619 | } 620 | 621 | return new Promise((resolve, reject) => { 622 | __x((err, result) => { 623 | if (err) return reject(err); 624 | resolve(result); 625 | }); 626 | }); 627 | } 628 | 629 | /** 630 | * bridge two endpoints together 631 | * @param {Endpoint | string} other - an Endpoint or uuid of a channel to bridge with 632 | * @param {Endpoint~operationCallback} [callback] - callback invoked when bridge operation completes 633 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 634 | * a reference to the Endpoint object 635 | */ 636 | bridge(other, callback) { 637 | assert.ok(typeof other === 'string' || other instanceof Endpoint, 638 | 'argument \'other\' must be either a uuid or an Endpoint') ; 639 | 640 | const otherUuid = typeof other === 'string' ? other : other.uuid ; 641 | 642 | const __x = (callback) => { 643 | this.api('uuid_bridge', [this.uuid, otherUuid], (err, event, headers, body) => { 644 | if (err) return callback(err); 645 | 646 | if (0 === body.indexOf('+OK')) { 647 | return callback(null) ; 648 | } 649 | callback(new Error(body)) ; 650 | }); 651 | }; 652 | 653 | if (callback) { 654 | __x(callback) ; 655 | return this ; 656 | } 657 | 658 | return new Promise((resolve, reject) => { 659 | __x((err, result) => { 660 | if (err) return reject(err); 661 | resolve(result); 662 | }); 663 | }); 664 | } 665 | 666 | 667 | /** 668 | * Park an endpoint that is currently bridged with another endpoint 669 | * @param {Endpoint~operationCallback} [callback] - callback invoked when bridge operation completes 670 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 671 | * a reference to the Endpoint object 672 | */ 673 | unbridge(callback) { 674 | const __x = (callback) => { 675 | this.api('uuid_transfer', [this.uuid, '-both', 'park', 'inline'], (err, evt) => { 676 | if (err) return callback(err); 677 | const body = evt.getBody() ; 678 | if (0 === body.indexOf('+OK')) { 679 | return callback(null) ; 680 | } 681 | callback(new Error(body)) ; 682 | }); 683 | }; 684 | 685 | if (callback) { 686 | __x(callback) ; 687 | return this ; 688 | } 689 | 690 | return new Promise((resolve, reject) => { 691 | __x((err, result) => { 692 | if (err) return reject(err); 693 | resolve(result); 694 | }); 695 | }); 696 | } 697 | 698 | /** 699 | * Get an array of conference member ids that do NOT match the specifed tag 700 | * @param {*} confName - conference name 701 | * @param {*} tag - tag to match against 702 | * @param {*} callback - optional 703 | * @returns Promise 704 | */ 705 | getNonMatchingConfParticipants(confName, tag, callback) { 706 | const __x = (callback) => { 707 | const args = [confName, 'gettag', tag, 'nomatch']; 708 | this.api('conference', args, (err, evt) => { 709 | if (err) return callback(err); 710 | const body = evt.getBody().trim() 711 | .split(',') 712 | .map((v) => parseInt(v, 10)) 713 | .filter((v) => !isNaN(v)); 714 | callback(null, body) ; 715 | }); 716 | }; 717 | 718 | if (callback) { 719 | __x(callback) ; 720 | return this ; 721 | } 722 | return new Promise((resolve, reject) => { 723 | __x((err, result) => { 724 | if (err) return reject(err); 725 | resolve(result); 726 | }); 727 | }); 728 | } 729 | 730 | setGain(opts, callback) { 731 | const __x = (callback) => { 732 | const db = parseDecibels(opts); 733 | const args = [this.uuid, 'setGain', db]; 734 | this.api('uuid_dub', args, (err, evt) => { 735 | if (err) return callback(err); 736 | const body = evt.getBody() ; 737 | if (0 === body.indexOf('+OK')) { 738 | return callback(null) ; 739 | } 740 | callback(new Error(body)) ; 741 | }); 742 | }; 743 | 744 | if (callback) { 745 | __x(callback) ; 746 | return this ; 747 | } 748 | return new Promise((resolve, reject) => { 749 | __x((err, result) => { 750 | if (err) return reject(err); 751 | resolve(result); 752 | }); 753 | }); 754 | } 755 | 756 | dub(opts, callback) { 757 | const {action, track, play, say, loop, gain} = opts; 758 | assert.ok(action, 'ep#dub: action is required'); 759 | assert.ok(track, 'ep#dub: track is required'); 760 | 761 | const __x = (callback) => { 762 | const args = [this.uuid, action, track]; 763 | if (action === 'playOnTrack') { 764 | args.push(play); 765 | args.push(loop ? 'loop' : 'once'); 766 | if (gain) args.push(gain); 767 | } 768 | else if (action === 'sayOnTrack') { 769 | args.push(say); 770 | args.push(loop ? 'loop' : 'once'); 771 | if (gain) args.push(gain); 772 | } 773 | 774 | this.api('uuid_dub', `^^|${args.join('|')}`, (err, evt) => { 775 | if (err) return callback(err); 776 | const body = evt.getBody() ; 777 | if (0 === body.indexOf('+OK')) { 778 | return callback(null) ; 779 | } 780 | callback(new Error(body)) ; 781 | }); 782 | }; 783 | 784 | if (callback) { 785 | __x(callback) ; 786 | return this ; 787 | } 788 | 789 | return new Promise((resolve, reject) => { 790 | __x((err, result) => { 791 | if (err) return reject(err); 792 | resolve(result); 793 | }); 794 | }); 795 | } 796 | 797 | startTranscription(opts, callback) { 798 | opts = opts || {}; 799 | let apiCall, bugname; 800 | if (opts.vendor.startsWith('custom:')) { 801 | apiCall = 'uuid_jambonz_transcribe'; 802 | bugname = `${opts.vendor}_transcribe`; 803 | } 804 | else { 805 | let vendor = opts.vendor; 806 | if (vendor === 'microsoft') vendor = 'azure'; 807 | if (vendor === 'polly') vendor = 'aws'; 808 | apiCall = `uuid_${vendor}_transcribe`; 809 | bugname = `${vendor}_transcribe`; 810 | } 811 | 812 | const type = opts.interim === true ? 'interim' : 'final'; 813 | const channels = opts.channels === 2 ? 'stereo' : 'mono'; 814 | const __x = (callback) => { 815 | const args = opts.hostport ? 816 | [this.uuid, 'start', opts.hostport, opts.locale || 'en-US', type, channels, opts.bugname || bugname] : 817 | [this.uuid, 'start', opts.locale || 'en-US', type, channels, opts.bugname || bugname]; 818 | if (opts.prompt) { 819 | const a = args.concat(opts.prompt); 820 | this.api(apiCall, `^^|${a.join('|')}`, (err, evt) => { 821 | if (err) return callback(err); 822 | const body = evt.getBody() ; 823 | if (0 === body.indexOf('+OK')) { 824 | return callback(null) ; 825 | } 826 | callback(new Error(body)) ; 827 | }); 828 | } 829 | else { 830 | this.api(apiCall, args, (err, evt) => { 831 | if (err) return callback(err); 832 | const body = evt.getBody() ; 833 | if (0 === body.indexOf('+OK')) { 834 | return callback(null) ; 835 | } 836 | callback(new Error(body)) ; 837 | }); 838 | } 839 | }; 840 | 841 | if (callback) { 842 | __x(callback) ; 843 | return this ; 844 | } 845 | 846 | return new Promise((resolve, reject) => { 847 | __x((err, result) => { 848 | if (err) return reject(err); 849 | resolve(result); 850 | }); 851 | }); 852 | } 853 | 854 | /** 855 | * Currently only available on Nuance 856 | */ 857 | startTranscriptionTimers(opts, callback) { 858 | if (typeof opts === 'function') { 859 | callback = opts; 860 | opts = {vendor: 'nuance'}; 861 | } 862 | let apiCall, bugname; 863 | switch (opts.vendor) { 864 | case 'nuance': 865 | apiCall = 'uuid_nuance_transcribe'; 866 | bugname = 'nuance_transcribe'; 867 | break; 868 | default: 869 | break; 870 | } 871 | const __x = (callback) => { 872 | this.api(apiCall, [this.uuid, 'start_timers', opts.bugname || bugname], (err, evt) => { 873 | if (err) return callback(err); 874 | const body = evt.getBody() ; 875 | if (0 === body.indexOf('+OK')) { 876 | return callback(null) ; 877 | } 878 | callback(new Error(body)) ; 879 | }); 880 | }; 881 | 882 | if (callback) { 883 | __x(callback) ; 884 | return this ; 885 | } 886 | 887 | return new Promise((resolve, reject) => { 888 | __x((err, result) => { 889 | if (err) return reject(err); 890 | resolve(result); 891 | }); 892 | }); 893 | } 894 | 895 | stopTranscription(opts, callback) { 896 | if (typeof opts === 'function') { 897 | callback = opts; 898 | opts = {vendor: 'google'}; 899 | } 900 | let apiCall, bugname; 901 | if (opts.vendor.startsWith('custom:')) { 902 | apiCall = 'uuid_jambonz_transcribe'; 903 | bugname = `${opts.vendor}_transcribe`; 904 | } 905 | else { 906 | let vendor = opts.vendor; 907 | if (vendor === 'microsoft') vendor = 'azure'; 908 | if (vendor === 'polly') vendor = 'aws'; 909 | apiCall = `uuid_${vendor}_transcribe`; 910 | bugname = `${vendor}_transcribe`; 911 | } 912 | 913 | const __x = (callback) => { 914 | const args = [this.uuid, 'stop', opts.bugname || bugname]; 915 | this.api(apiCall, args, (err, evt) => { 916 | if (err) return callback(err); 917 | const body = evt.getBody() ; 918 | if (0 === body.indexOf('+OK')) { 919 | return callback(null) ; 920 | } 921 | callback(new Error(body)) ; 922 | }); 923 | }; 924 | 925 | if (callback) { 926 | __x(callback) ; 927 | return this ; 928 | } 929 | 930 | return new Promise((resolve, reject) => { 931 | __x((err, result) => { 932 | if (err) return reject(err); 933 | resolve(result); 934 | }); 935 | }); 936 | } 937 | /** 938 | * VAD detection 939 | * @param {string} opts.strategy = one-shot, continuous, default is one-shot 940 | * @param {string} [opts.mode] Default value is 2 941 | * -1 ("disable fvad, use native") 942 | * - 0 ("quality") 943 | * - 1 ("low bitrate") 944 | * - 2 ("aggressive") 945 | * - 3 ("very aggressive") 946 | * @param {string} [opts.silenceMs] - number of milliseconds of silence that must 947 | * come to transition from talking to stop talking, default is 250 ms 948 | * @param {object|string} [opts.voiceMs] - number of milliseconds of voice that 949 | * must come to transition to start talking, default is 150ms 950 | * @param {function} [callback] - callback invoked when api request completes 951 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 952 | * a reference to the Endpoint object 953 | * Freeswitch command 954 | * uuid_vad_detect [start|stop] [one-shot|continuous] mode silence-ms voice-ms [bugname] 955 | */ 956 | startVadDetection(opts, callback) { 957 | opts = opts || {}; 958 | const strategy = opts.strategy || 'one-shot'; 959 | const mode = opts.mode || 2; 960 | const silenceMs = opts.silenceMs || 250; 961 | const voiceMs = opts.voiceMs || 150; 962 | const bugname = opts.bugname || 'vad_detection'; 963 | 964 | const __x = (callback) => { 965 | const args = [this.uuid, 'start', strategy, mode, silenceMs, voiceMs, bugname]; 966 | this.api('uuid_vad_detect', args, (err, evt) => { 967 | if (err) return callback(err); 968 | const body = evt.getBody() ; 969 | if (0 === body.indexOf('+OK')) { 970 | return callback(null) ; 971 | } 972 | callback(new Error(body)) ; 973 | }); 974 | }; 975 | 976 | if (callback) { 977 | __x(callback) ; 978 | return this ; 979 | } 980 | 981 | return new Promise((resolve, reject) => { 982 | __x((err, result) => { 983 | if (err) return reject(err); 984 | resolve(result); 985 | }); 986 | }); 987 | } 988 | 989 | stopVadDetection(opts, callback) { 990 | opts = opts || {}; 991 | const bugname = opts.bugname || 'vad_detection'; 992 | const __x = (callback) => { 993 | const args = [this.uuid, 'stop', bugname]; 994 | this.api('uuid_vad_detect', args, (err, evt) => { 995 | if (err) return callback(err); 996 | const body = evt.getBody() ; 997 | if (0 === body.indexOf('+OK')) { 998 | return callback(null) ; 999 | } 1000 | callback(new Error(body)) ; 1001 | }); 1002 | }; 1003 | 1004 | if (callback) { 1005 | __x(callback) ; 1006 | return this ; 1007 | } 1008 | 1009 | return new Promise((resolve, reject) => { 1010 | __x((err, result) => { 1011 | if (err) return reject(err); 1012 | resolve(result); 1013 | }); 1014 | }); 1015 | } 1016 | 1017 | /** 1018 | * Fork audio from the endpoint to a remote websocket server 1019 | * @param {string} opts.wsUrl = url of remote web socket server 1020 | * @param {string} [opts.mixType] - mono, mixed, or stereo. Default: mono. 1021 | * @param {string} [opts.sampling] - 8k or 16k. Default: 16k 1022 | * @param {object|string} [opts.metadata] - metadata to send at beginning of call in a text frame 1023 | * @param {function} [callback] - callback invoked when api request completes 1024 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1025 | * a reference to the Endpoint object 1026 | * Freeswitch command 1027 | * [start | stop | send_text | pause | resume | graceful-shutdown | stop_play ] 1028 | * [wss-url | path] [mono | mixed | stereo] [8000 | 16000 | 24000 | 32000 | 64000] 1029 | * [bugname] [metadata] [bidrectionalAduo_enabled] [bidrectionalAduo_stream_enabled] 1030 | * [bidrectionalAduo_stream_samplerate] 1031 | */ 1032 | forkAudioStart(opts, callback) { 1033 | assert.ok(typeof opts.wsUrl === 'string', 'opts.wsUrl is required'); 1034 | const sampling = opts.sampling || '8000'; 1035 | const mix = opts.mixType || 'mono'; 1036 | assert.ok(['mono', 'mixed', 'stereo'].includes(mix), 1037 | 'opts.mixType must be \'mono\', \'mixed\', \'stereo\''); 1038 | 1039 | const __x = (callback) => { 1040 | // uudid start ws-url mono 8000 1041 | const args = [this.uuid, 'start', opts.wsUrl, mix, sampling]; 1042 | // bugname 1043 | args.push(opts.bugname || ''); 1044 | // metadata 1045 | if (opts.metadata) { 1046 | const text = typeof opts.metadata === 'string' ? 1047 | `'${opts.metadata}'` : 1048 | `'${JSON.stringify(opts.metadata)}'`; 1049 | args.push(text); 1050 | } else { 1051 | args.push(''); 1052 | } 1053 | // bidrectionalAduo_enabled 1054 | //jslint does not support opts.bidirectionalAudio?.enabled 1055 | args.push(opts.bidirectionalAudio ? opts.bidirectionalAudio.enabled || 'true' : 'true'); 1056 | //bidrectionalAduo_stream_enabled 1057 | args.push(opts.bidirectionalAudio ? opts.bidirectionalAudio.streaming || 'false' : 'false'); 1058 | //bidrectionalAduo_stream_samplerate 1059 | args.push(opts.bidirectionalAudio ? opts.bidirectionalAudio.sampleRate || '' : ''); 1060 | this.api('uuid_audio_fork', `^^|${args.join('|')}`, (err, evt) => { 1061 | if (err) return callback(err); 1062 | const body = evt.getBody() ; 1063 | if (0 === body.indexOf('+OK')) { 1064 | return callback(null) ; 1065 | } 1066 | callback(new Error(body)) ; 1067 | }); 1068 | }; 1069 | 1070 | if (callback) { 1071 | __x(callback) ; 1072 | return this ; 1073 | } 1074 | 1075 | return new Promise((resolve, reject) => { 1076 | __x((err, result) => { 1077 | if (err) return reject(err); 1078 | resolve(result); 1079 | }); 1080 | }); 1081 | } 1082 | 1083 | /** 1084 | * send a text frame to the remote websocket server 1085 | * @param {string} metadata - text or object (will be jsonified) to send 1086 | * @param {function} [callback] - callback invoked when api request completes 1087 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1088 | * a reference to the Endpoint object 1089 | */ 1090 | forkAudioSendText(bugname, metadata, callback) { 1091 | assert.ok(arguments.length >= 1 && arguments.length <= 3, 'Invalid number of arguments'); 1092 | const args = [this.uuid, 'send_text']; 1093 | if (arguments.length === 1 && typeof bugname === 'function') { 1094 | /* forkAudioStop(callback) */ 1095 | callback = bugname; 1096 | bugname = null; 1097 | metadata = null; 1098 | } else if (arguments.length === 1) { 1099 | if (typeof bugname === 'object' || bugname.startsWith('{') || bugname.startsWith('[')) { 1100 | /* forkAudioStop(metadata) */ 1101 | metadata = bugname; 1102 | bugname = null; 1103 | } 1104 | else { 1105 | /* forkAudioStop(bugname) */ 1106 | metadata = null; 1107 | } 1108 | } else if (arguments.length === 2) { 1109 | if (typeof metadata === 'function') { 1110 | callback = metadata; 1111 | if (typeof bugname === 'object' || bugname.startsWith('{') || bugname.startsWith('[')) { 1112 | /* forkAudioStop(metadata, callback) */ 1113 | metadata = bugname; 1114 | bugname = null; 1115 | } 1116 | else { 1117 | /* forkAudioStop(bugname, callback) */ 1118 | metadata = null; 1119 | } 1120 | } 1121 | } 1122 | assert(callback === undefined || typeof callback === 'function', 'callback must be a function'); 1123 | 1124 | if (metadata && typeof metadata === 'object') metadata = `'${JSON.stringify(metadata)}'`; 1125 | else if (metadata) metadata = `'${metadata}'`; 1126 | 1127 | if (bugname) args.push(bugname); 1128 | args.push(metadata); 1129 | 1130 | const __x = (callback) => { 1131 | this.api('uuid_audio_fork', args, (err, evt) => { 1132 | if (err) return callback(err); 1133 | const body = evt.getBody() ; 1134 | if (0 === body.indexOf('+OK')) { 1135 | return callback(null) ; 1136 | } 1137 | callback(new Error(body)) ; 1138 | }); 1139 | }; 1140 | 1141 | if (callback) { 1142 | __x(callback) ; 1143 | return this ; 1144 | } 1145 | 1146 | return new Promise((resolve, reject) => { 1147 | __x((err, result) => { 1148 | if (err) return reject(err); 1149 | resolve(result); 1150 | }); 1151 | }); 1152 | } 1153 | 1154 | /** 1155 | * Stop forking audio 1156 | * @param {string} [bugname] - bugname to stop forking 1157 | * @param {Object|string} [metadata] - metadata to send as text frame before closing 1158 | * @param {function} [callback] - callback invoked when api request completes 1159 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1160 | * a reference to the Endpoint object 1161 | */ 1162 | forkAudioStop(bugname, metadata, callback) { 1163 | const args = [this.uuid, 'stop']; 1164 | if (arguments.length === 1 && typeof bugname === 'function') { 1165 | /* forkAudioStop(callback) */ 1166 | callback = bugname; 1167 | bugname = null; 1168 | metadata = null; 1169 | } else if (arguments.length === 1) { 1170 | if (typeof bugname === 'object' || bugname.startsWith('{') || bugname.startsWith('[')) { 1171 | /* forkAudioStop(metadata) */ 1172 | metadata = bugname; 1173 | bugname = null; 1174 | } 1175 | else { 1176 | /* forkAudioStop(bugname) */ 1177 | metadata = null; 1178 | } 1179 | } else if (arguments.length === 2) { 1180 | if (typeof metadata === 'function') { 1181 | callback = metadata; 1182 | if (typeof bugname === 'object' || bugname.startsWith('{') || bugname.startsWith('[')) { 1183 | /* forkAudioStop(metadata, callback) */ 1184 | metadata = bugname; 1185 | bugname = null; 1186 | } 1187 | else { 1188 | /* forkAudioStop(bugname, callback) */ 1189 | metadata = null; 1190 | } 1191 | } 1192 | } 1193 | assert(callback === undefined || typeof callback === 'function', 'callback must be a function'); 1194 | 1195 | if (metadata && typeof metadata === 'object') metadata = `'${JSON.stringify(metadata)}'`; 1196 | else if (metadata) metadata = `'${metadata}'`; 1197 | 1198 | if (bugname) args.push(bugname); 1199 | if (metadata) args.push(metadata); 1200 | 1201 | const __x = (callback) => { 1202 | if (metadata) args.push(metadata); 1203 | debug(`calling uuid_audio_fork with args ${JSON.stringify(args)}`); 1204 | this.api('uuid_audio_fork', args, (err, evt) => { 1205 | if (err) return callback(err); 1206 | const body = evt.getBody() ; 1207 | if (0 === body.indexOf('+OK')) { 1208 | return callback(null) ; 1209 | } 1210 | callback(new Error(body)) ; 1211 | }); 1212 | }; 1213 | 1214 | if (callback) { 1215 | __x(callback) ; 1216 | return this ; 1217 | } 1218 | 1219 | return new Promise((resolve, reject) => { 1220 | __x((err, result) => { 1221 | if (err) return reject(err); 1222 | resolve(result); 1223 | }); 1224 | }); 1225 | } 1226 | 1227 | /** 1228 | * Pause sending audio over the websocket connection 1229 | * @param {function} [callback] - callback invoked when api request completes 1230 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1231 | * a reference to the Endpoint object 1232 | */ 1233 | forkAudioPause(bugname, callback) { 1234 | const args = [this.uuid, 'pause']; 1235 | if (arguments.length === 1) { 1236 | // If the function is called with 1 argument, could be bugname or callback. 1237 | if (typeof bugname === 'function') { 1238 | callback = bugname; 1239 | bugname = null; 1240 | } 1241 | } 1242 | if (bugname) args.push(bugname); 1243 | 1244 | const __x = (callback) => { 1245 | debug(`calling uuid_audio_fork with args ${JSON.stringify(args)}`); 1246 | this.api('uuid_audio_fork', args, (err, evt) => { 1247 | if (err) return callback(err); 1248 | const body = evt.getBody() ; 1249 | if (0 === body.indexOf('+OK')) { 1250 | return callback(null) ; 1251 | } 1252 | callback(new Error(body)) ; 1253 | }); 1254 | }; 1255 | 1256 | if (callback) { 1257 | __x(callback) ; 1258 | return this ; 1259 | } 1260 | 1261 | return new Promise((resolve, reject) => { 1262 | __x((err, result) => { 1263 | if (err) return reject(err); 1264 | resolve(result); 1265 | }); 1266 | }); 1267 | } 1268 | 1269 | /** 1270 | * Resume sending audio over the websocket connection 1271 | * @param {function} [callback] - callback invoked when api request completes 1272 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1273 | * a reference to the Endpoint object 1274 | */ 1275 | forkAudioResume(bugname, callback) { 1276 | const args = [this.uuid, 'resume']; 1277 | if (arguments.length === 1) { 1278 | // If the function is called with 1 argument, could be bugname or callback. 1279 | if (typeof bugname === 'function') { 1280 | callback = bugname; 1281 | bugname = null; 1282 | } 1283 | } 1284 | if (bugname) args.push(bugname); 1285 | 1286 | const __x = (callback) => { 1287 | debug(`calling uuid_audio_fork with args ${JSON.stringify(args)}`); 1288 | this.api('uuid_audio_fork', args, (err, evt) => { 1289 | if (err) return callback(err); 1290 | const body = evt.getBody() ; 1291 | if (0 === body.indexOf('+OK')) { 1292 | return callback(null) ; 1293 | } 1294 | callback(new Error(body)) ; 1295 | }); 1296 | }; 1297 | 1298 | if (callback) { 1299 | __x(callback) ; 1300 | return this ; 1301 | } 1302 | 1303 | return new Promise((resolve, reject) => { 1304 | __x((err, result) => { 1305 | if (err) return reject(err); 1306 | resolve(result); 1307 | }); 1308 | }); 1309 | } 1310 | 1311 | /** 1312 | * mute the endpoint 1313 | */ 1314 | mute(callback) { 1315 | this._muted = true; 1316 | return this.execute('set_mute', 'read true', callback); 1317 | } 1318 | 1319 | /** 1320 | * unmute the endpoint 1321 | */ 1322 | unmute(callback) { 1323 | this._muted = false; 1324 | return this.execute('set_mute', 'read false', callback); 1325 | } 1326 | 1327 | /** 1328 | * toggle the endpoint mute status 1329 | */ 1330 | toggleMute(callback) { 1331 | this._muted = !this._muted; 1332 | return this.execute('set_mute', `read ${this._muted ? 'true' : 'false'}`, callback); 1333 | } 1334 | 1335 | /** 1336 | * call a freeswitch api method 1337 | * @param {string} command command name 1338 | * @param {string} [args] command arguments 1339 | * @param {Endpoint~mediaOperationsCallback} [callback] callback function 1340 | * @return {Promise|Endpoint} if no callback specified, a Promise that resolves with the response is returned 1341 | * otherwise a reference to the endpoint object 1342 | */ 1343 | api(command, args, callback) { 1344 | if (typeof args === 'function') { 1345 | callback = args ; 1346 | args = [] ; 1347 | } 1348 | 1349 | const __x = (callback) => { 1350 | if (!this._conn) return callback(new Error('endpoint no longer active')); 1351 | debug(`Endpoint#api ${command} ${args || ''}`); 1352 | this._conn.api(command, args, (...response) => { 1353 | debug(`Endpoint#api response: ${JSON.stringify(response).slice(0, 512)}`); 1354 | callback(null, ...response); 1355 | }); 1356 | } ; 1357 | 1358 | if (callback) { 1359 | __x(callback) ; 1360 | return this ; 1361 | } 1362 | 1363 | return new Promise((resolve, reject) => { 1364 | __x((err, response) => { 1365 | if (err) return reject(err); 1366 | resolve(response); 1367 | }); 1368 | }); 1369 | } 1370 | 1371 | /** 1372 | * execute a freeswitch application 1373 | * @param {string} app application name 1374 | * @param {string} [arg] application arguments, if any 1375 | * @param {Endpoint~mediaOperationsCallback} [callback] callback function 1376 | * @return {Promise|Endpoint} if no callback specified, a Promise that resolves with the response is returned 1377 | * otherwise a reference to the endpoint object 1378 | */ 1379 | execute(app, arg, callback) { 1380 | if (typeof arg === 'function') { 1381 | callback = arg ; 1382 | arg = ''; 1383 | } 1384 | 1385 | const __x = (callback) => { 1386 | if (!this._conn) return callback(new Error('endpoint no longer active')); 1387 | debug(`Endpoint#execute ${app} ${arg}`); 1388 | this._conn.execute(app, arg, (evt) => { 1389 | callback(null, evt); 1390 | }); 1391 | } ; 1392 | 1393 | if (callback) { 1394 | __x(callback) ; 1395 | return this ; 1396 | } 1397 | 1398 | return new Promise((resolve, reject) => { 1399 | __x((err, response) => { 1400 | if (err) return reject(err); 1401 | resolve(response); 1402 | }); 1403 | }); 1404 | 1405 | } 1406 | 1407 | executeAsync(app, arg, callback) { 1408 | return this._conn.execute(app, arg, callback); 1409 | } 1410 | 1411 | /** 1412 | * Modify endpoint to stream to new sdp 1413 | */ 1414 | modify(newSdp) { 1415 | let result; 1416 | return this._dialog.modify(newSdp) 1417 | .then((res) => { 1418 | result = res; 1419 | return this.getChannelVariables(true); 1420 | }) 1421 | .then((obj) => { 1422 | this.local.sdp = obj['variable_rtp_local_sdp_str'] ; 1423 | this.local.mediaIp = obj['variable_local_media_ip'] ; 1424 | this.local.mediaPort = obj['variable_local_media_port'] ; 1425 | 1426 | this.remote.sdp = obj['variable_switch_r_sdp'] ; 1427 | this.remote.mediaIp = obj['variable_remote_media_ip'] ; 1428 | this.remote.mediaPort = obj['variable_remote_media_port'] ; 1429 | 1430 | this.dtmfType = obj['variable_dtmf_type'] ; 1431 | 1432 | return result; 1433 | }); 1434 | } 1435 | 1436 | /** 1437 | * Releases an Endpoint and associated resources 1438 | * @param {Endpoint~operationsCallback} [callback] callback function invoked after endpoint has been released 1439 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1440 | * a reference to the Endpoint object 1441 | */ 1442 | destroy(callback) { 1443 | const __x = (callback) => { 1444 | if (State.CONNECTED !== this.state) { 1445 | // presumably already deleted, just return success 1446 | return callback(null); 1447 | } 1448 | this.state = State.DISCONNECTED ; 1449 | 1450 | if (!this.conn) { 1451 | this._dialog = null; 1452 | return callback(null); 1453 | } 1454 | 1455 | this.dialog.once('destroy', () => { 1456 | debug(`Endpoint#destroy - received BYE for ${this.uuid}`); 1457 | callback(null) ; 1458 | this._dialog = null; 1459 | }); 1460 | 1461 | debug(`Endpoint#destroy: executing hangup on ${this.uuid}`); 1462 | this.execute('hangup', (err, evt) => { 1463 | if (err) { 1464 | debug(`got error hanging up endpoint ${this.uuid}: ${err.message}`); 1465 | callback(err); 1466 | } 1467 | }); 1468 | }; 1469 | 1470 | if (callback) { 1471 | __x(callback) ; 1472 | return this ; 1473 | } 1474 | 1475 | return new Promise((resolve, reject) => { 1476 | __x((err, result) => { 1477 | if (err) return reject(err); 1478 | resolve(result); 1479 | }); 1480 | }); 1481 | } 1482 | 1483 | // endpoint applications 1484 | 1485 | /** 1486 | * record the full call 1487 | * @file {string} file - file to record to 1488 | * @param {endpointOperationCallback} [callback] - callback invoked with response to record command 1489 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1490 | * a reference to the Endpoint object 1491 | */ 1492 | recordSession(...args) { return endpointApps.recordSession(this, ...args); } 1493 | 1494 | /** 1495 | * record to a file from the endpoint's input stream 1496 | * @param {string} file file to record to 1497 | * @param {Endpoint~recordOptions} opts - record command options 1498 | * @param {endpointRecordCallback} [callback] - callback invoked with response to record command 1499 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1500 | * a reference to the Endpoint object 1501 | */ 1502 | record(file, opts, callback) { 1503 | if (typeof opts === 'function') { 1504 | callback = opts ; 1505 | opts = {} ; 1506 | } 1507 | opts = opts || {} ; 1508 | 1509 | const args = [] ; 1510 | ['timeLimitSecs', 'silenceThresh', 'silenceHits'].forEach((p) => { 1511 | if (opts[p]) { 1512 | args.push(opts[p]); 1513 | } 1514 | }); 1515 | 1516 | const __x = (callback) => { 1517 | this.execute('record', `${file} ${args.join(' ')}`, (err, evt) => { 1518 | if (err) return callback(err, evt); 1519 | const application = evt.getHeader('Application'); 1520 | if ('record' !== application) { 1521 | return callback(new Error(`Unexpected application in record response: ${application}`)) ; 1522 | } 1523 | 1524 | callback(null, { 1525 | terminatorUsed: evt.getHeader('variable_playback_terminator_used'), 1526 | recordSeconds: evt.getHeader('variable_record_seconds'), 1527 | recordMilliseconds: evt.getHeader('variable_record_ms'), 1528 | recordSamples: evt.getHeader('variable_record_samples'), 1529 | }) ; 1530 | }) ; 1531 | } ; 1532 | 1533 | if (callback) { 1534 | __x(callback) ; 1535 | return this ; 1536 | } 1537 | 1538 | return new Promise((resolve, reject) => { 1539 | __x((err, result) => { 1540 | if (err) return reject(err); 1541 | resolve(result); 1542 | }); 1543 | }); 1544 | } 1545 | 1546 | // conference member operations 1547 | 1548 | /** 1549 | * mute the member 1550 | * @param {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes 1551 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1552 | * a reference to the Endpoint object 1553 | */ 1554 | confMute(...args) { return confOperations.mute(this, ...args); } 1555 | 1556 | /** 1557 | * unmute the member 1558 | * @param {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes 1559 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1560 | * a reference to the Endpoint object 1561 | */ 1562 | confUnmute(...args) { return confOperations.unmute(this, ...args); } 1563 | 1564 | /** 1565 | * deaf the member 1566 | * @param {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes 1567 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1568 | * a reference to the Endpoint object 1569 | */ 1570 | 1571 | confDeaf(...args) { return confOperations.deaf(this, ...args); } 1572 | /** 1573 | * undeaf the member 1574 | * @param {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes 1575 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1576 | * a reference to the Endpoint object 1577 | */ 1578 | confUndeaf(...args) { return confOperations.undeaf(this, ...args); } 1579 | 1580 | /** 1581 | * kick the member out of the conference 1582 | * @param {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes 1583 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1584 | * a reference to the Endpoint object 1585 | */ 1586 | confKick(...args) { return confOperations.kick(this, ...args); } 1587 | 1588 | /** 1589 | * kick the member out of the conference without exit sound 1590 | * @param {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes 1591 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1592 | * a reference to the Endpoint object 1593 | */ 1594 | confHup(...args) { return confOperations.hup(this, ...args); } 1595 | 1596 | /** 1597 | * play a file to the member 1598 | * @param string file - file to play 1599 | * @param {Endpoint~playOptions} [opts] - play options 1600 | * @param {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes 1601 | * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise 1602 | * a reference to the ConferenceConnection object 1603 | */ 1604 | confPlay(...args) { return confOperations.play(this, ...args); } 1605 | 1606 | /** 1607 | * transfer a member to a new conference 1608 | * @param {String} newConf - name of new conference to transfer to 1609 | * @param {ConferenceConnection~mediaOperationsCallback} [cb] - callback invoked when transfer has completed 1610 | */ 1611 | transfer(...args) { return confOperations.transfer(this, ...args); } 1612 | 1613 | __onConferenceEvent(evt) { 1614 | const eventName = evt.getHeader('Event-Subclass') ; 1615 | 1616 | if (eventName === 'conference::maintenance') { 1617 | const action = evt.getHeader('Action') ; 1618 | debug(`Endpoint#__onConferenceEvent: conference event action: ${action}`) ; 1619 | 1620 | //invoke a handler for this action, if we have defined one 1621 | (Endpoint.prototype['_on' + upperFirst(camelCase(action))] || this._unhandled).bind(this, evt)() ; 1622 | 1623 | } 1624 | else { 1625 | debug(`Endpoint#__onConferenceEvent: got unhandled custom event: ${eventName}`) ; 1626 | } 1627 | } 1628 | 1629 | _onAddMember(evt) { 1630 | let memberId = -1; 1631 | const confUuid = evt.getHeader('Conference-Unique-ID'); 1632 | try { 1633 | memberId = parseInt(evt.getHeader('Member-ID')); 1634 | } catch (err) { 1635 | debug(`Endpoint#_onAddMember: error parsing memberId as an int: ${memberId}`); 1636 | } 1637 | debug(`Endpoint#_onAddMember: memberId ${memberId} conference uuid ${confUuid}`) ; 1638 | assert.ok(typeof this._joinCallback, 'function'); 1639 | this._joinCallback(memberId, confUuid) ; 1640 | } 1641 | 1642 | _unhandled(evt) { 1643 | debug(`unhandled Conference event for endpoint ${this.uuid} with action: ${evt.getHeader('Action')}`) ; 1644 | } 1645 | 1646 | _onError(err) { 1647 | if (err.errno && (err.errno === 'ECONNRESET' || err.errno === 'EPIPE') && this.state === State.DISCONNECTED) { 1648 | debug('ignoring connection reset error during teardown of connection') ; 1649 | return ; 1650 | } 1651 | console.error(`Endpoint#_onError: uuid: ${this.uuid}: ${err}`) ; 1652 | } 1653 | 1654 | _onChannelCallState(evt) { 1655 | const channelCallState = evt.getHeader('Channel-Call-State') ; 1656 | 1657 | debug(`Endpoint#_onChannelCallState ${this.uuid}: Channel-Call-State: ${channelCallState}`) ; 1658 | if (State.NOT_CONNECTED === this.state && 'EARLY' === channelCallState) { 1659 | this.state = State.EARLY ; 1660 | 1661 | // if we are using DLTS-SRTP, the 200 OK has been sent at this point; 1662 | // however, answer will not be sent by FSW until the handshake. 1663 | // We need to invoke the callback provided in the constructor now 1664 | // in order to allow the calling app to access the endpoint. 1665 | if (this.secure) { 1666 | this.getChannelVariables(true, (obj) => { 1667 | this.local.sdp = obj['variable_rtp_local_sdp_str'] ; 1668 | this.local.mediaIp = obj['variable_local_media_ip'] ; 1669 | this.local.mediaPort = obj['variable_local_media_port'] ; 1670 | 1671 | this.remote.sdp = obj['variable_switch_r_sdp'] ; 1672 | this.remote.mediaIp = obj['variable_remote_media_ip'] ; 1673 | this.remote.mediaPort = obj['variable_remote_media_port'] ; 1674 | 1675 | this.dtmfType = obj['variable_dtmf_type'] ; 1676 | this.sip.callId = obj['variable_sip_call_id'] ; 1677 | 1678 | this.emitReady() ; 1679 | }) ; 1680 | } 1681 | } 1682 | 1683 | if ('HANGUP' === channelCallState && State.CONNECTED === this.state) { 1684 | debug(`Endpoint#_onChannelCallState ${this.uuid}: got BYE from Freeswitch end of call`); 1685 | this.state = State.DISCONNECTED; 1686 | const reason = evt.getHeader('Hangup-Cause') ; 1687 | this.emit('destroy', {reason}); 1688 | } 1689 | 1690 | this.emit('channelCallState', {state: channelCallState}); 1691 | } 1692 | 1693 | _onDtmf(evt) { 1694 | if ('DTMF' === evt.getHeader('Event-Name')) { 1695 | const args = { 1696 | dtmf: evt.getHeader('DTMF-Digit'), 1697 | duration: evt.getHeader('DTMF-Duration'), 1698 | source: evt.getHeader('DTMF-Source') 1699 | }; 1700 | if (evt.getHeader('DTMF-SSRC')) args.ssrc = evt.getHeader('DTMF-SSRC'); 1701 | if (evt.getHeader('DTMF-Timestamp')) args.timestamp = evt.getHeader('DTMF-Timestamp'); 1702 | this.emit('dtmf', args); 1703 | } 1704 | } 1705 | 1706 | _onToneDetect(evt) { 1707 | let tone = evt.getHeader('Detected-Tone'); 1708 | if (!tone && evt.getHeader('Detected-Fax-Tone') === 'true') tone = 'fax'; 1709 | this.emit('tone', {tone}); 1710 | } 1711 | 1712 | _onPlaybackStart(evt) { 1713 | if (evt.getHeader('Playback-File-Type') === 'tts_stream') { 1714 | let header; 1715 | const opts = {}; 1716 | evt.firstHeader(); 1717 | do { 1718 | header = evt.nextHeader(); 1719 | if (header && header.startsWith('variable_tts_')) opts[header] = evt.getHeader(header); 1720 | } while (header); 1721 | this.emit('playback-start', opts); 1722 | } 1723 | else this.emit('playback-start', {file: evt.getHeader('Playback-File-Path')}); 1724 | } 1725 | 1726 | _onPlaybackStop(evt) { 1727 | if (evt.getHeader('Playback-File-Type') === 'tts_stream') { 1728 | let header; 1729 | const opts = {}; 1730 | evt.firstHeader(); 1731 | do { 1732 | header = evt.nextHeader(); 1733 | if (header && header.startsWith('variable_tts_')) opts[header] = evt.getHeader(header); 1734 | } while (header); 1735 | this.emit('playback-stop', opts); 1736 | } 1737 | else this.emit('playback-stop', {file: evt.getHeader('Playback-File-Path')}); 1738 | } 1739 | 1740 | _emitReady() { 1741 | if (!this._ready) { 1742 | this._ready = true ; 1743 | setImmediate(() => { 1744 | this.emit('ready'); 1745 | }); 1746 | } 1747 | } 1748 | 1749 | _onHangup(evt) { 1750 | /* 1751 | if (State.DISCONNECTED !== this.state) { 1752 | this.conn.disconnect(); 1753 | } 1754 | this.state = State.DISCONNECTED ; 1755 | this.emit('hangup', evt) ; 1756 | */ 1757 | } 1758 | 1759 | _onBye(evt) { 1760 | debug('Endpoint#_onBye: got BYE from media server') ; 1761 | this.emit('destroy') ; 1762 | } 1763 | 1764 | toJSON() { 1765 | return only(this, 'sip local remote uuid') ; 1766 | } 1767 | 1768 | toString() { 1769 | return this.toJSON().toString() ; 1770 | } 1771 | } 1772 | 1773 | /** 1774 | * Options governing the creation of an Endpoint 1775 | * @typedef {Object} Endpoint~createOptions 1776 | * @property {string} [debugDir] directory into which message trace files; 1777 | * the presence of this param will enable debug tracing 1778 | * @property {string|array} [codecs] preferred codecs; array order indicates order of preference 1779 | * 1780 | */ 1781 | 1782 | /** 1783 | * This callback is invoked when an endpoint has been created and is ready for commands. 1784 | * @callback Endpoint~createCallback 1785 | * @param {Error} error 1786 | * @param {Endpoint} ep the Endpoint 1787 | */ 1788 | 1789 | /** 1790 | * Options governing a play command 1791 | * @typedef {Object} Endpoint~playCollectOptions 1792 | * @property {String} file - file to play as a prompt 1793 | * @property {number} [min=0] minimum number of digits to collect 1794 | * @property {number} [max=128] maximum number of digits to collect 1795 | * @property {number} [tries=1] number of times to prompt before returning failure 1796 | * @property {String} [invalidFile=silence_stream://250] file or prompt to play when invalid digits are entered 1797 | * @property {number} [timeout=120000] total timeout in millseconds to wait for digits after prompt completes 1798 | * @property {String} [terminators=#] one or more keys which, if pressed, will terminate 1799 | * digit collection and return collected digits 1800 | * @property {String} [varName=myDigitBuffer] name of freeswitch variable to use to collect digits 1801 | * @property {String} [regexp=\\d+] regular expression to use to govern digit collection 1802 | * @property {number} [digitTimeout=8000] inter-digit timeout, in milliseconds 1803 | */ 1804 | /** 1805 | * Options governing a record command 1806 | * @typedef {Object} Endpoint~recordOptions 1807 | * @property {number} [timeLimitSecs] max duration of recording in seconds 1808 | * @property {number} [silenceThresh] energy levels below this are considered silence 1809 | * @property {number} [silenceHits] number of packets of silence after which to terminate the recording 1810 | */ 1811 | 1812 | /** 1813 | * Options governing a Playback command 1814 | * @typedef {Object} Endpoint~PlaybackOptions 1815 | * @property {String} [file] required, the name of the file to play 1816 | * @property {number} [seekOffset] optional, the seek offset to start play (integer, default: 0) 1817 | * @property {number} [timeoutSecs] optional, the number of seconds after 1818 | * which to automatically stop the play, (integer, default: play entire file) 1819 | */ 1820 | 1821 | /** 1822 | * This callback is invoked when a media operation has completed 1823 | * @callback Endpoint~playCollectOperationCallback 1824 | * @param {Error} err - error returned from play request 1825 | * @param {object} results - results of the operation 1826 | * @param {String} results.digits - digits collected, if any 1827 | * @param {String} results.terminatorUsed - termination key pressed, if any 1828 | * @param {String} results.playbackSeconds - number of seconds of audio played 1829 | * @param {String} results.playbackMilliseconds - number of fractional milliseconds of audio played 1830 | */ 1831 | 1832 | /** 1833 | * This callback is invoked when an operation has completed on the endpoint 1834 | * @callback Endpoint~operationCallback 1835 | * @param {Error} err - error returned from play request 1836 | */ 1837 | /** 1838 | * This callback is invoked when a freeswitch command has completed on the endpoint 1839 | * @callback Endpoint~mediaOperationsCallback 1840 | * @param {Error} err - error returned from play request 1841 | * @param {object} results freeswitch results 1842 | */ 1843 | 1844 | /** 1845 | * Speak a phrase that requires grammar rules 1846 | * @param {string} text phrase to speak 1847 | * @param {Endpoint~sayOptions} opts - say command options 1848 | * @param {Endpoint~playOperationCallback} cb - callback function invoked when operation completes 1849 | */ 1850 | 1851 | /** 1852 | * Options governing a say command 1853 | * @typedef {Object} Endpoint~sayOptions 1854 | * @property {String} sayType describes the type word or phrase that is being spoken; 1855 | * must be one of the following: 'number', 'items', 'persons', 'messages', 'currency', 'time_measurement', 1856 | * 'current_date', 'current_time', 'current_date_time', 'telephone_number', 'telephone_extensio', 'url', 1857 | * 'ip_address', 'email_address', 'postal_address', 'account_number', 'name_spelled', 1858 | * 'name_phonetic', 'short_date_time'. 1859 | * @property {String} sayMethod method of speaking; must be one of the following: 'pronounced', 'iterated', 'counted'. 1860 | * @property {String} [lang=en] language to speak 1861 | * @property {String} [gender] gender of voice to use, if provided must be one of: 'feminine','masculine','neuter'. 1862 | */ 1863 | 1864 | /** 1865 | * Options governing a join operation between an endpoint and a conference 1866 | * @typedef {Object} Endpoint~confJoinOptions 1867 | * @property {string} [pin] entry pin for the conference 1868 | * @property {string} [profile=default] conference profile to use 1869 | * @property {Object} [flags] parameters governing the connection of the endpoint to the conference 1870 | * @property {boolean} [flags.mute=false] enter the conference muted 1871 | * @property {boolean} [flags.deaf=false] enter the conference deaf'ed (can not hear) 1872 | * @property {boolean} [flags.muteDetect=false] Play the mute_detect_sound when 1873 | * talking detected by this conferee while muted 1874 | * @property {boolean} [flags.distDtmf=false] Send any DTMF from this member to all participants 1875 | * @property {boolean} [flags.moderator=false] Flag member as a moderator 1876 | * @property {boolean} [flags.nomoh=false] Disable music on hold when this member is the only member in the conference 1877 | * @property {boolean} [flags.endconf=false] Ends conference when all 1878 | * members with this flag leave the conference after profile param endconf-grace-time has expired 1879 | * @property {boolean} [flags.mintwo=false] End conference when it drops below 1880 | * 2 participants after a member enters with this flag 1881 | * @property {boolean} [flags.ghost=false] Do not count member in conference tally 1882 | * @property {boolean} [flags.joinOnly=false] Only allow joining a conference that already exists 1883 | * @property {boolean} [flags.positional=false] Process this member for positional audio on stereo outputs 1884 | * @property {boolean} [flags.noPositional=false] Do not process this member for positional audio on stereo outputs 1885 | * @property {boolean} [flags.joinVidFloor=false] Locks member as the video floor holder 1886 | * @property {boolean} [flags.noMinimizeEncoding] Bypass the video transcode minimizer 1887 | * and encode the video individually for this member 1888 | * @property {boolean} [flags.vmute=false] Enter conference video muted 1889 | * @property {boolean} [flags.secondScreen=false] Open a 'view only' connection to the conference, 1890 | * without impacting the conference count or data. 1891 | * @property {boolean} [flags.waitMod=false] Members will wait (with music) until a member 1892 | * with the 'moderator' flag set enters the conference 1893 | * @property {boolean} [flags.audioAlways=false] Do not use energy detection to choose which 1894 | * participants to mix; instead always mix audio from all members 1895 | * @property {boolean} [flags.videoBridgeFirstTwo=false] In mux mode, If there are only 2 people 1896 | * in conference, you will see only the other member 1897 | * @property {boolean} [flags.videoMuxingPersonalCanvas=false] In mux mode, each member will get their own canvas 1898 | * and they will not see themselves 1899 | * @property {boolean} [flags.videoRequiredForCanvas=false] Only video participants will be 1900 | * shown on the canvas (no avatars) 1901 | */ 1902 | /** 1903 | * This callback is invoked when a join operation between an Endpoint and a conference has completed 1904 | * @callback Endpoint~joinOperationCallback 1905 | * @param {Error} err - error returned from join request 1906 | * @param {ConferenceConnection} conn - object representing the connection of this participant to the conference 1907 | */ 1908 | 1909 | /** 1910 | * This callback is invoked when an endpoint has been destroyed / released. 1911 | * @callback Endpoint~destroyCallback 1912 | * @param {Error} error, if any 1913 | */ 1914 | 1915 | 1916 | /** execute a freeswitch application on the endpoint 1917 | * @method Endpoint#execute 1918 | * @param {string} app - application to execute 1919 | * @param {string | Array} [args] - arguments 1920 | * @param {Endpoint~mediaOperationCallback} cb - callback invoked when a 1921 | * CHANNEL_EXECUTE_COMPLETE is received for the application 1922 | */ 1923 | /** returns true if the Endpoint is in the 'connected' state 1924 | * @name Endpoint#connected 1925 | * @method 1926 | */ 1927 | 1928 | /** modify the endpoint by changing attributes of the media connection 1929 | * @name Endpoint#modify 1930 | * @method 1931 | * @param {string} sdp - 'hold', 'unhold', or a session description protocol 1932 | * @param {Endpoint~modifyCallback} [callback] - callback invoked when operation has completed 1933 | */ 1934 | /** 1935 | * This callback provides the response to a join request. 1936 | * @callback Endpoint~confJoinCallback 1937 | * @param {Error} err error returned from freeswitch, if any 1938 | * @param {Object} obj an object containing {memberId, conferenceUuid} properties 1939 | */ 1940 | /** 1941 | * This callback provides the response to a modifySession request. 1942 | * @callback Endpoint~modifyCallback 1943 | * @param {Error} err non-success sip response code received from far end 1944 | */ 1945 | /** 1946 | * This callback provides the response to a endpoint operation request of some kind. 1947 | * @callback Endpoint~endpointOperationCallback 1948 | * @param {Error} err - null if operation succeeds; otherwises provides an indication of the error 1949 | */ 1950 | /** 1951 | * This callback is invoked when the response is received to a command executed on the endpoint 1952 | * @callback Endpoint~mediaOperationCallback 1953 | * @param {Error} err error returned from freeswitch, if any 1954 | * @param {Object} response - response to the command 1955 | */ 1956 | /** 1957 | * This callback is invoked when the response is received to a command executed on the endpoint 1958 | * @callback Endpoint~getChannelVariablesCallback 1959 | * @param {Error} err error returned from freeswitch, if any 1960 | * @param {Object} obj - an object with key-value pairs where the key is channel variable name 1961 | * and the value is the associated value 1962 | */ 1963 | 1964 | /** 1965 | * Information describing either the local or remote end of a connection to an Endpoint 1966 | * @typedef {Object} Endpoint~NetworkConnection 1967 | * @property {String} sdp - session description protocol offered 1968 | */ 1969 | /** 1970 | * Information describing the SIP Dialog that established the Endpoint 1971 | * @typedef {Object} Endpoint~SipInfo 1972 | * @property {String} callId - SIP Call-ID 1973 | */ 1974 | /** 1975 | * destroy event triggered when the Endpoint is destroyed by the media server. 1976 | * @event Endpoint#destroy 1977 | */ 1978 | 1979 | 1980 | delegate(Endpoint.prototype, '_conn') 1981 | .method('connected') 1982 | .method('filter') ; 1983 | 1984 | delegate(Endpoint.prototype, '_dialog') 1985 | .method('request'); 1986 | // .method('modify') ; 1987 | 1988 | module.exports = exports = Endpoint ; 1989 | 1990 | const confOperations = {} ; 1991 | 1992 | // conference member unary operations 1993 | ['mute', 'unmute', 'deaf', 'undeaf', 'kick', 'hup', 'tmute', 'vmute', 'unvmute', 1994 | 'vmute-snap', 'saymember', 'dtmf'].forEach((op) => { 1995 | confOperations[op] = (endpoint, args, callback) => { 1996 | assert(endpoint instanceof Endpoint); 1997 | if (typeof args === 'function') { 1998 | callback = args ; 1999 | args = '' ; 2000 | } 2001 | args = args || ''; 2002 | if (Array.isArray(args)) args = args.join(' '); 2003 | 2004 | const __x = (callback) => { 2005 | if (!endpoint.conf.memberId) return callback(new Error('Endpoint not in conference')); 2006 | endpoint.api('conference', `${endpoint.conf.name} ${op} ${endpoint.conf.memberId} ${args}`, (err, evt) => { 2007 | if (err) return callback(err, evt); 2008 | const body = evt.getBody() ; 2009 | if (-1 !== ['mute', 'deaf', 'unmute', 'undeaf', 'kick', 'tmute', 'vmute', 'unvmute', 2010 | 'vmute-snap', 'dtmf'].indexOf(op)) { 2011 | if (/OK\s+/.test(body)) return callback(err, body); 2012 | return callback(new Error(body)); 2013 | } 2014 | return callback(err, evt); 2015 | }) ; 2016 | }; 2017 | 2018 | if (callback) { 2019 | __x(callback) ; 2020 | return this ; 2021 | } 2022 | 2023 | return new Promise((resolve, reject) => { 2024 | __x((err, result) => { 2025 | if (err) return reject(err); 2026 | resolve(result); 2027 | }); 2028 | }); 2029 | }; 2030 | }); 2031 | 2032 | // alias 2033 | Endpoint.prototype.unjoin = Endpoint.prototype.confKick ; 2034 | 2035 | confOperations.play = (endpoint, file, opts, callback) => { 2036 | debug(`Endpoint#confPlay endpoint ${endpoint.uuid} memberId ${endpoint.conf.memberId}`); 2037 | assert.ok(typeof file === 'string', '\'file\' is required and must be a file to play') ; 2038 | 2039 | if (typeof opts === 'function') { 2040 | callback = opts ; 2041 | opts = {} ; 2042 | } 2043 | opts = opts || {} ; 2044 | 2045 | const __x = (callback) => { 2046 | if (!endpoint.conf.memberId) return callback(new Error('Endpoint not in conference')); 2047 | 2048 | const args = [] ; 2049 | if (opts.vol) args.push('vol=' + opts.volume) ; 2050 | if (opts.fullScreen) args.push('full-screen=' + opts.fullScreen) ; 2051 | if (opts.pngMs) args.push('png_ms=' + opts.pngMs) ; 2052 | const s1 = args.length ? args.join(',') + ' ' : ''; 2053 | const cmdArgs = `${endpoint.conf.name} play ${file} ${s1} ${endpoint.conf.memberId}`; 2054 | 2055 | endpoint.api('conference', cmdArgs, (err, evt) => { 2056 | const body = evt.getBody() ; 2057 | if (/Playing file.*to member/.test(body)) return callback(null, evt); 2058 | callback(new Error(body)); 2059 | }); 2060 | }; 2061 | 2062 | if (callback) { 2063 | __x(callback) ; 2064 | return this ; 2065 | } 2066 | 2067 | return new Promise((resolve, reject) => { 2068 | __x((err, results) => { 2069 | if (err) return reject(err); 2070 | resolve(results); 2071 | }); 2072 | }); 2073 | }; 2074 | 2075 | confOperations.transfer = (endpoint, newConf, callback) => { 2076 | const confName = newConf instanceof Conference ? newConf.name : newConf; 2077 | assert.ok(typeof confName === 'string', '\'newConf\' is required and is the name of the conference to transfer to') ; 2078 | 2079 | const __x = (callback) => { 2080 | if (!endpoint.conf.memberId) return callback(new Error('Endpoint not in conference')); 2081 | 2082 | endpoint.api('conference', `${endpoint.conf.name} transfer ${confName} ${endpoint.conf.memberId}`, (err, evt) => { 2083 | if (err) return callback(err, evt); 2084 | const body = evt.getBody() ; 2085 | if (/OK Member.*sent to conference/.test(body)) return callback(null, body); 2086 | callback(new Error(body)); 2087 | }) ; 2088 | }; 2089 | 2090 | if (callback) { 2091 | __x(callback) ; 2092 | return this ; 2093 | } 2094 | 2095 | return new Promise((resolve, reject) => { 2096 | __x((err, result) => { 2097 | if (err) return reject(err); 2098 | resolve(result); 2099 | }); 2100 | }); 2101 | }; 2102 | 2103 | function setOrExport(which, endpoint, param, value, callback) { 2104 | assert(which === 'set' || which === 'export'); 2105 | assert(typeof param === 'string' || 2106 | (typeof param === 'object' && (typeof value == 'function' || typeof value === 'undefined'))); 2107 | 2108 | const obj = {} ; 2109 | if (typeof param === 'string') obj[param] = value ; 2110 | else { 2111 | Object.assign(obj, param) ; 2112 | callback = value ; 2113 | } 2114 | 2115 | const __x = async(callback) => { 2116 | const p = []; 2117 | if (which === 'set' && Object.keys(obj).length > 1) { 2118 | // Filter out entries with the characters that are problematic for multiset 2119 | const hasSpecialChar = (str) => { 2120 | if (typeof str !== 'string') return false; 2121 | return str.includes('^') || str.includes('\n') || str.includes('"') || str.includes("'"); 2122 | }; 2123 | const singleEntries = Object.entries(obj).filter(([_, value]) => hasSpecialChar(value)); 2124 | const multiEntries = Object.entries(obj).filter(([_, value]) => !hasSpecialChar(value)); 2125 | 2126 | if (multiEntries.length) { 2127 | const args = multiEntries.map(([key, value]) => `${key}=${value}`).join('^'); 2128 | p.push(endpoint.execute('multiset', `^^^${args}`)); 2129 | } 2130 | 2131 | singleEntries.forEach(([key, value]) => { 2132 | p.push(endpoint.execute(which, `${key}=${value}`)); 2133 | }); 2134 | } 2135 | else { 2136 | for (const [key, value] of Object.entries(obj)) { 2137 | p.push(endpoint.execute(which, `${key}=${value}`)); 2138 | } 2139 | } 2140 | await Promise.all(p); 2141 | callback(null); 2142 | } ; 2143 | 2144 | if (callback) { 2145 | __x(callback) ; 2146 | return endpoint ; 2147 | } 2148 | 2149 | return new Promise((resolve, reject) => { 2150 | __x((err, results) => { 2151 | if (err) return reject(err); 2152 | resolve(results); 2153 | }); 2154 | }); 2155 | } 2156 | 2157 | const endpointApps = {} ; 2158 | for (const [key, value] of Object.entries({recordSession: 'record_session'})) { 2159 | endpointApps[key] = (endpoint, ...args) => { 2160 | const len = args.length ; 2161 | let argList = args ; 2162 | let callback = null ; 2163 | 2164 | if (typeof args[len - 1] === 'function') { 2165 | argList = args.slice(0, len - 1); 2166 | callback = args[len - 1]; 2167 | } 2168 | const __x = (callback) => { 2169 | endpoint.execute(value, argList.join(' '), callback); 2170 | }; 2171 | 2172 | if (callback) { 2173 | __x(callback) ; 2174 | return this ; 2175 | } 2176 | 2177 | return new Promise((resolve, reject) => { 2178 | __x((err, result) => { 2179 | if (err) return reject(err); 2180 | resolve(result); 2181 | }); 2182 | }); 2183 | }; 2184 | } 2185 | -------------------------------------------------------------------------------- /lib/mediaserver.js: -------------------------------------------------------------------------------- 1 | const esl = require('drachtio-modesl') ; 2 | const assert = require('assert') ; 3 | const delegate = require('delegates') ; 4 | const Emitter = require('events').EventEmitter ; 5 | const only = require('only') ; 6 | const generateUuid = require('uuid-random') ; 7 | const Endpoint = require('./endpoint') ; 8 | const Conference = require('./conference'); 9 | const net = require('net') ; 10 | const {modifySdpCodecOrder} = require('./utils'); 11 | const debug = require('debug')('drachtio:fsmrf') ; 12 | 13 | /** 14 | * return true if an SDP requires DTLS 15 | * @param {string} sdp 16 | */ 17 | function requiresDtlsHandshake(sdp) { 18 | return /m=audio.*SAVP/.test(sdp); 19 | } 20 | 21 | /** 22 | * A freeswitch-based media-processing resource that contains Endpoints and Conferences. 23 | * @constructor 24 | * @param {esl.Connection} conn inbound connection to freeswitch event socket 25 | * @param {Mrf} mrf media resource function that instantiated this MediaServer 26 | * @param {object} app drachtio app 27 | * @param {number} listenPort tcp port to listen on for outbound event socket connections 28 | * 29 | * @fires MediaServer#connect 30 | * @fires MediaServer#ready 31 | * @fires MediaServer#error 32 | */ 33 | 34 | class MediaServer extends Emitter { 35 | constructor(conn, mrf, listenAddress, listenPort, advertisedAddress, advertisedPort, profile) { 36 | super() ; 37 | 38 | this._conn = conn ; 39 | this._mrf = mrf ; 40 | this._srf = mrf.srf ; 41 | this.pendingConnections = new Map(); 42 | this._isMediaServerReady = false; 43 | 44 | /** 45 | * maximum number of active Endpoints allowed on the MediaServer 46 | * @type {Number} 47 | */ 48 | this.maxSessions = 0 ; 49 | /** 50 | * current number of active Endpoints on the MediaServer 51 | * @type {Number} 52 | */ 53 | this.currentSessions = 0 ; 54 | /** 55 | * current calls per second on the MediaServer 56 | * @type {Number} 57 | */ 58 | this.cps = 0 ; 59 | 60 | /** 61 | * sip addresses and ports that the mediaserver is listening on 62 | * note that different addresses may be used for ipv4, ipv6, dtls, or udp connections 63 | * @type {Object} 64 | */ 65 | this.sip = { 66 | ipv4: { 67 | udp: {}, 68 | dtls: {} 69 | }, 70 | ipv6: { 71 | udp: {}, 72 | dtls: {} 73 | } 74 | } ; 75 | 76 | this._address = this._conn.socket.remoteAddress; 77 | this._conn.subscribe(['HEARTBEAT']) ; 78 | this._conn.on('esl::event::HEARTBEAT::*', this._onHeartbeat.bind(this)) ; 79 | this._conn.on('error', this._onError.bind(this)); 80 | this._conn.on('esl::end', () => { 81 | if (!this.closing) { 82 | this.emit('end'); 83 | console.error(`Mediaserver: lost connection to freeswitch at ${this.address}, attempting to reconnect..`); 84 | } 85 | }); 86 | this._conn.on('esl::ready', () => { 87 | console.info(`Mediaserver: connected to freeswitch at ${this.address}`); 88 | }); 89 | 90 | setTimeout(() => { 91 | if (!this._isMediaServerReady) { 92 | if (this.conn) this.conn.disconnect(); 93 | if (this._server) this._server.close(); 94 | this.emit('error', new Error('MediaServer not ready, timeout waiting for connection to freeswitch')); 95 | } 96 | }, 1000); 97 | 98 | //create the server (outbound connections) 99 | const server = net.createServer() ; 100 | server.listen(listenPort, listenAddress, () => { 101 | this.listenAddress = server.address().address; 102 | this.listenPort = server.address().port ; 103 | this.advertisedAddress = advertisedAddress || this.listenAddress; 104 | this.advertisedPort = advertisedPort || this.listenPort; 105 | // eslint-disable-next-line max-len 106 | debug(`listening on ${listenAddress}:${listenPort}, advertising ${this.advertisedAddress}:${this.advertisedPort}`); 107 | this._server = new esl.Server({server: server, myevents:false}, () => { 108 | this.emit('connect') ; 109 | 110 | // exec 'sofia status' on the freeswitch server to find out 111 | // configuration information, including the sip address and port the media server is listening on 112 | this._conn.api('sofia status', (res) => { 113 | const status = res.getBody() ; 114 | let re = new RegExp(`^\\s*${profile}\\s.*sip:mod_sofia@((?:[0-9]{1,3}\\.){3}[0-9]{1,3}:\\d+)`, 'm'); 115 | let results = re.exec(status) ; 116 | if (null === results) throw new Error(`No ${profile} sip profile found on the media server: ${status}`); 117 | if (results) { 118 | this.sip.ipv4.udp.address = results[1] ; 119 | } 120 | 121 | // see if we have a TLS endpoint (Note: it needs to come after the RTP one in the sofia status output) 122 | re = new RegExp(`^\\s*${profile}\\s.*sip:mod_sofia@((?:[0-9]{1,3}\\.){3}[0-9]{1,3}:\\d+).*\\(TLS\\)`, 'm'); 123 | results = re.exec(status) ; 124 | if (results) { 125 | this.sip.ipv4.dtls.address = results[1] ; 126 | } 127 | 128 | // see if we have an ipv6 endpoint 129 | re = /^\s*drachtio_mrf.*sip:mod_sofia@(\[[0-9a-f:]+\]:\d+)/m ; 130 | results = re.exec(status) ; 131 | if (results) { 132 | this.sip.ipv6.udp.address = results[1] ; 133 | } 134 | 135 | // see if we have an ipv6 TLS endpoint 136 | re = /^\s*drachtio_mrf.*sip:mod_sofia@(\[[0-9a-f:]+\]:\d+).*\(TLS\)/m ; 137 | results = re.exec(status); 138 | if (results) { 139 | this.sip.ipv6.dtls.address = results[1] ; 140 | } 141 | debug('media server signaling addresses: %s', JSON.stringify(this.sip)); 142 | 143 | if (!this._isMediaServerReady) { 144 | this.emit('ready') ; 145 | this._isMediaServerReady = true; 146 | } 147 | }); 148 | }); 149 | 150 | this._server.on('connection::ready', this._onNewCall.bind(this)) ; 151 | this._server.on('connection::close', this._onConnectionClosed.bind(this)) ; 152 | }) ; 153 | } 154 | 155 | get address() { 156 | return this._address ; 157 | } 158 | 159 | get conn() { 160 | return this._conn ; 161 | } 162 | 163 | get srf() { 164 | return this._srf; 165 | } 166 | 167 | /** 168 | * disconnect from the media server 169 | */ 170 | disconnect() { 171 | debug(`Mediaserver#disconnect - closing connection to ${this.address}`); 172 | this.closing = true; 173 | this._server.close() ; 174 | this.conn.removeAllListeners(); 175 | this.conn.disconnect() ; 176 | } 177 | 178 | /** 179 | * alias disconnect 180 | */ 181 | destroy() { 182 | return this.disconnect(); 183 | } 184 | 185 | /** 186 | * check if the media server has a specific capability 187 | * @param {string} a named capability - ipv6, ipv4, dtls, or udp 188 | * @return {Boolean} true if the media server supports this capability 189 | */ 190 | hasCapability(capability) { 191 | let family = 'ipv4' ; 192 | const cap = typeof capability === 'string' ? [capability] : capability ; 193 | let idx = cap.indexOf('ipv6') ; 194 | if (-1 !== idx) { 195 | cap.splice(idx, 1) ; 196 | family = 'ipv6' ; 197 | } 198 | else { 199 | idx = cap.indexOf('ipv4') ; 200 | if (-1 !== idx) { 201 | cap.splice(idx, 1) ; 202 | } 203 | } 204 | assert.ok(-1 !== ['dtls', 'udp'].indexOf(cap[0]), 'capability must be from the set ipv6, ipv4, dtls, udp') ; 205 | 206 | return 'address' in this.sip[family][cap[0]] ; 207 | } 208 | 209 | /** 210 | * send a freeswitch API command to the server 211 | * @param {string} command command to execute 212 | * @param {MediaServer~apiCallback} [callback] optional callback that returns api response 213 | * @return {Promise|Mediaserver} returns a Promise if no callback supplied; otherwise 214 | * a reference to the mediaserver object 215 | */ 216 | api(command, callback) { 217 | assert(typeof command, 'string', '\'command\' must be a valid freeswitch api command') ; 218 | 219 | const __x = (callback) => { 220 | this.conn.api(command, (res) => { 221 | callback(res.getBody()) ; 222 | }) ; 223 | }; 224 | 225 | if (callback) { 226 | __x(callback) ; 227 | return this ; 228 | } 229 | 230 | return new Promise((resolve) => { 231 | __x((body) => { 232 | resolve(body); 233 | }); 234 | }); 235 | } 236 | 237 | /** 238 | * allocate an Endpoint on the MediaServer, optionally allocating a media session to stream to a 239 | * remote far end SDP (session description protocol). If no far end SDP is provided, the endpoint 240 | * is initially created in the inactive state. 241 | * @param {MediaServer~EndpointOptions} [opts] - create options 242 | * @param {MediaServer~createEndpointCallback} [callback] callback that provides error or Endpoint 243 | * @return {Promise|Mediaserver} returns a Promise if no callback supplied; otherwise 244 | * a reference to the mediaserver object 245 | */ 246 | createEndpoint(opts, callback) { 247 | if (typeof opts === 'function') { 248 | callback = opts ; 249 | opts = {} ; 250 | } 251 | opts = opts || {} ; 252 | 253 | opts.headers = opts.headers || {}; 254 | opts.customEvents = this._mrf.customEvents; 255 | 256 | opts.is3pcc = !opts.remoteSdp; 257 | if (!opts.is3pcc && opts.codecs) { 258 | if (typeof opts.codecs === 'string') opts.codecs = [opts.codecs]; 259 | opts.remoteSdp = modifySdpCodecOrder(opts.remoteSdp, opts.codecs); 260 | } 261 | else if (opts.is3pcc && opts.srtp === true) { 262 | opts.headers['X-Secure-RTP'] = true; 263 | } 264 | 265 | var family = opts.family || 'ipv4' ; 266 | var proto = opts.dtls ? 'dtls' : 'udp'; 267 | 268 | assert.ok(opts.is3pcc || !requiresDtlsHandshake(opts.remoteSdp), 269 | 'Mediaserver#createEndpoint() can not be called with a remote sdp requiring a dtls handshake; ' + 270 | 'use Mediaserver#connectCaller() instead, as this allows the necessary handshake'); 271 | 272 | const __x = async(callback) => { 273 | if (!this.connected()) { 274 | return process.nextTick(() => { callback(new Error('too early: mediaserver is not connected')) ;}) ; 275 | } 276 | if (!this.sip[family][proto].address) { 277 | return process.nextTick(() => { callback(new Error('too early: mediaserver is not ready')) ;}) ; 278 | } 279 | 280 | /* utility functions */ 281 | function timeoutFn(dialog, uuid) { 282 | delete this.pendingConnections.delete(uuid); 283 | dialog.destroy(); 284 | debug(`MediaServer#createEndpoint - connection timeout for ${uuid}`); 285 | callback(new Error('Connection timeout')) ; 286 | } 287 | const produceEndpoint = (dialog, conn) => { 288 | debug(`MediaServer#createEndpoint - produceEndpoint for ${uuid}`); 289 | //deprecated: dialplan should be answering, but sending again does not hurt 290 | //if (!opts.is3pcc) conn.execute('answer'); 291 | 292 | const endpoint = new Endpoint(conn, dialog, this, opts); 293 | endpoint.once('ready', () => { 294 | debug(`MediaServer#createEndpoint - returning endpoint for uuid ${uuid}`); 295 | callback(null, endpoint); 296 | }); 297 | }; 298 | 299 | // generate a unique id to track the endpoint during creation 300 | let uri; 301 | const uuid = generateUuid() ; 302 | const hasDtls = opts.dtls && this.hasCapability([family, 'dtls']); 303 | if (hasDtls) { 304 | uri = `sips:drachtio@${this.sip[family]['dtls'].address};transport=tls`; 305 | } 306 | else { 307 | uri = `sip:drachtio@${this.sip[family]['udp'].address}`; 308 | } 309 | debug(`MediaServer#createEndpoint: sending ${opts.is3pcc ? '3ppc' : ''} INVITE to uri ${uri} with id ${uuid}`); 310 | 311 | this.pendingConnections.set(uuid, {}); 312 | try { 313 | const dlg = await this.srf.createUAC(uri, { 314 | headers: { 315 | ...opts.headers, 316 | 'User-Agent': `drachtio-fsmrf:${uuid}`, 317 | 'X-esl-outbound': `${this.advertisedAddress}:${this.advertisedPort}` 318 | }, 319 | localSdp: opts.remoteSdp 320 | }); 321 | debug(`MediaServer#createEndpoint - createUAC produced dialog for ${uuid}`) ; 322 | const obj = this.pendingConnections.get(uuid); 323 | obj.dialog = dlg; 324 | if (obj.conn) { 325 | // we already received outbound connection 326 | this.pendingConnections.delete(uuid); 327 | produceEndpoint.bind(this, obj.dialog, obj.conn)(); 328 | } 329 | else { 330 | // waiting for outbound connection 331 | obj.connTimeout = setTimeout(timeoutFn.bind(this, dlg, uuid), 4000); 332 | obj.fn = produceEndpoint.bind(this, obj.dialog); 333 | } 334 | } catch (err) { 335 | debug(`MediaServer#createEndpoint - createUAC returned error for ${uuid}`) ; 336 | this.pendingConnections.delete(uuid) ; 337 | callback(err); 338 | } 339 | }; 340 | 341 | if (callback) { 342 | __x(callback); 343 | return this ; 344 | } 345 | 346 | return new Promise((resolve, reject) => { 347 | __x((err, endpoint) => { 348 | if (err) return reject(err); 349 | resolve(endpoint); 350 | }); 351 | }); 352 | } 353 | 354 | /** 355 | * connects an incoming call to the media server, producing both an Endpoint and a SIP Dialog upon success 356 | * @param {Object} req - drachtio request object for incoming call 357 | * @param {Object} res - drachtio response object for incoming call 358 | * @param {MediaServer~EndpointOptions} [opts] - options for creating endpoint and responding to caller 359 | * @param {MediaServer~connectCallerCallback} callback callback invoked on completion of operation 360 | * @return {Promise|Mediaserver} returns a Promise if no callback supplied; otherwise 361 | * a reference to the mediaserver object 362 | */ 363 | connectCaller(req, res, opts, callback) { 364 | if (typeof opts === 'function') { 365 | callback = opts ; 366 | opts = {} ; 367 | } 368 | opts = opts || {} ; 369 | 370 | const __x = async(callback) => { 371 | 372 | // scenario 1: plain old RTP 373 | if (!requiresDtlsHandshake(req.body)) { 374 | try { 375 | const endpoint = await this.createEndpoint({ 376 | ...opts, 377 | remoteSdp: opts.remoteSdp || req.body, 378 | codecs: opts.codecs 379 | }); 380 | const dialog = await this.srf.createUAS(req, res, { 381 | localSdp: endpoint.local.sdp, 382 | headers: opts.headers 383 | }); 384 | callback(null, {endpoint, dialog}) ; 385 | } catch (err) { 386 | callback(err); 387 | } 388 | } 389 | else { 390 | // scenario 2: SRTP is being used and therefore creating an endpoint 391 | // involves completing a dtls handshake with the originator 392 | const pair = {}; 393 | const uuid = generateUuid() ; 394 | const family = opts.family || 'ipv4' ; 395 | const uri = `sip:drachtio@${this.sip[family]['udp'].address}`; 396 | 397 | /* set state of a pending connection (ie waiting for esl outbound connection) */ 398 | this.pendingConnections.set(uuid, {}); 399 | 400 | /* send invite to freeswitch */ 401 | this.srf.createUAC(uri, { 402 | headers: { 403 | ...opts.headers, 404 | 'User-Agent': `drachtio-fsmrf:${uuid}`, 405 | 'X-esl-outbound': `${this.advertisedAddress}:${this.advertisedPort}` 406 | }, 407 | localSdp: req.body 408 | }, {}, (err, dlg) => { 409 | if (err) { 410 | debug(`MediaServer#connectCaller - createUAC returned error for ${uuid}`) ; 411 | this.pendingConnections.delete(uuid) ; 412 | return callback(err); 413 | } 414 | // eslint-disable-next-line max-len 415 | debug(`MediaServer#connectCaller - createUAC (srtp scenario) produced dialog for ${uuid}: ${JSON.stringify(dlg)}`) ; 416 | 417 | /* update pending connection state now that freeswitch has answered */ 418 | const obj = this.pendingConnections.get(uuid); 419 | obj.dialog = dlg; 420 | obj.connTimeout = setTimeout(timeoutFn.bind(this, dlg, uuid), 4000); 421 | obj.fn = produceEndpoint.bind(this, obj.dialog); 422 | 423 | /* propagate answer back to caller, allowing dtls handshake to proceed */ 424 | this.srf.createUAS(req, res, { 425 | localSdp: dlg.remote.sdp, 426 | headers: opts.headers 427 | }, (err, dialog) => { 428 | if (err) { 429 | debug(`MediaServer#connectCaller - createUAS returned error for ${uuid}`) ; 430 | this.pendingConnections.delete(uuid) ; 431 | return callback(err); 432 | } 433 | pair.dialog = dialog; 434 | 435 | /* if the esl outbound connection has already arrived, then we are done */ 436 | if (pair.endpoint) callback(null, pair); 437 | }); 438 | }); 439 | 440 | /* called when we receive the outbound esl connection */ 441 | const produceEndpoint = (dialog, conn) => { 442 | debug(`MediaServer#connectCaller - (srtp scenario) produceEndpoint for ${uuid}`); 443 | 444 | const endpoint = new Endpoint(conn, dialog, this, opts); 445 | endpoint.once('ready', () => { 446 | debug(`MediaServer#createEndpoint - (srtp scenario) returning endpoint for uuid ${uuid}`); 447 | pair.endpoint = endpoint; 448 | 449 | /* if the 200 OK from freeswitch has arrived, then we are done */ 450 | if (pair.dialog) callback(null, pair); 451 | }); 452 | }; 453 | 454 | /* timeout waiting for esl connection after invite to FS answered */ 455 | const timeoutFn = (dialog, uuid) => { 456 | delete this.pendingConnections.delete(uuid); 457 | dialog.destroy(); 458 | pair.dialog && pair.dialog.destroy(); // UA dialog destroy 459 | debug(`MediaServer#createEndpoint - (srtp scenario) connection timeout for ${uuid}`); 460 | callback(new Error('Connection timeout')) ; 461 | }; 462 | } 463 | }; 464 | 465 | if (callback) { 466 | __x(callback); 467 | return this ; 468 | } 469 | 470 | return new Promise((resolve, reject) => { 471 | __x((err, pair) => { 472 | if (err) return reject(err); 473 | resolve(pair); 474 | }); 475 | }); 476 | } 477 | 478 | /** 479 | * creates a conference on the media server. 480 | * @param {String} [name] - conference name; if not supplied a unique name will be generated 481 | * @param {MediaServer~conferenceCreateOptions} [opts] - conference-level configuration options 482 | * @param {MediaServer~createConferenceCallback} [callback] - callback invoked when conference is created 483 | * @return {Promise|Mediaserver} returns a Promise if no callback supplied; otherwise 484 | * a reference to the mediaserver object 485 | */ 486 | createConference(name, opts, callback) { 487 | let generateConfName = false; 488 | if (typeof name !== 'string') { 489 | callback = opts; 490 | opts = name; 491 | name = `anon-${generateUuid()}`; 492 | generateConfName = true; 493 | } 494 | if (typeof opts === 'function') { 495 | callback = opts ; 496 | opts = {} ; 497 | } 498 | opts = opts || {} ; 499 | 500 | assert.equal(typeof name, 'string', '\'name\' is a required parameter') ; 501 | assert.ok(typeof opts === 'object', 'opts param must be an object') ; 502 | 503 | const verifyConfDoesNotExist = (name) => { 504 | return new Promise((resolve, reject) => { 505 | this.api(`conference ${name} list count`, (result) => { 506 | debug(`return from conference list: ${result}`) ; 507 | if (typeof result === 'string' && 508 | (result.match(/^No active conferences/) || result.match(/Conference.*not found/))) { 509 | return resolve(); 510 | } 511 | reject('conference exists'); 512 | }); 513 | }); 514 | }; 515 | 516 | const __x = async(callback) => { 517 | /* Steps for creating a conference: 518 | (1) Check to see if a conference of that name already exists - return error if so 519 | (2) Create the conference control leg (endpoint) 520 | (3) Create the conference 521 | */ 522 | try { 523 | if (!generateConfName) await verifyConfDoesNotExist(name); 524 | const endpoint = await this.createEndpoint(); 525 | opts.flags = {...opts.flags, endconf: true, mute: true, vmute: true}; 526 | const {confUuid} = await endpoint.join(name, opts); 527 | const conference = new Conference(name, confUuid, endpoint, opts); 528 | debug(`MediaServer#createConference: created conference ${name}:${confUuid}`) ; 529 | console.log(`MediaServer#createConference: created conference ${name}:${confUuid}`) ; 530 | callback(null, conference) ; 531 | } catch (err) { 532 | console.log({err}, 'mediaServer:createConference - error'); 533 | callback(err); 534 | } 535 | }; 536 | 537 | if (callback) { 538 | __x(callback); 539 | return this ; 540 | } 541 | 542 | return new Promise((resolve, reject) => { 543 | __x((err, conference) => { 544 | if (err) return reject(err); 545 | resolve(conference); 546 | }); 547 | }); 548 | } 549 | 550 | toJSON() { 551 | return only(this, 'sip maxSessions currentSessions cps cpuIdle fsVersion hostname v4address pendingConnections') ; 552 | } 553 | 554 | _onError(err) { 555 | debug(`Mediaserver#_onError: got error from freeswitch connection, attempting reconnect: ${err}`); 556 | } 557 | 558 | _onHeartbeat(evt) { 559 | this.maxSessions = parseInt(evt.getHeader('Max-Sessions')) ; 560 | this.currentSessions = parseInt(evt.getHeader('Session-Count')) ; 561 | this.cps = parseInt(evt.getHeader('Session-Per-Sec')) ; 562 | this.hostname = evt.getHeader('FreeSWITCH-Hostname') ; 563 | this.v4address = evt.getHeader('FreeSWITCH-IPv4') ; 564 | this.v6address = evt.getHeader('FreeSWITCH-IPv6') ; 565 | this.fsVersion = evt.getHeader('FreeSWITCH-Version') ; 566 | this.cpuIdle = parseFloat(evt.getHeader('Idle-CPU')) ; 567 | } 568 | 569 | _onCreateTimeout(uuid) { 570 | if (!(uuid in this.pendingConnections)) { 571 | console.error(`MediaServer#_onCreateTimeout: uuid not found: ${uuid}`) ; 572 | return ; 573 | } 574 | const obj = this.pendingConnections[uuid] ; 575 | obj.callback(new Error('Connection timeout')) ; 576 | clearTimeout(obj.createTimeout) ; 577 | delete this.pendingConnections[uuid] ; 578 | } 579 | 580 | _onNewCall(conn, id) { 581 | const userAgent = conn.getInfo().getHeader('variable_sip_user_agent') ; 582 | const re = /^drachtio-fsmrf:(.+)$/ ; 583 | const results = re.exec(userAgent) ; 584 | if (null === results) { 585 | console.error(`received INVITE without drachtio-fsmrf header, unexpected User-Agent: ${userAgent}`) ; 586 | return conn.execute('hangup', 'NO_ROUTE_DESTINATION') ; 587 | } 588 | const uuid = results[1] ; 589 | if (!uuid || !this.pendingConnections.has(uuid)) { 590 | console.error(`received INVITE with unknown uuid: ${uuid}`) ; 591 | return conn.execute('hangup', 'NO_ROUTE_DESTINATION') ; 592 | } 593 | const obj = this.pendingConnections.get(uuid); 594 | if (obj.fn) { 595 | // sip dialog was already created, so we can create endpoint here 596 | obj.fn(conn); 597 | clearTimeout(obj.connTimeout); 598 | this.pendingConnections.delete(uuid); 599 | } 600 | else { 601 | obj.conn = conn; 602 | } 603 | const count = this._server.getCountOfConnections(); 604 | const realUuid = conn.getInfo().getHeader('Channel-Unique-ID') ; 605 | debug(`MediaServer#_onNewCall: ${this.address} new connection id: ${id}, uuid: ${realUuid}, count is ${count}`); 606 | this.emit('channel::open', { 607 | uuid: realUuid, 608 | countOfConnections: count, 609 | countOfChannels: this.currentSessions 610 | }); 611 | } 612 | 613 | _onConnectionClosed(conn, id) { 614 | let uuid; 615 | if (conn) { 616 | const info = conn.getInfo(); 617 | if (info) { 618 | uuid = info.getHeader('Channel-Unique-ID'); 619 | } 620 | } 621 | const count = this._server.getCountOfConnections(); 622 | debug(`MediaServer#_onConnectionClosed: connection id: ${id}, uuid: ${uuid}, count is ${count}`); 623 | this.emit('channel::close', { 624 | uuid, 625 | countOfConnections: count, 626 | countOfChannels: this.currentSessions 627 | }); 628 | } 629 | } 630 | 631 | /** 632 | * This callback provides the response to an attempt to create an Endpoint on the MediaServer. 633 | * @callback MediaServer~createEndpointCallback 634 | * @param {Error} error encountered while attempting to create the endpoint 635 | * @param {Endpoint} endpoint that was created 636 | */ 637 | /** 638 | * This callback provides the response to an attempt to create a Conference on the MediaServer. 639 | * @callback MediaServer~createConferenceCallback 640 | * @param {Error} error encountered while attempting to create the conference 641 | * @param {Conference} conference that was created 642 | */ 643 | 644 | /** 645 | /** 646 | * This callback provides the response to an api request 647 | * @callback Mrf~apiCallback 648 | * @param {string} response - body of the response from freeswitch 649 | */ 650 | 651 | /** 652 | * This callback provides the response to an attempt connect a caller to the MediaServer. 653 | * @callback MediaServer~connectCallerCallback 654 | * @param {Error} err - error encountered while attempting to create the endpoint 655 | * @param {Endpoint} ep - endpoint that was created 656 | * @param {Dialog} dialog - sip dialog that was created 657 | */ 658 | 659 | /** returns true if the MediaServer is in the 'connected' state 660 | * @name MediaServer#connected 661 | * @method 662 | */ 663 | 664 | delegate(MediaServer.prototype, '_conn') 665 | .method('connected') ; 666 | 667 | /** 668 | * Options governing the creation of a conference 669 | * @typedef {Object} MediaServer~conferenceCreateOptions 670 | * @property {string} [pin] entry pin for the conference 671 | * @property {string} [profile=default] conference profile to use 672 | * @property {Object} [flags] parameters governing the connection of the endpoint to the conference 673 | * @property {boolean} [flags.waitMod=false] Members will wait (with music) until a member with the 'moderator' flag 674 | * set enters the conference 675 | * @property {boolean} [flags.audioAlways=false] Do not use energy detection to choose which participants to mix; 676 | * instead always mix audio from all members 677 | * @property {boolean} [flags.videoBridgeFirstTwo=false] In mux mode, If there are only 2 people in conference, 678 | * you will see only the other member 679 | * @property {boolean} [flags.videoMuxingPersonalCanvas=false] In mux mode, each member will get their own canvas 680 | * and they will not see themselves 681 | * @property {boolean} [flags.videoRequiredForCanvas=false] Only video participants will be shown 682 | * on the canvas (no avatars) 683 | */ 684 | 685 | /** 686 | * Arguments provided when creating an Endpoint on a MediaServer 687 | * @typedef {Object} MediaServer~EndpointOptions 688 | * @property {String} [remoteSdp] remote session description protocol 689 | * (if not provided, an initially inactive Endpoint will be created) 690 | * @property {String[]} [codecs] - array of codecs, in preferred order (e.g. ['PCMU','G722','PCMA']) 691 | */ 692 | 693 | /** 694 | * connect event triggered when connection is made to the freeswitch media server. 695 | * @event MediaServer#connect 696 | */ 697 | /** 698 | * ready event triggered after connecting to the server and verifying 699 | * it is properly configured and ready to accept calls. 700 | * @event MediaServer#ready 701 | */ 702 | /** 703 | * Error event triggered when connection to freeswitch media server fails. 704 | * 705 | * @event MediaServer#error 706 | * @type {object} 707 | * @property {String} message - Indicates the reason the connection failed 708 | */ 709 | 710 | module.exports = exports = MediaServer ; 711 | -------------------------------------------------------------------------------- /lib/mrf.js: -------------------------------------------------------------------------------- 1 | const esl = require('drachtio-modesl') ; 2 | const assert = require('assert') ; 3 | const MediaServer = require('./mediaserver') ; 4 | const Emitter = require('events').EventEmitter ; 5 | const os = require('os'); 6 | const parseBodyText = require('./utils').parseBodyText; 7 | const debug = require('debug')('drachtio:fsmrf') ; 8 | 9 | /** 10 | * Creates a media resource framework instance. 11 | * @constructor 12 | * @param {Srf} srf Srf instance 13 | * @param {Mrf~createOptions} [opts] configuration options 14 | */ 15 | class Mrf extends Emitter { 16 | 17 | constructor(srf, opts) { 18 | super() ; 19 | 20 | opts = opts || {} ; 21 | 22 | this._srf = srf ; 23 | this.debugDir = opts.debugDir ; 24 | this.debugSendonly = opts.sendonly ; 25 | this.localAddresses = []; 26 | this.customEvents = opts.customEvents || []; 27 | 28 | const interfaces = os.networkInterfaces(); 29 | for (const k in interfaces) { 30 | for (const k2 in interfaces[k]) { 31 | const address = interfaces[k][k2]; 32 | if (address.family === 'IPv4' && !address.internal) { 33 | this.localAddresses.push(address.address); 34 | } 35 | } 36 | } 37 | } 38 | 39 | get srf() { 40 | return this._srf ; 41 | } 42 | 43 | /** 44 | * connect to a specified media server 45 | * @param {Mrf~ConnectOptions} opts options describing media server to connect to 46 | * @param {Mrf~ConnectCallback} [callback] callback 47 | * @return {Promise} if no callback is specified 48 | */ 49 | connect(opts, callback) { 50 | assert.equal(typeof opts, 'object', 'argument \'opts\' must be provided with connection options') ; 51 | assert.equal(typeof opts.address, 'string', `argument \'opts.address\' containing 52 | media server address is required`) ; 53 | 54 | const address = opts.address ; 55 | const port = opts.port || 8021 ; 56 | const secret = opts.secret || 'ClueCon' ; 57 | const listenPort = opts.listenPort || 0 ; // 0 means any available port 58 | const listenAddress = opts.listenAddress || this.localAddresses[0] || '0.0.0.0' ; 59 | const profile = opts.profile || 'drachtio_mrf'; 60 | 61 | function _onError(callback, err) { 62 | callback(err); 63 | } 64 | 65 | const __x = (callback) => { 66 | const listener = _onError.bind(this, callback) ; 67 | debug(`Mrf#connect - connecting to ${address}:${port} with secret: ${secret}`); 68 | const conn = new esl.Connection(address, port, secret, () => { 69 | 70 | //...until we have initially connected and created a MediaServer object (which takes over error reporting) 71 | debug('initial connection made'); 72 | conn.removeListener('error', listener) ; 73 | 74 | const ms = new MediaServer(conn, this, listenAddress, listenPort, 75 | opts.advertisedAddress, opts.advertisedPort, profile) ; 76 | 77 | ms.once('ready', () => { 78 | debug('Mrf#connect - media server is ready for action!'); 79 | callback(null, ms) ; 80 | }) ; 81 | ms.once('error', (err) => { 82 | debug(`Mrf#connect - error event emitted from media server: ${err}`); 83 | callback(err) ; 84 | }); 85 | }); 86 | 87 | conn.on('error', listener); 88 | conn.on('esl::event::raw::text/rude-rejection', _onError.bind(this, callback, new Error('acl-error'))); 89 | }; 90 | 91 | if (callback) return __x(callback) ; 92 | 93 | return new Promise((resolve, reject) => { 94 | __x((err, mediaserver) => { 95 | if (err) return reject(err); 96 | resolve(mediaserver); 97 | }); 98 | }); 99 | } 100 | 101 | } 102 | 103 | /** 104 | /** 105 | * This callback provides the response to a connection attempt to a freeswitch server 106 | * @callback Mrf~ConnectCallback 107 | * @param {Error} err connection error, if any 108 | * @param {MediaServer} ms - MediaServer instance 109 | */ 110 | 111 | 112 | /** 113 | * Arguments provided when connecting to a freeswitch media server 114 | * @typedef {Object} Mrf~ConnectOptions 115 | * @property {String} address - hostname or IP address to connect to 116 | * @property {Number} [port=8021] - TCP port to connect to (freeswitch event socket) 117 | * @property {String} [secret=ClueCon] - freeswitch authentication secret 118 | * @property {String} [listenAddress=auto-discovered public IP address or 127.0.0.1] - 119 | * local TCP address to listen for external connections from the freeswitch media server 120 | * @property {Number} [listenPort=8085] - local TCP port to listen for external 121 | * connections from the freeswitch media server 122 | */ 123 | 124 | /** 125 | * Options governing the creation of an mrf instance 126 | * @typedef {Object} Mrf~createOptions 127 | * @property {string} [debugDir] directory into which message trace files; 128 | the presence of this param will enable debug tracing 129 | */ 130 | 131 | 132 | Mrf.utils = {parseBodyText}; 133 | 134 | module.exports = exports = Mrf ; 135 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | const transform = require('sdp-transform'); 2 | const assert = require('assert'); 3 | const debug = require('debug')('drachtio:fsmrf') ; 4 | 5 | const obj = {} ; 6 | module.exports = obj; 7 | 8 | const parseDecibels = (db) => { 9 | if (!db) return 0; 10 | if (typeof db === 'number') { 11 | return db; 12 | } 13 | else if (typeof db === 'string') { 14 | const match = db.match(/([+-]?\d+(\.\d+)?)\s*db/i); 15 | if (match) { 16 | return Math.trunc(parseFloat(match[1])); 17 | } else { 18 | return 0; 19 | } 20 | } else { 21 | return 0; 22 | } 23 | }; 24 | 25 | 26 | const parseBodyText = (txt) => { 27 | return txt.split('\n').reduce((obj, line) => { 28 | const data = line.split(': '); 29 | const key = data.shift(); 30 | const value = decodeURIComponent(data.shift()); 31 | 32 | if (0 === key.indexOf('variable_rtp_audio') || 33 | 0 === key.indexOf('variable_rtp_video') || 34 | 0 === key.indexOf('variable_playback')) { 35 | obj[key] = parseInt(value, 10); 36 | } 37 | else if (key && key.length > 0) { 38 | obj[key] = value; 39 | } 40 | 41 | return obj; 42 | }, {}); 43 | }; 44 | 45 | const sortFunctor = (codecs, rtp) => { 46 | const DEFAULT_SORT_ORDER = 999; 47 | const rtpMap = new Map(); 48 | rtpMap.set(0, 'PCMU'); 49 | rtpMap.set(8, 'PCMA'); 50 | rtpMap.set(18, 'G.729'); 51 | rtpMap.set(18, 'G729'); 52 | rtp.forEach((r) => { 53 | if (r.codec && r.payload) { 54 | const name = r.codec.toUpperCase(); 55 | if (name !== 'TELEPHONE-EVENT') rtpMap.set(r.payload, name); 56 | } 57 | }); 58 | 59 | function score(pt) { 60 | const n = parseInt(pt); 61 | if (!rtpMap.has(n)) { 62 | return DEFAULT_SORT_ORDER; 63 | } 64 | const name = rtpMap.get(n); 65 | if (codecs.includes(name)) { 66 | return codecs.indexOf(name); 67 | } 68 | return DEFAULT_SORT_ORDER; 69 | } 70 | return function(a, b) { 71 | return score(a) - score(b); 72 | }; 73 | }; 74 | 75 | const modifySdpCodecOrder = (sdp, codecList) => { 76 | assert(Array.isArray(codecList)); 77 | 78 | try { 79 | const codecs = codecList.map((c) => c.toUpperCase()); 80 | const obj = transform.parse(sdp); 81 | debug(`parsed SDP: ${JSON.stringify(obj)}`); 82 | 83 | for (let i = 0; i < obj.media.length; i++) { 84 | const sortFn = sortFunctor(codecs, obj.media[i].rtp); 85 | debug(`obj.media[i].payloads: ${obj.media[i].payloads}`); 86 | if (typeof obj.media[i].payloads === 'string') { 87 | const payloads = obj.media[i].payloads.split(' '); 88 | debug(`initial list: ${payloads}`); 89 | payloads.sort(sortFn); 90 | debug(`resorted payloads: ${payloads}, for codec list ${codecs}`); 91 | obj.media[i].payloads = payloads.join(' '); 92 | } 93 | } 94 | return transform.write(obj); 95 | } catch (err) { 96 | console.log(err, `Error parsing SDP: ${sdp}`); 97 | return sdp; 98 | } 99 | }; 100 | 101 | const upperFirst = ([firstLetter, ...restOfWord]) => firstLetter.toUpperCase() + restOfWord.join(''); 102 | 103 | module.exports = { 104 | parseDecibels, 105 | parseBodyText, 106 | sortFunctor, 107 | modifySdpCodecOrder, 108 | upperFirst 109 | }; 110 | 111 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "drachtio-fsmrf", 3 | "version": "4.0.3", 4 | "description": "freeswitch-based media resource function for drachtio", 5 | "main": "lib/mrf.js", 6 | "scripts": { 7 | "jslint": "eslint lib", 8 | "test": "NODE_ENV=test node test/ ", 9 | "coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/drachtio/drachtio-fsmrf.git" 14 | }, 15 | "engines": { 16 | "node": ">= 6.9.3" 17 | }, 18 | "author": "", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/drachtio/drachtio-fsmrf/issues" 22 | }, 23 | "homepage": "https://github.com/drachtio/drachtio-fsmrf#readme", 24 | "dependencies": { 25 | "camel-case": "^4.1.2", 26 | "debug": "^4.4.0", 27 | "delegates": "^0.1.0", 28 | "drachtio-modesl": "^1.2.9", 29 | "drachtio-srf": "^5.0.1", 30 | "only": "^0.0.2", 31 | "sdp-transform": "^2.15.0", 32 | "snake-case": "^3.0.4", 33 | "uuid-random": "^1.3.2" 34 | }, 35 | "devDependencies": { 36 | "clear-module": "^4.1.2", 37 | "config": "^3.3.8", 38 | "eslint": "^7.32.0", 39 | "eslint-plugin-promise": "^4.3.1", 40 | "nyc": "^15.1.0", 41 | "tape": "^5.7.5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/cafile.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDJDCCAo2gAwIBAgIBADANBgkqhkiG9w0BAQUFADBwMQswCQYDVQQGEwJVUzET 3 | MBEGA1UECBMKQ2FsaWZvcm5pYTERMA8GA1UEBxMIU2FuIEpvc2UxDjAMBgNVBAoT 4 | BXNpcGl0MSkwJwYDVQQLEyBTaXBpdCBUZXN0IENlcnRpZmljYXRlIEF1dGhvcml0 5 | eTAeFw0wMzA3MTgxMjIxNTJaFw0xMzA3MTUxMjIxNTJaMHAxCzAJBgNVBAYTAlVT 6 | MRMwEQYDVQQIEwpDYWxpZm9ybmlhMREwDwYDVQQHEwhTYW4gSm9zZTEOMAwGA1UE 7 | ChMFc2lwaXQxKTAnBgNVBAsTIFNpcGl0IFRlc3QgQ2VydGlmaWNhdGUgQXV0aG9y 8 | aXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDIh6DkcUDLDyK9BEUxkud 9 | +nJ4xrCVGKfgjHm6XaSuHiEtnfELHM+9WymzkBNzZpJu30yzsxwfKoIKugdNUrD4 10 | N3viCicwcN35LgP/KnbN34cavXHr4ZlqxH+OdKB3hQTpQa38A7YXdaoz6goW2ft5 11 | Mi74z03GNKP/G9BoKOGd5QIDAQABo4HNMIHKMB0GA1UdDgQWBBRrRhcU6pR2JYBU 12 | bhNU2qHjVBShtjCBmgYDVR0jBIGSMIGPgBRrRhcU6pR2JYBUbhNU2qHjVBShtqF0 13 | pHIwcDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExETAPBgNVBAcT 14 | CFNhbiBKb3NlMQ4wDAYDVQQKEwVzaXBpdDEpMCcGA1UECxMgU2lwaXQgVGVzdCBD 15 | ZXJ0aWZpY2F0ZSBBdXRob3JpdHmCAQAwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B 16 | AQUFAAOBgQCWbRvv1ZGTRXxbH8/EqkdSCzSoUPrs+rQqR0xdQac9wNY/nlZbkR3O 17 | qAezG6Sfmklvf+DOg5RxQq/+Y6I03LRepc7KeVDpaplMFGnpfKsibETMipwzayNQ 18 | QgUf4cKBiF+65Ue7hZuDJa2EMv8qW4twEhGDYclpFU9YozyS1OhvUg== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /test/conference.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') ; 2 | const Srf = require('drachtio-srf') ; 3 | const Mrf = require('..') ; 4 | const config = require('config') ; 5 | const clearRequire = require('clear-module'); 6 | const MediaServer = require('../lib/mediaserver'); 7 | const Conference = require('../lib/conference'); 8 | const Endpoint = require('../lib/endpoint'); 9 | const CONF_NAME = 'test'; 10 | const CONF_NAME2 = 'test2'; 11 | const CONF_RECORD_FILE = 'conf-test-recording.wav'; 12 | 13 | // connect the 2 apps to their drachtio servers 14 | const connect = async(agents) => { 15 | return Promise.all(agents.map((agent) => new Promise((resolve, reject) => { 16 | agent.once('connect', (err) => { 17 | if (err) reject(err); 18 | else resolve(); 19 | }); 20 | }))); 21 | }; 22 | 23 | // disconnect the 2 apps 24 | function disconnect(agents) { 25 | agents.forEach((app) => {app.disconnect();}) ; 26 | clearRequire('./../app'); 27 | } 28 | 29 | test('MediaServer#createConference without specifying a name', (t) => { 30 | t.timeoutAfter(5000); 31 | 32 | const srf = new Srf(); 33 | srf.connect(config.get('drachtio-uac')) ; 34 | const mrf = new Mrf(srf) ; 35 | 36 | let mediaserver ; 37 | 38 | return connect([srf]) 39 | .then(() => { 40 | return mrf.connect(config.get('freeswitch-uac')); 41 | }) 42 | .then((ms) => { 43 | t.pass('connected to media server') 44 | mediaserver = ms ; 45 | return mediaserver.createConference(); 46 | }) 47 | .then((conference) => { 48 | t.ok(conference instanceof Conference, `successfully created conference '${conference.name}'`); 49 | return conference.destroy() ; 50 | }) 51 | .then(() => { 52 | t.pass('conference destroyed'); 53 | mediaserver.disconnect() ; 54 | disconnect([srf]); 55 | return; 56 | }) 57 | .catch((err) => { 58 | t.fail(err); 59 | }); 60 | }) ; 61 | 62 | test('MediaServer#createConference using Promises', (t) => { 63 | t.timeoutAfter(5000); 64 | 65 | const srf = new Srf(); 66 | srf.connect(config.get('drachtio-uac')) ; 67 | const mrf = new Mrf(srf) ; 68 | 69 | let mediaserver ; 70 | 71 | return connect([srf]) 72 | .then(() => { 73 | return mrf.connect(config.get('freeswitch-uac')); 74 | }) 75 | .then((ms) => { 76 | mediaserver = ms ; 77 | return mediaserver.createConference(CONF_NAME, {maxMembers:5}); 78 | }) 79 | .then((conference) => { 80 | t.ok(conference instanceof Conference, `successfully created conference '${CONF_NAME}'`); 81 | return conference.destroy() ; 82 | }) 83 | .then(() => { 84 | t.pass('conference destroyed'); 85 | mediaserver.disconnect() ; 86 | disconnect([srf]); 87 | return ; 88 | }) 89 | .catch((err) => { 90 | t.fail(err); 91 | }); 92 | }) ; 93 | 94 | test('MediaServer#createConference using Callback', (t) => { 95 | t.timeoutAfter(5000); 96 | 97 | const srf = new Srf(); 98 | srf.connect(config.get('drachtio-uac')) ; 99 | const mrf = new Mrf(srf) ; 100 | 101 | let mediaserver ; 102 | 103 | return connect([srf]) 104 | .then(() => { 105 | return mrf.connect(config.get('freeswitch-uac')); 106 | }) 107 | .then((ms) => { 108 | mediaserver = ms ; 109 | return mediaserver.createConference(CONF_NAME); 110 | }) 111 | .then((conference) => { 112 | t.ok(conference instanceof Conference, `successfully created conference '${CONF_NAME}'`); 113 | return conference.destroy(); 114 | }) 115 | .then(() => { 116 | t.pass('conference destroyed'); 117 | mediaserver.disconnect() ; 118 | disconnect([srf]); 119 | return ; 120 | }) 121 | .catch((err) => { 122 | t.fail(err); 123 | }); 124 | }) ; 125 | 126 | test('Connect incoming call into a conference', (t) => { 127 | t.timeoutAfter(25000); 128 | 129 | const uac = require('./scripts/call-generator')(config.get('call-generator')) ; 130 | const srf = new Srf(); 131 | const mrf = new Mrf(srf) ; 132 | let dlg, ms, ep, conf, conf2 ; 133 | 134 | srf.connect(config.get('drachtio-sut')) ; 135 | 136 | connect([srf, uac]) 137 | .then(() => { 138 | srf.invite(handler); 139 | uac.startScenario() ; 140 | return ; 141 | }) 142 | .catch((err) => { 143 | t.fail(err); 144 | }); 145 | 146 | function handler(req, res) { 147 | let conf; 148 | mrf.connect(config.get('freeswitch-sut')) 149 | .then((mediaserver) => { 150 | t.ok(mediaserver instanceof MediaServer, 'contacted mediaserver'); 151 | ms = mediaserver ; 152 | return mediaserver.connectCaller(req, res); 153 | }) 154 | .then(({endpoint, dialog}) => { 155 | ep = endpoint ; 156 | dlg = dialog ; 157 | t.ok(ep instanceof Endpoint, 'connected incoming call to mediaserver'); 158 | return uac.streamTo(ep.local.sdp) ; 159 | }) 160 | .then(() => { 161 | return ms.createConference(CONF_NAME, {maxMembers: 54}); 162 | }) 163 | .then((conference) => { 164 | conf = conference; 165 | conf.set('max_members', 100); 166 | return conf; 167 | }) 168 | .then((conf) => { 169 | t.pass('set max members to 100'); 170 | return conf.get('max_members'); 171 | }) 172 | .then((max) => { 173 | t.ok(max === 100, 'verified max members is 100'); 174 | return conf.startRecording(CONF_RECORD_FILE); 175 | }) 176 | .then(() => { 177 | t.pass('started recording'); 178 | return ep.join(conf); 179 | }) 180 | .then(({memberId, confUuid}) => { 181 | t.ok(typeof memberId === 'number', `connected endpoint to conference with memberId ${memberId}`); 182 | return conf.getSize() ; 183 | }) 184 | .then((count) => { 185 | t.ok(count === 2, 'getSize() returns 2 total legs'); 186 | return ep.unjoin() ; 187 | }) 188 | .then(() => { 189 | t.pass('removed endpoint from conference'); 190 | return ep.join(conf) ; 191 | }) 192 | .then(({memberId, confUuid}) => { 193 | t.ok(typeof memberId === 'number', `added endpoint back to conference with memberId ${memberId}`); 194 | return conf.agc('on'); 195 | }) 196 | .then(() => { 197 | t.pass('agc on'); 198 | return conf.agc('off'); 199 | }) 200 | .then(() => { 201 | t.pass('agc off'); 202 | return ep.confMute(); 203 | }) 204 | .then(() => { 205 | t.pass('endpoint muted'); 206 | return ep.confUnmute(); 207 | }) 208 | .then(() => { 209 | t.pass('endpoint unmuted'); 210 | return ep.confPlay('silence_stream://100'); 211 | }) 212 | .then(() => { 213 | t.pass('played file to member'); 214 | return conf.pauseRecording(CONF_RECORD_FILE) ; 215 | }) 216 | .then((evt) => { 217 | t.pass('paused recording'); 218 | return conf.resumeRecording(CONF_RECORD_FILE); 219 | }) 220 | .then((evt) => { 221 | t.pass('resumed recording'); 222 | return ep.confDeaf(); 223 | }) 224 | .then(() => { 225 | t.pass('endpoint deafed'); 226 | return ep.confUndeaf(); 227 | }) 228 | .then(() => { 229 | t.pass('endpoint undeafed'); 230 | return conf.lock(); 231 | }) 232 | .then(() => { 233 | t.pass('locked conference'); 234 | return conf.unlock(); 235 | }) 236 | .then(() => { 237 | t.pass('unlocked conference'); 238 | return conf.mute('all'); 239 | }) 240 | .then(() => { 241 | t.pass('mute conference'); 242 | return conf.unmute('all'); 243 | }) 244 | .then(() => { 245 | t.pass('unmute conference'); 246 | return conf.deaf('all'); 247 | }) 248 | .then(() => { 249 | t.pass('deaf conference'); 250 | return conf.undeaf('all'); 251 | }) 252 | .then(() => { 253 | t.pass('undeaf conference'); 254 | return ms.createConference(CONF_NAME2); 255 | }) 256 | .then((conference) => { 257 | t.ok(conference instanceof Conference, 'created second conference'); 258 | conf2 = conference ; 259 | return ; 260 | }) 261 | .then(() => { 262 | return ep.transfer(conf2); 263 | }) 264 | .then(() => { 265 | t.pass('endpoint transfered to second conference'); 266 | return conf.stopRecording(CONF_RECORD_FILE); 267 | }) 268 | .then(() => { 269 | t.pass('stopped recording'); 270 | return ep.confHup(); 271 | }) 272 | .then((evt) => { 273 | t.pass('endpoint huped'); 274 | conf2.destroy() ; 275 | return conf.destroy() ; 276 | }) 277 | .then(() => { 278 | t.pass('conference destroyed'); 279 | return ep.destroy() ; 280 | }) 281 | .then(() => { 282 | t.pass('endpoint destroyed'); 283 | ms.disconnect() ; 284 | disconnect([srf, uac]); 285 | t.end(); 286 | return; 287 | }) 288 | .catch((err) => { 289 | console.error(`error ${err}`); 290 | t.fail(err); 291 | ep.destroy() ; 292 | conf.destroy() ; 293 | dlg.destroy() ; 294 | ms.disconnect() ; 295 | disconnect([srf, uac]); 296 | }); 297 | } 298 | }); 299 | -------------------------------------------------------------------------------- /test/docker-compose-testbed.yaml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | networks: 4 | drachtio-fsmrf: 5 | driver: bridge 6 | ipam: 7 | config: 8 | - subnet: 172.28.0.0/16 9 | 10 | services: 11 | drachtio-uac: 12 | image: drachtio/drachtio-server:latest 13 | command: drachtio --contact "sip:*;transport=udp" --loglevel debug 14 | container_name: drachtio-uac 15 | ports: 16 | - "9060:9022/tcp" 17 | networks: 18 | drachtio-fsmrf: 19 | ipv4_address: 172.28.0.10 20 | 21 | freeswitch-uac: 22 | image: drachtio/drachtio-freeswitch-mrf:v1.10.1-full 23 | command: freeswitch --sip-port 5060 --rtp-range-start 20000 --rtp-range-end 20020 24 | container_name: freeswitch-uac 25 | volumes: 26 | - ./sounds:/usr/local/freeswitch/sounds 27 | ports: 28 | - "9070:8021/tcp" 29 | networks: 30 | drachtio-fsmrf: 31 | ipv4_address: 172.28.0.11 32 | 33 | drachtio-sut: 34 | image: drachtio/drachtio-server:latest 35 | command: drachtio --contact "sip:*;transport=udp" --loglevel debug 36 | container_name: drachtio-sut 37 | ports: 38 | - "9061:9022/tcp" 39 | networks: 40 | drachtio-fsmrf: 41 | ipv4_address: 172.28.0.21 42 | 43 | freeswitch-sut: 44 | image: drachtio/drachtio-freeswitch-mrf:v1.10.1-full 45 | command: freeswitch --sip-port 5060 --rtp-range-start 20000 --rtp-range-end 20020 46 | container_name: freeswitch-sut 47 | volumes: 48 | - ./sounds:/usr/local/freeswitch/sounds 49 | - ./recordings:/tmp 50 | ports: 51 | - "9071:8021/tcp" 52 | networks: 53 | drachtio-fsmrf: 54 | ipv4_address: 172.28.0.22 55 | 56 | freeswitch-custom-profile-sut: 57 | image: byoungdale/drachtio-freeswitch-custom-mrf-profile:latest 58 | command: freeswitch --sip-port 5060 --rtp-range-start 20000 --rtp-range-end 20020 59 | container_name: freeswitch-custom-profile-sut 60 | volumes: 61 | - ./sounds:/usr/local/freeswitch/sounds 62 | - ./recordings:/tmp 63 | ports: 64 | - "9081:8021/tcp" 65 | networks: 66 | drachtio-fsmrf: 67 | ipv4_address: 172.28.0.23 68 | 69 | ws-server: 70 | image: drachtio/sample-ws-audio-fork:latest 71 | container_name: ws-server 72 | volumes: 73 | - ./recordings:/tmp 74 | networks: 75 | drachtio-fsmrf: 76 | ipv4_address: 172.28.0.30 77 | -------------------------------------------------------------------------------- /test/docker_start.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') ; 2 | const exec = require('child_process').exec ; 3 | 4 | const sleepFor = async(ms) => new Promise((resolve, reject) => setTimeout(resolve, ms)); 5 | 6 | test('starting docker network..', (t) => { 7 | t.plan(1); 8 | exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml up -d`, async(err, stdout, stderr) => { 9 | //console.log(stderr); 10 | console.log('docker network started, giving extra time for freeswitch to initialize...'); 11 | await testFreeswitches(['freeswitch-sut', 'freeswitch-uac'], 35000); 12 | t.pass('docker is up'); 13 | }); 14 | }); 15 | 16 | const testFreeswitches = async(arr, timeout) => { 17 | const timer = setTimeout(() => { 18 | throw new Error('timeout waiting for freeswitches to come up'); 19 | }, timeout); 20 | 21 | do { 22 | await sleepFor(5000); 23 | try { 24 | await Promise.all(arr.map((freeswitch) => testOneFsw(freeswitch))); 25 | //console.log('successfully connected to freeswitches'); 26 | clearTimeout(timer); 27 | return; 28 | } catch (err) { 29 | } 30 | } while(true); 31 | }; 32 | 33 | function testOneFsw(fsw) { 34 | return new Promise((resolve, reject) => { 35 | exec(`docker exec ${fsw} fs_cli -x "console loglevel debug"`, (err, stdout, stderr) => { 36 | if (err) reject(err); 37 | else resolve(err); 38 | }); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /test/docker_stop.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') ; 2 | const exec = require('child_process').exec ; 3 | 4 | test('stopping docker network..', (t) => { 5 | t.timeoutAfter(10000); 6 | exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml down`, (err, stdout, stderr) => { 7 | //console.log(`stderr: ${stderr}`); 8 | process.exit(0); 9 | }); 10 | t.end() ; 11 | }); 12 | 13 | -------------------------------------------------------------------------------- /test/endpoint.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') ; 2 | const Srf = require('drachtio-srf') ; 3 | const Mrf = require('..') ; 4 | const config = require('config') ; 5 | const clearRequire = require('clear-module'); 6 | const Endpoint = require('../lib/endpoint'); 7 | const EP_FILE = '/tmp/endpoint_record.wav'; 8 | const EP_FILE2 = '/tmp/endpoint_record2.wav'; 9 | 10 | // connect the 2 apps to their drachtio servers 11 | const connect = async(agents) => { 12 | return Promise.all(agents.map((agent) => new Promise((resolve, reject) => { 13 | agent.once('connect', (err) => { 14 | if (err) reject(err); 15 | else resolve(); 16 | }); 17 | }))); 18 | }; 19 | 20 | // disconnect the 2 apps 21 | function disconnect(agents) { 22 | agents.forEach((app) => {app.disconnect();}) ; 23 | clearRequire('./../app'); 24 | } 25 | 26 | 27 | test('MediaServer#connectCaller create active endpoint using Promise', (t) => { 28 | t.timeoutAfter(6000); 29 | 30 | const uac = require('./scripts/call-generator')(config.get('call-generator')) ; 31 | const srf = new Srf(); 32 | const mrf = new Mrf(srf) ; 33 | let ms, ep, dlg ; 34 | 35 | srf.connect(config.get('drachtio-sut')) ; 36 | 37 | connect([srf, uac]) 38 | .then(() => { 39 | srf.invite(handler); 40 | uac.startScenario() ; 41 | return ; 42 | }) 43 | .catch((err) => { 44 | t.fail(err); 45 | }); 46 | 47 | 48 | function handler(req, res) { 49 | 50 | mrf.connect(config.get('freeswitch-sut')) 51 | .then((mediaserver) => { 52 | t.pass('connected to media server'); 53 | ms = mediaserver ; 54 | return mediaserver.connectCaller(req, res); 55 | }) 56 | .then(({endpoint, dialog}) => { 57 | t.ok(endpoint instanceof Endpoint, 'connected incoming call to endpoint'); 58 | 59 | ep = endpoint ; 60 | dlg = dialog ; 61 | return uac.streamTo(ep.local.sdp); 62 | }) 63 | .then(() => { 64 | t.pass('modified uac to stream to endpoint'); 65 | return ep.getChannelVariables(); 66 | }) 67 | .then((vars) => { 68 | t.ok(vars.variable_rtp_use_codec_string.split(',').indexOf('PCMU') !== -1, 'PCMU is offered'); 69 | t.ok(vars.variable_rtp_use_codec_string.split(',').indexOf('PCMA') !== -1, 'PCMA is offered'); 70 | t.ok(vars.variable_rtp_use_codec_string.split(',').indexOf('OPUS') !== -1, 'OPUS is offered'); 71 | 72 | return ep.play({file:'voicemail/8000/vm-record_message.wav', seekOffset: 8000, timeoutSecs: 2}); 73 | }) 74 | .then((vars) => { 75 | t.ok(vars.playbackSeconds === "2", 'playbackSeconds is correct'); 76 | t.ok(vars.playbackMilliseconds === "2048", 'playbackMilliseconds is correct'); 77 | t.ok(vars.playbackLastOffsetPos === "104000", 'playbackLastOffsetPos is correct'); 78 | 79 | return ep.play('silence_stream://200'); 80 | }) 81 | .then(() => { 82 | t.pass('play a single file'); 83 | return ep.play(['silence_stream://150', 'silence_stream://150']); 84 | }) 85 | .catch((err) => { 86 | console.error(err); 87 | t.fail(err); 88 | }) 89 | .then(() => { 90 | t.pass('play an array of files'); 91 | ep.destroy() ; 92 | dlg.destroy() ; 93 | ms.disconnect() ; 94 | disconnect([srf, uac]); 95 | t.end() ; 96 | return; 97 | }) 98 | .catch ((err) => { 99 | t.fail(err); 100 | if (ep) ep.destroy() ; 101 | if (dlg) dlg.destroy() ; 102 | if (ms) ms.disconnect() ; 103 | disconnect([srf, uac]); 104 | t.end() ; 105 | }); 106 | } 107 | }); 108 | 109 | test('MediaServer#connectCaller create active endpoint using Callback', (t) => { 110 | t.timeoutAfter(5000); 111 | 112 | const uac = require('./scripts/call-generator')(config.get('call-generator')) ; 113 | const srf = new Srf(); 114 | const mrf = new Mrf(srf) ; 115 | let ms, ep, dlg ; 116 | 117 | srf.connect(config.get('drachtio-sut')) ; 118 | 119 | connect([srf, uac]) 120 | .then(() => { 121 | srf.invite(handler); 122 | uac.startScenario() ; 123 | return ; 124 | }) 125 | .catch((err) => { 126 | t.fail(err); 127 | }); 128 | 129 | function handler(req, res) { 130 | 131 | mrf.connect(config.get('freeswitch-sut')) 132 | .then((mediaserver) => { 133 | t.pass('connected to media server'); 134 | return ms = mediaserver ; 135 | }) 136 | .then(() => { 137 | return ms.connectCaller(req, res); 138 | }) 139 | .then(({endpoint, dialog}) => { 140 | ep = endpoint ; 141 | dlg = dialog ; 142 | return uac.streamTo(endpoint.local.sdp); 143 | }) 144 | .then(() => { 145 | t.pass('modified uac to stream to endpoint'); 146 | return ep.getChannelVariables(); 147 | }) 148 | .then((vars) => { 149 | t.ok(vars.variable_rtp_use_codec_string.split(',').indexOf('PCMU') !== -1, 'PCMU is offered'); 150 | t.ok(vars.variable_rtp_use_codec_string.split(',').indexOf('PCMA') !== -1, 'PCMA is offered'); 151 | t.ok(vars.variable_rtp_use_codec_string.split(',').indexOf('OPUS') !== -1, 'OPUS is offered'); 152 | ep.destroy() ; 153 | dlg.destroy() ; 154 | ms.disconnect() ; 155 | disconnect([srf, uac]); 156 | t.end() ; 157 | return; 158 | }) 159 | .catch((err) => { 160 | t.fail(err); 161 | }); 162 | } 163 | }); 164 | 165 | test('MediaServer#connectCaller add custom event listeners', (t) => { 166 | t.timeoutAfter(5000); 167 | 168 | const uac = require('./scripts/call-generator')(config.get('call-generator')) ; 169 | const srf = new Srf(); 170 | const mrf = new Mrf(srf) ; 171 | let ms, ep, dlg ; 172 | 173 | srf.connect(config.get('drachtio-sut')) ; 174 | 175 | connect([srf, uac]) 176 | .then(() => { 177 | srf.invite(handler); 178 | uac.startScenario() ; 179 | return ; 180 | }) 181 | .catch((err) => { 182 | t.fail(err); 183 | }); 184 | 185 | function handler(req, res) { 186 | 187 | mrf.connect(config.get('freeswitch-sut')) 188 | .then((mediaserver) => { 189 | t.pass('connected to media server'); 190 | return ms = mediaserver ; 191 | }) 192 | .then(() => { 193 | return ms.connectCaller(req, res); 194 | }) 195 | .then(({endpoint, dialog}) => { 196 | ep = endpoint ; 197 | dlg = dialog ; 198 | return uac.streamTo(endpoint.local.sdp); 199 | }) 200 | .then(() => { 201 | t.pass('modified uac to stream to endpoint'); 202 | t.throws(ep.addCustomEventListener.bind(ep, 'example::event'), 'throws if handler is not present'); 203 | t.throws(ep.addCustomEventListener.bind(ep, 'example::event', 'foobar'), 'throws if handler is not a function'); 204 | t.throws(ep.addCustomEventListener.bind(ep, 'CUSTOM example::event'), 'throws if incorrect form of event name used'); 205 | const listener = (args) => {}; 206 | ep.addCustomEventListener('example::event', (args) => {}); 207 | ep.addCustomEventListener('example::event', listener); 208 | t.equals(ep._customEvents.length, 1, 'successfully adds custom event listener'); 209 | t.equals(ep.listenerCount('example::event'), 2, 'successfully adds custom event listener'); 210 | ep.removeCustomEventListener('example::event', listener); 211 | t.equals(ep._customEvents.length, 1, 'successfully removes 1 listener'); 212 | t.equals(ep.listenerCount('example::event'), 1, 'successfully removes 1 listener'); 213 | ep.removeCustomEventListener('example::event'); 214 | t.equals(ep._customEvents.length, 0, 'successfully removes custom event listener'); 215 | return; 216 | }) 217 | .then(() => { 218 | ep.destroy() ; 219 | dlg.destroy() ; 220 | ms.disconnect() ; 221 | disconnect([srf, uac]); 222 | t.end() ; 223 | return; 224 | }) 225 | .catch((err) => { 226 | t.fail(err); 227 | }); 228 | } 229 | }); 230 | 231 | test('play and collect dtmf', (t) => { 232 | t.timeoutAfter(10000); 233 | 234 | const uac = require('./scripts/call-generator')(config.get('call-generator')) ; 235 | const srf = new Srf(); 236 | const mrf = new Mrf(srf) ; 237 | let ms, ep, ep2, dlg ; 238 | const digits = '1'; 239 | 240 | srf.connect(config.get('drachtio-sut')) ; 241 | 242 | connect([srf, uac]) 243 | .then(() => { 244 | srf.invite(handler); 245 | uac.startScenario() ; 246 | return ; 247 | }) 248 | .catch((err) => { 249 | t.fail(err); 250 | }); 251 | 252 | function handler(req, res) { 253 | 254 | mrf.connect(config.get('freeswitch-sut')) 255 | .then((mediaserver) => { 256 | t.pass('connected to media server'); 257 | ms = mediaserver ; 258 | return mediaserver.connectCaller(req, res); 259 | }) 260 | .then(({endpoint, dialog}) => { 261 | t.ok(endpoint instanceof Endpoint, 'connected incoming call to endpoint'); 262 | ep = endpoint ; 263 | dlg = dialog ; 264 | return uac.streamTo(ep.local.sdp); 265 | }) 266 | .then(() => { 267 | return ep.recordSession(EP_FILE); 268 | }) 269 | .then((evt) => { 270 | t.pass('record_session'); 271 | return uac.generateDtmf(digits); 272 | }) 273 | .then(() => { 274 | return t.pass(`generating dtmf digits: \'${digits}\'`); 275 | }) 276 | .then(() => { 277 | return ep.playCollect({file: 'silence_stream://200', min: 1, max: 4}); 278 | }) 279 | .then((response) => { 280 | t.ok(response.digits === '1', `detected digits: \'${response.digits}\'`); 281 | return ; 282 | }) 283 | .then(() => { 284 | return ms.createEndpoint({codecs: ['PCMU', 'PCMA', 'OPUS']}) ; 285 | }) 286 | .then((endpoint) => { 287 | ep2 = endpoint ; 288 | t.pass('created second endpoint'); 289 | return ; 290 | }) 291 | .then(() => { 292 | return ep.bridge(ep2); 293 | }) 294 | .then(() => { 295 | t.pass('bridged endpoint'); 296 | return ep.mute() ; 297 | }) 298 | .then(() => { 299 | t.ok(ep.muted, 'muted endpoint'); 300 | return ep.unmute(); 301 | }) 302 | .then(() => { 303 | t.ok(!ep.muted, 'unmuted endpoint'); 304 | return ep.toggleMute(); 305 | }) 306 | .then(() => { 307 | t.ok(ep.muted, 'muted endpoint via toggle'); 308 | return ep.toggleMute(); 309 | }) 310 | .then(() => { 311 | t.ok(!ep.muted, 'unmuted endpoint via toggle'); 312 | return ep.unbridge(); 313 | }) 314 | .then(() => { 315 | t.pass('unbridged endpoint'); 316 | return ep.set('playback_terminators', '#'); 317 | }) 318 | .then(() => { 319 | t.pass('set a single value'); 320 | return ep.set({ 321 | 'playback_terminators': '*', 322 | 'recording_follow_transfer': true 323 | }); 324 | }) 325 | .then((evt) => { 326 | t.pass('set multiple values'); 327 | ep.destroy() ; 328 | ep2.destroy() ; 329 | dlg.destroy() ; 330 | ms.disconnect() ; 331 | disconnect([srf, uac]); 332 | t.end() ; 333 | return ; 334 | }) 335 | .catch((err) => { 336 | console.error(err); 337 | t.fail(err); 338 | ep.destroy() ; 339 | dlg.destroy() ; 340 | ms.disconnect() ; 341 | disconnect([srf, uac]); 342 | t.end() ; 343 | }); 344 | } 345 | }); 346 | 347 | test('record', (t) => { 348 | t.timeoutAfter(10000); 349 | 350 | if (process.env.CI === 'travis') { 351 | t.pass('stubbed out for travis'); 352 | t.end(); 353 | return; 354 | } 355 | 356 | 357 | const uac = require('./scripts/call-generator')(config.get('call-generator')) ; 358 | const srf = new Srf(); 359 | const mrf = new Mrf(srf) ; 360 | let ms, ep, dlg ; 361 | 362 | srf.connect(config.get('drachtio-sut')) ; 363 | 364 | connect([srf, uac]) 365 | .then(() => { 366 | srf.invite(handler); 367 | uac.startScenario() ; 368 | return ; 369 | }) 370 | .catch((err) => { 371 | t.fail(err); 372 | }); 373 | 374 | function handler(req, res) { 375 | 376 | let promiseRecord; 377 | mrf.connect(config.get('freeswitch-sut')) 378 | .then((mediaserver) => { 379 | t.pass('connected to media server'); 380 | ms = mediaserver ; 381 | return mediaserver.connectCaller(req, res); 382 | }) 383 | .then(({endpoint, dialog}) => { 384 | t.ok(endpoint instanceof Endpoint, 'connected incoming call to endpoint'); 385 | ep = endpoint ; 386 | dlg = dialog ; 387 | ep.on('dtmf', (evt) => { 388 | t.pass(`got dtmf: ${JSON.stringify(evt)}`); 389 | }); 390 | return uac.streamTo(ep.local.sdp); 391 | }) 392 | .then(() => { 393 | return ep.set('playback_terminators', '123456789#*'); 394 | }) 395 | .then(() => { 396 | ep.play(['silence_stream://1000', 'voicemail/8000/vm-record_message.wav']); 397 | promiseRecord = ep.record(EP_FILE2, {timeLimitSecs: 3}); 398 | t.pass('started recording'); 399 | return uac.generateSilence(2000); 400 | }) 401 | .then((evt) => { 402 | t.pass('generating dtmf #'); 403 | uac.generateDtmf('#'); 404 | return promiseRecord; 405 | }) 406 | .then((evt) => { 407 | t.ok(evt.terminatorUsed === '#', `record terminated by # key: ${JSON.stringify(evt)}`); 408 | return; 409 | }) 410 | .then(() => { 411 | ep.destroy() ; 412 | dlg.destroy() ; 413 | ms.disconnect() ; 414 | disconnect([srf, uac]); 415 | t.end() ; 416 | return ; 417 | }) 418 | .catch((err) => { 419 | console.error(err); 420 | t.fail(err); 421 | ep.destroy() ; 422 | dlg.destroy() ; 423 | ms.disconnect() ; 424 | disconnect([srf, uac]); 425 | t.end() ; 426 | }); 427 | } 428 | }); 429 | 430 | test.skip('fork audio', (t) => { 431 | t.timeoutAfter(15000); 432 | 433 | if (process.env.CI === 'travis') { 434 | t.pass('stubbed out for travis'); 435 | t.end(); 436 | return; 437 | } 438 | 439 | const uac = require('./scripts/call-generator')(config.get('call-generator')) ; 440 | const srf = new Srf(); 441 | const mrf = new Mrf(srf) ; 442 | let ms, ep, dlg ; 443 | 444 | srf.connect(config.get('drachtio-sut')) ; 445 | 446 | connect([srf, uac]) 447 | .then(() => { 448 | srf.invite(handler); 449 | uac.startScenario() ; 450 | return ; 451 | }) 452 | .catch((err) => { 453 | t.fail(err); 454 | }); 455 | 456 | function handler(req, res) { 457 | 458 | let promisePlayFile; 459 | mrf.connect(config.get('freeswitch-sut')) 460 | .then((mediaserver) => { 461 | t.pass('connected to media server'); 462 | ms = mediaserver ; 463 | return mediaserver.connectCaller(req, res, {codecs: 'PCMU'}); 464 | }) 465 | .then(({endpoint, dialog}) => { 466 | t.ok(endpoint instanceof Endpoint, 'connected incoming call to endpoint'); 467 | ep = endpoint ; 468 | dlg = dialog ; 469 | return uac.streamTo(ep.local.sdp); 470 | }) 471 | .then(() => { 472 | return ep.forkAudioStart({ 473 | wsUrl: 'ws://ws-server:3001', 474 | mixType: 'stereo', 475 | sampling: '16000', 476 | metadata: {foo: 'bar'}, 477 | bidirectionalAudioSampleRate: 8000 478 | }); 479 | }) 480 | .then(() => { 481 | t.pass('started forking audio with metadata'); 482 | return uac.playFile('voicemail/16000/vm-record_message.wav'); 483 | }) 484 | .then((evt) => { 485 | return ep.forkAudioSendText('simple text'); 486 | }) 487 | .then(() => { 488 | t.pass('sent text frame '); 489 | return ep.forkAudioSendText({bar: 'baz'}); 490 | }) 491 | .then(() => { 492 | t.pass('sent text frame (json) '); 493 | return ep.forkAudioStop({foo: 'baz'}); 494 | }) 495 | .then(() => { 496 | t.pass('stopped forking audio with metadata'); 497 | return ep.forkAudioStart({ 498 | wsUrl: 'ws://ws-server:3001', 499 | mixType: 'stereo', 500 | sampling: '16000' 501 | }); 502 | }) 503 | .then(() => { 504 | t.pass('started forking audio with no metadata'); 505 | return uac.playFile('voicemail/16000/vm-record_message.wav'); 506 | }) 507 | .then((evt) => { 508 | return ep.forkAudioStop(); 509 | }) 510 | .then(() => { 511 | t.pass('stopped forking audio with no metadata'); 512 | return ; 513 | }) 514 | .then(() => { 515 | ep.destroy() ; 516 | dlg.destroy() ; 517 | ms.disconnect() ; 518 | disconnect([srf, uac]); 519 | t.end() ; 520 | return ; 521 | }) 522 | .catch((err) => { 523 | console.error(err); 524 | t.fail(err); 525 | ep.destroy() ; 526 | dlg.destroy() ; 527 | ms.disconnect() ; 528 | disconnect([srf, uac]); 529 | t.end() ; 530 | }); 531 | } 532 | }); -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | require('./docker_start'); 2 | require('./mediaserver'); 3 | require('./endpoint'); 4 | require('./conference'); 5 | require('./docker_stop'); 6 | -------------------------------------------------------------------------------- /test/mediaserver.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') ; 2 | const Srf = require('drachtio-srf') ; 3 | const Mrf = require('..') ; 4 | const config = require('config') ; 5 | const clearRequire = require('clear-module'); 6 | const MediaServer = require('../lib/mediaserver'); 7 | const debug = require('debug')('drachtio:fsmrf') ; 8 | 9 | // connect the 2 apps to their drachtio servers 10 | function connect(agents) { 11 | return Promise.all(agents.map((agent) => new Promise((resolve, reject) => { 12 | agent.once('connect', (err) => { 13 | if (err) reject(err); 14 | else resolve(); 15 | }); 16 | }))); 17 | } 18 | 19 | // disconnect the 2 apps 20 | function disconnect(agents) { 21 | agents.forEach((app) => {app.disconnect();}) ; 22 | clearRequire('./../app'); 23 | } 24 | 25 | test.skip('Mrf#connect using Promise', (t) => { 26 | t.timeoutAfter(5000); 27 | 28 | const srf = new Srf(); 29 | srf.connect(config.get('drachtio-uac')) ; 30 | const mrf = new Mrf(srf) ; 31 | 32 | connect([srf]) 33 | .then(() => { 34 | t.ok(mrf.localAddresses.constructor.name === 'Array', 'mrf.localAddresses is an array'); 35 | 36 | return mrf.connect(config.get('freeswitch-uac')); 37 | }) 38 | .then((mediaserver) => { 39 | t.ok(mediaserver.conn.socket.constructor.name === 'Socket', 'socket connected'); 40 | t.ok(mediaserver.srf instanceof Srf, 'mediaserver.srf is an Srf'); 41 | t.ok(mediaserver instanceof MediaServer, 42 | `successfully connected to mediaserver at ${mediaserver.sip.ipv4.udp.address}`); 43 | t.ok(mediaserver.hasCapability(['ipv4', 'udp']), 'mediaserver has ipv4 udp'); 44 | t.ok(mediaserver.hasCapability(['ipv4', 'dtls']), 'mediaserver has ipv4 dtls'); 45 | t.ok(!mediaserver.hasCapability(['ipv6', 'udp']), 'mediaserver does not have ipv6 udp'); 46 | t.ok(!mediaserver.hasCapability(['ipv6', 'dtls']), 'mediaserver does not have ipv6 dtls'); 47 | mediaserver.disconnect() ; 48 | t.ok(mediaserver.conn.socket === null, 'Mrf#disconnect closes socket'); 49 | disconnect([srf]); 50 | t.end() ; 51 | return; 52 | }) 53 | .catch((err) => { 54 | t.fail(err); 55 | }); 56 | }) ; 57 | 58 | test.skip('Mrf#connect rejects Promise with error when attempting connection to non-listening port', (t) => { 59 | t.timeoutAfter(5000); 60 | 61 | const srf = new Srf(); 62 | srf.connect(config.get('drachtio-uac')) ; 63 | const mrf = new Mrf(srf) ; 64 | 65 | connect([srf]) 66 | .then(() => { 67 | return mrf.connect(config.get('freeswitch-uac-fail')); 68 | }) 69 | .then((mediaserver) => { 70 | return t.fail('should not have succeeded'); 71 | }) 72 | .catch((err) => { 73 | t.ok(err.code === 'ECONNREFUSED', 'Promise rejects with connection refused error'); 74 | disconnect([srf]); 75 | t.end() ; 76 | }); 77 | }) ; 78 | 79 | test.skip('Mrf#connect using callback', (t) => { 80 | t.timeoutAfter(5000); 81 | 82 | const srf = new Srf(); 83 | srf.connect(config.get('drachtio-uac')) ; 84 | const mrf = new Mrf(srf) ; 85 | 86 | connect([srf]) 87 | .then(() => { 88 | t.ok(mrf.localAddresses.constructor.name === 'Array', 'mrf.localAddresses is an array'); 89 | 90 | return mrf.connect(config.get('freeswitch-uac'), (err, mediaserver) => { 91 | if (err) return t.fail(err); 92 | 93 | t.ok(mediaserver.conn.socket.constructor.name === 'Socket', 'socket connected'); 94 | t.ok(mediaserver.srf instanceof Srf, 'mediaserver.srf is an Srf'); 95 | t.ok(mediaserver instanceof MediaServer, 96 | `successfully connected to mediaserver at ${mediaserver.sip.ipv4.udp.address}`); 97 | t.ok(mediaserver.hasCapability(['ipv4', 'udp']), 'mediaserver has ipv4 udp'); 98 | t.ok(mediaserver.hasCapability(['ipv4', 'dtls']), 'mediaserver has ipv4 dtls'); 99 | t.ok(!mediaserver.hasCapability(['ipv6', 'udp']), 'mediaserver does not have ipv6 udp'); 100 | t.ok(!mediaserver.hasCapability(['ipv6', 'dtls']), 'mediaserver does not have ipv6 dtls'); 101 | disconnect([srf]); 102 | mediaserver.disconnect() ; 103 | t.ok(mediaserver.conn.socket === null, 'Mrf#disconnect closes socket'); 104 | t.end() ; 105 | }); 106 | }) 107 | .catch((err) => { 108 | t.fail(err); 109 | }); 110 | }) ; 111 | /* 112 | test('Mrf#connect callback returns error when attempting connection to non-listening port', (t) => { 113 | t.timeoutAfter(1000); 114 | 115 | const srf = new Srf(); 116 | srf.connect(config.get('drachtio-uac')) ; 117 | const mrf = new Mrf(srf) ; 118 | 119 | connect([srf]) 120 | .then(() => { 121 | return mrf.connect(config.get('freeswitch-uac-fail'), (err) => { 122 | t.ok(err.code === 'ECONNREFUSED', 'callback with err connection refused'); 123 | disconnect([srf]); 124 | t.end(); 125 | }) ; 126 | }) 127 | .catch((err) => { 128 | t.fail(err); 129 | }); 130 | }) ; 131 | */ 132 | 133 | /* Sending custom-profile Mrf setup */ 134 | 135 | test('Mrf# - custom-profile - connect using Promise', (t) => { 136 | t.timeoutAfter(5000); 137 | 138 | const srf = new Srf(); 139 | srf.connect(config.get('drachtio-uac')) ; 140 | const mrf = new Mrf(srf) ; 141 | 142 | 143 | connect([srf]) 144 | .then(() => { 145 | t.ok(mrf.localAddresses.constructor.name === 'Array', 'mrf.localAddresses is an array'); 146 | 147 | return mrf.connect(config.get('freeswitch-custom-profile-uac'), (err, mediaserver) => { 148 | if (err) return t.fail(err); 149 | 150 | t.ok(mediaserver.conn.socket.constructor.name === 'Socket', 'socket connected'); 151 | t.ok(mediaserver.srf instanceof Srf, 'mediaserver.srf is an Srf'); 152 | t.ok(mediaserver instanceof MediaServer, 153 | `successfully connected to mediaserver at ${mediaserver.sip.ipv4.udp.address}`); 154 | t.ok(mediaserver.hasCapability(['ipv4', 'udp']), 'mediaserver has ipv4 udp'); 155 | t.ok(mediaserver.hasCapability(['ipv4', 'dtls']), 'mediaserver has ipv4 dtls'); 156 | t.ok(!mediaserver.hasCapability(['ipv6', 'udp']), 'mediaserver does not have ipv6 udp'); 157 | t.ok(!mediaserver.hasCapability(['ipv6', 'dtls']), 'mediaserver does not have ipv6 dtls'); 158 | disconnect([srf]); 159 | mediaserver.disconnect() ; 160 | t.ok(mediaserver.conn.socket === null, 'Mrf#disconnect closes socket'); 161 | t.end() ; 162 | }); 163 | }) 164 | .catch((err) => { 165 | t.fail(err); 166 | }); 167 | }) ; 168 | 169 | test('Mrf# - custom-profile - connect using callback', (t) => { 170 | t.timeoutAfter(5000); 171 | 172 | const srf = new Srf(); 173 | srf.connect(config.get('drachtio-uac')) ; 174 | const mrf = new Mrf(srf) ; 175 | 176 | 177 | connect([srf]) 178 | .then(() => { 179 | t.ok(mrf.localAddresses.constructor.name === 'Array', 'mrf.localAddresses is an array'); 180 | 181 | return mrf.connect(config.get('freeswitch-custom-profile-uac'), (err, mediaserver) => { 182 | if (err) return t.fail(err); 183 | 184 | t.ok(mediaserver.conn.socket.constructor.name === 'Socket', 'socket connected'); 185 | t.ok(mediaserver.srf instanceof Srf, 'mediaserver.srf is an Srf'); 186 | t.ok(mediaserver instanceof MediaServer, 187 | `successfully connected to mediaserver at ${mediaserver.sip.ipv4.udp.address}`); 188 | t.ok(mediaserver.hasCapability(['ipv4', 'udp']), 'mediaserver has ipv4 udp'); 189 | t.ok(mediaserver.hasCapability(['ipv4', 'dtls']), 'mediaserver has ipv4 dtls'); 190 | t.ok(!mediaserver.hasCapability(['ipv6', 'udp']), 'mediaserver does not have ipv6 udp'); 191 | t.ok(!mediaserver.hasCapability(['ipv6', 'dtls']), 'mediaserver does not have ipv6 dtls'); 192 | disconnect([srf]); 193 | mediaserver.disconnect() ; 194 | t.ok(mediaserver.conn.socket === null, 'Mrf#disconnect closes socket'); 195 | t.end() ; 196 | }); 197 | }) 198 | .catch((err) => { 199 | t.fail(err); 200 | }); 201 | }) ; 202 | -------------------------------------------------------------------------------- /test/recordings/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drachtio/drachtio-fsmrf/a3b5af54476a91171305d336807641b5a6b58845/test/recordings/.keep -------------------------------------------------------------------------------- /test/scripts/call-generator.js: -------------------------------------------------------------------------------- 1 | const Srf = require('drachtio-srf') ; 2 | const Mrf = require('../..'); 3 | const assert = require('assert'); 4 | 5 | module.exports = function(opts) { 6 | 7 | const srf = new Srf() ; 8 | srf.connect(opts.drachtio); 9 | 10 | let ep, ms ; 11 | 12 | srf.startScenario = function() { 13 | const mrf = new Mrf(srf); 14 | 15 | mrf.connect(opts.freeswitch) 16 | .then((mediaserver) => { 17 | ms = mediaserver ; 18 | return mediaserver.createEndpoint(); 19 | }) 20 | .then((endpoint) => { 21 | ep = endpoint ; 22 | return srf.createUAC(opts.uri, { 23 | localSdp: endpoint.local.sdp 24 | }); 25 | }) 26 | .catch((err) => { 27 | assert(`call-generator: error connecting to media server at ${JSON.stringify(opts.freeswitch)}: ${err}`); 28 | }); 29 | }; 30 | 31 | srf.streamTo = function(remoteSdp) { 32 | return ep.dialog.modify(remoteSdp) ; 33 | }; 34 | 35 | srf.generateSilence = function(duration) { 36 | return ep.play(`silence_stream://${duration}`) 37 | .then((evt) => { 38 | return evt; 39 | }) 40 | .catch((err) => { 41 | console.log(`error: ${err}`); 42 | }); 43 | }; 44 | 45 | srf.playFile = function(file) { 46 | return ep.play(file) 47 | .catch((err) => console.log(`error: ${err}`)); 48 | }; 49 | 50 | srf.generateDtmf = function(digits) { 51 | ep.execute('send_dtmf', `${digits}@125`) 52 | .then((res) => { 53 | return; 54 | }) 55 | .catch((err, res) => { 56 | console.log(`error generating dtmf: ${JSON.stringify(err)}`); 57 | }); 58 | }; 59 | 60 | var origDisconnect = srf.disconnect.bind(srf) ; 61 | srf.disconnect = function() { 62 | ep.destroy() ; 63 | ms.disconnect() ; 64 | origDisconnect(); 65 | }; 66 | 67 | return srf ; 68 | } ; 69 | 70 | -------------------------------------------------------------------------------- /test/sounds/en/us/callie/endpoint_record.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drachtio/drachtio-fsmrf/a3b5af54476a91171305d336807641b5a6b58845/test/sounds/en/us/callie/endpoint_record.wav -------------------------------------------------------------------------------- /test/sounds/en/us/callie/endpoint_record2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drachtio/drachtio-fsmrf/a3b5af54476a91171305d336807641b5a6b58845/test/sounds/en/us/callie/endpoint_record2.wav -------------------------------------------------------------------------------- /test/sounds/en/us/callie/voicemail/16000/vm-record_message.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drachtio/drachtio-fsmrf/a3b5af54476a91171305d336807641b5a6b58845/test/sounds/en/us/callie/voicemail/16000/vm-record_message.wav -------------------------------------------------------------------------------- /test/sounds/en/us/callie/voicemail/8000/vm-record_message.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drachtio/drachtio-fsmrf/a3b5af54476a91171305d336807641b5a6b58845/test/sounds/en/us/callie/voicemail/8000/vm-record_message.wav -------------------------------------------------------------------------------- /test/tls/chain.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEogIBAAKCAQEA2rJilj/g1/1GwwbjwYVTQ7raVxT+GvBsWVAPzVRPmps0+ErV 3 | /8+k07schyyACF/xHnd2OGTMmqtc+0Cgoec4eTX8Plh96G38cfdZA5L/T9fD+Aa0 4 | s4EqWz+HSlusRw7PTR6B6wkX/aCBjYuHsQIcKajV/NmxRNhH33few8ILvGmJ2E6C 5 | 4EnLxtMt1YeUtNnQaHWxlvw6YoCSU64IYkFCY+OXvUpqt7e21iVRGu1CYGlbEkY7 6 | vKZ2nOdqDHyk9ao2b9uKOyvYV6FbmcHb8kg3BC5YwLf/ScYQkI7FC43IHMo1dmkF 7 | pK2lhW0FqCnXmrg7NK8CQ8P4l8rNFYLI5gSGXwIDAQABAoIBAHFibw6rC62v2NIg 8 | blDVCPhilfT0I1JgOCyN/8Na5PKpaWsZkZ3RUAmeRwomHjM5Ws+K7DYMvK+sDMcP 9 | GLkYIgVl4eOJCa5J58pGjVX3DnucyDN9do5id01bwI7ivI0StpOrL3xl+JQ8dS6n 10 | mrBBAczvhhJT4z5oS8smYM5peqAxuf/NUieeNEZzbOVCsY/dc4L6tBmKWW4bjl2b 11 | EKqNWNEmFJDqzHkJmLb02u+nummiCxRQ5YZAlhAyKRvQO7IWnXEV3cRIwSQsFb+g 12 | znQkzq439PGEUhzkFrlADcm2M63Mp717WmbvpuwQEpybyUS5iYS8ypdSImSryTJi 13 | MArn8RECgYEA9qSRKK7zpE+SLAqr4qj0xmgJy84a/VtKZC+OkfBoA/VsZ3TG+PnD 14 | XzS3KTcNeASzvaiP2T8qA028a5jV3pn8P8vjiGlGeC6y6R4s0Qb42CP8gmiWs4GJ 15 | Xn/snFqgwul9u8Tdsv9afBjfAgrpBnBP+9qyDpcSeuTUenGId4icAlUCgYEA4v5m 16 | 61daEqLZfaai7Q0Aiz3W4FfU/tJX+blOWqGKYTS/BcwkaVHlrrUsYd7CzEq1aeQK 17 | D8zd1hPug5FXbWvFO5yTSEeITSbEc6FPSiPHDdgkrHUkgifQxwANWVWQr0eEq2da 18 | eK08RhJRDoDzj7gPkSv7PTVYQc98VCGMgopboeMCgYBGfR3nTKjhKsSRxbL+Il9i 19 | XNV/47We9vo5y8WpO9XeW8PRhXRgL7GAgiZepxc4V8+uwn/qDL1LGGpjLdjht4x6 20 | ByFOGRhEvMPxD+irDJ0N2KsP8igvwTOrSFAtF1GeovCGO2tI/uWzVcBWaxvR9UNG 21 | rWf5938WlFONcukXkHlVyQKBgA7y6hw9mHT5vJEF1F0AKjUBUZFct25AtAKCLaLS 22 | WKamLp5XH17AQfwLemzHmtSRZvkeR3ta5pEepuqLO9K88jRGz3xHGbbbAr0KtooP 23 | aSCER4YEAO+BZ8JzQm3LsMeaUiZnnBGudvW2ZxgpbeDdIklROC6DwNg0rd9shjBi 24 | pD45AoGARcXbBmZvLvrBG1IqQruudV7JhwI7B6JQGVFsQn31QgAd9SiLWDfDhWm9 25 | xXDOQnA6lwW87Sdmbi1Z5GgQrd73N5cdc5v0GNPMAI5lIFX9Wj3tMIWdYrEPMxpT 26 | U2Tp5q8QTbWyYWdkUKFhQ528xsnwujcNaiilkEUGBb9Dhv2dswA= 27 | -----END RSA PRIVATE KEY----- 28 | -----BEGIN CERTIFICATE----- 29 | MIIDjjCCAnYCCQCGOYtFGs6qpTANBgkqhkiG9w0BAQsFADCBiDELMAkGA1UEBhMC 30 | VVMxCzAJBgNVBAgMAk1BMRQwEgYDVQQHDAtDaGFybGVzdG93bjEaMBgGA1UECgwR 31 | QmVhY2hkb2cgTmV0d29ya3MxFDASBgNVBAMMC0RhdmUgSG9ydG9uMSQwIgYJKoZI 32 | hvcNAQkBFhVkYXZlaEBiZWFjaGRvZ25ldC5jb20wHhcNMTgxMDI5MTg1ODI1WhcN 33 | MjAxMDE4MTg1ODI1WjCBiDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRQwEgYD 34 | VQQHDAtDaGFybGVzdG93bjEaMBgGA1UECgwRQmVhY2hkb2cgTmV0d29ya3MxFDAS 35 | BgNVBAMMC0RhdmUgSG9ydG9uMSQwIgYJKoZIhvcNAQkBFhVkYXZlaEBiZWFjaGRv 36 | Z25ldC5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDasmKWP+DX 37 | /UbDBuPBhVNDutpXFP4a8GxZUA/NVE+amzT4StX/z6TTuxyHLIAIX/Eed3Y4ZMya 38 | q1z7QKCh5zh5Nfw+WH3obfxx91kDkv9P18P4BrSzgSpbP4dKW6xHDs9NHoHrCRf9 39 | oIGNi4exAhwpqNX82bFE2Effd97Dwgu8aYnYToLgScvG0y3Vh5S02dBodbGW/Dpi 40 | gJJTrghiQUJj45e9Smq3t7bWJVEa7UJgaVsSRju8pnac52oMfKT1qjZv24o7K9hX 41 | oVuZwdvySDcELljAt/9JxhCQjsULjcgcyjV2aQWkraWFbQWoKdeauDs0rwJDw/iX 42 | ys0VgsjmBIZfAgMBAAEwDQYJKoZIhvcNAQELBQADggEBANPhM2Zqdfs1ZlveCgml 43 | dINU5DBidZTXv7hyddBtsHQsi7JqoUf2q5FTzVfi28HRmxAOKkufaYGq+M64oztb 44 | V3f6UAbhuGQPx92I9Axt25MnSYrKEtfkKnwb/1qDHw0rZfTRBAoBuJ3zB97TN9N8 45 | 1tkkCcYC/PquzuUhnjRhUQg64gqzCTN9AiZVx+n2YLyYn8niSE987cpaFGWr0OLK 46 | Mu+tQqLlVyLDOrJg024LvFd1whXayw3QGn6ZrUTE8MY0AS7OAzTtdN9mIumrdHcQ 47 | hTGZfc0nAZzDEakfRjLlxXQgTK+sKBGoh1VzCca8vBl5e/sJAdsWbS48eIbGmoXG 48 | 3ns= 49 | -----END CERTIFICATE----- 50 | -----BEGIN CERTIFICATE----- 51 | MIIDJDCCAo2gAwIBAgIBADANBgkqhkiG9w0BAQUFADBwMQswCQYDVQQGEwJVUzET 52 | MBEGA1UECBMKQ2FsaWZvcm5pYTERMA8GA1UEBxMIU2FuIEpvc2UxDjAMBgNVBAoT 53 | BXNpcGl0MSkwJwYDVQQLEyBTaXBpdCBUZXN0IENlcnRpZmljYXRlIEF1dGhvcml0 54 | eTAeFw0wMzA3MTgxMjIxNTJaFw0xMzA3MTUxMjIxNTJaMHAxCzAJBgNVBAYTAlVT 55 | MRMwEQYDVQQIEwpDYWxpZm9ybmlhMREwDwYDVQQHEwhTYW4gSm9zZTEOMAwGA1UE 56 | ChMFc2lwaXQxKTAnBgNVBAsTIFNpcGl0IFRlc3QgQ2VydGlmaWNhdGUgQXV0aG9y 57 | aXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDIh6DkcUDLDyK9BEUxkud 58 | +nJ4xrCVGKfgjHm6XaSuHiEtnfELHM+9WymzkBNzZpJu30yzsxwfKoIKugdNUrD4 59 | N3viCicwcN35LgP/KnbN34cavXHr4ZlqxH+OdKB3hQTpQa38A7YXdaoz6goW2ft5 60 | Mi74z03GNKP/G9BoKOGd5QIDAQABo4HNMIHKMB0GA1UdDgQWBBRrRhcU6pR2JYBU 61 | bhNU2qHjVBShtjCBmgYDVR0jBIGSMIGPgBRrRhcU6pR2JYBUbhNU2qHjVBShtqF0 62 | pHIwcDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExETAPBgNVBAcT 63 | CFNhbiBKb3NlMQ4wDAYDVQQKEwVzaXBpdDEpMCcGA1UECxMgU2lwaXQgVGVzdCBD 64 | ZXJ0aWZpY2F0ZSBBdXRob3JpdHmCAQAwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B 65 | AQUFAAOBgQCWbRvv1ZGTRXxbH8/EqkdSCzSoUPrs+rQqR0xdQac9wNY/nlZbkR3O 66 | qAezG6Sfmklvf+DOg5RxQq/+Y6I03LRepc7KeVDpaplMFGnpfKsibETMipwzayNQ 67 | QgUf4cKBiF+65Ue7hZuDJa2EMv8qW4twEhGDYclpFU9YozyS1OhvUg== 68 | -----END CERTIFICATE----- 69 | -------------------------------------------------------------------------------- /test/tls/dh1024.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN DH PARAMETERS----- 2 | MIGHAoGBAKai0LHvOCMyv6NEc9XK5pVfk086iHmS6KLzLCux3LFXH30ZueSVz6hk 3 | EgCOTQG1mks7B5oqK3HxnSXJ6RSn9sw+4cqPxxElmj62VfBHUnM6i94+UZaUOXN2 4 | VGXnJc6ne9negdBpxuf2zJDmb3GheJKtU0rW+ER9UniVQ+Iq0WgTAgEC 5 | -----END DH PARAMETERS----- 6 | -------------------------------------------------------------------------------- /test/tls/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDjjCCAnYCCQCGOYtFGs6qpTANBgkqhkiG9w0BAQsFADCBiDELMAkGA1UEBhMC 3 | VVMxCzAJBgNVBAgMAk1BMRQwEgYDVQQHDAtDaGFybGVzdG93bjEaMBgGA1UECgwR 4 | QmVhY2hkb2cgTmV0d29ya3MxFDASBgNVBAMMC0RhdmUgSG9ydG9uMSQwIgYJKoZI 5 | hvcNAQkBFhVkYXZlaEBiZWFjaGRvZ25ldC5jb20wHhcNMTgxMDI5MTg1ODI1WhcN 6 | MjAxMDE4MTg1ODI1WjCBiDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRQwEgYD 7 | VQQHDAtDaGFybGVzdG93bjEaMBgGA1UECgwRQmVhY2hkb2cgTmV0d29ya3MxFDAS 8 | BgNVBAMMC0RhdmUgSG9ydG9uMSQwIgYJKoZIhvcNAQkBFhVkYXZlaEBiZWFjaGRv 9 | Z25ldC5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDasmKWP+DX 10 | /UbDBuPBhVNDutpXFP4a8GxZUA/NVE+amzT4StX/z6TTuxyHLIAIX/Eed3Y4ZMya 11 | q1z7QKCh5zh5Nfw+WH3obfxx91kDkv9P18P4BrSzgSpbP4dKW6xHDs9NHoHrCRf9 12 | oIGNi4exAhwpqNX82bFE2Effd97Dwgu8aYnYToLgScvG0y3Vh5S02dBodbGW/Dpi 13 | gJJTrghiQUJj45e9Smq3t7bWJVEa7UJgaVsSRju8pnac52oMfKT1qjZv24o7K9hX 14 | oVuZwdvySDcELljAt/9JxhCQjsULjcgcyjV2aQWkraWFbQWoKdeauDs0rwJDw/iX 15 | ys0VgsjmBIZfAgMBAAEwDQYJKoZIhvcNAQELBQADggEBANPhM2Zqdfs1ZlveCgml 16 | dINU5DBidZTXv7hyddBtsHQsi7JqoUf2q5FTzVfi28HRmxAOKkufaYGq+M64oztb 17 | V3f6UAbhuGQPx92I9Axt25MnSYrKEtfkKnwb/1qDHw0rZfTRBAoBuJ3zB97TN9N8 18 | 1tkkCcYC/PquzuUhnjRhUQg64gqzCTN9AiZVx+n2YLyYn8niSE987cpaFGWr0OLK 19 | Mu+tQqLlVyLDOrJg024LvFd1whXayw3QGn6ZrUTE8MY0AS7OAzTtdN9mIumrdHcQ 20 | hTGZfc0nAZzDEakfRjLlxXQgTK+sKBGoh1VzCca8vBl5e/sJAdsWbS48eIbGmoXG 21 | 3ns= 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /test/tls/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEogIBAAKCAQEA2rJilj/g1/1GwwbjwYVTQ7raVxT+GvBsWVAPzVRPmps0+ErV 3 | /8+k07schyyACF/xHnd2OGTMmqtc+0Cgoec4eTX8Plh96G38cfdZA5L/T9fD+Aa0 4 | s4EqWz+HSlusRw7PTR6B6wkX/aCBjYuHsQIcKajV/NmxRNhH33few8ILvGmJ2E6C 5 | 4EnLxtMt1YeUtNnQaHWxlvw6YoCSU64IYkFCY+OXvUpqt7e21iVRGu1CYGlbEkY7 6 | vKZ2nOdqDHyk9ao2b9uKOyvYV6FbmcHb8kg3BC5YwLf/ScYQkI7FC43IHMo1dmkF 7 | pK2lhW0FqCnXmrg7NK8CQ8P4l8rNFYLI5gSGXwIDAQABAoIBAHFibw6rC62v2NIg 8 | blDVCPhilfT0I1JgOCyN/8Na5PKpaWsZkZ3RUAmeRwomHjM5Ws+K7DYMvK+sDMcP 9 | GLkYIgVl4eOJCa5J58pGjVX3DnucyDN9do5id01bwI7ivI0StpOrL3xl+JQ8dS6n 10 | mrBBAczvhhJT4z5oS8smYM5peqAxuf/NUieeNEZzbOVCsY/dc4L6tBmKWW4bjl2b 11 | EKqNWNEmFJDqzHkJmLb02u+nummiCxRQ5YZAlhAyKRvQO7IWnXEV3cRIwSQsFb+g 12 | znQkzq439PGEUhzkFrlADcm2M63Mp717WmbvpuwQEpybyUS5iYS8ypdSImSryTJi 13 | MArn8RECgYEA9qSRKK7zpE+SLAqr4qj0xmgJy84a/VtKZC+OkfBoA/VsZ3TG+PnD 14 | XzS3KTcNeASzvaiP2T8qA028a5jV3pn8P8vjiGlGeC6y6R4s0Qb42CP8gmiWs4GJ 15 | Xn/snFqgwul9u8Tdsv9afBjfAgrpBnBP+9qyDpcSeuTUenGId4icAlUCgYEA4v5m 16 | 61daEqLZfaai7Q0Aiz3W4FfU/tJX+blOWqGKYTS/BcwkaVHlrrUsYd7CzEq1aeQK 17 | D8zd1hPug5FXbWvFO5yTSEeITSbEc6FPSiPHDdgkrHUkgifQxwANWVWQr0eEq2da 18 | eK08RhJRDoDzj7gPkSv7PTVYQc98VCGMgopboeMCgYBGfR3nTKjhKsSRxbL+Il9i 19 | XNV/47We9vo5y8WpO9XeW8PRhXRgL7GAgiZepxc4V8+uwn/qDL1LGGpjLdjht4x6 20 | ByFOGRhEvMPxD+irDJ0N2KsP8igvwTOrSFAtF1GeovCGO2tI/uWzVcBWaxvR9UNG 21 | rWf5938WlFONcukXkHlVyQKBgA7y6hw9mHT5vJEF1F0AKjUBUZFct25AtAKCLaLS 22 | WKamLp5XH17AQfwLemzHmtSRZvkeR3ta5pEepuqLO9K88jRGz3xHGbbbAr0KtooP 23 | aSCER4YEAO+BZ8JzQm3LsMeaUiZnnBGudvW2ZxgpbeDdIklROC6DwNg0rd9shjBi 24 | pD45AoGARcXbBmZvLvrBG1IqQruudV7JhwI7B6JQGVFsQn31QgAd9SiLWDfDhWm9 25 | xXDOQnA6lwW87Sdmbi1Z5GgQrd73N5cdc5v0GNPMAI5lIFX9Wj3tMIWdYrEPMxpT 26 | U2Tp5q8QTbWyYWdkUKFhQ528xsnwujcNaiilkEUGBb9Dhv2dswA= 27 | -----END RSA PRIVATE KEY----- 28 | --------------------------------------------------------------------------------