commit 55d25b0eb900811f00def9ca1aa9c1c7ea2fa3be Author: Collin Kasbergen Date: Mon Jun 16 01:02:22 2025 +0200 Initial Code First Commit diff --git a/__pycache__/main.cpython-310.pyc b/__pycache__/main.cpython-310.pyc new file mode 100755 index 0000000..6f3c0dd Binary files /dev/null and b/__pycache__/main.cpython-310.pyc differ diff --git a/applicationServerKey b/applicationServerKey new file mode 100755 index 0000000..95ae454 --- /dev/null +++ b/applicationServerKey @@ -0,0 +1 @@ +BNf7z00erjRX3kUBfGZK-TE1Tz6Zypb1I4aDVZkaWO1113xV_L6hDMbe_Evv8ruUiu-E88xPhRNIEL4ayjqcL5o \ No newline at end of file diff --git a/audio/morning_briefing.wav b/audio/morning_briefing.wav new file mode 100755 index 0000000..b08b740 Binary files /dev/null and b/audio/morning_briefing.wav differ diff --git a/cert.conf b/cert.conf new file mode 100755 index 0000000..6780f38 --- /dev/null +++ b/cert.conf @@ -0,0 +1,15 @@ +[req] +default_bits = 2048 +prompt = no +default_md = sha256 +distinguished_name = dn +x509_extensions = v3_req + +[dn] +CN = 192.168.70.11 + +[v3_req] +subjectAltName = @alt_names + +[alt_names] +IP.1 = 192.168.70.11 diff --git a/cert.pem b/cert.pem new file mode 100755 index 0000000..5fcb791 --- /dev/null +++ b/cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC8DCCAdigAwIBAgIUfYhDachzIQ0cUwg6udGQupwWqPswDQYJKoZIhvcNAQEL +BQAwGDEWMBQGA1UEAwwNMTkyLjE2OC43MC4xMTAeFw0yNTA2MTMxNzU5MDlaFw0y +NjA2MTMxNzU5MDlaMBgxFjAUBgNVBAMMDTE5Mi4xNjguNzAuMTEwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcRXDC71Qgz5zEauV5ZwzdJCRGDv226b3s +0iQwVOPGBWRUX6ov7X4Q3cGCPWs4Qs6b7KE794l5gH4+GOKrrMEOSXV9pUjWO7pA +9ON0dr5iW1EFyjJiHmc5xPhuBKCGwZB21FEwAV4Jsbmn9MJPH3/lviWWBZeLgRTF +x1Ov/L/Hvb6Dpz5w84zfdeCw7PBiyZ6I4yvTBP6gTV/gj/TSR925Udnf0xP+a4+y +dhipweVhQuErsoQlwDVs12Hqvjfbv/DhKwGgAM+Az7TFg1zCvnkuBVgMGU/7Xo+u +enb6WDXfQFb1P9Hu5ldP4+ABgih0pQA2VkD3com/DkRYj4IL3bFzAgMBAAGjMjAw +MA8GA1UdEQQIMAaHBMCoRgswHQYDVR0OBBYEFLdoqA0m9lSxiOZT1RlKkxl3Phcr +MA0GCSqGSIb3DQEBCwUAA4IBAQBkQ6NIW16AFAf/+xPAJOP3zL1IDN3PKTW4FIq8 +JWzBLSv0xYQG2VzXqX04fUfFs5UGyTWQuDDlpWhzNvLp12mdjQnu+bnKSo3xZXOQ +NT4euxDba8R2AT1/j9WhfaWJqe/Xo3IWhx2XupyNTOduvX7DnWqmVgsRO+bNeb6g ++uE35ayEyQKEavZjTrnrUpxCp2LqwHTqFLBDelyNJWTVB9fDxpfDFU6ipqkSvoq7 +cJ9RcwK+be9cg9xImrEPkJ7NX3o86aMhJIPNBlDNOen0+WteQJd+cgpM3JsQ1E5F +rKBLWSGgns1sjbUhqZKZPKPxE/LI8HeGrRBaS84H7+/TEafZ +-----END CERTIFICATE----- diff --git a/data/surprises.json b/data/surprises.json new file mode 100755 index 0000000..d4b6d0d --- /dev/null +++ b/data/surprises.json @@ -0,0 +1,109 @@ +{ + "surprises": [ + "Did you know? The first computer virus was created in 1983 and was called 'Elk Cloner.'", + "Fun fact: The first video game, 'Tennis for Two,' was made in 1958 on an oscilloscope.", + "Tech tip: Using a dark mode UI can reduce eye strain and save battery life on OLED screens.", + "Gaming trivia: The longest game of chess ever played lasted 20 hours and 15 minutes!", + "Movie fact: 'Blade Runner' inspired the look of many future cyberpunk stories and films.", + "Design tip: White space is not empty space — it helps create focus and balance in layouts.", + "Programming trivia: The name 'Python' comes from 'Monty Python’s Flying Circus,' not the snake.", + "Entertainment fact: The lightsaber sound effect was created by mixing humming from an old film projector and interference from a TV set.", + "Sci-fi lore: The word 'robot' was first introduced in Karel Čapek’s 1920 play 'R.U.R.'", + "Did you know? The first webcam was used to monitor a coffee pot at Cambridge University.", + "Tech tip: Git was created by Linus Torvalds to help develop the Linux kernel efficiently.", + "Gaming fact: The original 'Doom' popularized 3D graphics in video games in 1993.", + "Movie trivia: 'The Matrix' was inspired by Japanese anime like 'Ghost in the Shell.'", + "Design tip: Using a grid system can improve consistency in UI and graphic designs.", + "Programming fun: The first computer bug was an actual moth stuck in a relay!", + "Entertainment fact: 'Star Wars' stormtroopers' armor was inspired by samurai armor.", + "Sci-fi fact: Isaac Asimov’s Three Laws of Robotics influence many AI ethics discussions today.", + "Did you know? The world’s first website is still online at info.cern.ch.", + "Tech tip: Using meaningful variable names can save debugging time later.", + "Gaming trivia: 'Minecraft' was created by Markus Persson in just six days.", + "Movie fact: The 'Alien' xenomorph design was inspired by insect anatomy.", + "Design tip: Contrast is key for readability—make sure text stands out against backgrounds.", + "Programming fun: 'Hello, World!' is traditionally the first program learned in many languages.", + "Entertainment fact: 'The Big Lebowski' became a cult classic despite poor initial reviews.", + "Sci-fi lore: The phrase 'May the Force be with you' is one of the most quoted movie lines ever.", + "Did you know? The first emoji was created in 1999 by Shigetaka Kurita in Japan.", + "Tech tip: Keyboard shortcuts can drastically improve coding efficiency.", + "Gaming fact: Speedrunning communities can complete games in minutes by exploiting glitches.", + "Movie trivia: The sound of the T-800 endoskeleton in 'Terminator' was created with a human skull and a hammer.", + "Design tip: Consistency in iconography helps users navigate software intuitively.", + "Programming trivia: The first computer programmer was Ada Lovelace in the 1800s.", + "Entertainment fact: 'Fight Club' was adapted from Chuck Palahniuk’s novel of the same name.", + "Sci-fi fact: 'Neuromancer' by William Gibson is credited with popularizing the cyberpunk genre.", + "Did you know? The Linux mascot, Tux the penguin, was chosen by Linus Torvalds himself.", + "Tech tip: Using version control like Git prevents lost code and eases collaboration.", + "Gaming trivia: 'Pac-Man' was originally called 'Pakkuman' in Japan.", + "Movie fact: '2001: A Space Odyssey' influenced visual effects in countless sci-fi movies.", + "Design tip: Use a limited color palette to create a cohesive visual identity.", + "Programming fun: The term 'bug' in software predates computers and was used in hardware.", + "Entertainment fact: 'The Rocky Horror Picture Show' holds the record for longest-running theatrical release.", + "Sci-fi lore: The phrase 'I’ll be back' was improvised by Arnold Schwarzenegger in 'The Terminator.'", + "Did you know? CAPTCHA tests are used to differentiate humans from bots online.", + "Tech tip: Writing modular code improves maintainability and scalability.", + "Gaming fact: 'The Legend of Zelda' was one of the first open-world games.", + "Movie trivia: 'The Fifth Element' costumes were designed by Jean-Paul Gaultier.", + "Design tip: Negative space can form interesting shapes that add meaning to your design.", + "Programming trivia: Java was originally called Oak.", + "Entertainment fact: 'The Princess Bride' is famous for its witty dialogue and quotable lines.", + "Sci-fi fact: The 'Tricorder' device from Star Trek inspired real-world portable diagnostic tools.", + "Did you know? The first computer mouse was made of wood.", + "Tech tip: Automated testing helps catch bugs before deployment.", + "Gaming trivia: The Konami Code (↑↑↓↓←→←→BA) unlocks cheats in many classic games.", + "Movie fact: 'Mad Max: Fury Road' used practical effects instead of CGI for most stunts.", + "Design tip: Typography affects mood—choose fonts that reflect your brand’s personality.", + "Programming fun: The emoji 😄 has a Unicode codepoint of U+1F604.", + "Entertainment fact: 'Donnie Darko' became a cult hit after its DVD release.", + "Sci-fi lore: The ‘Prime Directive’ in Star Trek forbids interference with alien civilizations.", + "Did you know? The first 1GB hard drive weighed over 500 pounds.", + "Tech tip: Refactoring code regularly keeps your project clean and efficient.", + "Gaming fact: 'Half-Life' was praised for integrating story and gameplay seamlessly.", + "Movie trivia: 'Inception' used rotating sets to create zero-gravity effects.", + "Design tip: Use color psychology to evoke emotions in your users.", + "Programming trivia: The first email was sent in 1971 by Ray Tomlinson.", + "Entertainment fact: 'The Shawshank Redemption' initially underperformed but became iconic over time.", + "Sci-fi fact: The movie 'Her' explores themes of AI-human relationships.", + "Did you know? Early video game graphics used only a few pixels per character.", + "Tech tip: Keyboard-driven development can speed up your workflow.", + "Gaming trivia: The famous 'Zelda' chest opening sound is from an old cassette tape.", + "Movie fact: The voice of HAL 9000 in '2001: A Space Odyssey' is Douglas Rain.", + "Design tip: Use alignment to create order and organization in layouts.", + "Programming fun: The Python logo is inspired by the Monty Python comedy troupe.", + "Entertainment fact: 'The Room' is famous for its bizarre dialogue and cult following.", + "Sci-fi lore: 'Dune' features one of the most complex fictional universes ever created.", + "Did you know? The QWERTY keyboard was designed to prevent typewriter jams.", + "Tech tip: Continuous integration automates testing and deployment.", + "Gaming fact: 'Portal' combines puzzle-solving with storytelling in a unique way.", + "Movie trivia: 'Ghostbusters' proton packs were inspired by particle accelerators.", + "Design tip: Responsive design ensures your site looks good on all devices.", + "Programming trivia: The first computer bug was actually a dead moth found in a Harvard Mark II.", + "Entertainment fact: 'Pulp Fiction' changed the narrative style of modern movies.", + "Sci-fi fact: 'Star Wars' lightsabers were inspired by samurai swords.", + "Did you know? The first website was published in 1991 by Tim Berners-Lee.", + "Tech tip: Use linters to catch syntax errors before running code.", + "Gaming trivia: 'Final Fantasy' was named because it was supposed to be the last game by its creator.", + "Movie fact: 'The Lord of the Rings' trilogy was filmed over 8 years.", + "Design tip: Use hierarchy to guide users through your content.", + "Programming fun: The JavaScript language was created in just 10 days.", + "Entertainment fact: 'Back to the Future' used a DeLorean as a time machine.", + "Sci-fi lore: The 'Matrix' movie introduced 'bullet time' visual effects.", + "Did you know? The term 'debugging' was popularized by Grace Hopper.", + "Tech tip: Profiling tools help optimize your program’s performance.", + "Gaming fact: 'The Witcher 3' features over 100 hours of gameplay content.", + "Movie trivia: 'Jurassic Park' was groundbreaking in CGI dinosaur effects.", + "Design tip: Use consistent spacing to create a balanced layout.", + "Programming trivia: The first computer game was 'Spacewar!' created in 1962.", + "Entertainment fact: 'Fight Club' explores themes of identity and consumerism.", + "Sci-fi fact: The 'Star Trek' communicator inspired early mobile phones.", + "Did you know? The first emoji was just 176 pixels.", + "Tech tip: Keeping dependencies updated prevents security vulnerabilities.", + "Gaming trivia: 'Tetris' was originally developed in the Soviet Union.", + "Movie fact: 'The Godfather' features an iconic opening with a wedding scene.", + "Design tip: Minimalism often leads to clearer communication.", + "Programming fun: The C programming language was created in 1972.", + "Entertainment fact: 'Stranger Things' pays homage to 80s sci-fi and horror.", + "Sci-fi lore: The 'Doctor Who' TARDIS is bigger on the inside." + ] +} \ No newline at end of file diff --git a/generate.py b/generate.py new file mode 100755 index 0000000..731850b --- /dev/null +++ b/generate.py @@ -0,0 +1,23 @@ +from cryptography.hazmat.primitives import serialization +import base64 + +def base64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=') + +# Load PEM public key +with open("vapid_public.pem", "rb") as f: + pem_data = f.read() + +public_key = serialization.load_pem_public_key(pem_data) + +# Extract the raw uncompressed public key bytes (0x04 + X + Y) +public_numbers = public_key.public_numbers() +x_bytes = public_numbers.x.to_bytes(32, 'big') +y_bytes = public_numbers.y.to_bytes(32, 'big') +uncompressed_key = b'\x04' + x_bytes + y_bytes + +# Base64url encode the uncompressed key bytes +public_key_b64url = base64url_encode(uncompressed_key) + +print("Base64url-encoded public key (use in frontend):") +print(public_key_b64url) diff --git a/jobs.sqlite b/jobs.sqlite new file mode 100755 index 0000000..53d8d44 Binary files /dev/null and b/jobs.sqlite differ diff --git a/key.pem b/key.pem new file mode 100755 index 0000000..009a8f6 --- /dev/null +++ b/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCcRXDC71Qgz5zE +auV5ZwzdJCRGDv226b3s0iQwVOPGBWRUX6ov7X4Q3cGCPWs4Qs6b7KE794l5gH4+ +GOKrrMEOSXV9pUjWO7pA9ON0dr5iW1EFyjJiHmc5xPhuBKCGwZB21FEwAV4Jsbmn +9MJPH3/lviWWBZeLgRTFx1Ov/L/Hvb6Dpz5w84zfdeCw7PBiyZ6I4yvTBP6gTV/g +j/TSR925Udnf0xP+a4+ydhipweVhQuErsoQlwDVs12Hqvjfbv/DhKwGgAM+Az7TF +g1zCvnkuBVgMGU/7Xo+uenb6WDXfQFb1P9Hu5ldP4+ABgih0pQA2VkD3com/DkRY +j4IL3bFzAgMBAAECggEAEC1uIXgGNQlZGMpaLCm7Xq9NpAUMReHHvBr7DRfUppzL +SKLIqwKCgPt7+2AhV+cunVyvSlZzYGUpbINF//C34aNAKvwv4KG5Q902KMncJrF+ +X0OR2K9+DzBxaW5IMfsa2fpb10Tk98ryxczkSq2fn2iCWpgEmrgHrvvWEYtwTRyR +kVhyaQuHet3xqlgNt5SLDNbLILq5vYX6imenzHIrlqqhgUWPu6I+c5tfebThE8Ch +jDnYdzLKub5XOvFEYW33W+CKhQiRMumkUZmnT89kJVFpqmhaeMVi+MoHPi77haQu +2Xlj3ITtKGCUc1/FW318Di0EFfzK59bhw8akfrKwgQKBgQDHOAswy8162Hep/tn4 +B751eeMeuj2P/yCQwCvDziHb5UL7JoEi/gG0tYAWVwTDbgW7v8zw92bcJir8LDgH +iruMvmjvQJSdVAd53K7AFytCD+15PjODgqQjZFBW7C/FaoJuP6YhJmUUFLEdO4Sr +lP4uja22miaLR7Fh0j4nT5J78wKBgQDIz7s64ypQf5JouAJAiLDXY8rUdWZ/Pdp0 +Sd8ZaQxAxfTdjs+vyqA5vdJA/qzNZ3yQa0JkG33bvJ/e/R+ZZUkcbhO64sSnBCF6 +nNZQNJ0o3Yxyf5AmMjXgXm1dROtEJxkqstY4eYmh969S/a0DZqqg2FTWssRPV5Sr +VYhNa/jUgQKBgCueNK0JYkLsVD7vIrAwmd3Ewxp+Mm5tsKagvLRRVI59ws2UX5/W +t27mclXx1YQoea7izq9Pq8C1xqGR2O6lzGyDYBxKKn/L3xsqW0SJjhoXvKnYGZVI +rEFnfEOb0NXLfSVAYFJPwr/DzAeXeXPL2l88+/TICXpjRw4g5yTaQjtLAoGAeCMP +OGO/ZPltdgjgEAu3AKVLK2AxOd6fWwL23hTip73GiOZvihQHV4QgzZGPgDNxalVB +GvL3kQZ//sAgr3zRCJkyZJRWYjUuclRyQvm3LEOfSsDWVIOlh932P96DgfqCK/Fi +B9duZ9/unA/3+hjp6+CAoRHCMFWNNBOvv+HgtAECgYBLSKqvN6ZG+C7IQ/ywRvM2 ++dufxvAZEeqhBchp/vrmZ0fU4IMNFOVyIkGibkm4LTbDufSCe/TjzOvM01PsE/FL +NTaN+03tkIethDJqE1yOMkd5weL0bN3FSyhzIjiQddWqIZxAglRT3Nzx3He2DKIv +zsEGzlQlJzNuNkwUjk4QOw== +-----END PRIVATE KEY----- diff --git a/main.py b/main.py new file mode 100755 index 0000000..26335cd --- /dev/null +++ b/main.py @@ -0,0 +1,514 @@ +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) \ No newline at end of file diff --git a/private_key.pem b/private_key.pem new file mode 100755 index 0000000..4c9d226 --- /dev/null +++ b/private_key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgmkI00EjnLa7dGWzm +FOVUTxljJH93jixCbPmrzSFVMRChRANCAATX+89NHq40V95FAXxmSvkxNU8+mcqW +9SOGg1WZGljtddd8Vfy+oQzG3vxL7/K7lIrvhPPMT4UTSBC+Gso6nC+a +-----END PRIVATE KEY----- diff --git a/public_key.pem b/public_key.pem new file mode 100755 index 0000000..9e735b9 --- /dev/null +++ b/public_key.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1/vPTR6uNFfeRQF8Zkr5MTVPPpnK +lvUjhoNVmRpY7XXXfFX8vqEMxt78S+/yu5SK74TzzE+FE0gQvhrKOpwvmg== +-----END PUBLIC KEY----- diff --git a/scheduler/__pycache__/cache.cpython-310.pyc b/scheduler/__pycache__/cache.cpython-310.pyc new file mode 100755 index 0000000..ea121fa Binary files /dev/null and b/scheduler/__pycache__/cache.cpython-310.pyc differ diff --git a/scheduler/__pycache__/jobs.cpython-310.pyc b/scheduler/__pycache__/jobs.cpython-310.pyc new file mode 100755 index 0000000..97d6aae Binary files /dev/null and b/scheduler/__pycache__/jobs.cpython-310.pyc differ diff --git a/scheduler/__pycache__/scheduler.cpython-310.pyc b/scheduler/__pycache__/scheduler.cpython-310.pyc new file mode 100755 index 0000000..6e95543 Binary files /dev/null and b/scheduler/__pycache__/scheduler.cpython-310.pyc differ diff --git a/scheduler/cache.py b/scheduler/cache.py new file mode 100755 index 0000000..bec6486 --- /dev/null +++ b/scheduler/cache.py @@ -0,0 +1,3 @@ +from diskcache import Cache + +cache = Cache(".cache") # Creates a folder named 'cache' diff --git a/scheduler/jobs.py b/scheduler/jobs.py new file mode 100755 index 0000000..1b1ef8a --- /dev/null +++ b/scheduler/jobs.py @@ -0,0 +1,570 @@ +from datetime import datetime, timezone +from dateutil import parser +from pathlib import Path +from .cache import cache +from openai import OpenAI + +import os +import requests +import random +import json + +OPENAI_API_KEY = "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" + +TODOIST_API_TOKEN = "c2233236d19d56128c89ed6b0a9d10a9e7b287f1" + +ACCUWEATHER_API_KEY = "YHeMcr9Aa96Goer8CANIB2E6QIbr5Dp0" +LOCATION_KEY = "251518" + +# Setup OpenAI client +os.environ["OPENAI_API_KEY"] = OPENAI_API_KEY +client = OpenAI() + +def update_quick_insight(): + job_id = "daily_quick_insight" + try: + cache[job_id] = {"status": "running", "started_at": datetime.now().isoformat()} + + # Grab cached news + news_items = cache.get("top_news_data", []) + if not news_items: + raise ValueError("No news data available in cache") + + # Prepare top 10 headlines + titles = [item["title"] for item in news_items if "title" in item] + titles_text = "\n".join(f"- {title}" for title in titles) + + messages = [ + { + "role": "system", + "content": ( + "You are a smart assistant that reads all today's headlines and generates one sharp, short insight. Focus on current trends, tech, business, or social issues. Keep it under 40 words." + ) + }, + { + "role": "user", + "content": f"Here are today's headlines:\n{titles_text}\n\nGive me one smart, timely insight." + } + ] + + # Request insight from ChatGPT + response = client.chat.completions.create( + model="gpt-4", + messages=messages, + temperature=0.7 + ) + + insight = response.choices[0].message.content.strip() + cache.set("daily_quick_insight_data", insight) + + cache[job_id] = { + "status": "completed", + "last_run": datetime.now().isoformat(), + "data": insight, + } + + except Exception as e: + cache[job_id] = { + "status": "failed", + "last_run": datetime.now().isoformat(), + "error": str(e), + } + +def get_relevant_news_titles(): + job_id = "select_relevant_news" + try: + cache[job_id] = {"status": "running", "started_at": datetime.now().isoformat()} + + articles = cache.get("top_news_data", []) + if not articles: + raise ValueError("No articles found in cache") + + titles = [article.get("title", "") for article in articles if article.get("title")] + + if not titles: + raise ValueError("No valid titles extracted from articles") + + prompt = ( + "Here are today's news headlines:\n\n" + + "\n".join(f"- {t}" for t in titles) + + "\n\nBased on my interests (e.g., AI, technology, programming, games, movies, entertainment), " + "please pick the 5 most relevant headlines and respond with ONLY a JSON array of strings. " + "Example format: [\"Title 1\", \"Title 2\", ...]" + ) + + response = client.chat.completions.create( + model="gpt-4", + messages=[ + {"role": "system", "content": "You are a helpful assistant that filters relevant news."}, + {"role": "user", "content": prompt} + ], + temperature=0.7 + ) + + raw_output = response.choices[0].message.content.strip() + + # Try to parse the response as JSON + try: + selected_titles = json.loads(raw_output) + if not isinstance(selected_titles, list): + raise ValueError("Parsed output is not a list.") + except Exception as parse_err: + raise ValueError(f"Failed to parse response as JSON: {parse_err}\nResponse: {raw_output}") + + cache.set("select_relevant_news_data", selected_titles) + + cache[job_id] = { + "status": "completed", + "last_run": datetime.now().isoformat(), + "data": selected_titles + } + + except Exception as e: + cache[job_id] = { + "status": "failed", + "last_run": datetime.now().isoformat(), + "error": str(e) + } + +def update_news(): + job_id = "top_news" + try: + cache[job_id] = {"status": "running", "started_at": datetime.now().isoformat()} + + today = datetime.now().date().isoformat() + + response = requests.get( + "https://newsapi.org/v2/everything", + params={ + "apiKey": "55678d36d7bd45ea849943ba88dcc899", + "language": "en", + "sortBy": "publishedAt", + "pageSize": 100, + "q": "*" # Using a dash to match all articles (NewsAPI requires a `q`) + } + ) + + if response.status_code != 200: + raise Exception(f"NewsAPI error: {response.status_code} - {response.text}") + + data = response.json() + articles = data.get("articles", []) + + if not articles: + raise ValueError("No news articles found for today") + + cache.set("top_news_data", articles) + + cache[job_id] = { + "status": "completed", + "last_run": datetime.now().isoformat(), + "data": articles + } + + except Exception as e: + cache[job_id] = { + "status": "failed", + "last_run": datetime.now().isoformat(), + "error": str(e), + } + +def generate_tts(text, filename): + # Ensure /audio directory exists + output_dir = "audio" + os.makedirs(output_dir, exist_ok=True) + + # Full path to output file + output_path = os.path.join(output_dir, f"{filename}.wav") + + url = "http://192.168.70.5:8880/v1/audio/speech" + headers = { + "accept": "application/json", + "x-raw-response": "test", + "Content-Type": "application/json", + } + payload = { + "model": "kokoro", + "input": text, + "voice": "af_heart", + "response_format": "wav", + "download_format": "wav", + "speed": 1, + "return_download_link": True + } + + r = requests.post(url, headers=headers, json=payload) + if r.status_code == 200: + with open(output_path, "wb") as f: + f.write(r.content) + print(f"TTS audio saved to {output_path}") + return output_path + else: + raise Exception(f"Failed to generate TTS. Status code: {r.status_code}") + +def update_morning_briefing_transcript(): + job_id = "morning_briefing_transcript" + try: + # Mark job as running + cache[job_id] = {"status": "running", "started_at": datetime.now().isoformat()} + + # Load all required data from cache + tasks = cache.get("daily_tasks_data", []) + forecast = cache.get("daily_forecast_data", {}) + dressing_advice = cache.get("daily_dressing_advice_data", "") + + if not tasks or not forecast or not dressing_advice: + raise ValueError("Missing required data in cache") + + # Extract forecast details + date_str = forecast.get("Date", "") + date_obj = datetime.fromisoformat(date_str) + date_formatted = date_obj.strftime("%A, %B %d, %Y") + + temp_min = forecast["Temperature"]["Minimum"]["Value"] + temp_max = forecast["Temperature"]["Maximum"]["Value"] + day_phrase = forecast["Day"]["IconPhrase"] + night_phrase = forecast["Night"]["IconPhrase"] + + # Build input task summary + task_lines = [] + for task in tasks: + due_time = task.get("due", {}).get("datetime") + task_time = "" + if due_time: + try: + dt_obj = datetime.fromisoformat(due_time.replace("Z", "+00:00")) + task_time = dt_obj.strftime("%H:%M") + task_lines.append(f"- At {task_time}, {task['content']}.") + except Exception: + task_lines.append(f"- {task['content']} (time format error).") + else: + task_lines.append(f"- {task['content']} (no specific time).") + tasks_summary = "\n".join(task_lines) + + # Construct the GPT prompt + prompt = ( + f"Today is {date_formatted}.\n\n" + f"Here are the tasks for today:\n{tasks_summary}\n\n" + f"The weather today will be {day_phrase} during the day and {night_phrase} at night. " + f"Temperatures range from {temp_min}°C to {temp_max}°C.\n\n" + f"Clothing advice: {dressing_advice}\n\n" + f"Write a friendly and concise morning briefing script in natural spoken English. " + f"Start with a brief greeting and mention the date. Then summarize the tasks, the weather, and the clothing advice. " + f"Make the tone encouraging and warm, as if you're helping someone start their day." + ) + + # Send to GPT + chat_response = client.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "system", "content": "You are a helpful assistant that creates a morning briefing."}, + {"role": "system", "content": "Your name is Eira and the users name is Collin. Start your briefing off with a variation on 'Hey Eira here'."}, + { + "role": "system", + "content": ( + "You are a helpful assistant that writes a spoken transcript for a daily morning briefing. " + "The transcript must be suitable for text-to-speech. Use complete sentences and natural language. " + "Do not use any special characters or markdown. Avoid line breaks or paragraph breaks—write as a single continuous paragraph." + ) + }, + {"role": "user", "content": prompt} + ] + ) + + transcript = chat_response.choices[0].message.content.strip() + + # Generate TTS Audio File + success = generate_tts(transcript, "morning_briefing") + if not success: + raise RuntimeError("TTS audio generation failed.") + + # Store transcript in cache + cache.set("morning_briefing_transcript_data", transcript) + + # Mark job as completed + cache[job_id] = { + "status": "completed", + "last_run": datetime.now().isoformat(), + "data": transcript, + } + + except Exception as e: + # Mark job as failed + cache[job_id] = { + "status": "failed", + "last_run": datetime.now().isoformat(), + "error": str(e), + } + +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) + +def update_daily_tasks(project_id=None, filter_query="today"): + job_id = "daily_tasks" + try: + # Mark job as running + cache[job_id] = {"status": "running", "started_at": datetime.now().isoformat()} + + + headers = { + "Authorization": f"Bearer {TODOIST_API_TOKEN}" + } + + params = {} + if project_id: + params['project_id'] = project_id + if filter_query: + params['filter'] = filter_query + + # Fetch from Todoist API + response = requests.get("https://api.todoist.com/rest/v2/tasks", headers=headers, params=params) + + if response.status_code != 200: + raise Exception(f"Todoist API error: {response.status_code} - {response.text}") + + data = response.json() + + + # Sort tasks by due datetime (handle tasks without due date by putting them last) + data.sort(key=get_due_datetime) + + # Cache the forecast itself (separately from job status) + cache.set("daily_tasks_data", data) + + # Mark job as completed + cache[job_id] = { + "status": "completed", + "last_run": datetime.now().isoformat(), + "data": data, + } + + except Exception as e: + # Mark job as failed + cache[job_id] = { + "status": "failed", + "last_run": datetime.now().isoformat(), + "error": str(e), + } + + +def update_weather(): + job_id = "daily_weather" + try: + cache[job_id] = {"status": "running", "started_at": datetime.now().isoformat()} + + response = requests.get( + f"http://dataservice.accuweather.com/forecasts/v1/daily/5day/{LOCATION_KEY}", + params={"apikey": ACCUWEATHER_API_KEY, "metric": "true"}, + ) + + if response.status_code != 200: + raise Exception(f"AccuWeather API error: {response.status_code} - {response.text}") + + data = response.json() + + today_str = datetime.now().date().isoformat() + daily_forecasts = data.get("DailyForecasts", []) + + today_forecast = next( + (f for f in daily_forecasts if f.get("Date", "").startswith(today_str)), + None + ) + + if not today_forecast: + raise ValueError(f"No forecast found for today ({today_str})") + + cache.set("daily_forecast_data", today_forecast) + + cache[job_id] = { + "status": "completed", + "last_run": datetime.now().isoformat(), + "data": today_forecast, + } + + except Exception as e: + cache[job_id] = { + "status": "failed", + "last_run": datetime.now().isoformat(), + "error": str(e), + } + +def update_current_weather(): + job_id = "current_weather" + try: + # Mark job as running + cache[job_id] = {"status": "running", "started_at": datetime.now().isoformat()} + + response = requests.get( + f"http://dataservice.accuweather.com/currentconditions/v1/{LOCATION_KEY}", + params={"apikey": ACCUWEATHER_API_KEY, "details": "true"}, + ) + + if response.status_code != 200: + raise Exception(f"AccuWeather API error: {response.status_code} - {response.text}") + + data = response.json() + if isinstance(data, list): + data = data[0] # AccuWeather returns a list + + cache.set("current_weather_data", data) + + # Mark job as completed + cache[job_id] = { + "status": "completed", + "last_run": datetime.now().isoformat(), + "data": data, + } + + except Exception as e: + # Mark job as failed + cache[job_id] = { + "status": "failed", + "last_run": datetime.now().isoformat(), + "error": str(e), + } + +def update_dressing_advice(): + job_id = "daily_dressing_advice" + try: + # Mark job as running + cache[job_id] = {"status": "running", "started_at": datetime.now().isoformat()} + + # Load cached forecast data + forecast = cache.get("daily_forecast_data") + if not forecast: + raise ValueError("No forecast data found in cache.") + + # Extract relevant weather info + temp_min = forecast.get("Temperature", {}).get("Minimum", {}).get("Value") + temp_max = forecast.get("Temperature", {}).get("Maximum", {}).get("Value") + phrase_day = forecast.get("Day", {}).get("IconPhrase", "") + phrase_night = forecast.get("Night", {}).get("IconPhrase", "") + date_str = forecast.get("Date", "") + + # Build prompt for GPT + prompt = ( + f"Today's weather forecast for {date_str}:\n" + f"- Minimum Temperature: {temp_min}°C\n" + f"- Maximum Temperature: {temp_max}°C\n" + f"- Daytime: {phrase_day}\n" + f"- Nighttime: {phrase_night}\n\n" + f"Based on this forecast, what clothing should someone wear today? Provide practical and sensible advice." + ) + + # Send prompt to OpenAI + chat_response = client.chat.completions.create( + model="gpt-4.1", # or "gpt-4o" if available + messages=[ + {"role": "system", "content": "You are a helpful assistant that gives dressing advice based on weather."}, + {"role": "system", "content": "Respond with one paragraph of readable text. No markup or special characters please."}, + {"role": "system", "content": "Don't include actual weather data in your advice."}, + {"role": "user", "content": prompt} + ] + ) + + advice = chat_response.choices[0].message.content.strip() + + # Cache the advice + cache.set("daily_dressing_advice_data", advice) + + # Mark job as completed + cache[job_id] = { + "status": "completed", + "last_run": datetime.now().isoformat(), + "data": advice, + } + + except Exception as e: + # Mark job as failed + cache[job_id] = { + "status": "failed", + "last_run": datetime.now().isoformat(), + "error": str(e), + } + + + except Exception as e: + # Mark job as failed + cache[job_id] = { + "status": "failed", + "last_run": datetime.now().isoformat(), + "error": str(e), + } + + +def update_daily_surprise(): + job_id = "daily_surprise" + try: + cache[job_id] = {"status": "running", "started_at": datetime.now().isoformat()} + + # Adjusted path to project root /data/surprises.json + file_path = Path(__file__).parent.parent / "data" / "surprises.json" + with open(file_path, "r", encoding="utf-8") as f: + surprises_data = json.load(f) + + surprises = surprises_data.get("surprises", []) + if not surprises: + raise Exception("No surprises found in the JSON file.") + + selected = random.choice(surprises) + + cache.set("daily_surprise_data", selected) + + cache[job_id] = { + "status": "completed", + "last_run": datetime.now().isoformat(), + "data": selected, + } + + except Exception as e: + cache[job_id] = { + "status": "failed", + "last_run": datetime.now().isoformat(), + "error": str(e), + } + +def refresh_meme(): + job_id = "daily_meme" + try: + cache[job_id] = { + "status": "running", + "started_at": datetime.now().isoformat(), + } + + headers = {"User-Agent": "EiraAI/1.0"} + response = requests.get("https://www.reddit.com/r/dankmemes/top.json?limit=20&t=day", headers=headers) + + if response.status_code != 200: + raise Exception(f"Reddit API error: {response.status_code} - {response.text}") + + memes = response.json()["data"]["children"] + meme = random.choice(memes)["data"] + meme_data = { + "title": meme["title"], + "image": meme["url"], + "permalink": f"https://reddit.com{meme['permalink']}" + } + + cache.set("daily_meme_data", meme_data) + + cache[job_id] = { + "status": "completed", + "last_run": datetime.now().isoformat(), + "data": meme_data, + } + + except Exception as e: + cache[job_id] = { + "status": "failed", + "last_run": datetime.now().isoformat(), + "error": str(e), + } diff --git a/scheduler/scheduler.py b/scheduler/scheduler.py new file mode 100755 index 0000000..8754c4e --- /dev/null +++ b/scheduler/scheduler.py @@ -0,0 +1,144 @@ +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger +from .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 +import asyncio +import atexit + +from datetime import datetime +from .cache import cache + +scheduler = AsyncIOScheduler() + +def get_jobs_info(): + jobs_info = [] + for job in scheduler.get_jobs(): + job_data = cache.get(job.id, {}) + jobs_info.append({ + "id": job.id, + "status": job_data.get("status", "unknown"), + "last_run": job_data.get("last_run"), + "next_run": job.next_run_time.isoformat() if job.next_run_time else None, + "error": job_data.get("error"), + "data": job_data.get("data"), + }) + return jobs_info + + +def my_job(): + job_id = "test_job" + try: + # Mark job as running + cache[job_id] = {"status": "running", "started_at": datetime.now().isoformat()} + + # Simulate work + print(f"Job ran at {datetime.now().isoformat()}") + + # Store success result + cache[job_id] = { + "status": "completed", + "last_run": datetime.now().isoformat(), + "data": {"note": "Test job executed successfully."} + } + + except Exception as e: + # Store failure result + cache[job_id] = { + "status": "failed", + "last_run": datetime.now().isoformat(), + "error": str(e) + } + +def start_scheduler(): + scheduler.configure( + jobstores={ + 'default': SQLAlchemyJobStore(url='sqlite:///jobs.sqlite') + }, + job_defaults={ + 'coalesce': False, + 'misfire_grace_time': 300 # 5 min grace period for missed jobs + } + ) + + scheduler.start() + + # Only add jobs if they don't already exist + if not scheduler.get_job("daily_quick_insight"): + scheduler.add_job( + update_quick_insight, + trigger=CronTrigger(hour=11, minute=5), + id="daily_quick_insight", + ) + + if not scheduler.get_job("select_relevant_news"): + scheduler.add_job( + get_relevant_news_titles, + trigger=CronTrigger(hour=11, minute=5), + id="select_relevant_news", + ) + + if not scheduler.get_job("top_news"): + scheduler.add_job( + update_news, + trigger=CronTrigger(hour=11, minute=0), + id="top_news", + ) + + if not scheduler.get_job("morning_briefing_transcript"): + scheduler.add_job( + update_morning_briefing_transcript, + trigger=CronTrigger(hour=6, minute=10), + id="morning_briefing_transcript", + ) + + if not scheduler.get_job("daily_tasks"): + scheduler.add_job( + update_daily_tasks, + trigger=CronTrigger(hour=6, minute=0), + id="daily_tasks", + ) + + if not scheduler.get_job("daily_weather"): + scheduler.add_job( + update_weather, + trigger=CronTrigger(hour=6, minute=0), + id="daily_weather", + ) + + if not scheduler.get_job("current_weather"): + scheduler.add_job( + update_current_weather, + trigger=IntervalTrigger(hours=1), + id="current_weather", + ) + + if not scheduler.get_job("daily_dressing_advice"): + scheduler.add_job( + update_dressing_advice, + trigger=CronTrigger(hour=6, minute=5), + id="daily_dressing_advice", + ) + + if not scheduler.get_job("daily_surprise"): + scheduler.add_job( + update_daily_surprise, + trigger=CronTrigger(hour=6, minute=0), + id="daily_surprise", + ) + + if not scheduler.get_job("daily_meme"): + scheduler.add_job( + refresh_meme, + trigger=CronTrigger(hour=6, minute=0), + id="daily_meme", + ) + + if not scheduler.get_job("test_job"): + scheduler.add_job( + my_job, + trigger=IntervalTrigger(seconds=30), + id="test_job", + ) + + atexit.register(lambda: scheduler.shutdown(wait=False)) diff --git a/test.py b/test.py new file mode 100755 index 0000000..7f69a61 --- /dev/null +++ b/test.py @@ -0,0 +1,10 @@ +from cryptography.hazmat.primitives import serialization + +with open("vapid_private.pem", "rb") as f: + key_data = f.read() + +try: + private_key = serialization.load_pem_private_key(key_data, password=None) + print("PEM loaded successfully!") +except Exception as e: + print("Failed to load PEM:", e)