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.
102 lines
4.0 KiB
Go
102 lines
4.0 KiB
Go
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))
|
|
}
|
|
}
|