diff --git a/db/jambones-sql.sql b/db/jambones-sql.sql index 950467b..c28c32a 100644 --- a/db/jambones-sql.sql +++ b/db/jambones-sql.sql @@ -148,8 +148,9 @@ CREATE TABLE applications application_sid CHAR(36) NOT NULL UNIQUE , name VARCHAR(64) NOT NULL, account_sid CHAR(36) NOT NULL COMMENT 'account that this application belongs to', -call_hook_sid CHAR(36) COMMENT 'webhook to call for inbound calls to phone numbers owned by this account', +call_hook_sid CHAR(36) COMMENT 'webhook to call for inbound calls ', call_status_hook_sid CHAR(36) COMMENT 'webhook to call for call status events', +messaging_hook_sid CHAR(36) COMMENT 'webhook to call for inbound SMS/MMS ', speech_synthesis_vendor VARCHAR(64) NOT NULL DEFAULT 'google', speech_synthesis_language VARCHAR(12) NOT NULL DEFAULT 'en-US', speech_synthesis_voice VARCHAR(64), @@ -242,6 +243,8 @@ ALTER TABLE applications ADD FOREIGN KEY call_hook_sid_idxfk (call_hook_sid) REF ALTER TABLE applications ADD FOREIGN KEY call_status_hook_sid_idxfk (call_status_hook_sid) REFERENCES webhooks (webhook_sid); +ALTER TABLE applications ADD FOREIGN KEY messaging_hook_sid_idxfk (messaging_hook_sid) REFERENCES webhooks (webhook_sid); + CREATE INDEX service_provider_sid_idx ON service_providers (service_provider_sid); CREATE INDEX name_idx ON service_providers (name); CREATE INDEX root_domain_idx ON service_providers (root_domain); diff --git a/db/jambones.sqs b/db/jambones.sqs index b72bea8..a5c1046 100644 --- a/db/jambones.sqs +++ b/db/jambones.sqs @@ -194,11 +194,13 @@ + + @@ -764,7 +766,7 @@ 345.00 - 240.00 + 260.00 0 @@ -812,7 +814,7 @@ 2 - + @@ -829,6 +831,20 @@ + + + + webhook_sid + webhooks + + + 4 + 1 + + + + + @@ -1083,17 +1099,17 @@ - + - - - + + + - + diff --git a/lib/models/application.js b/lib/models/application.js index 58acf75..8b2f73e 100644 --- a/lib/models/application.js +++ b/lib/models/application.js @@ -23,7 +23,9 @@ 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 +38,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 +130,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', } ]; diff --git a/lib/routes/api/applications.js b/lib/routes/api/applications.js index f833ec0..122f80c 100644 --- a/lib/routes/api/applications.js +++ b/lib/routes/api/applications.js @@ -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']; diff --git a/lib/routes/api/index.js b/lib/routes/api/index.js index eadf3aa..715ae93 100644 --- a/lib/routes/api/index.js +++ b/lib/routes/api/index.js @@ -20,4 +20,6 @@ api.use('/Sbcs', isAdminScope, require('./sbcs')); api.use('/Users', require('./users')); api.use('/login', require('./login')); +api.use('/messaging', require('./messaging')); + module.exports = api; diff --git a/lib/routes/api/messaging.js b/lib/routes/api/messaging.js new file mode 100644 index 0000000..727a964 --- /dev/null +++ b/lib/routes/api/messaging.js @@ -0,0 +1,95 @@ +const router = require('express').Router(); +const request = require('request'); +const sysError = require('./error'); +const partners = {}; +let initialized = false; +let idx = 0; + +function initPartners(logger) { + if (initialized) return; + initialized = true; + + if (process.env.JAMBONES_MESSAGING) { + try { + const obj = JSON.parse(process.env.JAMBONES_MESSAGING); + Object.assign(partners, obj); + logger.info({partners}, 'Messaging partners configuration'); + } catch (err) { + logger.error(err, `expected JSON for JAMBONES_MESSAGING : ${process.env.JAMBONES_MESSAGING}`); + } + } +} + +router.post('/:partner', async(req, res) => { + const partner = req.params.partner; + const {retrieveSet, logger} = req.app.locals; + const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`; + + // one time load of partner-specific filter and respond functions + initPartners(logger); + + const partnerModule = partners[partner]; + if (!partnerModule) { + logger.info(`rejecting incomingSms request from unknown partner ${partner}`); + return res.sendStatus(404); + } + + let filterFn, respondFn; + try { + const {filter, respond} = require(partnerModule); + // must at least provide a filter function + if (!filter) { + logger.info(`missing filter function in module ${partnerModule} for partner ${partner}`); + return res.sendStatus(404); + } + filterFn = filter; + if (!respond) respondFn = res.sendStatus.bind(null, 200); + else if (typeof respond === 'number' && respond >= 200 && respond < 299) { + respondFn = res.sendStatus.bind(null, respond); + } + else respondFn; + } catch (err) { + logger.info(err, `failure loading module ${partnerModule} for partner ${partner}`); + 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/${partner}`; + + const payload = Promise.resolve(filterFn(req.body, req)); + logger.debug({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 (201 === response.statusCode) { + // success + return doSendResponse(res, respondFn, body); + } + logger.error({statusCode: response.statusCode}, `Non-success response returned by createCall ${ip}`); + return res.sendStatus(500); + }); + } catch (err) { + sysError(logger, res, err); + } +}); + +async function doSendResponse(res, respondFn, body) { + const payload = await respondFn(body); + if (typeof payload === 'number') return res.sendStatus(payload); + else if (typeof payload === 'string') return res.status(200).json(payload); + else return res.sendStatus(200); +} +module.exports = router; diff --git a/lib/swagger/swagger.yaml b/lib/swagger/swagger.yaml index e81ae6d..dba5581 100644 --- a/lib/swagger/swagger.yaml +++ b/lib/swagger/swagger.yaml @@ -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: @@ -1715,10 +1718,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 +1737,6 @@ components: - application_sid - name - account_sid - - inbound_hook - - inbound_status_hook ApiKey: type: object properties: diff --git a/package.json b/package.json index dc86149..dcc920d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jambonz-api-server", - "version": "1.1.7", + "version": "1.2.0", "description": "", "main": "app.js", "scripts": { @@ -15,8 +15,8 @@ "url": "https://github.com/jambonz/jambonz-api-server.git" }, "dependencies": { - "@jambonz/db-helpers": "^0.3.8", - "@jambonz/realtimedb-helpers": "0.2.15", + "@jambonz/db-helpers": "^0.5.0", + "@jambonz/realtimedb-helpers": "0.2.16", "cors": "^2.8.5", "express": "^4.17.1", "mysql2": "^2.1.0", @@ -30,7 +30,7 @@ "yamljs": "^0.3.0" }, "devDependencies": { - "eslint": "^6.8.0", + "eslint": "^7.10.0", "eslint-plugin-promise": "^4.2.1", "nyc": "^15.1.0", "request-promise-native": "^1.0.9", diff --git a/test/applications.js b/test/applications.js index 79f7af1..885dee9 100644 --- a/test/applications.js +++ b/test/applications.js @@ -38,6 +38,9 @@ test('application tests', async(t) => { call_status_hook: { url: 'http://example.com/status', method: 'POST' + }, + messaging_hook: { + url: 'http://example.com/sms' } } }); @@ -58,6 +61,7 @@ test('application tests', async(t) => { json: true, }); t.ok(result[0].name === 'daveh' , 'successfully retrieved application by sid'); + t.ok(result[0].messaging_hook.url === 'http://example.com/sms' , 'successfully retrieved messaging_hook from application'); /* update applications */ result = await request.put(`/Applications/${sid}`, { @@ -67,11 +71,21 @@ test('application tests', async(t) => { body: { call_hook: { url: 'http://example2.com' + }, + messaging_hook: { + url: 'http://example2.com/mms' } } }); t.ok(result.statusCode === 204, 'successfully updated application'); + /* validate messaging hook was updated */ + result = await request.get(`/Applications/${sid}`, { + auth: authAdmin, + json: true, + }); + t.ok(result[0].messaging_hook.url === 'http://example2.com/mms' , 'successfully updated messaging_hook'); + /* assign phone number to application */ result = await request.put(`/PhoneNumbers/${phone_number_sid}`, { auth: authAdmin,