// Package apprise delivers notifications through the house Apprise API // (AGENTS.md §8, §11 Phase 9). // // Apprise is the shared notification gateway that already reaches Sam by // email. This client speaks the standard Apprise API server shape: // // POST {base}/notify/{key} JSON {"title","body","type"} // POST {base}/notify JSON {"urls","title","body","type"} (stateless) // // A key names a stored Apprise configuration. When no key is set the client // uses the stateless form with an explicit list of Apprise URLs, which is how // the lab is configured (APPRISE_URLS). Secrets come from environment.d and are // never committed. package apprise import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strings" "time" ) // Notification classes understood by Apprise. const ( TypeInfo = "info" TypeSuccess = "success" TypeWarning = "warning" TypeFailure = "failure" ) // Client posts a notification to one Apprise API server. type Client struct { // BaseURL is the Apprise API root, e.g. http://192.168.20.35:8210. BaseURL string // Key names a stored Apprise configuration. Empty means stateless mode. Key string // URLs is the stateless destination list, used when Key is empty. URLs []string // Token is sent as a bearer token when the Apprise API is protected. Token string HTTP *http.Client } // New builds a client. A zero timeout defaults to ten seconds. func New(baseURL, key string, urls []string, token string, timeout time.Duration) *Client { if timeout <= 0 { timeout = 10 * time.Second } return &Client{ BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), Key: strings.TrimSpace(key), URLs: urls, Token: token, HTTP: &http.Client{Timeout: timeout}, } } // Configured reports whether a delivery can be attempted. A nil client is not // configured, so callers never need to branch on nil. func (c *Client) Configured() bool { if c == nil || strings.TrimSpace(c.BaseURL) == "" { return false } return c.Key != "" || len(c.URLs) > 0 } // notifyBody is the Apprise API request body. `type` selects the notification // class; alerts are warnings. type notifyBody struct { URLs []string `json:"urls,omitempty"` Title string `json:"title,omitempty"` Body string `json:"body"` Type string `json:"type,omitempty"` } // Send delivers one informational notification. func (c *Client) Send(ctx context.Context, title, body string) error { return c.SendType(ctx, title, body, TypeInfo) } // SendType delivers one notification with an explicit Apprise class. A failure // is returned; it is never retried here, so a caller can record the outcome. func (c *Client) SendType(ctx context.Context, title, body, notifyType string) error { if !c.Configured() { return fmt.Errorf("apprise is not configured") } if notifyType == "" { notifyType = TypeInfo } payload := notifyBody{Title: title, Body: body, Type: notifyType} url := c.BaseURL + "/notify" if c.Key != "" { url += "/" + c.Key } else { payload.URLs = c.URLs } raw, err := json.Marshal(payload) if err != nil { return fmt.Errorf("encode apprise request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw)) if err != nil { return fmt.Errorf("build apprise request: %w", err) } req.Header.Set("Content-Type", "application/json; charset=utf-8") if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } resp, err := c.HTTP.Do(req) if err != nil { return fmt.Errorf("post apprise: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("post apprise: status %d", resp.StatusCode) } return nil }