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": {