Pivot from SMS to calendar-only: weekly pull, independent schedule
All checks were successful
Deploy / deploy (push) Successful in 36s

Deletes the entire SMS path at the user's request — Gmail SMTP, phone
number management, carrier gateway settings, and the daily SMS+calendar
job are all gone. Home Assistant calendar events are now the only output.

- scraper.py: get_week_menu() returns the whole school week in one call
  (the Nutrislice API is week-granular regardless of the date requested);
  drops the now-unused single-day get_lunch_menu() and the send_sms()/
  CLI entrypoint that only the SMS path used.
- app.py: push_week_to_calendar() fetches the week and pushes every day
  with a posted menu; runs on its own schedule (calendar_run_time/
  calendar_run_days, tagged "calendar" jobs) fully decoupled from
  anything else, since neither calendar target supports delete-based
  dedup and double-firing would create duplicate events. Manual actions
  become "Push This Week to Calendar" and "Preview This Week's Menu"
  (now a real dry-run over the whole week, not just today).
- Deleted the vacation/no-push date range feature: a day with no posted
  menu already produces empty items, and pushing already skips empty
  items — the extra vacation-range layer for suppressing days the school
  *did* post a menu for was judged unnecessary and removed along with
  its UI, settings field, and API endpoints.
- Deleted scheduler.py, a dead alternate entrypoint (unused by Dockerfile/
  docker-compose) that only worked via the now-deleted scraper.main().
- load_settings() now merges the file over _DEFAULTS instead of returning
  it verbatim — the server's existing settings.json predates this schema
  and has neither calendar_run_time nor calendar_run_days, which would
  otherwise silently schedule zero jobs after this deploys. Verified with
  a migration test using the server's actual old-schema settings shape.

All of this was exercised for real: Docker builds, live containers against
a mock webhook (single-day and full-week pushes, per-entity dedup
behavior), a migration test with the pre-existing settings.json shape,
and a Chrome pass over the rebuilt UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KsJqAZ82Veyf5oihBr71S5
This commit is contained in:
Barely Removable 2026-09-03 09:10:36 -07:00
parent d3498849d3
commit 1c31ec780b
6 changed files with 231 additions and 646 deletions

View file

@ -1,30 +1,20 @@
# Copy this to .env and fill in your values # Copy this to .env and fill in your values
# --- Required --- # --- Required ---
# 10-digit phone number (no + or country code) # Home Assistant webhook URL that receives the day's menu and creates a
PHONE_NUMBER=4805405363 # calendar event (see automation.lunch_menu_family_calendar in HA).
# e.g. http://192.168.86.X:8123/api/webhook/<random-token>
# 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 ---
# Home Assistant webhook URL to push the day's menu to (for a calendar-event
# automation). Leave unset to skip. e.g. http://192.168.86.X:8123/api/webhook/<id>
HA_WEBHOOK_URL= HA_WEBHOOK_URL=
# Time to send the text each day (24-hour HH:MM, container timezone) # --- Optional ---
RUN_TIME=07:00 # Day(s) of week to pull the upcoming school week and push it to the
# calendar (comma-separated: mon,tue,wed,thu,fri,sat,sun). Picking more
# than one day creates duplicate events for days already pushed — most
# people want just one.
CALENDAR_RUN_DAYS=sun
# Days to run (comma-separated: mon,tue,wed,thu,fri,sat,sun) # Time to run the pull (24-hour HH:MM, container timezone)
RUN_DAYS=mon,tue,wed,thu,fri CALENDAR_RUN_TIME=18:00
# HTTP trigger server port # HTTP server port
PORT=2323 PORT=2323

137
CLAUDE.md
View file

@ -1,57 +1,82 @@
# Lunch Notifier # Lunch Notifier
Single-file Flask app that scrapes the daily Mariposa (Kyrene school Single-file Flask app that scrapes the weekly Mariposa (Kyrene school
district) lunch menu and texts it to a list of phone numbers via district) lunch menu and pushes each day to a Home Assistant calendar as an
email-to-SMS carrier gateways (e.g. `4805551234@vtext.com` for Verizon). all-day event. Runs as one Docker container on the user's Unraid home
Runs as one Docker container on the user's Unraid home server. server.
**2026-09-03: pivoted from SMS to calendar-only.** Originally texted phone
numbers via email-to-SMS carrier gateways; that whole path (Gmail SMTP,
phone number management, carrier gateway settings) was deleted at the
user's request in favor of Home Assistant calendar events exclusively. If
you find references to texting/SMS/phone numbers anywhere (docs, old
commits), they describe the pre-2026-09-03 app, not the current one.
## Architecture ## Architecture
- **`app.py`** — everything: Flask web UI/API, APScheduler-style job - **`app.py`** — everything: Flask web UI/API, `schedule`-library job
scheduling (`schedule` library), and the email-to-SMS send logic scheduling, and the Home Assistant webhook push. The HTML/CSS/JS frontend
(`smtplib` via Gmail, app password auth). The HTML/CSS/JS frontend is is embedded as a Python string constant (`HTML`) rather than a separate
embedded as a Python string constant (`HTML`) rather than a separate
template file — no build step, no template engine. template file — no build step, no template engine.
- **`scraper.py`** — fetches and parses the school's published lunch menu. - **`scraper.py`** — fetches and parses the school's published lunch menu
- **`scheduler.py`** — day-of-week/time scheduling helpers used by `app.py`. from Nutrislice's API. `get_week_menu(date_str)` returns every day in the
- Settings (phone numbers, send time, days, vacation date ranges) persist API's week-granular response as `[{"date": ..., "items": [...]}, ...]`
to `/data/settings.json`, not environment variables — env vars (the API returns a full week regardless of which single date you ask
(`PHONE_NUMBER`, `RUN_TIME`, `RUN_DAYS`) are defaults used only the very for). Days with no posted menu (weekends, breaks, holidays) come back
first time, before a settings file exists. with an empty `items` list rather than being omitted.
- Vacation ranges suppress sends without disabling the schedule — checked - Settings (calendar pull schedule) persist to `/data/settings.json`, not
fresh on every run via `is_on_vacation()`. environment variables — env vars (`CALENDAR_RUN_TIME`, `CALENDAR_RUN_DAYS`)
- **Home Assistant integration (2026-09-02, HA side built 2026-09-03):** if are defaults used only the very first time, before a settings file exists.
`HA_WEBHOOK_URL` is set, every successful menu fetch also POSTs `load_settings()` merges the file's contents *over* `_DEFAULTS` rather
`{"date": ..., "items": [...]}` to that webhook, independent of whether than returning the file verbatim — this matters because the server's
the SMS send itself succeeds. On the HA side (`automation.lunch_menu_family_calendar`, settings.json predates this schema (it's from the SMS era) and lacks
`http://192.168.86.207:8123`), a `local_only` webhook trigger runs `calendar_run_time`/`calendar_run_days` entirely; without the merge, the
`calendar.create_event` against `calendar.family_2` (the "Family" Google scheduler would silently come up with zero jobs after every deploy until
Calendar entity — there are two entities both named "Family" in this HA someone happened to re-save the Schedule card in the UI. Verified via a
instance, `calendar.family` and `calendar.family_2`; this integration migration test (old-schema settings.json + fresh container → correct
deliberately targets `_2`), producing an all-day event with the menu as defaults filled in, `next_calendar_run` populated).
the description (bulleted). This makes the scrape here the single - **No vacation/no-push date ranges (removed 2026-09-03, briefly existed).**
source of truth — HA doesn't poll or re-scrape anything. A day with no posted menu already produces an empty `items` list from
`calendar.family_2` only supports `create_event`, not `delete_event` — `get_week_menu()`, and `_push_calendar()` already skips pushing when
a bad push can't be cleaned up via the HA API, only manually in Google `items` is empty — that's sufficient for "don't create an event for a day
Calendar. No duplicate-prevention either way: re-pushing the same with nothing happening." A separate vacation-range setting to *additionally*
date's menu creates a second event. The webhook id is a random token suppress days the school did post a menu for was judged unnecessary
that lives only in the server's `.env` (`HA_WEBHOOK_URL`) and in the complexity and deleted at the user's request; don't re-add it without
automation's trigger config — never committed here. If the calendar being asked.
push stops working, check both ends: `GET /api/integrations` on this - **Home Assistant integration (built 2026-09-02/03, calendar-only pivot
app reports whether `HA_WEBHOOK_URL` is set at all, and 2026-09-03):** `HA_WEBHOOK_URL` is now required, not optional — it's the
only way this app does anything externally-visible. On the HA side
(`automation.lunch_menu_family_calendar`, `http://192.168.86.207:8123`),
a `local_only` webhook trigger runs `calendar.create_event` against
`calendar.family_2` (the "Family" Google Calendar entity — there are two
entities both named "Family" in this HA instance, `calendar.family` and
`calendar.family_2`; this integration deliberately targets `_2` per user
choice). `calendar.family_2` only supports `create_event`, not
`delete_event` — a bad push can't be cleaned up via the HA API, only
manually in Google Calendar. No duplicate-prevention: re-pushing the
same date's menu creates a second event — this is why the default
schedule is a single day/week, not daily (see below). The webhook id is
a random token that lives only in the server's `.env` (`HA_WEBHOOK_URL`)
and in the automation's trigger config — never committed here. If the
calendar push stops working, check both ends: `GET /api/integrations`
on this app reports whether `HA_WEBHOOK_URL` is set at all, and
`ha_get_automation_traces` on `automation.lunch_menu_family_calendar` `ha_get_automation_traces` on `automation.lunch_menu_family_calendar`
shows whether HA is even receiving the webhook. shows whether HA is even receiving the webhook.
- **Manual actions (2026-09-03):** besides "Send Lunch Menu Now" (full job — - **Independent weekly-pull scheduler (2026-09-03):** calendar pulls run on
scrape, HA push, text everyone), the UI has "Push to Calendar Only" their own schedule (`calendar_run_time`/`calendar_run_days` in settings,
(`POST /api/push-calendar` → `push_calendar_only()`, scrape + HA push, no `schedule` jobs tagged `"calendar"`), completely decoupled from any other
texts — for testing the calendar sync without spamming phones) and job — there's nothing else in this app anymore, but the tag/isolation
"Preview Today's Menu" (`GET /api/preview` → `_scrape_menu()`, pure dry pattern (`schedule.clear("calendar")` instead of a bare `schedule.clear()`)
run, no side effects at all). `carrier_gateway` is editable in the is deliberate so a future second schedule wouldn't stomp this one.
Schedule card (dropdown of common US carriers + custom domain). Gmail `push_week_to_calendar()` fetches the whole current school week and
credentials and `HA_WEBHOOK_URL` stay env-only, not settings.json — the pushes every day with a posted menu. Default is Sunday 18:00 (one
UI has no auth, so secrets aren't made editable there; `GET firing/week) — the UI warns that picking more than one day/week creates
/api/integrations` just exposes whether each is configured (booleans, duplicate events for days already covered, since there's no dedup.
not values) so the UI can show a badge. - **Manual actions:** "Push This Week to Calendar" (`POST /api/push-calendar`
→ `push_week_to_calendar()`, same function the schedule calls) and
"Preview This Week's Menu" (`GET /api/preview` → `get_week_menu()`
directly, pure dry run, no Home Assistant push, no side effects at all —
use this to check the scraper before trusting a scheduled push).
## Local development ## Local development
@ -60,10 +85,10 @@ pip install -r requirements.txt
python3 app.py python3 app.py
``` ```
Needs `GMAIL_USER` and `GMAIL_APP_PASSWORD` env vars to actually send Needs `HA_WEBHOOK_URL` set to actually push anywhere; without it the app
(a Gmail **app password**, not the account password — required since Gmail still runs and previews fine, `GET /api/integrations` just reports
disallows plain SMTP auth with the real password). Visit `ha_webhook_configured: false` and pushes resolve to `"not configured"`.
`http://localhost:2323`. Visit `http://localhost:2323`.
## Deployment ## Deployment
@ -79,13 +104,15 @@ config, HTTPS-not-SSH git auth, known tradeoffs). In short: push to `main`
on the `forgejo` remote (`https://git.hippofam.com/ninja_hippo/lunch-notifier.git`) on the `forgejo` remote (`https://git.hippofam.com/ninja_hippo/lunch-notifier.git`)
triggers `.forgejo/workflows/deploy.yml`, which syncs into triggers `.forgejo/workflows/deploy.yml`, which syncs into
`/mnt/user/appdata/lunch-notifier` (excluding `.git`, `.env`, `data/`) and `/mnt/user/appdata/lunch-notifier` (excluding `.git`, `.env`, `data/`) and
runs `docker compose up -d --build`. runs `docker compose up -d --build`. **`.env` changes need an explicit
`docker compose up -d` on the server afterward** — env vars only load at
container start, a deploy that doesn't change code won't pick up a
manually-edited `.env` on its own.
**Persistent state, never touched by deploys:** `data/settings.json` (the **Persistent state, never touched by deploys:** `data/settings.json` (the
volume-mounted `/data` inside the container) holds phone numbers, schedule, volume-mounted `/data` inside the container) holds the calendar pull
and vacation ranges — this is live user data, not deploy-managed. `.env` schedule — this is live user data, not deploy-managed. `.env` holds
holds `GMAIL_USER`/`GMAIL_APP_PASSWORD`/`PHONE_NUMBER` etc. and is `HA_WEBHOOK_URL` and is server-only, gitignored, never committed.
server-only, gitignored, never committed.
**Migration note (2026-08-25):** this container originally used a Docker **Migration note (2026-08-25):** this container originally used a Docker
**named volume** (`lunch-notifier_lunch-settings`) instead of a bind mount. **named volume** (`lunch-notifier_lunch-settings`) instead of a bind mount.

View file

@ -1,7 +1,7 @@
FROM python:3.12-slim FROM python:3.12-slim
LABEL org.opencontainers.image.title="🍱 Lunch Notifier" LABEL org.opencontainers.image.title="🍱 Lunch Notifier"
LABEL org.opencontainers.image.description="Kyrene de la Mariposa lunch menu → SMS" LABEL org.opencontainers.image.description="Kyrene de la Mariposa lunch menu → Home Assistant calendar"
LABEL org.opencontainers.image.url="http://localhost:2323" 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 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" LABEL net.unraid.docker.icon="http://192.168.86.33:2323/icon.png"

493
app.py
View file

@ -1,22 +1,20 @@
""" """
Lunch Notifier — web UI + scheduler combined. Lunch Notifier — pulls the Mariposa lunch menu and pushes it to a Home
Visit http://localhost:2323 to manage settings. Assistant calendar. Visit http://localhost:2323 to manage settings.
""" """
import json import json
import logging import logging
import os import os
import smtplib
import threading import threading
import time import time
from datetime import date, datetime from datetime import datetime
from email.message import EmailMessage
from pathlib import Path from pathlib import Path
import requests import requests
import schedule import schedule
from flask import Flask, jsonify, request from flask import Flask, jsonify, request
from scraper import get_lunch_menu from scraper import get_week_menu
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -28,20 +26,11 @@ PORT = int(os.environ.get("PORT", "2323"))
# Env-var defaults (used only when no settings file exists yet) # Env-var defaults (used only when no settings file exists yet)
_DEFAULTS = { _DEFAULTS = {
"phone_numbers": [p for p in [os.environ.get("PHONE_NUMBER", "")] if p], "calendar_run_time": os.environ.get("CALENDAR_RUN_TIME", "18:00"),
"carrier_gateway": os.environ.get("CARRIER_GATEWAY", "vtext.com"), "calendar_run_days": [d.strip() for d in os.environ.get("CALENDAR_RUN_DAYS", "sun").split(",") if d.strip()],
"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 = { _status = {"last_calendar_run": None, "calendar_result": None}
"last_run": None,
"last_result": None,
"last_message": None,
"calendar_result": None,
"last_calendar_run": None,
}
_lock = threading.Lock() _lock = threading.Lock()
@ -51,7 +40,11 @@ def load_settings() -> dict:
if SETTINGS_FILE.exists(): if SETTINGS_FILE.exists():
try: try:
with open(SETTINGS_FILE) as f: with open(SETTINGS_FILE) as f:
return json.load(f) # Merge over _DEFAULTS so a settings.json from before a schema
# change (e.g. the pre-calendar SMS-era file) still gets
# calendar_run_time/calendar_run_days rather than silently
# scheduling nothing.
return {**_DEFAULTS, **json.load(f)}
except Exception: except Exception:
pass pass
return dict(_DEFAULTS) return dict(_DEFAULTS)
@ -63,61 +56,18 @@ def save_settings(s: dict) -> None:
json.dump(s, f, indent=2) json.dump(s, f, indent=2)
def is_on_vacation(settings: dict) -> str | None: # ── Calendar push ─────────────────────────────────────────────────────────────
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 _push_to_home_assistant(items: list[str], date_str: str) -> None: def _push_to_home_assistant(items: list[str], date_str: str) -> None:
webhook_url = os.environ["HA_WEBHOOK_URL"] webhook_url = os.environ["HA_WEBHOOK_URL"]
log.info(f"Pushing menu → Home Assistant webhook") log.info(f"Pushing {date_str} menu → Home Assistant webhook")
resp = requests.post(webhook_url, json={"date": date_str, "items": items}, timeout=10) resp = requests.post(webhook_url, json={"date": date_str, "items": items}, timeout=10)
resp.raise_for_status() resp.raise_for_status()
log.info("Home Assistant push ✓") log.info(f"Home Assistant push ✓ ({date_str})")
def _scrape_menu() -> tuple[list[str], str | None, str]:
"""Fetch the menu and build the display/SMS message. No side effects."""
try:
items, date_str = get_lunch_menu()
except Exception as e:
log.exception("Menu fetch failed")
return [], None, f"Error fetching Mariposa lunch menu: {e}"
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)."
if len(message) > 1400:
message = message[:1397] + "..."
return items, date_str, message
def _push_calendar(items: list[str], date_str: str | None) -> str: def _push_calendar(items: list[str], date_str: str | None) -> str:
"""Push to Home Assistant if configured. Returns a calendar_result status string.""" """Push one day to Home Assistant if configured. Returns a status string."""
if not items: if not items:
return "no items" return "no items"
if not os.environ.get("HA_WEBHOOK_URL"): if not os.environ.get("HA_WEBHOOK_URL"):
@ -126,57 +76,48 @@ def _push_calendar(items: list[str], date_str: str | None) -> str:
_push_to_home_assistant(items, date_str) _push_to_home_assistant(items, date_str)
return "ok" return "ok"
except Exception as e: except Exception as e:
log.error(f"Home Assistant push failed: {e}") log.error(f"Home Assistant push failed ({date_str}): {e}")
return f"error: {e}" return f"error: {e}"
def run_job() -> None: def push_week_to_calendar() -> None:
"""Full daily job: scrape, push to HA calendar, and text everyone.""" """Fetch the current school week and push every day that has a posted
menu — days with nothing posted (weekends, breaks, holidays) are
skipped automatically since there's nothing to create an event from.
Runs on its own schedule (calendar_run_time/calendar_run_days),
independent of anything else."""
global _status global _status
settings = load_settings()
vac = is_on_vacation(settings) try:
if vac: week = get_week_menu()
log.info(f"Skipping — vacation: {vac}") except Exception as e:
_status = {**_status, "last_run": datetime.now().isoformat(), "last_result": f"skipped ({vac})"} log.exception("Week fetch failed")
_status = {**_status, "last_calendar_run": datetime.now().isoformat(), "calendar_result": f"error: {e}"}
return return
phones = [p.strip() for p in settings.get("phone_numbers", []) if p.strip()] pushed = errors = no_items = 0
gateway = settings.get("carrier_gateway", "vtext.com") for day in week:
result = _push_calendar(day["items"], day["date"])
if result == "ok":
pushed += 1
elif result == "no items":
no_items += 1
else:
errors += 1
items, date_str, message = _scrape_menu() log.info(f"Week push: {pushed} pushed, {errors} errors, {no_items} no menu")
log.info(f"Message:\n{message}")
calendar_result = _push_calendar(items, date_str) if errors:
summary = f"{pushed} ok, {errors} error" + ("s" if errors != 1 else "")
errors = [] elif pushed:
for phone in phones: summary = f"ok ({pushed} day{'s' if pushed != 1 else ''})"
try: else:
_send_email_sms(message, phone, gateway) summary = "no items"
except Exception as e:
log.error(f"Failed to send to {phone}: {e}")
errors.append(str(e))
_status = { _status = {
**_status, **_status,
"last_run": datetime.now().isoformat(),
"last_result": "error: " + "; ".join(errors) if errors else "ok",
"last_message": message,
"calendar_result": calendar_result,
"last_calendar_run": datetime.now().isoformat(),
}
def push_calendar_only() -> None:
"""Scrape and push to Home Assistant without texting anyone."""
global _status
items, date_str, message = _scrape_menu()
log.info(f"Calendar-only push — message:\n{message}")
calendar_result = _push_calendar(items, date_str)
_status = {
**_status,
"calendar_result": calendar_result,
"last_calendar_run": datetime.now().isoformat(), "last_calendar_run": datetime.now().isoformat(),
"calendar_result": summary,
} }
@ -193,15 +134,15 @@ DAY_MAP_FNS = {
} }
def apply_schedule(settings: dict) -> None: def apply_calendar_schedule(settings: dict) -> None:
with _lock: with _lock:
schedule.clear() schedule.clear("calendar")
run_time = settings.get("run_time", "07:00") run_time = settings.get("calendar_run_time", "18:00")
for day in settings.get("run_days", []): for day in settings.get("calendar_run_days", []):
day = day.strip().lower() day = day.strip().lower()
if day in DAY_MAP_FNS: if day in DAY_MAP_FNS:
DAY_MAP_FNS[day]().at(run_time).do(run_job) DAY_MAP_FNS[day]().at(run_time).do(push_week_to_calendar).tag("calendar")
log.info(f"Scheduled: {day} at {run_time}") log.info(f"Scheduled calendar pull: {day} at {run_time}")
def _schedule_loop() -> None: def _schedule_loop() -> None:
@ -235,72 +176,42 @@ def api_get_settings():
def api_post_settings(): def api_post_settings():
data = request.get_json(force=True) data = request.get_json(force=True)
s = load_settings() s = load_settings()
for key in ("phone_numbers", "carrier_gateway", "run_time", "run_days"): for key in ("calendar_run_time", "calendar_run_days"):
if key in data: if key in data:
s[key] = data[key] s[key] = data[key]
save_settings(s) save_settings(s)
apply_schedule(s) apply_calendar_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/<vid>", 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}) return jsonify({"ok": True})
@app.route("/api/push-calendar", methods=["POST"]) @app.route("/api/push-calendar", methods=["POST"])
def api_push_calendar(): def api_push_calendar():
threading.Thread(target=push_calendar_only, daemon=True).start() threading.Thread(target=push_week_to_calendar, daemon=True).start()
return jsonify({"ok": True}) return jsonify({"ok": True})
@app.route("/api/preview") @app.route("/api/preview")
def api_preview(): def api_preview():
"""Dry-run scrape: no SMS, no Home Assistant push.""" """Dry-run week scrape: no Home Assistant push, no side effects."""
items, date_str, message = _scrape_menu() try:
return jsonify({"date": date_str, "items": items, "message": message}) week = get_week_menu()
except Exception as e:
return jsonify({"error": str(e)}), 502
return jsonify({"days": week})
@app.route("/api/integrations") @app.route("/api/integrations")
def api_integrations(): def api_integrations():
return jsonify({ return jsonify({"ha_webhook_configured": bool(os.environ.get("HA_WEBHOOK_URL"))})
"sms_configured": bool(os.environ.get("GMAIL_USER") and os.environ.get("GMAIL_APP_PASSWORD")),
"ha_webhook_configured": bool(os.environ.get("HA_WEBHOOK_URL")),
})
@app.route("/api/status") @app.route("/api/status")
def api_status(): def api_status():
s = load_settings()
with _lock: with _lock:
next_runs = sorted([j.next_run for j in schedule.get_jobs() if j.next_run]) next_runs = sorted([j.next_run for j in schedule.get_jobs("calendar") if j.next_run])
return jsonify({ return jsonify({
**_status, **_status,
"next_run": next_runs[0].isoformat() if next_runs else None, "next_calendar_run": next_runs[0].isoformat() if next_runs else None,
"on_vacation": is_on_vacation(s),
}) })
@ -366,7 +277,7 @@ header p { color: var(--muted); font-size: .875rem; margin-top: 2px; }
.field:last-child { margin-bottom: 0; } .field:last-child { margin-bottom: 0; }
label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text); label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text);
margin-bottom: 6px; } margin-bottom: 6px; }
input[type=text], input[type=time], input[type=date] { input[type=text], input[type=time] {
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
font-size: .875rem; color: var(--text); background: var(--card); font-size: .875rem; color: var(--text); background: var(--card);
} }
@ -377,17 +288,7 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--card); font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--card);
transition: all .15s; user-select: none; } transition: all .15s; user-select: none; }
.day-chip.on { background: var(--primary); color: #fff; border-color: var(--primary); } .day-chip.on { background: var(--primary); color: #fff; border-color: var(--primary); }
/* Lists */
.item-list { list-style: none; }
.item-row { display: flex; align-items: center; gap: 10px; padding: 10px 0;
border-bottom: 1px solid var(--border); }
.item-row:last-child { border-bottom: none; }
.item-main { flex: 1; }
.item-title { font-size: .875rem; font-weight: 500; }
.item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; } .item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; }
/* Add row */
.add-row { display: flex; gap: 8px; align-items: flex-end; margin-top: 14px; }
.add-row .field { flex: 1; margin-bottom: 0; }
/* Divider */ /* Divider */
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; } .divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
/* Toast */ /* Toast */
@ -396,8 +297,11 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s; font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s;
pointer-events: none; white-space: nowrap; z-index: 100; } pointer-events: none; white-space: nowrap; z-index: 100; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.empty-state { color: var(--muted); font-size: .875rem; padding: 6px 0; } .week-day { padding: 8px 0; border-bottom: 1px solid var(--border); }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .week-day:last-child { border-bottom: none; }
.week-day-date { font-size: .8rem; font-weight: 600; margin-bottom: 3px; }
.week-day-items { font-size: .8rem; color: #c9d1d9; white-space: pre-wrap; }
.week-day-empty { font-size: .8rem; color: var(--muted); font-style: italic; }
</style> </style>
</head> </head>
<body> <body>
@ -407,7 +311,7 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
<img src="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png" alt="Chip the Challenger" style="height:80px;width:auto;"> <img src="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png" alt="Chip the Challenger" style="height:80px;width:auto;">
<div> <div>
<h1>Lunch Notifier</h1> <h1>Lunch Notifier</h1>
<p>Kyrene de la Mariposa &middot; Automated lunch menu texts</p> <p>Kyrene de la Mariposa &middot; Menu &rarr; Home Assistant calendar</p>
</div> </div>
</div> </div>
</header> </header>
@ -416,108 +320,48 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
<div class="card"> <div class="card">
<div class="card-label">Status</div> <div class="card-label">Status</div>
<dl class="stat-grid"> <dl class="stat-grid">
<div class="stat"><dt>Last Run</dt><dd id="s-last-run">—</dd></div> <div class="stat"><dt>Last Pull</dt><dd id="s-last-run">—</dd></div>
<div class="stat"><dt>Result</dt><dd id="s-result">—</dd></div> <div class="stat"><dt>Result</dt><dd id="s-result">—</dd></div>
<div class="stat"><dt>Next Scheduled</dt><dd id="s-next">—</dd></div> <div class="stat"><dt>Next Scheduled</dt><dd id="s-next">—</dd></div>
<div class="stat"><dt>Mode</dt><dd id="s-mode">—</dd></div>
<div class="stat" id="s-cal-stat" style="display:none;"><dt>Calendar Sync</dt><dd id="s-cal">—</dd></div>
</dl> </dl>
<div id="s-last-msg" style="display:none;margin-top:14px;padding:10px 12px;background:#1c2128;border-radius:8px;font-size:.8rem;color:#c9d1d9;white-space:pre-wrap;"></div>
</div> </div>
<!-- Manual Actions --> <!-- Manual Actions -->
<div class="card"> <div class="card">
<div class="card-label">Manual Actions</div> <div class="card-label">Manual Actions</div>
<button id="send-btn" class="btn btn-primary btn-send" onclick="sendNow()"> <button id="push-cal-btn" class="btn btn-primary btn-send" onclick="pushCalendar()">
Send Lunch Menu Now Push This Week to Calendar
</button> </button>
<div class="row-2" style="margin-top:10px;"> <button id="preview-btn" class="btn btn-ghost" style="width:100%;margin-top:10px;" onclick="previewMenu()">
<button id="push-cal-btn" class="btn btn-ghost" onclick="pushCalendar()"> Preview This Week's Menu
Push to Calendar Only
</button> </button>
<button id="preview-btn" class="btn btn-ghost" onclick="previewMenu()"> <div id="preview-box" style="display:none;margin-top:14px;"></div>
Preview Today's Menu
</button>
</div>
<div id="preview-box" style="display:none;margin-top:14px;padding:10px 12px;background:#1c2128;border-radius:8px;font-size:.8rem;color:#c9d1d9;white-space:pre-wrap;"></div>
</div> </div>
<!-- Integrations --> <!-- Integrations -->
<div class="card"> <div class="card">
<div class="card-label">Integrations</div> <div class="card-label">Integrations</div>
<dl class="stat-grid"> <dl class="stat-grid">
<div class="stat"><dt>SMS (Gmail)</dt><dd id="i-sms">—</dd></div>
<div class="stat"><dt>Home Assistant</dt><dd id="i-ha">—</dd></div> <div class="stat"><dt>Home Assistant</dt><dd id="i-ha">—</dd></div>
</dl> </dl>
<div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> on the server, not here.</div> <div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> (<code>HA_WEBHOOK_URL</code>) on the server, not here.</div>
</div> </div>
<!-- Schedule --> <!-- Schedule -->
<div class="card"> <div class="card">
<div class="card-label">Schedule</div> <div class="card-label">Calendar Pull Schedule</div>
<div class="field"> <div class="field">
<label class="field-label">Send Time</label> <label class="field-label">Pull Time</label>
<input type="time" id="f-time" style="max-width:160px"> <input type="time" id="f-time" style="max-width:160px">
</div> </div>
<div class="field"> <div class="field">
<label class="field-label">Days</label> <label class="field-label">Pull Days</label>
<div class="days-row" id="days-row"></div> <div class="days-row" id="days-row"></div>
</div> <div class="item-sub" style="margin-top:8px;">Each day fires a full week pull. Picking more than one day will create duplicate calendar events for days already pushed &mdash; most people want just one (e.g. Sunday evening).</div>
<div class="field">
<label class="field-label">Carrier Gateway</label>
<select id="f-gateway" onchange="onGatewayChange()" style="width:100%;padding:9px 12px;border:1px solid var(--border);border-radius:8px;font-size:.875rem;color:var(--text);background:var(--card);">
<option value="vtext.com">Verizon (vtext.com)</option>
<option value="txt.att.net">AT&amp;T (txt.att.net)</option>
<option value="tmomail.net">T-Mobile (tmomail.net)</option>
<option value="messaging.sprintpcs.com">Sprint (messaging.sprintpcs.com)</option>
<option value="sms.myboostmobile.com">Boost Mobile (sms.myboostmobile.com)</option>
<option value="sms.cricketwireless.net">Cricket (sms.cricketwireless.net)</option>
<option value="email.uscc.net">US Cellular (email.uscc.net)</option>
<option value="msg.fi.google.com">Google Fi (msg.fi.google.com)</option>
<option value="__custom__">Custom&hellip;</option>
</select>
<input type="text" id="f-gateway-custom" placeholder="your-carrier-gateway.com"
style="margin-top:8px;display:none;">
</div> </div>
<hr class="divider"> <hr class="divider">
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button> <button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
</div> </div>
<!-- Phone Numbers -->
<div class="card">
<div class="card-label">Phone Numbers</div>
<ul class="item-list" id="phone-list"></ul>
<div class="add-row">
<div class="field">
<label class="field-label">Add Number (10 digits)</label>
<input type="text" id="f-phone" placeholder="4805551234" maxlength="15"
onkeydown="if(event.key==='Enter') addPhone()">
</div>
<button class="btn btn-primary btn-sm" onclick="addPhone()">Add</button>
</div>
</div>
<!-- Vacation -->
<div class="card">
<div class="card-label">Vacation / No-Send Periods</div>
<ul class="item-list" id="vac-list"></ul>
<hr class="divider">
<div class="field">
<label class="field-label">Label</label>
<input type="text" id="f-vac-label" placeholder="Spring Break">
</div>
<div class="row-2">
<div class="field">
<label class="field-label">Start Date</label>
<input type="date" id="f-vac-start">
</div>
<div class="field">
<label class="field-label">End Date</label>
<input type="date" id="f-vac-end">
</div>
</div>
<button class="btn btn-primary" onclick="addVacation()">Add Period</button>
</div>
</div> </div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
@ -539,18 +383,6 @@ async function load() {
applyIntegrations(ig); applyIntegrations(ig);
} }
const KNOWN_GATEWAYS = ['vtext.com','txt.att.net','tmomail.net','messaging.sprintpcs.com',
'sms.myboostmobile.com','sms.cricketwireless.net','email.uscc.net','msg.fi.google.com'];
function applyIntegrations(ig) {
document.getElementById('i-sms').innerHTML = ig.sms_configured
? '<span class="badge badge-green">Configured</span>'
: '<span class="badge badge-yellow">Not configured</span>';
document.getElementById('i-ha').innerHTML = ig.ha_webhook_configured
? '<span class="badge badge-green">Configured</span>'
: '<span class="badge badge-yellow">Not configured</span>';
}
function fmt(iso) { function fmt(iso) {
if (!iso) return '—'; if (!iso) return '—';
const d = new Date(iso); const d = new Date(iso);
@ -558,155 +390,46 @@ function fmt(iso) {
d.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'}); d.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});
} }
function applyIntegrations(ig) {
document.getElementById('i-ha').innerHTML = ig.ha_webhook_configured
? '<span class="badge badge-green">Configured</span>'
: '<span class="badge badge-yellow">Not configured</span>';
}
function applySettings(s) { function applySettings(s) {
document.getElementById('f-time').value = s.run_time || '07:00'; document.getElementById('f-time').value = s.calendar_run_time || '18:00';
// Carrier gateway
const gw = s.carrier_gateway || 'vtext.com';
const gwSel = document.getElementById('f-gateway');
const gwCustom = document.getElementById('f-gateway-custom');
if (KNOWN_GATEWAYS.includes(gw)) {
gwSel.value = gw;
gwCustom.style.display = 'none';
gwCustom.value = '';
} else {
gwSel.value = '__custom__';
gwCustom.style.display = '';
gwCustom.value = gw;
}
// Days
const row = document.getElementById('days-row'); const row = document.getElementById('days-row');
row.innerHTML = DAYS.map(d => row.innerHTML = DAYS.map(d =>
`<span class="day-chip ${(s.run_days||[]).includes(d)?'on':''}" data-day="${d}" `<span class="day-chip ${(s.calendar_run_days||[]).includes(d)?'on':''}" data-day="${d}"
onclick="this.classList.toggle('on')">${DAY_LABELS[d]}</span>` onclick="this.classList.toggle('on')">${DAY_LABELS[d]}</span>`
).join(''); ).join('');
// Phones
const pl = document.getElementById('phone-list');
if (!(s.phone_numbers||[]).length) {
pl.innerHTML = '<li class="empty-state">No numbers added yet.</li>';
} else {
pl.innerHTML = (s.phone_numbers||[]).map((p,i) => `
<li class="item-row">
<div class="item-main"><div class="item-title">${p}</div></div>
<button class="btn btn-danger btn-sm" onclick="removePhone(${i})">Remove</button>
</li>`).join('');
}
// Vacations
const vl = document.getElementById('vac-list');
if (!(s.vacations||[]).length) {
vl.innerHTML = '<li class="empty-state">No vacation periods set.</li>';
} else {
vl.innerHTML = (s.vacations||[]).map(v => `
<li class="item-row">
<div class="item-main">
<div class="item-title">${v.label}</div>
<div class="item-sub">${v.start} &rarr; ${v.end}</div>
</div>
<button class="btn btn-danger btn-sm" onclick="removeVacation('${v.id}')">Remove</button>
</li>`).join('');
}
} }
function applyStatus(st) { function applyStatus(st) {
document.getElementById('s-last-run').textContent = fmt(st.last_run); document.getElementById('s-last-run').textContent = fmt(st.last_calendar_run);
const res = st.last_result; const res = st.calendar_result;
let cls = res==='ok' ? 'badge-green' : res?.startsWith('skipped') ? 'badge-yellow' : res ? 'badge-red' : ''; let cls = res==='ok' || res?.startsWith('ok') ? 'badge-green'
: res?.startsWith('skipped') || res === 'no items' ? 'badge-yellow'
: res ? 'badge-red' : '';
document.getElementById('s-result').innerHTML = res ? `<span class="badge ${cls}">${res}</span>` : '—'; document.getElementById('s-result').innerHTML = res ? `<span class="badge ${cls}">${res}</span>` : '—';
document.getElementById('s-next').textContent = fmt(st.next_run); document.getElementById('s-next').textContent = fmt(st.next_calendar_run);
const vac = st.on_vacation;
document.getElementById('s-mode').innerHTML = vac
? `<span class="badge badge-yellow">Vacation: ${vac}</span>`
: '<span class="badge badge-green">Active</span>';
const calStat = document.getElementById('s-cal-stat');
const cal = st.calendar_result;
if (cal) {
calStat.style.display = '';
const calCls = cal === 'ok' ? 'badge-green' : cal.startsWith('error') ? 'badge-red' : 'badge-yellow';
const when = st.last_calendar_run ? `<div class="item-sub">${fmt(st.last_calendar_run)}</div>` : '';
document.getElementById('s-cal').innerHTML = `<span class="badge ${calCls}">${cal}</span>${when}`;
} else {
calStat.style.display = 'none';
}
const msgEl = document.getElementById('s-last-msg');
if (st.last_message) {
msgEl.textContent = st.last_message;
msgEl.style.display = '';
} else {
msgEl.style.display = 'none';
}
}
function onGatewayChange() {
const sel = document.getElementById('f-gateway');
document.getElementById('f-gateway-custom').style.display = sel.value === '__custom__' ? '' : 'none';
} }
async function saveSchedule() { async function saveSchedule() {
const run_time = document.getElementById('f-time').value; const calendar_run_time = document.getElementById('f-time').value;
const run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day); const calendar_run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day);
const gwSel = document.getElementById('f-gateway').value; await post('/api/settings', {calendar_run_time, calendar_run_days});
const carrier_gateway = gwSel === '__custom__'
? document.getElementById('f-gateway-custom').value.trim()
: gwSel;
if (!carrier_gateway) { toast('Enter a carrier gateway domain'); return; }
await post('/api/settings', {run_time, run_days, carrier_gateway});
toast('Schedule saved'); toast('Schedule saved');
load(); load();
} }
async function addPhone() {
const input = document.getElementById('f-phone');
const phone = input.value.trim().replace(/\D/g,'');
if (!phone) { toast('Enter a 10-digit number'); return; }
const phone_numbers = [...(cfg.phone_numbers||[]), phone];
await post('/api/settings', {phone_numbers});
input.value = '';
toast('Number added');
load();
}
async function removePhone(idx) {
const phone_numbers = (cfg.phone_numbers||[]).filter((_,i)=>i!==idx);
await post('/api/settings', {phone_numbers});
toast('Number removed');
load();
}
async function addVacation() {
const label = document.getElementById('f-vac-label').value.trim() || 'Vacation';
const start = document.getElementById('f-vac-start').value;
const end = document.getElementById('f-vac-end').value;
if (!start || !end) { toast('Set both start and end dates'); return; }
if (end < start) { toast('End must be after start'); return; }
await post('/api/vacation', {label, start, end});
document.getElementById('f-vac-label').value = '';
document.getElementById('f-vac-start').value = '';
document.getElementById('f-vac-end').value = '';
toast('Vacation period added');
load();
}
async function removeVacation(id) {
await fetch(`/api/vacation/${id}`, {method:'DELETE'});
toast('Removed');
load();
}
async function sendNow() {
const btn = document.getElementById('send-btn');
btn.disabled = true;
btn.textContent = 'Sending…';
await post('/api/send', {});
toast('Triggered! Check status in a moment.');
setTimeout(() => { btn.disabled=false; btn.textContent='Send Lunch Menu Now'; load(); }, 6000);
}
async function pushCalendar() { async function pushCalendar() {
const btn = document.getElementById('push-cal-btn'); const btn = document.getElementById('push-cal-btn');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Pushing…'; btn.textContent = 'Pushing…';
await post('/api/push-calendar', {}); await post('/api/push-calendar', {});
toast('Pushed! Check status in a moment.'); toast('Triggered! Check status in a moment.');
setTimeout(() => { btn.disabled=false; btn.textContent='Push to Calendar Only'; load(); }, 6000); setTimeout(() => { btn.disabled=false; btn.textContent='Push This Week to Calendar'; load(); }, 6000);
} }
async function previewMenu() { async function previewMenu() {
@ -717,11 +440,21 @@ async function previewMenu() {
try { try {
const r = await fetch('/api/preview'); const r = await fetch('/api/preview');
const data = await r.json(); const data = await r.json();
box.textContent = data.message; if (data.error) {
box.innerHTML = `<div class="week-day-empty">Error: ${data.error}</div>`;
} else {
box.innerHTML = (data.days||[]).map(d => `
<div class="week-day">
<div class="week-day-date">${d.date}</div>
${d.items.length
? `<div class="week-day-items">${d.items.map(i=>'• '+i).join('\\n')}</div>`
: '<div class="week-day-empty">No menu posted</div>'}
</div>`).join('');
}
box.style.display = ''; box.style.display = '';
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = "Preview Today's Menu"; btn.textContent = "Preview This Week's Menu";
} }
} }
@ -752,7 +485,7 @@ if __name__ == "__main__":
log.info(f"Creating default settings at {SETTINGS_FILE}") log.info(f"Creating default settings at {SETTINGS_FILE}")
save_settings(_DEFAULTS) save_settings(_DEFAULTS)
apply_schedule(load_settings()) apply_calendar_schedule(load_settings())
threading.Thread(target=_schedule_loop, daemon=True).start() threading.Thread(target=_schedule_loop, daemon=True).start()
log.info(f"Web UI → http://localhost:{PORT}") log.info(f"Web UI → http://localhost:{PORT}")
app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False) app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False)

View file

@ -1,115 +0,0 @@
"""
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)

View file

@ -1,9 +1,5 @@
import os
import sys
import logging import logging
import smtplib
from datetime import datetime from datetime import datetime
from email.message import EmailMessage
import requests import requests
@ -15,10 +11,7 @@ MENU_TYPE = "lunch"
API_BASE = "https://kyrene.api.nutrislice.com/menu/api/weeks/school" API_BASE = "https://kyrene.api.nutrislice.com/menu/api/weeks/school"
def get_lunch_menu(date_str: str | None = None) -> tuple[list[str], str]: def _fetch_week(date_str: str) -> dict:
if date_str is None:
date_str = datetime.now().strftime("%Y-%m-%d")
dt = datetime.strptime(date_str, "%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}/" url = f"{API_BASE}/{SCHOOL_SLUG}/menu-type/{MENU_TYPE}/{dt.year}/{dt.month:02d}/{dt.day:02d}/"
log.info(f"Fetching {url}") log.info(f"Fetching {url}")
@ -29,73 +22,30 @@ def get_lunch_menu(date_str: str | None = None) -> tuple[list[str], str]:
timeout=20, timeout=20,
) )
resp.raise_for_status() resp.raise_for_status()
data = resp.json() return resp.json()
def _extract_items(day: dict) -> list[str]:
items = [] items = []
for day in data.get("days", []):
if day.get("date") != date_str:
continue
for entry in day.get("menu_items", []): for entry in day.get("menu_items", []):
if entry.get("is_section_title") or entry.get("is_station_header"): if entry.get("is_section_title") or entry.get("is_station_header"):
continue continue
food = entry.get("food") food = entry.get("food")
if food and food.get("name"): if food and food.get("name"):
items.append(food["name"].strip()) items.append(food["name"].strip())
return items
return items, date_str
def send_sms(message: str, phone: str) -> None: def get_week_menu(date_str: str | None = None) -> list[dict]:
gmail_user = os.environ["GMAIL_USER"] """Returns every day in the school-published week containing date_str,
gmail_pass = os.environ["GMAIL_APP_PASSWORD"] as [{"date": "YYYY-MM-DD", "items": [...]}, ...] in the API's own order.
gateway = os.environ.get("CARRIER_GATEWAY", "vtext.com") Days with no posted menu (weekends, breaks, holidays) have an empty items list."""
to_addr = f"{phone}@{gateway}" if date_str is None:
date_str = datetime.now().strftime("%Y-%m-%d")
msg = EmailMessage() data = _fetch_week(date_str)
msg["From"] = gmail_user return [
msg["To"] = to_addr {"date": day.get("date"), "items": _extract_items(day)}
msg.set_content(message) for day in data.get("days", [])
if day.get("date")
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()