Say what the operator needs to do, keep quiet what needs nothing, and take answers from the desk
mesh/merge-gate pass: builds dunst, messenger → g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/plain-notifications delivered: every member is delivered
mesh/merge-gate pass: builds dunst, messenger → g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/plain-notifications delivered: every member is delivered
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).
This commit is contained in:
+11
-1
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -69,5 +69,8 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"emits": [
|
||||
"action-chosen"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 `<scope>.<id>.<kind>`. 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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"}) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user