From f00b5a19b1bec1ee1b6313d719630834570a3d4e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 13:57:33 +0200 Subject: [PATCH] Say what the operator needs to do, keep quiet what needs nothing, and take answers from the desk The operator could not tell from a notification whether to act, nor act without opening a session. Each message now opens with a verdict, a warning that needs nothing is not sent, and a notification offers answers the messenger performs through their seat verbs; dunst's send gains actions and says the chosen one (hq ADR 0253). --- modules/dunst/README.md | 12 +- modules/dunst/cmd/dunst-tools/actions.go | 168 ++++++++++++ modules/dunst/cmd/dunst-tools/actions_test.go | 92 +++++++ modules/dunst/cmd/dunst-tools/dunst.go | 12 +- modules/dunst/cmd/dunst-tools/main.go | 9 +- .../cmd/dunst-tools/manifest_helpers_test.go | 1 + modules/dunst/module.json | 5 +- modules/messenger/README.md | 14 + modules/messenger/cmd/messenger/answers.go | 238 +++++++++++++++++ modules/messenger/cmd/messenger/condition.go | 10 + modules/messenger/cmd/messenger/desktop.go | 13 + modules/messenger/cmd/messenger/holder.go | 63 ++++- .../messenger/cmd/messenger/holder_test.go | 5 +- modules/messenger/cmd/messenger/main.go | 38 +++ .../messenger/cmd/messenger/manifest_test.go | 7 +- modules/messenger/cmd/messenger/outbox.go | 57 +++- modules/messenger/cmd/messenger/words_test.go | 247 ++++++++++++++---- modules/messenger/module.json | 9 +- 18 files changed, 911 insertions(+), 89 deletions(-) create mode 100644 modules/dunst/cmd/dunst-tools/actions.go create mode 100644 modules/dunst/cmd/dunst-tools/actions_test.go create mode 100644 modules/messenger/cmd/messenger/answers.go diff --git a/modules/dunst/README.md b/modules/dunst/README.md index 59c90c88..303c1ff6 100644 --- a/modules/dunst/README.md +++ b/modules/dunst/README.md @@ -13,6 +13,16 @@ The notifier as a module (novox/hq ADR 0208, research 026/05). - **Requires no display of its own.** dunst speaks both X11 and Wayland and picks the one the session has, so it serves an X session and a later sway one alike. +## Answers from a notification + +`send` with `actions` runs `notify-send --wait` with one `--action` per answer, answers the id at once and +waits in this bundle for the operator's choice (dunst offers the actions on a middle click, or in its +context menu). The token chosen is said as this module's event `action-chosen`; it becomes the +node-notifier seat's event once seats publish events of their own (novox/hq to-be 46 phase 3). Nothing +is said for a notification closed, expired or replaced without an answer, and a wait ends after seven +days. A restart of this bundle ends the waits it held: the notification stays shown, and its answers do +nothing then (novox/hq ADR 0253). + ## Tools Every tool goes over the account's session bus. None needs the screen, and each answers clearly when @@ -20,7 +30,7 @@ the account is not logged in. | tool | does | |---|---| -| `node-notifier.send` | a notification: title, body, urgency, sender, icon, how long; answers its id | +| `node-notifier.send` | a notification: title, body, urgency, sender, icon, how long, and the answers it offers (`actions`: token and label); answers its id, and says the chosen answer later as the event `action-chosen` {id, token, machine} | | `node-notifier.history` | what was shown, newest first, with how long ago | | `dunst_pause` / `dunst_resume` | do not disturb: notifications are held back, not lost | | `dunst_close_all` | clear the screen; the history keeps them | diff --git a/modules/dunst/cmd/dunst-tools/actions.go b/modules/dunst/cmd/dunst-tools/actions.go new file mode 100644 index 00000000..d99ca055 --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/actions.go @@ -0,0 +1,168 @@ +package main + +// Answers from a notification (novox/hq ADR 0253, to-be 46 §11 "the desk"): `send` takes actions, each a +// token and a label. dunst offers them on the notification (a middle click, or its context menu, which +// is the node-launcher's menu); the one the operator chooses is emitted as this module's event +// `action-chosen` {id, token, machine}, and the operator channel's holder acts on it. It is this module's +// event until seats publish events of their own (to-be 46 phase 3), when it becomes the node-notifier +// seat's. +// +// notify-send waits for the answer (`--wait`) in this process: the id is answered at once, and the +// answer, if any, comes later. An answer is never invented: a notification closed, expired or replaced +// emits nothing, and a wait ends after AnswerWait. A restart of this bundle ends the waits it held, +// which the README says; the notification stays shown and its answers do nothing then. + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "syscall" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Choice is one action a notification offers. +type Choice struct { + Token string `json:"token"` + Label string `json:"label"` +} + +const ( + // MostChoices is how many actions one notification offers. + MostChoices = 4 + // AnswerWait is how long an answer is waited for. + AnswerWait = 7 * 24 * time.Hour + // ChosenEvent is the event an answer is said as. + ChosenEvent = "action-chosen" +) + +var tokenShape = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +// emit says an event as this module; a seam a test replaces. +var emit = func(event string, body any) error { return stdio.Emit(event, body) } + +// choicesOf reads `actions`: a list of {token, label}. +func choicesOf(args map[string]any) ([]Choice, error) { + v, present := args["actions"] + if !present || v == nil { + return nil, nil + } + list, ok := v.([]any) + if !ok { + return nil, errors.New("actions is a list of {token, label}") + } + if len(list) > MostChoices { + return nil, fmt.Errorf("at most %d actions", MostChoices) + } + var out []Choice + seen := map[string]bool{} + for _, it := range list { + o, ok := it.(map[string]any) + if !ok { + return nil, errors.New("actions is a list of {token, label}") + } + tok, _ := o["token"].(string) + label, _ := o["label"].(string) + label = strings.TrimSpace(label) + switch { + case !tokenShape.MatchString(tok): + return nil, fmt.Errorf("an action's token is letters, digits, - and _, at most 64: %q", tok) + case seen[tok]: + return nil, fmt.Errorf("the token %q is given twice", tok) + case label == "" || len(label) > 40 || strings.ContainsAny(label, "\n="): + return nil, fmt.Errorf("an action's label is one short line without '=': %q", label) + } + seen[tok] = true + out = append(out, Choice{Token: tok, Label: label}) + } + return out, nil +} + +// actionArgs are notify-send's arguments for the choices, and its wait. +func actionArgs(choices []Choice) []string { + args := []string{"--wait"} + for _, c := range choices { + args = append(args, "--action="+c.Token+"="+c.Label) + } + return args +} + +// sendWaiting runs notify-send with the choices, answers the id it prints first, and waits for the +// answer in the background: the next line it prints, if it is one of the tokens. +func (s Session) sendWaiting(args []string, choices []Choice) (SendResult, error) { + path, err := exec.LookPath("notify-send") + if err != nil { + return SendResult{}, errors.New("notify-send is not installed on this machine") + } + cmd := exec.Command(path, args...) + cmd.Env = s.Env() + if home := operatorHome(); exists(home) { + cmd.Dir = home + } + out, err := cmd.StdoutPipe() + if err != nil { + return SendResult{}, err + } + var errOut capped + cmd.Stderr = &errOut + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + return SendResult{}, fmt.Errorf("notify-send: %w", err) + } + lines := bufio.NewReader(out) + first := make(chan string, 1) + go func() { + line, _ := lines.ReadString('\n') + first <- strings.TrimSpace(line) + }() + var idLine string + select { + case idLine = <-first: + case <-time.After(busTimeout): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + _ = cmd.Wait() + return SendResult{}, fmt.Errorf("notify-send did not answer an id within %s: %w", busTimeout, ErrTimedOut) + } + id, err := strconv.Atoi(idLine) + if err != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + _ = cmd.Wait() + return SendResult{}, fmt.Errorf("notify-send answered no id: %q %s", idLine, strings.TrimSpace(errOut.String())) + } + go awaitAnswer(cmd, lines, id, choices) + return SendResult{ID: id}, nil +} + +// awaitAnswer reads what notify-send says when the notification ends, and says a chosen token. +func awaitAnswer(cmd *exec.Cmd, lines io.Reader, id int, choices []Choice) { + timer := time.AfterFunc(AnswerWait, func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }) + defer timer.Stop() + rest, _ := io.ReadAll(io.LimitReader(lines, 4096)) + _ = cmd.Wait() + if tok, ok := chosenToken(string(rest), choices); ok { + body := map[string]any{"id": id, "token": tok, "machine": os.Getenv("MESH_NODE")} + if err := emit(ChosenEvent, body); err != nil { + fmt.Fprintf(os.Stderr, "[dunst] the operator chose an action on notification %d and it could not be said: %v\n", id, err) + } + } +} + +// chosenToken is the token notify-send printed after the id, when it is one offered. +func chosenToken(rest string, choices []Choice) (string, bool) { + for _, line := range strings.Split(rest, "\n") { + line = strings.TrimSpace(line) + for _, c := range choices { + if line == c.Token { + return c.Token, true + } + } + } + return "", false +} diff --git a/modules/dunst/cmd/dunst-tools/actions_test.go b/modules/dunst/cmd/dunst-tools/actions_test.go new file mode 100644 index 00000000..d417646c --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/actions_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// **A notification offers answers, and the chosen one is said as an event** (novox/hq ADR 0253): the id +// is answered at once, the answer comes when the operator chooses, and nothing is said for a +// notification closed without one. +func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) { + withBus(t) + bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo 42; sleep 0.2; echo "$CHOSEN"`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + t.Setenv("MESH_NODE", "g14") + var mu sync.Mutex + var said []map[string]any + before := emit + emit = func(event string, body any) error { + mu.Lock() + defer mu.Unlock() + if event != ChosenEvent { + t.Errorf("event %q", event) + } + said = append(said, body.(map[string]any)) + return nil + } + t.Cleanup(func() { emit = before }) + + n, err := notificationOf(map[string]any{"summary": "Delivery of hq held for 36 hours", "body": "Needs you: release it, or stop it.", + "actions": []any{map[string]any{"token": "a1b2", "label": "Release"}, map[string]any{"token": "c3d4", "label": "Stop"}}}) + if err != nil { + t.Fatal(err) + } + t.Setenv("CHOSEN", "c3d4") + start := time.Now() + got, err := Send(n) + if err != nil || got.ID != 42 || time.Since(start) > 150*time.Millisecond { + t.Fatalf("%+v, %v, after %s: the id is answered before the answer", got, err, time.Since(start)) + } + asked, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(asked), "[--wait][--action=a1b2=Release][--action=c3d4=Stop][--]") { + t.Fatalf("notify-send was asked %s", asked) + } + deadline := time.Now().Add(3 * time.Second) + for { + mu.Lock() + n := len(said) + mu.Unlock() + if n > 0 || time.Now().After(deadline) { + break + } + time.Sleep(20 * time.Millisecond) + } + mu.Lock() + if len(said) != 1 || said[0]["token"] != "c3d4" || said[0]["id"] != 42 || said[0]["machine"] != "g14" { + t.Fatalf("said %v", said) + } + said = nil + mu.Unlock() + + // Closed without an answer, or an answer nobody offered: nothing is said. + for _, chosen := range []string{"", "zzzz"} { + t.Setenv("CHOSEN", chosen) + if _, err := Send(n); err != nil { + t.Fatal(err) + } + } + time.Sleep(600 * time.Millisecond) + mu.Lock() + defer mu.Unlock() + if len(said) != 0 { + t.Fatalf("an answer was invented: %v", said) + } +} + +func TestActionsAreRefusedForWhatTheyCannotBe(t *testing.T) { + for _, bad := range []any{"x", []any{"x"}, []any{map[string]any{"token": "a b", "label": "x"}}, + []any{map[string]any{"token": "a", "label": ""}}, []any{map[string]any{"token": "a", "label": "x=y"}}, + []any{map[string]any{"token": "a", "label": "x"}, map[string]any{"token": "a", "label": "y"}}, + []any{map[string]any{"token": "a", "label": "1"}, map[string]any{"token": "b", "label": "2"}, + map[string]any{"token": "c", "label": "3"}, map[string]any{"token": "d", "label": "4"}, + map[string]any{"token": "e", "label": "5"}}} { + if _, err := notificationOf(map[string]any{"summary": "x", "actions": bad}); err == nil { + t.Errorf("accepted %v", bad) + } + } +} diff --git a/modules/dunst/cmd/dunst-tools/dunst.go b/modules/dunst/cmd/dunst-tools/dunst.go index a05c3e93..094f826d 100644 --- a/modules/dunst/cmd/dunst-tools/dunst.go +++ b/modules/dunst/cmd/dunst-tools/dunst.go @@ -27,6 +27,7 @@ type Notification struct { ExpireMS int Category string ReplaceID int + Actions []Choice } func notificationOf(args map[string]any) (Notification, error) { @@ -66,7 +67,10 @@ func notificationOf(args map[string]any) (Notification, error) { if n.Category, err = text(args, "category", false); err != nil { return n, err } - n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1) + if n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1); err != nil { + return n, err + } + n.Actions, err = choicesOf(args) return n, err } @@ -94,11 +98,17 @@ func Send(n Notification) (SendResult, error) { if n.ReplaceID > 0 { args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID)) } + if len(n.Actions) > 0 { + args = append(args, actionArgs(n.Actions)...) + } // "--" so a title that starts with a dash is a title. args = append(args, "--", n.Summary) if n.Body != "" { args = append(args, n.Body) } + if len(n.Actions) > 0 { + return s.sendWaiting(args, n.Actions) + } r, err := s.run(busTimeout, "", "notify-send", args...) if err != nil { return SendResult{}, err diff --git a/modules/dunst/cmd/dunst-tools/main.go b/modules/dunst/cmd/dunst-tools/main.go index bd61453c..a9e0299a 100644 --- a/modules/dunst/cmd/dunst-tools/main.go +++ b/modules/dunst/cmd/dunst-tools/main.go @@ -22,8 +22,9 @@ func tools() []stdio.Tool { { Name: "node-notifier.send", Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " + - "normal, critical), and optionally the sending application's name, an icon and how long it stays. " + - "Answers the notification's id.", + "normal, critical), and optionally the sending application's name, an icon, how long it stays and " + + "the answers it offers (actions: token and label each). Answers the notification's id; the answer " + + "the operator chooses is said later as the event action-chosen {id, token, machine}.", Input: map[string]any{ "type": "object", "properties": map[string]any{ @@ -35,6 +36,10 @@ func tools() []stdio.Tool { "expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"}, "category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"}, "replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"}, + "actions": map[string]any{"type": "array", "description": "at most 4 answers the notification offers, " + + "each {token, label}; the chosen token is said as the event action-chosen", + "items": map[string]any{"type": "object", "properties": map[string]any{ + "token": map[string]any{"type": "string"}, "label": map[string]any{"type": "string"}}}}, }, "required": []string{"summary"}, }, diff --git a/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go b/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go index d4fb76d9..84fe6b64 100644 --- a/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go +++ b/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go @@ -19,6 +19,7 @@ type manifest struct { Claims []claim `json:"claims"` Seats []any `json:"seats"` Tools []string `json:"tools"` + Emits []string `json:"emits"` Environment *environment `json:"environment"` Shell []shellCode `json:"shell"` Resources []map[string]any `json:"resources"` diff --git a/modules/dunst/module.json b/modules/dunst/module.json index 67180987..c8eadea3 100644 --- a/modules/dunst/module.json +++ b/modules/dunst/module.json @@ -69,5 +69,8 @@ ] } ] - } + }, + "emits": [ + "action-chosen" + ] } diff --git a/modules/messenger/README.md b/modules/messenger/README.md index 32de38c3..686fdf89 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -44,6 +44,20 @@ condition's resolved line and how long it was open: "Resolved: openrazer works a A condition from a controller older than plain words, or a module's notice without a headline, is said from its summary, with code spans taken out (`cmd/messenger/words.go`). +**Every explanation opens with a verdict**: "Nothing for you to do." or "Needs you:" and one thing the +operator can do themselves. **A warning that needs nothing is not sent**: it stays open here and in the +controller's conditions, the history says it was kept quiet, and it is said the moment it needs the +operator (urgent at its bound, or its words now say "Needs you:"). An urgent condition is always said. + +**A notification offers answers** (`cmd/messenger/answers.go`): the condition's actions (Release, Stop, +Start, Restart, Silence for a week) and Details. On the desk they are dunst's actions (a middle click on +the notification, or its context menu); the dunst module says the chosen one as its event +`dunst.action-chosen`, and this module calls the action's seat verb itself, with a why naming the +operator, the channel and the label, then edits the notification to say what came of it. Details shows +the summary, the key and since when, on the machine that asked; a line the content rule refuses is said +withheld. A message that offers answers is never folded into a digest. Telegram shows the words but no +buttons yet (to-be 46 phase 6). + | When | What | |---|---| | `condition-raised` | one message, deduplicated by the condition's key | diff --git a/modules/messenger/cmd/messenger/answers.go b/modules/messenger/cmd/messenger/answers.go new file mode 100644 index 00000000..a05c5c00 --- /dev/null +++ b/modules/messenger/cmd/messenger/answers.go @@ -0,0 +1,238 @@ +package main + +// What the operator needs to do, and answering from the notification itself (novox/hq ADR 0253). +// +// - **Every explanation opens with a verdict**, written by the controller: "Nothing for you to do." or +// "Needs you:" and one thing the operator can do themselves. +// - **A warning that needs nothing is not sent.** The mesh handles it or it passes by itself; it is +// kept as open here (`operator-channel.open`, the history says "quiet") and in the controller's +// conditions, and said the moment it needs the operator: when it becomes urgent at its bound, or its +// words change to "Needs you:". An urgent condition is always said. +// - **A notification offers answers**: the condition's actions (Release, Stop, Start, Restart, Silence +// for a week) and Details. Each is a token; a channel that can take an answer (the desk, through the +// node-notifier seat's actions) hands the token back, and this holder calls the action's seat verb +// itself, with a why that names the operator, the channel and the label, then edits the notification +// to say what came of it. Details shows the summary and the key, for whoever wants them: the desk is a +// private surface (ADR 0234 §6). +// - None of these verbs declares an authorising tier yet (to-be 46 phase 5): each is a verb any granted +// principal may already call, which is ADR 0234's acknowledge. When one declares a tier, its answer goes +// through the controller's authorise request and answer instead, and the desk asks for the code. + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "strings" +) + +// Action is one answer a condition offers: a label, and the seat verb called with these arguments when +// it is chosen. Machine is set for a seat every machine holds. An argument "why" given empty is filled +// with the operator's words. +type Action struct { + Label string `json:"label"` + Verb string `json:"verb"` + Machine string `json:"machine,omitempty"` + Arguments map[string]string `json:"arguments,omitempty"` +} + +// DetailsLabel is the answer every condition's notification offers. +const DetailsLabel = "Details" + +// MostActions bounds what a notification offers, Details included. +const MostActions = 4 + +func sameActions(a, b []Action) bool { + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + return string(x) == string(y) +} + +// needsNothing is a record the operator need not hear of now: words of its own that open "Nothing for +// you to do.", and only a warning. A record without words (an older controller, a notice) is said as +// before. +func needsNothing(r *Record) bool { + return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition" +} + +// choices are the answers a record's message offers, each with its token, made once and kept. +func (h *Holder) choices(r *Record) []Choice { + if r.Origin != "condition" { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + if r.Tokens == nil { + r.Tokens = map[string]int{} + for i := range r.Actions { + if i == MostActions-1 { + break + } + r.Tokens[token()] = i + } + r.Tokens[token()] = -1 + } + out := make([]Choice, 0, len(r.Tokens)) + for i := range r.Actions { + for t, n := range r.Tokens { + if n == i { + out = append(out, Choice{Token: t, Label: r.Actions[i].Label}) + } + } + } + for t, n := range r.Tokens { + if n == -1 { + out = append(out, Choice{Token: t, Label: DetailsLabel}) + } + } + return out +} + +func token() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// Chosen takes an answer a channel handed back: the token of a choice, on a machine. It performs the +// action, says what came of it where the question was shown, and answers that in words. +func (h *Holder) Chosen(channel, machine, tok string) (string, error) { + h.work.Lock() + defer h.work.Unlock() + h.mu.Lock() + h.init() + var r *Record + idx := 0 + for _, rec := range h.open { + if i, ok := rec.Tokens[tok]; ok { + r, idx = rec, i + } + } + h.mu.Unlock() + if r == nil { + h.Logf("[messenger] an answer on %s came back for no open message (it ended, or the token is unknown)", channel) + return "", errorf("no open message offers that answer: it may have ended meanwhile") + } + now := h.Now() + headline, _ := wordsOf(r) + if idx == -1 { + // Each line held to the content rule on its own: a line that carries what may not leave is said + // withheld, and the rest is still shown. + var lines []string + for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} { + if refusal, ok := Check(l); !ok { + l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)" + } + lines = append(lines, l) + } + m := Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n")} + _, err := h.showOn(channel, machine, m) + h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "details", Outcome: outcomeOf(err)}) + return "shown", err + } + a := r.Actions[idx] + args := map[string]any{} + for k, v := range a.Arguments { + args[k] = v + } + if v, ok := a.Arguments["why"]; ok && v == "" { + args["why"] = fmt.Sprintf("the operator chose %s on the %s notification %q", a.Label, channel, headline) + } + address := "seat:" + a.Verb + if a.Machine != "" { + address += "@" + a.Machine + } + var err error + if h.Ask == nil { + err = errorf("this holder cannot call the mesh") + } else { + var raw json.RawMessage + raw, err = h.Ask(address, args) + if err == nil { + err = refusalIn(raw) + } + } + outcome := "done" + title := a.Label + ": done. " + headline + body := "You chose " + a.Label + " at " + now.In(h.zone()).Format("15:04") + "." + if err != nil { + outcome = "failed: " + err.Error() + title = "Could not " + strings.ToLower(a.Label) + ": " + headline + body = "The mesh refused it: " + firstLineOf(err.Error()) + if _, ok := Check(body); !ok { + body = "The mesh refused it; the reason carries what may not be shown here." + } + } + h.mu.Lock() + r.Answered = a.Label + ": " + outcome + sent := r.Sent[channel] + h.mu.Unlock() + h.persist(r) + m := Message{Title: title, Body: body, Quiet: err == nil} + if ch := h.channelNamed(channel); ch != nil && sent != "" && ch.CanEdit() { + if e := ch.Edit(sent, m); e != nil { + _, _ = h.showOn(channel, machine, m) + } + } else { + _, _ = h.showOn(channel, machine, m) + } + h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "answer " + a.Label, Outcome: outcome}) + h.Logf("[messenger] the operator chose %s for %s on %s: %s", a.Label, r.Key, channel, outcome) + return outcome, err +} + +func outcomeOf(err error) string { + if err != nil { + return "failed: " + err.Error() + } + return "sent" +} + +func firstLineOf(s string) string { + s, _, _ = strings.Cut(s, "\n") + if len(s) > 160 { + s = s[:160] + "…" + } + return s +} + +// refusalIn reads a tool's answer for a refusal: an error result is an error. +func refusalIn(raw json.RawMessage) error { + var wrapped struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + Error string `json:"error"` + } + if json.Unmarshal(raw, &wrapped) == nil { + if wrapped.IsError && len(wrapped.Content) > 0 { + return errorf("%s", wrapped.Content[0].Text) + } + if wrapped.Error != "" { + return errorf("%s", wrapped.Error) + } + } + return nil +} + +func (h *Holder) channelNamed(name string) Channel { + for _, ch := range h.channels() { + if ch.Name() == name { + return ch + } + } + return nil +} + +// showOn shows a message of the holder's own on one channel, on one machine where the channel has several. +func (h *Holder) showOn(channel, machine string, m Message) (string, error) { + ch := h.channelNamed(channel) + if ch == nil { + return "", errorf("no channel %s", channel) + } + if d, ok := ch.(*Desktop); ok && machine != "" { + return d.showOne(machine, m) + } + return ch.Send(m) +} diff --git a/modules/messenger/cmd/messenger/condition.go b/modules/messenger/cmd/messenger/condition.go index 5faa1bdc..cdd55003 100644 --- a/modules/messenger/cmd/messenger/condition.go +++ b/modules/messenger/cmd/messenger/condition.go @@ -19,6 +19,8 @@ package main // - Times are RFC 3339. // - `headline`, `explanation` and `resolved` are the plain words the operator reads (novox/hq ADR // 0253): a condition from a controller older than them has none, and is said from its summary. +// - `needs` is what the operator does, empty for nothing (the explanation opens with the verdict); +// `actions` are the answers a notification offers, each {label, verb, machine, arguments}. // - `key` is `..`. When it is absent it is made from subject and kind; when // neither gives one the event is unreadable. // - A cleared event carries the condition as last held, and may add `cleared` (its time). @@ -65,6 +67,8 @@ type Condition struct { Headline string Explanation string Resolved string + Needs string + Actions []Action Source string Resolver string Raised time.Time @@ -172,6 +176,12 @@ func DecodeCondition(event string, body []byte) (Condition, error) { c.Headline = str("headline") c.Explanation = str("explanation") c.Resolved = str("resolved") + c.Needs = str("needs") + if v, ok := raw["actions"]; ok && string(v) != "null" { + if e := json.Unmarshal(v, &c.Actions); e != nil { + return Condition{}, fmt.Errorf("%s: actions is not a list of {label, verb, machine, arguments}", event) + } + } c.Resolver = str("resolver") c.Raised = when("raised") c.LastObserved = when("last-observed", "last_observed", "lastObserved") diff --git a/modules/messenger/cmd/messenger/desktop.go b/modules/messenger/cmd/messenger/desktop.go index 91576548..e98a859d 100644 --- a/modules/messenger/cmd/messenger/desktop.go +++ b/modules/messenger/cmd/messenger/desktop.go @@ -97,6 +97,11 @@ func (d *Desktop) show(m Message, replace map[string]int) (string, error) { if replace != nil { args["replace_id"] = replace[machine] } + if len(m.Actions) > 0 { + // The answers the notification offers (node-notifier's actions): the notifier hands the chosen + // token back as an event, which the holder takes (answers.go). + args["actions"] = m.Actions + } raw, err := d.Ask("seat:node-notifier.send@"+machine, args) if err == nil { var id int @@ -140,6 +145,14 @@ func (d *Desktop) LastAnswers() map[string]string { return out } +// showOne shows a message on one machine only: an answer's outcome, or details, where it was asked. +func (d *Desktop) showOne(machine string, m Message) (string, error) { + only := &Desktop{Machines: func() []string { return []string{machine} }, Ask: d.Ask} + id, err := only.Send(m) + d.note(machine, err) + return id, err +} + // notificationID reads the notifier's answer, {"id": N}, whether the runtime hands it over bare or in // the tool reply's text content. func notificationID(raw json.RawMessage) (int, error) { diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 8a5bf05b..38f9310a 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -80,12 +80,19 @@ const ( DigestLines = 15 ) -// Message is what a channel shows: a title line and a body. +// Message is what a channel shows: a title line and a body, and the answers it offers. type Message struct { - Title string - Body string - Urgent bool - Quiet bool // a clearing: shown without urgency + Title string + Body string + Urgent bool + Quiet bool // a clearing: shown without urgency + Actions []Choice +} + +// Choice is one answer a message offers: the token a channel hands back when it is chosen, and its label. +type Choice struct { + Token string `json:"token"` + Label string `json:"label"` } func (m Message) Text() string { @@ -122,9 +129,16 @@ type Record struct { Summary string `json:"summary"` // Headline, Explanation and Resolved are the plain words the operator reads (novox/hq ADR 0253): // the controller's, or a module's through notify; empty, the message is said from the summary. - Headline string `json:"headline,omitempty"` - Explanation string `json:"explanation,omitempty"` - Resolved string `json:"resolved,omitempty"` + Headline string `json:"headline,omitempty"` + Explanation string `json:"explanation,omitempty"` + Resolved string `json:"resolved,omitempty"` + // Needs is what the operator does, empty for nothing; Actions the answers offered, each by a token + // (Tokens: token -> the action's index, or -1 for the details) a channel hands back when chosen. + Needs string `json:"needs,omitempty"` + Actions []Action `json:"actions,omitempty"` + Tokens map[string]int `json:"tokens,omitempty"` + // Answered is what the operator chose from a notification, and what came of it. + Answered string `json:"answered,omitempty"` Origin string `json:"origin"` More string `json:"more"` Raised time.Time `json:"raised"` @@ -146,13 +160,17 @@ type Record struct { // recordOf is a condition as this holder keeps it. func recordOf(c Condition) Record { return Record{Key: c.Key, Kind: c.Kind, Subject: c.SubjectWords(), Severity: c.Severity, Summary: c.Summary, - Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Origin: "condition", - More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill} + Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Needs: c.Needs, Actions: c.Actions, + Origin: "condition", + More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill} } // takeWords keeps the plain words current, as the summary is: a condition's words follow what is seen. func (r *Record) takeWords(from Record) { - r.Headline, r.Explanation, r.Resolved = from.Headline, from.Explanation, from.Resolved + r.Headline, r.Explanation, r.Resolved, r.Needs = from.Headline, from.Explanation, from.Resolved, from.Needs + if !sameActions(r.Actions, from.Actions) { + r.Actions, r.Tokens = from.Actions, nil + } } func (r *Record) silenced(now time.Time) bool { @@ -196,6 +214,8 @@ type Holder struct { Logf func(string, ...any) // Emit states a fact as this module (refused); nil states nothing. Emit func(event string, body any) error + // Ask calls a seat verb through the mesh, as this module: the answers the operator chooses. + Ask Asker // Zone is the operator's time zone, which every time in a message is said in; nil is this machine's. Zone func() *time.Location @@ -367,8 +387,10 @@ func (h *Holder) raised(rec Record, quiet bool) { old := h.open[rec.Key] h.mu.Unlock() if old != nil && old.Cleared.IsZero() { - // Said again while open: the same message. Its words are kept current; nothing is sent. + // Said again while open: the same message. Its words are kept current; nothing is sent — unless + // it was kept quiet as needing nothing and now needs the operator. h.mu.Lock() + wasQuiet := needsNothing(old) old.Summary, old.Subject, old.Kind, old.SilencedTill = rec.Summary, rec.Subject, rec.Kind, rec.SilencedTill old.takeWords(rec) if rec.Severity != "" { @@ -376,6 +398,7 @@ func (h *Holder) raised(rec Record, quiet bool) { } old.Seen = now h.mu.Unlock() + h.nowNeeded(old, wasQuiet, quiet) h.persist(old) return } @@ -415,6 +438,13 @@ func (h *Holder) raised(rec Record, quiet bool) { h.persist(&r) } +// nowNeeded says a record kept quiet as needing nothing, once its words say it needs the operator. +func (h *Holder) nowNeeded(r *Record, wasQuiet, quiet bool) { + if wasQuiet && !needsNothing(r) && !r.told() && !quiet && !r.silenced(h.Now()) && !h.queued(r.Key) { + h.deliver(r, "raised") + } +} + func (h *Holder) changed(rec Record, quiet bool) { h.mu.Lock() old := h.open[rec.Key] @@ -428,6 +458,7 @@ func (h *Holder) changed(rec Record, quiet bool) { } h.mu.Lock() escalated := old.Severity == Warning && rec.Severity == Urgent + wasQuiet := needsNothing(old) old.Summary, old.Subject, old.SilencedTill = rec.Summary, rec.Subject, rec.SilencedTill old.takeWords(rec) if rec.Severity != "" { @@ -435,9 +466,12 @@ func (h *Holder) changed(rec Record, quiet bool) { } old.Seen = h.Now() h.mu.Unlock() - if escalated && !quiet && !old.silenced(h.Now()) { + switch { + case escalated && !quiet && !old.silenced(h.Now()): // Routing differs for urgent: said once more, to both channels. h.deliver(old, "escalated") + default: + h.nowNeeded(old, wasQuiet, quiet) } h.persist(old) } @@ -556,6 +590,9 @@ func (h *Holder) say(r *Record, what string) Message { m := compose(r, what, now, h.zone(), 0) refusal, ok := Check(m.Text()) if ok { + if what != "cleared" { + m.Actions = h.choices(r) + } return m } h.mu.Lock() diff --git a/modules/messenger/cmd/messenger/holder_test.go b/modules/messenger/cmd/messenger/holder_test.go index ade54854..3ddf0a6b 100644 --- a/modules/messenger/cmd/messenger/holder_test.go +++ b/modules/messenger/cmd/messenger/holder_test.go @@ -73,7 +73,8 @@ func start() *clock { return &clock{t: time.Date(2026, 10, 6, func cond(key, sev, summary string) Condition { parts := strings.Split(key, ".") return Condition{Key: key, Scope: parts[0], ID: parts[1], Kind: parts[len(parts)-1], Severity: sev, Summary: summary, - Headline: headlineOf(key), Explanation: "What it means: " + summary + "."} + Headline: headlineOf(key), Explanation: "Needs you: look at it. What it means: " + summary + ".", + Needs: "look at it."} } // headlineOf is the headline a test condition carries: its key in words, so a message can be told by it @@ -122,7 +123,7 @@ func TestARaisedConditionIsSentOnceByItsKey(t *testing.T) { t.Fatalf("urgent: telegram %d, desktop %d; want one each", len(tg.sends), len(dt.sends)) } keyless(t, tg.sends...) - if tg.sends[0].Title != "Urgent: machine ace silent" || !strings.HasPrefix(tg.sends[0].Body, "What it means: ") || + if tg.sends[0].Title != "Urgent: machine ace silent" || !strings.HasPrefix(tg.sends[0].Body, "Needs you: look at it. What it means: ") || !strings.Contains(tg.sends[0].Body, "\nSince ") { t.Fatalf("message: %q", tg.sends[0].Text()) } diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index c93e15dc..27b021b4 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -171,6 +171,7 @@ func main() { Ask: stdio.Ask, }, Store: stateStore{}, + Ask: stdio.Ask, Logf: logf, Zone: func() *time.Location { loc, err := zoneOf(settings().TimeZone) @@ -247,6 +248,7 @@ func run(h *Holder, l *listening) { h.Condition(event, c) return nil } + go listenForAnswers(h) for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { err := stdio.Subscribe(ControllerSeat+".*", handle) if err == nil { @@ -260,6 +262,42 @@ func run(h *Holder, l *listening) { } } +// AnswerEvent is the desk's notifier saying which answer the operator chose: the dunst holder of +// node-notifier emits it as its module's event until seats publish events of their own (to-be 46 phase 3). +const AnswerEvent = "dunst.action-chosen" + +// listenForAnswers takes the answers the operator chooses on the desk, retried until it can. +func listenForAnswers(h *Holder) { + handle := func(e stdio.Envelope) error { + if e.Key != AnswerEvent { + return nil + } + var body struct { + Token string `json:"token"` + Machine string `json:"machine"` + } + if err := json.Unmarshal(e.Body, &body); err != nil || body.Token == "" { + logf("[messenger] an answer from the desk could not be read: %s", firstLineOf(string(e.Body))) + return nil + } + machine := body.Machine + if machine == "" { + machine = e.Node + } + _, _ = h.Chosen("desktop", machine, body.Token) + return nil + } + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + if err := stdio.Subscribe("dunst.*", handle); err == nil { + logf("[messenger] listening for the operator's answers on the desk") + return + } else { + logf("[messenger] not hearing the desk's answers yet (%v); asking again in %s", err, wait) + } + time.Sleep(wait) + } +} + // syncFromController reads the controller's open conditions and gives them to the holder. func syncFromController(h *Holder) { asked := h.Now() diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index f8386770..551c5fb7 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -55,10 +55,13 @@ func TestItDeclaresAndHoldsTheOperatorChannel(t *testing.T) { t.Fatalf("seats %+v claims %+v", m.Seats, m.Claims) } if !reflect.DeepEqual(m.Consumes, []string{ - ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared}) { + ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared, + AnswerEvent}) { t.Fatalf("consumes %v", m.Consumes) } - if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions"}) { + if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions", + "seat:mesh-delivery.release", "seat:mesh-delivery.stop", "seat:" + ControllerSeat + ".plans", + "seat:node-service-manager.restart"}) { t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes) } if !reflect.DeepEqual(m.State, []string{"open", "sent"}) { diff --git a/modules/messenger/cmd/messenger/outbox.go b/modules/messenger/cmd/messenger/outbox.go index 329e0095..ae701b8c 100644 --- a/modules/messenger/cmd/messenger/outbox.go +++ b/modules/messenger/cmd/messenger/outbox.go @@ -27,6 +27,11 @@ func (i item) urgent() bool { return i.Severity == Urgent && i.What != "cleared" // the desktop to Telegram when no session there takes it. No channel at all: it is unsent, said in // the status, and tried again every minute. func (h *Holder) deliver(r *Record, what string) { + if needsNothing(r) && what != "escalated" { + // Nothing for the operator to do, and only a warning: kept, not sent (ADR 0253). + h.record(Sent{At: h.Now(), Channel: "-", Key: r.Key, What: what, Outcome: "quiet: nothing for the operator to do"}) + return + } var to []Channel if r.Severity == Urgent { for _, ch := range h.channels() { @@ -231,11 +236,6 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) { if len(list) == 0 || (!force && !h.due(ch, list, now)) { return } - type entry struct { - it item - r *Record - msg Message - } var entries []entry for _, it := range list { r, m := h.resolve(it, now) @@ -279,6 +279,48 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) { } return } + if len(entries) > 1 { + // A message that asks the operator something goes alone, so its answers come with it. + var alone, rest []entry + for _, e := range entries { + if len(e.msg.Actions) > 1 { + alone = append(alone, e) + } else { + rest = append(rest, e) + } + } + if len(alone) > 0 { + for _, e := range alone { + if !h.sendEntries(ch, []entry{e}, now) { + h.failed(ch, list, taken, now, errorf("could not send")) + return + } + } + if len(rest) > 0 && !h.sendEntries(ch, rest, now) { + h.failed(ch, list, taken, now, errorf("could not send")) + return + } + h.drop(name, taken) + return + } + } + if !h.sendEntries(ch, entries, now) { + h.failed(ch, list, taken, now, errorf("could not send")) + return + } + h.drop(name, taken) +} + +// entry is one held item resolved to its record and message. +type entry struct { + it item + r *Record + msg Message +} + +// sendEntries sends entries as one message — itself when alone, a digest when several — and records it. +func (h *Holder) sendEntries(ch Channel, entries []entry, now time.Time) bool { + name := ch.Name() var m Message if len(entries) == 1 { m = entries[0].msg @@ -293,10 +335,8 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) { id, err := ch.Send(m) h.noteChannel(name, err) if err != nil { - h.failed(ch, list, taken, now, err) - return + return false } - h.drop(name, taken) h.mu.Lock() h.lastSend[name] = now h.lastTry[name] = time.Time{} @@ -342,6 +382,7 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) { h.persist(e.r) } } + return true } // drop removes the first n held items of a channel: those this flush took. diff --git a/modules/messenger/cmd/messenger/words_test.go b/modules/messenger/cmd/messenger/words_test.go index dae31ca8..f5026885 100644 --- a/modules/messenger/cmd/messenger/words_test.go +++ b/modules/messenger/cmd/messenger/words_test.go @@ -14,37 +14,57 @@ import ( // commit, verb or markup — and a clearance in one line. type example struct { - name, before string - key, kind, severity, summary string - subject map[string]string - headline, explanation, resolved string - title, body string // what the desktop shows - cleared string // the clearance's one line - openFor time.Duration + name, before string + key, kind, severity, summary string + subject map[string]string + headline, explanation string + needs, resolved string + actions []Action + popup bool // shown at all: a warning that needs nothing is not + title, body string // what the desktop shows + offers string // the answers it offers, by label + choose string // the answer chosen in the test + asked string // the seat verb that answer calls, and its arguments + cleared string // the clearance's one line + openFor time.Duration } +const waitingSummary = "the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word to start: " + + "`mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " + + "plan-1791454185265004861 --why …` starts it by hand" + +const waitingBefore = "CLEARED after 26 min: the walk of novox/mesh-catalog a6385479 has waited 56m0s for " + + "mesh-delivery's word to start: `mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " + + "plan-1791454185265004861 --why ...` starts it by hand\nabout: plan plan-1791454185265004861 (waiting)\n" + + "since: 2026-10-08 10:40 UTC\nkey: plan.plan-1791454185265004861.waiting\nmore: conditions show " + + "plan.plan-1791454... 65004861.waiting" + var examples = []example{{ - name: "a delivery waiting", - before: "CLEARED after 26 min: the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word " + - "to start: `mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " + - "plan-1791454185265004861 --why ...` starts it by hand\nabout: plan plan-1791454185265004861 (waiting)\n" + - "since: 2026-10-08 10:40 UTC\nkey: plan.plan-1791454185265004861.waiting\nmore: conditions show " + - "plan.plan-1791454... 65004861.waiting", + name: "a delivery waiting, under its bound", before: waitingBefore, key: "plan.plan-1791454185265004861.waiting", kind: "waiting", severity: Warning, - subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"}, - summary: "the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word to start: " + - "`mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " + - "plan-1791454185265004861 --why …` starts it by hand", + subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"}, summary: waitingSummary, headline: "openrazer delivery waiting to start", - explanation: "The change to openrazer is merged and built, and has waited 56 minutes for mesh-delivery (the " + - "module that decides when a delivery goes out) to let it start. Nothing to do yet; it becomes urgent after " + - "4 hours. To start it now, have an agent start it by hand.", - resolved: "openrazer delivery no longer waiting", - title: "openrazer delivery waiting to start", - body: "The change to openrazer is merged and built, and has waited 56 minutes for mesh-delivery (the module " + - "that decides when a delivery goes out) to let it start. Nothing to do yet; it becomes urgent after 4 hours. " + - "To start it now, have an agent start it by hand.\nSince 12:40.", - cleared: "Resolved: openrazer delivery no longer waiting, after 26 min", openFor: 26 * time.Minute, + explanation: "Nothing for you to do. The change to openrazer is merged and built, and has waited 56 minutes for " + + "mesh-delivery (the module that decides when a delivery goes out) to let it start. It becomes a question for " + + "you if it still waits after 4 hours.", + resolved: "openrazer delivery no longer waiting", popup: false, openFor: 26 * time.Minute, +}, { + name: "a delivery waiting, past its bound", before: waitingBefore, + key: "plan.plan-1791454185265004861.waiting", kind: "waiting", severity: Urgent, + subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"}, summary: waitingSummary, + headline: "openrazer delivery waiting to start", needs: "start it, or stop it.", + explanation: "Needs you: start it, or stop it. The change to openrazer is merged and built, and mesh-delivery " + + "(the module that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may be stuck.", + actions: []Action{ + {Label: "Start", Verb: "mesh-controller.plans", Arguments: map[string]string{"go": "plan-1791454185265004861", "why": ""}}, + {Label: "Stop", Verb: "mesh-controller.plans", Arguments: map[string]string{"stop": "plan-1791454185265004861", "why": ""}}}, + resolved: "openrazer delivery no longer waiting", popup: true, + title: "Urgent: openrazer delivery waiting to start", + body: "Needs you: start it, or stop it. The change to openrazer is merged and built, and mesh-delivery (the module " + + "that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may be stuck.\nSince 12:40.", + offers: "Start, Stop, Details", choose: "Start", + asked: `seat:mesh-controller.plans {"go":"plan-1791454185265004861","why":"the operator chose Start on the desktop notification \"openrazer delivery waiting to start\""}`, + cleared: "Resolved: openrazer delivery no longer waiting, after 5.0 h", openFor: 5 * time.Hour, }, { name: "a module unhealthy", before: "WARNING: openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the account's own " + @@ -54,13 +74,17 @@ var examples = []example{{ subject: map[string]string{"scope": "module", "id": "openrazer.g14", "machine": "g14"}, summary: "openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the account's own " + "service manager (exit-code)", - headline: "openrazer not working on g14", - explanation: "openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as " + - "soon as it runs again; if it stays, it needs a look at why it fails.", - resolved: "openrazer works again on g14", - title: "openrazer not working on g14", - body: "openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as soon " + - "as it runs again; if it stays, it needs a look at why it fails.\nSince 12:40.", + headline: "openrazer not working on g14", needs: "restart it; if it fails again, the details say why.", + explanation: "Needs you: restart it; if it fails again, the details say why. openrazer on g14 is not healthy: its " + + "service openrazer-daemon stopped with an error. It clears as soon as it runs again.", + actions: []Action{{Label: "Restart", Verb: "node-service-manager.restart", Machine: "g14", + Arguments: map[string]string{"unit": "openrazer-daemon.service", "scope": "user"}}}, + resolved: "openrazer works again on g14", popup: true, + title: "openrazer not working on g14", + body: "Needs you: restart it; if it fails again, the details say why. openrazer on g14 is not healthy: its service " + + "openrazer-daemon stopped with an error. It clears as soon as it runs again.\nSince 12:40.", + offers: "Restart, Details", choose: "Restart", + asked: `seat:node-service-manager.restart@g14 {"scope":"user","unit":"openrazer-daemon.service"}`, cleared: "Resolved: openrazer works again on g14, after 2.0 h", openFor: 2 * time.Hour, }, { name: "failed units on a machine", @@ -73,13 +97,19 @@ var examples = []example{{ summary: "shanks's service manager is degraded: 3 failed unit(s) no module places — mnt-recalbox.mount, " + "storage-media.mount, greenclip.service (the account's own manager). Each is the machine's own: mend or " + "remove it there, or have a module place it", - headline: "3 failed services on shanks", - explanation: "On shanks, mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module " + - "manages them, so the mesh does not repair them: mend or remove them on shanks.", - resolved: "No failed services on shanks any more", - title: "3 failed services on shanks", - body: "On shanks, mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, " + - "so the mesh does not repair them: mend or remove them on shanks.\nSince 12:40.", + headline: "3 failed services on shanks", needs: "mend or remove them on shanks, or silence this if they do not matter.", + explanation: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, " + + "mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh " + + "does not repair them.", + actions: []Action{{Label: "Silence for a week", Verb: "mesh-controller.conditions", + Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}}}, + resolved: "No failed services on shanks any more", popup: true, + title: "3 failed services on shanks", + body: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, mnt-recalbox " + + "(a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh does not repair " + + "them.\nSince 12:40.", + offers: "Silence for a week, Details", choose: "Silence for a week", + asked: `seat:mesh-controller.conditions {"for":"7d","silence":"machine.shanks.units","why":"the operator chose Silence for a week on the desktop notification \"3 failed services on shanks\""}`, cleared: "Resolved: no failed services on shanks any more, after 45 min", openFor: 45 * time.Minute, }, { name: "a healer wanted", @@ -92,19 +122,37 @@ var examples = []example{{ summary: "\"push\" was repaired by hand 35 times in 14 days, the last by g14.node-tools, through the " + "mesh-controller seat: a healer is wanted for it", headline: "Push keeps being fixed by hand", - explanation: "A person repaired push by hand 35 times in 14 days. A healer (an automatic repair) is wanted for " + - "it; nothing is broken now.", - resolved: "Resolved: no more hand repairs of push", - title: "Push keeps being fixed by hand", - body: "A person repaired push by hand 35 times in 14 days. A healer (an automatic repair) is wanted for it; " + - "nothing is broken now.\nSince 12:40.", - cleared: "Resolved: no more hand repairs of push, after 3 days", openFor: 3 * 24 * time.Hour, + explanation: "Nothing for you to do. A person repaired push by hand 35 times in 14 days, so an automatic repair " + + "is wanted for it. Nothing is broken now.", + resolved: "Resolved: no more hand repairs of push", popup: false, openFor: 3 * 24 * time.Hour, +}, { + name: "a delivery held", + before: "WARNING: the delivery novox/hq@055550802096 has been held for 36h2m6s, past its bound of 24h0m0s (it " + + "waits for the operator): healer H2 may none: the state is the operator's — `mesh-delivery.show " + + "novox/hq@055550802096`\nabout: delivery novox/hq@055550802096 (stalled)\nsince: 2026-10-07 00:38 UTC\n" + + "key: delivery.novox/hq_055550802096.stalled\nmore: conditions show delivery.novox/hq_055550802096.stalled", + key: "delivery.novox/hq_055550802096.stalled", kind: "stalled", severity: Warning, + subject: map[string]string{"scope": "delivery", "id": "novox/hq@055550802096"}, + summary: "the delivery novox/hq@055550802096 has been held for 36h2m6s, past its bound of 24h0m0s (it waits for " + + "the operator): healer H2 may none: the state is the operator's — `mesh-delivery.show novox/hq@055550802096`", + headline: "Delivery of hq held for 36 hours", needs: "release it, or stop it.", + explanation: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours, past its limit.", + actions: []Action{ + {Label: "Release", Verb: "mesh-delivery.release", Arguments: map[string]string{"id": "novox/hq@055550802096", "why": ""}}, + {Label: "Stop", Verb: "mesh-delivery.stop", Arguments: map[string]string{"id": "novox/hq@055550802096", "why": ""}}}, + resolved: "Delivery of hq is no longer held", popup: true, + title: "Delivery of hq held for 36 hours", + body: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours, past its limit.\nSince 12:40.", + offers: "Release, Stop, Details", choose: "Release", + asked: `seat:mesh-delivery.release {"id":"novox/hq@055550802096","why":"the operator chose Release on the desktop notification \"Delivery of hq held for 36 hours\""}`, + cleared: "Resolved: delivery of hq is no longer held, after 3 min", openFor: 3 * time.Minute, }} // event is the body a controller with plain words sends for a condition. func (e example) event(at time.Time, cleared bool) []byte { body := map[string]any{"key": e.key, "kind": e.kind, "subject": e.subject, "severity": e.severity, "summary": e.summary, "headline": e.headline, "explanation": e.explanation, "resolved": e.resolved, + "needs": e.needs, "actions": e.actions, "raised": "2026-10-08T10:40:00Z", "at": at.Format(time.RFC3339), "silenced": nil} if cleared { body["cleared"] = at.Format(time.RFC3339) @@ -113,16 +161,21 @@ func (e example) event(at time.Time, cleared bool) []byte { return raw } -func TestRealNotificationsReadInOneGlance(t *testing.T) { +func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *testing.T) { brussels, err := time.LoadLocation("Europe/Brussels") if err != nil { t.Fatal(err) } for _, e := range examples { t.Run(e.name, func(t *testing.T) { - h, _, dt, c, _ := newHolder(t) - h.Telegram = nil + h, tg, dt, c, _ := newHolder(t) h.Zone = func() *time.Location { return brussels } + var asked []string + h.Ask = func(address string, body any) (json.RawMessage, error) { + raw, _ := json.Marshal(body) + asked = append(asked, address+" "+string(raw)) + return json.RawMessage(`{"ok":true}`), nil + } c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC) raised, err := DecodeCondition(EventRaised, e.event(c.now(), false)) if err != nil { @@ -130,34 +183,114 @@ func TestRealNotificationsReadInOneGlance(t *testing.T) { } h.Condition(EventRaised, raised) settle(h, c) + if !e.popup { + if len(dt.sends)+len(tg.sends) != 0 { + t.Fatalf("a warning that needs nothing was shown: %+v %+v", dt.sends, tg.sends) + } + hist := h.History(5)["sends"].([]Sent) + if len(hist) == 0 || !strings.HasPrefix(hist[0].Outcome, "quiet") { + t.Errorf("not kept as quiet in the history: %+v", hist) + } + t.Logf("\nBEFORE:\n%s\n\nAFTER: no popup. Kept in the controller's conditions and the channel's history:\n%s\n%s", + e.before, e.headline, e.explanation) + return + } if len(dt.sends) != 1 { t.Fatalf("desktop sends %d", len(dt.sends)) } m := dt.sends[0] keyless(t, m) - if m.Title != e.title || m.Body != e.body { - t.Errorf("shows\n %q\n %q\nwant\n %q\n %q", m.Title, m.Body, e.title, e.body) + var labels []string + for _, a := range m.Actions { + labels = append(labels, a.Label) } - for _, w := range []string{"`", "UTC", "key:", "more:", "about:", "plan-", "a6385479"} { + if m.Title != e.title || m.Body != e.body || strings.Join(labels, ", ") != e.offers { + t.Errorf("shows\n %q\n %q\n %v\nwant\n %q\n %q\n %s", m.Title, m.Body, labels, e.title, e.body, e.offers) + } + for _, w := range []string{"`", "UTC", "key:", "more:", "about:", "plan-", "a6385479", "agent", "by hand"} { if strings.Contains(m.Text(), w) { t.Errorf("it still says %q: %q", w, m.Text()) } } + // The operator answers from the notification. + var tok string + for _, a := range m.Actions { + if a.Label == e.choose { + tok = a.Token + } + } + outcome, err := h.Chosen("desktop", "g14", tok) + if err != nil || outcome != "done" || len(asked) != 1 || asked[0] != e.asked { + t.Fatalf("chose %s: %q %v\nasked %v\nwant %s", e.choose, outcome, err, asked, e.asked) + } + done := dt.edits["1"] + if done.Title != e.choose+": done. "+e.headline { + t.Errorf("the answer's outcome: %q", done.Title) + } c.pass(e.openFor - BurstWindow) cleared, err := DecodeCondition(EventCleared, e.event(c.now(), true)) if err != nil { t.Fatal(err) } h.Condition(EventCleared, cleared) - done := dt.edits["1"] - if done.Title != e.cleared || done.Body != "" { - t.Errorf("clearance %q / %q, want one line %q", done.Title, done.Body, e.cleared) + end := dt.edits["1"] + if end.Title != e.cleared || end.Body != "" || len(end.Actions) != 0 { + t.Errorf("clearance %q / %q / %v, want one line %q", end.Title, end.Body, end.Actions, e.cleared) } - t.Logf("\nBEFORE:\n%s\n\nAFTER:\n%s\n%s\n\nCLEARED:\n%s", e.before, m.Title, m.Body, done.Title) + t.Logf("\nBEFORE:\n%s\n\nAFTER:\n%s\n%s\n[%s]\n\nCHOSEN %s:\n%s\n%s\n\nCLEARED:\n%s", e.before, m.Title, + m.Body, strings.Join(labels, "] ["), e.choose, done.Title, done.Body, end.Title) }) } } +// **A warning kept quiet is said once it needs the operator**: at its bound it becomes urgent, and then +// its words say "Needs you:". +func TestAQuietWarningIsSaidWhenItBecomesUrgent(t *testing.T) { + h, tg, dt, c, _ := newHolder(t) + e := examples[0] + raised, _ := DecodeCondition(EventRaised, e.event(c.now(), false)) + h.Condition(EventRaised, raised) + settle(h, c) + if len(dt.sends)+len(tg.sends) != 0 { + t.Fatal("said while it needed nothing") + } + past := examples[1] + c.pass(4 * time.Hour) + changed, _ := DecodeCondition(EventChanged, past.event(c.now(), false)) + h.Condition(EventChanged, changed) + settle(h, c) + if len(dt.sends) != 1 || len(tg.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: start it, or stop it.") { + t.Fatalf("desktop %+v telegram %d", dt.sends, len(tg.sends)) + } +} + +// **Details shows the summary and the key, where it was asked**, and an answer for a message that ended +// is refused in words. +func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) { + h, _, dt, c, _ := newHolder(t) + h.Zone = func() *time.Location { return time.UTC } + e := examples[5] + raised, _ := DecodeCondition(EventRaised, e.event(c.now(), false)) + h.Condition(EventRaised, raised) + settle(h, c) + m := dt.sends[0] + details := m.Actions[len(m.Actions)-1] + if details.Label != DetailsLabel { + t.Fatalf("%+v", m.Actions) + } + if _, err := h.Chosen("desktop", "g14", details.Token); err != nil { + t.Fatal(err) + } + shown := dt.sends[len(dt.sends)-1] + if shown.Title != "Details: "+e.headline || !strings.HasPrefix(shown.Body, e.summary) || + !strings.Contains(shown.Body, "Since: 8 Oct 10:40") { + t.Errorf("details: %q / %q", shown.Title, shown.Body) + } + if _, err := h.Chosen("desktop", "g14", "not-a-token"); err == nil { + t.Error("an unknown answer was taken") + } +} + // **A condition from a controller without plain words is said from its summary**, its markup taken out. func TestAConditionWithoutPlainWordsIsSaidFromItsSummary(t *testing.T) { h, tg, _, c, _ := newHolder(t) diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 47be307d..52c0531a 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -27,14 +27,19 @@ "consumes": [ "mesh-controller.condition-raised", "mesh-controller.condition-changed", - "mesh-controller.condition-cleared" + "mesh-controller.condition-cleared", + "dunst.action-chosen" ], "emits": [ "refused" ], "invokes": [ "seat:node-notifier.send", - "seat:mesh-controller.conditions" + "seat:mesh-controller.conditions", + "seat:mesh-delivery.release", + "seat:mesh-delivery.stop", + "seat:mesh-controller.plans", + "seat:node-service-manager.restart" ], "state": [ "open",