Files
meshcore-analyzer/cmd/server/notify_eval.go
T
efitenandClaude Opus 5.5 b65a75c88d feat(observer-logins): offline mails for an owner's own observers
New event observer.offline.own, open to every role and off by default.
An owner gets the observer offline/online mails for observers bound to
their own login; an admin with observer.offline too gets only the
all-observers mails. Creating a login adds the event to the user's
preferences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:10:00 +01:00

403 lines
15 KiB
Go

package main
import (
"sort"
"strings"
"time"
"github.com/meshcore-analyzer/users"
)
// Node notification evaluator (docs/specs/2026-10-07-node-notifications-design.md).
// evaluateNotifications is a pure function: everything it reads is in
// notifyInput, the time included, so it is tested without a clock or a
// database. The notifier (notify_service.go) gathers the input and acts on
// the result.
// notifyNode is one analyzer node row the evaluator needs.
type notifyNode struct {
Pubkey string // lowercase hex
Name string
Role string // as stored; compared lowercase
LastSeen string // nodes.last_seen, "" when NULL
BatteryMv *int // latest advert telemetry; nil when the node reports none
Foreign bool // nodes.foreign_advert = 1
}
// notifyObserver is one active (not soft-deleted) observer.
type notifyObserver struct {
ID string
Name string
LastSeen string // observers.last_seen, "" when NULL
}
// notifyInput is everything one evaluation reads.
type notifyInput struct {
Now time.Time
Health HealthThresholds // resolved, as Config.GetHealthThresholds returns it
LowMv int // Config.LowBatteryMv
Accounts map[int64]users.User
Prefs []users.NotifyPrefs
Watches []users.NotifyWatch
States []users.NotifyState
Nodes map[string]notifyNode // watched nodes, plus every foreign node when an admin chose foreign.new
Heard map[string]time.Time // newest packet certainly from the node (node page "Last Heard", #2260)
// SharedSeen is, per repeater and room, when its hop prefix (shared with
// other repeaters, last_relayed) was last in a path. Not evidence: it
// only explains an offline mail for a node that is "uncertain" in the UI.
SharedSeen map[string]time.Time
// OfflineAfter is the learned offline threshold per node (#2260,
// adaptiveOffline); a node without one, or with one beyond its role's
// silent window, uses that window.
OfflineAfter map[string]time.Duration
// NewestPacket is the newest packet in the store (zero: unknown). A
// node's silence is measured up to it, not up to Now: when the feed
// stalls, every node's clock stops with it, so a node with a short
// learned window is not mailed offline before IngestStale trips (#2260).
NewestPacket time.Time
Observers []notifyObserver // nil unless an admin chose observer.offline or a user observer.offline.own
// ObserverOwners maps an uppercase observer pubkey to its owner's user id
// (orphaned bindings omitted); nil when observer logins are off.
ObserverOwners map[string]int64
// IngestStale: the newest packet in the store is old, so a silent node
// and a silent feed look the same. node.offline and observer.offline
// are then not compared (states unchanged, no changes).
IngestStale bool
// IngestResumedAt: when the last stale period ended (zero: none since
// startup). For a node or observer whose stored state is good, evidence
// older than this counts as heard at this time, so nothing goes offline
// until a full silent window has passed after the feed came back.
IngestResumedAt time.Time
// External is the fresh externalAlerts feed; nil when it is not
// configured, nobody chose node.external, or the feed is stale. nil
// leaves every node.external state unchanged.
External *externalFeed
}
// notifyChange is one transition for one user.
type notifyChange struct {
Event string
Subject string // node pubkey or observer id
Name string // display name at evaluation time
To string // users.NotifyGood, users.NotifyBad or users.NotifyTold
BatteryMv *int // node.battery only
// SharedSeen: node.offline only, when the node's shared hop prefix was
// still in a path after its last certain packet (zero otherwise).
SharedSeen time.Time
Text string // node.external only: the alert text ("" when resolved)
URL string // node.external only: the alert's link, "" for the node page
At time.Time
}
// notifyResult: Changes per user, ordered by event then name; States are
// the rows to write (new or changed), disabled users included; Drop are the
// rows to delete (admin-event rows of users who are no longer admin).
type notifyResult struct {
Changes map[int64][]notifyChange
States []users.NotifyState
Drop []users.NotifyKey
}
const (
// batteryHysteresisMv: a low battery is good again only at lowMv + 100.
batteryHysteresisMv = 100
// foreignBaselineSubject marks that an admin's foreign.new baseline
// exists: the first evaluation after opting in stores every current
// foreign node as told without mailing; afterwards a foreign node
// without a row is new.
foreignBaselineSubject = "*"
)
var notifyEventRank = map[string]int{
users.NotifyNodeOffline: 0, users.NotifyNodeBattery: 1, users.NotifyNodeExternal: 2,
users.NotifyForeignNew: 3, users.NotifyObserverOffline: 4, users.NotifyObserverOfflineOwn: 5,
}
// nodeLabel is the node's name made safe for a mail line (mailSafeText),
// or the first 8 hex characters of its key.
func nodeLabel(n notifyNode, known bool, pk string) string {
if name := mailSafeText(n.Name); known && name != "" {
return name
}
if len(pk) > 8 {
return pk[:8]
}
return pk
}
// nodeOnlineState is good when the node was heard within its role's silent
// window, like roles.js getNodeStatus: the latest of last_seen and the packet
// store's certain last heard (its own packets and relay hops that can only be
// this node, #2260). last_relayed is not evidence: it also counts 1-byte
// prefix matches shared with other repeaters, which kept a silent repeater
// "online" for days. The window is the role's silent window, or the node's
// learned OfflineAfter when that is shorter (adaptiveOffline). A node
// missing from the analyzer database is bad. While prev is good, the
// latest evidence is never older than in.IngestResumedAt (the outage grace).
// While prev is bad (entered at prevAt), the node comes back only on
// evidence from that moment on: a window that grows back (a learned
// threshold lost to retention or a restart) must not read a still-silent
// node as back online.
func nodeOnlineState(pk string, n notifyNode, known bool, prev string, prevAt time.Time, in *notifyInput) string {
if !known {
return users.NotifyBad
}
last, _ := parseRelayTS(n.LastSeen)
if t, ok := in.Heard[pk]; ok && t.After(last) {
last = t
}
if prev == users.NotifyGood && last.Before(in.IngestResumedAt) {
last = in.IngestResumedAt
}
if prev == users.NotifyBad && !prevAt.IsZero() && last.Before(prevAt) {
return users.NotifyBad
}
now := in.Now
if !in.NewestPacket.IsZero() && in.NewestPacket.Before(now) {
now = in.NewestPacket
}
_, silentMs := in.Health.GetHealthMs(strings.ToLower(n.Role))
silent := time.Duration(silentMs) * time.Millisecond
if a, ok := in.OfflineAfter[pk]; ok && a > 0 && a < silent {
silent = a
}
if last.IsZero() || now.Sub(last) >= silent {
return users.NotifyBad
}
return users.NotifyGood
}
// batteryState applies the low-battery threshold with hysteresis.
func batteryState(mv, lowMv int, prev string) string {
if prev == users.NotifyBad {
if mv >= lowMv+batteryHysteresisMv {
return users.NotifyGood
}
return users.NotifyBad
}
if mv < lowMv {
return users.NotifyBad
}
return users.NotifyGood
}
// observerState: bad when last_seen is older than observerStaleMinutes,
// good again within observerOnlineMinutes. While prev is good, last_seen
// is never older than resumed (the outage grace, zero for none).
func observerState(o notifyObserver, h HealthThresholds, now, resumed time.Time, prev string) string {
t, ok := parseRelayTS(o.LastSeen)
if prev == users.NotifyGood && !resumed.IsZero() && (!ok || t.Before(resumed)) {
t, ok = resumed, true
}
if !ok {
return users.NotifyBad
}
age := now.Sub(t)
if prev == users.NotifyBad {
if age <= time.Duration(h.ObserverOnlineMinutes)*time.Minute {
return users.NotifyGood
}
return users.NotifyBad
}
if age > time.Duration(h.ObserverStaleMinutes)*time.Minute {
return users.NotifyBad
}
return users.NotifyGood
}
// evalExternal compares one watched node's current external alerts with the
// stored node.external states of one user. The first fresh feed after the
// node is watched (no baseline row) stores the current alerts silently;
// afterwards a new or returning alert is a bad change and a stored bad alert
// missing from the feed is a good (resolved) change.
func evalExternal(res *notifyResult, prev map[users.NotifyKey]string, bad []string, current map[string]externalAlert,
uid int64, pk, name string, mailing bool, now time.Time) {
base := users.NotifyKey{UserID: uid, Event: users.NotifyNodeExternal, Subject: pk + "/" + externalBaselineKey}
_, baselined := prev[base]
if !baselined {
res.States = append(res.States, users.NotifyState{NotifyKey: base, State: users.NotifyGood, ChangedAt: now})
}
emit := func(key, state string, a externalAlert) {
k := users.NotifyKey{UserID: uid, Event: users.NotifyNodeExternal, Subject: pk + "/" + key}
res.States = append(res.States, users.NotifyState{NotifyKey: k, State: state, ChangedAt: now})
if baselined && mailing {
res.Changes[uid] = append(res.Changes[uid], notifyChange{Event: users.NotifyNodeExternal, Subject: k.Subject,
Name: name, To: state, Text: a.Text, URL: a.URL, At: now})
}
}
for _, key := range sortedExternalKeys(current) {
if prev[users.NotifyKey{UserID: uid, Event: users.NotifyNodeExternal, Subject: pk + "/" + key}] != users.NotifyBad {
emit(key, users.NotifyBad, current[key])
}
}
sort.Strings(bad)
for _, key := range bad {
if _, still := current[key]; !still {
emit(key, users.NotifyGood, externalAlert{})
}
}
}
// evaluateNotifications computes every chosen (user, event, subject) state
// and compares it with the stored one. A subject without a stored state is
// stored without a change (a restart or a new watch never mails); a stored
// state that differs is a change. Users missing from Accounts are skipped.
// A user with watches but no preferences row gets the defaults (enabled,
// node events). A disabled user's states are kept current without changes,
// so turning notifications on again never mails what happened while off.
// Admin events are evaluated only for admins; a non-admin's admin-event
// rows are dropped, like opting out, so a re-promotion starts silently.
func evaluateNotifications(in notifyInput) notifyResult {
prev := make(map[users.NotifyKey]string, len(in.States))
prevAt := make(map[users.NotifyKey]time.Time, len(in.States)) // when each stored state was entered
// extBad: per user and node, the node.external keys stored as bad.
extBad := map[int64]map[string][]string{}
for _, s := range in.States {
prev[s.NotifyKey] = s.State
prevAt[s.NotifyKey] = s.ChangedAt
if s.Event == users.NotifyNodeExternal && s.State == users.NotifyBad {
if pk, key, ok := strings.Cut(s.Subject, "/"); ok && key != externalBaselineKey {
if extBad[s.UserID] == nil {
extBad[s.UserID] = map[string][]string{}
}
extBad[s.UserID][pk] = append(extBad[s.UserID][pk], key)
}
}
}
watched := map[int64][]string{}
for _, w := range in.Watches {
watched[w.UserID] = append(watched[w.UserID], w.Pubkey)
}
var foreign []string
for pk, n := range in.Nodes {
if n.Foreign {
foreign = append(foreign, pk)
}
}
sort.Strings(foreign)
res := notifyResult{Changes: map[int64][]notifyChange{}}
for _, s := range in.States {
if u, ok := in.Accounts[s.UserID]; ok && u.Role != users.RoleAdmin && users.IsAdminNotifyEvent(s.Event) {
res.Drop = append(res.Drop, s.NotifyKey)
}
}
prefs := make([]users.NotifyPrefs, 0, len(in.Prefs)+len(watched))
hasPrefs := make(map[int64]bool, len(in.Prefs))
for _, p := range in.Prefs {
prefs = append(prefs, p)
hasPrefs[p.UserID] = true
}
var defaulted []int64
for uid := range watched {
if !hasPrefs[uid] {
defaulted = append(defaulted, uid)
}
}
sort.Slice(defaulted, func(i, j int) bool { return defaulted[i] < defaulted[j] })
for _, uid := range defaulted {
prefs = append(prefs, users.DefaultNotifyPrefs(uid))
}
mailing := false // Enabled of the user being evaluated
store := func(k users.NotifyKey, state string) {
res.States = append(res.States, users.NotifyState{NotifyKey: k, State: state, ChangedAt: in.Now})
}
compare := func(k users.NotifyKey, name, state string, mv *int) {
old, had := prev[k]
if had && old == state {
return
}
store(k, state)
if had && mailing {
res.Changes[k.UserID] = append(res.Changes[k.UserID],
notifyChange{Event: k.Event, Subject: k.Subject, Name: name, To: state, BatteryMv: mv, At: in.Now})
}
}
for _, p := range prefs {
u, ok := in.Accounts[p.UserID]
if !ok {
continue
}
mailing = p.Enabled
for _, pk := range watched[p.UserID] {
n, known := in.Nodes[pk]
name := nodeLabel(n, known, pk)
if p.Has(users.NotifyNodeOffline) && !in.IngestStale {
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyNodeOffline, Subject: pk}
state := nodeOnlineState(pk, n, known, prev[k], prevAt[k], &in)
before := len(res.Changes[p.UserID])
compare(k, name, state, nil)
if state == users.NotifyBad && len(res.Changes[p.UserID]) > before {
if seen, ok := in.SharedSeen[pk]; ok && seen.After(in.Heard[pk]) {
res.Changes[p.UserID][before].SharedSeen = seen
}
}
}
if p.Has(users.NotifyNodeBattery) && known && n.BatteryMv != nil {
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyNodeBattery, Subject: pk}
compare(k, name, batteryState(*n.BatteryMv, in.LowMv, prev[k]), n.BatteryMv)
}
if p.Has(users.NotifyNodeExternal) && in.External != nil {
evalExternal(&res, prev, extBad[p.UserID][pk], in.External.Alerts[pk], p.UserID, pk, name, mailing, in.Now)
}
}
if p.Has(users.NotifyObserverOfflineOwn) && !p.Has(users.NotifyObserverOffline) && !in.IngestStale {
for _, o := range in.Observers {
if owner, ok := in.ObserverOwners[strings.ToUpper(o.ID)]; !ok || owner != p.UserID {
continue
}
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyObserverOfflineOwn, Subject: o.ID}
name := mailSafeText(o.Name)
if name == "" {
name = o.ID
}
compare(k, name, observerState(o, in.Health, in.Now, in.IngestResumedAt, prev[k]), nil)
}
}
if u.Role != users.RoleAdmin {
continue
}
if p.Has(users.NotifyForeignNew) {
base := users.NotifyKey{UserID: p.UserID, Event: users.NotifyForeignNew, Subject: foreignBaselineSubject}
_, baselined := prev[base]
if !baselined {
store(base, users.NotifyGood)
}
for _, pk := range foreign {
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyForeignNew, Subject: pk}
if _, told := prev[k]; told {
continue
}
store(k, users.NotifyTold)
if baselined && mailing {
res.Changes[p.UserID] = append(res.Changes[p.UserID], notifyChange{Event: users.NotifyForeignNew, Subject: pk,
Name: nodeLabel(in.Nodes[pk], true, pk), To: users.NotifyTold, At: in.Now})
}
}
}
if p.Has(users.NotifyObserverOffline) && !in.IngestStale {
for _, o := range in.Observers {
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyObserverOffline, Subject: o.ID}
name := mailSafeText(o.Name)
if name == "" {
name = o.ID
}
compare(k, name, observerState(o, in.Health, in.Now, in.IngestResumedAt, prev[k]), nil)
}
}
}
for _, list := range res.Changes {
sort.SliceStable(list, func(i, j int) bool {
if a, b := notifyEventRank[list[i].Event], notifyEventRank[list[j].Event]; a != b {
return a < b
}
if list[i].Name != list[j].Name {
return list[i].Name < list[j].Name
}
return list[i].Subject < list[j].Subject
})
}
return res
}