Phase 9: Hub API and Apprise alerts (/capabilities, /ask, /notify)

Build on Phases 1-8. Adds the project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md) and the alert engine from
AGENTS.md §4 FR-12/FR-13, §8, §11 Phase 9.

- hub: internal/hub implements AskRequest/AskResponse/NotifyRequest and a
  deterministic, read-only Answerer. It answers screen time today/yesterday,
  what is blocked now, and device presence, scoped to one child or all kids.
  Screen time is the foreground-app total per device-day; the domain total is
  used only when a device reports no apps, so a report carrying both is never
  double-counted.
- apprise: internal/apprise is a small Apprise API client (stored key or
  stateless URL mode, optional bearer token, typed notifications).
- alert: internal/alert evaluates device state on an interval and raises one
  Apprise alert per transition: a limit hit (a device becomes blocked) or a
  device offline longer than the threshold. A never-seen device is never
  alerted, and the first observation seeds state without replaying an old
  limit. The alert is recorded before delivery, with its delivery outcome.
- store: migration 005 adds `alerts` (delivery outcome, audited) and
  `alert_state` (per-device dedup). New usage reads from the telemetry detail
  table so the daily answer can be checked independently.
- web: GET /capabilities, POST /ask (read-only) and POST /notify behind the
  shared CONTROL_HUB_TOKEN, separate from the session and admin API tokens.
  /notify accepts the contract's {from,level,message,person} and delivers it
  through Apprise (the future tamper-report path, FR-17).
- cmd/control: start the alert engine (CONTROL_ALERTS_ENABLED,
  CONTROL_ALERT_OFFLINE_AFTER, CONTROL_ALERT_INTERVAL) and add `alerts` and
  `usage` subcommands for read-only inspection.
- deploy/verify-phase9.sh + `make verify-phase9` prove the definition of done;
  version 0.7.0-phase9.

Measured on .13 (2026-10-13):
- GET /capabilities -> internet_use_control topics.
- POST /ask "how long have the kids been on screens today?" -> "The kids have
  had 1h 22m of screen time today." (total_seconds=4925); the independent
  count from the telemetry detail table is also 4925 (app=4925, domain=4800).
- A manual lock raised a delivered limit alert (id 4); an MQTT offline status
  raised a delivered offline alert (id 5); POST /notify delivered (id 6). All
  three report delivered=1 in `alerts`.

Tests: gofmt, go vet ./... and go test ./... pass in golang:1.25-alpine.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 16:41:07 +11:00
parent 4f233a8012
commit 3cf353d933
23 changed files with 2881 additions and 24 deletions

263
internal/alert/alert.go Normal file
View File

@@ -0,0 +1,263 @@
// 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)
}
}
}
}
// 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"
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)
}