diff --git a/app.js b/app.js index 0c11259..227a75b 100644 --- a/app.js +++ b/app.js @@ -33,6 +33,7 @@ const { const { lookupAppBySid, lookupAccountBySid, + lookupAccountByPhoneNumber, lookupAppByPhoneNumber } = require('@jambonz/db-helpers')({ host: process.env.JAMBONES_MYSQL_HOST, @@ -55,6 +56,7 @@ Object.assign(app.locals, { retrieveSet, lookupAppBySid, lookupAccountBySid, + lookupAccountByPhoneNumber, lookupAppByPhoneNumber }); diff --git a/lib/routes/api/accounts.js b/lib/routes/api/accounts.js index 9738eeb..b52495e 100644 --- a/lib/routes/api/accounts.js +++ b/lib/routes/api/accounts.js @@ -135,6 +135,31 @@ 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'); + //obj.to = typeof obj.to === 'string' ? [obj.to] : obj.to; + + if (!obj.text && (!obj.media || 0 === obj.media.length)) { + 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 = 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; diff --git a/lib/routes/api/sms-inbound.js b/lib/routes/api/sms-inbound.js index d39b7cd..eaf09d8 100644 --- a/lib/routes/api/sms-inbound.js +++ b/lib/routes/api/sms-inbound.js @@ -1,6 +1,6 @@ const router = require('express').Router(); const request = require('request'); -const smsPartnerFn = require('../../utils/sms-partner'); +const getProvider = require('../../utils/sms-provider'); const uuidv4 = require('uuid/v4'); const sysError = require('./error'); let idx = 0; @@ -15,44 +15,44 @@ async function doSendResponse(res, respondFn, body) { } } -router.post('/:partner', async(req, res) => { - const partnerName = req.params.partner; +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`; - const getPartner = smsPartnerFn(logger); + logger.debug({path: req.path, body: req.body}, 'incomingSMS from carrier'); - // search for partner module - const arr = getPartner(partnerName); + // search for provider module + const arr = getProvider(logger, provider); if (!arr) { - logger.info( - `rejecting incomingSms request from unknown or badly configured partner ${partnerName}` + logger.info({body: req.body, params: req.params}, + `rejecting incomingSms request from unknown provider ${provider}` ); return res.sendStatus(404); } - const partnerData = arr[1]; - if (!partnerData || !partnerData.module) { - logger.info( - `rejecting incomingSms request from unknown or badly configured partner ${partnerName}` + 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 partner module + // load provider module let filterFn, respondFn; try { const { fromProviderFormat, formatProviderResponse - } = require(partnerData.module); + } = require(providerData.module); // must at least provide a filter function if (!fromProviderFormat) { logger.info( - `missing fromProviderFormat function in module ${partnerData.module} for partner ${partnerName}` + `missing fromProviderFormat function in module ${providerData.module} for provider ${provider}` ); return res.sendStatus(404); } @@ -61,7 +61,7 @@ router.post('/:partner', async(req, res) => { } catch (err) { logger.info( err, - `failure loading module ${partnerData.module} for partner ${partnerName}` + `failure loading module ${providerData.module} for provider ${provider}` ); return res.sendStatus(500); } @@ -77,7 +77,7 @@ router.post('/:partner', async(req, res) => { .status(480); } const ip = fs[idx++ % fs.length]; - const serviceUrl = `http://${ip}:3000/v1/messaging/${partnerName}`; + const serviceUrl = `http://${ip}:3000/v1/messaging/${provider}`; const messageSid = uuidv4(); const payload = await Promise.resolve(filterFn({messageSid}, req.body)); @@ -86,7 +86,9 @@ router.post('/:partner', async(req, res) => { * since there could be multiple Tos, we have to search through (and cc also) */ let app; - const dids = [payload.to].concat([payload.cc]).filter((n) => n.length); + 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); @@ -124,8 +126,9 @@ router.post('/:partner', async(req, res) => { logger.error(err, `Error sending incomingSms POST to ${ip}`); return res.sendStatus(500); } - if (201 === response.statusCode) { + 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}`); diff --git a/lib/routes/api/sms-outbound.js b/lib/routes/api/sms-outbound.js index 54963cc..277e190 100644 --- a/lib/routes/api/sms-outbound.js +++ b/lib/routes/api/sms-outbound.js @@ -1,39 +1,41 @@ const router = require('express').Router(); -const smsPartnerFn = require('../../utils/sms-partner'); +const getProvider = require('../../utils/sms-provider'); const sysError = require('./error'); router.post('/', async(req, res) => { const { logger } = req.app.locals; - const getPartner = smsPartnerFn(logger); try { - // if partner specified use it, otherwise use first in list - const arr = getPartner(req.body.partner); + // 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 partnerData = arr[1]; - if (!partnerData || !partnerData.module) { - throw new Error(`rejecting outgoingSms request for unknown or badly configured partner ${req.body.partner}`); + const providerData = arr[1]; + if (!providerData || !providerData.module) { + throw new Error(`rejecting outgoingSms request for unknown or badly configured provider ${req.body.provider}`); } - const partnerName = arr[0]; - const opts = partnerData.options; + const provider = arr[0]; + const opts = providerData.options; if (!opts || !opts.url) { - throw new Error(`rejecting outgoingSms request -- no HTTP url for ${req.body.partner}`); + throw new Error(`rejecting outgoingSms request -- no HTTP url for ${req.body.provider}`); } - // load partner module - const { sendSms } = require(partnerData.module); + // load provider module + const { sendSms } = require(providerData.module); if (!sendSms) { - throw new Error(`missing sendSms function in module ${partnerData.module} for partner ${partnerName}`); + throw new Error(`missing sendSms function in module ${providerData.module} for provider ${provider}`); } // send the SMS - const response = await sendSms(opts, req.body); + 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({'status': 'ok'}); + res.status(200).json(response); } catch (err) { sysError(logger, res, err); } diff --git a/lib/swagger/swagger.yaml b/lib/swagger/swagger.yaml index dba5581..10b22e7 100644 --- a/lib/swagger/swagger.yaml +++ b/lib/swagger/swagger.yaml @@ -1436,9 +1436,9 @@ paths: application/json: schema: required: - - callSid + - sid properties: - callSid: + sid: type: string format: uuid example: 2531329f-fb09-4ef7-887e-84e648214436 @@ -1468,8 +1468,6 @@ paths: application/json: schema: $ref: '#/components/schemas/GeneralError' - - /Accounts/{AccountSid}/Calls/{CallSid}: parameters: - name: AccountSid @@ -1570,7 +1568,37 @@ 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 + 400: + description: bad request components: securitySchemes: bearerAuth: @@ -1918,6 +1946,30 @@ components: required: - type example: {"type": "phone", "number": "+16172375080"} + Message: + properties: + provider: + type: string + from: + type: string + to: + type: array + items: + type: string + cc: + type: array + items: + type: string + text: + type: string + media: + type: array + items: + type: string + required: + - from + - to + example: {"from": "13394445678", "to": "16173333456", "text": "please call when you can"} security: - bearerAuth: [] \ No newline at end of file diff --git a/lib/utils/sms-partner.js b/lib/utils/sms-provider.js similarity index 51% rename from lib/utils/sms-partner.js rename to lib/utils/sms-provider.js index c938f3a..e8ddfcb 100644 --- a/lib/utils/sms-partner.js +++ b/lib/utils/sms-provider.js @@ -1,14 +1,16 @@ -const partners = new Map(); +const providers = new Map(); +let init = false; -function initPartners(logger) { +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}`); - partners.set(key, value); + providers.set(key, value); } - logger.info(`Configured ${partners.size} SMS partners`); + logger.info(`Configured ${providers.size} SMS providers`); } catch (err) { logger.error(err, `expected JSON for JAMBONES_MESSAGING : ${process.env.JAMBONES_MESSAGING}`); } @@ -16,23 +18,23 @@ function initPartners(logger) { else { logger.info('no JAMBONES_MESSAGING env var, messaging is disabled'); } + init = true; } -function getPartner(logger, partner) { +function getProvider(logger, partner) { + initProviders(logger); if (typeof partner === 'string') { - const config = partners.get(partner); + const config = providers.get(partner); const arr = [partner, config]; - logger.debug({arr}, 'getPartner by name'); + logger.debug({arr}, 'getProvider by name'); return arr; } - else if (partners.size) { - const arr = partners.entries().next().value; - logger.debug({arr}, 'getPartner by first available'); + else if (providers.size) { + const arr = providers.entries().next().value; + logger.debug({arr}, 'getProvider by first available'); return arr; } } -module.exports = (logger) => { - initPartners(logger); - return getPartner.bind(null, logger); -}; +module.exports = getProvider; + diff --git a/package.json b/package.json index fcb820a..5efa5f9 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "url": "https://github.com/jambonz/jambonz-api-server.git" }, "dependencies": { - "@jambonz/db-helpers": "^0.5.0", - "@jambonz/messaging-peerless": "0.0.5", + "@jambonz/db-helpers": "^0.5.1", + "@jambonz/messaging-382com": "0.0.2", + "@jambonz/messaging-peerless": "0.0.6", + "@jambonz/messaging-simwood": "0.0.4", "@jambonz/realtimedb-helpers": "0.2.17", "cors": "^2.8.5", "express": "^4.17.1", @@ -26,6 +28,7 @@ "pino": "^5.17.0", "request": "^2.88.2", "request-debug": "^0.2.0", + "swagger-ui-dist": "^3.35.0", "swagger-ui-express": "^4.1.4", "uuid": "^3.4.0", "yamljs": "^0.3.0"