"""Ntfy: push a notification to a phone. The flow-level counterpart to the engine's own alerting — this is a flow deciding something is worth saying, rather than the engine reporting that it broke. """ from __future__ import annotations import logging from collections.abc import Iterable from typing import Any from pydantic import BaseModel, ConfigDict, Field from fluksio.flow.messages import MessageSpec from fluksio.flow.nodes.base import Node from fluksio.flow.nodes.http import shared_client logger = logging.getLogger(__name__) class NtfyNode(Node): """Publish an incoming value as a notification on an ntfy topic.""" # A notification sent twice is read twice. idempotent = False class Params(BaseModel): model_config = ConfigDict(extra="allow") server: str = Field( default="https://ntfy.sh", description="Base URL of the ntfy server." ) topic: str = Field(description="Topic to publish to.") title: str = Field(default="", description="Notification title.") priority: str = Field( default="default", description="min, low, default, high or urgent.", ) tags: str = Field(default="", description="Comma-separated ntfy tags.") token: str = Field( default="", description="Access token, for a server that needs one.", json_schema_extra={"x-secret": True}, ) timeout: float = Field(default=10.0, gt=0) __slots__ = ("cfg",) def __init__( self, requires: MessageSpec | Iterable[MessageSpec] = (), provides: MessageSpec | Iterable[MessageSpec] = (), params: dict[str, Any] | None = None, name: str | None = None, ): self.cfg = self.Params.model_validate(params or {}) super().__init__( f=self._notify, requires=requires, provides=provides, params=params, name=name or "ntfy", ) def _notify(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None: if not kwargs: return None body = str(next(iter(kwargs.values()))) headers = {"Priority": self.cfg.priority} if self.cfg.title: headers["Title"] = self.cfg.title if self.cfg.tags: headers["Tags"] = self.cfg.tags if self.cfg.token: headers["Authorization"] = f"Bearer {self.cfg.token}" response = shared_client().post( f"{self.cfg.server.rstrip('/')}/{self.cfg.topic}", content=body.encode(), headers=headers, timeout=self.cfg.timeout, ) response.raise_for_status() return None