Add ntfy push and Listmonk newsletter with lightweight Docker deploy tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
41
backend/integrations.py
Normal file
41
backend/integrations.py
Normal 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")}
|
||||
@@ -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
105
backend/ntfy_push.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user