Default to America/Phoenix and email-alert once when the session expires
All checks were successful
Deploy / deploy (push) Successful in 25s
All checks were successful
Deploy / deploy (push) Successful in 25s
Phoenix doesn't observe DST, so schedule times stay meaningful year-round without needing a seasonal adjustment. notify.py reuses lunch-notifier's existing Gmail-SMTP-with-app-password pattern rather than adding a new notification channel. The alert fires exactly once per expiry (not on every failed scheduled run while it's down) via a dedupe flag in notify_state.json, reset the moment a fresh session is successfully imported. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H6TgffxTvYhqxykAbVQg2v
This commit is contained in:
parent
48d8bac332
commit
3b08af7938
7 changed files with 111 additions and 10 deletions
|
|
@ -38,7 +38,9 @@ cd /mnt/user/appdata/massprice && docker compose restart
|
||||||
|
|
||||||
1. Create `/mnt/user/appdata/massprice` on Unraid and a real `.env`
|
1. Create `/mnt/user/appdata/massprice` on Unraid and a real `.env`
|
||||||
there (copy `.env.example`, fill in `LOGIN_PASSWORD`, `SECRET_KEY`,
|
there (copy `.env.example`, fill in `LOGIN_PASSWORD`, `SECRET_KEY`,
|
||||||
`HOST_PORT`, `TZ`).
|
`HOST_PORT`, `TZ` — and optionally `GMAIL_USER`/`GMAIL_APP_PASSWORD`/
|
||||||
|
`ALERT_EMAIL` for the session-expiry email alert, same Gmail-app-
|
||||||
|
password pattern as `lunch-notifier`).
|
||||||
2. First `docker compose up -d --build` — confirm the container starts
|
2. First `docker compose up -d --build` — confirm the container starts
|
||||||
and `/login` responds on `HOST_PORT`.
|
and `/login` responds on `HOST_PORT`.
|
||||||
3. Log into the webui, go to the **Session** page, and follow its
|
3. Log into the webui, go to the **Session** page, and follow its
|
||||||
|
|
@ -61,12 +63,14 @@ cd /mnt/user/appdata/massprice && docker compose restart
|
||||||
|
|
||||||
## Persistent state, never touched by deploys
|
## Persistent state, never touched by deploys
|
||||||
|
|
||||||
`.env` holds `LOGIN_PASSWORD` and `SECRET_KEY`. `data/` (bind-mounted to
|
`.env` holds `LOGIN_PASSWORD`, `SECRET_KEY`, and (optionally)
|
||||||
|
`GMAIL_USER`/`GMAIL_APP_PASSWORD`/`ALERT_EMAIL`. `data/` (bind-mounted to
|
||||||
`/data`) holds `schedules.json` (the schedules set from the webui),
|
`/data`) holds `schedules.json` (the schedules set from the webui),
|
||||||
`auth_state.json` (the saved MassPrice session imported via the Session
|
`auth_state.json` (the saved MassPrice session imported via the Session
|
||||||
page — a live bearer credential for the user's MassPrice account while
|
page — a live bearer credential for the user's MassPrice account while
|
||||||
valid, see `CLAUDE.md`; losing this just costs one re-import, not data),
|
valid, see `CLAUDE.md`; losing this just costs one re-import, not data),
|
||||||
`run_history.json`, and `rules_cache.json`. Both are excluded from the
|
`run_history.json`, `rules_cache.json`, and `notify_state.json` (the
|
||||||
|
session-expiry alert's send-once dedupe flag). Both are excluded from the
|
||||||
deploy rsync (which runs `--delete`) and from git.
|
deploy rsync (which runs `--delete`) and from git.
|
||||||
|
|
||||||
## If scheduled runs stop working
|
## If scheduled runs stop working
|
||||||
|
|
|
||||||
14
.env.example
14
.env.example
|
|
@ -16,4 +16,16 @@ HOST_PORT=5057
|
||||||
|
|
||||||
# ── Timezone ─────────────────────────────────────────────────────────
|
# ── Timezone ─────────────────────────────────────────────────────────
|
||||||
# Controls what "6:00 AM" means for your schedules
|
# Controls what "6:00 AM" means for your schedules
|
||||||
TZ=America/New_York
|
TZ=America/Phoenix
|
||||||
|
|
||||||
|
# ── Session-expiry email alert (optional) ───────────────────────────
|
||||||
|
# Sent once when the saved MassPrice session expires/is missing — not
|
||||||
|
# on every failed run, just the first one after it goes bad. Same
|
||||||
|
# Gmail-app-password pattern as lunch-notifier: GMAIL_APP_PASSWORD is a
|
||||||
|
# Gmail *app password* (Google Account → Security → App Passwords), not
|
||||||
|
# your real Gmail password — required since Gmail blocks plain SMTP auth
|
||||||
|
# with the account password. Leave blank to disable the alert entirely.
|
||||||
|
GMAIL_USER=
|
||||||
|
GMAIL_APP_PASSWORD=
|
||||||
|
# Defaults to GMAIL_USER if left blank
|
||||||
|
ALERT_EMAIL=
|
||||||
|
|
|
||||||
30
CLAUDE.md
30
CLAUDE.md
|
|
@ -23,10 +23,10 @@ tool is confirmed working, so there's exactly one thing triggering runs.
|
||||||
(`LOGIN_PASSWORD` env var, `hmac.compare_digest`) — this protects the
|
(`LOGIN_PASSWORD` env var, `hmac.compare_digest`) — this protects the
|
||||||
*admin UI itself*, unrelated to the MassPrice/TCGplayer login below.
|
*admin UI itself*, unrelated to the MassPrice/TCGplayer login below.
|
||||||
Routes: `/` (Dashboard — rule cards with schedule editor), `/activity`
|
Routes: `/` (Dashboard — rule cards with schedule editor), `/activity`
|
||||||
(run history), and a small JSON API
|
(run history), `/session` (login-session import), and a small JSON API
|
||||||
(`/api/rules/refresh`, `/api/rules/<id>/schedule`,
|
(`/api/rules/refresh`, `/api/rules/<id>/schedule`,
|
||||||
`/api/rules/<id>/run`) that the Dashboard's JS calls without full page
|
`/api/rules/<id>/run`, `/api/session/import`) that the frontend JS
|
||||||
reloads.
|
calls without full page reloads.
|
||||||
- **`scheduler.py`** — an in-process APScheduler `BackgroundScheduler`
|
- **`scheduler.py`** — an in-process APScheduler `BackgroundScheduler`
|
||||||
holding one `CronTrigger` job per `(rule, schedule entry)` pair,
|
holding one `CronTrigger` job per `(rule, schedule entry)` pair,
|
||||||
rebuilt from `schedules.json` on startup and whenever the webui saves
|
rebuilt from `schedules.json` on startup and whenever the webui saves
|
||||||
|
|
@ -61,7 +61,8 @@ tool is confirmed working, so there's exactly one thing triggering runs.
|
||||||
- **`storage.py`** — tiny JSON read/write helpers (temp-file +
|
- **`storage.py`** — tiny JSON read/write helpers (temp-file +
|
||||||
`os.replace`, so a crash mid-write can't corrupt state) for
|
`os.replace`, so a crash mid-write can't corrupt state) for
|
||||||
everything under `/data`: `schedules.json`, `auth_state.json`,
|
everything under `/data`: `schedules.json`, `auth_state.json`,
|
||||||
`run_history.json` (bounded to the last 200 entries), `rules_cache.json`.
|
`run_history.json` (bounded to the last 200 entries), `rules_cache.json`,
|
||||||
|
`notify_state.json` (the session-expiry alert's send-once dedupe flag).
|
||||||
- **`templates/` / `static/`** — a small, plain dark dashboard. This is
|
- **`templates/` / `static/`** — a small, plain dark dashboard. This is
|
||||||
a deliberately different, simpler visual identity than Arr Summary's
|
a deliberately different, simpler visual identity than Arr Summary's
|
||||||
teletext system — that design language is specific to that app, not a
|
teletext system — that design language is specific to that app, not a
|
||||||
|
|
@ -119,6 +120,27 @@ The Dashboard shows a red banner (`_session_warning()` in `app.py`) when
|
||||||
the most recent run/refresh failed specifically because of a missing/
|
the most recent run/refresh failed specifically because of a missing/
|
||||||
expired session, so this doesn't just look like "no rules found."
|
expired session, so this doesn't just look like "no rules found."
|
||||||
|
|
||||||
|
## Session-expiry email alert
|
||||||
|
|
||||||
|
**`notify.py`** — a thin Gmail-SMTP-with-app-password sender, the same
|
||||||
|
pattern `lunch-notifier` already uses on this box (reused deliberately
|
||||||
|
rather than adding a second notification channel). `GMAIL_USER`/
|
||||||
|
`GMAIL_APP_PASSWORD` (a Gmail **app password**, not the real account
|
||||||
|
password — Gmail blocks plain SMTP auth otherwise) and optional
|
||||||
|
`ALERT_EMAIL` (defaults to `GMAIL_USER`). `send_alert()` is a best-effort
|
||||||
|
no-op — returns `False` and just logs — if those env vars aren't set or
|
||||||
|
the send fails, since a broken mail config shouldn't break automation.
|
||||||
|
|
||||||
|
Fires exactly **once** per expiry, not on every failed scheduled run
|
||||||
|
while it's down: `automation._notify_session_expired_once()` is called
|
||||||
|
from `_ensure_authenticated()` right before it raises, checks/sets a
|
||||||
|
`session_expired_notified` flag in `notify_state.json`, and only sends
|
||||||
|
if that flag isn't already set. The flag resets to `False` the moment
|
||||||
|
`import_cookies()` successfully saves a fresh session, so the next
|
||||||
|
expiry gets its own alert. The flag is only set `True` after a
|
||||||
|
confirmed-sent email — if mail is misconfigured, every failing run keeps
|
||||||
|
retrying the send rather than silently giving up after the first one.
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
2
app.py
2
app.py
|
|
@ -8,7 +8,7 @@ import automation
|
||||||
import scheduler
|
import scheduler
|
||||||
import storage
|
import storage
|
||||||
|
|
||||||
APP_VERSION = "0.3.0"
|
APP_VERSION = "0.4.0"
|
||||||
APP_VERSION_DATE = "2026-09-01"
|
APP_VERSION_DATE = "2026-09-01"
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ from datetime import datetime, timezone
|
||||||
|
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
import notify
|
||||||
import storage
|
import storage
|
||||||
|
|
||||||
logger = logging.getLogger("massprice.automation")
|
logger = logging.getLogger("massprice.automation")
|
||||||
|
|
@ -79,9 +80,33 @@ def _is_logged_out(page):
|
||||||
return "/Rule" not in page.url
|
return "/Rule" not in page.url
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_session_expired_once():
|
||||||
|
"""Send the email alert only on the first failure after the session
|
||||||
|
goes bad, not on every scheduled run that fails while it's down —
|
||||||
|
the flag resets when a fresh session is imported successfully."""
|
||||||
|
state = storage.load("notify_state.json", {})
|
||||||
|
if state.get("session_expired_notified"):
|
||||||
|
return
|
||||||
|
sent = notify.send_alert(
|
||||||
|
"MassPrice Scheduler: login session expired",
|
||||||
|
"The saved MassPrice session has expired or is missing. "
|
||||||
|
"Scheduled rule runs will keep failing until you log back in "
|
||||||
|
"via the Session page in the MassPrice Scheduler webui and "
|
||||||
|
"import a fresh cookie export.",
|
||||||
|
)
|
||||||
|
# Only mark as notified if the email actually sent — an unset/broken
|
||||||
|
# mail config shouldn't silently swallow the next attempt forever.
|
||||||
|
if sent:
|
||||||
|
storage.save(
|
||||||
|
"notify_state.json",
|
||||||
|
{"session_expired_notified": True, "notified_at": datetime.now(timezone.utc).isoformat()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_authenticated(page):
|
def _ensure_authenticated(page):
|
||||||
page.goto(f"{BASE_URL}/Rule/List")
|
page.goto(f"{BASE_URL}/Rule/List")
|
||||||
if _is_logged_out(page):
|
if _is_logged_out(page):
|
||||||
|
_notify_session_expired_once()
|
||||||
raise AutomationError(
|
raise AutomationError(
|
||||||
"MassPrice session expired or missing — log in via the "
|
"MassPrice session expired or missing — log in via the "
|
||||||
"Session page and paste in a fresh cookie export."
|
"Session page and paste in a fresh cookie export."
|
||||||
|
|
@ -264,5 +289,6 @@ def import_cookies(raw_text):
|
||||||
"itself, after finishing login, not from the login page."
|
"itself, after finishing login, not from the login page."
|
||||||
)
|
)
|
||||||
_save_state(context)
|
_save_state(context)
|
||||||
|
storage.save("notify_state.json", {"session_expired_notified": False})
|
||||||
finally:
|
finally:
|
||||||
browser.close()
|
browser.close()
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,8 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- LOGIN_PASSWORD=${LOGIN_PASSWORD}
|
- LOGIN_PASSWORD=${LOGIN_PASSWORD}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- TZ=${TZ:-America/New_York}
|
- TZ=${TZ:-America/Phoenix}
|
||||||
|
- GMAIL_USER=${GMAIL_USER:-}
|
||||||
|
- GMAIL_APP_PASSWORD=${GMAIL_APP_PASSWORD:-}
|
||||||
|
- ALERT_EMAIL=${ALERT_EMAIL:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
34
notify.py
Normal file
34
notify.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Email alerts via Gmail SMTP + app password — the same pattern already
|
||||||
|
used by lunch-notifier on this box, reused here rather than adding a new
|
||||||
|
notification service. Silently a no-op if GMAIL_USER/GMAIL_APP_PASSWORD
|
||||||
|
aren't set, so the alert feature is opt-in.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
logger = logging.getLogger("massprice.notify")
|
||||||
|
|
||||||
|
GMAIL_USER = os.environ.get("GMAIL_USER")
|
||||||
|
GMAIL_APP_PASSWORD = os.environ.get("GMAIL_APP_PASSWORD")
|
||||||
|
ALERT_EMAIL = os.environ.get("ALERT_EMAIL") or GMAIL_USER
|
||||||
|
|
||||||
|
|
||||||
|
def send_alert(subject, body):
|
||||||
|
"""Best-effort: logs and returns False on any failure rather than
|
||||||
|
raising, since a broken alert channel shouldn't break automation."""
|
||||||
|
if not GMAIL_USER or not GMAIL_APP_PASSWORD or not ALERT_EMAIL:
|
||||||
|
return False
|
||||||
|
msg = MIMEText(body)
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = GMAIL_USER
|
||||||
|
msg["To"] = ALERT_EMAIL
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
|
||||||
|
server.login(GMAIL_USER, GMAIL_APP_PASSWORD)
|
||||||
|
server.sendmail(GMAIL_USER, [ALERT_EMAIL], msg.as_string())
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send alert email")
|
||||||
|
return False
|
||||||
Loading…
Add table
Add a link
Reference in a new issue