// Package notify posts owner-notice embeds to a Discord webhook. It is // deliberately small and stdlib-only: one POST of a JSON body needs no // Discord library, and the path imports nothing of this repo's session or // OAuth packages — that independence is why a webhook was chosen over a bot // (issue #171, AC9). package notify import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "bookmarkmanager/backend/internal/latest" ) // dangerColor is the dark design branch's --danger, #cf5c4d = 13589581. The // integer is unreadable, so a future edit will otherwise "fix" it — do not: // --ember means new chapter only, and a fault wearing ember would tell the // owner a stall is a release. This is the one colour a fault wears. const dangerColor = 13589581 // Client posts owner notices to one Discord webhook. The webhook address is a // secret in the class of TOKEN_KEY: it is never logged, never rendered, and // never carried in a returned error, which the poller logs. type Client struct { webhookURL string baseURL string // the deployment's public origin; embed URLs resolve against it http *http.Client } // New returns a Client posting to webhookURL. baseURL is the deployment's // public origin (Config.PublicBaseURL); the embed's deep-linked title is // built from it. func New(webhookURL, baseURL string) *Client { return &Client{ webhookURL: webhookURL, baseURL: strings.TrimSuffix(baseURL, "/"), http: &http.Client{Timeout: 10 * time.Second}, } } // Notify posts one owner notice as a Discord embed: the danger colour, the // subject as a deep-linked title, the sentence as the description, the pass // time as the timestamp and the machine word in the footer. The send is // wrapped in a deadline so a hanging Discord cannot hold a poller Lane. On // failure the error carries no part of the webhook address (the poller logs // it), and the caller leaves the suppression row unwritten so the next pass // retries while the condition holds. func (c *Client) Notify(ctx context.Context, f latest.Fault, sentence, href string) error { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() body, err := json.Marshal(c.payload(f, sentence, href)) if err != nil { return fmt.Errorf("owner notice: marshal: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.webhookURL, strings.NewReader(string(body))) if err != nil { return errors.New("owner notice: build request") } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { // The transport error embeds the webhook address; the address is a // secret in the class of TOKEN_KEY and the poller logs this error. return errors.New("owner notice: send failed") } defer resp.Body.Close() // Cap the read: a Discord error page is enough, and draining the body // lets the connection be reused. io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) if resp.StatusCode < 200 || resp.StatusCode > 299 { return fmt.Errorf("owner notice: webhook status %d", resp.StatusCode) } return nil } // payload is the webhook body: one embed and nothing else. No fields, no // thumbnail, no author block — the wire shape is what Discord reads. func (c *Client) payload(f latest.Fault, sentence, href string) webhookPayload { return webhookPayload{Embeds: []embed{{ Color: dangerColor, Title: subject(f), URL: c.baseURL + href, Description: sentence, Timestamp: time.UnixMilli(f.Since).UTC().Format(time.RFC3339), Footer: embedFooter{Text: f.Condition}, }}} } // subject renders the embed's title: the Site the fault is about, with the // machine word so the title needs no per-condition wording here — #172's // conditions pass different sentences, not a different builder. For a fault // that is not one Site's, the machine word stands alone. func subject(f latest.Fault) string { if f.Site == "" { return f.Condition } return f.Site + ": " + f.Condition } type webhookPayload struct { Embeds []embed `json:"embeds"` } type embed struct { Color int `json:"color"` Title string `json:"title"` URL string `json:"url"` Description string `json:"description"` Timestamp string `json:"timestamp"` Footer embedFooter `json:"footer"` } type embedFooter struct { Text string `json:"text"` }