Files
andros cfdf3aa20d Add _author_from_post helper and fix broken test import
Extract netloc parsing into _author_from_post so tests can import and
verify it independently. _get_author_nick uses it as fallback instead
of duplicating the urlparse logic.
2026-05-19 20:49:02 +02:00

147 lines
4.5 KiB
Python

import logging
import re
import time
from urllib.parse import urlparse
import httpx
import jwt
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
APNS_HOST_PRODUCTION = "https://api.push.apple.com"
APNS_HOST_SANDBOX = "https://api.sandbox.push.apple.com"
TOKEN_TTL = 3300 # 55 minutes (APNs tokens valid for 1 hour)
NAME_CACHE_TTL = 3600 # 1 hour
_token_cache: dict = {"token": None, "generated_at": 0}
_name_cache: dict[str, tuple[str, float]] = {}
def _load_private_key() -> str:
with open(settings.APNS_PRIVATE_KEY_PATH) as f:
return f.read()
def _get_token() -> str:
now = time.time()
if _token_cache["token"] and now - _token_cache["generated_at"] < TOKEN_TTL:
return _token_cache["token"]
private_key = _load_private_key()
token = jwt.encode(
{"iss": settings.APNS_TEAM_ID, "iat": int(now)},
private_key,
algorithm="ES256",
headers={"kid": settings.APNS_KEY_ID},
)
_token_cache["token"] = token
_token_cache["generated_at"] = now
return token
def _author_from_post(post_url: str) -> str:
feed_url = post_url.split("#")[0]
return urlparse(feed_url).netloc or feed_url
def _get_author_nick(feed_url: str) -> str:
now = time.time()
if feed_url in _name_cache:
nick, ts = _name_cache[feed_url]
if now - ts < NAME_CACHE_TTL:
return nick
fallback = _author_from_post(feed_url)
try:
response = requests.get(
f"{settings.RELAY_BASE_URL.rstrip('/')}/feed-content/",
params={"feed": feed_url},
timeout=5,
)
if response.ok:
content = response.json().get("data", {}).get("content", "")
nick_match = re.search(r"^#\+NICK:\s*(.+)$", content, re.MULTILINE)
title_match = re.search(r"^#\+TITLE:\s*(.+)$", content, re.MULTILINE)
nick_val = nick_match.group(1).strip() if nick_match else ""
title_val = title_match.group(1).strip() if title_match else ""
nick = nick_val or title_val or fallback
_name_cache[feed_url] = (nick, now)
return nick
except Exception:
logger.warning("Could not fetch nick for %s", feed_url)
return fallback
def _build_payload(notification: dict) -> dict:
ntype = notification.get("type", "")
post_url = notification.get("post", "")
author_feed = post_url.split("#")[0]
author = _get_author_nick(author_feed)
bodies: dict[str, str] = {
"mention": f"{author} mentioned you",
"reply": f"{author} replied to your post",
"reaction": f"{author} reacted with {notification.get('emoji', '')}",
"boost": f"{author} boosted your post",
}
body = bodies.get(ntype, "New notification")
target_urls: dict[str, str] = {
"mention": post_url,
"reply": notification.get("parent", post_url),
"reaction": notification.get("parent", post_url),
"boost": notification.get("boosted", post_url),
}
target_url = target_urls.get(ntype, post_url)
return {
"aps": {
"alert": {"title": "Org Social", "body": body},
"sound": "default",
},
"notification": notification,
"post_url": post_url,
"target_url": target_url,
}
def send_push(device_token: str, notification: dict, sandbox: bool = False) -> bool:
host = APNS_HOST_SANDBOX if sandbox else APNS_HOST_PRODUCTION
url = f"{host}/3/device/{device_token}"
try:
token = _get_token()
headers = {
"authorization": f"bearer {token}",
"apns-topic": settings.APNS_BUNDLE_ID,
"apns-push-type": "alert",
"apns-priority": "10",
}
with httpx.Client(http2=True, timeout=10.0) as client:
response = client.post(url, json=_build_payload(notification), headers=headers)
if response.status_code == 200:
return True
logger.warning(
"APNs %s for token %s...: %s",
response.status_code,
device_token[:10],
response.text,
)
if response.status_code == 410:
from app.subscriptions.models import Device
Device.objects.filter(device_token=device_token).delete()
logger.info("Removed stale device token %s...", device_token[:10])
return False
except Exception:
logger.exception("Failed to send push to %s...", device_token[:10])
return False