Add ntfy push notifications with action buttons for meal reminders
CI / test-and-package (push) Successful in 37s

- ntfy.py: send_meal_reminder() with Home/Out HTTP action buttons, test helper
- models.py: new columns ntfy_url, ntfy_token, callback_token on users table
- meals.py: /api/ntfy-callback (public, token-authenticated), /ntfy-settings, /ntfy-test
- app.py: flask send-reminders CLI command (cron-schedulable)
- Dashboard: ntfy config form in Account Settings
- i18n: translations for all ntfy strings (en/fr/de)
- Dockerfile: include ntfy.py in COPY
- tests: 15 new tests covering callback, settings, and auth
- docs: updated AGENTS.md and doc/index.md
This commit is contained in:
agentbox
2026-08-01 06:17:26 +00:00
parent 7bf00ee2e3
commit 3681f9dfce
10 changed files with 650 additions and 4 deletions
+112
View File
@@ -0,0 +1,112 @@
"""Ntfy notification helpers — send meal reminders with action buttons."""
from __future__ import annotations
import json
import urllib.request
import urllib.error
from datetime import date
def _ntfy_headers(token: str) -> dict[str, str]:
"""Build Authorization header if token is provided."""
headers = {"Content-Type": "application/json"}
if token.strip():
headers["Authorization"] = f"Bearer {token.strip()}"
return headers
def send_meal_reminder(
ntfy_url: str,
ntfy_token: str,
callback_token: str,
meal_type: str,
for_date: date,
app_base_url: str,
) -> bool:
"""Send a meal reminder notification with Home/Out action buttons.
Args:
ntfy_url: Full topic URL (e.g. https://ntfy.sh/mytopic).
ntfy_token: Bearer token for auth (empty for public topics).
callback_token: Per-user token for the webhook callback.
meal_type: 'lunch' or 'dinner'.
for_date: The meal date.
app_base_url: Base URL of this app (e.g. https://meals.ct.cozytren.ch).
Returns:
True if the notification was sent successfully.
"""
date_str = for_date.isoformat()
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"
payload = {
"topic": ntfy_url.rstrip("/").rsplit("/", 1)[-1],
"title": f"{emoji} {meal_label} — Meal Reminder",
"message": f"Will you be home for {meal_label.lower()} on {date_str}?",
"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,
"meal_type": meal_type,
"date": date_str,
"status": "yes",
}),
},
{
"action": "http",
"label": "🍽 Out",
"method": "POST",
"url": callback_url,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"token": callback_token,
"meal_type": meal_type,
"date": date_str,
"status": "no",
}),
},
],
"click": f"{app_base_url.rstrip('/')}/dashboard?date={date_str}",
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(ntfy_url, data=data, headers=_ntfy_headers(ntfy_token))
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return 200 <= resp.status < 300
except urllib.error.URLError as e:
print(f"[ntfy] Failed to send to {ntfy_url}: {e}")
return False
def send_test_notification(ntfy_url: str, ntfy_token: str) -> bool:
"""Send a simple test notification to verify ntfy configuration."""
payload = {
"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")
req = urllib.request.Request(ntfy_url, data=data, headers=_ntfy_headers(ntfy_token))
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return 200 <= resp.status < 300
except urllib.error.URLError as e:
print(f"[ntfy] Test failed for {ntfy_url}: {e}")
return False