Owner notices: the stall, one webhook path from env to Discord (#171)
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.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package latest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// ConditionStall is the owner-notice machine word for a Lane that owed Polls,
|
||||
// made none, and has nothing to say for it. The word is the message's footer
|
||||
// and its suppression key; it is wire-stable. #172 declares the other three
|
||||
// words (no-browser-route, sidecar-down, adapter-broken); this ticket
|
||||
// declares only the stall.
|
||||
const ConditionStall = "stall"
|
||||
|
||||
// OwnerWindow is the class-level staleness boundary every owner-notice
|
||||
// condition measures against — the same twelve hours the Lanes page's
|
||||
// "not checked in 12h" filter uses (internal/web/admin.go). Declared here
|
||||
// once so #172's three conditions and the admin filters share one figure.
|
||||
const OwnerWindow = 12 * time.Hour
|
||||
|
||||
// Fault is one condition the owner is told about, judged from durable rows
|
||||
// alone. Site is "" for a fault that is not one Site's.
|
||||
type Fault struct {
|
||||
Condition string // one of the Condition* words
|
||||
Site string
|
||||
Since int64 // unix ms the episode began; the message's age
|
||||
}
|
||||
|
||||
// FaultInput is everything the judgement reads. A struct so #172's three
|
||||
// conditions can add inputs without changing either caller.
|
||||
type FaultInput struct {
|
||||
Passes []store.LanePass
|
||||
}
|
||||
|
||||
// FaultsFrom judges the owner-notice conditions from durable rows alone, so
|
||||
// the poller and the landing page cannot disagree about what a fault is.
|
||||
//
|
||||
// A Lane stalls when its latest pass shows due > 0, none checked, no skip
|
||||
// value and no refusal — exactly the row the mid-loop browser loss writes
|
||||
// (see the comment at the outcomeUnreachable return in runLanePass), so a
|
||||
// Lane that owed Polls, made none, and has nothing to say for it is a fault.
|
||||
// The no-refusal clause is AC6's amendment: the twice-refused break happens
|
||||
// inside the pass loop, not on an early return, so a newly-gated Site would
|
||||
// otherwise send two messages. A pause is excluded by Skip == "" (SkipPaused):
|
||||
// the owner's own act is not reported back (AC10). The episode began at the
|
||||
// pass that produced the row; the stall test itself has no age clause — the
|
||||
// class-level twelve hours belongs to #172's conditions.
|
||||
func FaultsFrom(in FaultInput, now time.Time) []Fault {
|
||||
var faults []Fault
|
||||
for _, p := range in.Passes {
|
||||
if p.Due > 0 && p.Checked == 0 && p.Skip == "" && p.Refused == 0 {
|
||||
faults = append(faults, Fault{Condition: ConditionStall, Site: p.Site, Since: p.RanAt})
|
||||
}
|
||||
}
|
||||
return faults
|
||||
}
|
||||
|
||||
// Notifier delivers one owner notice. The poller neither retries nor queues:
|
||||
// an error is logged and the suppression row left unwritten, so the next pass
|
||||
// tries again while the condition holds.
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, f Fault, sentence, href string) error
|
||||
}
|
||||
|
||||
// ownerNoticeConditions is every condition this package judges, for the
|
||||
// clear loop in recordPass: a condition absent from a pass's fault list
|
||||
// forgets its episode, so the next occurrence sends again. #172 extends the
|
||||
// list when it adds its conditions.
|
||||
var ownerNoticeConditions = []string{ConditionStall}
|
||||
|
||||
// noticeFor renders one fault's message: the description sentence — condition,
|
||||
// age, repair — and the deep link the embed's title points at. Each condition
|
||||
// provides its own wording; the stall is the only one today (issue #171).
|
||||
func noticeFor(f Fault, row store.LanePass, now time.Time) (sentence, href string) {
|
||||
switch f.Condition {
|
||||
case ConditionStall:
|
||||
return fmt.Sprintf(
|
||||
"%s owed %d Polls and made none — %s; check the Lane's browser sidecar and the Site's challenge state",
|
||||
f.Site, row.Due, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// humanAge renders a duration the way an owner reads it in a message: minutes
|
||||
// under an hour, then hours, then days; a stall that was just born reads
|
||||
// "just now".
|
||||
func humanAge(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user