Initial commit

This commit is contained in:
Will Bradley 2023-02-11 17:29:12 -08:00
commit cdc58cd438
13 changed files with 3793 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
dist/*
node_modules/*
parser.ts

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

22
README.md Normal file
View File

@ -0,0 +1,22 @@
# Twilio Time & Temperature in Typescript
Make your own Time and Temperature phone number!
A rewrite of https://gitlab.com/zyphlar/twilio-time-and-temperature in Typescript.
## Prerequisites
- A Twilio account
- NodeJS 18.x
- Ideally, AWS Lambda for hosting, but not required
## Configuration/Running/Building
- Copy `util/parser.orig.ts` to `util/parser.ts` and edit it to match your desired Twilio phone numbers and locales.
- For easy dev, run `npm run dev-build-start`.
- Access time-temp.php to ensure it's working (should generate Twilio-compatible XML aka TwiML.)
## Deployment
- To deploy to AWS Lambda, run `npm run build` and upload dist/index.zip to a new Lambda function.
- Set your Twilio phone number(s) to send an HTTP POST WebHook to the Lambda function.
- Call the number to test!

3213
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "time-temp-typescript",
"version": "0.0.1",
"description": "Time and Temperature phone service for Typescript and AWS Lambda",
"author": "zyphlar",
"license": "MIT",
"scripts": {
"prebuild": "rm -rf dist",
"build": "esbuild src/index.ts --bundle --loader:.pug=file --sourcemap --platform=node --target=es2020 --outfile=dist/index.js",
"postbuild": "cd dist && zip -r index.zip index.js* *.pug",
"build-dev": "esbuild src/server.ts --bundle --loader:.pug=file --sourcemap --platform=node --target=es2020 --outfile=dist/server.js",
"serve": "cd dist && node server.js",
"start": "npm run serve",
"dev-build-start": "npm run build-dev && npm run serve",
"local-lambda": "cp util/test-lambda-event.json dist/event.json && cd dist && node -e \"console.log(require('./index').handler(require('./event.json')));\""
},
"dependencies": {
"aws-lambda": "^1.0.7",
"body-parser": "^1.20.1",
"dwml-to-json": "^0.1.0",
"errorhandler": "^1.5.1",
"express": "^4.18.2",
"moment": "^2.29.4",
"moment-timezone": "^0.5.40",
"pug": "^3.0.2",
"xml-js": "^1.6.11"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.110",
"@types/errorhandler": "^1.5.0",
"@types/express": "^4.17.17",
"@types/moment": "^2.13.0",
"@types/moment-timezone": "^0.5.30",
"@types/pug": "^2.0.6",
"@types/xml-js": "^1.0.0",
"esbuild": "^0.17.7"
}
}

20
src/app.ts Normal file
View File

@ -0,0 +1,20 @@
import express from "express";
import path from "path";
import bodyParser from "body-parser";
import * as homeController from "./controllers/home";
//import * as apiController from "./controllers/api";
const app = express();
app.set("port", process.env.PORT || 3000);
// app.set("views", path.join(__dirname, "../views"));
// app.set("view engine", "pug");
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.get("/", homeController.index);
app.post("/", homeController.index);
//app.get("/api", apiController.getApi);
export default app;

158
src/controllers/home.ts Normal file
View File

@ -0,0 +1,158 @@
import { Request, Response } from "express";
import pug from 'pug';
import { getCompassDirection, getApiConfigs } from "util/parser";
import pugWeather from 'views/weather.pug'
var moment = require('moment-timezone');
const convert = require("xml-js");
var dwmlParser = require('dwml-to-json');
// $postDigits = &$_POST['Digits'];
// $from = &$_POST['From'];
// $to = &$_POST['To'];
// $city = &$_GET['city'];
// if (array_key_exists('Digits',$_GET) || array_key_exists('From',$_GET) || array_key_exists('To',$_GET)) {
// $postDigits = &$_GET['Digits'];
// $from = &$_GET['From'];
// $to = &$_GET['To'];
// }
/**
* Home page.
* @route GET /
*/
export const index = async (req: Request, res: Response) => {
console.error("Request: ", req.params, req.query);
var params;
if (req.params.length > 0) {
params = req.params;
} else {
params = req.query;
}
res.set('Content-Type', 'application/xml');
const template = pug.compileFile(pugWeather);
res.send(template(await renderTimeTemp(params)));
};
/**
* Home page via lambda
*/
export const lambda = async (queryParams) => {
console.error("Params: ", queryParams);
const template = pug.compileFile(pugWeather);
return template(await renderTimeTemp(queryParams));
};
/******** Begin Weather Parse ***********/
interface Location {
region: string;
latitude: string;
longitude: string;
elevation: string;
wfo: string;
timezone: string;
areaDescription: string;
radar: string;
zone: string;
county: string;
firezone: string;
metar: string;
}
interface Time {
layoutKey: string;
startPeriodName: string[];
startValidTime: string[];
tempLabel: string[];
}
interface Data {
temperature: string[];
pop: (string | null)[];
weather: string[];
iconLink: string[];
hazard: any[];
hazardUrl: any[];
text: string[];
}
interface CurrentObservation {
id: string;
name: string;
elev: string;
latitude: string;
longitude: string;
Date: string;
Temp: string;
Dewp: string;
Relh: string;
Winds: string;
Windd: string;
Gust: string;
Weather: string;
Weatherimage: string;
Visibility: string;
Altimeter: string;
SLP: string;
timezone: string;
state: string;
WindChill: string;
}
interface WeatherData {
operationalMode: string;
srsName: string;
creationDate: string;
creationDateLocal: string;
productionCenter: string;
credit: string;
moreInformation: string;
location: Location;
time: Time;
data: Data;
currentobservation: CurrentObservationObj;
}
async function renderTimeTemp(params:Array<string,string>) {
// console.error("Got: ",params);
var apiConfigs:ApiConfigs = getApiConfigs(params);
// console.error("Now: ", apiConfigs);
var responseCopy:any;
const weatherData:WeatherData = await fetch(
apiConfigs.weather_url,
{
headers: new Headers({
"Accept" : "application/json",
"Content-Type" : "application/json",
"User-Agent" : "time-temp-typescript"
})
}
)
.then(res => { responseCopy = res.clone(); return res.json()})
.catch(error => {
console.error("Fetch error", error, responseCopy);
});
// console.log(weatherData);
return {
data: weatherData,
getCompassDirection: getCompassDirection,
now: moment().tz(apiConfigs.tz).format('h:mm:ss A, MMMM D, YYYY'),
voiceA: "Polly.Matthew-Neural",
voiceB: "Polly.Joanna-Neural",
apiConfigs: apiConfigs,
formatObsDate: (d) => moment(d, "DD MMM hh:mm A Z").format('h:mm A'),
convertFtoC: (f) => Math.round((f - 32) / 1.8),
getLastPartOfLocation: (l) => { var match = l.match("^(.*)[-,](.*)$"); if (match && match.length > 1) { return match[2]; } else { return l; } }
};
}

23
src/index.ts Normal file
View File

@ -0,0 +1,23 @@
import { Context, APIGatewayProxyResult, APIGatewayEvent } from 'aws-lambda';
const querystring = require('node:querystring');
import * as homeController from "./controllers/home";
export const handler = async (event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> => {
var params;
console.error("Event", event);
if (event.isBase64Encoded && event.body != "") {
const postVars = Buffer.from(event.body, 'base64').toString('utf8');
params = querystring.parse(postVars);
} else {
params = event.queryStringParameters;
}
return {
statusCode: 200,
headers: {"content-type": "application/xml"},
body: await homeController.lambda(params)
};
};

16
src/server.ts Normal file
View File

@ -0,0 +1,16 @@
// import errorHandler from "errorhandler";
import app from "./app";
/**
* Start Express server.
*/
const server = app.listen(app.get("port"), () => {
console.log(
" App is running at http://localhost:%d in %s mode",
app.get("port"),
app.get("env")
);
console.log(" Press CTRL-C to stop\n");
});
export default server;

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "es6",
"noImplicitAny": true,
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"rootDir": ".",
"paths": {
"*": [
"node_modules/*"
],
"util/*": [ "util/*" ],
}
},
"include": [
"src/**/*",
]
}

87
util/parser.orig.ts Normal file
View File

@ -0,0 +1,87 @@
var moment = require('moment');
export interface ApiConfigs {
weather_url: string;
city_name: string;
tz: string;
}
export var getApiConfigs:ApiConfigs = function(params:Array<string,string>) {
var first_number = "+12225551212";
var second_number = "+13335554343";
var now = moment().format('Y-m-d');
var out:ApiConfigs = {};
if (params['To'] == first_number || params['city'] == "first") {
out.weather_url = "https://forecast.weather.gov/MapClick.php?lat=34.052&lon=-118.243&unit=0&lg=english&FcstType=json";
out.city_name = "Los Angeles";
out.tz = "America/Los_Angeles";
} else if (params['To'] == second_number || params['city'] == "second") {
out.weather_url = "https://forecast.weather.gov/MapClick.php?lat=39.739&lon=-104.984&unit=0&lg=english&FcstType=json";
out.city_name = "Denver";
out.tz = "America/Denver";
} else {
out.weather_url = "https://forecast.weather.gov/MapClick.php?lat=40.7146&lon=-74.0071&unit=0&lg=english&FcstType=json";
out.city_name = "New York";
out.tz = "America/New_York";
}
return out;
}
export var getCompassDirection = function(bearing:number) {
var direction:string;
const tmp = Math.round(bearing / 22.5);
switch(tmp) {
case 1:
direction = "North Northeast";
break;
case 2:
direction = "North East";
break;
case 3:
direction = "East Northeast";
break;
case 4:
direction = "East";
break;
case 5:
direction = "East Southeast";
break;
case 6:
direction = "South East";
break;
case 7:
direction = "South Southeast";
break;
case 8:
direction = "South";
break;
case 9:
direction = "South Southwest";
break;
case 10:
direction = "South West";
break;
case 11:
direction = "West Southwest";
break;
case 12:
direction = "West";
break;
case 13:
direction = "West Northwest";
break;
case 14:
direction = "North West";
break;
case 15:
direction = "North Northwest";
break;
default:
direction = "North";
}
return direction;
}

123
util/test-lambda-event.json Normal file
View File

@ -0,0 +1,123 @@
{
"body": "dG89JTJCMTIyMjU1NTEyMTI",
"resource": "/{proxy+}",
"path": "/path/to/resource",
"httpMethod": "POST",
"isBase64Encoded": true,
"queryStringParameters": {
"foo": "bar"
},
"multiValueQueryStringParameters": {
"foo": [
"bar"
]
},
"pathParameters": {
"proxy": "/path/to/resource"
},
"stageVariables": {
"baz": "qux"
},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, sdch",
"Accept-Language": "en-US,en;q=0.8",
"Cache-Control": "max-age=0",
"CloudFront-Forwarded-Proto": "https",
"CloudFront-Is-Desktop-Viewer": "true",
"CloudFront-Is-Mobile-Viewer": "false",
"CloudFront-Is-SmartTV-Viewer": "false",
"CloudFront-Is-Tablet-Viewer": "false",
"CloudFront-Viewer-Country": "US",
"Host": "1234567890.execute-api.us-east-1.amazonaws.com",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Custom User Agent String",
"Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)",
"X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==",
"X-Forwarded-For": "127.0.0.1, 127.0.0.2",
"X-Forwarded-Port": "443",
"X-Forwarded-Proto": "https"
},
"multiValueHeaders": {
"Accept": [
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"
],
"Accept-Encoding": [
"gzip, deflate, sdch"
],
"Accept-Language": [
"en-US,en;q=0.8"
],
"Cache-Control": [
"max-age=0"
],
"CloudFront-Forwarded-Proto": [
"https"
],
"CloudFront-Is-Desktop-Viewer": [
"true"
],
"CloudFront-Is-Mobile-Viewer": [
"false"
],
"CloudFront-Is-SmartTV-Viewer": [
"false"
],
"CloudFront-Is-Tablet-Viewer": [
"false"
],
"CloudFront-Viewer-Country": [
"US"
],
"Host": [
"0123456789.execute-api.us-east-1.amazonaws.com"
],
"Upgrade-Insecure-Requests": [
"1"
],
"User-Agent": [
"Custom User Agent String"
],
"Via": [
"1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)"
],
"X-Amz-Cf-Id": [
"cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA=="
],
"X-Forwarded-For": [
"127.0.0.1, 127.0.0.2"
],
"X-Forwarded-Port": [
"443"
],
"X-Forwarded-Proto": [
"https"
]
},
"requestContext": {
"accountId": "123456789012",
"resourceId": "123456",
"stage": "prod",
"requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
"requestTime": "09/Apr/2015:12:34:56 +0000",
"requestTimeEpoch": 1428582896000,
"identity": {
"cognitoIdentityPoolId": null,
"accountId": null,
"cognitoIdentityId": null,
"caller": null,
"accessKey": null,
"sourceIp": "127.0.0.1",
"cognitoAuthenticationType": null,
"cognitoAuthenticationProvider": null,
"userArn": null,
"userAgent": "Custom User Agent String",
"user": null
},
"path": "/prod/path/to/resource",
"resourcePath": "/{proxy+}",
"httpMethod": "POST",
"apiId": "1234567890",
"protocol": "HTTP/1.1"
}
}

46
views/weather.pug Normal file
View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
Response
Gather(input="dtmf" timeout="5" numDigits="3")
Say(voice=voiceA)
| Hello. #{now}, was the current time when this call began.
Say(voice=voiceB)
| #{apiConfigs.city_name} Time and Temperature is a free hobby service, courtesy of your friendly local hackerspace, and the National Weather Service.
Say(voice=voiceA)
| It was #{data.currentobservation.Temp} degrees Fahrenheit or #{convertFtoC(data.currentobservation.Temp)} degrees Celsius at #{getLastPartOfLocation(data.currentobservation.name)} at #{formatObsDate(data.currentobservation.Date)}.
| The relative humidity was #{data.currentobservation.Relh}%.
| The dew point was #{data.currentobservation.Dewp} degrees.
if data.currentobservation.WindChill != "NA"
| The wind chill was #{data.currentobservation.WindChill} degrees.
| The weather was #{data.currentobservation.Weather}.
| Winds were #{data.currentobservation.Winds} miles per hour coming from the #{getCompassDirection(data.currentobservation.Windd)}
if data.currentobservation.Gust != "NA" && data.currentobservation.Gust != 0
| and gusting to #{data.currentobservation.Gust} miles per hour.
else
| .
| The pressure was #{data.currentobservation.SLP} inches of mercury, which is
if data.currentobservation.SLP > 30.4
| very dry.
else if data.currentobservation.SLP > 29.75
| fair.
else if data.currentobservation.SLP > 29.25
| changing.
else if data.currentobservation.SLP > 28.6
| rainy.
else if data.currentobservation.SLP > 0
| stormy.
| The visibility was #{Math.round(data.currentobservation.Visibility)} miles.
Say(voice=voiceB)
| Please press any key to hear this message again.
// press two to hear the news, or press nine to hear a poem.
Say(voice=voiceB)
| Thank you for calling.
// Your fortune is: Your lover will never wish to leave you..
Pause(length=10)
Say(voice=voiceB)
| Goodbye!