Files
mangaBookmark/backend/internal/notify/notify.go
T
sulthan e93e79c1bb Spec #137: per-Series poll failure state, completion hint, and outbound owner notification (#174)
Implements spec #137 (spec 4 of 4 from wayfinder map #114).

Closes #137.

Tickets: #164, #165, #166, #167, #168, #169, #170, #171, #172, #173 — all closed, landed on this branch.

## Summary
- #164/#168: sixth outcome word `not_found`; per-Site completed marker predicate.
- #165/#169: `poll_failures` row is the failure state; the pass remembers a Site-reported completion.
- #166/#171: two new admin filters (`failing`, `unverified`); outbound owner notification + stall condition.
- #167/#170: a failure names itself on the Series page; the completion hint reaches the owner and decides nothing.
- #172: the other three fault conditions (no-browser-route, sidecar-down, adapter-broken) feeding the notifier.
- #173: the landing verdict line shares the same `latest.FaultsFrom` judgement the notifier uses, so the page and the push cannot disagree.

`cd backend && go test ./...` green on the merged branch (8 packages).

Reviewed-on: #174
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-23 11:31:10 +07:00

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"`
}