7b22460f5e
Rollout note: the owner_notices table starts empty, so the first pass after deploy sends for conditions already true — correct per one-row-per-episode; say so rather than have it reported as a bug. Prod step: create the webhook, set DISCORD_WEBHOOK_URL on the deployment, redeploy — unset is silent by design, and without that step the feature ships dark. Security invariants preserved: the webhook address is a secret in the class of TOKEN_KEY (never logged, never on a config-printing line), and the owner gate is unchanged.
123 lines
4.3 KiB
Go
123 lines
4.3 KiB
Go
// 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"`
|
|
}
|