Initial commit: Lunch Notifier source from server
This commit is contained in:
commit
b568287528
9 changed files with 878 additions and 0 deletions
21
.claude/settings.local.json
Normal file
21
.claude/settings.local.json
Normal file
|
|
@ -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/)"
|
||||
]
|
||||
}
|
||||
}
|
||||
26
.env.example
Normal file
26
.env.example
Normal file
|
|
@ -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
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.env
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
|
|
@ -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"]
|
||||
576
app.py
Normal file
576
app.py
Normal file
|
|
@ -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/<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})
|
||||
|
||||
|
||||
@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 = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>🍱 Lunch Notifier</title>
|
||||
<link rel="icon" href="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--card: #161b22;
|
||||
--primary: #14b8a6;
|
||||
--primary-hover: #0d9488;
|
||||
--danger: #f87171;
|
||||
--success: #4ade80;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--border: #30363d;
|
||||
--radius: 12px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
.container { max-width: 620px; margin: 0 auto; padding: 24px 16px 48px; }
|
||||
header { margin-bottom: 28px; }
|
||||
header h1 { font-size: 1.5rem; font-weight: 700; }
|
||||
header p { color: var(--muted); font-size: .875rem; margin-top: 2px; }
|
||||
.card { background: var(--card); border-radius: var(--radius); padding: 20px;
|
||||
margin-bottom: 16px; box-shadow: var(--shadow); border: 1px solid var(--border); }
|
||||
.card-label { font-size: .7rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .06em; color: var(--muted); margin-bottom: 14px; }
|
||||
/* Status grid */
|
||||
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.stat dt { font-size: .75rem; color: var(--muted); margin-bottom: 2px; }
|
||||
.stat dd { font-size: .875rem; font-weight: 500; }
|
||||
/* Badges */
|
||||
.badge { display: inline-block; padding: 2px 10px; border-radius: 99px; font-size: .75rem; font-weight: 600; }
|
||||
.badge-green { background: #14532d; color: #4ade80; }
|
||||
.badge-yellow { background: #713f12; color: #fbbf24; }
|
||||
.badge-red { background: #7f1d1d; color: #fca5a5; }
|
||||
.badge-blue { background: #1e3a5f; color: #7dd3fc; }
|
||||
/* Buttons */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 9px 18px; border-radius: 8px; font-size: .875rem; font-weight: 600;
|
||||
cursor: pointer; border: none; transition: background .15s, opacity .15s; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--primary); color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--primary-hover); }
|
||||
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
||||
.btn-ghost:hover { background: var(--bg); }
|
||||
.btn-danger { background: var(--card); color: var(--danger); border: 1px solid #7f1d1d; }
|
||||
.btn-danger:hover { background: #1a0f0f; }
|
||||
.btn-send { width: 100%; padding: 14px; font-size: 1rem; border-radius: 10px; }
|
||||
.btn-sm { padding: 6px 12px; font-size: .75rem; }
|
||||
/* Form elements */
|
||||
.field { margin-bottom: 14px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text);
|
||||
margin-bottom: 6px; }
|
||||
input[type=text], input[type=time], input[type=date] {
|
||||
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
|
||||
font-size: .875rem; color: var(--text); background: var(--card);
|
||||
}
|
||||
input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: transparent; }
|
||||
/* Days toggle */
|
||||
.days-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.day-chip { padding: 6px 13px; border: 1.5px solid var(--border); border-radius: 99px;
|
||||
font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--card);
|
||||
transition: all .15s; user-select: none; }
|
||||
.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; }
|
||||
/* 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 { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
||||
/* Toast */
|
||||
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(8px);
|
||||
background: #1f2937; color: #fff; padding: 10px 20px; border-radius: 99px;
|
||||
font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s;
|
||||
pointer-events: none; white-space: nowrap; z-index: 100; }
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
.empty-state { color: var(--muted); font-size: .875rem; padding: 6px 0; }
|
||||
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div style="display:flex;align-items:center;gap:16px;">
|
||||
<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>
|
||||
<h1>Lunch Notifier</h1>
|
||||
<p>Kyrene de la Mariposa · Automated lunch menu texts</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="card">
|
||||
<div class="card-label">Status</div>
|
||||
<dl class="stat-grid">
|
||||
<div class="stat"><dt>Last Run</dt><dd id="s-last-run">—</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>Mode</dt><dd id="s-mode">—</dd></div>
|
||||
</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>
|
||||
|
||||
<!-- Send Now -->
|
||||
<div class="card">
|
||||
<div class="card-label">Manual Send</div>
|
||||
<button id="send-btn" class="btn btn-primary btn-send" onclick="sendNow()">
|
||||
Send Lunch Menu Now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Schedule -->
|
||||
<div class="card">
|
||||
<div class="card-label">Schedule</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Send Time</label>
|
||||
<input type="time" id="f-time" style="max-width:160px">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Days</label>
|
||||
<div class="days-row" id="days-row"></div>
|
||||
</div>
|
||||
<hr class="divider">
|
||||
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
|
||||
</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 class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const DAYS = ['mon','tue','wed','thu','fri','sat','sun'];
|
||||
const DAY_LABELS = {mon:'Mon',tue:'Tue',wed:'Wed',thu:'Thu',fri:'Fri',sat:'Sat',sun:'Sun'};
|
||||
let cfg = {};
|
||||
|
||||
async function load() {
|
||||
const [s, st] = await Promise.all([
|
||||
fetch('/api/settings').then(r=>r.json()),
|
||||
fetch('/api/status').then(r=>r.json()),
|
||||
]);
|
||||
cfg = s;
|
||||
applySettings(s);
|
||||
applyStatus(st);
|
||||
}
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString([], {month:'short',day:'numeric'}) + ' ' +
|
||||
d.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});
|
||||
}
|
||||
|
||||
function applySettings(s) {
|
||||
document.getElementById('f-time').value = s.run_time || '07:00';
|
||||
// Days
|
||||
const row = document.getElementById('days-row');
|
||||
row.innerHTML = DAYS.map(d =>
|
||||
`<span class="day-chip ${(s.run_days||[]).includes(d)?'on':''}" data-day="${d}"
|
||||
onclick="this.classList.toggle('on')">${DAY_LABELS[d]}</span>`
|
||||
).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} → ${v.end}</div>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm" onclick="removeVacation('${v.id}')">Remove</button>
|
||||
</li>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function applyStatus(st) {
|
||||
document.getElementById('s-last-run').textContent = fmt(st.last_run);
|
||||
const res = st.last_result;
|
||||
let cls = res==='ok' ? 'badge-green' : res?.startsWith('skipped') ? 'badge-yellow' : res ? 'badge-red' : '';
|
||||
document.getElementById('s-result').innerHTML = res ? `<span class="badge ${cls}">${res}</span>` : '—';
|
||||
document.getElementById('s-next').textContent = fmt(st.next_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 msgEl = document.getElementById('s-last-msg');
|
||||
if (st.last_message) {
|
||||
msgEl.textContent = st.last_message;
|
||||
msgEl.style.display = '';
|
||||
} else {
|
||||
msgEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSchedule() {
|
||||
const run_time = document.getElementById('f-time').value;
|
||||
const run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day);
|
||||
await post('/api/settings', {run_time, run_days});
|
||||
toast('Schedule saved');
|
||||
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 post(url, body) {
|
||||
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function toast(msg) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => el.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
load();
|
||||
setInterval(load, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ── 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)
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
|
|
@ -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
|
||||
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
flask==3.1.0
|
||||
requests==2.32.3
|
||||
schedule==1.2.2
|
||||
115
scheduler.py
Normal file
115
scheduler.py
Normal file
|
|
@ -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)
|
||||
101
scraper.py
Normal file
101
scraper.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue