├── .eslintrc.js ├── .gitignore ├── .importjs.js ├── Makefile ├── README.md ├── data └── zipcodes.csv ├── devServer.js ├── index.js ├── lib ├── apiAiResponseFormatter.js ├── apiClient.js ├── handleLookupShelter.js ├── handleMoreShelters.js └── zipCodes.js ├── locales ├── en.json ├── es.json └── ht.json └── package.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "airbnb-base", 3 | "plugins": [ 4 | "import" 5 | ], 6 | "rules": { 7 | "comma-dangle": 0, 8 | "no-underscore-dangle": 0, 9 | "function-paren-newline": ["multiline", 2], 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .project 4 | -------------------------------------------------------------------------------- /.importjs.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | environments: ['node'] 3 | } 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | serve: 2 | node devServer.js 3 | 4 | deploy: 5 | gcloud beta functions deploy sheltersByZip --stage-bucket irma-response-bot-api --trigger-http 6 | 7 | devdeploy: 8 | gcloud beta functions deploy sheltersByZip --stage-bucket irbd-20170907 --trigger-http 9 | 10 | data/zipcodes.csv: 11 | wget -O data/US.zip http://download.geonames.org/export/zip/US.zip 12 | unzip -d data data/US.zip 13 | rm -f data/readme.txt data/US.zip 14 | mv data/US.txt data/zipcodes.csv 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Irma Response Bot API 2 | 3 | An API for the purposes of creating a chatbot connecting Floridians with shelters and help requests. 4 | 5 | This is a simple API which converts queries for a ZIP code into a lat/long, and then queries the Irma API with that lat/long. 6 | 7 | Demo: 8 | https://us-central1-irma-response-bot.cloudfunctions.net/sheltersByZip?testAction=lookup-shelter&zip=33144&lang=en 9 | 10 | ## Installation (Mac osx with Homebrew) 11 | You will need a version of nodejs installed. 12 | 13 | ```bash 14 | brew cask install google-cloud-sdk 15 | npm install 16 | 17 | gcloud components update && gcloud components install beta 18 | ``` 19 | 20 | ## Deploying the Cloud function 21 | First, create a project in Google Developers Console: 22 | https://console.developers.google.com 23 | 24 | Then, you will need to deploy the cloud function to your project. There is [a quickstart here](https://cloud.google.com/functions/docs/quickstart) and also you might find [the cloud console](https://console.cloud.google.com/functions/list) helpful. 25 | 26 | Log in from the command line: 27 | ```bash 28 | gcloud auth login 29 | gcloud config set project irma-response-bot 30 | ``` 31 | 32 | You will need to make a bucket to store the build artifact: 33 | ```bash 34 | gsutil mb -p [PROJECT_ID] gs://[BUCKET_NAME] 35 | ``` 36 | 37 | Deploy the app: 38 | ```bash 39 | # TODO: this needs to dynamically look up the project ID and bucket name 40 | make deploy 41 | ``` 42 | -------------------------------------------------------------------------------- /devServer.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const app = express() 3 | var bodyParser = require('body-parser'); 4 | 5 | const sheltersByZip = require('./index.js'); 6 | 7 | app.use(bodyParser.json()); // for parsing application/json 8 | 9 | app.get('/', sheltersByZip.sheltersByZip); 10 | app.post('/', sheltersByZip.sheltersByZip); 11 | 12 | app.listen(3000, function () { 13 | console.log('Dev Server listening on http://localhost:3000') 14 | }) 15 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const console = require('console'); 2 | const path = require('path'); 3 | 4 | const i18n = require('i18n'); 5 | 6 | const handleLookupShelter = require('./lib/handleLookupShelter'); 7 | const handleMoreShelters = require('./lib/handleMoreShelters'); 8 | 9 | i18n.configure({ 10 | indent: ' ', 11 | locales: ['en', 'es'], 12 | objectNotation: true, 13 | directory: path.join(__dirname, '/locales'), 14 | }); 15 | 16 | const extractContextParameter = (req, contextName, paramName) => { 17 | // developmet mode: 18 | // 19 | // curl "localhost:3000?context[theContextName][theParamName]=value" 20 | if (req.query && req.query.context && req.query.context[contextName]) { 21 | return req.query.context[contextName][paramName]; 22 | } 23 | 24 | // production mode: 25 | if (!req.body || !req.body.result) { 26 | return null; 27 | } else { 28 | const context = req.body.result.contexts.find(context => context.name == contextName); 29 | if (context) { 30 | return context.parameters[paramName]; 31 | } else { 32 | return null; 33 | } 34 | } 35 | }; 36 | 37 | exports.sheltersByZip = function sheltersByZip(req, res) { 38 | let parsedRequest; 39 | 40 | if (req.query.testAction) { 41 | // development mode 42 | switch (req.query.testAction) { 43 | case 'lookup-shelter': 44 | parsedRequest = { method: handleLookupShelter, params: req.query }; 45 | break; 46 | case 'lookup-shelter-more': 47 | parsedRequest = { method: handleMoreShelters, params: req.query }; 48 | break; 49 | }; 50 | 51 | } else if (req.body && req.body.result) { 52 | // production mode 53 | switch (req.body.result.action) { 54 | case 'lookup-shelter': 55 | parsedRequest = { 56 | method: handleLookupShelter, 57 | params: { 58 | zip: req.body.result.parameters.zip, 59 | lang: req.body.result.parameters.lang || 'en', 60 | shownShelterIds: extractContextParameter(req, 'shownshelterids', 'shownShelterIds') || [], 61 | }, 62 | }; 63 | break; 64 | case 'lookup-shelter-more': 65 | parsedRequest = { 66 | method: handleMoreShelters, 67 | params: { 68 | zip: extractContextParameter(req, 'individualneedsshelter-followup', 'zip'), 69 | lang: extractContextParameter(req, 'individualneedsshelter-followup', 'lang') || 'en', 70 | shownShelterIds: extractContextParameter(req, 'shownshelterids', 'shownShelterIds') || [], 71 | }, 72 | }; 73 | break; 74 | } 75 | } else { 76 | res.status(400) 77 | res.send('Invalid request').end(); 78 | return; 79 | } 80 | 81 | const method = parsedRequest.method; 82 | console.log('params', parsedRequest.params); 83 | method(parsedRequest.params) 84 | .then(({ headers, status, body }) => { 85 | res.set(headers); 86 | res.status(status); 87 | res.send(body).end(); 88 | }) 89 | .catch((error) => { 90 | console.error(error); 91 | res.status(500); 92 | res.send(error.toString()).end(); 93 | }) 94 | }; 95 | -------------------------------------------------------------------------------- /lib/apiAiResponseFormatter.js: -------------------------------------------------------------------------------- 1 | const i18n = require('i18n'); 2 | 3 | const contextOut = (shownShelterIds) => ( 4 | shownShelterIds.length ? 5 | [ 6 | { 7 | name: "shownshelterids", 8 | lifespan: 2, 9 | parameters: { 10 | shownShelterIds 11 | }, 12 | } 13 | ] : [] 14 | ); 15 | 16 | const responseObject = (text, shownShelterIds = []) => ( 17 | { 18 | speech: text, 19 | displayText: text, 20 | contextOut: contextOut(shownShelterIds), 21 | data: {}, 22 | source: 'irma-api', 23 | } 24 | ); 25 | 26 | module.exports = { 27 | invalidZipCode() { 28 | return responseObject(i18n.__('error.zip.not_found')); 29 | }, 30 | 31 | formatShelterMessage(shelter, distanceMi, numMoreWithinTenMi) { 32 | let respText; 33 | let shownShelterIds = []; 34 | 35 | if (shelter) { 36 | // TODO: Give people an option to see another shelter here. 37 | // "Would you like to see another shelter?" 38 | // 39 | // This would give the next result. 40 | const distance = distanceMi < 1 ? i18n.__('less than a mile') : `${distanceMi} miles`; 41 | const more = numMoreWithinTenMi > 0 ? i18n.__('shelter.closest_more', { numMore: numMoreWithinTenMi }) : ''; 42 | 43 | shownShelterIds.push(shelter.id); 44 | 45 | respText = i18n.__('shelter.closest', { 46 | distance, 47 | shelter: i18n.__('shelter.shelter', { 48 | name: shelter.shelter, 49 | address: shelter.address, 50 | phone: shelter.phone 51 | }), 52 | city: shelter.city, 53 | more, 54 | }); 55 | } else { 56 | respText = i18n.__('shelter.none_found'); 57 | } 58 | 59 | respText += i18n.__('shelter.automated_disclaimer'); 60 | 61 | return responseObject(respText, shownShelterIds); 62 | }, 63 | 64 | formatMoreSheltersMessage(shelters, shownShelterIds) { 65 | const filteredShelters = shelters.filter(shelter => ( 66 | shownShelterIds.indexOf(shelter.id) === -1 && 67 | shownShelterIds.indexOf(shelter.id.toString()) === -1 68 | )); 69 | const numSheltersToShow = Math.min(filteredShelters.length, 3); 70 | const numSheltersRemaining = filteredShelters.length - numSheltersToShow; 71 | let more = ''; 72 | 73 | if (numSheltersRemaining > 0) { 74 | more = i18n.__('shelter.closest_more', { more: numSheltersRemaining }); 75 | } else { 76 | return responseObject(i18n.__('shelter.no_more_shelters'), shownShelterIds); 77 | } 78 | 79 | const sheltersToShow = filteredShelters.slice(0, numSheltersToShow); 80 | sheltersToShow.forEach(shelter => shownShelterIds.push(shelter.id)); 81 | 82 | const respText = i18n.__('shelter.closest_next_page', { 83 | num: sheltersToShow.length, 84 | shelters: sheltersToShow.map(shelter => 85 | i18n.__('shelter.shelter', { 86 | name: shelter.shelter, 87 | address: shelter.address, 88 | phone: shelter.phone 89 | }) 90 | ).join("\n\n"), 91 | more, 92 | }); 93 | 94 | return responseObject(respText, shownShelterIds); 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /lib/apiClient.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | 3 | const greatCircle = require('great-circle'); 4 | 5 | const API = { 6 | hostname: 'irma-api.herokuapp.com', 7 | port: 443, 8 | }; 9 | 10 | const shelterDistance = (lat, lon, shelter) => ( 11 | greatCircle.distance( 12 | shelter.latitude, 13 | shelter.longitude, 14 | lat, 15 | lon 16 | ) 17 | ); 18 | 19 | module.exports = { 20 | sheltersByLatLon(lat, lon) { 21 | return new Promise((resolve, reject) => { 22 | const path = `/api/v1/shelters?lat=${lat}&lon=${lon}&accepting=true`; 23 | 24 | https.get(Object.assign({}, API, { path }), (res) => { 25 | res.on('error', (e) => { reject(e); }); 26 | 27 | let respData = ''; 28 | // eslint-disable-next-line 29 | res.on('data', chunk => respData += chunk); 30 | res.on('end', () => { 31 | const { shelters } = JSON.parse(respData); 32 | 33 | // sort shelters so the closer ones come first 34 | shelters.sort((a, b) => shelterDistance(lat, lon, a) < shelterDistance(lat, lon, b) ? -1 : 1); 35 | 36 | if (shelters.length > 0) { 37 | resolve({ shelters }); 38 | } else { 39 | // no nearby shelters 40 | resolve({ shelters: [] }); 41 | } 42 | }); 43 | }); 44 | }); 45 | }, 46 | 47 | closestShelterToLatLon(lat, lon) { 48 | return new Promise((resolve, reject) => { 49 | const path = `/api/v1/shelters?lat=${lat}&lon=${lon}&accepting=true`; 50 | 51 | https.get(Object.assign({}, API, { path }), (res) => { 52 | res.on('error', (e) => { reject(e); }); 53 | 54 | let respData = ''; 55 | // eslint-disable-next-line 56 | res.on('data', chunk => respData += chunk); 57 | res.on('end', () => { 58 | const { shelters } = JSON.parse(respData); 59 | 60 | if (shelters.length > 0) { 61 | const distanceKm = shelterDistance(lat, lon, shelters[0]); 62 | const distanceMi = Math.round(distanceKm / 1.609); 63 | 64 | // 10 mi = 16.09 km 65 | // subtract one so as to not double-count `shelters[0]` 66 | const numMoreWithinTenMi = shelters.filter(shelter => shelterDistance(lat, lon, shelter) < 16.09).length - 1; 67 | 68 | resolve({ shelter: shelters[0], distanceMi, numMoreWithinTenMi }); 69 | } else { 70 | // could not find a nearby shelter 71 | resolve({ shelter: null, distanceMi: null }); 72 | } 73 | }); 74 | }); 75 | }); 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /lib/handleLookupShelter.js: -------------------------------------------------------------------------------- 1 | const console = require('console'); 2 | 3 | const i18n = require('i18n'); 4 | 5 | const apiAiResponseFormatter = require('./apiAiResponseFormatter'); 6 | const apiClient = require('./apiClient'); 7 | const zipCodes = require('./zipCodes'); 8 | 9 | module.exports = ({ zip, lang, shownShelterIds }) => { 10 | return new Promise((resolve, reject) => { 11 | if (!zip) { 12 | resolve({ 13 | headers: { 'Content-Type': 'application/json' }, 14 | status: 400, 15 | body: JSON.stringify( 16 | { error: i18n.__('No zip given, or bad geolocation data.') } 17 | ), 18 | }); 19 | } else if (!(zip in zipCodes)) { 20 | resolve({ 21 | headers: { 'Content-Type': 'application/json' }, 22 | status: 200, 23 | body: JSON.stringify(apiAiResponseFormatter.invalidZipCode()), 24 | }); 25 | } else { 26 | // lookin' good, let's keep going. 27 | console.log('returning results for zip', zip, ', language: ', lang); 28 | 29 | i18n.setLocale(lang); 30 | 31 | const [lat, lon] = zipCodes[zip]; 32 | 33 | apiClient 34 | .closestShelterToLatLon(lat, lon) 35 | .then(({ shelter, distanceMi, numMoreWithinTenMi }) => { 36 | resolve({ 37 | headers: { 'Content-Type': 'application/json' }, 38 | status: 200, 39 | body: JSON.stringify( 40 | apiAiResponseFormatter.formatShelterMessage( 41 | shelter, 42 | distanceMi, 43 | numMoreWithinTenMi, 44 | shownShelterIds 45 | ) 46 | ), 47 | }) 48 | }) 49 | .catch((e) => { 50 | reject(e); 51 | }); 52 | } 53 | }); 54 | }; 55 | -------------------------------------------------------------------------------- /lib/handleMoreShelters.js: -------------------------------------------------------------------------------- 1 | const i18n = require('i18n'); 2 | 3 | const apiAiResponseFormatter = require('./apiAiResponseFormatter'); 4 | const apiClient = require('./apiClient'); 5 | const zipCodes = require('./zipCodes'); 6 | 7 | /* 8 | * Tell the user about other shelters near that ZIP. 9 | * 10 | * Anything in the `shownShelterIds` array should be filtered 11 | * out, because those shelters have already been shown. 12 | */ 13 | module.exports = ({ zip, lang, shownShelterIds }) => { 14 | return new Promise((resolve, reject) => { 15 | if (!zip) { 16 | resolve({ 17 | headers: { 'Content-Type': 'application/json' }, 18 | status: 400, 19 | body: JSON.stringify( 20 | { error: i18n.__('No zip given, or bad geolocation data.') } 21 | ), 22 | }); 23 | } else if (!(zip in zipCodes)) { 24 | resolve({ 25 | headers: { 'Content-Type': 'application/json' }, 26 | status: 200, 27 | body: JSON.stringify(apiAiResponseFormatter.invalidZipCode()), 28 | }); 29 | } else { 30 | const [lat, lon] = zipCodes[zip]; 31 | 32 | apiClient 33 | .sheltersByLatLon(lat, lon) 34 | .then(({ shelters }) => { 35 | resolve({ 36 | headers: { 'Content-Type': 'application/json' }, 37 | status: 200, 38 | body: JSON.stringify( 39 | apiAiResponseFormatter.formatMoreSheltersMessage( 40 | shelters, 41 | shownShelterIds 42 | ) 43 | ), 44 | }) 45 | }) 46 | .catch((e) => { 47 | reject(e); 48 | }); 49 | } 50 | }); 51 | }; 52 | -------------------------------------------------------------------------------- /lib/zipCodes.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const csvparse = require('csv-parse/lib/sync'); 5 | 6 | const ZIP_CODES = fs.readFileSync(path.resolve(__dirname, '../data/zipcodes.csv')); 7 | 8 | const latLongByZIP = {}; 9 | 10 | csvparse(ZIP_CODES, { delimiter: "\t" }).forEach(function(el) { 11 | const zip = el[1]; 12 | const latitude = el[9]; 13 | const longitude = el[10]; 14 | 15 | latLongByZIP[zip] = [latitude, longitude]; 16 | }); 17 | 18 | module.exports = latLongByZIP; 19 | -------------------------------------------------------------------------------- /locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "zip": { 4 | "not_found": "Hm, we aren’t able to find that ZIP code. Please double check that you typed it correctly and try again." 5 | } 6 | }, 7 | "shelter": { 8 | "shelter": "{{name}}\nAddress: {{address}}.\nPhone: {{phone}}", 9 | "closest": "The closest open shelter is {{distance}} away.\n\n{{shelter}}\n\n{{{more}}}", 10 | "closest_more": "There are {{numMore}} more shelters within 10 miles. Reply with \"more\" to see them.\n\n", 11 | "closest_next_page": "Here are the next {{num}} closest shelters.\n\n{{shelters}}\n\n{{{more}}}", 12 | "none_found": "We couldn't find any open shelters close to you. Check IrmaResponse.org for additional information or call 2-1-1 or 3-1-1.", 13 | "no_more_shelters": "We couldn't find any more shelters. Check IrmaResponse.org for additional information or call 2-1-1 or 3-1-1.", 14 | "automated_disclaimer": "This is an automated service. In case of emergency, call 9-1-1. If you need transportation to a shelter, call 1-800-955-5504. For other help and resources, call 2-1-1." 15 | }, 16 | "less than a mile": "less than a mile", 17 | "No zip given, or zip code geolocation data missing.": "No zip given, or zip code geolocation data missing." 18 | } 19 | -------------------------------------------------------------------------------- /locales/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "zip": { 4 | "not_found": "Mmm, no pudimos encontrar ese código postal. Por favor asegúrese de que lo escribió correctamente e intente de nuevo." 5 | } 6 | }, 7 | "shelter": { 8 | "closest": "Hay unos cuantos albergues en su área inmediata.\n\n{{shelter}}.", 9 | "none_found": "No pudimos encontrar ningún refugio abierto cerca de usted. Consulte IrmaResponse.org para obtener más información adicional o llame al 2-1-1 o 3-1-1.", 10 | "automated_disclaimer": "Este servicio es automatizado. Si usted requiere primeros auxilios, llame al 9-1-1.\nSi usted está en una zona de evacuación y no tiene transporte hacia un refugio, llame al 1-800-955-5504.\nSi usted tiene problemas con ansiedad o angustia, enviar un texto al 741-741 día o noche para ponerse en contacto con un/a terapeuta profesional de crisis.", 11 | "closest_more": "shelter.closest_more", 12 | "shelter": "{{name}}\nDirección: {{address}}\n{{phone}}" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /locales/ht.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "zip": { 4 | "not_found": "Hm, we aren’t able to find that ZIP code. Please double check that you typed it correctly and try again." 5 | } 6 | }, 7 | "shelter": { 8 | "closest": "Dakò! Men kèk abri nan zòn ou an: {{name}}.\n\nDirección: {{address}}\n\n{{phone}}.", 9 | "none_found": "We couldn't find any open shelters close to you. Check http://irmaresponse.org for additional information or call 2-1-1 or 3-1-1.", 10 | "automated_disclaimer": "Bonjou, sa a se yon sèvis otomatik ki ka ede w jwenn yon abri ki disponib. Si sa a se yon ijans, pann leve, li rele 9-1-1. Ki kòd postal ou ye?" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "irma-response-bot-api", 3 | "version": "1.0.0", 4 | "description": "This is a simple API which converts queries for a ZIP code into a lat/long, and then queries the Irma API with that lat/long.", 5 | "main": "index.js", 6 | "dependencies": { 7 | "body-parser": "^1.17.2", 8 | "csv": "^1.1.1", 9 | "csv-parse": "^1.2.1", 10 | "great-circle": "^0.2.8", 11 | "i18n": "^0.8.3" 12 | }, 13 | "devDependencies": { 14 | "eslint": "^4.6.1", 15 | "eslint-config-airbnb-base": "^12.0.0", 16 | "eslint-plugin-import": "^2.7.0", 17 | "express": "^4.15.4", 18 | "great-circle": "^0.2.8" 19 | }, 20 | "scripts": { 21 | "test": "echo \"Error: no test specified\" && exit 1" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/tdooner/irma-response-bot-api.git" 26 | }, 27 | "author": "", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/tdooner/irma-response-bot-api/issues" 31 | }, 32 | "homepage": "https://github.com/tdooner/irma-response-bot-api#readme" 33 | } 34 | --------------------------------------------------------------------------------