From 9ae5ee3ef5634cd438cb1b02b10341f63d0ea4e1 Mon Sep 17 00:00:00 2001 From: Evan <17254809+bigun27@users.noreply.github.com> Date: Mon, 20 Jan 2025 23:15:24 -0500 Subject: [PATCH] First go at it --- .gitea/workflows/build-api-stable.yml | 28 ++++++++ .gitea/workflows/build-api-testing.yml | 25 +++++++ app/Dockerfile | 2 +- app/config.py | 3 + app/docker-compose.yml | 13 ++-- app/requirements.txt | 3 +- app/routes/client.py | 97 ++++++++++++++++++++++++-- app/utils.py | 63 +++++++++++++++++ 8 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 .gitea/workflows/build-api-stable.yml create mode 100644 .gitea/workflows/build-api-testing.yml diff --git a/.gitea/workflows/build-api-stable.yml b/.gitea/workflows/build-api-stable.yml new file mode 100644 index 0000000..42aaa2b --- /dev/null +++ b/.gitea/workflows/build-api-stable.yml @@ -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 + diff --git a/.gitea/workflows/build-api-testing.yml b/.gitea/workflows/build-api-testing.yml new file mode 100644 index 0000000..efc3908 --- /dev/null +++ b/.gitea/workflows/build-api-testing.yml @@ -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 diff --git a/app/Dockerfile b/app/Dockerfile index adcb5b7..4f51dbc 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -8,7 +8,7 @@ ENV PROMETHEUS_MULTIPROC_DIR=/dev/shm ENV PAPERSIZE=letter # Defining working directory and adding source code -WORKDIR /template +WORKDIR /AtmoAssistant COPY . . # Install requirements diff --git a/app/config.py b/app/config.py index 1be1c2c..6203fb2 100644 --- a/app/config.py +++ b/app/config.py @@ -4,6 +4,9 @@ import tempfile env_DEBUG = os.environ.get("DEBUG", "").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)) if not env_SECRET_KEY: env_SECRET_KEY = os.urandom(24) diff --git a/app/docker-compose.yml b/app/docker-compose.yml index 54a4c32..df05a8d 100644 --- a/app/docker-compose.yml +++ b/app/docker-compose.yml @@ -1,15 +1,18 @@ services: - template: - container_name: template - image: git.bigun.dev/evan/template:stable + AtmoAssistant: + container_name: AtmoAssistant + image: git.bigun.dev/evan/AtmoAssistant:stable ports: - 80:5000 # API - 9200:9200 # Prometheus restart: unless-stopped volumes: - /etc/localtime:/etc/localtime - - ./database:/template/instance + - ./database:/AtmoAssistant/instance environment: - 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 + - 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 diff --git a/app/requirements.txt b/app/requirements.txt index 421fbe9..6d1dc93 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,4 +1,5 @@ Flask==3.1.0 flask_sqlalchemy==3.1.1 gunicorn==23.0.0 -prometheus-flask-exporter==0.23.1 \ No newline at end of file +prometheus-flask-exporter==0.23.1 +twilio==9.4.3 \ No newline at end of file diff --git a/app/routes/client.py b/app/routes/client.py index 8646364..374210b 100644 --- a/app/routes/client.py +++ b/app/routes/client.py @@ -1,10 +1,99 @@ from flask import jsonify, request +from twilio.twiml.voice_response import VoiceResponse, Gather -from utils import ( - logger, - validate_data_presence, -) +from utils import logger, validate_data_presence, _get_weather, _get_cords # from config import from . import routes as app 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 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 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"] + + # 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 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"] + + # 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) diff --git a/app/utils.py b/app/utils.py index fa073f7..1162a13 100644 --- a/app/utils.py +++ b/app/utils.py @@ -4,10 +4,12 @@ from urllib import parse import logging import re import typing as t +import requests import models 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): @@ -47,3 +49,64 @@ def validate_data_presence(data: t.Dict[str, t.Any], keys: list[str]) -> bool: if key not in data: return False 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