From a6296dfd6f07b6078f4f82f340dc853f122bfe79 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 13:26:26 +0200 Subject: [PATCH 1/2] Say each notification in plain words the operator reads at a glance The desktop popups were walls of plan ids, commits, keys and verb syntax in UTC. The messenger now shows the condition's headline, explanation and resolved line, since when in the operator's time zone, and a clearance in one line (hq ADR 0253). --- modules/messenger/README.md | 14 +- modules/messenger/cmd/messenger/condition.go | 8 + .../messenger/cmd/messenger/history_test.go | 18 +- modules/messenger/cmd/messenger/holder.go | 98 +++----- .../messenger/cmd/messenger/holder_test.go | 43 +++- modules/messenger/cmd/messenger/main.go | 46 +++- modules/messenger/cmd/messenger/outbox.go | 28 +-- modules/messenger/cmd/messenger/status.go | 24 +- modules/messenger/cmd/messenger/sync.go | 6 +- modules/messenger/cmd/messenger/words.go | 211 ++++++++++++++++++ modules/messenger/cmd/messenger/words_test.go | 191 ++++++++++++++++ modules/messenger/module.json | 2 +- 12 files changed, 573 insertions(+), 116 deletions(-) create mode 100644 modules/messenger/cmd/messenger/words.go create mode 100644 modules/messenger/cmd/messenger/words_test.go diff --git a/modules/messenger/README.md b/modules/messenger/README.md index b669735a..32de38c3 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -35,6 +35,15 @@ what is said is decided by the state now and by when a thing happened, never by - **Start grace.** Nothing is sent in the **first minute** after start but that one urgent message; what arrives meanwhile goes out, coalesced, when the minute ends. +**A message reads in one glance** (novox/hq ADR 0253). Its title is the condition's headline — a few +plain words such as "openrazer not working on g14" — prefixed "Urgent:", "Still open:" or "Now urgent:" +where that applies; its body is the condition's explanation (what happened, what it means, whether the +operator needs to act) and since when, in the operator's time zone. No key, id, commit, command or markup: +those stay in the controller's `conditions` and in this module's history. A clearance is one line, the +condition's resolved line and how long it was open: "Resolved: openrazer works again on g14, after 26 min". +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`). + | When | What | |---|---| | `condition-raised` | one message, deduplicated by the condition's key | @@ -76,6 +85,9 @@ Nothing is sent until these are given; `messenger_status` says which is missing. 2. **The chat id**, as a setting: `settings` for `messenger` with `{"telegram-chat-id": ""}`. 3. **The desktop machines**, as a setting: `{"desktop-machines": ["", …]}`. Without it, warnings go to Telegram. +4. **The time zone**, as a setting: `{"time-zone": ""}`. Every time a message says is + said in it; not given, the zone of the machine this runs on. A name this machine does not know is + logged and that zone used instead. ## Tools @@ -83,7 +95,7 @@ Nothing is sent until these are given; `messenger_status` says which is missing. |---|---| | `operator-channel.open` | what is open now, urgent first, with where it went, silenced, reminded, held, refused, unsent | | `operator-channel.history` | what was said lately, and the refusals | -| `operator-channel.notify` | a message from a module using the seat: key, severity, summary; `clear` to end it | +| `operator-channel.notify` | a message from a module using the seat: key, severity, summary, and best a plain `headline`, `explanation` and `resolved` line; `clear` to end it | | `messenger_status` | whether the operator can be reached and why not; `check` asks Telegram whether the token works | | `messenger_recent` | the recent sends, edits, digests and failures | | `messenger_test` | a test message now, to telegram, desktop or both | diff --git a/modules/messenger/cmd/messenger/condition.go b/modules/messenger/cmd/messenger/condition.go index e674147f..5faa1bdc 100644 --- a/modules/messenger/cmd/messenger/condition.go +++ b/modules/messenger/cmd/messenger/condition.go @@ -17,6 +17,8 @@ package main // - `severity` is `urgent` or `warning`. Anything else is not guessed: the event is unreadable. // - `silenced` is absent, null, or an object {until, by, why}; `until` in the past is not silenced. // - 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. // - `key` is `..`. When it is absent it is made from subject and kind; when // neither gives one the event is unreadable. // - A cleared event carries the condition as last held, and may add `cleared` (its time). @@ -60,6 +62,9 @@ type Condition struct { Machine string Severity string Summary string + Headline string + Explanation string + Resolved string Source string Resolver string Raised time.Time @@ -164,6 +169,9 @@ func DecodeCondition(event string, body []byte) (Condition, error) { c.Kind = str("kind") c.Severity = str("severity") c.Summary = str("summary") + c.Headline = str("headline") + c.Explanation = str("explanation") + c.Resolved = str("resolved") c.Resolver = str("resolver") c.Raised = when("raised") c.LastObserved = when("last-observed", "last_observed", "lastObserved") diff --git a/modules/messenger/cmd/messenger/history_test.go b/modules/messenger/cmd/messenger/history_test.go index 5402bfb2..aed732d3 100644 --- a/modules/messenger/cmd/messenger/history_test.go +++ b/modules/messenger/cmd/messenger/history_test.go @@ -205,7 +205,7 @@ func TestTodaysBacklogWithAnUrgentStillOpenIsOneMessage(t *testing.T) { if len(tg.sends) != 1 || len(dt.sends) != 1 || len(tg.edits)+len(dt.edits) != 0 { t.Fatalf("telegram %d desktop %d", len(tg.sends), len(dt.sends)) } - if !strings.HasPrefix(tg.sends[0].Title, "URGENT: ") || !strings.Contains(tg.sends[0].Text(), "merge.mesh-catalog.f2b51686.not-acted") { + if !strings.HasPrefix(tg.sends[0].Title, "Urgent: ") || !strings.Contains(tg.sends[0].Text(), "Merge not acted") { t.Fatalf("message: %q", tg.sends[0].Text()) } if got := len(h.Open()); got != 4 { @@ -280,11 +280,11 @@ func TestABurstIsOneMessage(t *testing.T) { t.Fatalf("sent inside the window: %d", len(tg.sends)) } runFor(h, c, time.Minute) - if len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "WARNING: 5 new warnings") { + if len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "Mesh: 5 new problems") { t.Fatalf("sends %d: %+v", len(tg.sends), tg.sends) } for i := 0; i < 5; i++ { - if !strings.Contains(tg.sends[0].Body, fmt.Sprintf("seat.s%d.silent", i)) { + if !strings.Contains(tg.sends[0].Body, fmt.Sprintf("seat s%d silent", i)) { t.Fatalf("the digest does not name s%d: %q", i, tg.sends[0].Body) } } @@ -293,7 +293,7 @@ func TestABurstIsOneMessage(t *testing.T) { h.Condition(EventCleared, Condition{Key: fmt.Sprintf("seat.s%d.silent", i)}) } runFor(h, c, time.Minute) - if len(tg.sends) != 2 || len(tg.edits) != 0 || !strings.HasPrefix(tg.sends[1].Title, "CLEARED: 3 cleared") { + if len(tg.sends) != 2 || len(tg.edits) != 0 || !strings.HasPrefix(tg.sends[1].Title, "Mesh: 3 resolved") { t.Fatalf("sends %d edits %d: %q", len(tg.sends), len(tg.edits), tg.sends[len(tg.sends)-1].Title) } // A burst of urgent ones: the first at once, the rest as one. @@ -303,7 +303,7 @@ func TestABurstIsOneMessage(t *testing.T) { c.pass(time.Second) } runFor(h, c, time.Minute) - if len(tg.sends) != 4 || !strings.HasPrefix(tg.sends[3].Title, "URGENT: 5 new urgent") { + if len(tg.sends) != 4 || !strings.HasPrefix(tg.sends[3].Title, "Urgent: 5 urgent problems") { t.Fatalf("sends %d: %q", len(tg.sends), tg.sends[len(tg.sends)-1].Title) } } @@ -322,7 +322,7 @@ func TestTheDesktopIsGentle(t *testing.T) { // Urgent is not held to the desktop's rhythm. h.Condition(EventRaised, cond("machine.ace.silent", Urgent, "the home server is silent")) h.Flush() - if len(dt.sends) != 2 || !strings.HasPrefix(dt.sends[1].Title, "URGENT: ") || len(tg.sends) != 1 { + if len(dt.sends) != 2 || !strings.HasPrefix(dt.sends[1].Title, "Urgent: ") || len(tg.sends) != 1 { t.Fatalf("urgent: desktop %d telegram %d", len(dt.sends), len(tg.sends)) } // The urgent message took the held warnings along — one notification, read in one go. What @@ -358,7 +358,7 @@ func TestNothingIsSentInTheFirstMinuteButTheUrgentOpenAsOne(t *testing.T) { if len(res.Said) != 2 || len(res.State) != 1 || len(res.News) != 1 { t.Fatalf("sync: %+v", res) } - if len(tg.sends) != 1 || len(dt.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "URGENT: 2 new urgent") { + if len(tg.sends) != 1 || len(dt.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "Urgent: 2 urgent problems") { t.Fatalf("telegram %d desktop %d: %+v", len(tg.sends), len(dt.sends), tg.sends) } c.pass(10 * time.Second) @@ -377,8 +377,8 @@ func TestNothingIsSentInTheFirstMinuteButTheUrgentOpenAsOne(t *testing.T) { if len(dt.sends) != 2 || len(tg.sends) != 2 { t.Fatalf("after the grace: telegram %d desktop %d", len(tg.sends), len(dt.sends)) } - if d := dt.sends[1]; !strings.Contains(d.Body, "plan.42.stalled") || !strings.Contains(d.Body, "plan.43.stalled") || - !strings.Contains(d.Body, "machine.shanks.silent") || strings.Contains(d.Body, "plan.41.stalled") { + if d := dt.sends[1]; !strings.Contains(d.Body, "plan 42 stalled") || !strings.Contains(d.Body, "plan 43 stalled") || + !strings.Contains(d.Body, "machine shanks silent") || strings.Contains(d.Body, "plan 41 stalled") { t.Fatalf("desktop digest: %q", d.Text()) } } diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index aee54f69..8a5bf05b 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -115,11 +115,16 @@ func editsSilently(ch Channel) bool { // Record is one open message, kept in the module's own state so a restart forgets nothing. type Record struct { - Key string `json:"key"` - Kind string `json:"kind"` - Subject string `json:"subject"` - Severity string `json:"severity"` - Summary string `json:"summary"` + Key string `json:"key"` + Kind string `json:"kind"` + Subject string `json:"subject"` + Severity string `json:"severity"` + 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"` Origin string `json:"origin"` More string `json:"more"` Raised time.Time `json:"raised"` @@ -138,6 +143,18 @@ type Record struct { Seen time.Time `json:"seen,omitempty"` } +// 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} +} + +// 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 +} + func (r *Record) silenced(now time.Time) bool { return !r.SilencedTill.IsZero() && now.Before(r.SilencedTill) } @@ -179,6 +196,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 + // 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 work sync.Mutex // one event, call or tick at a time mu sync.Mutex // what the status reads @@ -289,10 +308,7 @@ func (h *Holder) Condition(event string, c Condition) { h.lastHeard = now synced := h.syncedAt h.mu.Unlock() - rec := Record{ - Key: c.Key, Kind: c.Kind, Subject: c.SubjectWords(), Severity: c.Severity, Summary: c.Summary, - Origin: "condition", More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill, - } + rec := recordOf(c) when := c.When(event) quiet := false switch { @@ -339,9 +355,9 @@ func (h *Holder) Unreadable(key string, err error) { h.mu.Unlock() h.Logf("[messenger] refused %s: %v (unreadable events since start: %d)", key, err, n) h.sayOnce("messenger.unreadable-event", time.Hour, Message{ - Title: "WARNING: a condition event could not be read", - Body: fmt.Sprintf("the operator-channel's holder could not read %d condition event(s) from the controller; "+ - "what was wrong is in messenger_status. A condition may be open that was not said.", n), + Title: "A mesh message could not be read", + Body: fmt.Sprintf("The messenger could not read %d update(s) from the controller, so a problem may be open "+ + "that was not said. An agent can find out why in the messenger's status.", n), }) } @@ -354,6 +370,7 @@ func (h *Holder) raised(rec Record, quiet bool) { // Said again while open: the same message. Its words are kept current; nothing is sent. h.mu.Lock() old.Summary, old.Subject, old.Kind, old.SilencedTill = rec.Summary, rec.Subject, rec.Kind, rec.SilencedTill + old.takeWords(rec) if rec.Severity != "" { old.Severity = rec.Severity } @@ -368,6 +385,7 @@ func (h *Holder) raised(rec Record, quiet bool) { old.Cleared = time.Time{} old.Count++ old.Summary, old.Severity, old.SilencedTill = rec.Summary, rec.Severity, rec.SilencedTill + old.takeWords(rec) old.Seen = now h.mu.Unlock() if !quiet && !old.silenced(now) && old.told() { @@ -411,6 +429,7 @@ func (h *Holder) changed(rec Record, quiet bool) { h.mu.Lock() escalated := old.Severity == Warning && rec.Severity == Urgent old.Summary, old.Subject, old.SilencedTill = rec.Summary, rec.Subject, rec.SilencedTill + old.takeWords(rec) if rec.Severity != "" { old.Severity = rec.Severity } @@ -521,52 +540,6 @@ func remindAfter(severity string) time.Duration { return RemindWarning } -// compose is the message for a record. withhold names what the content rule refused, so the words -// that carried it are not sent. -func compose(r *Record, what string, now time.Time, withhold int) Message { - sev := strings.ToUpper(r.Severity) - if sev == "" { - sev = "WARNING" - } - summary := r.Summary - if withhold > 0 { - summary = "channel-refused: this message carried " + r.Refused + ", so its words are withheld" - } - var title string - switch what { - case "raised": - title = sev + ": " + summary - case "reminder": - title = "STILL OPEN after " + roughly(now.Sub(r.Raised)) + ": " + summary - case "escalated": - title = "NOW URGENT: " + summary - case "reopened": - title = sev + " (open again, " + fmt.Sprint(r.Count) + " times): " + summary - case "cleared": - title = "CLEARED after " + roughly(r.Cleared.Sub(r.Raised)) + ": " + summary - default: - title = sev + ": " + summary - } - lines := []string{} - if withhold < 3 && r.Subject != "" { - about := "about: " + r.Subject - if r.Kind != "" { - about += " (" + r.Kind + ")" - } - lines = append(lines, about) - } - lines = append(lines, "since: "+r.Raised.UTC().Format("2006-01-02 15:04")+" UTC") - if withhold < 2 { - lines = append(lines, "key: "+r.Key) - if r.More != "" { - lines = append(lines, "more: "+r.More) - } - } else { - lines = append(lines, "more: conditions (the open ones, through the mesh)") - } - return Message{Title: title, Body: strings.Join(lines, "\n"), Urgent: r.Severity == Urgent, Quiet: what == "cleared"} -} - // say composes a record's message under the content rule: refused, it is composed again with less of // it, until what remains may leave. The refusal is recorded and stated once per record. func (h *Holder) say(r *Record, what string) Message { @@ -574,13 +547,13 @@ func (h *Holder) say(r *Record, what string) Message { if r.Refused != "" { // Refused before: its words stay withheld in every later message too. for w := 1; w <= 3; w++ { - m := compose(r, what, now, w) + m := compose(r, what, now, h.zone(), w) if _, ok := Check(m.Text()); ok { return m } } } - m := compose(r, what, now, 0) + m := compose(r, what, now, h.zone(), 0) refusal, ok := Check(m.Text()) if ok { return m @@ -605,13 +578,12 @@ func (h *Holder) say(r *Record, what string) Message { } } for w := 1; w <= 3; w++ { - m := compose(r, what, now, w) + m := compose(r, what, now, h.zone(), w) if _, ok := Check(m.Text()); ok { return m } } - return Message{Title: "WARNING: channel-refused: a message carried " + refusal.What + " and was withheld", - Body: "more: conditions (the open ones, through the mesh)", Urgent: r.Severity == Urgent} + return withheld(refusal.What, r.Severity == Urgent) } // deliver sends a record's message where its severity routes it. diff --git a/modules/messenger/cmd/messenger/holder_test.go b/modules/messenger/cmd/messenger/holder_test.go index f54c48d5..ade54854 100644 --- a/modules/messenger/cmd/messenger/holder_test.go +++ b/modules/messenger/cmd/messenger/holder_test.go @@ -72,7 +72,24 @@ func (c *clock) pass(d time.Duration) { c.t = c.t.Add(d) } func start() *clock { return &clock{t: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)} } 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} + 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 + "."} +} + +// headlineOf is the headline a test condition carries: its key in words, so a message can be told by it +// and never carries the key itself (novox/hq ADR 0253). +func headlineOf(key string) string { return strings.ReplaceAll(key, ".", " ") } + +// keyless fails a message that carries a condition's key: the operator reads words, never keys. +func keyless(t *testing.T, ms ...Message) { + t.Helper() + for _, m := range ms { + for _, w := range strings.Fields(m.Text()) { + if strings.Count(w, ".") >= 2 && !strings.HasSuffix(w, ".") { + t.Errorf("a message carries a key %q: %q", w, m.Text()) + } + } + } } // settle lets a burst window pass and sends what is due. @@ -104,7 +121,9 @@ func TestARaisedConditionIsSentOnceByItsKey(t *testing.T) { if len(tg.sends) != 1 || len(dt.sends) != 1 { t.Fatalf("urgent: telegram %d, desktop %d; want one each", len(tg.sends), len(dt.sends)) } - if !strings.Contains(tg.sends[0].Text(), "machine.ace.silent") || !strings.HasPrefix(tg.sends[0].Title, "URGENT: ") { + keyless(t, tg.sends...) + if tg.sends[0].Title != "Urgent: machine ace silent" || !strings.HasPrefix(tg.sends[0].Body, "What it means: ") || + !strings.Contains(tg.sends[0].Body, "\nSince ") { t.Fatalf("message: %q", tg.sends[0].Text()) } if open := h.Open(); len(open) != 1 || open[0].Count != 1 { @@ -151,7 +170,7 @@ func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) { if len(tg.sends) != CapPerHour+1 { t.Fatalf("sent %d, want the cap of %d and one saying so", len(tg.sends), CapPerHour) } - if last := tg.sends[CapPerHour]; !strings.HasPrefix(last.Title, "HELD BACK: telegram is at its cap") { + if last := tg.sends[CapPerHour]; !strings.HasPrefix(last.Title, "Mesh messages paused for this hour") { t.Fatalf("the cap was not said: %q", last.Title) } st := h.Status("listening") @@ -172,11 +191,11 @@ func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) { t.Fatalf("sends %d", len(tg.sends)) } d := tg.sends[len(tg.sends)-1] - if !strings.HasPrefix(d.Title, "URGENT: 5 new urgent") { + if !strings.HasPrefix(d.Title, "Urgent: 5 urgent problems") { t.Fatalf("digest: %q", d.Title) } for i := 20; i < 25; i++ { - if !strings.Contains(d.Body, fmt.Sprintf("machine.m%d.silent", i)) { + if !strings.Contains(d.Body, fmt.Sprintf("machine m%d silent", i)) { t.Fatalf("the digest does not name m%d: %q", i, d.Body) } } @@ -194,7 +213,7 @@ func TestAMessageCarryingAnAddressIsRefusedAndSaidWithItsWordsWithheld(t *testin if strings.Contains(text, "192.168") || strings.Contains(text, "token=") { t.Fatalf("the address left: %q", text) } - if !strings.Contains(text, "channel-refused") || !strings.Contains(text, "provider.keycloak.ace.failing") { + if !strings.Contains(text, "withheld, because it carried an IPv4 address") || !strings.Contains(text, "provider keycloak ace failing") { t.Fatalf("not said as refused, by key: %q", text) } hist := h.History(10)["refusals"].([]RefusalNote) @@ -242,7 +261,7 @@ func TestStillOpenPastItsBoundItIsSaidOnceMore(t *testing.T) { } c.pass(2 * time.Minute) h.Tick() - if len(tg.sends) != 3 || !strings.HasPrefix(tg.sends[2].Title, "STILL OPEN after 61 min") { + if len(tg.sends) != 3 || (tg.sends[2].Title != "Still open: bus controller slow-consumer" || !strings.Contains(tg.sends[2].Body, "for 61 min")) { t.Fatalf("urgent reminder: %d %q", len(tg.sends), tg.sends[len(tg.sends)-1].Title) } c.pass(3 * time.Hour) @@ -253,7 +272,7 @@ func TestStillOpenPastItsBoundItIsSaidOnceMore(t *testing.T) { c.pass(9 * time.Hour) // the warning is now 13 h old h.Tick() settle(h, c) - if len(tg.sends) != 4 || !strings.Contains(tg.sends[3].Text(), "plan.41.stalled") { + if len(tg.sends) != 4 || tg.sends[3].Title != "Still open: plan 41 stalled" { t.Fatalf("warning reminder: %d", len(tg.sends)) } } @@ -268,7 +287,7 @@ func TestClearedEditsTheFirstMessageAndReopenedWithinTenMinutesIsNotNew(t *testi if len(tg.sends) != 1 || len(tg.edits) != 1 || len(dt.edits) != 1 { t.Fatalf("telegram sends %d edits %d, desktop edits %d", len(tg.sends), len(tg.edits), len(dt.edits)) } - if m := tg.edits["1"]; !strings.HasPrefix(m.Title, "CLEARED after 14 min") { + if m := tg.edits["1"]; m.Title != "Resolved: machine ace silent, after 14 min" || m.Body != "" { t.Fatalf("edit: %q", m.Title) } if len(h.Open()) != 0 { @@ -277,7 +296,7 @@ func TestClearedEditsTheFirstMessageAndReopenedWithinTenMinutesIsNotNew(t *testi c.pass(5 * time.Minute) k.At = c.now() // a reopening keeps its first raising time; the event says when it happened h.Condition(EventRaised, k) - if len(tg.sends) != 1 || !strings.Contains(tg.edits["1"].Title, "open again, 2 times") { + if len(tg.sends) != 1 || !strings.Contains(tg.edits["1"].Title, "(again, 2nd time)") { t.Fatalf("reopened as new: sends %d, edit %q", len(tg.sends), tg.edits["1"].Title) } h.Condition(EventCleared, Condition{Key: k.Key}) @@ -326,7 +345,7 @@ func TestEscalationIsSaidOnce(t *testing.T) { h.Condition(EventChanged, k) h.Condition(EventChanged, k) settle(h, c) - if len(dt.sends) != 2 || len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "NOW URGENT") { + if len(dt.sends) != 2 || len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "Now urgent: ") { t.Fatalf("desktop %d telegram %d", len(dt.sends), len(tg.sends)) } // Escalated while the warning still waited its window: one message, the urgent one. @@ -335,7 +354,7 @@ func TestEscalationIsSaidOnce(t *testing.T) { k2.Severity = Urgent h.Condition(EventChanged, k2) settle(h, c) - if len(dt.sends) != 3 || !strings.HasPrefix(dt.sends[2].Title, "NOW URGENT") { + if len(dt.sends) != 3 || !strings.HasPrefix(dt.sends[2].Title, "Now urgent: ") { t.Fatalf("desktop %d: %q", len(dt.sends), dt.sends[len(dt.sends)-1].Title) } } diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index e8286f7a..c93e15dc 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -25,6 +25,22 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } type Settings struct { TelegramChatID string `json:"telegram-chat-id"` DesktopMachines []string `json:"desktop-machines"` + // TimeZone is the operator's, an IANA name such as Europe/Brussels: every time a message says is said + // in it. Empty is this machine's own zone. + TimeZone string `json:"time-zone"` +} + +// zoneOf is the location a time-zone setting names, or this machine's own when it names none or one +// that does not exist (said, never guessed silently). +func zoneOf(name string) (*time.Location, error) { + if name == "" { + return time.Local, nil + } + loc, err := time.LoadLocation(name) + if err != nil { + return time.Local, fmt.Errorf("the time-zone %q is not one this machine knows; times are said in its own zone", name) + } + return loc, nil } func readSettings(path string) (Settings, error) { @@ -47,6 +63,9 @@ func readSettings(path string) (Settings, error) { case float64: s.TelegramChatID = fmt.Sprintf("%.0f", v) } + if tz, ok := loose["time-zone"].(string); ok { + s.TimeZone = strings.TrimSpace(tz) + } if list, ok := loose["desktop-machines"].([]any); ok { for _, m := range list { if name, ok := m.(string); ok && strings.TrimSpace(name) != "" { @@ -128,7 +147,7 @@ func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.n func main() { settingsFile := os.Getenv("MESH_MESSENGER_SETTINGS") var said sync.Mutex - lastSaid := "" + lastSaid, zoneSaid := "", "" settings := func() Settings { s, err := readSettings(settingsFile) said.Lock() @@ -153,7 +172,20 @@ func main() { }, Store: stateStore{}, Logf: logf, - Emit: func(event string, body any) error { return stdio.Emit(event, body) }, + Zone: func() *time.Location { + loc, err := zoneOf(settings().TimeZone) + said.Lock() + defer said.Unlock() + if err != nil && err.Error() != zoneSaid { + logf("[messenger] %v", err) + } + zoneSaid = "" + if err != nil { + zoneSaid = err.Error() + } + return loc + }, + Emit: func(event string, body any) error { return stdio.Emit(event, body) }, } h.init() l := &listening{now: "not yet: starting"} @@ -286,7 +318,8 @@ func tools(h *Holder, l *listening) []stdio.Tool { Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 50)), nil }}, {Name: "operator-channel.notify", Description: "Tell the operator something, as a module that uses the seat: a key (the same key is the same " + - "message), urgent or warning, one line in the mesh's words, and what it is about. clear says it is over. " + + "message), urgent or warning, one line in the mesh's words, and what it is about; and best, in plain " + + "words, a headline, an explanation and the line said when it is over. clear says it is over. " + "Roles and words only: an address, a path or a secret is refused. Deduplicated, capped and routed " + "like the controller's conditions.", Input: map[string]any{ @@ -294,11 +327,16 @@ func tools(h *Holder, l *listening) []stdio.Tool { "severity": map[string]any{"type": "string", "enum": []string{Urgent, Warning}}, "summary": str("one line in the mesh's words"), "subject": str("what it is about: a machine's role, a module, a plan"), + "headline": str("a few plain words the operator reads as the title, e.g. backup failed on ace: no ids, keys or commands"), + "explanation": str("one or two plain sentences: what happened, what it means for the operator, and whether " + + "they need to act"), + "resolved": str("the one short line said when it is over, e.g. backups run again on ace"), "clear": map[string]any{"type": "boolean", "description": "it is over: the message is edited to say so"}, }, Run: func(a map[string]any) (any, error) { clear, _ := a["clear"].(bool) - return h.Notify(strArg(a, "key"), strArg(a, "severity"), strArg(a, "summary"), strArg(a, "subject"), clear) + return h.Notify(strArg(a, "key"), strArg(a, "severity"), strArg(a, "summary"), strArg(a, "subject"), clear, + strArg(a, "headline"), strArg(a, "explanation"), strArg(a, "resolved")) }}, {Name: "messenger_status", Description: "Whether the operator can be reached, and why not: each channel — can it send, what it lacks " + diff --git a/modules/messenger/cmd/messenger/outbox.go b/modules/messenger/cmd/messenger/outbox.go index dfe067bb..329e0095 100644 --- a/modules/messenger/cmd/messenger/outbox.go +++ b/modules/messenger/cmd/messenger/outbox.go @@ -265,9 +265,9 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) { said := h.capSaid[name] h.mu.Unlock() if said.IsZero() || now.Sub(said) >= time.Hour { - m := Message{Title: fmt.Sprintf("HELD BACK: %s is at its cap of %d messages an hour", name, CapPerHour), - Body: fmt.Sprintf("%d message(s) are held and go out as one when the hour allows.\n"+ - "more: conditions (through the mesh)", len(entries)), Urgent: true} + m := Message{Title: "Mesh messages paused for this hour", + Body: fmt.Sprintf("The mesh sent %d messages here this hour, its limit. %d more are held and come as "+ + "one message when the hour allows.", CapPerHour, len(entries)), Urgent: true} _, err := ch.Send(m) h.noteChannel(name, err) if err == nil { @@ -414,32 +414,24 @@ func digest(items []item, msgs []Message) Message { parts = append(parts, fmt.Sprintf("%d %s", n, many)) } } - add(newU, "new urgent", "new urgent") - add(newW, "new warning", "new warnings") + add(newU, "urgent problem", "urgent problems") + add(newW, "new problem", "new problems") add(still, "still open", "still open") - add(cleared, "cleared", "cleared") + add(cleared, "resolved", "resolved") add(notices, "notice", "notices") - prefix := "WARNING" + prefix := "Mesh" if urgent { - prefix = "URGENT" - } else if quiet { - prefix = "CLEARED" + prefix = "Urgent" } + // Each line is one message's title: a headline, or a resolved line — plain words, no key (ADR 0253). var lines []string for i, m := range msgs { if i == DigestLines { lines = append(lines, fmt.Sprintf("and %d more", len(msgs)-DigestLines)) break } - line := m.Title - if key := items[i].Key; items[i].Msg == nil { - if _, ok := Check(key); ok { - line += " [" + key + "]" - } - } - lines = append(lines, "- "+line) + lines = append(lines, "- "+m.Title) } - lines = append(lines, "more: conditions (through the mesh)") return Message{Title: prefix + ": " + strings.Join(parts, ", "), Body: strings.Join(lines, "\n"), Urgent: urgent, Quiet: quiet} } diff --git a/modules/messenger/cmd/messenger/status.go b/modules/messenger/cmd/messenger/status.go index dd8ba52c..8a824dd8 100644 --- a/modules/messenger/cmd/messenger/status.go +++ b/modules/messenger/cmd/messenger/status.go @@ -36,6 +36,7 @@ type Status struct { Stale int `json:"events_taken_as_state"` // older than the freshness bound: recorded, never said Grace bool `json:"in_start_grace,omitempty"` StateProblem string `json:"state_problem,omitempty"` + TimeZone string `json:"time_zone"` // the zone every time in a message is said in Rules []string `json:"rules"` } @@ -95,7 +96,10 @@ func (h *Holder) Status(listening string) Status { h.mu.Unlock() sort.Strings(st.Unsent) st.Listening = listening + st.TimeZone = h.zone().String() st.Rules = []string{ + "a message reads in one glance: the condition's headline as its title, its explanation, and since when in the operator's time zone; no key, id, commit or command — those stay in the controller's conditions and in this holder's history (novox/hq ADR 0253)", + "a clearance is one line: the condition's resolved line and how long it was open", "history is never news: what is open is read from the controller on start, every 10 min and when an old event arrives; an event older than that reading is not acted on, one older than 10 min by its own time is state, never a message", "a clearing of something never said says nothing; an urgent condition open and never said is said, all of them in one message", "nothing is sent in the first minute after start but that one message", @@ -213,7 +217,7 @@ func (h *Holder) Test(channel, text string) map[string]string { out["refused"] = "it carried " + refusal.String() + "; nothing was sent" return out } - m := Message{Title: "TEST: " + text, Body: "nothing is wrong; this was asked for"} + m := Message{Title: "Test message: " + text, Body: "Nothing is wrong; this was asked for."} for _, ch := range h.channels() { if channel != "" && channel != "both" && channel != ch.Name() { continue @@ -240,7 +244,7 @@ func (h *Holder) Test(channel, text string) map[string]string { // Notify takes a message from a module that uses the seat (research 028 Q5, modules as sources): // the same shape, rule, cap and deduplication as a condition. Its key is put under `notify.` so it // can never be taken for one of the controller's conditions. -func (h *Holder) Notify(key, severity, summary, subject string, clear bool) (map[string]any, error) { +func (h *Holder) Notify(key, severity, summary, subject string, clear bool, plain ...string) (map[string]any, error) { if key == "" { return nil, errorf("key is required: the same key is the same message") } @@ -266,8 +270,20 @@ func (h *Holder) Notify(key, severity, summary, subject string, clear bool) (map if summary == "" { return nil, errorf("summary is required: one line in the mesh's words") } - h.raised(Record{Key: key, Kind: "notice", Subject: subject, Severity: severity, Summary: summary, - Origin: "notify", More: "operator-channel.open"}, false) + rec := Record{Key: key, Kind: "notice", Subject: subject, Severity: severity, Summary: summary, + Origin: "notify", More: "operator-channel.open"} + // The plain words a module may give (novox/hq ADR 0253): a headline, an explanation, a resolved line. + for i, w := range plain { + switch i { + case 0: + rec.Headline = w + case 1: + rec.Explanation = w + case 2: + rec.Resolved = w + } + } + h.raised(rec, false) h.mu.Lock() r := h.open[key] var sent []string diff --git a/modules/messenger/cmd/messenger/sync.go b/modules/messenger/cmd/messenger/sync.go index 1d4ad23a..70e5ef9d 100644 --- a/modules/messenger/cmd/messenger/sync.go +++ b/modules/messenger/cmd/messenger/sync.go @@ -52,10 +52,7 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult { var urgent []*Record var fresh []*Record for _, c := range open { - rec := Record{ - Key: c.Key, Kind: c.Kind, Subject: c.SubjectWords(), Severity: c.Severity, Summary: c.Summary, - Origin: "condition", More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill, - } + rec := recordOf(c) h.mu.Lock() old := h.open[c.Key] var r *Record @@ -72,6 +69,7 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult { old.Count++ } old.Summary, old.Subject, old.Kind, old.SilencedTill = rec.Summary, rec.Subject, rec.Kind, rec.SilencedTill + old.takeWords(rec) if rec.Severity != "" { old.Severity = rec.Severity } diff --git a/modules/messenger/cmd/messenger/words.go b/modules/messenger/cmd/messenger/words.go new file mode 100644 index 00000000..ff521619 --- /dev/null +++ b/modules/messenger/cmd/messenger/words.go @@ -0,0 +1,211 @@ +package main + +// What a message says (novox/hq ADR 0253): **it reads in one glance.** A title in plain words, one or two +// plain sentences on what happened, what it means and whether to act, and when it began in the +// operator's own time. No key, id, commit, verb or markup: the condition's key and summary stay in the +// controller (`conditions`) and in this holder's history, for whoever looks closer. +// +// - The words are the condition's own: its headline, explanation and resolved line, made by the +// controller where the condition is made, so every channel says the same thing. A condition from a +// controller older than them, or a module's notice without a headline, is said from its summary, +// with what the operator cannot read taken out (plainSummary). +// - A clearance is one short line: "Resolved: openrazer works again on g14, after 26 min". +// - A reminder, an escalation and a reopening say so in the title, before the same headline. +// - Times are the operator's (the setting time-zone; this machine's zone when not given), and said as a +// clock time today, a weekday this week, a date before that. + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +func (h *Holder) zone() *time.Location { + if h.Zone != nil { + if loc := h.Zone(); loc != nil { + return loc + } + } + return time.Local +} + +// compose is the message for a record. withhold > 0 when the content rule refused what it said: 1 says +// the headline alone, more says only that something was withheld. +func compose(r *Record, what string, now time.Time, loc *time.Location, withhold int) Message { + headline, explanation := wordsOf(r) + urgent := r.Severity == Urgent + if withhold >= 2 { + return withheld(r.Refused, urgent) + } + if withhold == 1 { + explanation = "Part of this message was withheld, because it carried " + r.Refused + "." + } + var title string + switch what { + case "cleared": + return Message{Title: resolvedLine(r) + ", after " + roughly(r.Cleared.Sub(r.Raised)), Quiet: true} + case "reminder": + title = "Still open: " + headline + case "escalated": + title = "Now urgent: " + headline + case "reopened": + title = headline + " (again" + times(r.Count) + ")" + if urgent { + title = "Urgent: " + title + } + default: + title = headline + if urgent { + title = "Urgent: " + headline + } + } + since := "Since " + when(r.Raised, now, loc) + if what == "reminder" { + since = fmt.Sprintf("Open since %s, for %s.", when(r.Raised, now, loc), roughly(now.Sub(r.Raised))) + } else { + since += "." + } + body := since + if explanation != "" { + body = explanation + "\n" + since + } + return Message{Title: title, Body: body, Urgent: urgent} +} + +// withheld is what is sent in place of a message the content rule refused. +func withheld(carried string, urgent bool) Message { + return Message{Title: "A mesh message was withheld", + Body: "It carried " + carried + ", which may not leave the mesh. An agent can read it among the mesh's " + + "open conditions.", Urgent: urgent} +} + +// times is " 2nd time" for a count of 2, "" for 1. +func times(n int) string { + if n < 2 { + return "" + } + suffix := "th" + switch { + case n%100 >= 11 && n%100 <= 13: + case n%10 == 1: + suffix = "st" + case n%10 == 2: + suffix = "nd" + case n%10 == 3: + suffix = "rd" + } + return fmt.Sprintf(", %d%s time", n, suffix) +} + +// wordsOf is a record's headline and explanation: its own, or made from its summary. +func wordsOf(r *Record) (string, string) { + if r.Headline != "" { + return r.Headline, r.Explanation + } + summary := plainSummary(r.Summary) + if r.Origin == "notify" && len(summary) <= 80 { + return capital(summary), "" + } + return fallbackHeadline(r), capital(summary) +} + +// resolvedLine is the line a clearance says: the condition's own, or made from its headline. +func resolvedLine(r *Record) string { + line := r.Resolved + if line == "" { + headline, _ := wordsOf(r) + line = "Resolved: " + lowerFirst(headline) + } + if !strings.HasPrefix(line, "Resolved") { + line = "Resolved: " + lowerFirst(line) + } + return line +} + +// fallbackHeadline is a title for something with no headline: what it is about and its kind, in words. +func fallbackHeadline(r *Record) string { + what := strings.ReplaceAll(r.Kind, "-", " ") + if what == "" || what == "notice" { + what = "a notice" + } + about := "" + if r.Origin == "notify" { + about = r.Subject + } else if machine := machineOf(r.Subject); machine != "" { + about = machine + } else { + // A seat, a provider or a module is named by a name; a plan, a delivery or a call by an id, never said. + for _, scope := range []string{"seat ", "provider ", "module "} { + if name, ok := strings.CutPrefix(r.Subject, scope); ok { + about = name + } + } + } + if about == "" { + return capital(what) + } + return capital(what) + " on " + about +} + +// machineOf is the machine a record's subject words name: "machine ace" or "x on ace". +func machineOf(subject string) string { + if _, on, ok := strings.Cut(subject, " on "); ok { + return on + } + if m, ok := strings.CutPrefix(subject, "machine "); ok { + return m + } + return "" +} + +var ( + codeSpan = regexp.MustCompile("`([^`]*)`") + noise = regexp.MustCompile(`\s+`) +) + +// plainSummary is a summary with what the operator cannot read taken out: markup and the verbs in code +// spans. Used only where the condition says no plain words of its own. +func plainSummary(s string) string { + s = codeSpan.ReplaceAllStringFunc(s, func(m string) string { + inner := strings.Trim(m, "`") + if strings.ContainsAny(inner, ". ") { + return "a command" + } + return inner + }) + s = strings.NewReplacer("**", "", "`", "").Replace(s) + return strings.TrimSpace(noise.ReplaceAllString(s, " ")) +} + +// when is a moment as the operator reads it, in their zone: "10:40" today, "Tue 10:40" this week, +// "1 Oct 10:40" before. +func when(t, now time.Time, loc *time.Location) string { + if t.IsZero() { + return "now" + } + lt, ln := t.In(loc), now.In(loc) + y1, m1, d1 := lt.Date() + y2, m2, d2 := ln.Date() + switch { + case y1 == y2 && m1 == m2 && d1 == d2: + return lt.Format("15:04") + case ln.Sub(lt) < 6*24*time.Hour: + return lt.Format("Mon 15:04") + } + return lt.Format("2 Jan 15:04") +} + +func capital(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func lowerFirst(s string) string { + if s == "" || (len(s) > 1 && strings.ToUpper(s[:2]) == s[:2]) { + return s + } + return strings.ToLower(s[:1]) + s[1:] +} diff --git a/modules/messenger/cmd/messenger/words_test.go b/modules/messenger/cmd/messenger/words_test.go new file mode 100644 index 00000000..dae31ca8 --- /dev/null +++ b/modules/messenger/cmd/messenger/words_test.go @@ -0,0 +1,191 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" + _ "time/tzdata" +) + +// The notifications the operator could not read on 2026-10-08, said again from the events a controller +// with plain words sends (novox/hq ADR 0253). BEFORE is the popup as it appeared; AFTER is what the desktop +// shows now: a title in plain words, an explanation, since when in the operator's time — no key, id, +// 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 +} + +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", + 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", + 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, +}, { + name: "a module unhealthy", + before: "WARNING: openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the account's own " + + "service manager (exit-code)\nabout: module openrazer.g14 on g14 (module-unhealthy)\nsince: 2026-10-08 " + + "10:40 UTC\nkey: module.openrazer.g14.unhealthy\nmore: conditions show module.openrazer.g14.unhealthy", + key: "module.openrazer.g14.unhealthy", kind: "module-unhealthy", severity: Warning, + 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.", + cleared: "Resolved: openrazer works again on g14, after 2.0 h", openFor: 2 * time.Hour, +}, { + name: "failed units on a machine", + before: "WARNING: 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\nabout: machine shanks on shanks (machine-units)\nsince: " + + "2026-10-08 10:40 UTC\nkey: machine.shanks.units\nmore: conditions show machine.shanks.units", + key: "machine.shanks.units", kind: "machine-units", severity: Warning, + subject: map[string]string{"scope": "machine", "id": "shanks", "machine": "shanks"}, + 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.", + cleared: "Resolved: no failed services on shanks any more, after 45 min", openFor: 45 * time.Minute, +}, { + name: "a healer wanted", + before: "WARNING: \"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\nabout: mesh hand-acts.push (healer-wanted)\nsince: " + + "2026-10-08 10:40 UTC\nkey: mesh.hand-acts.push.healer-wanted\nmore: conditions show " + + "mesh.hand-acts.push.healer-wanted", + key: "mesh.hand-acts.push.healer-wanted", kind: "healer-wanted", severity: Warning, + subject: map[string]string{"scope": "mesh", "id": "hand-acts.push"}, + 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, +}} + +// 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, + "raised": "2026-10-08T10:40:00Z", "at": at.Format(time.RFC3339), "silenced": nil} + if cleared { + body["cleared"] = at.Format(time.RFC3339) + } + raw, _ := json.Marshal(body) + return raw +} + +func TestRealNotificationsReadInOneGlance(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.Zone = func() *time.Location { return brussels } + 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 { + t.Fatal(err) + } + h.Condition(EventRaised, raised) + settle(h, c) + 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) + } + for _, w := range []string{"`", "UTC", "key:", "more:", "about:", "plan-", "a6385479"} { + if strings.Contains(m.Text(), w) { + t.Errorf("it still says %q: %q", w, m.Text()) + } + } + 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) + } + t.Logf("\nBEFORE:\n%s\n\nAFTER:\n%s\n%s\n\nCLEARED:\n%s", e.before, m.Title, m.Body, done.Title) + }) + } +} + +// **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) + h.Zone = func() *time.Location { return time.UTC } + k := Condition{Key: "machine.ace.silent", Scope: "machine", ID: "ace", Kind: "silent", Severity: Urgent, + Summary: "ace has not been heard from: `push ace` sends it again", Raised: c.now()} + h.Condition(EventRaised, k) + m := tg.sends[0] + if m.Title != "Urgent: Silent on ace" || m.Body != "Ace has not been heard from: a command sends it again\nSince 12:00." { + t.Errorf("%q / %q", m.Title, m.Body) + } + keyless(t, m) +} + +// **Times are the operator's**: today a clock time, this week a weekday, before that a date. +func TestTimesAreSaidInTheOperatorsZone(t *testing.T) { + brussels, _ := time.LoadLocation("Europe/Brussels") + now := time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC) + for at, want := range map[time.Time]string{ + now.Add(-56 * time.Minute): "11:44", + now.Add(-26 * time.Hour): "Wed 10:40", + now.Add(-10 * 24 * time.Hour): "28 Sep 12:40", + } { + if got := when(at, now, brussels); got != want { + t.Errorf("%s: %q, want %q", at, got, want) + } + } + if loc, err := zoneOf("Nowhere/Atlantis"); err == nil || loc != time.Local { + t.Errorf("a zone that does not exist was not said: %v", err) + } +} diff --git a/modules/messenger/module.json b/modules/messenger/module.json index aac05f0b..47be307d 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -62,7 +62,7 @@ "path": "${dir:state}/settings.json", "mode": "0600", "merge": "json", - "content": "{\n \"telegram-chat-id\": \"\",\n \"desktop-machines\": []\n}\n" + "content": "{\n \"telegram-chat-id\": \"\",\n \"desktop-machines\": [],\n \"time-zone\": \"\"\n}\n" } ], "build": { From f00b5a19b1bec1ee1b6313d719630834570a3d4e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 13:57:33 +0200 Subject: [PATCH 2/2] Say what the operator needs to do, keep quiet what needs nothing, and take answers from the desk The operator could not tell from a notification whether to act, nor act without opening a session. Each message now opens with a verdict, a warning that needs nothing is not sent, and a notification offers answers the messenger performs through their seat verbs; dunst's send gains actions and says the chosen one (hq ADR 0253). --- modules/dunst/README.md | 12 +- modules/dunst/cmd/dunst-tools/actions.go | 168 ++++++++++++ modules/dunst/cmd/dunst-tools/actions_test.go | 92 +++++++ modules/dunst/cmd/dunst-tools/dunst.go | 12 +- modules/dunst/cmd/dunst-tools/main.go | 9 +- .../cmd/dunst-tools/manifest_helpers_test.go | 1 + modules/dunst/module.json | 5 +- modules/messenger/README.md | 14 + modules/messenger/cmd/messenger/answers.go | 238 +++++++++++++++++ modules/messenger/cmd/messenger/condition.go | 10 + modules/messenger/cmd/messenger/desktop.go | 13 + modules/messenger/cmd/messenger/holder.go | 63 ++++- .../messenger/cmd/messenger/holder_test.go | 5 +- modules/messenger/cmd/messenger/main.go | 38 +++ .../messenger/cmd/messenger/manifest_test.go | 7 +- modules/messenger/cmd/messenger/outbox.go | 57 +++- modules/messenger/cmd/messenger/words_test.go | 247 ++++++++++++++---- modules/messenger/module.json | 9 +- 18 files changed, 911 insertions(+), 89 deletions(-) create mode 100644 modules/dunst/cmd/dunst-tools/actions.go create mode 100644 modules/dunst/cmd/dunst-tools/actions_test.go create mode 100644 modules/messenger/cmd/messenger/answers.go diff --git a/modules/dunst/README.md b/modules/dunst/README.md index 59c90c88..303c1ff6 100644 --- a/modules/dunst/README.md +++ b/modules/dunst/README.md @@ -13,6 +13,16 @@ The notifier as a module (novox/hq ADR 0208, research 026/05). - **Requires no display of its own.** dunst speaks both X11 and Wayland and picks the one the session has, so it serves an X session and a later sway one alike. +## Answers from a notification + +`send` with `actions` runs `notify-send --wait` with one `--action` per answer, answers the id at once and +waits in this bundle for the operator's choice (dunst offers the actions on a middle click, or in its +context menu). The token chosen is said as this module's event `action-chosen`; it becomes the +node-notifier seat's event once seats publish events of their own (novox/hq to-be 46 phase 3). Nothing +is said for a notification closed, expired or replaced without an answer, and a wait ends after seven +days. A restart of this bundle ends the waits it held: the notification stays shown, and its answers do +nothing then (novox/hq ADR 0253). + ## Tools Every tool goes over the account's session bus. None needs the screen, and each answers clearly when @@ -20,7 +30,7 @@ the account is not logged in. | tool | does | |---|---| -| `node-notifier.send` | a notification: title, body, urgency, sender, icon, how long; answers its id | +| `node-notifier.send` | a notification: title, body, urgency, sender, icon, how long, and the answers it offers (`actions`: token and label); answers its id, and says the chosen answer later as the event `action-chosen` {id, token, machine} | | `node-notifier.history` | what was shown, newest first, with how long ago | | `dunst_pause` / `dunst_resume` | do not disturb: notifications are held back, not lost | | `dunst_close_all` | clear the screen; the history keeps them | diff --git a/modules/dunst/cmd/dunst-tools/actions.go b/modules/dunst/cmd/dunst-tools/actions.go new file mode 100644 index 00000000..d99ca055 --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/actions.go @@ -0,0 +1,168 @@ +package main + +// Answers from a notification (novox/hq ADR 0253, to-be 46 §11 "the desk"): `send` takes actions, each a +// token and a label. dunst offers them on the notification (a middle click, or its context menu, which +// is the node-launcher's menu); the one the operator chooses is emitted as this module's event +// `action-chosen` {id, token, machine}, and the operator channel's holder acts on it. It is this module's +// event until seats publish events of their own (to-be 46 phase 3), when it becomes the node-notifier +// seat's. +// +// notify-send waits for the answer (`--wait`) in this process: the id is answered at once, and the +// answer, if any, comes later. An answer is never invented: a notification closed, expired or replaced +// emits nothing, and a wait ends after AnswerWait. A restart of this bundle ends the waits it held, +// which the README says; the notification stays shown and its answers do nothing then. + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "syscall" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Choice is one action a notification offers. +type Choice struct { + Token string `json:"token"` + Label string `json:"label"` +} + +const ( + // MostChoices is how many actions one notification offers. + MostChoices = 4 + // AnswerWait is how long an answer is waited for. + AnswerWait = 7 * 24 * time.Hour + // ChosenEvent is the event an answer is said as. + ChosenEvent = "action-chosen" +) + +var tokenShape = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +// emit says an event as this module; a seam a test replaces. +var emit = func(event string, body any) error { return stdio.Emit(event, body) } + +// choicesOf reads `actions`: a list of {token, label}. +func choicesOf(args map[string]any) ([]Choice, error) { + v, present := args["actions"] + if !present || v == nil { + return nil, nil + } + list, ok := v.([]any) + if !ok { + return nil, errors.New("actions is a list of {token, label}") + } + if len(list) > MostChoices { + return nil, fmt.Errorf("at most %d actions", MostChoices) + } + var out []Choice + seen := map[string]bool{} + for _, it := range list { + o, ok := it.(map[string]any) + if !ok { + return nil, errors.New("actions is a list of {token, label}") + } + tok, _ := o["token"].(string) + label, _ := o["label"].(string) + label = strings.TrimSpace(label) + switch { + case !tokenShape.MatchString(tok): + return nil, fmt.Errorf("an action's token is letters, digits, - and _, at most 64: %q", tok) + case seen[tok]: + return nil, fmt.Errorf("the token %q is given twice", tok) + case label == "" || len(label) > 40 || strings.ContainsAny(label, "\n="): + return nil, fmt.Errorf("an action's label is one short line without '=': %q", label) + } + seen[tok] = true + out = append(out, Choice{Token: tok, Label: label}) + } + return out, nil +} + +// actionArgs are notify-send's arguments for the choices, and its wait. +func actionArgs(choices []Choice) []string { + args := []string{"--wait"} + for _, c := range choices { + args = append(args, "--action="+c.Token+"="+c.Label) + } + return args +} + +// sendWaiting runs notify-send with the choices, answers the id it prints first, and waits for the +// answer in the background: the next line it prints, if it is one of the tokens. +func (s Session) sendWaiting(args []string, choices []Choice) (SendResult, error) { + path, err := exec.LookPath("notify-send") + if err != nil { + return SendResult{}, errors.New("notify-send is not installed on this machine") + } + cmd := exec.Command(path, args...) + cmd.Env = s.Env() + if home := operatorHome(); exists(home) { + cmd.Dir = home + } + out, err := cmd.StdoutPipe() + if err != nil { + return SendResult{}, err + } + var errOut capped + cmd.Stderr = &errOut + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + return SendResult{}, fmt.Errorf("notify-send: %w", err) + } + lines := bufio.NewReader(out) + first := make(chan string, 1) + go func() { + line, _ := lines.ReadString('\n') + first <- strings.TrimSpace(line) + }() + var idLine string + select { + case idLine = <-first: + case <-time.After(busTimeout): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + _ = cmd.Wait() + return SendResult{}, fmt.Errorf("notify-send did not answer an id within %s: %w", busTimeout, ErrTimedOut) + } + id, err := strconv.Atoi(idLine) + if err != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + _ = cmd.Wait() + return SendResult{}, fmt.Errorf("notify-send answered no id: %q %s", idLine, strings.TrimSpace(errOut.String())) + } + go awaitAnswer(cmd, lines, id, choices) + return SendResult{ID: id}, nil +} + +// awaitAnswer reads what notify-send says when the notification ends, and says a chosen token. +func awaitAnswer(cmd *exec.Cmd, lines io.Reader, id int, choices []Choice) { + timer := time.AfterFunc(AnswerWait, func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }) + defer timer.Stop() + rest, _ := io.ReadAll(io.LimitReader(lines, 4096)) + _ = cmd.Wait() + if tok, ok := chosenToken(string(rest), choices); ok { + body := map[string]any{"id": id, "token": tok, "machine": os.Getenv("MESH_NODE")} + if err := emit(ChosenEvent, body); err != nil { + fmt.Fprintf(os.Stderr, "[dunst] the operator chose an action on notification %d and it could not be said: %v\n", id, err) + } + } +} + +// chosenToken is the token notify-send printed after the id, when it is one offered. +func chosenToken(rest string, choices []Choice) (string, bool) { + for _, line := range strings.Split(rest, "\n") { + line = strings.TrimSpace(line) + for _, c := range choices { + if line == c.Token { + return c.Token, true + } + } + } + return "", false +} diff --git a/modules/dunst/cmd/dunst-tools/actions_test.go b/modules/dunst/cmd/dunst-tools/actions_test.go new file mode 100644 index 00000000..d417646c --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/actions_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// **A notification offers answers, and the chosen one is said as an event** (novox/hq ADR 0253): the id +// is answered at once, the answer comes when the operator chooses, and nothing is said for a +// notification closed without one. +func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) { + withBus(t) + bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo 42; sleep 0.2; echo "$CHOSEN"`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + t.Setenv("MESH_NODE", "g14") + var mu sync.Mutex + var said []map[string]any + before := emit + emit = func(event string, body any) error { + mu.Lock() + defer mu.Unlock() + if event != ChosenEvent { + t.Errorf("event %q", event) + } + said = append(said, body.(map[string]any)) + return nil + } + t.Cleanup(func() { emit = before }) + + n, err := notificationOf(map[string]any{"summary": "Delivery of hq held for 36 hours", "body": "Needs you: release it, or stop it.", + "actions": []any{map[string]any{"token": "a1b2", "label": "Release"}, map[string]any{"token": "c3d4", "label": "Stop"}}}) + if err != nil { + t.Fatal(err) + } + t.Setenv("CHOSEN", "c3d4") + start := time.Now() + got, err := Send(n) + if err != nil || got.ID != 42 || time.Since(start) > 150*time.Millisecond { + t.Fatalf("%+v, %v, after %s: the id is answered before the answer", got, err, time.Since(start)) + } + asked, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(asked), "[--wait][--action=a1b2=Release][--action=c3d4=Stop][--]") { + t.Fatalf("notify-send was asked %s", asked) + } + deadline := time.Now().Add(3 * time.Second) + for { + mu.Lock() + n := len(said) + mu.Unlock() + if n > 0 || time.Now().After(deadline) { + break + } + time.Sleep(20 * time.Millisecond) + } + mu.Lock() + if len(said) != 1 || said[0]["token"] != "c3d4" || said[0]["id"] != 42 || said[0]["machine"] != "g14" { + t.Fatalf("said %v", said) + } + said = nil + mu.Unlock() + + // Closed without an answer, or an answer nobody offered: nothing is said. + for _, chosen := range []string{"", "zzzz"} { + t.Setenv("CHOSEN", chosen) + if _, err := Send(n); err != nil { + t.Fatal(err) + } + } + time.Sleep(600 * time.Millisecond) + mu.Lock() + defer mu.Unlock() + if len(said) != 0 { + t.Fatalf("an answer was invented: %v", said) + } +} + +func TestActionsAreRefusedForWhatTheyCannotBe(t *testing.T) { + for _, bad := range []any{"x", []any{"x"}, []any{map[string]any{"token": "a b", "label": "x"}}, + []any{map[string]any{"token": "a", "label": ""}}, []any{map[string]any{"token": "a", "label": "x=y"}}, + []any{map[string]any{"token": "a", "label": "x"}, map[string]any{"token": "a", "label": "y"}}, + []any{map[string]any{"token": "a", "label": "1"}, map[string]any{"token": "b", "label": "2"}, + map[string]any{"token": "c", "label": "3"}, map[string]any{"token": "d", "label": "4"}, + map[string]any{"token": "e", "label": "5"}}} { + if _, err := notificationOf(map[string]any{"summary": "x", "actions": bad}); err == nil { + t.Errorf("accepted %v", bad) + } + } +} diff --git a/modules/dunst/cmd/dunst-tools/dunst.go b/modules/dunst/cmd/dunst-tools/dunst.go index a05c3e93..094f826d 100644 --- a/modules/dunst/cmd/dunst-tools/dunst.go +++ b/modules/dunst/cmd/dunst-tools/dunst.go @@ -27,6 +27,7 @@ type Notification struct { ExpireMS int Category string ReplaceID int + Actions []Choice } func notificationOf(args map[string]any) (Notification, error) { @@ -66,7 +67,10 @@ func notificationOf(args map[string]any) (Notification, error) { if n.Category, err = text(args, "category", false); err != nil { return n, err } - n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1) + if n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1); err != nil { + return n, err + } + n.Actions, err = choicesOf(args) return n, err } @@ -94,11 +98,17 @@ func Send(n Notification) (SendResult, error) { if n.ReplaceID > 0 { args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID)) } + if len(n.Actions) > 0 { + args = append(args, actionArgs(n.Actions)...) + } // "--" so a title that starts with a dash is a title. args = append(args, "--", n.Summary) if n.Body != "" { args = append(args, n.Body) } + if len(n.Actions) > 0 { + return s.sendWaiting(args, n.Actions) + } r, err := s.run(busTimeout, "", "notify-send", args...) if err != nil { return SendResult{}, err diff --git a/modules/dunst/cmd/dunst-tools/main.go b/modules/dunst/cmd/dunst-tools/main.go index bd61453c..a9e0299a 100644 --- a/modules/dunst/cmd/dunst-tools/main.go +++ b/modules/dunst/cmd/dunst-tools/main.go @@ -22,8 +22,9 @@ func tools() []stdio.Tool { { Name: "node-notifier.send", Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " + - "normal, critical), and optionally the sending application's name, an icon and how long it stays. " + - "Answers the notification's id.", + "normal, critical), and optionally the sending application's name, an icon, how long it stays and " + + "the answers it offers (actions: token and label each). Answers the notification's id; the answer " + + "the operator chooses is said later as the event action-chosen {id, token, machine}.", Input: map[string]any{ "type": "object", "properties": map[string]any{ @@ -35,6 +36,10 @@ func tools() []stdio.Tool { "expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"}, "category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"}, "replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"}, + "actions": map[string]any{"type": "array", "description": "at most 4 answers the notification offers, " + + "each {token, label}; the chosen token is said as the event action-chosen", + "items": map[string]any{"type": "object", "properties": map[string]any{ + "token": map[string]any{"type": "string"}, "label": map[string]any{"type": "string"}}}}, }, "required": []string{"summary"}, }, diff --git a/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go b/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go index d4fb76d9..84fe6b64 100644 --- a/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go +++ b/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go @@ -19,6 +19,7 @@ type manifest struct { Claims []claim `json:"claims"` Seats []any `json:"seats"` Tools []string `json:"tools"` + Emits []string `json:"emits"` Environment *environment `json:"environment"` Shell []shellCode `json:"shell"` Resources []map[string]any `json:"resources"` diff --git a/modules/dunst/module.json b/modules/dunst/module.json index 67180987..c8eadea3 100644 --- a/modules/dunst/module.json +++ b/modules/dunst/module.json @@ -69,5 +69,8 @@ ] } ] - } + }, + "emits": [ + "action-chosen" + ] } diff --git a/modules/messenger/README.md b/modules/messenger/README.md index 32de38c3..686fdf89 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -44,6 +44,20 @@ condition's resolved line and how long it was open: "Resolved: openrazer works a A condition from a controller older than plain words, or a module's notice without a headline, is said from its summary, with code spans taken out (`cmd/messenger/words.go`). +**Every explanation opens with a verdict**: "Nothing for you to do." or "Needs you:" and one thing the +operator can do themselves. **A warning that needs nothing is not sent**: it stays open here and in the +controller's conditions, the history says it was kept quiet, and it is said the moment it needs the +operator (urgent at its bound, or its words now say "Needs you:"). An urgent condition is always said. + +**A notification offers answers** (`cmd/messenger/answers.go`): the condition's actions (Release, Stop, +Start, Restart, Silence for a week) and Details. On the desk they are dunst's actions (a middle click on +the notification, or its context menu); the dunst module says the chosen one as its event +`dunst.action-chosen`, and this module calls the action's seat verb itself, with a why naming the +operator, the channel and the label, then edits the notification to say what came of it. Details shows +the summary, the key and since when, on the machine that asked; a line the content rule refuses is said +withheld. A message that offers answers is never folded into a digest. Telegram shows the words but no +buttons yet (to-be 46 phase 6). + | When | What | |---|---| | `condition-raised` | one message, deduplicated by the condition's key | diff --git a/modules/messenger/cmd/messenger/answers.go b/modules/messenger/cmd/messenger/answers.go new file mode 100644 index 00000000..a05c5c00 --- /dev/null +++ b/modules/messenger/cmd/messenger/answers.go @@ -0,0 +1,238 @@ +package main + +// What the operator needs to do, and answering from the notification itself (novox/hq ADR 0253). +// +// - **Every explanation opens with a verdict**, written by the controller: "Nothing for you to do." or +// "Needs you:" and one thing the operator can do themselves. +// - **A warning that needs nothing is not sent.** The mesh handles it or it passes by itself; it is +// kept as open here (`operator-channel.open`, the history says "quiet") and in the controller's +// conditions, and said the moment it needs the operator: when it becomes urgent at its bound, or its +// words change to "Needs you:". An urgent condition is always said. +// - **A notification offers answers**: the condition's actions (Release, Stop, Start, Restart, Silence +// for a week) and Details. Each is a token; a channel that can take an answer (the desk, through the +// node-notifier seat's actions) hands the token back, and this holder calls the action's seat verb +// itself, with a why that names the operator, the channel and the label, then edits the notification +// to say what came of it. Details shows the summary and the key, for whoever wants them: the desk is a +// private surface (ADR 0234 §6). +// - None of these verbs declares an authorising tier yet (to-be 46 phase 5): each is a verb any granted +// principal may already call, which is ADR 0234's acknowledge. When one declares a tier, its answer goes +// through the controller's authorise request and answer instead, and the desk asks for the code. + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "strings" +) + +// Action is one answer a condition offers: a label, and the seat verb called with these arguments when +// it is chosen. Machine is set for a seat every machine holds. An argument "why" given empty is filled +// with the operator's words. +type Action struct { + Label string `json:"label"` + Verb string `json:"verb"` + Machine string `json:"machine,omitempty"` + Arguments map[string]string `json:"arguments,omitempty"` +} + +// DetailsLabel is the answer every condition's notification offers. +const DetailsLabel = "Details" + +// MostActions bounds what a notification offers, Details included. +const MostActions = 4 + +func sameActions(a, b []Action) bool { + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + return string(x) == string(y) +} + +// needsNothing is a record the operator need not hear of now: words of its own that open "Nothing for +// you to do.", and only a warning. A record without words (an older controller, a notice) is said as +// before. +func needsNothing(r *Record) bool { + return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition" +} + +// choices are the answers a record's message offers, each with its token, made once and kept. +func (h *Holder) choices(r *Record) []Choice { + if r.Origin != "condition" { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + if r.Tokens == nil { + r.Tokens = map[string]int{} + for i := range r.Actions { + if i == MostActions-1 { + break + } + r.Tokens[token()] = i + } + r.Tokens[token()] = -1 + } + out := make([]Choice, 0, len(r.Tokens)) + for i := range r.Actions { + for t, n := range r.Tokens { + if n == i { + out = append(out, Choice{Token: t, Label: r.Actions[i].Label}) + } + } + } + for t, n := range r.Tokens { + if n == -1 { + out = append(out, Choice{Token: t, Label: DetailsLabel}) + } + } + return out +} + +func token() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// Chosen takes an answer a channel handed back: the token of a choice, on a machine. It performs the +// action, says what came of it where the question was shown, and answers that in words. +func (h *Holder) Chosen(channel, machine, tok string) (string, error) { + h.work.Lock() + defer h.work.Unlock() + h.mu.Lock() + h.init() + var r *Record + idx := 0 + for _, rec := range h.open { + if i, ok := rec.Tokens[tok]; ok { + r, idx = rec, i + } + } + h.mu.Unlock() + if r == nil { + h.Logf("[messenger] an answer on %s came back for no open message (it ended, or the token is unknown)", channel) + return "", errorf("no open message offers that answer: it may have ended meanwhile") + } + now := h.Now() + headline, _ := wordsOf(r) + if idx == -1 { + // Each line held to the content rule on its own: a line that carries what may not leave is said + // withheld, and the rest is still shown. + var lines []string + for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} { + if refusal, ok := Check(l); !ok { + l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)" + } + lines = append(lines, l) + } + m := Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n")} + _, err := h.showOn(channel, machine, m) + h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "details", Outcome: outcomeOf(err)}) + return "shown", err + } + a := r.Actions[idx] + args := map[string]any{} + for k, v := range a.Arguments { + args[k] = v + } + if v, ok := a.Arguments["why"]; ok && v == "" { + args["why"] = fmt.Sprintf("the operator chose %s on the %s notification %q", a.Label, channel, headline) + } + address := "seat:" + a.Verb + if a.Machine != "" { + address += "@" + a.Machine + } + var err error + if h.Ask == nil { + err = errorf("this holder cannot call the mesh") + } else { + var raw json.RawMessage + raw, err = h.Ask(address, args) + if err == nil { + err = refusalIn(raw) + } + } + outcome := "done" + title := a.Label + ": done. " + headline + body := "You chose " + a.Label + " at " + now.In(h.zone()).Format("15:04") + "." + if err != nil { + outcome = "failed: " + err.Error() + title = "Could not " + strings.ToLower(a.Label) + ": " + headline + body = "The mesh refused it: " + firstLineOf(err.Error()) + if _, ok := Check(body); !ok { + body = "The mesh refused it; the reason carries what may not be shown here." + } + } + h.mu.Lock() + r.Answered = a.Label + ": " + outcome + sent := r.Sent[channel] + h.mu.Unlock() + h.persist(r) + m := Message{Title: title, Body: body, Quiet: err == nil} + if ch := h.channelNamed(channel); ch != nil && sent != "" && ch.CanEdit() { + if e := ch.Edit(sent, m); e != nil { + _, _ = h.showOn(channel, machine, m) + } + } else { + _, _ = h.showOn(channel, machine, m) + } + h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "answer " + a.Label, Outcome: outcome}) + h.Logf("[messenger] the operator chose %s for %s on %s: %s", a.Label, r.Key, channel, outcome) + return outcome, err +} + +func outcomeOf(err error) string { + if err != nil { + return "failed: " + err.Error() + } + return "sent" +} + +func firstLineOf(s string) string { + s, _, _ = strings.Cut(s, "\n") + if len(s) > 160 { + s = s[:160] + "…" + } + return s +} + +// refusalIn reads a tool's answer for a refusal: an error result is an error. +func refusalIn(raw json.RawMessage) error { + var wrapped struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + Error string `json:"error"` + } + if json.Unmarshal(raw, &wrapped) == nil { + if wrapped.IsError && len(wrapped.Content) > 0 { + return errorf("%s", wrapped.Content[0].Text) + } + if wrapped.Error != "" { + return errorf("%s", wrapped.Error) + } + } + return nil +} + +func (h *Holder) channelNamed(name string) Channel { + for _, ch := range h.channels() { + if ch.Name() == name { + return ch + } + } + return nil +} + +// showOn shows a message of the holder's own on one channel, on one machine where the channel has several. +func (h *Holder) showOn(channel, machine string, m Message) (string, error) { + ch := h.channelNamed(channel) + if ch == nil { + return "", errorf("no channel %s", channel) + } + if d, ok := ch.(*Desktop); ok && machine != "" { + return d.showOne(machine, m) + } + return ch.Send(m) +} diff --git a/modules/messenger/cmd/messenger/condition.go b/modules/messenger/cmd/messenger/condition.go index 5faa1bdc..cdd55003 100644 --- a/modules/messenger/cmd/messenger/condition.go +++ b/modules/messenger/cmd/messenger/condition.go @@ -19,6 +19,8 @@ package main // - Times are RFC 3339. // - `headline`, `explanation` and `resolved` are the plain words the operator reads (novox/hq ADR // 0253): a condition from a controller older than them has none, and is said from its summary. +// - `needs` is what the operator does, empty for nothing (the explanation opens with the verdict); +// `actions` are the answers a notification offers, each {label, verb, machine, arguments}. // - `key` is `..`. When it is absent it is made from subject and kind; when // neither gives one the event is unreadable. // - A cleared event carries the condition as last held, and may add `cleared` (its time). @@ -65,6 +67,8 @@ type Condition struct { Headline string Explanation string Resolved string + Needs string + Actions []Action Source string Resolver string Raised time.Time @@ -172,6 +176,12 @@ func DecodeCondition(event string, body []byte) (Condition, error) { c.Headline = str("headline") c.Explanation = str("explanation") c.Resolved = str("resolved") + c.Needs = str("needs") + if v, ok := raw["actions"]; ok && string(v) != "null" { + if e := json.Unmarshal(v, &c.Actions); e != nil { + return Condition{}, fmt.Errorf("%s: actions is not a list of {label, verb, machine, arguments}", event) + } + } c.Resolver = str("resolver") c.Raised = when("raised") c.LastObserved = when("last-observed", "last_observed", "lastObserved") diff --git a/modules/messenger/cmd/messenger/desktop.go b/modules/messenger/cmd/messenger/desktop.go index 91576548..e98a859d 100644 --- a/modules/messenger/cmd/messenger/desktop.go +++ b/modules/messenger/cmd/messenger/desktop.go @@ -97,6 +97,11 @@ func (d *Desktop) show(m Message, replace map[string]int) (string, error) { if replace != nil { args["replace_id"] = replace[machine] } + if len(m.Actions) > 0 { + // The answers the notification offers (node-notifier's actions): the notifier hands the chosen + // token back as an event, which the holder takes (answers.go). + args["actions"] = m.Actions + } raw, err := d.Ask("seat:node-notifier.send@"+machine, args) if err == nil { var id int @@ -140,6 +145,14 @@ func (d *Desktop) LastAnswers() map[string]string { return out } +// showOne shows a message on one machine only: an answer's outcome, or details, where it was asked. +func (d *Desktop) showOne(machine string, m Message) (string, error) { + only := &Desktop{Machines: func() []string { return []string{machine} }, Ask: d.Ask} + id, err := only.Send(m) + d.note(machine, err) + return id, err +} + // notificationID reads the notifier's answer, {"id": N}, whether the runtime hands it over bare or in // the tool reply's text content. func notificationID(raw json.RawMessage) (int, error) { diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 8a5bf05b..38f9310a 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -80,12 +80,19 @@ const ( DigestLines = 15 ) -// Message is what a channel shows: a title line and a body. +// Message is what a channel shows: a title line and a body, and the answers it offers. type Message struct { - Title string - Body string - Urgent bool - Quiet bool // a clearing: shown without urgency + Title string + Body string + Urgent bool + Quiet bool // a clearing: shown without urgency + Actions []Choice +} + +// Choice is one answer a message offers: the token a channel hands back when it is chosen, and its label. +type Choice struct { + Token string `json:"token"` + Label string `json:"label"` } func (m Message) Text() string { @@ -122,9 +129,16 @@ type Record struct { Summary string `json:"summary"` // Headline, Explanation and Resolved are the plain words the operator reads (novox/hq ADR 0253): // the controller's, or a module's through notify; empty, the message is said from the summary. - Headline string `json:"headline,omitempty"` - Explanation string `json:"explanation,omitempty"` - Resolved string `json:"resolved,omitempty"` + Headline string `json:"headline,omitempty"` + Explanation string `json:"explanation,omitempty"` + Resolved string `json:"resolved,omitempty"` + // Needs is what the operator does, empty for nothing; Actions the answers offered, each by a token + // (Tokens: token -> the action's index, or -1 for the details) a channel hands back when chosen. + Needs string `json:"needs,omitempty"` + Actions []Action `json:"actions,omitempty"` + Tokens map[string]int `json:"tokens,omitempty"` + // Answered is what the operator chose from a notification, and what came of it. + Answered string `json:"answered,omitempty"` Origin string `json:"origin"` More string `json:"more"` Raised time.Time `json:"raised"` @@ -146,13 +160,17 @@ type Record struct { // recordOf is a condition as this holder keeps it. func recordOf(c Condition) Record { return Record{Key: c.Key, Kind: c.Kind, Subject: c.SubjectWords(), Severity: c.Severity, Summary: c.Summary, - Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Origin: "condition", - More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill} + Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Needs: c.Needs, Actions: c.Actions, + Origin: "condition", + More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill} } // takeWords keeps the plain words current, as the summary is: a condition's words follow what is seen. func (r *Record) takeWords(from Record) { - r.Headline, r.Explanation, r.Resolved = from.Headline, from.Explanation, from.Resolved + r.Headline, r.Explanation, r.Resolved, r.Needs = from.Headline, from.Explanation, from.Resolved, from.Needs + if !sameActions(r.Actions, from.Actions) { + r.Actions, r.Tokens = from.Actions, nil + } } func (r *Record) silenced(now time.Time) bool { @@ -196,6 +214,8 @@ type Holder struct { Logf func(string, ...any) // Emit states a fact as this module (refused); nil states nothing. Emit func(event string, body any) error + // Ask calls a seat verb through the mesh, as this module: the answers the operator chooses. + Ask Asker // Zone is the operator's time zone, which every time in a message is said in; nil is this machine's. Zone func() *time.Location @@ -367,8 +387,10 @@ func (h *Holder) raised(rec Record, quiet bool) { old := h.open[rec.Key] h.mu.Unlock() if old != nil && old.Cleared.IsZero() { - // Said again while open: the same message. Its words are kept current; nothing is sent. + // Said again while open: the same message. Its words are kept current; nothing is sent — unless + // it was kept quiet as needing nothing and now needs the operator. h.mu.Lock() + wasQuiet := needsNothing(old) old.Summary, old.Subject, old.Kind, old.SilencedTill = rec.Summary, rec.Subject, rec.Kind, rec.SilencedTill old.takeWords(rec) if rec.Severity != "" { @@ -376,6 +398,7 @@ func (h *Holder) raised(rec Record, quiet bool) { } old.Seen = now h.mu.Unlock() + h.nowNeeded(old, wasQuiet, quiet) h.persist(old) return } @@ -415,6 +438,13 @@ func (h *Holder) raised(rec Record, quiet bool) { h.persist(&r) } +// nowNeeded says a record kept quiet as needing nothing, once its words say it needs the operator. +func (h *Holder) nowNeeded(r *Record, wasQuiet, quiet bool) { + if wasQuiet && !needsNothing(r) && !r.told() && !quiet && !r.silenced(h.Now()) && !h.queued(r.Key) { + h.deliver(r, "raised") + } +} + func (h *Holder) changed(rec Record, quiet bool) { h.mu.Lock() old := h.open[rec.Key] @@ -428,6 +458,7 @@ func (h *Holder) changed(rec Record, quiet bool) { } h.mu.Lock() escalated := old.Severity == Warning && rec.Severity == Urgent + wasQuiet := needsNothing(old) old.Summary, old.Subject, old.SilencedTill = rec.Summary, rec.Subject, rec.SilencedTill old.takeWords(rec) if rec.Severity != "" { @@ -435,9 +466,12 @@ func (h *Holder) changed(rec Record, quiet bool) { } old.Seen = h.Now() h.mu.Unlock() - if escalated && !quiet && !old.silenced(h.Now()) { + switch { + case escalated && !quiet && !old.silenced(h.Now()): // Routing differs for urgent: said once more, to both channels. h.deliver(old, "escalated") + default: + h.nowNeeded(old, wasQuiet, quiet) } h.persist(old) } @@ -556,6 +590,9 @@ func (h *Holder) say(r *Record, what string) Message { m := compose(r, what, now, h.zone(), 0) refusal, ok := Check(m.Text()) if ok { + if what != "cleared" { + m.Actions = h.choices(r) + } return m } h.mu.Lock() diff --git a/modules/messenger/cmd/messenger/holder_test.go b/modules/messenger/cmd/messenger/holder_test.go index ade54854..3ddf0a6b 100644 --- a/modules/messenger/cmd/messenger/holder_test.go +++ b/modules/messenger/cmd/messenger/holder_test.go @@ -73,7 +73,8 @@ func start() *clock { return &clock{t: time.Date(2026, 10, 6, func cond(key, sev, summary string) Condition { parts := strings.Split(key, ".") return Condition{Key: key, Scope: parts[0], ID: parts[1], Kind: parts[len(parts)-1], Severity: sev, Summary: summary, - Headline: headlineOf(key), Explanation: "What it means: " + summary + "."} + Headline: headlineOf(key), Explanation: "Needs you: look at it. What it means: " + summary + ".", + Needs: "look at it."} } // headlineOf is the headline a test condition carries: its key in words, so a message can be told by it @@ -122,7 +123,7 @@ func TestARaisedConditionIsSentOnceByItsKey(t *testing.T) { t.Fatalf("urgent: telegram %d, desktop %d; want one each", len(tg.sends), len(dt.sends)) } keyless(t, tg.sends...) - if tg.sends[0].Title != "Urgent: machine ace silent" || !strings.HasPrefix(tg.sends[0].Body, "What it means: ") || + if tg.sends[0].Title != "Urgent: machine ace silent" || !strings.HasPrefix(tg.sends[0].Body, "Needs you: look at it. What it means: ") || !strings.Contains(tg.sends[0].Body, "\nSince ") { t.Fatalf("message: %q", tg.sends[0].Text()) } diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index c93e15dc..27b021b4 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -171,6 +171,7 @@ func main() { Ask: stdio.Ask, }, Store: stateStore{}, + Ask: stdio.Ask, Logf: logf, Zone: func() *time.Location { loc, err := zoneOf(settings().TimeZone) @@ -247,6 +248,7 @@ func run(h *Holder, l *listening) { h.Condition(event, c) return nil } + go listenForAnswers(h) for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { err := stdio.Subscribe(ControllerSeat+".*", handle) if err == nil { @@ -260,6 +262,42 @@ func run(h *Holder, l *listening) { } } +// AnswerEvent is the desk's notifier saying which answer the operator chose: the dunst holder of +// node-notifier emits it as its module's event until seats publish events of their own (to-be 46 phase 3). +const AnswerEvent = "dunst.action-chosen" + +// listenForAnswers takes the answers the operator chooses on the desk, retried until it can. +func listenForAnswers(h *Holder) { + handle := func(e stdio.Envelope) error { + if e.Key != AnswerEvent { + return nil + } + var body struct { + Token string `json:"token"` + Machine string `json:"machine"` + } + if err := json.Unmarshal(e.Body, &body); err != nil || body.Token == "" { + logf("[messenger] an answer from the desk could not be read: %s", firstLineOf(string(e.Body))) + return nil + } + machine := body.Machine + if machine == "" { + machine = e.Node + } + _, _ = h.Chosen("desktop", machine, body.Token) + return nil + } + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + if err := stdio.Subscribe("dunst.*", handle); err == nil { + logf("[messenger] listening for the operator's answers on the desk") + return + } else { + logf("[messenger] not hearing the desk's answers yet (%v); asking again in %s", err, wait) + } + time.Sleep(wait) + } +} + // syncFromController reads the controller's open conditions and gives them to the holder. func syncFromController(h *Holder) { asked := h.Now() diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index f8386770..551c5fb7 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -55,10 +55,13 @@ func TestItDeclaresAndHoldsTheOperatorChannel(t *testing.T) { t.Fatalf("seats %+v claims %+v", m.Seats, m.Claims) } if !reflect.DeepEqual(m.Consumes, []string{ - ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared}) { + ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared, + AnswerEvent}) { t.Fatalf("consumes %v", m.Consumes) } - if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions"}) { + if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions", + "seat:mesh-delivery.release", "seat:mesh-delivery.stop", "seat:" + ControllerSeat + ".plans", + "seat:node-service-manager.restart"}) { t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes) } if !reflect.DeepEqual(m.State, []string{"open", "sent"}) { diff --git a/modules/messenger/cmd/messenger/outbox.go b/modules/messenger/cmd/messenger/outbox.go index 329e0095..ae701b8c 100644 --- a/modules/messenger/cmd/messenger/outbox.go +++ b/modules/messenger/cmd/messenger/outbox.go @@ -27,6 +27,11 @@ func (i item) urgent() bool { return i.Severity == Urgent && i.What != "cleared" // the desktop to Telegram when no session there takes it. No channel at all: it is unsent, said in // the status, and tried again every minute. func (h *Holder) deliver(r *Record, what string) { + if needsNothing(r) && what != "escalated" { + // Nothing for the operator to do, and only a warning: kept, not sent (ADR 0253). + h.record(Sent{At: h.Now(), Channel: "-", Key: r.Key, What: what, Outcome: "quiet: nothing for the operator to do"}) + return + } var to []Channel if r.Severity == Urgent { for _, ch := range h.channels() { @@ -231,11 +236,6 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) { if len(list) == 0 || (!force && !h.due(ch, list, now)) { return } - type entry struct { - it item - r *Record - msg Message - } var entries []entry for _, it := range list { r, m := h.resolve(it, now) @@ -279,6 +279,48 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) { } return } + if len(entries) > 1 { + // A message that asks the operator something goes alone, so its answers come with it. + var alone, rest []entry + for _, e := range entries { + if len(e.msg.Actions) > 1 { + alone = append(alone, e) + } else { + rest = append(rest, e) + } + } + if len(alone) > 0 { + for _, e := range alone { + if !h.sendEntries(ch, []entry{e}, now) { + h.failed(ch, list, taken, now, errorf("could not send")) + return + } + } + if len(rest) > 0 && !h.sendEntries(ch, rest, now) { + h.failed(ch, list, taken, now, errorf("could not send")) + return + } + h.drop(name, taken) + return + } + } + if !h.sendEntries(ch, entries, now) { + h.failed(ch, list, taken, now, errorf("could not send")) + return + } + h.drop(name, taken) +} + +// entry is one held item resolved to its record and message. +type entry struct { + it item + r *Record + msg Message +} + +// sendEntries sends entries as one message — itself when alone, a digest when several — and records it. +func (h *Holder) sendEntries(ch Channel, entries []entry, now time.Time) bool { + name := ch.Name() var m Message if len(entries) == 1 { m = entries[0].msg @@ -293,10 +335,8 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) { id, err := ch.Send(m) h.noteChannel(name, err) if err != nil { - h.failed(ch, list, taken, now, err) - return + return false } - h.drop(name, taken) h.mu.Lock() h.lastSend[name] = now h.lastTry[name] = time.Time{} @@ -342,6 +382,7 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) { h.persist(e.r) } } + return true } // drop removes the first n held items of a channel: those this flush took. diff --git a/modules/messenger/cmd/messenger/words_test.go b/modules/messenger/cmd/messenger/words_test.go index dae31ca8..f5026885 100644 --- a/modules/messenger/cmd/messenger/words_test.go +++ b/modules/messenger/cmd/messenger/words_test.go @@ -14,37 +14,57 @@ import ( // commit, verb or markup — and a clearance in one line. type example struct { - name, before string - key, kind, severity, summary string - subject map[string]string - headline, explanation, resolved string - title, body string // what the desktop shows - cleared string // the clearance's one line - openFor time.Duration + name, before string + key, kind, severity, summary string + subject map[string]string + headline, explanation string + needs, resolved string + actions []Action + popup bool // shown at all: a warning that needs nothing is not + title, body string // what the desktop shows + offers string // the answers it offers, by label + choose string // the answer chosen in the test + asked string // the seat verb that answer calls, and its arguments + cleared string // the clearance's one line + openFor time.Duration } +const waitingSummary = "the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word to start: " + + "`mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " + + "plan-1791454185265004861 --why …` starts it by hand" + +const waitingBefore = "CLEARED after 26 min: the walk of novox/mesh-catalog a6385479 has waited 56m0s for " + + "mesh-delivery's word to start: `mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " + + "plan-1791454185265004861 --why ...` starts it by hand\nabout: plan plan-1791454185265004861 (waiting)\n" + + "since: 2026-10-08 10:40 UTC\nkey: plan.plan-1791454185265004861.waiting\nmore: conditions show " + + "plan.plan-1791454... 65004861.waiting" + var examples = []example{{ - name: "a delivery waiting", - before: "CLEARED after 26 min: the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word " + - "to start: `mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " + - "plan-1791454185265004861 --why ...` starts it by hand\nabout: plan plan-1791454185265004861 (waiting)\n" + - "since: 2026-10-08 10:40 UTC\nkey: plan.plan-1791454185265004861.waiting\nmore: conditions show " + - "plan.plan-1791454... 65004861.waiting", + name: "a delivery waiting, under its bound", before: waitingBefore, key: "plan.plan-1791454185265004861.waiting", kind: "waiting", severity: Warning, - subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"}, - summary: "the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word to start: " + - "`mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " + - "plan-1791454185265004861 --why …` starts it by hand", + subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"}, summary: waitingSummary, headline: "openrazer delivery waiting to start", - explanation: "The change to openrazer is merged and built, and has waited 56 minutes for mesh-delivery (the " + - "module that decides when a delivery goes out) to let it start. Nothing to do yet; it becomes urgent after " + - "4 hours. To start it now, have an agent start it by hand.", - resolved: "openrazer delivery no longer waiting", - title: "openrazer delivery waiting to start", - body: "The change to openrazer is merged and built, and has waited 56 minutes for mesh-delivery (the module " + - "that decides when a delivery goes out) to let it start. Nothing to do yet; it becomes urgent after 4 hours. " + - "To start it now, have an agent start it by hand.\nSince 12:40.", - cleared: "Resolved: openrazer delivery no longer waiting, after 26 min", openFor: 26 * time.Minute, + explanation: "Nothing for you to do. The change to openrazer is merged and built, and has waited 56 minutes for " + + "mesh-delivery (the module that decides when a delivery goes out) to let it start. It becomes a question for " + + "you if it still waits after 4 hours.", + resolved: "openrazer delivery no longer waiting", popup: false, openFor: 26 * time.Minute, +}, { + name: "a delivery waiting, past its bound", before: waitingBefore, + key: "plan.plan-1791454185265004861.waiting", kind: "waiting", severity: Urgent, + subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"}, summary: waitingSummary, + headline: "openrazer delivery waiting to start", needs: "start it, or stop it.", + explanation: "Needs you: start it, or stop it. The change to openrazer is merged and built, and mesh-delivery " + + "(the module that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may be stuck.", + actions: []Action{ + {Label: "Start", Verb: "mesh-controller.plans", Arguments: map[string]string{"go": "plan-1791454185265004861", "why": ""}}, + {Label: "Stop", Verb: "mesh-controller.plans", Arguments: map[string]string{"stop": "plan-1791454185265004861", "why": ""}}}, + resolved: "openrazer delivery no longer waiting", popup: true, + title: "Urgent: openrazer delivery waiting to start", + body: "Needs you: start it, or stop it. The change to openrazer is merged and built, and mesh-delivery (the module " + + "that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may be stuck.\nSince 12:40.", + offers: "Start, Stop, Details", choose: "Start", + asked: `seat:mesh-controller.plans {"go":"plan-1791454185265004861","why":"the operator chose Start on the desktop notification \"openrazer delivery waiting to start\""}`, + cleared: "Resolved: openrazer delivery no longer waiting, after 5.0 h", openFor: 5 * time.Hour, }, { name: "a module unhealthy", before: "WARNING: openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the account's own " + @@ -54,13 +74,17 @@ var examples = []example{{ subject: map[string]string{"scope": "module", "id": "openrazer.g14", "machine": "g14"}, summary: "openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the account's own " + "service manager (exit-code)", - headline: "openrazer not working on g14", - explanation: "openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as " + - "soon as it runs again; if it stays, it needs a look at why it fails.", - resolved: "openrazer works again on g14", - title: "openrazer not working on g14", - body: "openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as soon " + - "as it runs again; if it stays, it needs a look at why it fails.\nSince 12:40.", + headline: "openrazer not working on g14", needs: "restart it; if it fails again, the details say why.", + explanation: "Needs you: restart it; if it fails again, the details say why. openrazer on g14 is not healthy: its " + + "service openrazer-daemon stopped with an error. It clears as soon as it runs again.", + actions: []Action{{Label: "Restart", Verb: "node-service-manager.restart", Machine: "g14", + Arguments: map[string]string{"unit": "openrazer-daemon.service", "scope": "user"}}}, + resolved: "openrazer works again on g14", popup: true, + title: "openrazer not working on g14", + body: "Needs you: restart it; if it fails again, the details say why. openrazer on g14 is not healthy: its service " + + "openrazer-daemon stopped with an error. It clears as soon as it runs again.\nSince 12:40.", + offers: "Restart, Details", choose: "Restart", + asked: `seat:node-service-manager.restart@g14 {"scope":"user","unit":"openrazer-daemon.service"}`, cleared: "Resolved: openrazer works again on g14, after 2.0 h", openFor: 2 * time.Hour, }, { name: "failed units on a machine", @@ -73,13 +97,19 @@ var examples = []example{{ summary: "shanks's service manager is degraded: 3 failed unit(s) no module places — mnt-recalbox.mount, " + "storage-media.mount, greenclip.service (the account's own manager). Each is the machine's own: mend or " + "remove it there, or have a module place it", - headline: "3 failed services on shanks", - explanation: "On shanks, mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module " + - "manages them, so the mesh does not repair them: mend or remove them on shanks.", - resolved: "No failed services on shanks any more", - title: "3 failed services on shanks", - body: "On shanks, mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, " + - "so the mesh does not repair them: mend or remove them on shanks.\nSince 12:40.", + headline: "3 failed services on shanks", needs: "mend or remove them on shanks, or silence this if they do not matter.", + explanation: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, " + + "mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh " + + "does not repair them.", + actions: []Action{{Label: "Silence for a week", Verb: "mesh-controller.conditions", + Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}}}, + resolved: "No failed services on shanks any more", popup: true, + title: "3 failed services on shanks", + body: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, mnt-recalbox " + + "(a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh does not repair " + + "them.\nSince 12:40.", + offers: "Silence for a week, Details", choose: "Silence for a week", + asked: `seat:mesh-controller.conditions {"for":"7d","silence":"machine.shanks.units","why":"the operator chose Silence for a week on the desktop notification \"3 failed services on shanks\""}`, cleared: "Resolved: no failed services on shanks any more, after 45 min", openFor: 45 * time.Minute, }, { name: "a healer wanted", @@ -92,19 +122,37 @@ var examples = []example{{ summary: "\"push\" was repaired by hand 35 times in 14 days, the last by g14.node-tools, through the " + "mesh-controller seat: a healer is wanted for it", headline: "Push keeps being fixed by hand", - explanation: "A person repaired push by hand 35 times in 14 days. A healer (an automatic repair) is wanted for " + - "it; nothing is broken now.", - resolved: "Resolved: no more hand repairs of push", - title: "Push keeps being fixed by hand", - body: "A person repaired push by hand 35 times in 14 days. A healer (an automatic repair) is wanted for it; " + - "nothing is broken now.\nSince 12:40.", - cleared: "Resolved: no more hand repairs of push, after 3 days", openFor: 3 * 24 * time.Hour, + explanation: "Nothing for you to do. A person repaired push by hand 35 times in 14 days, so an automatic repair " + + "is wanted for it. Nothing is broken now.", + resolved: "Resolved: no more hand repairs of push", popup: false, openFor: 3 * 24 * time.Hour, +}, { + name: "a delivery held", + before: "WARNING: the delivery novox/hq@055550802096 has been held for 36h2m6s, past its bound of 24h0m0s (it " + + "waits for the operator): healer H2 may none: the state is the operator's — `mesh-delivery.show " + + "novox/hq@055550802096`\nabout: delivery novox/hq@055550802096 (stalled)\nsince: 2026-10-07 00:38 UTC\n" + + "key: delivery.novox/hq_055550802096.stalled\nmore: conditions show delivery.novox/hq_055550802096.stalled", + key: "delivery.novox/hq_055550802096.stalled", kind: "stalled", severity: Warning, + subject: map[string]string{"scope": "delivery", "id": "novox/hq@055550802096"}, + summary: "the delivery novox/hq@055550802096 has been held for 36h2m6s, past its bound of 24h0m0s (it waits for " + + "the operator): healer H2 may none: the state is the operator's — `mesh-delivery.show novox/hq@055550802096`", + headline: "Delivery of hq held for 36 hours", needs: "release it, or stop it.", + explanation: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours, past its limit.", + actions: []Action{ + {Label: "Release", Verb: "mesh-delivery.release", Arguments: map[string]string{"id": "novox/hq@055550802096", "why": ""}}, + {Label: "Stop", Verb: "mesh-delivery.stop", Arguments: map[string]string{"id": "novox/hq@055550802096", "why": ""}}}, + resolved: "Delivery of hq is no longer held", popup: true, + title: "Delivery of hq held for 36 hours", + body: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours, past its limit.\nSince 12:40.", + offers: "Release, Stop, Details", choose: "Release", + asked: `seat:mesh-delivery.release {"id":"novox/hq@055550802096","why":"the operator chose Release on the desktop notification \"Delivery of hq held for 36 hours\""}`, + cleared: "Resolved: delivery of hq is no longer held, after 3 min", openFor: 3 * time.Minute, }} // event is the body a controller with plain words sends for a condition. func (e example) event(at time.Time, cleared bool) []byte { body := map[string]any{"key": e.key, "kind": e.kind, "subject": e.subject, "severity": e.severity, "summary": e.summary, "headline": e.headline, "explanation": e.explanation, "resolved": e.resolved, + "needs": e.needs, "actions": e.actions, "raised": "2026-10-08T10:40:00Z", "at": at.Format(time.RFC3339), "silenced": nil} if cleared { body["cleared"] = at.Format(time.RFC3339) @@ -113,16 +161,21 @@ func (e example) event(at time.Time, cleared bool) []byte { return raw } -func TestRealNotificationsReadInOneGlance(t *testing.T) { +func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *testing.T) { brussels, err := time.LoadLocation("Europe/Brussels") if err != nil { t.Fatal(err) } for _, e := range examples { t.Run(e.name, func(t *testing.T) { - h, _, dt, c, _ := newHolder(t) - h.Telegram = nil + h, tg, dt, c, _ := newHolder(t) h.Zone = func() *time.Location { return brussels } + var asked []string + h.Ask = func(address string, body any) (json.RawMessage, error) { + raw, _ := json.Marshal(body) + asked = append(asked, address+" "+string(raw)) + return json.RawMessage(`{"ok":true}`), nil + } c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC) raised, err := DecodeCondition(EventRaised, e.event(c.now(), false)) if err != nil { @@ -130,34 +183,114 @@ func TestRealNotificationsReadInOneGlance(t *testing.T) { } h.Condition(EventRaised, raised) settle(h, c) + if !e.popup { + if len(dt.sends)+len(tg.sends) != 0 { + t.Fatalf("a warning that needs nothing was shown: %+v %+v", dt.sends, tg.sends) + } + hist := h.History(5)["sends"].([]Sent) + if len(hist) == 0 || !strings.HasPrefix(hist[0].Outcome, "quiet") { + t.Errorf("not kept as quiet in the history: %+v", hist) + } + t.Logf("\nBEFORE:\n%s\n\nAFTER: no popup. Kept in the controller's conditions and the channel's history:\n%s\n%s", + e.before, e.headline, e.explanation) + return + } if len(dt.sends) != 1 { t.Fatalf("desktop sends %d", len(dt.sends)) } m := dt.sends[0] keyless(t, m) - if m.Title != e.title || m.Body != e.body { - t.Errorf("shows\n %q\n %q\nwant\n %q\n %q", m.Title, m.Body, e.title, e.body) + var labels []string + for _, a := range m.Actions { + labels = append(labels, a.Label) } - for _, w := range []string{"`", "UTC", "key:", "more:", "about:", "plan-", "a6385479"} { + if m.Title != e.title || m.Body != e.body || strings.Join(labels, ", ") != e.offers { + t.Errorf("shows\n %q\n %q\n %v\nwant\n %q\n %q\n %s", m.Title, m.Body, labels, e.title, e.body, e.offers) + } + for _, w := range []string{"`", "UTC", "key:", "more:", "about:", "plan-", "a6385479", "agent", "by hand"} { if strings.Contains(m.Text(), w) { t.Errorf("it still says %q: %q", w, m.Text()) } } + // The operator answers from the notification. + var tok string + for _, a := range m.Actions { + if a.Label == e.choose { + tok = a.Token + } + } + outcome, err := h.Chosen("desktop", "g14", tok) + if err != nil || outcome != "done" || len(asked) != 1 || asked[0] != e.asked { + t.Fatalf("chose %s: %q %v\nasked %v\nwant %s", e.choose, outcome, err, asked, e.asked) + } + done := dt.edits["1"] + if done.Title != e.choose+": done. "+e.headline { + t.Errorf("the answer's outcome: %q", done.Title) + } c.pass(e.openFor - BurstWindow) cleared, err := DecodeCondition(EventCleared, e.event(c.now(), true)) if err != nil { t.Fatal(err) } h.Condition(EventCleared, cleared) - done := dt.edits["1"] - if done.Title != e.cleared || done.Body != "" { - t.Errorf("clearance %q / %q, want one line %q", done.Title, done.Body, e.cleared) + end := dt.edits["1"] + if end.Title != e.cleared || end.Body != "" || len(end.Actions) != 0 { + t.Errorf("clearance %q / %q / %v, want one line %q", end.Title, end.Body, end.Actions, e.cleared) } - t.Logf("\nBEFORE:\n%s\n\nAFTER:\n%s\n%s\n\nCLEARED:\n%s", e.before, m.Title, m.Body, done.Title) + t.Logf("\nBEFORE:\n%s\n\nAFTER:\n%s\n%s\n[%s]\n\nCHOSEN %s:\n%s\n%s\n\nCLEARED:\n%s", e.before, m.Title, + m.Body, strings.Join(labels, "] ["), e.choose, done.Title, done.Body, end.Title) }) } } +// **A warning kept quiet is said once it needs the operator**: at its bound it becomes urgent, and then +// its words say "Needs you:". +func TestAQuietWarningIsSaidWhenItBecomesUrgent(t *testing.T) { + h, tg, dt, c, _ := newHolder(t) + e := examples[0] + raised, _ := DecodeCondition(EventRaised, e.event(c.now(), false)) + h.Condition(EventRaised, raised) + settle(h, c) + if len(dt.sends)+len(tg.sends) != 0 { + t.Fatal("said while it needed nothing") + } + past := examples[1] + c.pass(4 * time.Hour) + changed, _ := DecodeCondition(EventChanged, past.event(c.now(), false)) + h.Condition(EventChanged, changed) + settle(h, c) + if len(dt.sends) != 1 || len(tg.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: start it, or stop it.") { + t.Fatalf("desktop %+v telegram %d", dt.sends, len(tg.sends)) + } +} + +// **Details shows the summary and the key, where it was asked**, and an answer for a message that ended +// is refused in words. +func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) { + h, _, dt, c, _ := newHolder(t) + h.Zone = func() *time.Location { return time.UTC } + e := examples[5] + raised, _ := DecodeCondition(EventRaised, e.event(c.now(), false)) + h.Condition(EventRaised, raised) + settle(h, c) + m := dt.sends[0] + details := m.Actions[len(m.Actions)-1] + if details.Label != DetailsLabel { + t.Fatalf("%+v", m.Actions) + } + if _, err := h.Chosen("desktop", "g14", details.Token); err != nil { + t.Fatal(err) + } + shown := dt.sends[len(dt.sends)-1] + if shown.Title != "Details: "+e.headline || !strings.HasPrefix(shown.Body, e.summary) || + !strings.Contains(shown.Body, "Since: 8 Oct 10:40") { + t.Errorf("details: %q / %q", shown.Title, shown.Body) + } + if _, err := h.Chosen("desktop", "g14", "not-a-token"); err == nil { + t.Error("an unknown answer was taken") + } +} + // **A condition from a controller without plain words is said from its summary**, its markup taken out. func TestAConditionWithoutPlainWordsIsSaidFromItsSummary(t *testing.T) { h, tg, _, c, _ := newHolder(t) diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 47be307d..52c0531a 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -27,14 +27,19 @@ "consumes": [ "mesh-controller.condition-raised", "mesh-controller.condition-changed", - "mesh-controller.condition-cleared" + "mesh-controller.condition-cleared", + "dunst.action-chosen" ], "emits": [ "refused" ], "invokes": [ "seat:node-notifier.send", - "seat:mesh-controller.conditions" + "seat:mesh-controller.conditions", + "seat:mesh-delivery.release", + "seat:mesh-delivery.stop", + "seat:mesh-controller.plans", + "seat:node-service-manager.restart" ], "state": [ "open",