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:
2026-08-23 00:06:39 +07:00
parent 5a32943528
commit 7b22460f5e
13 changed files with 785 additions and 18 deletions
+101
View File
@@ -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))
}
}