fix: send ntfy metadata as HTTP headers, add auto-login from notification click
CI / test-and-package (push) Successful in 42s

- ntfy.py: switch from JSON body to HTTP headers (Title, Priority, Tags,
  Actions, Click) with plain message body. ntfy servers treat the POST
  body as the message, so JSON body fields were ignored and displayed as
  raw JSON.
- ntfy.py: replace em dashes in headers with hyphens (HTTP headers must
  be latin-1 encodable).
- meals.py: add /auto-login/<token> route that logs users in via their
  ntfy callback token so clicking a notification opens the dashboard
  without re-entering credentials.
- ntfy.py: update Click URL to /auto-login/<token>?date=...
This commit is contained in:
agentbox
2026-08-01 06:50:23 +00:00
parent 8466a6e029
commit 09eda19fc4
2 changed files with 83 additions and 51 deletions
+27
View File
@@ -342,6 +342,33 @@ def ntfy_callback():
return {"message": "Response recorded", "status": status}, 200 return {"message": "Response recorded", "status": status}, 200
# ── Auto-login from ntfy notification click ──────────────────────────────────
@meals_bp.route("/auto-login/<token>")
def auto_login(token):
"""Log in a user via their ntfy callback token, then redirect to dashboard.
Clicking an ntfy notification opens this URL. The callback token
identifies the user, so they don't need to re-enter credentials.
"""
user = get_user_by_callback_token(token)
if user is None:
flash(t("Invalid login link."), "error")
return redirect(url_for("auth.login"))
session["user_id"] = user["id"]
session["username"] = user["username"]
session["is_admin"] = bool(user["is_admin"])
session["household_id"] = user["household_id"]
session["household_name"] = user["household_name"] or ""
date_param = request.args.get("date", "")
target = url_for("meals.dashboard")
if date_param:
target += f"?date={date_param}"
return redirect(target)
# ── Cron tick (called by meal-cron container) ───────────────────────────────── # ── Cron tick (called by meal-cron container) ─────────────────────────────────
REMINDER_HOURS = {"lunch": 10, "dinner": 16} REMINDER_HOURS = {"lunch": 10, "dinner": 16}
+48 -43
View File
@@ -8,9 +8,9 @@ import urllib.error
from datetime import date from datetime import date
def _ntfy_headers(token: str) -> dict[str, str]: def _ntfy_headers(token: str, extra: dict[str, str] | None = None) -> dict[str, str]:
"""Build Authorization header if token is provided.""" """Build headers for ntfy request, optionally with Authorization."""
headers = {"Content-Type": "application/json"} headers = dict(extra) if extra else {}
if token.strip(): if token.strip():
headers["Authorization"] = f"Bearer {token.strip()}" headers["Authorization"] = f"Bearer {token.strip()}"
return headers return headers
@@ -39,49 +39,54 @@ def send_meal_reminder(
""" """
date_str = for_date.isoformat() date_str = for_date.isoformat()
meal_label = "Lunch" if meal_type == "lunch" else "Dinner" meal_label = "Lunch" if meal_type == "lunch" else "Dinner"
emoji = "🌤" if meal_type == "lunch" else "🌙"
callback_url = f"{app_base_url.rstrip('/')}/api/ntfy-callback" callback_url = f"{app_base_url.rstrip('/')}/api/ntfy-callback"
payload = { topic = ntfy_url.rstrip("/").rsplit("/", 1)[-1]
"topic": ntfy_url.rstrip("/").rsplit("/", 1)[-1], message = f"Will you be home for {meal_label.lower()} on {date_str}?"
"title": f"{emoji} {meal_label} — Meal Reminder",
"message": f"Will you be home for {meal_label.lower()} on {date_str}?", home_body = json.dumps({
"priority": 4,
"tags": ["plate_with_cutlery"],
"actions": [
{
"action": "http",
"label": "🏠 Home",
"method": "POST",
"url": callback_url,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"token": callback_token, "token": callback_token,
"meal_type": meal_type, "meal_type": meal_type,
"date": date_str, "date": date_str,
"status": "yes", "status": "yes",
}), })
}, out_body = json.dumps({
{
"action": "http",
"label": "🍽 Out",
"method": "POST",
"url": callback_url,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"token": callback_token, "token": callback_token,
"meal_type": meal_type, "meal_type": meal_type,
"date": date_str, "date": date_str,
"status": "no", "status": "no",
}), })
},
],
"click": f"{app_base_url.rstrip('/')}/dashboard?date={date_str}",
}
data = json.dumps(payload).encode("utf-8") actions = json.dumps([
req = urllib.request.Request(ntfy_url, data=data, headers=_ntfy_headers(ntfy_token)) {
"action": "http",
"label": "Home",
"method": "POST",
"url": callback_url,
"headers": {"Content-Type": "application/json"},
"body": home_body,
},
{
"action": "http",
"label": "Out",
"method": "POST",
"url": callback_url,
"headers": {"Content-Type": "application/json"},
"body": out_body,
},
])
headers = _ntfy_headers(ntfy_token, {
"Title": f"{meal_label} - Meal Reminder",
"Priority": "4",
"Tags": "plate_with_cutlery",
"Actions": actions,
"Click": f"{app_base_url.rstrip('/')}/auto-login/{callback_token}?date={date_str}",
})
data = message.encode("utf-8")
req = urllib.request.Request(ntfy_url, data=data, headers=headers)
try: try:
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
@@ -93,16 +98,16 @@ def send_meal_reminder(
def send_test_notification(ntfy_url: str, ntfy_token: str) -> bool: def send_test_notification(ntfy_url: str, ntfy_token: str) -> bool:
"""Send a simple test notification to verify ntfy configuration.""" """Send a simple test notification to verify ntfy configuration."""
payload = { topic = ntfy_url.rstrip("/").rsplit("/", 1)[-1]
"topic": ntfy_url.rstrip("/").rsplit("/", 1)[-1],
"title": "✅ Meal Tracker — Test",
"message": "Your ntfy integration is working! You'll receive meal reminders here.",
"priority": 3,
"tags": ["white_check_mark"],
}
data = json.dumps(payload).encode("utf-8") headers = _ntfy_headers(ntfy_token, {
req = urllib.request.Request(ntfy_url, data=data, headers=_ntfy_headers(ntfy_token)) "Title": "Meal Tracker - Test",
"Priority": "3",
"Tags": "white_check_mark",
})
data = "Your ntfy integration is working! You'll receive meal reminders here.".encode("utf-8")
req = urllib.request.Request(ntfy_url, data=data, headers=headers)
try: try:
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp: