├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── API.md ├── LICENSE ├── README.md ├── example └── app.js ├── lib ├── command.js ├── context.js ├── index.js └── state.js ├── package.json └── test └── index.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "google" 4 | ], 5 | "env": { 6 | "node": true 7 | }, 8 | "parserOptions": { 9 | "ecmaVersion": 2018 10 | }, 11 | "rules": { 12 | "new-cap": ["error", { "properties": true }] 13 | } 14 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage 3 | html-report 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "10" 5 | - "11" 6 | - "12" -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | attention: using javascript promises 4 | 5 | ### context.onEvent(event) 6 | 7 | events 8 | 9 | 'variables' - on start call 10 | 'close' - on end session 11 | 'hangup' - on hangup channel 12 | 13 | 14 | ### context.answer() 15 | 16 | ### context.asyncagiBreak() 17 | 18 | ### context.channelStatus(channel) 19 | 20 | ### context.controlStreamFile(filename, escape_digits, skipms, ffchar, rewchr, pausechr, offsetms) 21 | details https://wiki.asterisk.org/wiki/display/AST/Asterisk+13+AGICommand_control+stream+file 22 | 23 | ### context.databaseDel(variable, value) 24 | 25 | ### context.databaseDeltree(key) 26 | 27 | ### context.databaseGet 28 | 29 | ### context.databasePut 30 | 31 | ### context.exec 32 | 33 | ### context.getData 34 | 35 | ### context.getFullVariable 36 | 37 | ### context.getOption 38 | 39 | ### context.getVariable 40 | 41 | ### context.gosub 42 | 43 | ### context.hangup 44 | 45 | ### context.noop 46 | 47 | ### context.receiveChar 48 | 49 | ### context.receiveText 50 | 51 | ### context.recordFile 52 | 53 | ### context.sayAlpha 54 | 55 | ### context.sayDate 56 | 57 | ### context.sayDatetime 58 | 59 | ### context.sayDigits 60 | 61 | ### context.sayNumber 62 | 63 | ### context.sayPhonetic 64 | 65 | ### context.sayTime 66 | 67 | ### context.sendImage 68 | 69 | ### context.sendText 70 | 71 | ### context.setAutohangup 72 | 73 | ### context.setCallerid 74 | 75 | ### context.setContext 76 | 77 | ### context.setExtension 78 | 79 | ### context.setMusic 80 | 81 | ### context.setPriority 82 | 83 | ### context.setVariable 84 | 85 | ### context.speechActivateGrammar 86 | 87 | ### context.speechCreate 88 | 89 | ### context.speechDeactivateGrammar 90 | 91 | ### context.speechDestroy 92 | 93 | ### context.speechLoadGrammar 94 | 95 | ### context.speechRecognize 96 | 97 | ### context.speechSet 98 | 99 | ### context.speechUnloadGrammar 100 | 101 | ### context.streamFile 102 | 103 | ### context.tddMode 104 | 105 | ### context.verbose 106 | 107 | ### context.waitForDigit 108 | 109 | ### context.exec(command, [args]) 110 | 111 | Dispatches the `EXEC` AGI command to asterisk with supplied command name and arguments. 112 | 113 | ```js 114 | context.exec('Dial', opt1, opt2, .., optN) 115 | .then(function(result) 116 | //the channel call app Dial with options 117 | }); 118 | 119 | context.exec('RecieveFax', '/tmp/myfax.tif') 120 | .then(function(result) { 121 | //fax has been recieved by asterisk and written to /tmp/myfax.tif 122 | }); 123 | ``` 124 | 125 | ### context.hangup() 126 | 127 | Dispatches the 'HANGUP' AGI command to asterisk. Does __not__ close the sockets automatically. _callback_ is called with the result of the dispatch. 128 | 129 | ```js 130 | context.hangup(). 131 | ``` 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Dmitriev Sergey 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 | # ding-dong 2 | 3 | [![Build Status](https://travis-ci.org/antirek/ding-dong.svg?branch=master)](https://travis-ci.org/antirek/ding-dong) 4 | 5 | node.js lib for Fast AGI (Asterisk Gateway Interface) server 6 | 7 | [Fork of node-agi](http://github.com/brianc/node-agi) 8 | 9 | 10 | Use ding-dong 11 | ============= 12 | 13 | [voicer](http://github.com/antirek/voicer) - AGI voice recognizer for Asterisk (use Yandex and Google speech recognizers) 14 | 15 | [agi-number-archer](http://github.com/antirek/agi-number-archer) - AGI server for find region code of phone number (Russia) 16 | 17 | [lcr-finder](http://github.com/antirek/lcr-finder) - least cost router for Asterisk 18 | 19 | 20 | ## Install 21 | 22 | ``` 23 | npm install ding-dong 24 | 25 | ``` 26 | 27 | `````javascript 28 | 29 | const AGIServer = require('ding-dong'); 30 | 31 | const handler = (context) => { 32 | context.onEvent('variables') 33 | .then((vars) => { 34 | return context.streamFile('beep'); 35 | }) 36 | .then((result) => { 37 | return context.setVariable('RECOGNITION_RESULT', 'I\'m your father, Luc'); 38 | }) 39 | .then((result) => { 40 | return context.close(); 41 | }); 42 | }; 43 | 44 | var agi = new AGIServer(handler, {port: 3000}); 45 | agi.init(); 46 | 47 | ````` 48 | 49 | ### Add to Asterisk extensions.conf 50 | 51 | ````` 52 | [default] 53 | exten = > 1000,1,AGI(agi://localhost:3000) 54 | ````` 55 | 56 | ## API 57 | 58 | see [API.md](API.md) 59 | 60 | 61 | ## Links 62 | 63 | [Asterisk AGI](https://wiki.asterisk.org/wiki/display/AST/Asterisk+13+AGI+Commands) -------------------------------------------------------------------------------- /example/app.js: -------------------------------------------------------------------------------- 1 | const AGIServer = require('./../lib/index'); 2 | 3 | const handler = (context) => { 4 | context.onEvent('variables') 5 | .then((vars) => { 6 | console.log('vars', vars); 7 | return context.streamFile('beep'); 8 | }) 9 | .then((result) => { 10 | return context.setVariable( 11 | 'RECOGNITION_RESULT', 'I\'m your father, Luc'); 12 | }) 13 | .then((result) => { 14 | return context.end(); 15 | }) 16 | .fail(console.log); 17 | }; 18 | 19 | const agi = new AGIServer(handler, { 20 | debug: true, 21 | port: 3007 22 | }); 23 | agi.init() -------------------------------------------------------------------------------- /lib/command.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | name: 'exec', 4 | command: 'EXEC', 5 | params: 10, 6 | }, 7 | { 8 | name: 'databaseDel', 9 | command: 'DATABASE DEL', 10 | params: 2, 11 | }, 12 | { 13 | name: 'databaseDelTree', 14 | command: 'DATABASE DELTREE', 15 | params: 2, 16 | }, 17 | { 18 | name: 'databaseGet', 19 | command: 'DATABASE GET', 20 | params: 2, 21 | }, 22 | { 23 | name: 'databasePut', 24 | command: 'DATABASE PUT', 25 | params: 3, 26 | }, 27 | { 28 | name: 'speechCreate', 29 | command: 'SPEECH CREATE', 30 | params: 1, 31 | }, 32 | { 33 | name: 'speechDestroy', 34 | command: 'SPEECH DESTROY', 35 | params: 0, 36 | }, 37 | { 38 | name: 'speechActivateGrammar', 39 | command: 'SPEECH ACTIVATE GRAMMAR', 40 | params: 1, 41 | }, 42 | { 43 | name: 'speechDeactivateGrammar', 44 | command: 'SPEECH DEACTIVATE GRAMMAR', 45 | params: 1, 46 | }, 47 | { 48 | name: 'speechLoadGrammar', 49 | command: 'SPEECH LOAD GRAMMAR', 50 | params: 2, 51 | }, 52 | { 53 | name: 'speechUnloadGrammar', 54 | command: 'SPEECH UNLOAD GRAMMAR', 55 | params: 1, 56 | }, 57 | { 58 | name: 'speechSet', 59 | command: 'SPEECH SET', 60 | params: 2, 61 | }, 62 | { 63 | name: 'speechRecognize', 64 | command: 'SPEECH RECOGNIZE', 65 | params: 3, 66 | }, 67 | { 68 | name: 'getVariable', 69 | command: 'GET VARIABLE', 70 | params: 1, 71 | }, 72 | { 73 | name: 'getFullVariable', 74 | command: 'GET FULL VARIABLE', 75 | params: 2, 76 | }, 77 | { 78 | name: 'getData', 79 | command: 'GET DATA', 80 | params: 3, 81 | }, 82 | { 83 | name: 'getOption', 84 | command: 'GET OPTION', 85 | params: 3, 86 | paramRules: [ 87 | null, 88 | { 89 | prepare: function(value) { 90 | return '"' + value + '"'; 91 | }, 92 | }, 93 | ], 94 | }, 95 | { 96 | name: 'receiveChar', 97 | command: 'RECEIVE CHAR', 98 | params: 1, 99 | }, 100 | { 101 | name: 'receiveText', 102 | command: 'RECEIVE TEXT', 103 | params: 1, 104 | }, 105 | { 106 | name: 'setAutoHangup', 107 | command: 'SET AUTOHANGUP', 108 | params: 1, 109 | }, 110 | { 111 | name: 'setCallerID', 112 | command: 'SET CALLERID', 113 | params: 1, 114 | }, 115 | { 116 | name: 'setContext', 117 | command: 'SET CONTEXT', 118 | params: 1, 119 | }, 120 | { 121 | name: 'setExtension', 122 | command: 'SET EXTENSION', 123 | params: 1, 124 | }, 125 | { 126 | name: 'setPriority', 127 | command: 'SET PRIORITY', 128 | params: 1, 129 | }, 130 | { 131 | name: 'setMusic', 132 | command: 'SET MUSIC', 133 | params: 1, 134 | }, 135 | { 136 | name: 'setVariable', 137 | command: 'SET VARIABLE', 138 | params: 2, 139 | paramRules: [ 140 | null, 141 | { 142 | prepare: function(value) { 143 | return '"' + value + '"'; 144 | }, 145 | }, 146 | ], 147 | }, 148 | { 149 | name: 'sendImage', 150 | command: 'SEND IMAGE', 151 | params: 1, 152 | }, 153 | { 154 | name: 'sendText', 155 | command: 'SEND TEXT', 156 | params: 1, 157 | paramRules: [ 158 | { 159 | prepare: function(value) { 160 | return '"' + value + '"'; 161 | }, 162 | }, 163 | ], 164 | }, 165 | { 166 | name: 'channelStatus', 167 | command: 'CHANNEL STATUS', 168 | params: 1, 169 | }, 170 | { 171 | name: 'answer', 172 | command: 'ANSWER', 173 | params: 0, 174 | }, 175 | { 176 | name: 'verbose', 177 | command: 'VERBOSE', 178 | params: 2, 179 | paramRules: [ 180 | { 181 | prepare: function(value) { 182 | return '"' + value + '"'; 183 | }, 184 | }, 185 | ], 186 | }, 187 | { 188 | name: 'tddMode', 189 | command: 'TDD MODE', 190 | params: 1, 191 | }, 192 | { 193 | name: 'noop', 194 | command: 'NOOP', 195 | params: 0, 196 | }, 197 | { 198 | name: 'gosub', 199 | command: 'GOSUB', 200 | params: 4, 201 | }, 202 | { 203 | name: 'recordFile', 204 | command: 'RECORD FILE', 205 | params: 7, 206 | paramRules: [ 207 | { 208 | default: '#', 209 | prepare: function(value) { 210 | return '"' + value + '"'; 211 | }, 212 | }, 213 | null, 214 | null, 215 | { 216 | prepare: function(value) { 217 | return value * 1000; 218 | }, 219 | }, 220 | ], 221 | }, 222 | { 223 | name: 'sayNumber', 224 | command: 'SAY NUMBER', 225 | params: 2, 226 | paramRules: [ 227 | null, 228 | { 229 | default: '#', 230 | prepare: function(value) { 231 | return '"' + value + '"'; 232 | }, 233 | }, 234 | ], 235 | }, 236 | { 237 | name: 'sayAlpha', 238 | command: 'SAY ALPHA', 239 | params: 2, 240 | paramRules: [ 241 | null, 242 | { 243 | default: '#', 244 | prepare: function(value) { 245 | return '"' + value + '"'; 246 | }, 247 | }, 248 | ], 249 | }, 250 | { 251 | name: 'sayDate', 252 | command: 'SAY DATE', 253 | params: 2, 254 | paramRules: [ 255 | null, 256 | { 257 | default: '#', 258 | prepare: function(value) { 259 | return '"' + value + '"'; 260 | }, 261 | }, 262 | ], 263 | }, 264 | { 265 | name: 'sayTime', 266 | command: 'SAY TIME', 267 | params: 2, 268 | paramRules: [ 269 | null, 270 | { 271 | default: '#', 272 | prepare: function(value) { 273 | return '"' + value + '"'; 274 | }, 275 | }, 276 | ], 277 | }, 278 | { 279 | name: 'sayDateTime', 280 | command: 'SAY DATETIME', 281 | params: 4, 282 | paramRules: [ 283 | null, 284 | { 285 | prepare: function(value) { 286 | return '"' + value + '"'; 287 | }, 288 | }, 289 | ], 290 | }, 291 | { 292 | name: 'sayDigits', 293 | command: 'SAY DIGITS', 294 | params: 2, 295 | paramRules: [ 296 | null, 297 | { 298 | prepare: function(value) { 299 | return '"' + value + '"'; 300 | }, 301 | }, 302 | ], 303 | }, 304 | { 305 | name: 'sayPhonetic', 306 | command: 'SAY PHONETIC', 307 | params: 2, 308 | paramRules: [ 309 | null, 310 | { 311 | prepare: function(value) { 312 | return '"' + value + '"'; 313 | }, 314 | }, 315 | ], 316 | }, 317 | { 318 | name: 'controlStreamFile', 319 | command: 'CONTROL STREAM FILE', 320 | params: 7, 321 | }, 322 | { 323 | name: 'streamFile', 324 | command: 'STREAM FILE', 325 | params: 2, 326 | paramRules: [ 327 | { 328 | prepare: function(value) { 329 | return '"' + value + '"'; 330 | }, 331 | }, 332 | { 333 | default: '#', 334 | prepare: function(value) { 335 | return '"' + value + '"'; 336 | }, 337 | }, 338 | ], 339 | }, 340 | { 341 | name: 'waitForDigit', 342 | command: 'WAIT FOR DIGIT', 343 | params: 1, 344 | }, 345 | { 346 | name: 'hangup', 347 | command: 'HANGUP', 348 | params: 0, 349 | }, 350 | { 351 | name: 'asyncAGIBreak', 352 | command: 'ASYNCAGI BREAK', 353 | params: 0, 354 | }, 355 | ]; 356 | -------------------------------------------------------------------------------- /lib/context.js: -------------------------------------------------------------------------------- 1 | const Readable = require('readable-stream'); 2 | const EventEmitter = require('events').EventEmitter; 3 | const state = require('./state'); 4 | const commands = require('./command'); 5 | 6 | // base context 7 | 8 | const Context = function(conn, loggerOptions = {}) { 9 | EventEmitter.call(this); 10 | 11 | const consoleDecorator = function(arrow, data) { 12 | return console.log(arrow, JSON.stringify(data)); 13 | }; 14 | this.log = (loggerOptions.logger) ? 15 | loggerOptions.logger : 16 | consoleDecorator; 17 | 18 | 19 | this.debug = loggerOptions.debug; 20 | this.conn = conn; 21 | this.stream = new Readable(); 22 | this.stream.setEncoding('utf8'); 23 | this.stream.wrap(this.conn); 24 | this.state = state.init; 25 | 26 | this.msg = ''; 27 | this.variables = {}; 28 | this.pending = null; 29 | 30 | const self = this; 31 | this.stream.on('readable', function() { 32 | // always keep the 'leftover' part of the message 33 | self.msg = self.read(); 34 | }); 35 | 36 | this.stream.on('error', this.emit.bind(this, 'error')); 37 | this.stream.on('close', this.emit.bind(this, 'close')); 38 | }; 39 | 40 | require('util').inherits(Context, EventEmitter); 41 | 42 | Context.prototype.read = function() { 43 | const buffer = this.stream.read(); 44 | if (!buffer) return this.msg; 45 | 46 | this.msg += buffer; 47 | 48 | if (this.state === state.init) { 49 | // we don't have whole message 50 | if (this.msg.indexOf('\n\n') < 0) return this.msg; 51 | this.readVariables(this.msg); 52 | } else if (this.state === state.waiting) { 53 | // we don't have whole message 54 | if (this.msg.indexOf('\n') < 0) return this.msg; 55 | this.readResponse(this.msg); 56 | } 57 | 58 | return ''; 59 | }; 60 | 61 | Context.prototype.readVariables = function(msg) { 62 | const lines = msg.split('\n'); 63 | 64 | lines.map(function(line) { 65 | const split = line.split(':'); 66 | const name = split[0]; 67 | const value = split[1]; 68 | this.variables[name] = (value || '').trim(); 69 | }, this); 70 | 71 | this.emit('variables', this.variables); 72 | this.setState(state.waiting); 73 | }; 74 | 75 | Context.prototype.readResponse = function(msg) { 76 | const lines = msg.split('\n'); 77 | 78 | lines.map(function(line) { 79 | this.readResponseLine(line); 80 | }, this); 81 | }; 82 | 83 | Context.prototype.readResponseLine = function(line) { 84 | if (!line) return; 85 | 86 | // var parsed = /^(\d{3})(?: result=)(.*)/.exec(line); 87 | const parsed = /^(\d{3})(?: result=)([^(]*)(?:\((.*)\))?/.exec(line); 88 | 89 | 90 | if (!parsed) { 91 | return this.emit('hangup'); 92 | } 93 | 94 | const response = { 95 | code: parseInt(parsed[1]), 96 | result: parsed[2].trim(), 97 | }; 98 | if (parsed[3]) { 99 | response.value = parsed[3]; 100 | } 101 | 102 | // our last command had a pending callback 103 | if (this.pending) { 104 | const pending = this.pending; 105 | this.pending = null; 106 | pending(null, response); 107 | } 108 | this.emit('response', response); 109 | }; 110 | 111 | Context.prototype.setState = function(state) { 112 | this.state = state; 113 | }; 114 | 115 | Context.prototype.send = function(msg, cb) { 116 | this.pending = cb; 117 | this.stream.write(msg); 118 | }; 119 | 120 | Context.prototype.close = function() { 121 | this.conn.destroy(); 122 | this.stream.end(); 123 | return Promise.resolve(); 124 | }; 125 | 126 | Context.prototype.sendCommand = function(command) { 127 | if (this.debug) this.log('------->', {command: command}); 128 | const self = this; 129 | return new Promise(function(resolve, reject) { 130 | self.send(command + '\n', function(err, result) { 131 | if (self.debug) self.log('<-------', {err: err, result: result}); 132 | if (err) { 133 | reject(err); 134 | } else { 135 | resolve(result); 136 | } 137 | }); 138 | }); 139 | }; 140 | 141 | Context.prototype.onEvent = function(event) { 142 | const self = this; 143 | return new Promise(function(resolve) { 144 | self.on(event, function(data) { 145 | resolve(data); 146 | }); 147 | }); 148 | }; 149 | 150 | // additional agi commands 151 | 152 | commands.forEach(function(command) { 153 | let str = ''; 154 | Context.prototype[command.name] = function(...args) { 155 | if (command.params > 0) { 156 | // const args = [].slice.call(arguments, 0, command.params); 157 | str = command.command + ' ' + 158 | prepareArgs(args, command.paramRules, command.params).join(' '); 159 | } else { 160 | str = command.command; 161 | } 162 | return this.sendCommand(str); 163 | }; 164 | }); 165 | 166 | const prepareArgs = function(args, argsRules, count) { 167 | if (!argsRules || !count) { 168 | return args; 169 | } 170 | 171 | return (new Array(count)).fill(null) 172 | .map(function(arg, i) { 173 | arg = args[i] !== undefined && args[i] !== null ? 174 | args[i] : 175 | argsRules[i] && argsRules[i].default || ''; 176 | const prepare = argsRules[i] && argsRules[i].prepare || 177 | function(x) { 178 | return x; 179 | }; 180 | 181 | return prepare(String(arg)); 182 | }); 183 | }; 184 | 185 | // sugar commands 186 | 187 | Context.prototype.dial = function(target, timeout, params) { 188 | return this.exec('Dial', target + ',' + timeout + ',' + params); 189 | }; 190 | 191 | module.exports = Context; 192 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const Context = require('./context'); 3 | 4 | /** 5 | * 6 | */ 7 | class AgiServer extends EventEmitter { 8 | /** 9 | * 10 | * @param {*} handler 11 | * @param {*} options 12 | */ 13 | constructor(handler, options) { 14 | super(); 15 | 16 | options = options || {}; 17 | 18 | this.options = { 19 | port: options.port || 3000, 20 | debug: options.debug || false, 21 | logger: options.logger || false, 22 | host: options.host, 23 | }; 24 | 25 | this.handler = handler; 26 | 27 | this.server = require('net').createServer((connection) => { 28 | const context = new Context(connection, { 29 | debug: this.options.debug, 30 | logger: this.options.logger, 31 | }); 32 | 33 | this.handler(context); 34 | }); 35 | } 36 | 37 | /** 38 | * 39 | */ 40 | init() { 41 | this.server.on('error', (err) => { 42 | this.emit('error', new Error('Internal TCP server error')); 43 | }); 44 | this.server.on('close', () => this.emit('close')); 45 | 46 | this.server.listen(this.options.port, this.options.host, () => { 47 | console.log('agi server on', this.options.port, 'listen'); 48 | }); 49 | } 50 | 51 | /** 52 | * @return {Promise} 53 | */ 54 | close() { 55 | return new Promise((resolve, reject) => { 56 | this.server.close((err) => { 57 | if (err) { 58 | return reject(err); 59 | } else { 60 | return resolve(); 61 | } 62 | }); 63 | }); 64 | } 65 | } 66 | 67 | module.exports = AgiServer; 68 | -------------------------------------------------------------------------------- /lib/state.js: -------------------------------------------------------------------------------- 1 | // states for context 2 | const state = { 3 | init: 0, 4 | waiting: 2, 5 | }; 6 | 7 | module.exports = state; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Sergey Dmitriev ", 3 | "name": "ding-dong", 4 | "description": "Write AGI-server quickly! (AGI - Asterisk Gateway Interface)", 5 | "version": "0.2.0", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git://github.com/antirek/ding-dong.git" 10 | }, 11 | "main": "lib/", 12 | "scripts": { 13 | "test": "mocha -R tap --exit", 14 | "lint-fix": "eslint --fix ." 15 | }, 16 | "dependencies": { 17 | "readable-stream": "^2.3.0" 18 | }, 19 | "devDependencies": { 20 | "eslint": "^5.16.0", 21 | "eslint-config-google": "^0.12.0", 22 | "expect.js": "^0.3.1", 23 | "memorystream": "^0.3.0", 24 | "mocha": "6.1.4" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const MemoryStream = require('memorystream'); 2 | const AgiServer = require('./../lib'); 3 | const expect = require('expect.js'); 4 | const Context = require('./../lib/context'); 5 | const state = require('./../lib/state'); 6 | 7 | // helpers 8 | const writeVars = function(stream) { 9 | stream.write('agi_network: yes\n'); 10 | stream.write('agi_uniqueid: 13507138.14\n'); 11 | stream.write('agi_arg_1: test\n'); 12 | stream.write('\n\n'); 13 | }; 14 | 15 | const context = function(cb) { 16 | const stream = new MemoryStream(); 17 | const ctx = new Context(stream); 18 | // TODO nasty 19 | ctx.send = function(msg, cb) { 20 | ctx.pending = cb; 21 | ctx.sent = ctx.sent || []; 22 | ctx.sent.push(msg); 23 | }; 24 | 25 | ctx.once('variables', function(vars) { 26 | cb(ctx); 27 | }); 28 | 29 | writeVars(stream); 30 | }; 31 | 32 | describe('Context', function() { 33 | beforeEach(function(done) { 34 | const self = this; 35 | context(function(context) { 36 | self.context = context; 37 | done(); 38 | }); 39 | }); 40 | 41 | describe('parsing variables', function() { 42 | it('works', function(done) { 43 | const vars = this.context.variables; 44 | expect(vars['agi_network']).ok(); 45 | expect(vars['agi_network']).to.eql('yes'); 46 | expect(vars['agi_uniqueid']).to.eql('13507138.14'); 47 | expect(vars['agi_arg_1']).to.eql('test'); 48 | done(); 49 | }); 50 | 51 | it('puts context into waiting state', function() { 52 | expect(this.context.state).to.eql(state.waiting); 53 | }); 54 | }); 55 | 56 | describe('sending command', function() { 57 | it('writes out', function() { 58 | this.context.send('EXEC test'); 59 | expect(this.context.sent.length).to.eql(1); 60 | expect(this.context.sent.join('')).to.eql('EXEC test'); 61 | }); 62 | }); 63 | 64 | describe('context.exec', function() { 65 | it('sends exec command', function() { 66 | this.context.exec('test', 'bang', 'another'); 67 | expect(this.context.sent.join('')).to.eql('EXEC test bang another\n'); 68 | }); 69 | }); 70 | 71 | describe('command flow', function() { 72 | describe('success', function() { 73 | it('emits proper repsonse', function(done) { 74 | const context = this.context; 75 | 76 | process.nextTick(function() { 77 | context.exec('test', 'bang', 'another'); 78 | context.stream.write('200'); 79 | context.stream.write(' result=0\n\n'); 80 | }); 81 | 82 | context.on('response', function(msg) { 83 | expect(msg.code).to.equal(200); 84 | expect(msg.result).to.eql('0'); 85 | done(); 86 | }); 87 | }); 88 | 89 | it('invokes callback with response', function(done) { 90 | const context = this.context; 91 | 92 | process.nextTick(function() { 93 | context.stream.write('200 result=0'); 94 | context.stream.write('\n'); 95 | context.stream.write('200 result=0'); 96 | context.stream.write('\n'); 97 | }); 98 | 99 | context.exec('test', 'boom').then(function() { 100 | done(); 101 | }); 102 | }); 103 | 104 | it('includes the response value', function(done) { 105 | const context = this.context; 106 | 107 | process.nextTick(function() { 108 | context.exec('test', 'bang', 'another'); 109 | context.stream.write('200'); 110 | context.stream.write(' result=0 (a value)\n\n'); 111 | }); 112 | 113 | context.on('response', function(msg) { 114 | expect(msg.code).to.equal(200); 115 | expect(msg.result).to.eql('0'); 116 | expect(msg.value).to.eql('a value'); 117 | done(); 118 | }); 119 | }); 120 | }); 121 | 122 | describe('two commands', function(done) { 123 | it('invokes two callbacks', function(done) { 124 | const context = this.context; 125 | 126 | process.nextTick(function() { 127 | context.stream.write('200 result=0\n'); 128 | }); 129 | 130 | context.exec('test') 131 | .then(function(res) { 132 | expect(res.result).to.eql('0'); 133 | process.nextTick(function() { 134 | context.stream.write('200 result=1\n'); 135 | }); 136 | return context.exec('test 2'); 137 | }) 138 | .then(function(res) { 139 | expect(res.result).to.eql('1'); 140 | done(); 141 | }); 142 | }); 143 | }); 144 | }); 145 | 146 | describe('hangup', function() { 147 | it('raises hangup on context', function(done) { 148 | this.context.on('hangup', done); 149 | this.context.stream.write('HANGUP\n'); 150 | }); 151 | 152 | describe('in command response', function() { 153 | it('is passed to callback', function(done) { 154 | const context = this.context; 155 | this.context.exec('whatever', function(err, res) { 156 | }); 157 | this.context.on('hangup', done); 158 | process.nextTick(function() { 159 | context.stream.write('200 result=-1\nHANGUP\n'); 160 | }); 161 | }); 162 | }); 163 | }); 164 | 165 | describe('databaseDel', function() { 166 | it('sends correct command', function() { 167 | this.context.databaseDel('family', 'test'); 168 | expect(this.context.sent.join('')) 169 | .to.eql('DATABASE DEL family test\n'); 170 | }); 171 | }); 172 | 173 | describe('databaseDelTree', function() { 174 | it('sends correct command', function() { 175 | this.context.databaseDelTree('family', 'test'); 176 | expect(this.context.sent.join('')) 177 | .to.eql('DATABASE DELTREE family test\n'); 178 | }); 179 | }); 180 | 181 | describe('databaseGet', function() { 182 | it('sends correct command', function() { 183 | this.context.databaseGet('family', 'test'); 184 | expect(this.context.sent.join('')) 185 | .to.eql('DATABASE GET family test\n'); 186 | }); 187 | }); 188 | 189 | describe('databasePut', function() { 190 | it('sends correct command', function() { 191 | this.context.databasePut('family', 'test', 'value'); 192 | expect(this.context.sent.join('') 193 | ).to.eql('DATABASE PUT family test value\n'); 194 | }); 195 | }); 196 | 197 | describe('speechCreate', function() { 198 | it('sends correct command', function() { 199 | this.context.speechCreate('engine'); 200 | expect(this.context.sent.join('')).to.eql('SPEECH CREATE engine\n'); 201 | }); 202 | }); 203 | 204 | describe('speechDestroy', function() { 205 | it('sends correct command', function() { 206 | this.context.speechDestroy(); 207 | expect(this.context.sent.join('')).to.eql('SPEECH DESTROY\n'); 208 | }); 209 | }); 210 | 211 | describe('speechActivateGrammar', function() { 212 | it('sends correct command', function() { 213 | this.context.speechActivateGrammar('name'); 214 | expect(this.context.sent.join('')) 215 | .to.eql('SPEECH ACTIVATE GRAMMAR name\n'); 216 | }); 217 | }); 218 | 219 | describe('speechDeactivateGrammar', function() { 220 | it('sends correct command', function() { 221 | this.context.speechDeactivateGrammar('name'); 222 | expect(this.context.sent.join('')) 223 | .to.eql('SPEECH DEACTIVATE GRAMMAR name\n'); 224 | }); 225 | }); 226 | 227 | describe('speechLoadGrammar', function() { 228 | it('sends correct command', function() { 229 | this.context.speechLoadGrammar('name', 'path'); 230 | expect(this.context.sent.join('')) 231 | .to.eql('SPEECH LOAD GRAMMAR name path\n'); 232 | }); 233 | }); 234 | 235 | describe('speechUnloadGrammar', function() { 236 | it('sends correct command', function() { 237 | this.context.speechUnloadGrammar('name'); 238 | expect(this.context.sent.join('')).to.eql('SPEECH UNLOAD GRAMMAR name\n'); 239 | }); 240 | }); 241 | 242 | describe('speechSet', function() { 243 | it('sends correct command', function() { 244 | this.context.speechSet('name', 'value'); 245 | expect(this.context.sent.join('')).to.eql('SPEECH SET name value\n'); 246 | }); 247 | }); 248 | 249 | describe('speechRecognize', function() { 250 | it('sends correct command', function() { 251 | this.context.speechRecognize('prompt', 'timeout', 'offset'); 252 | expect(this.context.sent.join('')) 253 | .to.eql('SPEECH RECOGNIZE prompt timeout offset\n'); 254 | }); 255 | }); 256 | 257 | describe('setVariable', function() { 258 | it('sends correct command', function() { 259 | this.context.setVariable('test', 'test test test'); 260 | expect(this.context.sent.join('')) 261 | .to.eql('SET VARIABLE test "test test test"\n'); 262 | }); 263 | }); 264 | 265 | describe('setAutoHangup', function() { 266 | it('sends correct command', function() { 267 | this.context.setAutoHangup(10); 268 | expect(this.context.sent.join('')).to.eql('SET AUTOHANGUP 10\n'); 269 | }); 270 | }); 271 | 272 | describe('setCallerID', function() { 273 | it('sends correct command', function() { 274 | this.context.setCallerID('246'); 275 | expect(this.context.sent.join('')).to.eql('SET CALLERID 246\n'); 276 | }); 277 | }); 278 | 279 | describe('setContext', function() { 280 | it('sends correct command', function() { 281 | this.context.setContext('outbound'); 282 | expect(this.context.sent.join('')).to.eql('SET CONTEXT outbound\n'); 283 | }); 284 | }); 285 | 286 | describe('setExtension', function() { 287 | it('sends correct command', function() { 288 | this.context.setExtension('245'); 289 | expect(this.context.sent.join('')).to.eql('SET EXTENSION 245\n'); 290 | }); 291 | }); 292 | 293 | describe('setPriority', function() { 294 | it('sends correct command', function() { 295 | this.context.setPriority('2'); 296 | expect(this.context.sent.join('')).to.eql('SET PRIORITY 2\n'); 297 | }); 298 | }); 299 | 300 | describe('setMusic', function() { 301 | it('sends correct command', function() { 302 | this.context.setMusic('default'); 303 | expect(this.context.sent.join('')).to.eql('SET MUSIC default\n'); 304 | }); 305 | }); 306 | 307 | describe('channelStatus', function() { 308 | it('sends correct command', function() { 309 | this.context.channelStatus('test'); 310 | expect(this.context.sent.join('')).to.eql('CHANNEL STATUS test\n'); 311 | }); 312 | }); 313 | 314 | describe('getFullVariable', function() { 315 | it('sends correct command', function() { 316 | this.context.getFullVariable('test', 'test'); 317 | expect(this.context.sent.join('')) 318 | .to.eql('GET FULL VARIABLE test test\n'); 319 | }); 320 | }); 321 | 322 | describe('getData', function() { 323 | it('sends correct command', function() { 324 | this.context.getData('test', 10, 5); 325 | expect(this.context.sent.join('')).to.eql('GET DATA test 10 5\n'); 326 | }); 327 | }); 328 | 329 | describe('getOption', function() { 330 | it('sends correct command', function() { 331 | this.context.getOption('test', '#', 5); 332 | expect(this.context.sent.join('')).to.eql('GET OPTION test "#" 5\n'); 333 | }); 334 | }); 335 | 336 | describe('getVariable', function() { 337 | it('sends correct command', function() { 338 | this.context.getVariable('test'); 339 | expect(this.context.sent.join('')).to.eql('GET VARIABLE test\n'); 340 | }); 341 | }); 342 | 343 | describe('receiveChar', function() { 344 | it('sends correct command', function() { 345 | this.context.receiveChar(5); 346 | expect(this.context.sent.join('')).to.eql('RECEIVE CHAR 5\n'); 347 | }); 348 | }); 349 | 350 | describe('receiveText', function() { 351 | it('sends correct command', function() { 352 | this.context.receiveText(5); 353 | expect(this.context.sent.join('')).to.eql('RECEIVE TEXT 5\n'); 354 | }); 355 | }); 356 | 357 | describe('stream file', function() { 358 | it('sends', function() { 359 | this.context.streamFile('test', '1234567890#*', function() {}); 360 | expect(this.context.sent.join('')) 361 | .to.eql('STREAM FILE "test" "1234567890#*"\n'); 362 | }); 363 | }); 364 | 365 | describe('record file', function() { 366 | it('record', function() { 367 | this.context.recordFile('test', 'wav', '#', 10, 0, 1, 2, function() {}); 368 | expect(this.context.sent.join('')) 369 | .to.eql('RECORD FILE "test" wav # 10000 0 1 2\n'); 370 | }); 371 | }); 372 | 373 | describe('say number', function() { 374 | it('say number', function() { 375 | this.context.sayNumber('1234', '#', function() {}); 376 | expect(this.context.sent.join('')).to.eql('SAY NUMBER 1234 "#"\n'); 377 | }); 378 | }); 379 | 380 | describe('say alpha', function() { 381 | it('say alpha', function() { 382 | this.context.sayAlpha('1234', '#', function() {}); 383 | expect(this.context.sent.join('')).to.eql('SAY ALPHA 1234 "#"\n'); 384 | }); 385 | }); 386 | 387 | describe('say date', function() { 388 | it('say date', function() { 389 | this.context.sayDate('1234', '#', function() {}); 390 | expect(this.context.sent.join('')).to.eql('SAY DATE 1234 "#"\n'); 391 | }); 392 | }); 393 | 394 | describe('say time', function() { 395 | it('say time', function() { 396 | this.context.sayTime('1234', '#', function() {}); 397 | expect(this.context.sent.join('')).to.eql('SAY TIME 1234 "#"\n'); 398 | }); 399 | }); 400 | 401 | describe('say datetime', function() { 402 | it('say datetime', function() { 403 | this.context.sayDateTime('1234', '#', 'Y', 'DST', function() {}); 404 | expect(this.context.sent.join('')) 405 | .to.eql('SAY DATETIME 1234 "#" Y DST\n'); 406 | }); 407 | }); 408 | 409 | describe('say phonetic', function() { 410 | it('say phonetic', function() { 411 | this.context.sayPhonetic('1234ABCD', '#', function() {}); 412 | expect(this.context.sent.join('')).to.eql('SAY PHONETIC 1234ABCD "#"\n'); 413 | }); 414 | }); 415 | 416 | describe('context dial', function() { 417 | it('context dial', function() { 418 | this.context.dial('123', 10, 'A', function() {}); 419 | expect(this.context.sent.join('')).to.eql('EXEC Dial 123,10,A\n'); 420 | }); 421 | }); 422 | 423 | describe('say digits', function() { 424 | it('say digits', function() { 425 | this.context.sayDigits('1234', '#'); 426 | expect(this.context.sent.join('')).to.eql('SAY DIGITS 1234 "#"\n'); 427 | }); 428 | }); 429 | 430 | describe('send image', function() { 431 | it('send image', function() { 432 | this.context.sendImage('1234'); 433 | expect(this.context.sent.join('')).to.eql('SEND IMAGE 1234\n'); 434 | }); 435 | }); 436 | 437 | describe('send text', function() { 438 | it('send text', function() { 439 | this.context.sendText('1234'); 440 | expect(this.context.sent.join('')).to.eql('SEND TEXT "1234"\n'); 441 | }); 442 | }); 443 | 444 | describe('waitForDigit', function() { 445 | it('sends with default timeout', function() { 446 | this.context.waitForDigit(5000); 447 | expect(this.context.sent.join('')).to.eql('WAIT FOR DIGIT 5000\n'); 448 | }); 449 | 450 | it('sends with specified timeout', function() { 451 | this.context.waitForDigit(-1); 452 | expect(this.context.sent.join('')).to.eql('WAIT FOR DIGIT -1\n'); 453 | }); 454 | }); 455 | 456 | describe('hangup', function() { 457 | it('sends "HANGUP\\n"', function() { 458 | this.context.hangup(); 459 | expect(this.context.sent.join('')).to.eql('HANGUP\n'); 460 | }); 461 | }); 462 | 463 | describe('asyncAGIBreak', function() { 464 | it('sends "ASYNCAGI BREAK\\n"', function() { 465 | this.context.asyncAGIBreak(); 466 | expect(this.context.sent.join('')).to.eql('ASYNCAGI BREAK\n'); 467 | }); 468 | }); 469 | 470 | describe('answer', function() { 471 | it('sends "ANSWER\\n"', function() { 472 | this.context.answer(); 473 | expect(this.context.sent.join('')).to.eql('ANSWER\n'); 474 | }); 475 | }); 476 | 477 | describe('verbose', function() { 478 | it('sends correct command', function() { 479 | this.context.verbose('good', 2); 480 | expect(this.context.sent.join('')).to.eql('VERBOSE "good" 2\n'); 481 | }); 482 | }); 483 | 484 | describe('tddMode', function() { 485 | it('sends correct command', function() { 486 | this.context.tddMode('on'); 487 | expect(this.context.sent.join('')).to.eql('TDD MODE on\n'); 488 | }); 489 | }); 490 | 491 | describe('noop', function() { 492 | it('sends correct command', function() { 493 | this.context.noop(); 494 | expect(this.context.sent.join('')).to.eql('NOOP\n'); 495 | }); 496 | }); 497 | 498 | describe('gosub', function() { 499 | it('sends correct command', function() { 500 | this.context.gosub('out', '241', '6', 'do'); 501 | expect(this.context.sent.join('')).to.eql('GOSUB out 241 6 do\n'); 502 | }); 503 | }); 504 | 505 | describe('events', function() { 506 | describe('error', function() { 507 | it('is emitted when socket emits error', function(done) { 508 | this.context.on('error', function(err) { 509 | expect(err).to.eql('test'); 510 | done(); 511 | }); 512 | this.context.stream.emit('error', 'test'); 513 | }); 514 | }); 515 | 516 | describe('close', function() { 517 | it('is emitted when socket emits close', function(done) { 518 | this.context.on('close', function(hasError) { 519 | expect(hasError).ok(); 520 | done(); 521 | }); 522 | 523 | this.context.stream.emit('close', true); 524 | }); 525 | }); 526 | }); 527 | }); 528 | 529 | describe('AgiServer#createServer', function() { 530 | it('returns instance of net.Server', function() { 531 | const net = require('net'); 532 | const agiServer = new AgiServer(() => {}); 533 | expect(agiServer.server instanceof net.Server).ok(); 534 | }); 535 | 536 | it('invokes callback when a new connection is established', function(done) { 537 | const agiServer = new AgiServer(function(context) { 538 | expect(context instanceof Context); 539 | done(); 540 | }, { 541 | port: 3001, 542 | }); 543 | agiServer.init(); 544 | 545 | agiServer.server.emit('connection', new MemoryStream()); 546 | }); 547 | }); 548 | --------------------------------------------------------------------------------