diff --git a/modules/dunst/README.md b/modules/dunst/README.md index 303c1ff6..b30a1691 100644 --- a/modules/dunst/README.md +++ b/modules/dunst/README.md @@ -20,8 +20,10 @@ waits in this bundle for the operator's choice (dunst offers the actions on a mi 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). +days. A notification that offers answers stays until dismissed (`expire_ms` 0). A restart of this bundle +ends the waits it held; it says `started` {machine} once it serves, and the operator channel shows the +open answers there again (novox/hq ADR 0258). `notify-send` prints the id before it waits, also into a +pipe (checked on the laptop on 2026-10-08: the id at once, and "Wait timeout expired" when it expired). ## Tools diff --git a/modules/dunst/cmd/dunst-tools/actions.go b/modules/dunst/cmd/dunst-tools/actions.go index d99ca055..fe0e998f 100644 --- a/modules/dunst/cmd/dunst-tools/actions.go +++ b/modules/dunst/cmd/dunst-tools/actions.go @@ -39,10 +39,27 @@ const ( 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" + // ChosenEvent is the event an answer is said as; StartedEvent says this bundle started, so the answers + // an earlier one waited for are lost and the operator channel shows them again (novox/hq ADR 0258). + ChosenEvent = "action-chosen" + StartedEvent = "started" ) +// sayStarted says, once this bundle serves, that it started on this machine; retried while the runtime +// cannot take it yet, and said in the log when it never could. +func sayStarted() { + body := map[string]any{"machine": os.Getenv("MESH_NODE")} + var err error + for wait := time.Second; wait <= time.Minute; wait *= 2 { + time.Sleep(wait) + if err = emit(StartedEvent, body); err == nil { + return + } + } + fmt.Fprintf(os.Stderr, "[dunst] could not say it started (answers waited for before are lost until the "+ + "operator channel shows them again): %v\n", err) +} + var tokenShape = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) // emit says an event as this module; a seam a test replaces. diff --git a/modules/dunst/cmd/dunst-tools/actions_test.go b/modules/dunst/cmd/dunst-tools/actions_test.go index d417646c..3ba7f9a4 100644 --- a/modules/dunst/cmd/dunst-tools/actions_test.go +++ b/modules/dunst/cmd/dunst-tools/actions_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "path/filepath" "strings" @@ -43,7 +44,7 @@ func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) { 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][--]") { + if !strings.Contains(string(asked), "[--expire-time=0][--wait][--action=a1b2=Release][--action=c3d4=Stop][--]") { t.Fatalf("notify-send was asked %s", asked) } deadline := time.Now().Add(3 * time.Second) @@ -90,3 +91,20 @@ func TestActionsAreRefusedForWhatTheyCannotBe(t *testing.T) { } } } + +// **A bundle that started says so**, so the operator channel shows again the answers lost with the one +// before. +func TestABundleThatStartedSaysSo(t *testing.T) { + t.Setenv("MESH_NODE", "g14") + var said []string + before := emit + emit = func(event string, body any) error { + said = append(said, fmt.Sprintf("%s %v", event, body)) + return nil + } + t.Cleanup(func() { emit = before }) + sayStarted() + if len(said) != 1 || said[0] != "started map[machine:g14]" { + t.Fatalf("%v", said) + } +} diff --git a/modules/dunst/cmd/dunst-tools/dunst.go b/modules/dunst/cmd/dunst-tools/dunst.go index 094f826d..a9991095 100644 --- a/modules/dunst/cmd/dunst-tools/dunst.go +++ b/modules/dunst/cmd/dunst-tools/dunst.go @@ -89,6 +89,11 @@ func Send(n Notification) (SendResult, error) { if n.Icon != "" { args = append(args, "--icon="+n.Icon) } + if len(n.Actions) > 0 && n.ExpireMS < 0 { + // A notification that offers answers stays until it is dismissed: a warning's answers must not die + // with dunst's ten seconds (novox/hq ADR 0258). + n.ExpireMS = 0 + } if n.ExpireMS >= 0 { args = append(args, "--expire-time="+strconv.Itoa(n.ExpireMS)) } diff --git a/modules/dunst/cmd/dunst-tools/main.go b/modules/dunst/cmd/dunst-tools/main.go index a9e0299a..ee45021b 100644 --- a/modules/dunst/cmd/dunst-tools/main.go +++ b/modules/dunst/cmd/dunst-tools/main.go @@ -11,6 +11,7 @@ import ( ) func main() { + go sayStarted() if err := stdio.Serve("", tools()); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/modules/dunst/module.json b/modules/dunst/module.json index c8eadea3..3f34298b 100644 --- a/modules/dunst/module.json +++ b/modules/dunst/module.json @@ -71,6 +71,7 @@ ] }, "emits": [ - "action-chosen" + "action-chosen", + "started" ] } diff --git a/modules/messenger/README.md b/modules/messenger/README.md index 686fdf89..0841debd 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -49,14 +49,18 @@ operator can do themselves. **A warning that needs nothing is not sent**: it sta 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). +**A notification offers answers** (`cmd/messenger/answers.go`), limited by novox/hq ADR 0258: **a desk +click performs only an acknowledgement**. Any process of the operator's account can choose a dunst action +(`dunstctl action`), so a click proves nothing about who chose. Until answers are authorised (to-be 46 +phase 5) the only answers offered and performed are **Details** and **Silence for a week** (the allow-list +`answerable`); release, stop, start and restart are said in words ("Needs you: release it, or stop it.") +and offered as no answer. Each answer is a token made for one message of one condition: bound to the +machines it was shown on, good for a day, used once, refused for a condition cleared or silenced, and one +event is acted on once. A refused answer is said on the desk where it was chosen, never dropped. When the +dunst bundle says it started (`dunst.started`), the open notifications that offer answers are shown there +again with fresh tokens. A silence is recorded with the cause `operator-answer`, which the hand-act log +does not count as a repair. A warning kept quiet is also said once after a day. Telegram shows the words +but no buttons. | When | What | |---|---| diff --git a/modules/messenger/cmd/messenger/answers.go b/modules/messenger/cmd/messenger/answers.go index a05c5c00..f926ff22 100644 --- a/modules/messenger/cmd/messenger/answers.go +++ b/modules/messenger/cmd/messenger/answers.go @@ -1,29 +1,36 @@ package main -// What the operator needs to do, and answering from the notification itself (novox/hq ADR 0253). +// What the operator needs to do, and answering from the notification itself (novox/hq ADR 0253, as +// limited by ADR 0258). // // - **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. +// - **A warning that needs nothing is not sent.** It is kept open here and in the controller's +// conditions, the history says it was kept quiet, and it is said when it needs the operator — urgent +// at its bound, or its words changed to "Needs you:" (an event, or a reading of the state) — and once +// after a day in any case (QuietRemindAfter), so nothing stays silent for ever. +// - **A desk click performs only an acknowledgement** (ADR 0234 §8). Any process of the operator's account +// can choose a notification's action (`dunstctl action`), so a click proves nothing about who chose; +// until answers are authorised (to-be 46 phase 5) the only answers offered and performed are Details +// and silencing a condition. Everything else — release, stop, start, restart — is said in words and +// offered as no answer. The allow-list is `answerable`; an action outside it is never offered, and a +// token for one is refused. +// - **A token is an answer's one-time ticket**: made for one message of one condition, bound to the +// machines it was shown on, valid for TokenFor, used once. An answer for a condition cleared or +// silenced, from another machine, used, expired or unknown, is refused — and the refusal is shown where +// the answer came from, so a click is never silently dead. One event is acted on once, whatever its +// redelivery. +// - **A notifier that restarted lost the answers it was waiting for**; when it says it started, the open +// notifications that offer answers are shown there again with fresh tokens (Reoffer). import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" + "sort" "strings" + "time" ) // Action is one answer a condition offers: a label, and the seat verb called with these arguments when @@ -36,11 +43,39 @@ type Action struct { Arguments map[string]string `json:"arguments,omitempty"` } -// DetailsLabel is the answer every condition's notification offers. -const DetailsLabel = "Details" +// Grant is one token's answer: the action (or -1, Details), when it was made, and whether it was used. +type Grant struct { + Index int `json:"index"` + Issued time.Time `json:"issued"` + Used bool `json:"used,omitempty"` +} -// MostActions bounds what a notification offers, Details included. -const MostActions = 4 +const ( + // DetailsLabel is the answer every condition's notification offers. + DetailsLabel = "Details" + // MostActions bounds what a notification offers, Details included. + MostActions = 4 + // TokenFor is how long an answer's token is good for. + TokenFor = 24 * time.Hour + // KeptEvents is how many answered events are remembered, so a redelivered one is acted on once. + KeptEvents = 200 +) + +// answerable is the allow-list of answers a desk click performs (ADR 0258): silencing a condition, an +// acknowledgement any granted principal may already make. Nothing else, until answers are authorised. +func answerable(a Action) bool { + if a.Verb != "mesh-controller.conditions" || a.Machine != "" || a.Arguments["silence"] == "" { + return false + } + for k := range a.Arguments { + switch k { + case "silence", "for", "why", "cause": + default: + return false + } + } + return true +} func sameActions(a, b []Action) bool { x, _ := json.Marshal(a) @@ -49,73 +84,105 @@ func sameActions(a, b []Action) bool { } // 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. +// you to do.", and only a warning. A record without words (an older controller, a notice) is said. 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. +// choices are the answers a record's next message offers, each with a fresh token; the tokens of its +// earlier messages end with them. func (h *Holder) choices(r *Record) []Choice { if r.Origin != "condition" { return nil } + now := h.Now() 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 = map[string]Grant{} + var out []Choice + for i, a := range r.Actions { + if !answerable(a) || len(out) == MostActions-1 { + continue } - r.Tokens[token()] = -1 + t := token() + r.Tokens[t] = Grant{Index: i, Issued: now} + out = append(out, Choice{Token: t, Label: a.Label}) } - 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}) + t := token() + r.Tokens[t] = Grant{Index: -1, Issued: now} + return append(out, Choice{Token: t, Label: DetailsLabel}) +} + +func token() string { + var b [12]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// shownOn are the machines a record's desktop message was shown on. +func shownOn(r *Record) []string { + var out []string + for _, part := range strings.Split(r.Sent["desktop"], ",") { + if m, _, ok := strings.Cut(part, "="); ok { + out = append(out, m) } } 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) { +// Chosen takes an answer a channel handed back: the token of a choice, on a machine, in an event. It +// performs an allowed answer, shows what came of it — or why it was refused — where it was chosen, and +// answers that in words. The same event is acted on once. +func (h *Holder) Chosen(channel, machine, tok, event string) (string, error) { h.work.Lock() defer h.work.Unlock() h.mu.Lock() h.init() + if event != "" { + if outcome, done := h.answered[event]; done { + h.mu.Unlock() + return outcome, nil + } + } + now := h.Now() var r *Record - idx := 0 + var g Grant for _, rec := range h.open { - if i, ok := rec.Tokens[tok]; ok { - r, idx = rec, i + if gr, ok := rec.Tokens[tok]; ok { + r, g = rec, gr } } 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") + refuse := func(why string) (string, error) { + h.Logf("[messenger] an answer on %s from %s was refused: %s", channel, machine, why) + h.remember(event, "refused: "+why) + h.record(Sent{At: now, Channel: channel, Key: keyOf(r), What: "answer", Outcome: "refused: " + why}) + _, _ = h.showOn(channel, machine, Message{Title: "That answer was not taken", + Body: capital(why) + ". Nothing was done.", Quiet: true}) + return "refused", errorf("%s", why) } - now := h.Now() + switch { + case r == nil: + return refuse("it belongs to no open message; the problem may have ended, or a newer message replaced it") + case !r.Cleared.IsZero(): + return refuse("the problem it answered has ended") + case r.silenced(now): + return refuse("the problem it answered is silenced") + case g.Used: + return refuse("it was already chosen once") + case now.Sub(g.Issued) > TokenFor: + return refuse("it is more than a day old") + case channel == "desktop" && !contains(shownOn(r), machine): + return refuse("it was not shown on " + machine) + case g.Index >= len(r.Actions) || (g.Index >= 0 && !answerable(r.Actions[g.Index])): + return refuse("it is not an answer a notification may give") + } + h.mu.Lock() + g.Used = true + r.Tokens[tok] = g + h.mu.Unlock() headline, _ := wordsOf(r) - if idx == -1 { + if g.Index == -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 @@ -125,36 +192,34 @@ func (h *Holder) Chosen(channel, machine, tok string) (string, error) { } lines = append(lines, l) } - m := Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n")} - _, err := h.showOn(channel, machine, m) + _, err := h.showOn(channel, machine, Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n")}) h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "details", Outcome: outcomeOf(err)}) + h.remember(event, "shown") + h.persist(r) return "shown", err } - a := r.Actions[idx] + a := r.Actions[g.Index] 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 + args["why"] = fmt.Sprintf("chosen on the %s notification %q on %s (a desk click: whoever was at the "+ + "operator's session)", channel, headline, 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) + raw, err = h.Ask("seat:"+a.Verb, 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") + "." + body := "Chosen at " + now.In(h.zone()).Format("15:04") + "." if err != nil { outcome = "failed: " + err.Error() title = "Could not " + strings.ToLower(a.Label) + ": " + headline @@ -176,11 +241,86 @@ func (h *Holder) Chosen(channel, machine, tok string) (string, error) { } else { _, _ = h.showOn(channel, machine, m) } + h.remember(event, outcome) 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) + h.Logf("[messenger] %s was chosen for %s on %s (%s): %s", a.Label, r.Key, channel, machine, outcome) return outcome, err } +// Reoffer shows again, on a machine whose notifier restarted and so lost the answers it waited for, every +// open message shown there that offers answers — with fresh tokens, in place of the old one. +func (h *Holder) Reoffer(machine string) int { + h.work.Lock() + defer h.work.Unlock() + h.mu.Lock() + h.init() + var recs []*Record + for _, r := range h.open { + if r.Cleared.IsZero() && len(r.Tokens) > 0 && contains(shownOn(r), machine) { + recs = append(recs, r) + } + } + h.mu.Unlock() + sort.Slice(recs, func(i, j int) bool { return recs[i].Key < recs[j].Key }) + n := 0 + for _, r := range recs { + if r.silenced(h.Now()) || h.Desktop == nil { + continue + } + m := h.say(r, "raised") + id := "" + for _, part := range strings.Split(r.Sent["desktop"], ",") { + if strings.HasPrefix(part, machine+"=") { + id = part + } + } + if err := h.Desktop.Edit(id, m); err != nil { + h.Logf("[messenger] could not offer %s again on %s: %v", r.Key, machine, err) + continue + } + h.persist(r) + n++ + } + if n > 0 { + h.Logf("[messenger] the notifier on %s started again: %d message(s) with answers shown there again", machine, n) + } + return n +} + +func (h *Holder) remember(event, outcome string) { + if event == "" { + return + } + h.mu.Lock() + defer h.mu.Unlock() + if h.answered == nil { + h.answered = map[string]string{} + } + if len(h.answered) >= KeptEvents { + for k := range h.answered { + delete(h.answered, k) + break + } + } + h.answered[event] = outcome +} + +func keyOf(r *Record) string { + if r == nil { + return "(none)" + } + return r.Key +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + func outcomeOf(err error) string { if err != nil { return "failed: " + err.Error() diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go new file mode 100644 index 00000000..277fc3ff --- /dev/null +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -0,0 +1,247 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" +) + +// The review of 2026-10-08 (novox/hq ADR 0258): a desk click performs only an acknowledgement, and an +// answer's token is a one-time ticket bound to its condition and its machine. + +// answering is a holder with the failed-units condition shown on the desktop of g14, and the calls the +// holder makes recorded. +func answering(t *testing.T, actions ...Action) (*Holder, *fakeChannel, *clock, *[]string, Condition) { + t.Helper() + h, _, dt, c, _ := newHolder(t) + h.Telegram = nil + 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 + } + e := examples[3] + if len(actions) > 0 { + e.actions = actions + } + k, err := DecodeCondition(EventRaised, e.event(c.now(), false)) + if err != nil { + t.Fatal(err) + } + h.Condition(EventRaised, k) + settle(h, c) + if len(dt.sends) != 1 { + t.Fatalf("desktop sends %d", len(dt.sends)) + } + return h, dt, c, asked, k +} + +func tokenOf(t *testing.T, m Message, label string) string { + t.Helper() + for _, a := range m.Actions { + if a.Label == label { + return a.Token + } + } + t.Fatalf("%q is not offered: %+v", label, m.Actions) + return "" +} + +// **Only an acknowledgement is offered or performed.** Release, Stop, Start and Restart are refused by the +// allow-list: never offered, and a token for one (forged into the state) is refused without a call. +func TestOnlyAnAcknowledgementIsAnAnswer(t *testing.T) { + release := Action{Label: "Release", Verb: "mesh-delivery.release", Arguments: map[string]string{"id": "x", "why": ""}} + restart := Action{Label: "Restart", Verb: "node-service-manager.restart", Machine: "g14", + Arguments: map[string]string{"unit": "x.service"}} + start := Action{Label: "Start", Verb: "mesh-controller.plans", Arguments: map[string]string{"go": "p", "why": ""}} + sneaky := Action{Label: "Silence", Verb: "mesh-controller.conditions", Arguments: map[string]string{"silence": "k", "stop": "p"}} + silence := Action{Label: "Silence for a week", Verb: "mesh-controller.conditions", + Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": "", "cause": "operator-answer"}} + for _, a := range []Action{release, restart, start, sneaky} { + if answerable(a) { + t.Errorf("%s is answerable", a.Label) + } + } + if !answerable(silence) { + t.Error("silence is not answerable") + } + h, dt, _, asked, _ := answering(t, release, restart, start, silence) + m := dt.sends[0] + var labels []string + for _, a := range m.Actions { + labels = append(labels, a.Label) + } + if strings.Join(labels, ",") != "Silence for a week,Details" { + t.Fatalf("offered %v", labels) + } + // A token for Release, put in the state by hand: refused, nothing called, and said on the desk. + h.mu.Lock() + r := h.open["machine.shanks.units"] + r.Tokens["forged"] = Grant{Index: 0, Issued: h.Now()} + h.mu.Unlock() + before := len(dt.sends) + if out, err := h.Chosen("desktop", "g14", "forged", "e1"); err == nil || out != "refused" || len(*asked) != 0 { + t.Fatalf("a release was performed from a click: %q %v %v", out, err, *asked) + } + if len(dt.sends) != before+1 || dt.sends[len(dt.sends)-1].Title != "That answer was not taken" { + t.Errorf("the refusal was not said where the click was: %+v", dt.sends[len(dt.sends)-1]) + } +} + +// **At most four answers**, Details included. +func TestANotificationOffersAtMostFourAnswers(t *testing.T) { + var many []Action + for i := 0; i < 6; i++ { + many = append(many, Action{Label: fmt.Sprintf("Silence %d", i), Verb: "mesh-controller.conditions", + Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}}) + } + _, dt, _, _, _ := answering(t, many...) + if n := len(dt.sends[0].Actions); n != MostActions || dt.sends[0].Actions[n-1].Label != DetailsLabel { + t.Fatalf("offered %+v", dt.sends[0].Actions) + } +} + +// **A token is used once, an event acted on once**: the same token twice is refused the second time; the +// same event delivered twice is acted on once and answers what came of it. +func TestATokenIsUsedOnceAndAnEventActedOnOnce(t *testing.T) { + h, dt, _, asked, _ := answering(t) + tok := tokenOf(t, dt.sends[0], "Silence for a week") + if out, err := h.Chosen("desktop", "g14", tok, "e1"); err != nil || out != "done" || len(*asked) != 1 { + t.Fatalf("%q %v %v", out, err, *asked) + } + if out, err := h.Chosen("desktop", "g14", tok, "e1"); err != nil || out != "done" || len(*asked) != 1 { + t.Fatalf("a redelivered event was acted on again: %q %v %v", out, err, *asked) + } + if out, err := h.Chosen("desktop", "g14", tok, "e2"); err == nil || out != "refused" || len(*asked) != 1 { + t.Fatalf("the same token was taken twice: %q %v %v", out, err, *asked) + } +} + +// **A token is bound to its machine and its condition's life**: from another machine, for a condition +// cleared or silenced, or past its day, it is refused. +func TestATokenIsRefusedElsewhereAfterItsConditionAndPastItsDay(t *testing.T) { + h, dt, c, asked, k := answering(t) + tok := tokenOf(t, dt.sends[0], "Silence for a week") + if out, _ := h.Chosen("desktop", "shanks", tok, "e1"); out != "refused" { + t.Errorf("an answer from a machine it was not shown on was taken: %q", out) + } + if out, _ := h.Chosen("desktop", "g14", "0123456789abcdef01234567", "e2"); out != "refused" { + t.Errorf("an unknown token was taken: %q", out) + } + c.pass(TokenFor + time.Minute) + if out, _ := h.Chosen("desktop", "g14", tok, "e3"); out != "refused" { + t.Errorf("a token past its day was taken: %q", out) + } + + h, dt, c, asked, k = answering(t) + tok = tokenOf(t, dt.sends[0], "Silence for a week") + k.SilencedTill = c.now().Add(time.Hour) + h.Condition(EventChanged, k) + if out, _ := h.Chosen("desktop", "g14", tok, "e4"); out != "refused" { + t.Errorf("an answer for a silenced condition was taken: %q", out) + } + + h, dt, c, asked, k = answering(t) + tok = tokenOf(t, dt.sends[0], "Silence for a week") + h.Condition(EventCleared, Condition{Key: k.Key}) + if out, _ := h.Chosen("desktop", "g14", tok, "e5"); out != "refused" || len(*asked) != 0 { + t.Errorf("an answer for a cleared condition was taken: %q %v", out, *asked) + } +} + +// **A refused verb is said where it was chosen**: the mesh's refusal edits the notification. +func TestAVerbTheMeshRefusesIsSaid(t *testing.T) { + h, dt, _, _, _ := answering(t) + h.Ask = func(string, any) (json.RawMessage, error) { + return json.RawMessage(`{"isError":true,"content":[{"text":"silence is refused: no such condition"}]}`), nil + } + tok := tokenOf(t, dt.sends[0], "Silence for a week") + out, err := h.Chosen("desktop", "g14", tok, "e1") + if err == nil || !strings.HasPrefix(out, "failed") { + t.Fatalf("%q %v", out, err) + } + if m := dt.edits["g14=1"]; !strings.HasPrefix(m.Title, "Could not silence for a week") || + !strings.Contains(m.Body, "no such condition") { + t.Errorf("the refusal was not said: %+v", m) + } +} + +// **A words-only change to "Needs you" after a quiet time is said**, through the event and through a +// reading of the state; and a quiet warning is said once after a day. +func TestAQuietWarningIsSaidWhenItsWordsNeedTheOperator(t *testing.T) { + quiet := examples[0] + needing := quiet + needing.needs = "check that mesh-delivery runs." + needing.explanation = "Needs you: check that mesh-delivery runs. It has waited." + + // Through the event. + h, tg, dt, c, _ := newHolder(t) + k, _ := DecodeCondition(EventRaised, quiet.event(c.now(), false)) + h.Condition(EventRaised, k) + runFor(h, c, 2*time.Hour) + if len(dt.sends)+len(tg.sends) != 0 { + t.Fatal("said while it needed nothing") + } + k, _ = DecodeCondition(EventChanged, needing.event(c.now(), false)) + h.Condition(EventChanged, k) + settle(h, c) + if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: check that mesh-delivery runs.") { + t.Fatalf("event: desktop %+v", dt.sends) + } + + // Through a reading of the state. + h, tg, dt, c, _ = newHolder(t) + k, _ = DecodeCondition(EventRaised, quiet.event(c.now(), false)) + h.Condition(EventRaised, k) + runFor(h, c, 2*time.Hour) + k, _ = DecodeCondition("conditions", needing.event(c.now(), false)) + k.At = time.Time{} + h.Sync([]Condition{k}, c.now()) + settle(h, c) + if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: check that mesh-delivery runs.") { + t.Fatalf("sync: desktop %+v", dt.sends) + } + + // After a day, once, even if it still needs nothing. + h, tg, dt, c, _ = newHolder(t) + c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC) + k, _ = DecodeCondition(EventRaised, quiet.event(c.now(), false)) + h.Condition(EventRaised, k) + for i := 0; i < 50; i++ { + c.pass(time.Hour) + h.Tick() + settle(h, c) + } + if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Still open after a day: ") || + !strings.HasPrefix(dt.sends[0].Body, "Nothing for you to do.") { + t.Fatalf("after a day: desktop %+v", dt.sends) + } + _ = tg +} + +// **A notifier that started again is offered the open answers again**, with fresh tokens: the old ones +// are refused, the new ones taken. +func TestANotifierThatRestartedIsOfferedTheAnswersAgain(t *testing.T) { + h, dt, _, asked, _ := answering(t) + old := tokenOf(t, dt.sends[0], "Silence for a week") + if n := h.Reoffer("g14"); n != 1 { + t.Fatalf("offered again: %d", n) + } + again := dt.edits["g14=1"] + fresh := tokenOf(t, again, "Silence for a week") + if fresh == old || again.Title != dt.sends[0].Title { + t.Fatalf("not offered again: %+v", again) + } + if out, _ := h.Chosen("desktop", "g14", old, "e1"); out != "refused" { + t.Errorf("the lost answer's token still works: %q", out) + } + if out, _ := h.Chosen("desktop", "g14", fresh, "e2"); out != "done" || len(*asked) != 1 { + t.Errorf("the fresh token: %q %v", out, *asked) + } + if n := h.Reoffer("shanks"); n != 0 { + t.Errorf("offered on a machine it was never shown on: %d", n) + } +} diff --git a/modules/messenger/cmd/messenger/desktop.go b/modules/messenger/cmd/messenger/desktop.go index e98a859d..735ef35b 100644 --- a/modules/messenger/cmd/messenger/desktop.go +++ b/modules/messenger/cmd/messenger/desktop.go @@ -98,6 +98,8 @@ func (d *Desktop) show(m Message, replace map[string]int) (string, error) { args["replace_id"] = replace[machine] } if len(m.Actions) > 0 { + // Answers stay until the operator dismisses them: a warning's answers must not die in ten seconds. + args["expire_ms"] = 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 diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 38f9310a..c75c244b 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -56,10 +56,12 @@ import ( const ( RemindUrgent = time.Hour RemindWarning = 12 * time.Hour - ReopenWindow = 10 * time.Minute - CapPerHour = 20 - KeptSends = 200 - KeptRefusals = 50 + // QuietRemindAfter: a warning kept quiet as needing nothing is said once after this. + QuietRemindAfter = 24 * time.Hour + ReopenWindow = 10 * time.Minute + CapPerHour = 20 + KeptSends = 200 + KeptRefusals = 50 // FreshFor: an event older than this by its own time is history — state, never a message. FreshFor = 10 * time.Minute @@ -134,9 +136,12 @@ type Record struct { 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"` + Needs string `json:"needs,omitempty"` + Actions []Action `json:"actions,omitempty"` + Tokens map[string]Grant `json:"tokens,omitempty"` + // QuietReminded: a warning kept quiet as needing nothing was said once after QuietRemindAfter, so + // nothing stays silent for ever. + QuietReminded bool `json:"quiet_reminded,omitempty"` // Answered is what the operator chose from a notification, and what came of it. Answered string `json:"answered,omitempty"` Origin string `json:"origin"` @@ -246,6 +251,8 @@ type Holder struct { wantSync bool history int stale int + // answered is what came of each answer event, by its id: a redelivered one is acted on once. + answered map[string]string } func (h *Holder) init() { @@ -541,6 +548,13 @@ func (h *Holder) Tick() { // No channel could take it when it was said: tried again. h.deliver(r, r.Pending) h.persist(r) + case needsNothing(r) && !r.told() && !r.QuietReminded && !r.History && now.Sub(r.Raised) >= QuietRemindAfter: + // Quiet for a day: said once, so nothing stays silent for ever (ADR 0258). + h.mu.Lock() + r.QuietReminded = true + h.mu.Unlock() + h.deliver(r, "quiet-reminder") + h.persist(r) case !r.Reminded && !r.FirstSent.IsZero() && now.Sub(r.Raised) >= remindAfter(r.Severity) && now.Sub(r.FirstSent) >= remindAfter(r.Severity): // Once more after its bound — counted from its raising, and never sooner after it was diff --git a/modules/messenger/cmd/messenger/holder_test.go b/modules/messenger/cmd/messenger/holder_test.go index 3ddf0a6b..df48e702 100644 --- a/modules/messenger/cmd/messenger/holder_test.go +++ b/modules/messenger/cmd/messenger/holder_test.go @@ -29,6 +29,9 @@ func (f *fakeChannel) Send(m Message) (string, error) { } f.n++ f.sends = append(f.sends, m) + if f.name == "desktop" { + return fmt.Sprintf("g14=%d", f.n), nil // as the desktop answers: the machine and the notification + } return fmt.Sprint(f.n), nil } func (f *fakeChannel) Edit(id string, m Message) error { diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index 27b021b4..3ace7170 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -14,6 +14,8 @@ import ( "sync" "time" + _ "time/tzdata" // the operator's zone is known whatever this machine has installed + stdio "git.novox.be/novox/mesh-sdk/go" ) @@ -262,13 +264,28 @@ 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" +// AnswerEvent is the desk's notifier saying which answer was chosen, and StartedEvent that it started (so +// the answers it waited for are lost): the dunst holder of node-notifier emits them as its module's events +// until seats publish events of their own (to-be 46 phase 3). +const ( + AnswerEvent = "dunst.action-chosen" + StartedEvent = "dunst.started" +) // 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 == StartedEvent { + var body struct { + Machine string `json:"machine"` + } + _ = json.Unmarshal(e.Body, &body) + if body.Machine == "" { + body.Machine = e.Node + } + h.Reoffer(body.Machine) + return nil + } if e.Key != AnswerEvent { return nil } @@ -284,7 +301,11 @@ func listenForAnswers(h *Holder) { if machine == "" { machine = e.Node } - _, _ = h.Chosen("desktop", machine, body.Token) + // The machine is the event's own node, said by the runtime: the body's word is only a hint. + if e.Node != "" { + machine = e.Node + } + _, _ = h.Chosen("desktop", machine, body.Token, e.Headers["x-event-id"]) return nil } for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 551c5fb7..13a63d2e 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -56,12 +56,10 @@ func TestItDeclaresAndHoldsTheOperatorChannel(t *testing.T) { } if !reflect.DeepEqual(m.Consumes, []string{ ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared, - AnswerEvent}) { + AnswerEvent, StartedEvent}) { 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", - "seat:mesh-delivery.release", "seat:mesh-delivery.stop", "seat:" + ControllerSeat + ".plans", - "seat:node-service-manager.restart"}) { + if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions"}) { 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 ae701b8c..cdb29ff2 100644 --- a/modules/messenger/cmd/messenger/outbox.go +++ b/modules/messenger/cmd/messenger/outbox.go @@ -27,7 +27,7 @@ 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" { + if needsNothing(r) && what != "escalated" && what != "quiet-reminder" { // 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 diff --git a/modules/messenger/cmd/messenger/sync.go b/modules/messenger/cmd/messenger/sync.go index 70e5ef9d..1633ae22 100644 --- a/modules/messenger/cmd/messenger/sync.go +++ b/modules/messenger/cmd/messenger/sync.go @@ -57,12 +57,14 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult { old := h.open[c.Key] var r *Record known := old != nil + wasQuiet := false switch { case old != nil && old.Seen.After(asked): h.mu.Unlock() res.Skipped = append(res.Skipped, c.Key) continue case old != nil: + wasQuiet = needsNothing(old) && !old.told() if !old.Cleared.IsZero() { // Ended here, open there: back to open, quietly — the reading is not an event. old.Cleared = time.Time{} @@ -98,6 +100,9 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult { urgent = append(urgent, r) case !known && now.Sub(r.Raised) <= FreshFor: fresh = append(fresh, r) + case wasQuiet && !needsNothing(r): + // Kept quiet as needing nothing; its words now need the operator (ADR 0258). + fresh = append(fresh, r) default: res.State = append(res.State, r.Key) } diff --git a/modules/messenger/cmd/messenger/words.go b/modules/messenger/cmd/messenger/words.go index ff521619..226749f4 100644 --- a/modules/messenger/cmd/messenger/words.go +++ b/modules/messenger/cmd/messenger/words.go @@ -47,6 +47,8 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold return Message{Title: resolvedLine(r) + ", after " + roughly(r.Cleared.Sub(r.Raised)), Quiet: true} case "reminder": title = "Still open: " + headline + case "quiet-reminder": + title = "Still open after a day: " + headline case "escalated": title = "Now urgent: " + headline case "reopened": @@ -61,7 +63,7 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold } } since := "Since " + when(r.Raised, now, loc) - if what == "reminder" { + if what == "reminder" || what == "quiet-reminder" { since = fmt.Sprintf("Open since %s, for %s.", when(r.Raised, now, loc), roughly(now.Sub(r.Raised))) } else { since += "." diff --git a/modules/messenger/cmd/messenger/words_test.go b/modules/messenger/cmd/messenger/words_test.go index f5026885..389013ad 100644 --- a/modules/messenger/cmd/messenger/words_test.go +++ b/modules/messenger/cmd/messenger/words_test.go @@ -55,15 +55,12 @@ var examples = []example{{ 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\""}`, + offers: "Details", choose: "Details", + cleared: "Resolved: openrazer delivery no longer waiting, after 5.0 h", openFor: 5 * time.Hour, }, { name: "a module unhealthy", @@ -74,17 +71,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", 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"}}}, + headline: "openrazer not working on g14", + needs: "restart its service openrazer-daemon on g14; if it fails again, the details say why.", + explanation: "Needs you: restart its service openrazer-daemon on g14; 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.", 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"}`, + body: "Needs you: restart its service openrazer-daemon on g14; 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: "Details", choose: "Details", cleared: "Resolved: openrazer works again on g14, after 2.0 h", openFor: 2 * time.Hour, }, { name: "failed units on a machine", @@ -102,14 +99,14 @@ var examples = []example{{ "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": ""}}}, + Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": "", "cause": "operator-answer"}}}, 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\""}`, + asked: `seat:mesh-controller.conditions {"cause":"operator-answer","for":"7d","silence":"machine.shanks.units","why":"chosen on the desktop notification \"3 failed services on shanks\" on g14 (a desk click: whoever was at the operator's session)"}`, cleared: "Resolved: no failed services on shanks any more, after 45 min", openFor: 45 * time.Minute, }, { name: "a healer wanted", @@ -137,14 +134,11 @@ var examples = []example{{ "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, + 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\""}`, + offers: "Details", choose: "Details", + cleared: "Resolved: delivery of hq is no longer held, after 3 min", openFor: 3 * time.Minute, }} @@ -219,13 +213,21 @@ func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *te 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) + outcome, err := h.Chosen("desktop", "g14", tok, "event-1") + var done Message + if e.choose == DetailsLabel { + done = dt.sends[len(dt.sends)-1] + if err != nil || outcome != "shown" || len(asked) != 0 || done.Title != "Details: "+e.headline { + t.Fatalf("details: %q %v %v %q", outcome, err, asked, done.Title) + } + } else { + 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["g14=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)) @@ -233,7 +235,7 @@ func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *te t.Fatal(err) } h.Condition(EventCleared, cleared) - end := dt.edits["1"] + end := dt.edits["g14=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) } @@ -278,7 +280,7 @@ func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) { if details.Label != DetailsLabel { t.Fatalf("%+v", m.Actions) } - if _, err := h.Chosen("desktop", "g14", details.Token); err != nil { + if _, err := h.Chosen("desktop", "g14", details.Token, ""); err != nil { t.Fatal(err) } shown := dt.sends[len(dt.sends)-1] @@ -286,7 +288,7 @@ func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) { !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 { + if _, err := h.Chosen("desktop", "g14", "not-a-token", ""); err == nil { t.Error("an unknown answer was taken") } } diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 52c0531a..7801db20 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -28,18 +28,15 @@ "mesh-controller.condition-raised", "mesh-controller.condition-changed", "mesh-controller.condition-cleared", - "dunst.action-chosen" + "dunst.action-chosen", + "dunst.started" ], "emits": [ "refused" ], "invokes": [ "seat:node-notifier.send", - "seat:mesh-controller.conditions", - "seat:mesh-delivery.release", - "seat:mesh-delivery.stop", - "seat:mesh-controller.plans", - "seat:node-service-manager.restart" + "seat:mesh-controller.conditions" ], "state": [ "open",