514 lines
16 KiB
Python
Executable File
514 lines
16 KiB
Python
Executable File
from fastapi import FastAPI, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import JSONResponse, FileResponse
|
||
from fastapi import HTTPException
|
||
from pathlib import Path
|
||
from scheduler.scheduler import start_scheduler, scheduler, get_jobs_info, my_job
|
||
from scheduler.cache import cache
|
||
from scheduler.jobs import update_daily_tasks, update_weather, refresh_meme, update_current_weather, update_daily_surprise, update_dressing_advice, update_morning_briefing_transcript, update_news, get_relevant_news_titles, update_quick_insight
|
||
from webpush import WebPush, WebPushSubscription
|
||
from cryptography.hazmat.primitives import serialization
|
||
import json
|
||
import uvicorn
|
||
import requests
|
||
import random
|
||
import os
|
||
import base64
|
||
|
||
from datetime import datetime, timezone
|
||
from dateutil import parser
|
||
from urllib.parse import unquote
|
||
|
||
from pydantic import BaseModel
|
||
from typing import Optional, Dict
|
||
|
||
app = FastAPI()
|
||
|
||
# Allow frontend requests (adjust origin for production)
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"], # Or ["http://localhost:5173"] for Vite
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
TODOIST_API_TOKEN = "c2233236d19d56128c89ed6b0a9d10a9e7b287f1"
|
||
|
||
ACCUWEATHER_API_KEY = "YHeMcr9Aa96Goer8CANIB2E6QIbr5Dp0"
|
||
LOCATION_KEY = "251518"
|
||
|
||
job_functions = {
|
||
"daily_quick_insight": update_quick_insight,
|
||
"select_relevant_news": get_relevant_news_titles,
|
||
"top_news": update_news,
|
||
"morning_briefing_transcript": update_morning_briefing_transcript,
|
||
"daily_tasks": update_daily_tasks,
|
||
"daily_weather": update_weather,
|
||
"current_weather": update_current_weather,
|
||
"daily_dressing_advice": update_dressing_advice,
|
||
"daily_surprise": update_daily_surprise,
|
||
"daily_meme": refresh_meme,
|
||
"test_job": my_job,
|
||
}
|
||
|
||
wp = WebPush(
|
||
public_key=Path("./public_key.pem"),
|
||
private_key=Path("./private_key.pem"),
|
||
subscriber="admin@mail.com",
|
||
)
|
||
|
||
class ParcelAddRequest(BaseModel):
|
||
nickname: str
|
||
tracking_code: str
|
||
postal_code: str
|
||
|
||
async def get_parcel_cache() -> Dict[str, dict]:
|
||
return cache.get("parcel_data") or {}
|
||
|
||
async def save_parcel_cache(data: Dict[str, dict]):
|
||
cache.set("parcel_data", data)
|
||
|
||
@app.delete("/api/parcels/remove/{tracking_code}")
|
||
async def remove_parcel(tracking_code: str):
|
||
parcel_cache = await get_parcel_cache()
|
||
if tracking_code not in parcel_cache:
|
||
raise HTTPException(status_code=404, detail="Parcel not found")
|
||
del parcel_cache[tracking_code]
|
||
await save_parcel_cache(parcel_cache)
|
||
return {"message": f"Parcel {tracking_code} removed"}
|
||
|
||
@app.post("/api/parcels/add")
|
||
async def add_parcel(parcel: ParcelAddRequest):
|
||
parcel_cache = await get_parcel_cache()
|
||
if parcel.tracking_code in parcel_cache:
|
||
raise HTTPException(status_code=400, detail="Parcel already tracked")
|
||
parcel_cache[parcel.tracking_code] = {
|
||
"nickname": parcel.nickname,
|
||
"postal_code": parcel.postal_code,
|
||
}
|
||
await save_parcel_cache(parcel_cache)
|
||
return {"message": "Parcel added"}
|
||
|
||
@app.get("/api/parcels")
|
||
async def get_parcels():
|
||
parcel_cache = await get_parcel_cache()
|
||
parcels = []
|
||
for tracking_code, info in parcel_cache.items():
|
||
tracking_url = f"https://jouw.postnl.nl/track-and-trace/{tracking_code}-NL-{info['postal_code']}"
|
||
parcels.append({
|
||
"nickname": info["nickname"],
|
||
"tracking_code": tracking_code,
|
||
"postal_code": info["postal_code"],
|
||
"tracking_url": tracking_url,
|
||
})
|
||
return {"parcels": parcels}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
def get_subscriptions() -> list[dict]:
|
||
return cache.get("push_subscriptions") or []
|
||
|
||
def save_subscriptions(subs: list[dict]):
|
||
cache.set("push_subscriptions", subs)
|
||
|
||
def add_subscription(sub_dict: dict):
|
||
current_subs = get_subscriptions()
|
||
if sub_dict not in current_subs:
|
||
current_subs.append(sub_dict)
|
||
save_subscriptions(current_subs)
|
||
print("✅ New subscription added:", sub_dict)
|
||
else:
|
||
print("ℹ️ Subscription already exists:", sub_dict["endpoint"])
|
||
|
||
def remove_subscription(sub_dict: dict):
|
||
current_subs = get_subscriptions()
|
||
updated_subs = [sub for sub in current_subs if sub != sub_dict]
|
||
|
||
if len(updated_subs) != len(current_subs):
|
||
save_subscriptions(updated_subs)
|
||
print("🗑️ Subscription removed:", sub_dict)
|
||
else:
|
||
print("⚠️ Subscription not found:", sub_dict)
|
||
|
||
def subscription_exists(sub: dict) -> bool:
|
||
current_subs = get_subscriptions()
|
||
return any(sub == existing for existing in current_subs)
|
||
|
||
|
||
@app.post("/api/send-all")
|
||
async def send_push_to_all():
|
||
subscriptions = get_subscriptions()
|
||
if not subscriptions:
|
||
return JSONResponse({"message": "No subscribers yet"}, status_code=400)
|
||
|
||
payload = {
|
||
"title": "📬 Your Morning Briefing is Ready!",
|
||
"body": "Click here to read it.",
|
||
"icon": "/logo.png",
|
||
"badge": "/logo.png",
|
||
"data": {"url": "/morning-briefing"}
|
||
}
|
||
|
||
to_remove = []
|
||
for sub_dict in subscriptions:
|
||
try:
|
||
subscription = WebPushSubscription(**sub_dict)
|
||
message = wp.get(message=json.dumps(payload), subscription=subscription)
|
||
resp = requests.post(url=subscription.endpoint, data=message.encrypted, headers=message.headers)
|
||
resp.raise_for_status()
|
||
print("✅ Push sent to", subscription.endpoint)
|
||
except Exception as ex:
|
||
print("❌ Push failed for", subscription.endpoint, ":", ex)
|
||
# Mark for removal if failure indicates invalid subscription (e.g., 404 or 410)
|
||
if hasattr(ex, 'response') and ex.response is not None:
|
||
status_code = ex.response.status_code
|
||
if status_code in (404, 410): # Gone or Not Found = subscription no longer valid
|
||
to_remove.append(sub_dict)
|
||
try:
|
||
print("Details:", ex.response.json())
|
||
except:
|
||
print("Response body:", ex.response.text)
|
||
|
||
if to_remove:
|
||
# Remove invalid subscriptions from cache
|
||
for bad_sub in to_remove:
|
||
if bad_sub in subscriptions:
|
||
subscriptions.remove(bad_sub)
|
||
# Update cache with cleaned list
|
||
cache.set("push_subscriptions", subscriptions)
|
||
print(f"Removed {len(to_remove)} invalid subscriptions from cache.")
|
||
|
||
return {"message": "Push sent to all subscribers (invalid subs removed)"}
|
||
|
||
|
||
@app.post("/api/send-test")
|
||
async def send_test_push(request: Request):
|
||
try:
|
||
sub_dict = await request.json()
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||
|
||
if not isinstance(sub_dict, dict) or "endpoint" not in sub_dict:
|
||
raise HTTPException(status_code=400, detail="Missing or invalid subscription data")
|
||
|
||
try:
|
||
subscription = WebPushSubscription(**sub_dict)
|
||
|
||
payload = {
|
||
"title": "🔔 Test Notification",
|
||
"body": "This is a test push just for you!",
|
||
"icon": "/logo.png",
|
||
"badge": "/logo.png",
|
||
"data": {"url": "/settings"}
|
||
}
|
||
|
||
message = wp.get(message=json.dumps(payload), subscription=subscription)
|
||
resp = requests.post(
|
||
url=subscription.endpoint,
|
||
data=message.encrypted,
|
||
headers=message.headers
|
||
)
|
||
resp.raise_for_status()
|
||
print("✅ Test push sent to", subscription.endpoint)
|
||
return {"message": "Test push sent successfully"}
|
||
|
||
except Exception as ex:
|
||
print("❌ Test push failed:", ex)
|
||
if hasattr(ex, 'response') and ex.response is not None:
|
||
status_code = ex.response.status_code
|
||
# Remove the subscription if invalid
|
||
if status_code in (404, 410):
|
||
subscriptions = get_subscriptions()
|
||
if sub_dict in subscriptions:
|
||
subscriptions.remove(sub_dict)
|
||
cache.set("push_subscriptions", subscriptions)
|
||
print("Removed invalid subscription from cache due to failure.")
|
||
try:
|
||
print("Details:", ex.response.json())
|
||
except:
|
||
print("Response body:", ex.response.text)
|
||
raise HTTPException(status_code=500, detail="Failed to send test push")
|
||
|
||
|
||
@app.post("/api/subscribe")
|
||
async def subscribe(sub: dict):
|
||
if subscription_exists(sub):
|
||
return {"message": "Already subscribed"}
|
||
|
||
add_subscription(sub)
|
||
return {"message": "Subscription stored"}
|
||
|
||
|
||
@app.post("/api/unsubscribe")
|
||
async def unsubscribe(sub: dict) -> JSONResponse:
|
||
#sub_dict = subscription.model_dump()
|
||
remove_subscription(sub)
|
||
return JSONResponse(content={"status": "unsubscribed"})
|
||
|
||
@app.post("/api/check-subscription")
|
||
async def check_subscription_exists(sub: dict):
|
||
if subscription_exists(sub):
|
||
return {"exists": True}
|
||
return {"exists": False}
|
||
|
||
@app.get("/insight/daily")
|
||
def get_daily_quick_insight():
|
||
insight = cache.get("daily_quick_insight_data")
|
||
if insight:
|
||
return {"source": "cache", "data": insight}
|
||
else:
|
||
return {"error": "No insight available yet"}
|
||
|
||
@app.get("/news/relevant")
|
||
def get_relevant_news_articles():
|
||
articles = cache.get("top_news_data")
|
||
selected_titles = cache.get("select_relevant_news_data")
|
||
|
||
if not articles:
|
||
return {"error": "No news data available yet"}
|
||
if not selected_titles:
|
||
return {"error": "No selected relevant news titles available yet"}
|
||
|
||
# Normalize titles for matching
|
||
title_set = set(t.strip() for t in selected_titles)
|
||
|
||
filtered_articles = [
|
||
article for article in articles
|
||
if article.get("title", "").strip() in title_set
|
||
]
|
||
|
||
return {
|
||
"source": "cache",
|
||
"data": filtered_articles
|
||
}
|
||
|
||
@app.post("/audio/{filename}/get")
|
||
async def get_audio_file(filename: str):
|
||
AUDIO_DIR = "audio"
|
||
|
||
# Sanitize filename to prevent path traversal
|
||
safe_filename = os.path.basename(filename).replace("..", "").replace("/", "")
|
||
file_path = os.path.join(AUDIO_DIR, safe_filename)
|
||
|
||
if not os.path.isfile(file_path):
|
||
raise HTTPException(status_code=404, detail="Audio file not found.")
|
||
|
||
return FileResponse(
|
||
path=file_path,
|
||
media_type="audio/wav",
|
||
filename=safe_filename
|
||
)
|
||
|
||
@app.get("/advice/dressing")
|
||
def get_daily_dressing_advice():
|
||
advice = cache.get("daily_dressing_advice_data")
|
||
if advice:
|
||
return {"source": "cache", "data": advice}
|
||
else:
|
||
return {"error": "No advice available yet"}
|
||
|
||
@app.post("/todo/{task_title}/{due_datetime}/complete")
|
||
def complete_todoist_task(task_title: str, due_datetime: str):
|
||
headers = {
|
||
"Authorization": f"Bearer {TODOIST_API_TOKEN}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
# Decode and normalize inputs
|
||
task_title = unquote(task_title)
|
||
due_datetime = unquote(due_datetime).replace("Z", "+00:00")
|
||
|
||
try:
|
||
target_dt = datetime.fromisoformat(due_datetime)
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid datetime format. Use ISO format like 2025-06-13T08:00:00")
|
||
|
||
# Fetch all open tasks to find the matching one
|
||
response = requests.get("https://api.todoist.com/rest/v2/tasks", headers=headers)
|
||
if response.status_code != 200:
|
||
raise HTTPException(status_code=500, detail="Failed to fetch tasks from Todoist")
|
||
|
||
tasks = response.json()
|
||
|
||
# Match task by title and due datetime
|
||
matching_task = next((
|
||
t for t in tasks
|
||
if t["content"] == task_title and
|
||
"due" in t and
|
||
"datetime" in t["due"] and
|
||
datetime.fromisoformat(t["due"]["datetime"].replace("Z", "+00:00")) == target_dt
|
||
), None)
|
||
|
||
if not matching_task:
|
||
raise HTTPException(status_code=404, detail="Task not found")
|
||
|
||
# Mark the task as complete
|
||
task_id = matching_task["id"]
|
||
complete_url = f"https://api.todoist.com/rest/v2/tasks/{task_id}/close"
|
||
complete_response = requests.post(complete_url, headers=headers)
|
||
|
||
if complete_response.status_code in [200, 204]:
|
||
return {"message": "Task marked as complete", "task_id": task_id}
|
||
else:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail={
|
||
"error": "Failed to complete task in Todoist",
|
||
"details": complete_response.json()
|
||
}
|
||
)
|
||
|
||
@app.post("/todo/{task_title}/{due_datetime}/create")
|
||
def create_todoist_task(task_title: str, due_datetime: str):
|
||
headers = {
|
||
"Authorization": f"Bearer {TODOIST_API_TOKEN}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
# Decode the task title in case it has URL-encoded characters
|
||
task_title = unquote(task_title)
|
||
due_datetime = unquote(due_datetime)
|
||
due_datetime = due_datetime.replace("Z", "+00:00")
|
||
|
||
# Validate the datetime format
|
||
try:
|
||
dt = datetime.fromisoformat(due_datetime)
|
||
except ValueError:
|
||
return {"error": "Invalid datetime format. Use ISO format like 2025-06-13T08:00:00"}
|
||
|
||
payload = {
|
||
"content": task_title,
|
||
"due_datetime": due_datetime
|
||
}
|
||
|
||
response = requests.post(
|
||
"https://api.todoist.com/rest/v2/tasks",
|
||
headers=headers,
|
||
json=payload
|
||
)
|
||
|
||
if response.status_code == 200 or response.status_code == 204:
|
||
return {"message": "Task created", "task": payload}
|
||
else:
|
||
return {
|
||
"error": "Failed to create task in Todoist",
|
||
"details": response.json()
|
||
}
|
||
|
||
@app.get("/todo/today")
|
||
def get_tasks_today():
|
||
tasks = cache.get("daily_tasks_data")
|
||
if tasks:
|
||
return {"source": "cache", "data": tasks}
|
||
else:
|
||
return {"error": "No tasks available yet"}
|
||
|
||
def get_due_datetime(task):
|
||
try:
|
||
dt_str = task.get('due', {}).get('datetime')
|
||
if dt_str:
|
||
dt = parser.isoparse(dt_str)
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
return dt
|
||
except Exception:
|
||
pass
|
||
return datetime.max.replace(tzinfo=timezone.utc)
|
||
|
||
@app.get("/todo/all")
|
||
def get_todoist_tasks(project_id=None, filter_query=None):
|
||
"""
|
||
Fetches tasks from Todoist.
|
||
- project_id: optional ID of a specific project.
|
||
- filter_query: Todoist filter string, e.g. "today | overdue".
|
||
"""
|
||
headers = {
|
||
"Authorization": f"Bearer {TODOIST_API_TOKEN}"
|
||
}
|
||
|
||
params = {}
|
||
if project_id:
|
||
params['project_id'] = project_id
|
||
if filter_query:
|
||
params['filter'] = filter_query
|
||
|
||
response = requests.get("https://api.todoist.com/rest/v2/tasks", headers=headers, params=params)
|
||
|
||
data = response.json()
|
||
|
||
data.sort(key=get_due_datetime)
|
||
|
||
if response.status_code == 200:
|
||
return {"source": "todoist", "data": data}
|
||
else:
|
||
return {"error": "Something went wrong trying to contact the Todoist API"}
|
||
|
||
@app.get("/surprise/daily")
|
||
def get_daily_surprise():
|
||
surprise = cache.get("daily_surprise_data")
|
||
if surprise:
|
||
return {"source": "cache", "data": surprise}
|
||
else:
|
||
return {"error": "No surprise available yet"}
|
||
|
||
@app.get("/meme/daily")
|
||
def get_daily_meme():
|
||
meme = cache.get("daily_meme_data")
|
||
if meme:
|
||
return {"source": "cache", "data": meme}
|
||
else:
|
||
return {"source": "none", "data": None}
|
||
|
||
@app.get("/weather/daily")
|
||
def get_daily_forecast():
|
||
forecast = cache.get("daily_forecast_data")
|
||
if forecast:
|
||
return {"source": "cache", "data": forecast}
|
||
else:
|
||
return {"source": "none", "data": None}
|
||
|
||
@app.get("/weather/current")
|
||
def get_current_weather():
|
||
current_data = cache.get("current_weather_data")
|
||
if current_data:
|
||
return {"source": "cache", "data": current_data}
|
||
else:
|
||
return {"source": "none", "data": None}
|
||
|
||
@app.get("/admin/jobs")
|
||
def list_jobs():
|
||
return get_jobs_info()
|
||
|
||
@app.post("/admin/jobs/{job_id}/run")
|
||
def run_job_now(job_id: str):
|
||
job = scheduler.get_job(job_id)
|
||
if not job:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
fn = job_functions.get(job_id)
|
||
if not fn:
|
||
raise HTTPException(status_code=400, detail="No callable associated with this job")
|
||
|
||
try:
|
||
fn() # Directly call the job function
|
||
return {"status": "triggered"}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@app.on_event("startup")
|
||
async def on_startup():
|
||
start_scheduler()
|
||
|
||
@app.on_event("shutdown")
|
||
async def on_shutdown():
|
||
scheduler.shutdown(wait=False)
|
||
|
||
# Hardcoded run block
|
||
if __name__ == "__main__":
|
||
uvicorn.run("main:app", host="0.0.0.0", port=8000, ssl_keyfile="key.pem", ssl_certfile="cert.pem", reload=True) |