The code for this service has been moved to this GitHub repository.
Last active
December 4, 2018 18:07
-
-
Save alexdlaird/b3daa5be9df02c9de983362b8094515b to your computer and use it in GitHub Desktop.
Texting service to receive current air quality conditions and maps, powered by AirNow, Twilio, and AWS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* This Twilio Function is no longer recommend as it does not put a cache in front of requests to | |
* AirNow's servers, and we want to minimize the load on their servers. However, it is still presented | |
* for educational purposes. | |
* | |
* To use this code in a Twilio Function, define a Twilio Runtime environment variable of | |
* `AIRNOW_API_KEY`, then ensure the dependency of `request` for version `2.88.0` is present. | |
* | |
* Create a phone number and set it up. Under "Messaging", select "Function" for when | |
* "A Message Comes In", and point it to the Function created above. | |
* | |
* You can now text a zip code (optionally along with the word "map") to the Twilio number to get | |
* Air Quality Index reports. | |
*/ | |
const request = require('request'); | |
const AQI_MESSAGES = { | |
'Good': 'Air quality is considered satisfactory, and air pollution poses little or no risk.', | |
'Moderate': 'Air quality is acceptable; however, for some pollutants there may be a moderate health concern for a very small number of people. For example, people who are unusually sensitive to ozone may experience respiratory symptoms.', | |
'Unhealthy for Sensitive Groups': 'Although general public is not likely to be affected at this AQI range, people with lung disease, older adults and children are at a greater risk from exposure to ozone, whereas persons with heart and lung disease, older adults and children are at greater risk from the presence of particles in the air.', | |
'Unhealthy': 'Everyone may begin to experience some adverse health effects, and members of the sensitive groups may experience more serious effects.', | |
'Very Unhealthy': 'This would trigger a health alert signifying that everyone may experience more serious health effects.', | |
'Hazardous': 'This would trigger a health warnings of emergency conditions. The entire population is more likely to be affected.' | |
} | |
// A static cache of some of the most affected areas. Yes, this is ugly :)! Ideally, a dynamically generated | |
// cache would be much preferred, so porting this to AWS Lambda may be a better solution, but this is a quick fix | |
// to reducing AirNow's load impact for now. | |
const REPORT_AREA_TO_REGION_IMG = { | |
// SF | |
'Oakland': 'cur_aqi_sanfrancisco_ca.jpg', | |
'San Francisco': 'cur_aqi_sanfrancisco_ca.jpg', | |
'San Jose': 'cur_aqi_sanfrancisco_ca.jpg', | |
'Concord': 'cur_aqi_sanfrancisco_ca.jpg', | |
'Redwood City': 'cur_aqi_sanfrancisco_ca.jpg', | |
'Fremont': 'cur_aqi_sanfrancisco_ca.jpg', | |
'San Rafael': 'cur_aqi_sanfrancisco_ca.jpg', | |
'Fairfield': 'cur_aqi_sanfrancisco_ca.jpg', | |
'Livermore': 'cur_aqi_sanfrancisco_ca.jpg', | |
'Napa': 'cur_aqi_sanfrancisco_ca.jpg', | |
'Santa Rosa': 'cur_aqi_sanfrancisco_ca.jpg', | |
// Sacramento | |
'Sacramento': 'cur_aqi_sacramento_ca.jpg', | |
'Auburn': 'cur_aqi_sacramento_ca.jpg', | |
'Placerville': 'cur_aqi_sacramento_ca.jpg', | |
// LA | |
'Los Angeles': 'cur_aqi_losangeles_ca.jpg', | |
'Anaheim': 'cur_aqi_losangeles_ca.jpg', | |
'Malibu': 'cur_aqi_losangeles_ca.jpg', | |
'Santa Ana': 'cur_aqi_losangeles_ca.jpg', | |
'Irvine': 'cur_aqi_losangeles_ca.jpg', | |
'San Bernardino': 'cur_aqi_losangeles_ca.jpg', | |
'Chino': 'cur_aqi_losangeles_ca.jpg', | |
'Riverside': 'cur_aqi_losangeles_ca.jpg', | |
'Perris Vly': 'cur_aqi_losangeles_ca.jpg', | |
'E San Fernando Vly': 'cur_aqi_losangeles_ca.jpg', | |
'W San Fernando Vly': 'cur_aqi_losangeles_ca.jpg', | |
'Santa Clarita Vly': 'cur_aqi_losangeles_ca.jpg', | |
'NW Coastal LA': 'cur_aqi_losangeles_ca.jpg', | |
'SW Coastal LA': 'cur_aqi_losangeles_ca.jpg', | |
'South Coastal LA': 'cur_aqi_losangeles_ca.jpg', | |
'Central LA CO': 'cur_aqi_losangeles_ca.jpg', | |
// Ventura | |
'Oxnard': 'cur_aqi_ventura.jpg', | |
'Simi Valley': 'cur_aqi_ventura.jpg', | |
'Santa Paula': 'cur_aqi_ventura.jpg', | |
'Piru': 'cur_aqi_ventura.jpg', | |
'Thousand Oaks': 'cur_aqi_ventura.jpg', | |
// CA/NV | |
'Woodland': 'cur_aqi_ca_nv.jpg', | |
'Yuba City/Marysville': 'cur_aqi_ca_nv.jpg', | |
'Chico': 'cur_aqi_ca_nv.jpg', | |
'Stockton': 'cur_aqi_ca_nv.jpg', | |
'Sloughhouse': 'cur_aqi_ca_nv.jpg', | |
'Santa Maria': 'cur_aqi_ca_nv.jpg', | |
'Santa Barbara': 'cur_aqi_ca_nv.jpg', | |
'San Luis Obispo': 'cur_aqi_ca_nv.jpg', | |
'Modesto': 'cur_aqi_ca_nv.jpg', | |
'Davis': 'cur_aqi_ca_nv.jpg', | |
'Merced': 'cur_aqi_ca_nv.jpg', | |
'Rio Vista': 'cur_aqi_ca_nv.jpg', | |
'Sequoia/Kings Canyon National Parks': 'cur_aqi_ca_nv.jpg', | |
'Vacaville': 'cur_aqi_ca_nv.jpg', | |
'Goleta': 'cur_aqi_ca_nv.jpg', | |
'Fresno': 'cur_aqi_ca_nv.jpg' | |
} | |
exports.handler = function(context, event, callback) { | |
let twiml = new Twilio.twiml.MessagingResponse(); | |
let zipCode = event.Body.toLowerCase(); | |
const includeMap = zipCode.includes("map"); | |
// Check to ensure the message is valid (a zip code with an optional "map" at the end) | |
if (!/^\d+(( )?map)?$/i.test(zipCode)) { | |
twiml.message('Send us a zip code and we\'ll reply with the area\'s Air Quality Index (AQI). Put "map" at the end and we\'ll include the regional map too.'); | |
callback(null, twiml); | |
return; | |
} | |
if (includeMap) { | |
zipCode = zipCode.split("map")[0].trim(); | |
} | |
console.log('zipCode:', zipCode); | |
console.log('includeMap:', includeMap); | |
// Get the current conditions from the AirNow API | |
const airNowApiUrl = 'http://www.airnowapi.org/aq/observation/zipCode/current/?format=application/json&zipCode=' + zipCode + '&distance=25&API_KEY=' + context.AIRNOW_API_KEY; | |
request(airNowApiUrl, {timeout: 2500}, function (error, response, body) { | |
console.log('error:', error); | |
console.log('statusCode:', response && response.statusCode); | |
console.log('body:', body); | |
// AirNow's API is significantly more stanble than their site, but it still fails | |
// often due to overload (especially in fire season), so protect against that | |
if (error !== null) { | |
if (error.code === 'ETIMEDOUT' || error.code === 'ESOCKETTIMEDOUT') { | |
console.log('AirNow seems overloaded, the request for any data failed'); | |
twiml.message('Oops, something went wrong. AirNow seems overloaded at this time.'); | |
} else { | |
console.log('An unknown AirNow error occurred'); | |
twiml.message('Oops, something went wrong. AirNow may be unavailable at this time.'); | |
} | |
callback(null, twiml); | |
return; | |
} | |
const data = JSON.parse(body); | |
console.log('data:', data); | |
// Identify the PM2.5 (if present) parameter, which contains the AQI, in the response | |
let aqiParam; | |
for (let i = 0; i < data.length; ++i) { | |
console.log(data[i]); | |
if (data[i]['ParameterName'] == 'PM2.5') { | |
aqiParam = data[i]; | |
break; | |
} | |
} | |
if (typeof aqiParam !== 'undefined') { | |
console.log('reportArea:', aqiParam['ReportingArea']) | |
// Clean up the time format | |
const suffix = (aqiParam['HourObserved'] >= 12) ? 'PM' : 'AM'; | |
let time = (aqiParam['HourObserved'] > 12) ? aqiParam['HourObserved'] - 12 : aqiParam['HourObserved']; | |
time = ((time == '00') ? 12 : time) + suffix + ' ' + aqiParam['LocalTimeZone']; | |
const message = twiml.message(); | |
message.body(aqiParam['Category']['Name'] + ' AQI of ' + aqiParam['AQI'] + ' for ' + aqiParam['ReportingArea'] + ' at ' + time + '. ' + AQI_MESSAGES[aqiParam['Category']['Name']] + '\nSource: AirNow'); | |
// If the user requested a map, build the media URL to send back with the Twilm response | |
if (includeMap) { | |
const mapUrlPrefix = 'https://files.airnowtech.org/airnow/today/'; | |
// As much as possible, we're trying to ease the load on AirNow's servers, so | |
// check if this region is in our static cache | |
if (aqiParam['ReportingArea'] in REPORT_AREA_TO_REGION_IMG) { | |
let mapUrl = mapUrlPrefix + REPORT_AREA_TO_REGION_IMG[aqiParam['ReportingArea']]; | |
mapUrl = mapUrl.substr(0, mapUrl.indexOf('.jpg') + 4); | |
console.log('mapUrl (cached):', mapUrl); | |
message.media(mapUrl); | |
callback(null, twiml); | |
} else { | |
const localAirNowUrl = 'https://airnow.gov/index.cfm?action=airnow.local_city&zipcode=' + zipCode + '&submit=Go'; | |
request(localAirNowUrl, {timeout: 2000}, function (error, response, body) { | |
console.log('mapError:', error); | |
console.log('mapStatusCode:', response && response.statusCode); | |
if (error !== null) { | |
console.log('AirNow map rendering seems to have errored'); | |
callback(null, twiml); | |
return; | |
} | |
let mapUrl = body.substr(body.indexOf(mapUrlPrefix)); | |
mapUrl = mapUrl.substr(0, mapUrl.indexOf('.jpg') + 4); | |
console.log('mapUrl:', mapUrl); | |
message.media(mapUrl); | |
callback(null, twiml); | |
}); | |
} | |
} else { | |
callback(null, twiml); | |
} | |
} else { | |
console.log('AirNow gave us an unexpected response, probably unavailable for zip code ' + zipCode); | |
twiml.message('Oops, something went wrong. AirNow may be unavailable for this zip code.'); | |
callback(null, twiml); | |
} | |
}); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment