diff --git a/apprise/notify.go b/apprise/notify.go new file mode 100644 index 0000000..5003fd6 --- /dev/null +++ b/apprise/notify.go @@ -0,0 +1,167 @@ +package apprise + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + + "github.com/safing/portbase/utils" +) + +// Notifier sends messsages to an Apprise API. +type Notifier struct { + // URL defines the Apprise API endpoint. + URL string + + // DefaultType defines the default message type. + DefaultType MsgType + + // DefaultTag defines the default message tag. + DefaultTag string + + // DefaultFormat defines the default message format. + DefaultFormat MsgFormat + + // AllowUntagged defines if untagged messages are allowed, + // which are sent to all configured apprise endpoints. + AllowUntagged bool + + client *http.Client + clientLock sync.Mutex +} + +// Message represents the message to be sent to the Apprise API. +type Message struct { + // Title is an optional title to go along with the body. + Title string `json:"title,omitempty"` + + // Body is the main message content. This is the only required field. + Body string `json:"body"` + + // Type defines the message type you want to send as. + // The valid options are info, success, warning, and failure. + // If no type is specified then info is the default value used. + Type MsgType `json:"type,omitempty"` + + // Tag is used to notify only those tagged accordingly. + // Use a comma (,) to OR your tags and a space ( ) to AND them. + Tag string `json:"tag,omitempty"` + + // Format optionally identifies the text format of the data you're feeding Apprise. + // The valid options are text, markdown, html. + // The default value if nothing is specified is text. + Format MsgFormat `json:"format,omitempty"` +} + +// MsgType defines the message type. +type MsgType string + +// Message Types. +const ( + TypeInfo MsgType = "info" + TypeSuccess MsgType = "success" + TypeWarning MsgType = "warning" + TypeFailure MsgType = "failure" +) + +// MsgFormat defines the message format. +type MsgFormat string + +// Message Formats. +const ( + FormatText MsgFormat = "text" + FormatMarkdown MsgFormat = "markdown" + FormatHTML MsgFormat = "html" +) + +type errorResponse struct { + Error string `json:"error"` +} + +// Send sends a message to the Apprise API. +func (n *Notifier) Send(ctx context.Context, m *Message) error { + // Check if the message has a body. + if m.Body == "" { + return errors.New("the message must have a body") + } + + // Apply notifier defaults. + n.applyDefaults(m) + + // Check if the message is tagged. + if m.Tag == "" && !n.AllowUntagged { + return errors.New("the message must have a tag") + } + + // Marshal the message to JSON. + payload, err := json.Marshal(m) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + // Create request. + request, err := http.NewRequestWithContext(ctx, http.MethodPost, n.URL, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + request.Header.Set("Content-Type", "application/json") + + // Send message to API. + resp, err := n.getClient().Do(request) + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated, http.StatusNoContent, http.StatusAccepted: + return nil + default: + // Try to tease body contents. + if body, err := io.ReadAll(resp.Body); err == nil && len(body) > 0 { + // Try to parse json response. + errorResponse := &errorResponse{} + if err := json.Unmarshal(body, errorResponse); err == nil && errorResponse.Error != "" { + return fmt.Errorf("failed to send message: apprise returned %q with an error message: %s", resp.Status, errorResponse.Error) + } + return fmt.Errorf("failed to send message: %s (body teaser: %s)", resp.Status, utils.SafeFirst16Bytes(body)) + } + return fmt.Errorf("failed to send message: %s", resp.Status) + } +} + +func (n *Notifier) applyDefaults(m *Message) { + if m.Type == "" { + m.Type = n.DefaultType + } + if m.Tag == "" { + m.Tag = n.DefaultTag + } + if m.Format == "" { + m.Format = n.DefaultFormat + } +} + +// SetClient sets a custom http client for accessing the Apprise API. +func (n *Notifier) SetClient(client *http.Client) { + n.clientLock.Lock() + defer n.clientLock.Unlock() + + n.client = client +} + +func (n *Notifier) getClient() *http.Client { + n.clientLock.Lock() + defer n.clientLock.Unlock() + + // Create client if needed. + if n.client == nil { + n.client = &http.Client{} + } + + return n.client +}