// Package alert watches device state and tells Sam when something needs his // attention (AGENTS.md §4 FR-13, §5 screen 6, §11 Phase 9). // // Two conditions raise an alert: // // - a limit is hit: a device becomes blocked (a weekly window turned on, or a // manual lock was applied), and // - a device is offline too long: presence is false and the last report is // older than the configured threshold. // // Alerts fire once per transition, not once per poll. The last observed // condition is kept in `alert_state`; the first time the engine sees a device it // seeds that state without alerting, so a restart never replays an old limit. // // Delivery goes through Apprise. The `alerts` row is written before delivery, so // a delivery failure leaves a visible, auditable record rather than silence. package alert import ( "context" "fmt" "log/slog" "time" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/apprise" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" ) // DefaultOfflineAfter is how long a device may be unreachable before Sam is // told. Overridable with CONTROL_ALERT_OFFLINE_AFTER. const DefaultOfflineAfter = 30 * time.Minute // DefaultInterval is how often the engine re-evaluates. It bounds how late an // alert can be, and it is short enough that a limit hit feels immediate. const DefaultInterval = 30 * time.Second // Store is the data surface the engine needs. *store.Store satisfies it. type Store interface { ListDevices(ctx context.Context) ([]store.Device, error) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) AlertStates(ctx context.Context) (map[int64]store.AlertState, error) SetAlertState(ctx context.Context, deviceID int64, locked, offline bool) error CreateAlert(ctx context.Context, deviceID *int64, kind, message string) (*store.Alert, error) MarkAlertDelivered(ctx context.Context, id int64, errMsg string) error } // Notifier delivers one alert. *apprise.Client satisfies it; a nil Notifier is // not configured and the engine records the alert as undelivered. type Notifier interface { Configured() bool SendType(ctx context.Context, title, body, notifyType string) error } // Engine evaluates device state on an interval and raises transition alerts. type Engine struct { Store Store Notifier Notifier Log *slog.Logger OfflineAfter time.Duration Interval time.Duration Location *time.Location // Now is injectable so tests are deterministic. Now func() time.Time } // New builds an engine. A zero offlineAfter uses DefaultOfflineAfter; a zero // interval uses DefaultInterval. func New(st Store, notifier Notifier, log *slog.Logger, offlineAfter time.Duration, loc *time.Location) *Engine { if offlineAfter <= 0 { offlineAfter = DefaultOfflineAfter } if log == nil { log = slog.Default() } if loc == nil { loc = time.Local } return &Engine{ Store: st, Notifier: notifier, Log: log, OfflineAfter: offlineAfter, Interval: DefaultInterval, Location: loc, Now: func() time.Time { return time.Now().In(loc) }, } } // Run evaluates once at start-up (to seed state without alerting) and then on // every tick until ctx is cancelled. func (e *Engine) Run(ctx context.Context) { interval := e.Interval if interval <= 0 { interval = DefaultInterval } e.Evaluate(ctx) ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: e.Evaluate(ctx) } } } // Evaluate checks every device once and raises an alert for each condition that // just became true. func (e *Engine) Evaluate(ctx context.Context) { now := e.now() devs, err := e.Store.ListDevices(ctx) if err != nil { e.Log.Error("alert evaluate: list devices", "err", err) return } states, err := e.Store.AlertStates(ctx) if err != nil { e.Log.Error("alert evaluate: alert states", "err", err) return } for _, dev := range devs { eff, err := e.Store.EffectiveState(ctx, dev, now) if err != nil { e.Log.Error("alert evaluate: effective state", "device", dev.Name, "err", err) continue } locked := eff.Locked offline := e.offlineTooLong(dev, now) prev, known := states[dev.ID] if !known { // First observation: remember the condition, alert on nothing. if err := e.Store.SetAlertState(ctx, dev.ID, locked, offline); err != nil { e.Log.Error("alert seed state", "device", dev.Name, "err", err) } continue } if locked && !prev.Locked { e.fire(ctx, dev, store.AlertLimit, limitMessage(dev, eff, e.loc())) } if offline && !prev.Offline { e.fire(ctx, dev, store.AlertOffline, offlineMessage(dev, now)) } if locked != prev.Locked || offline != prev.Offline { if err := e.Store.SetAlertState(ctx, dev.ID, locked, offline); err != nil { e.Log.Error("alert save state", "device", dev.Name, "err", err) } } } } // Raise records and delivers one alert that the engine did not observe itself, // such as a device tamper report arriving over MQTT (FR-17). It is a thin // wrapper over the same fire path, so delivery, recording and the audit row are // identical to an observed alert. func (e *Engine) Raise(ctx context.Context, dev store.Device, kind, message string) { e.fire(ctx, dev, kind, message) } // TamperMessage is the one sentence Sam receives when a kids' client reports // that its enforcement was bypassed. func TamperMessage(dev store.Device, kind, detail string) string { what := tamperDescription(kind) if detail != "" { return fmt.Sprintf("%s reported tampering: %s (%s).", dev.Name, what, detail) } return fmt.Sprintf("%s reported tampering: %s.", dev.Name, what) } // tamperDescription turns the client's short kind token into a readable phrase. func tamperDescription(kind string) string { switch kind { case "accessibility_disabled": return "the Accessibility Service was disabled" case "overlay_revoked": return "the overlay permission was revoked" case "force_stopped": return "the app was force stopped" default: return "the client enforcement was bypassed" } } // fire records an alert and delivers it. A delivery failure is recorded on the // row and logged; it is never retried here, so a broken Apprise cannot spin. func (e *Engine) fire(ctx context.Context, dev store.Device, kind, message string) { deviceID := dev.ID alert, err := e.Store.CreateAlert(ctx, &deviceID, kind, message) if err != nil { e.Log.Error("alert create", "device", dev.Name, "kind", kind, "err", err) return } if e.Notifier == nil || !e.Notifier.Configured() { if err := e.Store.MarkAlertDelivered(ctx, alert.ID, "apprise is not configured"); err != nil { e.Log.Error("alert record", "id", alert.ID, "err", err) } e.Log.Warn("alert not delivered: apprise is not configured", "device", dev.Name, "kind", kind, "message", message) return } title := "Internet Use Control — " + alertTitle(kind) dctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() if err := e.Notifier.SendType(dctx, title, message, apprise.TypeWarning); err != nil { if err := e.Store.MarkAlertDelivered(ctx, alert.ID, err.Error()); err != nil { e.Log.Error("alert record", "id", alert.ID, "err", err) } e.Log.Error("alert delivery failed", "device", dev.Name, "kind", kind, "err", err) return } if err := e.Store.MarkAlertDelivered(ctx, alert.ID, ""); err != nil { e.Log.Error("alert record", "id", alert.ID, "err", err) } e.Log.Info("alert delivered", "device", dev.Name, "kind", kind, "alert_id", alert.ID) } // offlineTooLong reports whether a device has been unreachable past the // threshold. A device that has never reported is never "offline too long": a // freshly registered device is not an alert. func (e *Engine) offlineTooLong(dev store.Device, now time.Time) bool { if dev.Online || dev.LastSeen == nil { return false } return now.Sub(*dev.LastSeen) > e.OfflineAfter } func (e *Engine) now() time.Time { if e.Now == nil { return time.Now().In(e.loc()) } return e.Now() } func (e *Engine) loc() *time.Location { if e.Location == nil { return time.Local } return e.Location } // limitMessage is the one sentence Sam receives when a device is blocked. func limitMessage(dev store.Device, eff store.EffectiveState, loc *time.Location) string { reason := "its schedule" if eff.Reason == "manual" { reason = "a manual lock" } if eff.Until != nil { return fmt.Sprintf("%s reached its limit and is blocked by %s (until %s).", dev.Name, reason, eff.Until.In(loc).Format("15:04")) } return fmt.Sprintf("%s reached its limit and is blocked by %s.", dev.Name, reason) } // offlineMessage is the one sentence Sam receives when a device is unreachable. func offlineMessage(dev store.Device, now time.Time) string { if dev.LastSeen == nil { return fmt.Sprintf("%s has been offline for a while.", dev.Name) } return fmt.Sprintf("%s has been offline for %s.", dev.Name, humanDuration(now.Sub(*dev.LastSeen))) } func alertTitle(kind string) string { switch kind { case store.AlertLimit: return "limit hit" case store.AlertOffline: return "device offline" case store.AlertTamper: return "tamper alert" default: return "notification" } } // humanDuration renders a duration compactly: "2h 10m", "45m", "under a minute". func humanDuration(d time.Duration) string { seconds := int(d.Seconds()) if seconds <= 0 { return "0m" } if seconds < 60 { return "under a minute" } m := seconds / 60 h := m / 60 m %= 60 if h == 0 { return fmt.Sprintf("%dm", m) } return fmt.Sprintf("%dh %02dm", h, m) }