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
113 lines
3.7 KiB
Python
113 lines
3.7 KiB
Python
"""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
|