aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorBen Beltran <ben@nsovocal.com>2017-02-19 02:04:42 -0600
committerBen Beltran <ben@nsovocal.com>2017-02-19 02:04:42 -0600
commitb0b2aee1145b50a58c22d39627df717d45c6338f (patch)
tree068b1f22bb170312b0830840a72a3ff0e21ae207 /lib
parentdc10b1de1a73effd6d0ab5744709913ef5cbe5b3 (diff)
parent2082455bb240291413c368c1b74736a739db2a2c (diff)
Merge branch 'release/1.0.0'
Diffstat (limited to 'lib')
-rw-r--r--lib/controllers/main_menu.js96
-rw-r--r--lib/controllers/recording_menu.js82
-rw-r--r--lib/controllers/recordings.js165
-rw-r--r--lib/dead_drop.js134
4 files changed, 477 insertions, 0 deletions
diff --git a/lib/controllers/main_menu.js b/lib/controllers/main_menu.js
new file mode 100644
index 0000000..06e172b
--- /dev/null
+++ b/lib/controllers/main_menu.js
@@ -0,0 +1,96 @@
+'use strict';
+
+const Twilio = require('twilio');
+
+const internals = {};
+
+internals.kMenuTimeout = 10; // timeout in seconds
+internals.kMenuDigits = 1; // number of digits in the menu
+internals.kContentType = 'application/xml'; // The content type used to respond
+internals.kMenuLanguage = 'es-mx'; // the language to use
+internals.kTimeoutMessage = 'Oh... está bien. Adios!';
+internals.kMainMenuRoute = '/menus/main';
+internals.kRecordingMenuRoute = '/menus/recording';
+internals.kRandomMessageRoute = '/recordings/0';
+internals.kLeaveMessageRoute = '/recordings';
+internals.kMenuMessage = 'Marca 1 para grabar mensaje. ' +
+ 'Marca 2 para escuchar mensaje al azar. ' +
+ 'Marca 3 si conoces el número de mensaje'; // the message that will be shown
+internals.kMenuInvalidResponseMessage = 'No entendí... Volviendo al menu principal.'; // invalid selection message
+internals.kMenuOptions = {
+ leaveMessage: 1,
+ listenToRandomMessage: 2,
+ listenToSpecificMessage: 3
+}; // the menu options
+
+/**
+ * Handles the HTTP requests for the main menu
+ *
+ * @class MainMenuController
+ */
+module.exports = internals.MainMenuController = class MainMenuController {
+
+ /**
+ * Serves the menu
+ *
+ * @function serveMenu
+ * @memberof MainMenuController
+ * @instance
+ * @return {generator} a koa compatible handler generator function
+ */
+ serveMenu() {
+
+ return function * () {
+
+ const response = new Twilio.TwimlResponse();
+
+ // the action will default to post in the same URL, so no change
+ // required there.
+ response.gather({
+ timeout: internals.kMenuTimeout,
+ numDigits: internals.kMenuDigits
+ }, function nestedHandler() {
+
+ this.say(internals.kMenuMessage, { language: internals.kMenuLanguage });
+ }).say(internals.kTimeoutMessage, { language: internals.kMenuLanguage });
+
+ this.type = internals.kContentType;
+ this.body = response.toString();
+ };
+ }
+
+ /**
+ * Parses the selected main menu response
+ *
+ * @function parseMenuSelection
+ * @memberof MainMenuController
+ * @instance
+ * @return {generator} a koa compatible handler generator function
+ */
+ parseMenuSelection() {
+
+ return function * () {
+
+ const menuSelection = parseInt(this.request.body.Digits);
+
+ const response = new Twilio.TwimlResponse();
+
+ if (menuSelection === internals.kMenuOptions.leaveMessage) {
+ response.redirect(internals.kLeaveMessageRoute, { method: 'GET' });
+ }
+ else if (menuSelection === internals.kMenuOptions.listenToRandomMessage) {
+ response.redirect(internals.kRandomMessageRoute, { method: 'GET' });
+ }
+ else if (menuSelection === internals.kMenuOptions.listenToSpecificMessage) {
+ response.redirect(internals.kRecordingMenuRoute, { method: 'GET' });
+ }
+ else {
+ response.say(internals.kMenuInvalidResponseMessage, { language: internals.kMenuLanguage })
+ .redirect(internals.kMainMenuRoute, { method: 'GET' });
+ }
+
+ this.type = internals.kContentType;
+ this.body = response.toString();
+ };
+ }
+};
diff --git a/lib/controllers/recording_menu.js b/lib/controllers/recording_menu.js
new file mode 100644
index 0000000..55f2836
--- /dev/null
+++ b/lib/controllers/recording_menu.js
@@ -0,0 +1,82 @@
+'use strict';
+
+const Twilio = require('twilio');
+
+const internals = {};
+
+internals.kMenuTimeout = 10; // timeout in seconds
+internals.kContentType = 'application/xml'; // The content type used to respond
+internals.kMenuLanguage = 'es-mx'; // the language to use
+internals.kMainMenuRoute = '/menus/main';
+internals.kListenMessageRoute = '/recordings/';
+internals.kMenuMessage = 'Escribe el numero de mensaje y presiona gato para terminar.';
+internals.kTimeoutMessage = 'Bueno... volviendo al menú principal.';
+internals.kMenuInvalidResponseMessage = 'No entendí... Volviendo al menu principal.'; // invalid selection message
+
+/**
+ * Handles the HTTP requests for the recording menu
+ *
+ * @class RecordingMenuController
+ */
+module.exports = internals.RecordingMenuController = class RecordingMenuController {
+
+ /**
+ * Serves the menu
+ *
+ * @function serveMenu
+ * @memberof RecordingMenuController
+ * @instance
+ * @return {generator} a koa compatible handler generator function
+ */
+ serveMenu() {
+
+ return function * () {
+
+ const response = new Twilio.TwimlResponse();
+
+ // the action will default to post in the same URL, so no change
+ // required there.
+ response.gather({
+ timeout: internals.kMenuTimeout
+ }, function nestedHandler() {
+
+ this.say(internals.kMenuMessage, { language: internals.kMenuLanguage });
+ })
+ .say(internals.kTimeoutMessage, { language: internals.kMenuLanguage })
+ .redirect(internals.kMainMenuRoute, { method: 'GET' });
+
+ this.type = internals.kContentType;
+ this.body = response.toString();
+ };
+ }
+
+ /**
+ * Parses the selected recording id
+ *
+ * @function parseMenuSelection
+ * @memberof RecordingMenuController
+ * @instance
+ * @return {generator} a koa compatible handler generator function
+ */
+ parseMenuSelection() {
+
+ return function * () {
+
+ const messageId = parseInt(this.request.body.Digits);
+
+ const response = new Twilio.TwimlResponse();
+
+ if (messageId) {
+ response.redirect(`${internals.kListenMessageRoute}${messageId}`, { method: 'GET' });
+ }
+ else {
+ response.say(internals.kMenuInvalidResponseMessage, { language: internals.kMenuLanguage })
+ .redirect(internals.kMainMenuRoute, { method: 'GET' });
+ }
+
+ this.type = internals.kContentType;
+ this.body = response.toString();
+ };
+ }
+};
+
diff --git a/lib/controllers/recordings.js b/lib/controllers/recordings.js
new file mode 100644
index 0000000..c7a7584
--- /dev/null
+++ b/lib/controllers/recordings.js
@@ -0,0 +1,165 @@
+'use strict';
+
+const Joi = require('joi');
+const Pify = require('pify');
+const Redis = require('redis');
+const Twilio = require('twilio');
+
+const internals = {};
+
+internals.kContentType = 'application/xml'; // The content type used to respond
+internals.kLanguage = 'es-mx'; // the language to use
+internals.kMaxMessageLength = 30; // max message length in seconds
+internals.kRecordingsSet = 'recordings';
+internals.kRecordMessage = 'Graba tu mensaje despues del bip y ' +
+ 'presiona cualquier tecla para finalizar. '; // the recording message
+internals.kConfirmationMessage = 'Gracias. Tu mensaje es el número: ';
+internals.kNotFoundMessage = 'Mensaje no encontrado. Adiós!';
+
+internals.kRecordingSchema = Joi.object().keys({
+ url: Joi.string().required()
+});
+
+/**
+ * Handles the HTTP requests for the recording menu
+ *
+ * @class RecordingsController
+ * @param {DeadDrop.tConfiguration} config The configuration to
+ * initialize.
+ */
+module.exports = internals.RecordingsController = class RecordingsController {
+ constructor(config) {
+
+ this._redis = Redis.createClient(config.redis);
+
+ // Log an error if it happens.
+ this._redis.on('error', (err) => {
+
+ console.error(err);
+ });
+ }
+
+ /**
+ * Start recording process
+ *
+ * @function startRecording
+ * @memberof RecordingsController
+ * @instance
+ * @return {generator} a koa compatible handler generator function
+ */
+ startRecording() {
+
+ return function * () {
+
+ const response = new Twilio.TwimlResponse();
+
+ // the action will default to post in the same URL, so no change
+ // required there.
+ response.say(internals.kRecordMessage, { language: internals.kLanguage })
+ .record({
+ maxLength: internals.kMaxMessageLength
+ });
+
+ this.type = internals.kContentType;
+ this.body = response.toString();
+ };
+ }
+
+ /**
+ * Saves the recording for later use
+ *
+ * @function saveRecording
+ * @memberof RecordingsController
+ * @instance
+ * @return {generator} a koa compatible handler generator function
+ */
+ saveRecording() {
+
+ const self = this;
+
+ return function * () {
+
+ const zadd = Pify(self._redis.zadd.bind(self._redis));
+
+ const response = new Twilio.TwimlResponse();
+
+ const id = Date.now().toString().substr(2,10);
+ const url = this.request.body.RecordingUrl;
+ const separatedId = id.split('').join('. ');
+ const recording = {
+ url
+ };
+
+ yield self._validate(recording).catch((err) => {
+
+ this.throw(err.message, 422);
+ });
+
+ // Add to ordered set for quick fetches, and set for random fetches
+ yield zadd(internals.kRecordingsSet, parseInt(id), url);
+
+ response.say(`${internals.kConfirmationMessage}${separatedId}`, { language: internals.kLanguage });
+
+ this.type = internals.kContentType;
+ this.body = response.toString();
+ };
+ }
+
+ /**
+ * Gets a recording.
+ *
+ * @function getRecording
+ * @memberof RecordingsController
+ * @instance
+ * @return {generator} a koa compatible handler generator function
+ */
+ getRecording() {
+
+ const self = this;
+
+ return function * (id) {
+
+ id = parseInt(id);
+
+ const zcard = Pify(self._redis.zcard.bind(self._redis));
+ const zrange = Pify(self._redis.zrange.bind(self._redis));
+ const zscore = Pify(self._redis.zscore.bind(self._redis));
+ const zrangebyscore = Pify(self._redis.zrangebyscore.bind(self._redis));
+
+ const response = new Twilio.TwimlResponse();
+ let location = null;
+
+ if (!id) {
+ const maxNumber = yield zcard(internals.kRecordingsSet);
+ const index = Math.floor(Math.random() * maxNumber); // get random between 0 and cardinality
+ location = yield zrange(internals.kRecordingsSet, index, index);
+ }
+ else {
+ location = yield zrangebyscore(internals.kRecordingsSet, id, id);
+ }
+
+ if (location && location.length > 0) {
+ if (!id) {
+ id = yield zscore(internals.kRecordingsSet, location[0]);
+ }
+
+ const separatedId = id.toString().split('').join('. ');
+ response.play(location[0]).say(separatedId, { language: internals.kLanguage });
+ }
+ else {
+ response.say(internals.kNotFoundMessage, { language: internals.kLanguage });
+ }
+
+ this.type = internals.kContentType;
+ this.body = response.toString();
+ };
+ }
+
+ // Validates the post schema
+
+ _validate(post) {
+
+ const validate = Pify(Joi.validate.bind(Joi));
+ return validate(post, internals.kRecordingSchema);
+ }
+};
diff --git a/lib/dead_drop.js b/lib/dead_drop.js
new file mode 100644
index 0000000..3336444
--- /dev/null
+++ b/lib/dead_drop.js
@@ -0,0 +1,134 @@
+'use strict';
+
+const Koa = require('koa');
+const KoaBodyParser = require('koa-bodyparser');
+const KoaRoute = require('koa-route');
+
+const MainMenuController = require('./controllers/main_menu');
+const RecordingMenuController = require('./controllers/recording_menu');
+const RecordingsController = require('./controllers/recordings');
+
+const internals = {};
+
+/**
+ * The Dead Drop class is the main entry point for the application.
+ *
+ * @class DeadDrop
+ * @param {DeadDrop.tConfiguration} config the initialization options to
+ * extend the instance
+ */
+module.exports = internals.DeadDrop = class DeadDrop {
+
+ constructor(config) {
+
+ Object.assign(this, config);
+ }
+
+ /**
+ * Initializes the application and starts listening. Also prints a
+ * nice robotic banner with information.
+ *
+ * @function run
+ * @memberof DeadDrop
+ * @instance
+ */
+ run() {
+
+ this._initializeServer();
+ this._startServer();
+ this._printBanner();
+
+ return Promise.resolve();
+ }
+
+ // Initializes the Koa application and all the handlers.
+
+ _initializeServer() {
+
+ const self = this;
+
+ this._app = Koa();
+
+ this._app.use(KoaBodyParser());
+
+ this._app.use(function * (next) {
+
+ const accountSid = this.request.body.AccountSid || this.request.query.AccountSid;
+
+ if (accountSid === self.twilioAccountSid) {
+ yield next;
+ }
+ else {
+ this.throw('Unauthorized', 401);
+ }
+ });
+
+ this._initializeMainMenuRoutes();
+ this._initializeRecordingMenuRoutes();
+ this._initializeRecordingsRoutes();
+
+ this._app.use(function * () {
+
+ this.body = 'How did you get here? Shoo!';
+ });
+
+ }
+
+ // Starts listening
+
+ _startServer() {
+
+ this._app.listen(this.port);
+ }
+
+ // Initializes the main menu routes.
+
+ _initializeMainMenuRoutes() {
+
+ const mainMenuController = new MainMenuController();
+
+ this._app.use(KoaRoute.get('/menus/main', mainMenuController.serveMenu()));
+ this._app.use(KoaRoute.post('/menus/main', mainMenuController.parseMenuSelection()));
+ }
+
+ // Initializes the recording menu routes.
+
+ _initializeRecordingMenuRoutes() {
+
+ const recordingMenuController = new RecordingMenuController();
+
+ this._app.use(KoaRoute.get('/menus/recording', recordingMenuController.serveMenu()));
+ this._app.use(KoaRoute.post('/menus/recording', recordingMenuController.parseMenuSelection()));
+ }
+
+ // Initializes the recordings routes.
+
+ _initializeRecordingsRoutes() {
+
+ const recordingsController = new RecordingsController({
+ redis: this.redis
+ });
+
+ this._app.use(KoaRoute.get('/recordings', recordingsController.startRecording()));
+ this._app.use(KoaRoute.post('/recordings', recordingsController.saveRecording()));
+ this._app.use(KoaRoute.get('/recordings/:id', recordingsController.getRecording()));
+ }
+
+ // Prints the banner.
+
+ _printBanner() {
+
+ console.log(' >o<');
+ console.log(' /-----\\');
+ console.log(` |ú ù| - Happy to listen on: ${this.port}`);
+ console.log(' | U |');
+ console.log(' \\---/');
+ console.log(' +---------+');
+ console.log(' ~| () |~');
+ console.log(' ~| /\\ | ~');
+ console.log(' ~| \\/ | ~c');
+ console.log(' ^+---------+');
+ console.log(' (o==o==o) ');
+
+ }
+};