Add ntfy push and Listmonk newsletter with lightweight Docker deploy tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric
2026-06-08 02:24:55 -05:00
parent 47b1ae8514
commit 22a960799c
24 changed files with 846 additions and 3 deletions

41
backend/integrations.py Normal file
View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Any
import httpx
from .settings import get_settings
async def subscribe_newsletter(
*,
email: str,
name: str = "",
list_ids: list[int] | None = None,
city_slug: str = "",
) -> dict[str, Any]:
settings = get_settings()
if not settings.listmonk_api_key:
return {"ok": False, "error": "newsletter_not_configured"}
payload: dict[str, Any] = {
"email": email.strip(),
"name": name.strip() or email.split("@")[0],
"status": "enabled",
"lists": list_ids or settings.listmonk_default_list_ids,
"preconfirm_subscriptions": True,
}
if city_slug:
payload["attribs"] = {"city": city_slug}
headers = {"Authorization": f"token {settings.listmonk_api_key}"}
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(
f"{settings.listmonk_api_url.rstrip('/')}/api/subscribers",
json=payload,
headers=headers,
)
if response.status_code >= 400:
return {"ok": False, "error": response.text[:300]}
data = response.json()
return {"ok": True, "subscriber": data.get("data")}

View File

@@ -20,6 +20,8 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from .avatar_assets import avatar_for_name
from .integrations import subscribe_newsletter
from .ntfy_push import dispatch_ntfy_for_user, ntfy_subscribe_url, ntfy_topic_for_user
from .meetup_live import (
access_lock_reason,
build_lounge_chat_url,
@@ -756,7 +758,7 @@ def default_notification_preferences(user_id: str, email: str = "") -> dict[str,
return {
"userId": user_id,
"email": email,
"channels": {"in_app": True, "email": False, "wechat": False},
"channels": {"in_app": True, "push": False, "email": False, "wechat": False},
"categories": {
"system": True,
"meetup": True,
@@ -818,7 +820,7 @@ async def create_notification(
metadata: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
try:
return await pb.create_record(
record = await pb.create_record(
"notifications",
{
"title": title,
@@ -839,6 +841,22 @@ async def create_notification(
)
except PocketBaseError:
return None
if target_user_id:
try:
prefs = await notification_preferences_for_user(target_user_id)
await dispatch_ntfy_for_user(
user_id=target_user_id,
preferences=prefs,
title=title,
body=body,
category=category,
priority=priority,
action_url=action_url,
icon=icon,
)
except Exception:
pass
return record
async def receipt_for(notification_id: str, user_id: str) -> dict[str, Any] | None:
@@ -1587,6 +1605,20 @@ async def mark_all_notifications_read(request: Request) -> dict[str, Any]:
return {"ok": True, "count": count}
@app.get("/api/notifications/push-config")
async def notification_push_config(request: Request) -> dict[str, Any]:
_, user = await current_user(request)
if not user:
raise HTTPException(status_code=401, detail="请先登录")
user_id = user["id"]
return {
"ok": True,
"topic": ntfy_topic_for_user(user_id),
"subscribeUrl": ntfy_subscribe_url(user_id),
"serverUrl": settings.ntfy_url,
}
@app.get("/api/notifications/preferences")
async def notification_preferences(request: Request) -> dict[str, Any]:
_, user = await current_user(request)
@@ -1819,6 +1851,28 @@ async def get_meetup(meetup_id: str) -> dict[str, Any]:
return {"ok": True, "item": public_record(record)}
class NewsletterSubscribePayload(BaseModel):
email: str
name: str = ""
citySlug: str = ""
list: str = "general"
@app.post("/api/newsletter/subscribe")
async def newsletter_subscribe(payload: NewsletterSubscribePayload) -> dict[str, Any]:
email = payload.email.strip()
if not email or "@" not in email:
raise HTTPException(status_code=400, detail="邮箱格式不正确")
result = await subscribe_newsletter(
email=email,
name=payload.name,
city_slug=payload.citySlug.strip(),
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("error") or "订阅失败")
return {"ok": True, "subscriber": result.get("subscriber")}
@app.get("/api/meetups/{meetup_id}/session")
async def meetup_session(meetup_id: str, request: Request) -> dict[str, Any]:
record = await fetch_meetup_record(meetup_id)
@@ -1835,6 +1889,7 @@ async def meetup_session(meetup_id: str, request: Request) -> dict[str, Any]:
"displayName": display_name,
"videoUrl": build_mirotalk_join_url(record, display_name) if is_live_event else "",
"chatUrl": build_lounge_chat_url(record) if is_live_event else "",
"replayUrl": str(record.get("replayUrl") or "").strip() if is_live_event else "",
"loungeChannel": lounge_channel,
"mirotalkRoom": str(record.get("mirotalkRoom") or ""),
"meetingProvider": str(record.get("meetingProvider") or ("mirotalk" if is_live_event else "")),

105
backend/ntfy_push.py Normal file
View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import re
from datetime import datetime, time
from typing import Any
from zoneinfo import ZoneInfo
import httpx
from .settings import get_settings
def ntfy_topic_for_user(user_id: str) -> str:
clean = re.sub(r"[^a-zA-Z0-9_-]", "", user_id.strip())[:32]
return f"nomadcna-{clean or 'guest'}"
def ntfy_subscribe_url(user_id: str) -> str:
settings = get_settings()
topic = ntfy_topic_for_user(user_id)
return f"{settings.ntfy_url}/{topic}"
def _in_quiet_hours(preferences: dict[str, Any]) -> bool:
start_raw = str(preferences.get("quietHoursStart") or "22:00")
end_raw = str(preferences.get("quietHoursEnd") or "08:00")
try:
start = time.fromisoformat(start_raw[:5])
end = time.fromisoformat(end_raw[:5])
except ValueError:
return False
now = datetime.now(ZoneInfo("Asia/Shanghai")).time()
if start <= end:
return start <= now <= end
return now >= start or now <= end
def _should_push(preferences: dict[str, Any], category: str, priority: str) -> bool:
channels = preferences.get("channels") or {}
categories = preferences.get("categories") or {}
if not channels.get("push"):
return False
if not categories.get(category, True):
return False
if priority == "high":
return True
return not _in_quiet_hours(preferences)
async def push_ntfy_message(
*,
topic: str,
title: str,
message: str,
click: str = "",
priority: str = "default",
tags: str = "",
) -> bool:
settings = get_settings()
if not settings.ntfy_enabled:
return False
url = f"{settings.ntfy_url.rstrip('/')}/{topic}"
headers: dict[str, str] = {"Title": title[:250]}
if click:
headers["Click"] = click[:1000]
if tags:
headers["Tags"] = tags[:100]
if priority in {"min", "low", "default", "high", "max", "urgent"}:
headers["Priority"] = priority
auth = settings.ntfy_auth_token.strip()
if auth:
headers["Authorization"] = f"Bearer {auth}"
try:
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, content=message[:4000], headers=headers)
return response.status_code < 400
except httpx.HTTPError:
return False
async def dispatch_ntfy_for_user(
*,
user_id: str,
preferences: dict[str, Any],
title: str,
body: str,
category: str = "system",
priority: str = "normal",
action_url: str = "",
icon: str = "🔔",
) -> bool:
if not user_id or not _should_push(preferences, category, priority):
return False
settings = get_settings()
site = settings.site_public_url.rstrip("/")
click = action_url if action_url.startswith("http") else f"{site}{action_url}" if action_url else site
ntfy_priority = "high" if priority == "high" else "default"
return await push_ntfy_message(
topic=ntfy_topic_for_user(user_id),
title=title,
message=body or title,
click=click,
priority=ntfy_priority,
tags=icon,
)

View File

@@ -195,6 +195,31 @@ class Settings:
or os.getenv("NEXT_PUBLIC_LOUNGE_URL")
or "https://lounge.nomadro.cn"
).rstrip("/")
self.listmonk_url = (
os.getenv("LISTMONK_URL")
or os.getenv("NEXT_PUBLIC_LISTMONK_URL")
or "https://listmonk.nomadro.cn"
).rstrip("/")
self.listmonk_api_url = (
os.getenv("LISTMONK_API_URL") or self.listmonk_url
).rstrip("/")
self.listmonk_api_key = os.getenv("LISTMONK_API_KEY", "").strip()
list_ids_raw = os.getenv("LISTMONK_DEFAULT_LIST_IDS", "1")
self.listmonk_default_list_ids = [
int(x.strip()) for x in list_ids_raw.split(",") if x.strip().isdigit()
] or [1]
self.ntfy_url = (
os.getenv("NTFY_URL")
or os.getenv("NEXT_PUBLIC_NTFY_URL")
or "https://ntfy.nomadro.cn"
).rstrip("/")
self.ntfy_auth_token = os.getenv("NTFY_AUTH_TOKEN", "").strip()
self.ntfy_enabled = _bool_env("NTFY_ENABLED", "true")
self.site_public_url = (
os.getenv("NEXT_PUBLIC_SITE_URL")
or os.getenv("SITE_PUBLIC_URL")
or "https://nomadro.cn"
).rstrip("/")
@lru_cache(maxsize=1)