First go at it
This commit is contained in:
parent
13af3b78eb
commit
9ae5ee3ef5
8 changed files with 223 additions and 11 deletions
28
.gitea/workflows/build-api-stable.yml
Normal file
28
.gitea/workflows/build-api-stable.yml
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
name: Build image - Stable
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-app-stable:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
PACKAGE_SERVER: git.bigun.dev
|
||||||
|
PACKAGE_USER: evan
|
||||||
|
PACKAGE_REPO: evan/AtmoAssistant
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build & Deploy
|
||||||
|
run: |
|
||||||
|
version=$(echo ${{ github.ref }} | awk -F "v" '{print $2}')
|
||||||
|
cd app
|
||||||
|
echo ${{ secrets.PACKAGE_TOKEN }} | docker login -u ${{ env.PACKAGE_USER }} --password-stdin ${{ env.PACKAGE_SERVER }}
|
||||||
|
docker build -t ${{ env.PACKAGE_SERVER }}/${{ env.PACKAGE_REPO }}:stable .
|
||||||
|
docker build -t ${{ env.PACKAGE_SERVER }}/${{ env.PACKAGE_REPO }}:$version .
|
||||||
|
docker push ${{ env.PACKAGE_SERVER }}/${{ env.PACKAGE_REPO }}:stable
|
||||||
|
docker push ${{ env.PACKAGE_SERVER }}/${{ env.PACKAGE_REPO }}:$version
|
||||||
|
|
25
.gitea/workflows/build-api-testing.yml
Normal file
25
.gitea/workflows/build-api-testing.yml
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
name: Build image - Testing
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'api/**'
|
||||||
|
- '.gitea/workflows/build-api-testing.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-api-testing:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
PACKAGE_SERVER: git.bigun.dev
|
||||||
|
PACKAGE_USER: evan
|
||||||
|
PACKAGE_REPO: evan/AtmoAssistant
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build & Deploy
|
||||||
|
run: |
|
||||||
|
cd api
|
||||||
|
echo ${{ secrets.PACKAGE_TOKEN }} | docker login -u ${{ env.PACKAGE_USER }} --password-stdin ${{ env.PACKAGE_SERVER }}
|
||||||
|
docker build -t ${{ env.PACKAGE_SERVER }}/${{ env.PACKAGE_REPO }}:testing .
|
||||||
|
docker push ${{ env.PACKAGE_SERVER }}/${{ env.PACKAGE_REPO }}:testing
|
|
@ -8,7 +8,7 @@ ENV PROMETHEUS_MULTIPROC_DIR=/dev/shm
|
||||||
ENV PAPERSIZE=letter
|
ENV PAPERSIZE=letter
|
||||||
|
|
||||||
# Defining working directory and adding source code
|
# Defining working directory and adding source code
|
||||||
WORKDIR /template
|
WORKDIR /AtmoAssistant
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Install requirements
|
# Install requirements
|
||||||
|
|
|
@ -4,6 +4,9 @@ import tempfile
|
||||||
env_DEBUG = os.environ.get("DEBUG", "").lower() == "true"
|
env_DEBUG = os.environ.get("DEBUG", "").lower() == "true"
|
||||||
|
|
||||||
env_SECURE = os.environ.get("SECURE", "").lower() == "true"
|
env_SECURE = os.environ.get("SECURE", "").lower() == "true"
|
||||||
|
env_OWM_KEY = os.environ.get("OWM_API_KEY", "")
|
||||||
|
env_OWM_UNITS = os.environ.get("OWM_UNITS", "")
|
||||||
|
env_AUTHORIZED_CALLERS = list(os.environ.get("AUTHORIZED_CALLERS", ""))
|
||||||
env_SECRET_KEY = os.environ.get("SECRET_KEY", os.urandom(24))
|
env_SECRET_KEY = os.environ.get("SECRET_KEY", os.urandom(24))
|
||||||
if not env_SECRET_KEY:
|
if not env_SECRET_KEY:
|
||||||
env_SECRET_KEY = os.urandom(24)
|
env_SECRET_KEY = os.urandom(24)
|
||||||
|
|
|
@ -1,15 +1,18 @@
|
||||||
services:
|
services:
|
||||||
template:
|
AtmoAssistant:
|
||||||
container_name: template
|
container_name: AtmoAssistant
|
||||||
image: git.bigun.dev/evan/template:stable
|
image: git.bigun.dev/evan/AtmoAssistant:stable
|
||||||
ports:
|
ports:
|
||||||
- 80:5000 # API
|
- 80:5000 # API
|
||||||
- 9200:9200 # Prometheus
|
- 9200:9200 # Prometheus
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime
|
- /etc/localtime:/etc/localtime
|
||||||
- ./database:/template/instance
|
- ./database:/AtmoAssistant/instance
|
||||||
environment:
|
environment:
|
||||||
- DEBUG=FALSE # Enables debug route and Flask's debug mode
|
- DEBUG=FALSE # Enables debug route and Flask's debug mode
|
||||||
- SECRET_KEY="" # Should be a long random value, randomly regenerated every launch if not specified
|
- SECRET_KEY= # Should be a long random value, randomly regenerated every launch if not specified
|
||||||
- SECURE=FALSE # Set to True when using HTTPS
|
- SECURE=FALSE # Set to True when using HTTPS
|
||||||
|
- OWM_API_KEY= # API key from OpenWeatherMap (One Call 3.0 and Geocoding)
|
||||||
|
- OWM_UNITS= # Units for OpenWeatherMap (Standard, Metric, Imperial)
|
||||||
|
- AUTHORIZED_CALLERS= # Comma seperated list of authorized phone numbers, eg +13365550916,+13365553721
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
Flask==3.1.0
|
Flask==3.1.0
|
||||||
flask_sqlalchemy==3.1.1
|
flask_sqlalchemy==3.1.1
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
prometheus-flask-exporter==0.23.1
|
prometheus-flask-exporter==0.23.1
|
||||||
|
twilio==9.4.3
|
|
@ -1,10 +1,99 @@
|
||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
|
from twilio.twiml.voice_response import VoiceResponse, Gather
|
||||||
|
|
||||||
from utils import (
|
from utils import logger, validate_data_presence, _get_weather, _get_cords
|
||||||
logger,
|
|
||||||
validate_data_presence,
|
|
||||||
)
|
|
||||||
|
|
||||||
# from config import
|
# from config import
|
||||||
from . import routes as app
|
from . import routes as app
|
||||||
from . import by_path_counter
|
from . import by_path_counter
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/voice", methods=["GET", "POST"])
|
||||||
|
@by_path_counter
|
||||||
|
def voice():
|
||||||
|
"""Respond to incoming phone calls with a menu of options"""
|
||||||
|
# Start our TwiML response
|
||||||
|
resp = VoiceResponse()
|
||||||
|
|
||||||
|
from_number = request.form["From"]
|
||||||
|
if from_number not in env_AUTHORIZED_CALLERS:
|
||||||
|
resp.say("You are calling from an unauthorized number. Goodbye.")
|
||||||
|
return str(resp)
|
||||||
|
|
||||||
|
resp.say("Welcome to Atmo Assistant.")
|
||||||
|
# Start our <Gather> verb
|
||||||
|
gather = Gather(num_digits=1, action="/gather")
|
||||||
|
gather.say(
|
||||||
|
"Main menu. Enter one for Asheboro. Two for Lynchburg. Three for Cullowhee. Zero for another zipcode."
|
||||||
|
)
|
||||||
|
resp.append(gather)
|
||||||
|
|
||||||
|
# If the user doesn't select an option, redirect them into a loop
|
||||||
|
resp.redirect("/voice")
|
||||||
|
|
||||||
|
return str(resp)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/gather", methods=["GET", "POST"])
|
||||||
|
@by_path_counter
|
||||||
|
def gather():
|
||||||
|
"""Processes results from the <Gather> prompt in /voice"""
|
||||||
|
# Start our TwiML response
|
||||||
|
resp = VoiceResponse()
|
||||||
|
|
||||||
|
# If Twilio's request to our app included already gathered digits,
|
||||||
|
# process them
|
||||||
|
if "Digits" in request.values:
|
||||||
|
# Get which digit the caller chose
|
||||||
|
choice = request.values["Digits"]
|
||||||
|
|
||||||
|
# <Say> a different message depending on the caller's choice
|
||||||
|
if choice == "1":
|
||||||
|
weather = _get_weather(35.6396, -79.8509)
|
||||||
|
resp.say(weather)
|
||||||
|
elif choice == "2":
|
||||||
|
weather = _get_weather(37.3490, -79.1787)
|
||||||
|
resp.say(weather)
|
||||||
|
elif choice == "3":
|
||||||
|
weather = _get_weather(35.3087, -83.1861)
|
||||||
|
resp.say(weather)
|
||||||
|
elif choice == "0":
|
||||||
|
custom = Gather(num_digits=5, action="/custom")
|
||||||
|
custom.say("Enter a zipcode for its weather.")
|
||||||
|
resp.append(custom)
|
||||||
|
else:
|
||||||
|
# If the caller didn't choose 1 or 2, apologize and ask them again
|
||||||
|
resp.say("Sorry, I don't understand that choice.")
|
||||||
|
|
||||||
|
# If the user didn't choose 1 or 2 (or anything), send them back to /voice
|
||||||
|
resp.redirect("/voice")
|
||||||
|
|
||||||
|
return str(resp)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/custom", methods=["GET", "POST"])
|
||||||
|
@by_path_counter
|
||||||
|
def custom():
|
||||||
|
"""Processes results from the <Gather> prompt in /gather"""
|
||||||
|
# Start our TwiML response
|
||||||
|
resp = VoiceResponse()
|
||||||
|
|
||||||
|
# If Twilio's request to our app included already gathered digits,
|
||||||
|
# process them
|
||||||
|
if "Digits" in request.values:
|
||||||
|
# Get which digit the caller chose
|
||||||
|
choice = request.values["Digits"]
|
||||||
|
|
||||||
|
# <Say> a different message depending on the caller's choice
|
||||||
|
if len(choice) == 5:
|
||||||
|
weather = _get_weather(_get_cords(choice))
|
||||||
|
resp.say(weather)
|
||||||
|
return str(resp)
|
||||||
|
else:
|
||||||
|
# If the caller didn't choose 1 or 2, apologize and ask them again
|
||||||
|
resp.say("Sorry, I don't understand that choice.")
|
||||||
|
|
||||||
|
# If the user didn't choose 1 or 2 (or anything), send them back to /voice
|
||||||
|
resp.redirect("/voice")
|
||||||
|
|
||||||
|
return str(resp)
|
||||||
|
|
63
app/utils.py
63
app/utils.py
|
@ -4,10 +4,12 @@ from urllib import parse
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import typing as t
|
import typing as t
|
||||||
|
import requests
|
||||||
|
|
||||||
import models
|
import models
|
||||||
|
|
||||||
logger = logging.getLogger("gunicorn.error")
|
logger = logging.getLogger("gunicorn.error")
|
||||||
|
weather_template = "The current temperature is {0} and feels like {1}. The high today is {2} with a low of {3}. The current humidity is {4} percent. The summary for today is: {5}."
|
||||||
|
|
||||||
|
|
||||||
def str_none(x):
|
def str_none(x):
|
||||||
|
@ -47,3 +49,64 @@ def validate_data_presence(data: t.Dict[str, t.Any], keys: list[str]) -> bool:
|
||||||
if key not in data:
|
if key not in data:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _get_weather(lat, long):
|
||||||
|
try:
|
||||||
|
if lat is None or long is None:
|
||||||
|
return "An error has occured and the provided zipcode could not be understood."
|
||||||
|
|
||||||
|
weather_json = _get_weather_json(lat, long)
|
||||||
|
if weather_json is None:
|
||||||
|
return "An error has occured and the weather could not be retrieved."
|
||||||
|
|
||||||
|
weather = weather_template.format(
|
||||||
|
weather_json.current.temp,
|
||||||
|
weather_json.current.feels_like,
|
||||||
|
weather_json.daily[0].temp.max,
|
||||||
|
weather_json.daily[0].temp.min,
|
||||||
|
weather_json.current.humidity,
|
||||||
|
weather_json.daily[0].summary,
|
||||||
|
)
|
||||||
|
return weather
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error in _get_weather: " + e)
|
||||||
|
return "An error has occured and the weather could not be retrieved."
|
||||||
|
|
||||||
|
|
||||||
|
def _get_weather_json(lat, long):
|
||||||
|
url = "https://api.openweathermap.org/data/3.0/onecall?lat={0}&lon={1}&exclude=alerts,minutely,hourly&units={2}&appid={3}".format(
|
||||||
|
lat, long, env_OWM_UNITS, env_OWM_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
weather = response.json()
|
||||||
|
return weather
|
||||||
|
else:
|
||||||
|
logger.error("Error in _get_weather_json: " + str(response.status_code))
|
||||||
|
return None
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error("Error in _get_weather_json: " + e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cords(zipcode):
|
||||||
|
url = "http://api.openweathermap.org/geo/1.0/zip?zip={0},US&appid={1}".format(
|
||||||
|
zipcode, env_OWM_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
zipcode = response.json()
|
||||||
|
return zipcode.lat, zipcode.long
|
||||||
|
else:
|
||||||
|
logger.error("Error in _get_cords: " + str(response.status_code))
|
||||||
|
return None, None
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error("Error in _get_cords: " + e)
|
||||||
|
return None, None
|
||||||
|
|
Loading…
Reference in a new issue