add sms messaging support

This commit is contained in:
Dave Horton
2020-10-09 08:04:39 -04:00
parent 53763aae14
commit ea64fb1a58
15 changed files with 445 additions and 58 deletions
+12 -21
View File
@@ -1,29 +1,13 @@
const Model = require('./model');
const {getMysqlConnection} = require('../db');
const listSqlSp = `
SELECT * from applications
WHERE account_sid in (
SELECT account_sid from accounts
WHERE service_provider_sid = ?
)`;
const listSqlAccount = 'SELECT * from applications WHERE account_sid = ?';
const retrieveSqlSp = `
SELECT * from applications
WHERE account_sid in (
SELECT account_sid from accounts
WHERE service_provider_sid = ?
)
AND application_sid = ?`;
const retrieveSqlAccount = `
SELECT * from applications
WHERE account_sid = ?
AND application_sid = ?`;
const retrieveSql = `SELECT * from applications app
LEFT JOIN webhooks AS ch
ON app.call_hook_sid = ch.webhook_sid
LEFT JOIN webhooks AS sh
ON app.call_status_hook_sid = sh.webhook_sid`;
ON app.call_status_hook_sid = sh.webhook_sid
LEFT JOIN webhooks AS mh
ON app.messaging_hook_sid = mh.webhook_sid`;
function transmogrifyResults(results) {
return results.map((row) => {
@@ -36,8 +20,13 @@ function transmogrifyResults(results) {
Object.assign(obj, {call_status_hook: row.sh});
}
else obj.call_status_hook = null;
if (row.mh && Object.keys(row.mh).length && row.mh.url !== null) {
Object.assign(obj, {messaging_hook: row.mh});
}
else obj.messaging_hook = null;
delete obj.call_hook_sid;
delete obj.call_status_hook_sid;
delete obj.messaging_hook_sid;
return obj;
});
}
@@ -123,12 +112,14 @@ Application.fields = [
{
name: 'call_hook_sid',
type: 'string',
required: true
},
{
name: 'call_status_hook_sid',
type: 'string',
required: true
},
{
name: 'messaging_hook_sid',
type: 'string',
}
];
+67
View File
@@ -5,6 +5,7 @@ const Account = require('../../models/account');
const Webhook = require('../../models/webhook');
const ApiKey = require('../../models/api-key');
const ServiceProvider = require('../../models/service-provider');
const uuidv4 = require('uuid/v4');
const decorate = require('./decorate');
const snakeCase = require('../../utils/snake-case');
const sysError = require('./error');
@@ -135,6 +136,30 @@ async function validateCreateCall(logger, sid, req) {
}
}
async function validateCreateMessage(logger, sid, req) {
const obj = req.body;
const {lookupAccountByPhoneNumber} = req.app.locals;
if (req.user.account_sid !== sid) {
throw new DbErrorBadRequest(`unauthorized createMessage request for account ${sid}`);
}
if (!obj.from) throw new DbErrorBadRequest('missing from property');
else {
const regex = /^\+(\d+)$/;
const arr = regex.exec(obj.from);
const from = arr ? arr[1] : obj.from;
const account = await lookupAccountByPhoneNumber(from);
if (!account) throw new DbErrorBadRequest(`accountSid ${sid} does not own phone number ${from}`);
}
if (!obj.to) throw new DbErrorBadRequest('missing to property');
if (!obj.text && !obj.media) {
throw new DbErrorBadRequest('either text or media required in outbound message');
}
}
async function validateAdd(req) {
/* account-level token can not be used to add accounts */
if (req.user.hasAccountAuth) {
@@ -420,5 +445,47 @@ router.post('/:sid/Calls/:callSid', async(req, res) => {
}
});
/**
* create a new Message
*/
router.post('/:sid/Messages', async(req, res) => {
const sid = req.params.sid;
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
const {retrieveSet, logger} = req.app.locals;
try {
const fs = await retrieveSet(setName);
if (0 === fs.length) {
logger.info('No available feature servers to handle createMessage API request');
return res.json({msg: 'no available feature servers at this time'}).status(500);
}
const ip = fs[idx++ % fs.length];
logger.info({fs}, `feature servers available for createMessage API request, selecting ${ip}`);
const serviceUrl = `http://${ip}:3000/v1/createMessage/${sid}`;
await validateCreateMessage(logger, sid, req);
const payload = Object.assign({messageSid: uuidv4()}, req.body);
logger.debug({payload}, `sending createMessage API request to to ${ip}`);
updateLastUsed(logger, sid, req).catch((err) => {});
request({
url: serviceUrl,
method: 'POST',
json: true,
body: payload
}, (err, response, body) => {
if (err) {
logger.error(err, `Error sending createMessage POST to ${ip}`);
return res.sendStatus(500);
}
if (response.statusCode !== 200) {
logger.error({statusCode: response.statusCode}, `Non-success response returned by createMessage ${serviceUrl}`);
return res.sendStatus(500);
}
res.status(201).json(body);
});
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+2 -2
View File
@@ -62,7 +62,7 @@ router.post('/', async(req, res) => {
// create webhooks if provided
const obj = Object.assign({}, req.body);
for (const prop of ['call_hook', 'call_status_hook']) {
for (const prop of ['call_hook', 'call_status_hook', 'messaging_hook']) {
if (obj[prop]) {
obj[`${prop}_sid`] = await Webhook.make(obj[prop]);
delete obj[prop];
@@ -113,7 +113,7 @@ router.put('/:sid', async(req, res) => {
// create webhooks if provided
const obj = Object.assign({}, req.body);
for (const prop of ['call_hook', 'call_status_hook']) {
for (const prop of ['call_hook', 'call_status_hook', 'messaging_hook']) {
if (prop in obj && Object.keys(obj[prop]).length) {
if ('webhook_sid' in obj[prop]) {
const sid = obj[prop]['webhook_sid'];
+4
View File
@@ -20,4 +20,8 @@ api.use('/Sbcs', isAdminScope, require('./sbcs'));
api.use('/Users', require('./users'));
api.use('/login', require('./login'));
// messaging
api.use('/messaging', require('./sms-inbound')); // inbound SMS from carrier
api.use('/outboundSMS', require('./sms-outbound')); // outbound SMS from feature server
module.exports = api;
+2 -2
View File
@@ -31,13 +31,13 @@ async function validateAdd(req) {
async function checkInUse(req, sid) {
const phoneNumber = await PhoneNumber.retrieve(sid);
if (phoneNumber.account_sid) {
throw new DbErrorUnprocessableRequest('cannot delete phone number that is assigned to an account');
throw new DbErrorUnprocessableRequest('cannot delete phone number that is assigned to an account');
}
}
/* can not change number or voip carrier */
async function validateUpdate(req, sid) {
const result = await PhoneNumber.retrieve(sid);
//const result = await PhoneNumber.retrieve(sid);
if (req.body.voip_carrier_sid) throw new DbErrorBadRequest('voip_carrier_sid may not be modified');
if (req.body.number) throw new DbErrorBadRequest('number may not be modified');
-2
View File
@@ -97,6 +97,4 @@ router.put('/:sid', async(req, res) => {
}
});
module.exports = router;
+142
View File
@@ -0,0 +1,142 @@
const router = require('express').Router();
const request = require('request');
const getProvider = require('../../utils/sms-provider');
const uuidv4 = require('uuid/v4');
const sysError = require('./error');
let idx = 0;
async function doSendResponse(res, respondFn, body) {
if (typeof respondFn === 'number') res.sendStatus(respondFn);
else if (typeof respondFn !== 'function') res.sendStatus(200);
else {
const payload = await respondFn(body);
res.status(200).json(payload);
}
}
router.post('/:provider', async(req, res) => {
const provider = req.params.provider;
const {
retrieveSet,
lookupAppByPhoneNumber,
logger
} = req.app.locals;
const setName = `${process.env.JAMBONES_CLUSTER_ID || 'default'}:active-fs`;
logger.debug({path: req.path, body: req.body}, 'incomingSMS from carrier');
// search for provider module
const arr = getProvider(logger, provider);
if (!arr) {
logger.info({body: req.body, params: req.params},
`rejecting incomingSms request from unknown provider ${provider}`
);
return res.sendStatus(404);
}
const providerData = arr[1];
if (!providerData || !providerData.module) {
logger.info({body: req.body, params: req.params},
`rejecting incomingSms request from badly configured provider ${provider}`
);
return res.sendStatus(404);
}
// load provider module
let filterFn, respondFn;
try {
const {
fromProviderFormat,
formatProviderResponse
} = require(providerData.module);
// must at least provide a filter function
if (!fromProviderFormat) {
logger.info(
`missing fromProviderFormat function in module ${providerData.module} for provider ${provider}`
);
return res.sendStatus(404);
}
filterFn = fromProviderFormat;
respondFn = formatProviderResponse;
} catch (err) {
logger.info(
err,
`failure loading module ${providerData.module} for provider ${provider}`
);
return res.sendStatus(500);
}
try {
const fs = await retrieveSet(setName);
if (0 === fs.length) {
logger.info('No available feature servers to handle createCall API request');
return res
.json({
msg: 'no available feature servers at this time'
})
.status(480);
}
const ip = fs[idx++ % fs.length];
const serviceUrl = `http://${ip}:3000/v1/messaging/${provider}`;
const messageSid = uuidv4();
const payload = await Promise.resolve(filterFn({messageSid}, req.body));
/**
* lookup the application associated with the number in the To field
* since there could be multiple Tos, we have to search through (and cc also)
*/
let app;
const to = Array.isArray(payload.to) ? payload.to : [payload.to];
const cc = Array.isArray(payload.cc) ? payload.cc : (payload.cc ? [payload.cc] : []);
const dids = to.concat(cc).filter((n) => n.length);
for (let did of dids) {
const regex = /^\+(\d+)$/;
const arr = regex.exec(did);
did = arr ? arr[1] : did;
const obj = await lookupAppByPhoneNumber(did);
logger.info({obj}, `lookup app for phone number ${did}`);
if (obj) {
logger.info({did, obj}, 'Found app for DID');
app = obj;
break;
}
}
if (!app) {
logger.info({payload}, 'No application found for incoming SMS');
return res.sendStatus(404);
}
if (!app.messaging_hook) {
logger.info({payload}, `app "${app.name}" found for incoming SMS does not have an associated messaging hook`);
return res.sendStatus(404);
}
payload.app = app;
logger.debug({body: req.body, payload}, 'filtered incoming SMS');
logger.info({payload, url: serviceUrl}, `sending incomingSms API request to FS at ${ip}`);
request({
url: serviceUrl,
method: 'POST',
json: true,
body: payload,
},
async(err, response, body) => {
if (err) {
logger.error(err, `Error sending incomingSms POST to ${ip}`);
return res.sendStatus(500);
}
if (200 === response.statusCode) {
// success
logger.info({body}, 'sending response to provider for incomingSMS');
return doSendResponse(res, respondFn, body);
}
logger.error({statusCode: response.statusCode}, `Non-success response returned by incomingSms ${ip}`);
return res.sendStatus(500);
});
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+44
View File
@@ -0,0 +1,44 @@
const router = require('express').Router();
const getProvider = require('../../utils/sms-provider');
const sysError = require('./error');
router.post('/', async(req, res) => {
const { logger } = req.app.locals;
try {
// if provider specified use it, otherwise use first in list
const arr = getProvider(logger, req.body.provider);
if (!Array.isArray(arr)) {
throw new Error('outboundSMS - unable to locate sms provider to use to send message');
}
const providerData = arr[1];
if (!providerData || !providerData.module) {
throw new Error(`rejecting outgoingSms request for unknown or badly configured provider ${req.body.provider}`);
}
const provider = arr[0];
const opts = providerData.options;
if (!opts || !opts.url) {
throw new Error(`rejecting outgoingSms request -- no HTTP url for ${req.body.provider}`);
}
// load provider module
const { sendSms } = require(providerData.module);
if (!sendSms) {
throw new Error(`missing sendSms function in module ${providerData.module} for provider ${provider}`);
}
// send the SMS
const payload = req.body;
delete payload.provider;
logger.debug({opts, payload}, `outboundSMS - sending to ${opts.url}`);
const response = await sendSms(opts, payload);
logger.info({response, payload: req.body}, `outboundSMS - sent to ${opts.url}`);
res.status(200).json(response);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+61 -11
View File
@@ -1249,10 +1249,13 @@ paths:
format: uuid
call_hook:
$ref: '#/components/schemas/Webhook'
description: authentication webhook for inbound calls from PSTN
description: application webhook to handle inbound voice calls
call_status_hook:
$ref: '#/components/schemas/Webhook'
description: webhook for call status events
description: webhook to report call status events
messaging_hook:
$ref: '#/components/schemas/Webhook'
description: application webhook to handle inbound SMS/MMS messages
speech_synthesis_vendor:
type: string
speech_synthesis_voice:
@@ -1433,9 +1436,9 @@ paths:
application/json:
schema:
required:
- callSid
- sid
properties:
callSid:
sid:
type: string
format: uuid
example: 2531329f-fb09-4ef7-887e-84e648214436
@@ -1465,8 +1468,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
/Accounts/{AccountSid}/Calls/{CallSid}:
parameters:
- name: AccountSid
@@ -1567,7 +1568,39 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
$ref: '#/components/schemas/GeneralError'
/Accounts/{AccountSid}/Messages:
post:
summary: create an outgoing SMS message
operationId: createMessage
parameters:
- name: AccountSid
in: path
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Message'
responses:
201:
description: call successfully created
content:
application/json:
schema:
required:
- sid
properties:
sid:
type: string
format: uuid
example: 2531329f-fb09-4ef7-887e-84e648214436
providerResponse:
type: string
400:
description: bad request
components:
securitySchemes:
bearerAuth:
@@ -1715,10 +1748,13 @@ components:
format: uuid
call_hook:
$ref: '#/components/schemas/Webhook'
description: authentication webhook for registration
description: application webhook for inbound voice calls
call_status_hook:
$ref: '#/components/schemas/Webhook'
description: authentication webhook for registration
description: webhhok for reporting call status events
messaging_hook:
$ref: '#/components/schemas/Webhook'
description: application webhook for inbound SMS/MMS
speech_synthesis_vendor:
type: string
speech_synthesis_voice:
@@ -1731,8 +1767,6 @@ components:
- application_sid
- name
- account_sid
- inbound_hook
- inbound_status_hook
ApiKey:
type: object
properties:
@@ -1914,6 +1948,22 @@ components:
required:
- type
example: {"type": "phone", "number": "+16172375080"}
Message:
properties:
provider:
type: string
from:
type: string
to:
type: string
text:
type: string
media:
type: string
required:
- from
- to
example: {"from": "13394445678", "to": "16173333456", "text": "please call when you can"}
security:
- bearerAuth: []
+40
View File
@@ -0,0 +1,40 @@
const providers = new Map();
let init = false;
function initProviders(logger) {
if (init) return;
if (process.env.JAMBONES_MESSAGING) {
try {
const obj = JSON.parse(process.env.JAMBONES_MESSAGING);
for (const [key, value] of Object.entries(obj)) {
logger.debug({config: value}, `Adding SMS provider ${key}`);
providers.set(key, value);
}
logger.info(`Configured ${providers.size} SMS providers`);
} catch (err) {
logger.error(err, `expected JSON for JAMBONES_MESSAGING : ${process.env.JAMBONES_MESSAGING}`);
}
}
else {
logger.info('no JAMBONES_MESSAGING env var, messaging is disabled');
}
init = true;
}
function getProvider(logger, partner) {
initProviders(logger);
if (typeof partner === 'string') {
const config = providers.get(partner);
const arr = [partner, config];
logger.debug({arr}, 'getProvider by name');
return arr;
}
else if (providers.size) {
const arr = providers.entries().next().value;
logger.debug({arr}, 'getProvider by first available');
return arr;
}
}
module.exports = getProvider;