notify_client.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. """Notify! Partner API client. Resource retries belong to the lifecycle owner.
  2. The gateway requires a query token, so never expose request URLs or response
  3. bodies in errors. In particular an uncertain start must retain its activity ID
  4. instead of being retried as a new tile. Contract: getnotifyapp.com/apidocs/.
  5. """
  6. import logging
  7. import re
  8. from typing import Any
  9. from urllib.parse import urlsplit
  10. import httpx
  11. from backend.app.core.logging_filters import redact_query_tokens
  12. BASE_URL = "https://push.getnotifyapp.com"
  13. _ID = re.compile(r"[A-Za-z0-9]{8,32}\Z")
  14. _ACTIVITY_ID = re.compile(r"LA[A-Z0-9]{6}\Z")
  15. _WIDGET_ID = re.compile(r"WG[A-Z0-9]{6}\Z")
  16. class _NotifyTokenFilter(logging.Filter):
  17. def filter(self, record: logging.LogRecord) -> bool:
  18. # httpx formats URL *objects*, which the string-only access-log
  19. # filter cannot redact. Cover debug sessions as well as normal use.
  20. message = record.getMessage()
  21. if BASE_URL in message:
  22. record.msg = redact_query_tokens(message)
  23. record.args = ()
  24. return True
  25. _http_logger = logging.getLogger("httpx")
  26. if not any(isinstance(item, _NotifyTokenFilter) for item in _http_logger.filters):
  27. _http_logger.addFilter(_NotifyTokenFilter())
  28. class NotifyError(Exception):
  29. """Safe user-facing error plus the gateway's structured recovery hints."""
  30. def __init__(
  31. self,
  32. message: str,
  33. *,
  34. status_code: int | None = None,
  35. payload: dict | None = None,
  36. retry_after_seconds: int | None = None,
  37. delivery_state: str | None = None,
  38. ):
  39. super().__init__(message)
  40. self.status_code = status_code
  41. self.payload = payload or {}
  42. activity_id = self.payload.get("activityId")
  43. self.activity_id = activity_id if isinstance(activity_id, str) and _ACTIVITY_ID.fullmatch(activity_id) else None
  44. widget_id = self.payload.get("widgetId")
  45. self.widget_id = widget_id if isinstance(widget_id, str) and _WIDGET_ID.fullmatch(widget_id) else None
  46. self.delivery_state = delivery_state or self.payload.get("deliveryState")
  47. self.retry_after_seconds = retry_after_seconds
  48. self.opening_the_app_may_help = self.payload.get("openingTheAppMayHelp") is True
  49. def notify_credentials(config: dict) -> tuple[str, str]:
  50. """Accept legacy and newer device IDs, and group IDs, without guessing a platform."""
  51. device_id = config.get("device_id")
  52. token = config.get("token")
  53. if not isinstance(device_id, str) or not _ID.fullmatch(device_id.strip()):
  54. raise NotifyError("Notify! requires a valid device or group ID (8–32 letters and digits)")
  55. if not isinstance(token, str) or not token.strip():
  56. raise NotifyError("Notify! requires a device or group token")
  57. for field in (
  58. "live_activities",
  59. "lock_screen_widgets",
  60. "live_activity_privacy",
  61. "live_activity_stage",
  62. "time_sensitive",
  63. ):
  64. if field in config and not isinstance(config[field], bool):
  65. raise NotifyError(f"Notify! {field} must be a boolean")
  66. if config.get("live_activities") is True and device_id.strip().upper().startswith(("GRP", "WB", "MC")):
  67. raise NotifyError("Notify! Live Activities require an iOS device ID")
  68. if config.get("lock_screen_widgets") is True and device_id.strip().upper().startswith(("GRP", "WB", "MC")):
  69. raise NotifyError("Notify! Lock Screen widgets require an iOS device ID")
  70. for field in ("icon_url", "live_activity_button_url"):
  71. if config.get(field) and not _https_url(config[field]):
  72. raise NotifyError(f"Notify! {field} must be an HTTPS URL without embedded credentials")
  73. button = config.get("live_activity_button_url")
  74. if button and len(button) > 512:
  75. raise NotifyError("Notify! dashboard URL must be at most 512 characters")
  76. if config.get("live_activity_style") not in (None, "", "bar", "segments", "none"):
  77. raise NotifyError("Notify! progress style must be bar, segments, or none")
  78. tint = config.get("live_activity_tint")
  79. if tint and (not isinstance(tint, str) or not re.fullmatch(r"#[0-9a-fA-F]{6}", tint)):
  80. raise NotifyError("Notify! tint must be a color in #RRGGBB format")
  81. symbol = config.get("live_activity_symbol")
  82. if symbol and (not isinstance(symbol, str) or len(symbol) > 64 or "\x00" in symbol):
  83. raise NotifyError("Notify! symbol must be at most 64 characters without NUL")
  84. metrics = config.get("live_activity_metrics", [])
  85. choices = {"progress", "eta", "layers", "nozzle", "bed", "chamber"}
  86. if (
  87. not isinstance(metrics, list)
  88. or len(metrics) > 6
  89. or any(not isinstance(m, str) or m not in choices for m in metrics)
  90. ):
  91. raise NotifyError("Notify! metrics must contain up to six supported metric names")
  92. return device_id.strip(), token.strip()
  93. def _https_url(value: Any) -> str | None:
  94. if not isinstance(value, str) or not value.strip():
  95. return None
  96. try:
  97. parsed = urlsplit(value.strip())
  98. if parsed.scheme == "https" and parsed.hostname and not parsed.username and not parsed.password:
  99. return value.strip()
  100. except ValueError:
  101. pass
  102. return None
  103. def notify_supports_photos(device_id: Any) -> bool:
  104. """Browser and group targets receive text notifications without camera photos."""
  105. return isinstance(device_id, str) and not device_id.strip().upper().startswith(("WB", "GRP"))
  106. class NotifyClient:
  107. def __init__(self, client: httpx.AsyncClient):
  108. self.client = client
  109. async def _request(
  110. self, method: str, path: str, token: str, *, params: dict | None = None, content: dict | None = None
  111. ) -> dict:
  112. if not isinstance(token, str) or not token.strip():
  113. raise NotifyError("Notify! requires a device or group token")
  114. try:
  115. response = await self.client.request(
  116. method,
  117. f"{BASE_URL}{path}",
  118. params={"token": token.strip(), **(params or {})},
  119. json=content,
  120. follow_redirects=False,
  121. )
  122. except httpx.HTTPError:
  123. # A timeout after a start may mean Apple accepted it. Do not put
  124. # str(exc) into a notification log: it can include the query token.
  125. raise NotifyError("Notify! could not be reached", delivery_state="unknown") from None
  126. try:
  127. payload = response.json()
  128. except ValueError:
  129. payload = {}
  130. if not isinstance(payload, dict):
  131. payload = {}
  132. if not 200 <= response.status_code < 300 or payload.get("success") is False:
  133. retry = payload.get("retryAfterSeconds", response.headers.get("Retry-After"))
  134. try:
  135. retry = max(1, int(retry)) if retry is not None else None
  136. except (ValueError, TypeError, OverflowError):
  137. retry = None
  138. messages = {
  139. 400: "Notify! rejected the request; check the device registration and message",
  140. 401: "Notify! rejected the credentials",
  141. 403: "Notify! rejected the credentials or resource ID",
  142. 404: "Notify! device or resource was not found",
  143. 409: "Notify! could not apply the request to the current resource",
  144. 410: "Notify! resource is no longer available",
  145. 429: "Notify! rate limit reached; wait before retrying",
  146. 502: "Notify! could not confirm delivery",
  147. 503: "Notify! is temporarily unavailable",
  148. }
  149. if path.startswith("/live-activity/"):
  150. messages.update(
  151. {
  152. 400: "Notify! rejected the request; check device support and the five Live Activity limit",
  153. 409: "Open Notify! on the iOS device and enable Live Activities before starting a tile",
  154. 410: "Notify! Live Activity has ended",
  155. }
  156. )
  157. elif path.startswith("/widgets/"):
  158. messages.update(
  159. {
  160. 400: "Notify! rejected the widget request; check its content and the ten widget limit",
  161. 403: "Notify! rejected the credentials or widget ID",
  162. 409: "Notify! requires a precise widget ID when the device has multiple widgets",
  163. }
  164. )
  165. raise NotifyError(
  166. messages.get(response.status_code, f"Notify! request failed (HTTP {response.status_code})"),
  167. status_code=response.status_code,
  168. payload=payload,
  169. retry_after_seconds=retry,
  170. )
  171. if not payload:
  172. raise NotifyError("Notify! returned an invalid response", delivery_state="unknown")
  173. return payload
  174. @staticmethod
  175. def _id(value: str) -> str:
  176. if not isinstance(value, str) or not _ID.fullmatch(value.strip()):
  177. raise NotifyError("Notify! ID must contain 8–32 letters and digits")
  178. return value.strip()
  179. @staticmethod
  180. def _activity_id(value: str) -> str:
  181. if not isinstance(value, str) or not _ACTIVITY_ID.fullmatch(value):
  182. raise NotifyError("Notify! updates and ends require a precise activity ID")
  183. return value
  184. @staticmethod
  185. def _widget_id(value: str) -> str:
  186. if not isinstance(value, str) or not _WIDGET_ID.fullmatch(value):
  187. raise NotifyError("Notify! widget reads, updates and deletes require a precise widget ID")
  188. return value
  189. async def validate(self, device_id: str, token: str) -> dict:
  190. return await self._request("GET", "/link", token, params={"id": self._id(device_id)})
  191. async def send_notification(
  192. self,
  193. device_id: str,
  194. token: str,
  195. *,
  196. title: str,
  197. text: str,
  198. group_type: str | None = None,
  199. icon_url: str | None = None,
  200. image_url: str | None = None,
  201. time_sensitive: bool = False,
  202. ) -> dict:
  203. content: dict[str, Any] = {"title": title, "text": text}
  204. if group_type:
  205. content["groupType"] = group_type
  206. if time_sensitive:
  207. content["timeSensitive"] = True
  208. if icon_url:
  209. icon = _https_url(icon_url)
  210. if not icon:
  211. raise NotifyError("Notify! icon URL must use HTTPS")
  212. content["iconUrl"] = icon
  213. # Photos use Bambuddy's existing public photo URL. A LAN/HTTP-only
  214. # installation still gets the text, without a broken remote image.
  215. if (image := _https_url(image_url)) and notify_supports_photos(device_id):
  216. content["imageUrl"] = image
  217. result = await self._request("POST", f"/notify-json/{self._id(device_id)}", token, content=content)
  218. if result.get("success") is not True:
  219. raise NotifyError("Notify! did not confirm notification delivery")
  220. if result.get("failureCount", 0):
  221. raise NotifyError("Notify! could not deliver to every device in the group")
  222. return result
  223. async def start_activity(self, device_id: str, token: str, content: dict) -> dict:
  224. device_id = self._id(device_id)
  225. if _ACTIVITY_ID.fullmatch(device_id):
  226. # Legacy device IDs can begin with LA. The gateway resolves IDs by
  227. # lookup, so new=1 against an activity ID would update someone
  228. # else's existing tile rather than create ours. Prove this is a
  229. # registered device without rejecting legitimate legacy IDs.
  230. identity = await self.validate(device_id, token)
  231. if identity.get("type") != "device":
  232. raise NotifyError("Notify! Live Activities require a device ID, not an activity or group ID")
  233. result = await self._request("POST", f"/live-activity/{device_id}", token, params={"new": "1"}, content=content)
  234. activity_id = result.get("activityId")
  235. if not isinstance(activity_id, str) or not _ACTIVITY_ID.fullmatch(activity_id):
  236. raise NotifyError("Notify! did not return an activity ID", delivery_state="unknown")
  237. return result
  238. async def update_activity(self, activity_id: str, token: str, content: dict) -> dict:
  239. return await self._request("POST", f"/live-activity/{self._activity_id(activity_id)}", token, content=content)
  240. async def end_activity(self, activity_id: str, token: str, content: dict | None = None) -> dict:
  241. return await self._request("DELETE", f"/live-activity/{self._activity_id(activity_id)}", token, content=content)
  242. async def get_activity(self, activity_id: str, token: str) -> dict:
  243. return await self._request("GET", f"/live-activity/{self._id(activity_id)}", token)
  244. async def create_widget(self, device_id: str, token: str, content: dict) -> dict:
  245. try:
  246. result = await self._request(
  247. "POST", f"/widgets/{self._id(device_id)}", token, params={"new": "1"}, content=content
  248. )
  249. except NotifyError as error:
  250. # A server error may follow a committed create. A 503 explicitly
  251. # refuses writes; other uncertain creates must not be retried blind.
  252. if error.status_code and error.status_code >= 500 and error.status_code != 503 and not error.delivery_state:
  253. error.delivery_state = "unknown"
  254. raise
  255. widget_id = result.get("widgetId")
  256. if not isinstance(widget_id, str) or not _WIDGET_ID.fullmatch(widget_id):
  257. raise NotifyError("Notify! did not return a widget ID", delivery_state="unknown")
  258. return result
  259. async def update_widget(self, widget_id: str, token: str, content: dict) -> dict:
  260. return await self._request("POST", f"/widgets/{self._widget_id(widget_id)}", token, content=content)
  261. async def get_widget(self, widget_id: str, token: str) -> dict:
  262. return await self._request("GET", f"/widgets/{self._widget_id(widget_id)}", token)
  263. async def list_widgets(self, device_id: str, token: str) -> dict:
  264. result = await self._request("GET", f"/widgets/{self._id(device_id)}", token)
  265. widgets = result.get("widgets")
  266. if not isinstance(widgets, list) or any(
  267. not isinstance(widget, dict)
  268. or not isinstance(widget.get("widgetId"), str)
  269. or not _WIDGET_ID.fullmatch(widget["widgetId"])
  270. for widget in widgets
  271. ):
  272. # Cleanup uses this list to distinguish invalid credentials from
  273. # an already deleted widget. A malformed reply cannot prove absence.
  274. raise NotifyError("Notify! returned an invalid widget list", delivery_state="unknown")
  275. return result
  276. async def delete_widget(self, widget_id: str, token: str) -> dict:
  277. return await self._request("DELETE", f"/widgets/{self._widget_id(widget_id)}", token)