mirror of
https://github.com/jambonz/jambonz-api-server.git
synced 2026-07-24 21:12:10 +00:00
add sms messaging support
This commit is contained in:
+12
-21
@@ -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',
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -97,6 +97,4 @@ router.put('/:sid', async(req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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: []
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user