// Package apprise delivers notifications through the house Apprise API on // `.35:8210` (AGENTS.md §8). Phase 7 uses it for the morning briefs: one brief // per boy, plus one combined brief to Jo and Sam (FR-9, FR-14). // // The Apprise API holds every destination in a stored configuration, selected // by tag. The school service therefore never holds an email address, a phone // number or an SMTP password: it only names a tag such as "harry_email" // (AGENTS.md §10 rule 3). The API shape it speaks is: // // POST {base}/notify/{key} JSON {"tag":["harry_email"],"title","body","type"} // POST {base}/notify JSON {"urls":[...],"title","body","type"} (stateless) // // A failure is returned as an error with the redacted server text, so the // caller can record it and show it loudly (FR-12). Nothing is retried here. package apprise import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // Notification classes understood by Apprise. const ( TypeInfo = "info" TypeSuccess = "success" TypeWarning = "warning" TypeFailure = "failure" ) // Result is the outcome of one delivery attempt. type Result struct { // Status is the HTTP status the Apprise API returned (0 when the request // never completed). Status int // Body is the redacted response text, kept short for the log. Body string // Duration is how long the attempt took. Duration time.Duration } // OK reports whether Apprise accepted the notification for every service. func (r Result) OK() bool { return r.Status >= 200 && r.Status < 300 } // Client posts notifications to one Apprise API server. type Client struct { // BaseURL is the Apprise API root, for example http://192.168.20.35:8210. BaseURL string // Key names a stored Apprise configuration. Empty means stateless mode, // which requires URLs on every send. Key string // Token is sent as a bearer token when the Apprise API is protected. Token string // HTTP is the transport. A nil value means a client with a 15s timeout. HTTP *http.Client } // New builds a client. A zero timeout defaults to fifteen seconds: the morning // brief waits on a real SMTP or WhatsApp gateway, so a short timeout would // report a false failure. func New(baseURL, key, token string, timeout time.Duration) *Client { if timeout <= 0 { timeout = 15 * time.Second } return &Client{ BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), Key: strings.TrimSpace(key), Token: strings.TrimSpace(token), HTTP: &http.Client{Timeout: timeout}, } } // Configured reports whether a delivery can be attempted. A nil client is not // configured, so callers never branch on nil. func (c *Client) Configured() bool { if c == nil || strings.TrimSpace(c.BaseURL) == "" { return false } return true // the base URL is set; a key or explicit URLs are checked on send } // notifyBody is the Apprise API request body. `tag` selects a subset of the // stored configuration; `urls` is the stateless alternative. type notifyBody struct { URLs []string `json:"urls,omitempty"` Tag []string `json:"tag,omitempty"` Title string `json:"title,omitempty"` Body string `json:"body"` Type string `json:"type,omitempty"` } // SendTags delivers one notification to every stored destination that carries // one of the given tags. An empty tag list is refused: sending the whole stored // configuration would hit destinations that are configured for other purposes // (and the empty WhatsApp placeholders), so the failure would be ambiguous. func (c *Client) SendTags(ctx context.Context, tags []string, title, body, notifyType string) (Result, error) { if c == nil || strings.TrimSpace(c.BaseURL) == "" { return Result{}, fmt.Errorf("apprise is not configured: set SCHOOL_APPRISE_URL") } if c.Key == "" { return Result{}, fmt.Errorf("apprise key is not set: set SCHOOL_APPRISE_KEY") } clean := make([]string, 0, len(tags)) for _, t := range tags { if t = strings.TrimSpace(t); t != "" { clean = append(clean, t) } } if len(clean) == 0 { return Result{}, fmt.Errorf("apprise tag is empty: nothing would be delivered") } return c.post(ctx, c.BaseURL+"/notify/"+c.Key, notifyBody{Tag: clean, Title: title, Body: body, Type: notifyType}) } // SendURLs delivers one notification to an explicit stateless destination list. // It is the fallback when no stored configuration is set. func (c *Client) SendURLs(ctx context.Context, urls []string, title, body, notifyType string) (Result, error) { if c == nil || strings.TrimSpace(c.BaseURL) == "" { return Result{}, fmt.Errorf("apprise is not configured: set SCHOOL_APPRISE_URL") } if len(urls) == 0 { return Result{}, fmt.Errorf("apprise destination list is empty") } return c.post(ctx, c.BaseURL+"/notify", notifyBody{URLs: urls, Title: title, Body: body, Type: notifyType}) } func (c *Client) post(ctx context.Context, url string, payload notifyBody) (Result, error) { if payload.Type == "" { payload.Type = TypeInfo } raw, err := json.Marshal(payload) if err != nil { return Result{}, fmt.Errorf("encode apprise request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw)) if err != nil { return Result{}, fmt.Errorf("build apprise request: %w", err) } req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("Accept", "application/json") if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } httpClient := c.HTTP if httpClient == nil { httpClient = &http.Client{Timeout: 15 * time.Second} } started := time.Now() resp, err := httpClient.Do(req) if err != nil { // A transport error is wrapped by net/http; it never echoes a secret. return Result{Duration: time.Since(started)}, fmt.Errorf("post apprise: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) res := Result{Status: resp.StatusCode, Body: Truncate(Redact(strings.TrimSpace(string(body))), 300), Duration: time.Since(started)} if !res.OK() { msg := fmt.Sprintf("apprise returned status %d", resp.StatusCode) if res.Body != "" { msg += ": " + res.Body } return res, fmt.Errorf("post apprise: %s", msg) } return res, nil } // Redact removes anything that must never reach a log, a database row or a // screen: email addresses, phone numbers, Apprise keys and long tokens. // Apprise echoes the failing destination in its error text, so this runs on // every response before the text is stored (AGENTS.md §10 rule 3). func Redact(s string) string { var b strings.Builder b.Grow(len(s)) for i := 0; i < len(s); { c := s[i] if isEmailStart(c) { if j := emailEnd(s, i); j > i { b.WriteString("") i = j continue } } if isDigit(c) { // A run of seven or more digits is a phone number or a token. j := i for j < len(s) && (isDigit(s[j]) || s[j] == '-' || s[j] == ' ') && j-i < 24 { j++ } run := strings.NewReplacer("-", "", " ", "").Replace(s[i:j]) if len(run) >= 7 { b.WriteString("") i = j continue } } b.WriteByte(c) i++ } return b.String() } func isEmailStart(c byte) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || isDigit(c) } func isDigit(c byte) bool { return c >= '0' && c <= '9' } // emailEnd returns the index just past an address starting at i, or i when the // text at i is not an address. func emailEnd(s string, i int) int { j := i for j < len(s) && (isEmailStart(s[j]) || s[j] == '.' || s[j] == '_' || s[j] == '%' || s[j] == '+' || s[j] == '-') { j++ } if j >= len(s) || s[j] != '@' { return i } j++ host := j for j < len(s) && (isEmailStart(s[j]) || s[j] == '.' || s[j] == '-') { j++ } if j == host || !strings.Contains(s[host:j], ".") { return i } return j } // Truncate shortens s to at most n bytes, marking that it was cut. func Truncate(s string, n int) string { if n <= 0 || len(s) <= n { return s } cut := s[:n] if i := strings.LastIndexAny(cut, " \n"); i > n/2 { cut = cut[:i] } return cut + "… (truncated)" }