commit b56828752888bb8a4d06e26020ece633aa9331a8 Author: mattie726 Date: Tue Aug 25 22:53:07 2026 -0700 Initial commit: Lunch Notifier source from server diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0be1aa4 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,21 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:kyrene.nutrislice.com)", + "Bash(docker info)", + "Bash(docker compose build)", + "Bash(docker compose up -d)", + "Bash(curl -s http://localhost:2323/)", + "Bash(curl -s -X POST http://localhost:2323/send)", + "Bash(docker exec lunch-lunch-notifier-1 python -c \":*)", + "Bash(docker compose build --no-cache)", + "Bash(docker compose up -d --force-recreate)", + "Bash(curl -s http://localhost:2323/api/status)", + "Bash(curl -s -X POST http://localhost:2323/api/send)", + "Bash(ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@192.168.86.33 \"uname -a && docker info 2>&1 | head -3\")", + "Bash(ssh root@192.168.86.33 \"timedatectl 2>/dev/null || date && cat /etc/timezone 2>/dev/null || ls /etc/localtime\")", + "Bash(ssh root@192.168.86.33 \"mkdir -p /mnt/user/appdata/lunch-notifier\")", + "Bash(rsync -av --exclude='__pycache__' --exclude='*.pyc' /Users/matt/Lunch/ root@192.168.86.33:/mnt/user/appdata/lunch-notifier/)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..17cc6bb --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Copy this to .env and fill in your values + +# --- Required --- +# 10-digit phone number (no + or country code) +PHONE_NUMBER=4805405363 + +# Your carrier's SMS email gateway +# Verizon: vtext.com | T-Mobile: tmomail.net | AT&T: txt.att.net | Cricket: sms.cricketwireless.net +CARRIER_GATEWAY=vtext.com + +# Gmail address to send from +GMAIL_USER=you@gmail.com + +# Gmail App Password (myaccount.google.com β†’ Security β†’ App passwords) +# Remove spaces from the 16-char code +GMAIL_APP_PASSWORD=xxxxxxxxxxxxxxxxxxxx + +# --- Optional --- +# Time to send the text each day (24-hour HH:MM, container timezone) +RUN_TIME=07:00 + +# Days to run (comma-separated: mon,tue,wed,thu,fri,sat,sun) +RUN_DAYS=mon,tue,wed,thu,fri + +# HTTP trigger server port +PORT=2323 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6bde21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +data/ +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f5393a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +LABEL org.opencontainers.image.title="🍱 Lunch Notifier" +LABEL org.opencontainers.image.description="Kyrene de la Mariposa lunch menu β†’ SMS" +LABEL org.opencontainers.image.url="http://localhost:2323" +LABEL org.opencontainers.image.icon="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png" +LABEL net.unraid.docker.icon="http://192.168.86.33:2323/icon.png" + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY scraper.py app.py ./ + +# Settings persist here (mount as a volume) +VOLUME ["/data"] + +CMD ["python", "app.py"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..c1313ba --- /dev/null +++ b/app.py @@ -0,0 +1,576 @@ +""" +Lunch Notifier β€” web UI + scheduler combined. +Visit http://localhost:2323 to manage settings. +""" +import json +import logging +import os +import smtplib +import threading +import time +from datetime import date, datetime +from email.message import EmailMessage +from pathlib import Path + +import schedule +from flask import Flask, jsonify, request + +from scraper import get_lunch_menu + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger(__name__) + +app = Flask(__name__) + +SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "/data/settings.json")) +PORT = int(os.environ.get("PORT", "2323")) + +# Env-var defaults (used only when no settings file exists yet) +_DEFAULTS = { + "phone_numbers": [p for p in [os.environ.get("PHONE_NUMBER", "")] if p], + "carrier_gateway": os.environ.get("CARRIER_GATEWAY", "vtext.com"), + "run_time": os.environ.get("RUN_TIME", "07:00"), + "run_days": [d.strip() for d in os.environ.get("RUN_DAYS", "mon,tue,wed,thu,fri").split(",") if d.strip()], + "vacations": [], +} + +_status = {"last_run": None, "last_result": None, "last_message": None} +_lock = threading.Lock() + + +# ── Settings ────────────────────────────────────────────────────────────────── + +def load_settings() -> dict: + if SETTINGS_FILE.exists(): + try: + with open(SETTINGS_FILE) as f: + return json.load(f) + except Exception: + pass + return dict(_DEFAULTS) + + +def save_settings(s: dict) -> None: + SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(SETTINGS_FILE, "w") as f: + json.dump(s, f, indent=2) + + +def is_on_vacation(settings: dict) -> str | None: + today = date.today().isoformat() + for v in settings.get("vacations", []): + if v["start"] <= today <= v["end"]: + return v.get("label", "Vacation") + return None + + +# ── Core job ────────────────────────────────────────────────────────────────── + +def _send_email_sms(message: str, phone: str, gateway: str) -> None: + gmail_user = os.environ["GMAIL_USER"] + gmail_pass = os.environ["GMAIL_APP_PASSWORD"] + to_addr = f"{phone}@{gateway}" + msg = EmailMessage() + msg["From"] = gmail_user + msg["To"] = to_addr + msg.set_content(message) + log.info(f"Sending β†’ {to_addr}") + with smtplib.SMTP("smtp.gmail.com", 587, timeout=15) as smtp: + smtp.starttls() + smtp.login(gmail_user, gmail_pass) + smtp.send_message(msg) + log.info(f"Sent βœ“ β†’ {to_addr}") + + +def run_job() -> None: + global _status + settings = load_settings() + + vac = is_on_vacation(settings) + if vac: + log.info(f"Skipping β€” vacation: {vac}") + _status = {**_status, "last_run": datetime.now().isoformat(), "last_result": f"skipped ({vac})"} + return + + phones = [p.strip() for p in settings.get("phone_numbers", []) if p.strip()] + gateway = settings.get("carrier_gateway", "vtext.com") + + try: + items, date_str = get_lunch_menu() + if items: + body = "\n".join(f"β€’ {i}" for i in items) + message = f"Mariposa Lunch {date_str}:\n{body}" + else: + message = f"No lunch menu posted for {date_str} (may be a break or holiday)." + except Exception as e: + log.exception("Menu fetch failed") + message = f"Error fetching Mariposa lunch menu: {e}" + + if len(message) > 1400: + message = message[:1397] + "..." + + log.info(f"Message:\n{message}") + + errors = [] + for phone in phones: + try: + _send_email_sms(message, phone, gateway) + except Exception as e: + log.error(f"Failed to send to {phone}: {e}") + errors.append(str(e)) + + _status = { + "last_run": datetime.now().isoformat(), + "last_result": "error: " + "; ".join(errors) if errors else "ok", + "last_message": message, + } + + +# ── Scheduler ───────────────────────────────────────────────────────────────── + +DAY_MAP_FNS = { + "mon": lambda: schedule.every().monday, + "tue": lambda: schedule.every().tuesday, + "wed": lambda: schedule.every().wednesday, + "thu": lambda: schedule.every().thursday, + "fri": lambda: schedule.every().friday, + "sat": lambda: schedule.every().saturday, + "sun": lambda: schedule.every().sunday, +} + + +def apply_schedule(settings: dict) -> None: + with _lock: + schedule.clear() + run_time = settings.get("run_time", "07:00") + for day in settings.get("run_days", []): + day = day.strip().lower() + if day in DAY_MAP_FNS: + DAY_MAP_FNS[day]().at(run_time).do(run_job) + log.info(f"Scheduled: {day} at {run_time}") + + +def _schedule_loop() -> None: + while True: + with _lock: + schedule.run_pending() + time.sleep(30) + + +# ── Flask API ───────────────────────────────────────────────────────────────── + +MASCOT_URL = "https://resources.finalsite.net/images/f_png,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png" + +@app.route("/") +def index(): + return HTML, 200, {"Content-Type": "text/html"} + + +@app.route("/icon.png") +def icon(): + import requests as req + r = req.get(MASCOT_URL, timeout=10) + return r.content, 200, {"Content-Type": "image/png", "Cache-Control": "public, max-age=86400"} + + +@app.route("/api/settings", methods=["GET"]) +def api_get_settings(): + return jsonify(load_settings()) + + +@app.route("/api/settings", methods=["POST"]) +def api_post_settings(): + data = request.get_json(force=True) + s = load_settings() + for key in ("phone_numbers", "carrier_gateway", "run_time", "run_days"): + if key in data: + s[key] = data[key] + save_settings(s) + apply_schedule(s) + return jsonify({"ok": True}) + + +@app.route("/api/vacation", methods=["POST"]) +def api_add_vacation(): + data = request.get_json(force=True) + s = load_settings() + s.setdefault("vacations", []).append({ + "id": str(int(time.time() * 1000)), + "start": data["start"], + "end": data["end"], + "label": data.get("label", "Vacation"), + }) + save_settings(s) + return jsonify({"ok": True}) + + +@app.route("/api/vacation/", methods=["DELETE"]) +def api_delete_vacation(vid: str): + s = load_settings() + s["vacations"] = [v for v in s.get("vacations", []) if str(v.get("id")) != vid] + save_settings(s) + return jsonify({"ok": True}) + + +@app.route("/api/send", methods=["POST"]) +def api_send(): + threading.Thread(target=run_job, daemon=True).start() + return jsonify({"ok": True}) + + +@app.route("/api/status") +def api_status(): + s = load_settings() + with _lock: + next_runs = sorted([j.next_run for j in schedule.get_jobs() if j.next_run]) + return jsonify({ + **_status, + "next_run": next_runs[0].isoformat() if next_runs else None, + "on_vacation": is_on_vacation(s), + }) + + +# ── Embedded UI ─────────────────────────────────────────────────────────────── + +HTML = """ + + + + +🍱 Lunch Notifier + + + + +
+
+
+ Chip the Challenger +
+

Lunch Notifier

+

Kyrene de la Mariposa · Automated lunch menu texts

+
+
+
+ + +
+
Status
+
+
Last Run
β€”
+
Result
β€”
+
Next Scheduled
β€”
+
Mode
β€”
+
+ +
+ + +
+
Manual Send
+ +
+ + +
+
Schedule
+
+ + +
+
+ +
+
+
+ +
+ + +
+
Phone Numbers
+
    +
    +
    + + +
    + +
    +
    + + +
    +
    Vacation / No-Send Periods
    +
      +
      +
      + + +
      +
      +
      + + +
      +
      + + +
      +
      + +
      +
      + +
      + + + +""" + + +# ── Entrypoint ──────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + if not SETTINGS_FILE.exists(): + log.info(f"Creating default settings at {SETTINGS_FILE}") + save_settings(_DEFAULTS) + + apply_schedule(load_settings()) + threading.Thread(target=_schedule_loop, daemon=True).start() + log.info(f"Web UI β†’ http://localhost:{PORT}") + app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cfa485d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + lunch-notifier: + container_name: Lunch-Notifier + build: . + restart: unless-stopped + env_file: .env + ports: + - "2323:2323" + volumes: + - ./data:/data + # Uncomment to share host timezone so RUN_TIME matches your local clock: + # - /etc/localtime:/etc/localtime:ro + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ee695e4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask==3.1.0 +requests==2.32.3 +schedule==1.2.2 diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 0000000..2ca9bcf --- /dev/null +++ b/scheduler.py @@ -0,0 +1,115 @@ +""" +Runs scraper.py on a schedule inside the container. + +HTTP trigger server on PORT (default 2323): + GET / β†’ health check / status + POST /send β†’ trigger scraper immediately + +Env vars: + PHONE_NUMBER – destination phone (required) + TEXTBELT_KEY – textbelt key (default: "textbelt" free tier) + RUN_TIME – HH:MM 24h time to fire daily (default: 07:00) + RUN_DAYS – comma-sep days: mon,tue,wed,thu,fri (default) + PORT – HTTP port (default: 2323) +""" +import logging +import os +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +import schedule + +from scraper import main as run_scraper # now a plain sync function + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger(__name__) + +RUN_TIME = os.environ.get("RUN_TIME", "07:00") +RUN_DAYS = [d.strip().lower() for d in os.environ.get("RUN_DAYS", "mon,tue,wed,thu,fri").split(",")] +PORT = int(os.environ.get("PORT", "2323")) + +DAY_MAP = { + "mon": schedule.every().monday, + "tue": schedule.every().tuesday, + "wed": schedule.every().wednesday, + "thu": schedule.every().thursday, + "fri": schedule.every().friday, + "sat": schedule.every().saturday, + "sun": schedule.every().sunday, +} + +_last_run: str = "never" +_last_result: str = "n/a" + + +def job(): + global _last_run, _last_result + log.info("Running scheduled lunch menu check...") + try: + run_scraper() + _last_result = "ok" + except SystemExit: + _last_result = "sms_failed" + except Exception as e: + log.exception("Scraper failed") + _last_result = f"error: {e}" + _last_run = time.strftime("%Y-%m-%d %H:%M:%S") + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + log.info("HTTP %s", fmt % args) + + def _respond(self, code: int, body: str): + data = body.encode() + self.send_response(code) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + if self.path == "/": + status = ( + f"lunch-notifier running\n" + f"schedule : {', '.join(RUN_DAYS)} at {RUN_TIME}\n" + f"phone : {os.environ.get('PHONE_NUMBER', 'NOT SET')}\n" + f"last run : {_last_run}\n" + f"last result: {_last_result}\n\n" + f"POST /send to trigger immediately" + ) + self._respond(200, status) + else: + self._respond(404, "not found") + + def do_POST(self): + if self.path == "/send": + log.info("Manual trigger via HTTP POST /send") + threading.Thread(target=job, daemon=True).start() + self._respond(200, "Triggered β€” check container logs for result.") + else: + self._respond(404, "not found") + + +def start_http_server(): + server = HTTPServer(("0.0.0.0", PORT), Handler) + log.info(f"HTTP trigger server listening on port {PORT}") + server.serve_forever() + + +# ── Schedule setup ──────────────────────────────────────────────────────────── +for day in RUN_DAYS: + if day in DAY_MAP: + DAY_MAP[day].at(RUN_TIME).do(job) + log.info(f"Scheduled: {day} at {RUN_TIME}") + else: + log.warning(f"Unknown day '{day}', skipping.") + +log.info(f"Phone: {os.environ.get('PHONE_NUMBER', 'NOT SET')}") + +threading.Thread(target=start_http_server, daemon=True).start() + +while True: + schedule.run_pending() + time.sleep(30) diff --git a/scraper.py b/scraper.py new file mode 100644 index 0000000..4758a31 --- /dev/null +++ b/scraper.py @@ -0,0 +1,101 @@ +import os +import sys +import logging +import smtplib +from datetime import datetime +from email.message import EmailMessage + +import requests + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger(__name__) + +SCHOOL_SLUG = "kyrene-de-la-mariposa" +MENU_TYPE = "lunch" +API_BASE = "https://kyrene.api.nutrislice.com/menu/api/weeks/school" + + +def get_lunch_menu(date_str: str | None = None) -> tuple[list[str], str]: + if date_str is None: + date_str = datetime.now().strftime("%Y-%m-%d") + + dt = datetime.strptime(date_str, "%Y-%m-%d") + url = f"{API_BASE}/{SCHOOL_SLUG}/menu-type/{MENU_TYPE}/{dt.year}/{dt.month:02d}/{dt.day:02d}/" + log.info(f"Fetching {url}") + + resp = requests.get( + url, + headers={"Accept": "application/json", "User-Agent": "Mozilla/5.0"}, + timeout=20, + ) + resp.raise_for_status() + data = resp.json() + + items = [] + for day in data.get("days", []): + if day.get("date") != date_str: + continue + for entry in day.get("menu_items", []): + if entry.get("is_section_title") or entry.get("is_station_header"): + continue + food = entry.get("food") + if food and food.get("name"): + items.append(food["name"].strip()) + + return items, date_str + + +def send_sms(message: str, phone: str) -> None: + gmail_user = os.environ["GMAIL_USER"] + gmail_pass = os.environ["GMAIL_APP_PASSWORD"] + gateway = os.environ.get("CARRIER_GATEWAY", "vtext.com") + to_addr = f"{phone}@{gateway}" + + msg = EmailMessage() + msg["From"] = gmail_user + msg["To"] = to_addr + msg.set_content(message) + + log.info(f"Sending email-to-SMS β†’ {to_addr}") + with smtplib.SMTP("smtp.gmail.com", 587) as smtp: + smtp.starttls() + smtp.login(gmail_user, gmail_pass) + smtp.send_message(msg) + log.info("Sent.") + + +def main(): + phone = os.environ.get("PHONE_NUMBER", "").strip() + if not phone: + log.error("PHONE_NUMBER environment variable is not set.") + sys.exit(1) + + try: + items, date_str = get_lunch_menu() + if items: + body = "\n".join(f"β€’ {i}" for i in items) + message = f"Mariposa Lunch {date_str}:\n{body}" + else: + message = ( + f"No lunch menu posted for {date_str} " + f"(Kyrene de la Mariposa β€” may be a break or holiday)." + ) + except Exception as e: + log.exception("Failed to fetch menu") + message = f"Error fetching Mariposa lunch menu: {e}" + + # SMS via email has a ~160 char limit per message segment; keep it short + if len(message) > 1400: + message = message[:1397] + "..." + + log.info(f"Message:\n{message}") + + try: + send_sms(message, phone) + except Exception as e: + log.error(f"Failed to send SMS: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main()