initial checkin

This commit is contained in:
Dave Horton
2021-02-17 20:06:23 -05:00
commit 87b8e5ac92
29 changed files with 3321 additions and 0 deletions

1
.eslintignore Normal file
View File

@@ -0,0 +1 @@
test/*

126
.eslintrc.json Normal file
View File

@@ -0,0 +1,126 @@
{
"env": {
"node": true,
"es6": true
},
"parserOptions": {
"ecmaFeatures": {
"jsx": false,
"modules": false
},
"ecmaVersion": 2018
},
"plugins": ["promise"],
"rules": {
"promise/always-return": "error",
"promise/no-return-wrap": "error",
"promise/param-names": "error",
"promise/catch-or-return": "error",
"promise/no-native": "off",
"promise/no-nesting": "warn",
"promise/no-promise-in-callback": "warn",
"promise/no-callback-in-promise": "warn",
"promise/no-return-in-finally": "warn",
// Possible Errors
// http://eslint.org/docs/rules/#possible-errors
"comma-dangle": [2, "only-multiline"],
"no-control-regex": 2,
"no-debugger": 2,
"no-dupe-args": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-empty-character-class": 2,
"no-ex-assign": 2,
"no-extra-boolean-cast" : 2,
"no-extra-parens": [2, "functions"],
"no-extra-semi": 2,
"no-func-assign": 2,
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-negated-in-lhs": 2,
"no-obj-calls": 2,
"no-proto": 2,
"no-unexpected-multiline": 2,
"no-unreachable": 2,
"use-isnan": 2,
"valid-typeof": 2,
// Best Practices
// http://eslint.org/docs/rules/#best-practices
"no-fallthrough": 2,
"no-octal": 2,
"no-redeclare": 2,
"no-self-assign": 2,
"no-unused-labels": 2,
// Strict Mode
// http://eslint.org/docs/rules/#strict-mode
"strict": [2, "never"],
// Variables
// http://eslint.org/docs/rules/#variables
"no-delete-var": 2,
"no-undef": 2,
"no-unused-vars": [2, {"args": "none"}],
// Node.js and CommonJS
// http://eslint.org/docs/rules/#nodejs-and-commonjs
"no-mixed-requires": 2,
"no-new-require": 2,
"no-path-concat": 2,
"no-restricted-modules": [2, "sys", "_linklist"],
// Stylistic Issues
// http://eslint.org/docs/rules/#stylistic-issues
"comma-spacing": 2,
"eol-last": 2,
"indent": [2, 2, {"SwitchCase": 1}],
"keyword-spacing": 2,
"max-len": [2, 120, 2],
"new-parens": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multiple-empty-lines": [2, {"max": 2}],
"no-trailing-spaces": [2, {"skipBlankLines": false }],
"quotes": [2, "single", "avoid-escape"],
"semi": 2,
"space-before-blocks": [2, "always"],
"space-before-function-paren": [2, "never"],
"space-in-parens": [2, "never"],
"space-infix-ops": 2,
"space-unary-ops": 2,
// ECMAScript 6
// http://eslint.org/docs/rules/#ecmascript-6
"arrow-parens": [2, "always"],
"arrow-spacing": [2, {"before": true, "after": true}],
"constructor-super": 2,
"no-class-assign": 2,
"no-confusing-arrow": 2,
"no-const-assign": 2,
"no-dupe-class-members": 2,
"no-new-symbol": 2,
"no-this-before-super": 2,
"prefer-const": 2
},
"globals": {
"DTRACE_HTTP_CLIENT_REQUEST" : false,
"LTTNG_HTTP_CLIENT_REQUEST" : false,
"COUNTER_HTTP_CLIENT_REQUEST" : false,
"DTRACE_HTTP_CLIENT_RESPONSE" : false,
"LTTNG_HTTP_CLIENT_RESPONSE" : false,
"COUNTER_HTTP_CLIENT_RESPONSE" : false,
"DTRACE_HTTP_SERVER_REQUEST" : false,
"LTTNG_HTTP_SERVER_REQUEST" : false,
"COUNTER_HTTP_SERVER_REQUEST" : false,
"DTRACE_HTTP_SERVER_RESPONSE" : false,
"LTTNG_HTTP_SERVER_RESPONSE" : false,
"COUNTER_HTTP_SERVER_RESPONSE" : false,
"DTRACE_NET_STREAM_END" : false,
"LTTNG_NET_STREAM_END" : false,
"COUNTER_NET_SERVER_CONNECTION_CLOSE" : false,
"DTRACE_NET_SERVER_CONNECTION" : false,
"LTTNG_NET_SERVER_CONNECTION" : false,
"COUNTER_NET_SERVER_CONNECTION" : false
}
}

16
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: CI
on:
push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12
- run: npm ci
- run: npm run jslint
- run: npm test

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# github pages site
_site
#transient test cases
examples/nosave.*.js
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
.nyc_output/
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules
.DS_Store
examples/*

10
lib/index.js Normal file
View File

@@ -0,0 +1,10 @@
const Jambonz = require('./rest/jambonz');
const initializer = (accountSid, apiKey, opts) => {
return new Jambonz(accountSid, apiKey, opts);
};
initializer.Jambonz = Jambonz;
initializer.WebhookResponse = require('./jambonz/webhook-response');
module.exports = initializer;

387
lib/jambonz/specs.json Normal file
View File

@@ -0,0 +1,387 @@
{
"sip_decline": {
"properties": {
"status": "number",
"reason": "string",
"headers": "object"
},
"required": [
"status"
]
},
"dequeue": {
"properties": {
"name": "string",
"actionHook": "object|string",
"timeout": "number",
"beep": "boolean"
},
"required": [
"name"
]
},
"enqueue": {
"properties": {
"name": "string",
"actionHook": "object|string",
"waitHook": "object|string",
"_": "object"
},
"required": [
"name"
]
},
"leave": {
"properties": {
}
},
"hangup": {
"properties": {
"headers": "object"
},
"required": [
]
},
"play": {
"properties": {
"url": "string",
"loop": "number",
"earlyMedia": "boolean"
},
"required": [
"url"
]
},
"say": {
"properties": {
"text": "string|array",
"loop": "number",
"synthesizer": "#synthesizer",
"earlyMedia": "boolean"
},
"required": [
"text"
]
},
"gather": {
"properties": {
"actionHook": "object|string",
"finishOnKey": "string",
"input": "array",
"numDigits": "number",
"partialResultHook": "object|string",
"speechTimeout": "number",
"timeout": "number",
"recognizer": "#recognizer",
"play": "#play",
"say": "#say"
},
"required": [
"actionHook"
]
},
"conference": {
"properties": {
"name": "string",
"beep": "boolean",
"startConferenceOnEnter": "boolean",
"endConferenceOnExit": "boolean",
"maxParticipants": "number",
"actionHook": "object|string",
"waitHook": "object|string",
"statusEvents": "array",
"statusHook": "object|string",
"enterHook": "object|string"
},
"required": [
"name"
]
},
"dial": {
"properties": {
"actionHook": "object|string",
"answerOnBridge": "boolean",
"callerId": "string",
"confirmHook": "object|string",
"dialMusic": "string",
"dtmfCapture": "object",
"dtmfHook": "object|string",
"headers": "object",
"listen": "#listen",
"target": ["#target"],
"timeLimit": "number",
"timeout": "number",
"proxy": "string",
"transcribe": "#transcribe"
},
"required": [
"target"
]
},
"dialogflow": {
"properties": {
"credentials": "object|string",
"project": "string",
"environment": "string",
"lang": "string",
"actionHook": "object|string",
"eventHook": "object|string",
"events": "[string]",
"welcomeEvent": "string",
"welcomeEventParams": "object",
"noInputTimeout": "number",
"noInputEvent": "string",
"passDtmfAsTextInput": "boolean",
"thinkingMusic": "string",
"tts": "#synthesizer",
"bargein": "boolean"
},
"required": [
"project",
"credentials",
"lang"
]
},
"dtmf": {
"properties": {
"dtmf": "string",
"duration": "number"
},
"required": [
"dtmf"
]
},
"lex": {
"properties": {
"botId": "string",
"botAlias": "string",
"credentials": "object",
"region": "string",
"locale": "string",
"intent": "#lexIntent",
"welcomeMessage": "string",
"metadata": "object",
"bargein": "boolean",
"passDtmf": "boolean",
"actionHook": "object|string",
"eventHook": "object|string",
"prompt": {
"type": "string",
"enum": ["lex", "tts"]
},
"noInputTimeout": "number",
"tts": "#synthesizer"
},
"required": [
"botId",
"botAlias",
"region",
"prompt"
]
},
"listen": {
"properties": {
"actionHook": "object|string",
"auth": "#auth",
"finishOnKey": "string",
"maxLength": "number",
"metadata": "object",
"mixType": {
"type": "string",
"enum": ["mono", "stereo", "mixed"]
},
"passDtmf": "boolean",
"playBeep": "boolean",
"sampleRate": "number",
"timeout": "number",
"transcribe": "#transcribe",
"url": "string",
"wsAuth": "#auth",
"earlyMedia": "boolean"
},
"required": [
"url"
]
},
"message": {
"properties": {
"provider": "string",
"to": "string",
"from": "string",
"text": "string",
"media": "string|array",
"actionHook": "object|string"
},
"required": [
"to",
"from"
]
},
"pause": {
"properties": {
"length": "number"
},
"required": [
"length"
]
},
"redirect": {
"properties": {
"actionHook": "object|string"
},
"required": [
"actionHook"
]
},
"rest_dial": {
"properties": {
"account_sid": "string",
"application_sid": "string",
"call_hook": "object|string",
"call_status_hook": "object|string",
"from": "string",
"speech_synthesis_vendor": "string",
"speech_synthesis_voice": "string",
"speech_synthesis_language": "string",
"speech_recognizer_vendor": "string",
"speech_recognizer_language": "string",
"tag": "object",
"to": "#target",
"headers": "object",
"timeout": "number"
},
"required": [
"call_hook",
"from",
"to"
]
},
"tag": {
"properties": {
"data": "object"
},
"required": [
"data"
]
},
"transcribe": {
"properties": {
"transcriptionHook": "string",
"recognizer": "#recognizer",
"earlyMedia": "boolean"
},
"required": [
"transcriptionHook",
"recognizer"
]
},
"target": {
"properties": {
"type": {
"type": "string",
"enum": ["phone", "sip", "user", "teams"]
},
"confirmHook": "object|string",
"method": {
"type": "string",
"enum": ["GET", "POST"]
},
"name": "string",
"number": "string",
"sipUri": "string",
"auth": "#auth",
"vmail": "boolean",
"tenant": "string"
},
"required": [
"type"
]
},
"auth": {
"properties": {
"username": "string",
"password": "string"
},
"required": [
"username",
"password"
]
},
"synthesizer": {
"properties": {
"vendor": {
"type": "string",
"enum": ["google", "aws", "polly", "default"]
},
"language": "string",
"voice": "string",
"gender": {
"type": "string",
"enum": ["MALE", "FEMALE", "NEUTRAL"]
}
},
"required": [
"vendor"
]
},
"recognizer": {
"properties": {
"vendor": {
"type": "string",
"enum": ["google", "aws", "default"]
},
"language": "string",
"hints": "array",
"altLanguages": "array",
"profanityFilter": "boolean",
"interim": "boolean",
"singleUtterance": "boolean",
"dualChannel": "boolean",
"separateRecognitionPerChannel": "boolean",
"punctuation": "boolean",
"enhancedModel": "boolean",
"words": "boolean",
"diarization": "boolean",
"diarizationMinSpeakers": "number",
"diarizationMaxSpeakers": "number",
"interactionType": {
"type": "string",
"enum": [
"unspecified",
"discussion",
"presentation",
"phone_call",
"voicemail",
"voice_search",
"voice_command",
"dictation"
]
},
"naicsCode": "number",
"identifyChannels": "boolean",
"vocabularyName": "string",
"vocabularyFilterName": "string",
"filterMethod": {
"type": "string",
"enum": [
"remove",
"mask",
"tag"
]
}
},
"required": [
"vendor"
]
},
"lexIntent": {
"properties": {
"name": "string",
"slots": "object"
},
"required": [
"name"
]
}
}

76
lib/jambonz/utils.js Normal file
View File

@@ -0,0 +1,76 @@
const debug = require('debug')('jambonz:jambonz-node');
const assert = require('assert');
const specs = new Map(Object.entries(require('./specs.json')));
/**
* copied from jambonz-feature-server/lib/tasks.js
*/
function validate(name, data) {
debug(`validating ${name} with data ${JSON.stringify(data)}`);
// validate the instruction is supported
if (!specs.has(name)) throw new Error(`invalid instruction: ${name}`);
// check type of each element and make sure required elements are present
const specData = specs.get(name);
let required = specData.required || [];
for (const dKey in data) {
if (dKey in specData.properties) {
const dVal = data[dKey];
const dSpec = specData.properties[dKey];
debug(`Task:validate validating property ${dKey} with value ${JSON.stringify(dVal)}`);
if (typeof dSpec === 'string' && dSpec === 'array') {
if (!Array.isArray(dVal)) throw new Error(`${name}: property ${dKey} is not an array`);
}
else if (typeof dSpec === 'string' && dSpec.includes('|')) {
const types = dSpec.split('|').map((t) => t.trim());
if (!types.includes(typeof dVal) && !(types.includes('array') && Array.isArray(dVal))) {
throw new Error(`${name}: property ${dKey} has invalid data type, must be one of ${types}`);
}
}
else if (typeof dSpec === 'string' && ['number', 'string', 'object', 'boolean'].includes(dSpec)) {
// simple types
if (typeof dVal !== specData.properties[dKey]) {
throw new Error(`${name}: property ${dKey} has invalid data type`);
}
}
else if (Array.isArray(dSpec) && dSpec[0].startsWith('#')) {
const name = dSpec[0].slice(1);
for (const item of dVal) {
validate(name, item);
}
}
else if (typeof dSpec === 'object') {
// complex types
const type = dSpec.type;
assert.ok(['number', 'string', 'object', 'boolean'].includes(type),
`invalid or missing type in spec ${JSON.stringify(dSpec)}`);
if (type === 'string' && dSpec.enum) {
assert.ok(Array.isArray(dSpec.enum), `enum must be an array ${JSON.stringify(dSpec.enum)}`);
if (!dSpec.enum.includes(dVal)) throw new Error(`invalid value ${dVal} must be one of ${dSpec.enum}`);
}
}
else if (typeof dSpec === 'string' && dSpec.startsWith('#')) {
// reference to another datatype (i.e. nested type)
const name = dSpec.slice(1);
//const obj = {};
//obj[name] = dVal;
validate(name, dVal);
}
else {
assert.ok(0, `invalid spec ${JSON.stringify(dSpec)}`);
}
required = required.filter((item) => item !== dKey);
}
else throw new Error(`${name}: unknown property ${dKey}`);
}
if (required.length > 0) throw new Error(`${name}: missing value for ${required}`);
}
/**
* end of copy
*/
module.exports = {
validate,
specs
};

View File

@@ -0,0 +1,33 @@
const {validate, specs} = require('./utils');
class WebhookResponse {
constructor() {
this.payload = [];
}
get length() {
return this.payload.length;
}
set length(len) {
this.payload.length = len;
}
toJSON() {
return this.payload;
}
addVerb(verb, payload) {
validate(verb, payload);
this.payload.push({verb, ...payload});
}
}
for (const [verb] of specs) {
WebhookResponse.prototype[verb] = function(payload) {
return WebhookResponse.prototype.addVerb.call(this, verb, payload);
};
}
module.exports = WebhookResponse;

14
lib/rest/jambonz.js Normal file
View File

@@ -0,0 +1,14 @@
const assert = require('assert');
class Jambonz {
constructor(accountSid, apiKey, opts) {
assert.ok(typeof accountSid === 'string', 'accountSid required');
assert.ok(typeof apiKey === 'string', 'apiKey required');
opts = opts || {};
this.endpoint = opts.endpoint;
// TODO: test credentials, throw exception on failure
}
}
module.exports = Jambonz;

2342
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@jambonz/node-client",
"version": "0.0.1",
"description": "",
"main": "lib/index.js",
"scripts": {
"test": "NODE_ENV=test node test/ | ./node_modules/.bin/tap-spec",
"coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test",
"jslint": "eslint app.js lib"
},
"author": "Dave Horton",
"repository": {
"type": "git",
"url": "git@github.com:jambonz/jambonz-node.git"
},
"license": "MIT",
"dependencies": {
"bent": "^7.3.12",
"eslint": "^7.20.0",
"eslint-plugin-promise": "^4.3.1",
"nyc": "^15.1.0",
"pino": "^6.11.1"
},
"devDependencies": {
"blue-tape": "^1.0.0",
"clear-module": "^4.1.1",
"tap-spec": "^5.0.0"
}
}

View File

@@ -0,0 +1,16 @@
{
"dial": {
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
"type": "foo",
"number": "sip:1617333456@sip.trunk1.com",
"auth": {
"user": "foo",
"password": "bar"
}
}
]
}
}

View File

@@ -0,0 +1,4 @@
{
"key1": "value",
"key2": "value"
}

View File

@@ -0,0 +1 @@
[1, 2]

View File

@@ -0,0 +1,5 @@
{
"sip:decline": {
"status": "hello"
}
}

View File

@@ -0,0 +1,15 @@
{
"dial": {
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
"type": "sip",
"number": "sip:1617333456@sip.trunk1.com",
"auth": {
"password": "bar"
}
}
]
}
}

View File

@@ -0,0 +1,3 @@
{
"foo": "bar"
}

View File

@@ -0,0 +1,6 @@
{
"sip:decline": {
"status": 480,
"foo": "bar"
}
}

View File

@@ -0,0 +1,31 @@
[
{
"verb": "gather",
"actionHook": "https://00dd977a.ngrok.io/gather",
"input": ["speech"],
"timeout": 12,
"recognizer": {
"vendor": "google",
"language": "en-US",
"hints": ["sales", "support", "engineering", "human resources", "HR", "operator", "agent"]
},
"say": {
"text": "Please say the name of the department that you would like to speak with. To speak to an operator, just say operator.",
"synthesizer": {
"vendor": "google",
"language": "en-US"
}
}
},
{
"verb": "say",
"text": "I'm sorry, I did not hear a response. Goodbye.",
"synthesizer": {
"vendor": "google",
"language": "en-US"
}
},
{
"verb": "hangup"
}
]

View File

@@ -0,0 +1,21 @@
{
"dial": {
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
"type": "phone",
"number": "+15083084809"
}
],
"listen": {
"url": "wss://myrecorder.example.com:4433",
"mixType" : "stereo",
"sampleRate": 8000,
"passDtmf": true,
"metadata": {
"clientId": "12udih"
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"dial": {
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
"type": "phone",
"number": "+15083084809"
}
]
}
}

View File

@@ -0,0 +1,16 @@
{
"dial": {
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
"type": "sip",
"number": "sip:1617333456@sip.trunk1.com",
"auth": {
"username": "foo",
"password": "bar"
}
}
]
}
}

View File

@@ -0,0 +1,21 @@
{
"dial": {
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
"type": "phone",
"number": "+15083084809"
}
],
"transcribe": {
"transcriptionHook": "/transcribe",
"recognizer": {
"vendor": "google",
"language" : "en-US",
"dualChannel" : true,
"interim": true
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"dial": {
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
"type": "user",
"name": "spike@sip.example.com"
}
]
}
}

View File

@@ -0,0 +1,5 @@
{
"pause": {
"length": 3
}
}

View File

@@ -0,0 +1,9 @@
{
"say": {
"text": ["hi there", "John"],
"synthesizer": {
"vendor": "google",
"language": "en-US"
}
}
}

9
test/data/good/say.json Normal file
View File

@@ -0,0 +1,9 @@
{
"say": {
"text": "hi there",
"synthesizer": {
"vendor": "google",
"language": "en-US"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"sip:decline": {
"status": 480,
"reason": "Gone Fishin",
"headers": {
"Retry-After": 300
}
}
}

54
test/index.js Normal file
View File

@@ -0,0 +1,54 @@
const test = require('blue-tape');
const assert = require('assert');
const fetchData = (json) => {
const keys = Object.keys(json);
return json[keys[0]];
}
test('unit tests', (t) => {
const WebhookResponse = require('..').WebhookResponse;
let app = new WebhookResponse();
app.sip_decline(fetchData(require('./data/good/sip-decline')));
t.pass('sip_decline: passes');
app.say(fetchData(require('./data/good/say')));
t.pass('say: passes');
app.say(fetchData(require('./data/good/say-text-array')));
t.pass('say: passes with array of text');
app.pause(fetchData(require('./data/good/pause')));
t.pass('pause: passes with array of text');
app.dial(fetchData(require('./data/good/dial-sip')));
t.pass('dial: passes with target sip');
app.dial(fetchData(require('./data/good/dial-phone')));
t.pass('dial: passes with target phone');
app.dial(fetchData(require('./data/good/dial-user')));
t.pass('dial: passes with target user');
app.dial(fetchData(require('./data/good/dial-listen')));
t.pass('dial: passes with embedded listen');
//let payload = app.toJSON();
//console.log(payload);
//let task = makeTask(logger, require('./data/good/sip-decline'));
//t.ok(task.name === 'sip:decline', 'parsed sip:decline');
//t.throws(errInvalidInstruction, /malformed jambonz application payload/, 'throws error for invalid instruction');
//t.throws(errUnknownProperty, /unknown property/, 'throws error for invalid instruction');
//t.throws(errMissingProperty, /missing value/, 'throws error for missing required property');
//t.throws(errInvalidType, /invalid data type/, 'throws error for invalid data type');
//t.throws(errBadEnum, /must be one of/, 'throws error for invalid enum');
//t.throws(errBadPayload, /malformed jambonz application payload/, 'throws error for invalid payload with multiple keys');
//t.throws(errBadPayload2, /malformed jambonz application payload/, 'throws error for invalid payload that is not an object');
t.end();
});
const errInvalidInstruction = () => makeTask(logger, require('./data/bad/unknown-instruction'));
const errUnknownProperty = () => makeTask(logger, require('./data/bad/unknown-property'));
const errMissingProperty = () => makeTask(logger, require('./data/bad/missing-required-property'));
const errInvalidType = () => makeTask(logger, require('./data/bad/invalid-type'));
const errBadEnum = () => makeTask(logger, require('./data/bad/bad-enum'));
const errBadPayload = () => makeTask(logger, require('./data/bad/bad-payload'));
const errBadPayload2 = () => makeTask(logger, require('./data/bad/bad-payload2'));